From e2c8fccbde07033fe6d690db9efbf653a911d7d0 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 10:52:05 +0800 Subject: [PATCH 01/74] =?UTF-8?q?docs(spec):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=A7=86=E5=9B=BE=E6=94=B9=E9=80=A0=E4=B8=BA?= =?UTF-8?q?=E5=B9=B3=E9=9D=A2=E5=9B=BE=E8=A1=A8=EF=BC=88QGraphicsView?= =?UTF-8?q?=EF=BC=8C=E6=BA=90=E7=A0=81=E7=BA=A7=E5=A4=8D=E5=88=BB=20web?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情 dock 从 VTK 渲染换为本地面板+平面图表,并真正接上数据集选择 - 散点=Plotly scattergl 方块(客户端用 QGraphicsRectItem 复刻输出) - 网格等值面=自制 marching-squares 引擎;采用 VTK+预处理混合方案(~95%) 上采样2x+平滑+banded+NaN凸包裁剪+等值线简化 - 真实 API:getErtRawDataScatterGraph/inversion·rows/colorGradation/queryException - dd 类型驱动的图表策略框架,首版落地 dd_inversion_data - 异常归属辨析:右上对象异常(地图)与详情底部异常表(图表)并存 - 复用 core::Grid/ScatterField/ColorScale/Anomaly + 现有 GridContourActor 管线 --- .../2026-06-11-dataset-detail-view-design.md | 283 ++++++++++++++++++ .../web-datasetinfo-grid-with-anomaly.png | Bin 0 -> 530737 bytes .../specs/assets/web-datasetinfo-scatter.png | Bin 0 -> 73313 bytes 3 files changed, 283 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md create mode 100644 docs/superpowers/specs/assets/web-datasetinfo-grid-with-anomaly.png create mode 100644 docs/superpowers/specs/assets/web-datasetinfo-scatter.png diff --git a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md new file mode 100644 index 0000000..9fa45d4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md @@ -0,0 +1,283 @@ +# 数据集详情视图(平面图表)改造 设计 + +- 日期:2026-06-11 +- 分支建议:`feat/dataset-detail-chart` +- 状态:设计(待评审) +- 参考材料: + - 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(R051–R096)、「测井参数表」「DD类型」 + - 原 web 系统:`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(经 Playwright 操作页面 + 抓取 JS chunk 做源码级分析) + - API:`docs/apis/business_OpenAPI.json` + - 参考截图:`assets/web-datasetinfo-scatter.png`(原数据散点)、`assets/web-datasetinfo-grid-with-anomaly.png`(网格等值面 + 异常叠加 + 异常表) + +--- + +## 1. 背景与问题 + +客户端工作台的「数据详情」dock 当前实现有两个问题: + +1. **用了 VTK 渲染**:`main.cpp` 下方「数据详情」dock 是一个独立的 `QVTKOpenGLStereoWidget` + `vtkRenderer`,`rebuildDetail` lambda 用 `vtkBandedPolyDataContourFilter`/散点/电极 actor 配合 `applyTop2D` 正交相机"平躺"渲染剖面。数据集详情本质是**平面图表**,不该走 3D 渲染管线。 +2. **从未真正接上数据集选择**:`main.cpp:705-713` 数据集列表单击只调 `nav.selectDataset(dsId)`(驱动右下「数据集属性」表单),`main.cpp:604` 创建的 `currentDsId` 此后**再无任何赋值**,`itemClicked` 也不触发 `rebuildDetail`。即详情图表收不到选中的 dsId,点数据集时它是空的。 + +本设计将「数据详情」dock 重建为**本地面板 + 平面图表**(QGraphicsView),接真实 API,并真正接上数据集选择链路,目标 100% 复刻原 web 系统 datasetInfo 页面的展示功能。 + +--- + +## 2. 目标与范围 + +### 2.1 目标 +- 数据详情 dock 改为平面图表(QGraphicsView),**不再使用 VTK 渲染窗口**(VTK 仅作几何算法库)。 +- 100% 复刻原 web datasetInfo 的**展示**:原数据散点视图、网格等值面视图、色阶图例、等值线/标注、异常叠加 + 异常列表。 +- 接真实 API(`ApiDatasetRepository`)。 +- 架构按 **dd 类型驱动的图表策略框架**搭建,首版落地 `dd_inversion_data`(ERT 反演),其余 dd 类型作为框架内后续扩展。 + +### 2.2 范围内(仅展示) +- 原数据/网格数据 视图切换 +- 网格等值面渲染(混合方案,见 §8)+ 等值线 + 标注 +- 原数据散点渲染 +- 色阶图例(原数据 type1 / 网格 type2) +- 显示异常/电极/等值线 开关 +- 异常叠加(剖面上)+ 底部异常列表(数据集级,§7.3 辨析) +- 多 Tab 壳:一个或多个数据集详情页(R095) + +### 2.3 范围外(编辑/工具类,后续单独立项) +网格化参数(GridDialog)、色阶配置(colorEditor)、白化(WhiteningDialog)、滤波/迭代处理、异常框注/自动标注(AutoAnnotationDialog)、另存为(SaveAsDialog)、导出(ExportDialog)、描述富文本编辑、大视图(Esc) 全屏。 + +### 2.4 后续 dd 类型(框架内扩展,非本 spec) +`dd_ert_measurement_data` / `dd_ert_measurement_gr_data` / `dd_grid` / `dd_trajectory_data`、测井(`y深度-x数值折线` / `x时间-y数值曲线`,见「测井参数表」)、GPR(`dd/gpr/channel/image`)、TEM 等。 +> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。 + +--- + +## 3. 原 web 系统分析(源码级) + +> 方法:Playwright 操作 datasetInfo 页面 → 抓取无障碍快照、网络请求、localStorage、活 API 响应;进一步抓取并分析前端 JS chunk(`contourChartAdapter`/`plotlyScatterSimpleChart`/`contourPage`/`colorUtils` 等)。无 sourcemap,代码已压缩但可读。 + +### 3.1 页面结构(客户端只取内容区,左侧 web 导航不复刻) +标题(ds 名)→ `原数据 | 网格数据` 切换 → 工具条 → 图表 → 色阶图例 →(网格视图)底部 `异常列表 | 描述` 表。 + +### 3.2 原数据散点 —— Plotly `scattergl`(`plotlyScatterSimpleChart`) +``` +type:"scattergl", mode:"markers", +marker:{ size:10-12, color:vlist, colorscale, zmin/zmax, symbol:"square", + line:{color:white, width:1} } +yaxis.scaleanchor = x // x:y 等比锁定 +colorbar:{ title:"数值", thickness:15, len:.7 } // 可选 +``` +方形点、按 `vlist` 经色阶着色、白描边、等比。 +→ **客户端用 `QGraphicsRectItem`(方块)按色阶着色复刻输出,不引入 Plotly。** + +### 3.3 网格等值面 —— 自制 marching-squares 引擎(`contourChartAdapter`,canvas) +源码为自带等值线库(`window.contourCore`),API:`computeContours / marchingSquares / pathFinding / levels / smooth / nullHandling / labels / colorbar / renderers / axes / Overlay`。 +- **渲染模式**:`heatmap`(`putImageData` 逐格 + canvas 缩放平滑)/ `fill`(填充色带 `drawFilledPaths`)/ `lines`(等值线 `drawStrokePaths`)/ `fill+lines`(默认显示:色带 + 等值线 + 标注)。 +- **levels** = colorBar 离散分段值;`mapColors(v, vmin, vmax, colorscale)` 着色。 +- **额外处理**:2× 双线性上采样(`DEFAULT_UPSAMPLE_SCALE:2`)+ 平滑(`DEFAULT_SMOOTHING:.3`)+ 简化容差(`simplifyTolerance` 默认 `.5`,滑块)+ **数据凸包裁剪**(`makeBinaryMask`/`createClipPath`/`DEFAULT_CLIP_LEVEL:.95`,对应截图里随地形起伏的不规则白边)+ 标注(`showLabels:true`)。 + +### 3.4 数据接口(全部真实 API 验活) +| 用途 | 接口 | 返回(关键字段) | +|---|---|---| +| 原数据散点 | `GET dd/ert/inversion/getErtRawDataScatterGraph/{dsId}` | `xlist/ylist/vlist/hlist/projectXList/projectYList`(各 1D,本例 240)、`min/max` | +| 网格等值面 | `GET dd/ert/inversion/rows/{dsId}` | `DDErtInversionGraphDataVO`:`x[nx]`、`y[ny]`、`v[ny][nx]`、`z[ny][nx]`(地形)、`elevation[nx]`、`vmin/vmax`、`sectionType`(1垂直/2水平) | +| 色阶 | `POST lvl/colorGradation/getDetail`(body `{dsObjectId, businessCode:"", type}`,type1=原数据 / type2=网格) | `data.properties.colorBar=[[值,"rgba(r,g,b,a)"],…]`、`lvlMinMax`、`lineConfig{showLines,color,lineType}`、`labelConfig{showLabels,color}` | +| 异常 | `GET exception/queryException/{dsId}` | 见 §6.4(与 `core::Anomaly` 一致) | + +> 注:网格等值面数据来自服务端**已网格化**的 `inversion/rows`,前端不做插值;前端"网格"按钮是把 `xSpacing/xsize…` POST 给 `inversion/grid` 让服务端重算(范围外)。 + +--- + +## 4. 架构总览 + +**核心思路**:详情 dock 的渲染器从「VTK 3D 管线」换成「VTK 仅算等值面几何 + QGraphicsView 画 2D 场景」。数据模型、仓储接口复用,新增渲染器 + 面板壳 + dd 策略 + API 仓储 + 一个小详情控制器;并真正接上数据集选择链路。 + +``` +┌─ 数据详情 dock(重建)──────────────────────────────────────┐ +│ DatasetDetailPanel (QTabWidget 壳, 每 ds 一页, R095 多Tab) │ +│ └─ DatasetDetailPage │ +│ ├─ 标题栏(ds 名) │ +│ ├─ 原数据 / 网格数据 切换 + 显示异常/电极/等值线 开关 │ +│ ├─ DatasetChartView : QGraphicsView ← 新渲染器 │ +│ │ scene: 等值面色带 / 散点方块 / 坐标轴 / 异常 / 图例 │ +│ └─ AnomalyTablePanel(ds 级异常表, 行显隐→图表叠加) │ +└─────────────────────────────────────────────────────────────┘ + 右上「对象异常」面板(不动)= TM 异常总和, 眼睛→中央 VTK 地图 + 右下「数据集属性」面板(不动)= dynamicForm 表单 + ▲ 渲染输入(复用现有 core 模型) ▲ dd 类型分派 +┌─ chart 层(新)──────────────────┐ ┌─ 策略注册(新)──────────────┐ +│ ContourBuilder (src/render/) │ │ IDatasetChartStrategy │ +│ VTK 仅算法: 上采样+平滑→ │ │ + ErtInversionStrategy(首个)│ +│ vtkBandedPolyDataContourFilter │ │ registry: ddCode → strategy │ +│ → 分层带多边形+等值线(几何,无窗口)│ │ (扩展点: 测井/时序/GPR…) │ +└──────────────────────────────────┘ └──────────────────────────────┘ + ▲ 数据模型(复用,零改动) +┌─ core::Grid / ScatterField / ColorScale / Anomaly ──────────┐ +└─────────────────────────────────────────────────────────────┘ + ▲ 加载 +┌─ IDatasetRepository(接口已存在)───────────────────────────┐ +│ loadGrid / loadScatter / loadColorScale / loadAnomalies │ +│ · ApiDatasetRepository(新, 真实 API, §6.3) │ +│ · LocalSampleRepository(保留, 离线/测试) │ +└─────────────────────────────────────────────────────────────┘ + ▲ 编排(新小控制器, 不污染 WorkbenchNavController) +┌─ DatasetDetailController(新, 持 IDatasetRepository + 注册表)┐ +│ 在 main.cpp 与 nav.selectDataset 并联挂到 datasetList 单击 │ +│ 读 ddCode → 选策略 → 拉数据 → 发信号 → 面板被动渲染 │ +└─────────────────────────────────────────────────────────────┘ +``` + +**关键边界** +- **中央 2D/3D 地图视图仍是 VTK**(在地图上显示数据集是 VTK 的正职),本次只换数据详情 dock。 +- **VTK 在新链路里只当算法库**(无 render window);**新增依赖仅 Qt Widgets 的 `QGraphicsView`**(LGPL 合规),无第三方图表库。 + +--- + +## 5. 组件细化 + +### 5.1 `ContourBuilder`(新,`src/render/`,VTK 仅算法) +复用 `GridContourActor.cpp:36-82` 已验证的「`core::Grid` → `vtkStructuredGrid` → `vtkDataSetSurfaceFilter` → `vtkBandedPolyDataContourFilter`(按 colorBar 真实非均匀分段 + `GenerateContourEdgesOn`)」管线,**只在输出端分叉**:不建 mapper/actor,而是从 `banded->GetOutput()`(port0, cell 标量) 提取色带多边形、从 port1 提取等值线折线。 + +混合保真预处理(见 §8):入网格先 **2× 双线性上采样 + 高斯平滑** → banded → 按 NaN 掩膜做**数据凸包裁剪** → 等值线 Douglas-Peucker **简化**(容差参数)。 + +- 入:`core::Grid`(含 NaN 标记无效区)+ `core::ColorScale`(分段值)+ `ContourOptions{upsample, smooth, simplifyTol, showLines, showLabels}`。 +- 出:`std::vector ring; }>` + `std::vector pts; }>`。 +- 纯函数、无 Qt 依赖、可单测(给定网格→断言色带数/层级/裁剪边界)。 + +### 5.2 `DatasetChartView : QGraphicsView`(新,`src/app/panels/chart/`) +持 `QGraphicsScene`,对外:`showContour(grid, scale, opts)` / `showScatter(field, scale)` / `setAnomalies(list, hidden)` / `setOverlays(showAnomaly, showElectrode, showContourLine)`。 +- Item:色带 `QGraphicsPathItem`(填充)、等值线 `QGraphicsPathItem`、散点 `QGraphicsRectItem`(方块, 白描边)、异常 `QGraphicsPathItem`(可拾取)、电极顶部标记、标注 `QGraphicsSimpleTextItem`。 +- **坐标轴**:viewport 层 overlay 自绘,监听 `transform` 变化重算刻度(缩放/平移时轴钉边缘)。**y 轴用高程、向上为正**(场景 y 翻转);散点视图 **x:y 等比**(对齐 Plotly `scaleanchor`)。 +- 交互:滚轮缩放、拖动平移、点击异常高亮、重置视图。 +- **同一个类**供详情页、(后续)大视图、自动框注对话框复用——普通 QWidget、无 OpenGL 上下文负担。 + +### 5.3 dd 类型策略(新,扩展点,`src/app/panels/chart/`) +```cpp +struct IDatasetChartStrategy { + virtual ~IDatasetChartStrategy() = default; + virtual std::string ddCode() const = 0; + virtual void load(DatasetDetailController&, const std::string& dsId) = 0; // 决定拉哪些数据、画什么 +}; +``` +- `ErtInversionStrategy`(首个,`ddCode()=="dd_inversion_data"`):原数据→`loadScatter`+散点;网格→`loadGrid`+`ContourBuilder`+色带/等值线;异常→`loadAnomalies`。 +- `ChartStrategyRegistry`:`ddCode → strategy`;未注册 → 页内占位「暂不支持该类型预览」(优雅降级)。 + +### 5.4 `ApiDatasetRepository`(新,实现 `IDatasetRepository`,真实 API,`src/data/api/`) +| 方法 | 接口 | 解析(→ `src/data/parse/`) | +|---|---|---| +| `loadScatter(dsId)` | `getErtRawDataScatterGraph/{dsId}` | → `ScatterField`(xlist/ylist/hlist/vlist→x/y/z/v,projectX/Y) | +| `loadGrid(dsId)` | `inversion/rows/{dsId}` | `DDErtInversionGraphDataVO` → `Grid`(x/y/v/z/elevation/vmin/vmax) | +| `loadColorScale(dsId, variant)` | `colorGradation/getDetail`(type1/2) | `properties.colorBar[[值,rgba]]` → `ColorScale::addStop` | +| `loadAnomalies(dsId)` | `queryException/{dsId}` | → `core::Anomaly[]`(§6.4) | +> 现有 `IDatasetRepository` 的 `loadColorScale` 需加 `variant`(原数据/网格) 参数;`loadScatterColorScale` 可并入。`LocalSampleRepository` 保留为离线/测试桩。 + +### 5.5 `AnomalyTablePanel`(新或复用 `AnomalyListPanel`,`src/app/panels/`) +ds 级异常表,列:名称 / 异常类型 / 几何类型 / 创建时间 / 备注 / 操作(仅展示版:显隐眼睛;定位)。行显隐 → 发信号驱动 `DatasetChartView::setAnomalies` 的 hidden 集(对齐现有 `hiddenAnoms` 语义)。 + +### 5.6 `DatasetDetailPanel` / `DatasetDetailPage`(新,`src/app/panels/`) +- `DatasetDetailPanel`:`QTabWidget`,按 dsId 去重托管多个 `DatasetDetailPage`(R095)。 +- `DatasetDetailPage`:标题 + 原数据/网格数据 切换 + 叠加开关 + `DatasetChartView` + `AnomalyTablePanel`。 + +### 5.7 `DatasetDetailController`(新,`src/controller/`) +持 `IDatasetRepository&` + `ChartStrategyRegistry&`;slot `openDataset(dsId, ddCode)`:选策略→拉数据→`emit chartReady(pageModel)`;被动视图。**不并入 `WorkbenchNavController`**(后者只持 `IProjectRepository`、专注项目/结构/异常树导航,混入图表数据会破坏单一职责)。 + +--- + +## 6. 数据模型与映射 + +### 6.1 复用的 core 模型(零改动) +- `core::Grid`(`Field.hpp`):`nx/ny/valueAt(i,j)`、`x/y`、`z[nx*ny]`、`elevation[nx]`、`vmin/vmax` ⇆ `DDErtInversionGraphDataVO`。 +- `core::ScatterField`:`x/y/z/v`、`projX/projY` ⇆ 散点 VO。 +- `core::ColorScale`:阶梯色阶 `addStop/colorAt/stopValues` ⇆ colorBar。 +- `core::Anomaly`:`markType`(点/线/面)、`localPts`、`lineColor/lineWidth/dashed` ⇆ queryException。 + +### 6.2 NaN / 无效区 +`inversion/rows` 的 `v` 在测区外为无效;`core::Grid` 用 NaN 标记,`ContourBuilder` 据此做凸包裁剪(§8),渲染成不规则白边。 + +### 6.3 色阶(colorBar) +`properties.colorBar` 为 `[[值字符串, "rgba(r,g,b,a)"], …]`(本例 17 段),`type1`(原数据)/`type2`(网格) 颜色相同、分段值不同。映射:每段 `addStop(parseDouble(值), core::parseColor(rgba))`;`lineConfig` 驱动等值线显隐/色/线型。 + +### 6.4 异常(queryException → core::Anomaly,活数据验证) +``` +exceptionName → name +exceptionTypeName(异常区) → typeName +exceptionMarkType(1点/2线/3面) → markType +exceptionMarkTypeName(多段线…) → 表"几何类型"列 +location.coordinate[{x,y}] → localPts (剖面局部坐标: x=桩号, y=高程) +legend.polylineColor/Width → lineColor/lineWidth +legend.polylineShape=="dash"→ dashed +createTime/remark → 表"创建时间/备注"列 +geographicalCoordinates / latitudeLongitude → (地图用, 详情视图不需要) +``` +> 客户端已有 `parseExceptions` 映射此结构(`Anomaly.hpp` 注释印证),活数据已验证一致。 + +--- + +## 7. 数据流与交互 + +### 7.1 选择 → 渲染 +`datasetList` 单击 → 同时:(a) `nav.selectDataset(dsId)`(现状,驱动右下属性表单);(b) `detailCtrl.openDataset(dsId, ddCode)`(新)→ 选策略 → 拉 scatter/grid/colorScale/anomalies → `emit chartReady` → `DatasetDetailPanel` 渲染。 + +### 7.2 Tab 与联动(R055-057, R095) +- 双击数据集 = 新建或聚焦已开页(按 dsId 去重);单击 = 聚焦已开页(若已打开)。 +- 活动 Tab 切换 → 反向高亮数据集列表对应项。 +- 视图切换 原数据/网格数据;叠加开关 显示异常/电极/等值线。 + +### 7.3 异常归属辨析(决策记录) +原 web datasetInfo 是**单页**,底部异常表是单页布局产物。客户端是**多面板工作台**,存在两处不同作用域的异常显示,**经决策保留两者、各管各的视图**: + +| 位置 | 接口 | 作用域 | 眼睛控制 | +|---|---|---|---| +| 右上「对象异常」面板(现状不动) | `queryExceptionByTmObjectId/{tmId}` | 对象/TM 级(勾选驱动,多 TM 聚合) | 中央 VTK 2D/3D **地图** | +| 详情 dock 底部「异常列表」(本 spec, 复刻 web) | `queryException/{dsId}` | 数据集级(更直观) | **图表剖面**叠加 | + +剖面上的**异常叠加**属"画在图上",天然归详情视图;右上面板的列表与地图叠加不变。 + +--- + +## 8. 渲染保真度(混合方案) + +网格等值面采用 **VTK 算法 + 预处理** 混合方案(~95% 保真,复用 VTK,不重写整套引擎): + +1. **2× 双线性上采样** `core::Grid`(对齐 web `DEFAULT_UPSAMPLE_SCALE:2`)——可用 `vtkImageData` + `vtkImageResize`/手写双线性。 +2. **高斯平滑**(对齐 web `DEFAULT_SMOOTHING:.3`)。 +3. `vtkBandedPolyDataContourFilter` 按 colorBar 分段值出**色带多边形 + 等值线**。 +4. **数据凸包裁剪**:按 NaN 掩膜裁掉测区外(对齐 web `makeBinaryMask`/`clipLevel .95`),得不规则白边。 +5. 等值线 **Douglas-Peucker 简化**(容差参数,默认 0.5,对应"简化容差")。 +6. 默认 `fill+lines + showLabels`;等值线色/显隐由 colorBar `lineConfig` 驱动。 + +原数据散点:`QGraphicsRectItem` 方块、按色阶着色、白描边、**x:y 等比**(对齐 Plotly `scaleanchor`)。 + +> 与「轻量 VTK banded 原生」(~85%, 矩形范围/块状) 和「高保真整体移植引擎」(~100%, 工作量最大) 相比,混合方案是保真度/工作量的折中。 + +--- + +## 9. 错误 / 空 / 加载态 +- 加载中:图表区骨架/转圈。 +- 空态:异常表「暂无数据」;未注册 ddCode「暂不支持该类型预览」;无网格/散点数据时空图占位。 +- 错误:API 失败 → 页内内联错误(沿用 `RepoResult` 错误传播,不崩)。 +- 跨项目上下文/数据过期:优雅降级。主题令牌复用(明暗一致)。 + +--- + +## 10. 测试策略(GoogleTest/CTest) +- **单元**:`ContourBuilder`(给网格→断言色带数/层级/凸包裁剪/简化点数);`ColorScale`(colorBar 解析、`colorAt` 边界);解析器(散点/反演网格/色阶/异常 VO→模型);`ChartStrategyRegistry` 查找与降级。 +- **组件**:`DatasetChartView` 按数据生成 scene item 数;叠加开关隐藏对应 item;`AnomalyTablePanel` 行开关发信号;`DatasetDetailPanel` 按 dsId 去重 Tab。 +- **集成**:`DatasetDetailController.openDataset` 编排(用 `LocalSampleRepository` 桩)→ 断言 `chartReady` 负载。 +- **视觉**:对照 `assets/web-datasetinfo-*.png` 人工核对散点/等值面/异常叠加。 + +--- + +## 11. 风险与开放问题 +1. **凸包裁剪精度**:web 用 binary mask + clipLevel .95;VTK 侧用 NaN 掩膜裁剪需调参,可能与 web 边界略有出入(在 95% 目标内可接受)。 +2. **坐标轴钉边重算**:QGraphicsView 缩放时轴刻度重算是已知但非免费的模式,需专门处理。 +3. **`IDatasetRepository.loadColorScale` 签名变更**(加 variant)会动到 `LocalSampleRepository` 与现有 VTK 详情调用点——后者将随本次重建移除,注意配平。 +4. **其它 dd 类型无活样本**(测井/GPR/TEM 详情页):框架预留,落地需数据样本。 +5. **大数据集性能**:散点 240 点、网格 32×100→上采样 64×200 量级,QGraphicsView 可承受;更大测线需评估(web 用 `hightPerformaceContainer`,本版先不优化)。 + +--- + +## 12. 落地顺序(供 plan 细化) +1. `core::Grid` NaN 支持 + `ContourBuilder`(VTK 几何提取 + 上采样/平滑/裁剪/简化)+ 单测。 +2. `ApiDatasetRepository`(4 接口 + 解析)+ `loadColorScale` variant + 单测。 +3. `DatasetChartView`(散点/等值面/轴/图例/异常叠加 + 交互)。 +4. dd 策略框架 + `ErtInversionStrategy` + 注册表。 +5. `AnomalyTablePanel` + `DatasetDetailPage/Panel`(多 Tab)。 +6. `DatasetDetailController` + `main.cpp` 接线(移除旧 VTK 详情 dock,接上数据集单击/双击 + 反向联动)。 +7. 视觉核对 + 集成测试。 diff --git a/docs/superpowers/specs/assets/web-datasetinfo-grid-with-anomaly.png b/docs/superpowers/specs/assets/web-datasetinfo-grid-with-anomaly.png new file mode 100644 index 0000000000000000000000000000000000000000..f88a571ca0a5a00ffd7dead53369533fe5c58c3e GIT binary patch literal 530737 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

  • h7Q~>FkZ0wZ=;lvv+L4-{?=<|z5Ei9NHeI< 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 z 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%0DDcnf`bT6x>{&5RfvRs++&rdx;i-0~x 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�e`TQI+R#D!-w?b+ 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 z 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 F_zypMsPO;* literal 0 HcmV?d00001 diff --git a/docs/superpowers/specs/assets/web-datasetinfo-scatter.png b/docs/superpowers/specs/assets/web-datasetinfo-scatter.png new file mode 100644 index 0000000000000000000000000000000000000000..ffaf9e8eddcb7d5ff0e5a355755b40c82e777753 GIT binary patch literal 73313 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%0AG6J=m)IqnY(;InFt7> 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 Cd^Ros literal 0 HcmV?d00001 From 29cef952fe8a6582c53b3a57b5d3697f10a8c043 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:16:45 +0800 Subject: [PATCH 02/74] =?UTF-8?q?docs(plan):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=A7=86=E5=9B=BE=E6=94=B9=E9=80=A0=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=88TDD=EF=BC=8C7=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-11-dataset-detail-chart.md | 1628 +++++++++++++++++ 1 file changed, 1628 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-dataset-detail-chart.md diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md b/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md new file mode 100644 index 0000000..de020db --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md @@ -0,0 +1,1628 @@ +# 数据集详情视图(平面图表)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把客户端「数据详情」dock 从 VTK 渲染重建为本地面板 + 平面图表(QGraphicsView),接真实 API,真正接上数据集选择链路,100% 复刻 web datasetInfo 的展示(ERT 反演首版)。 + +**Architecture:** VTK 仅当几何算法库(`vtkBandedPolyDataContourFilter` 出色带/等值线几何,无 render window);QGraphicsView 画 2D 场景。新增 `ContourBuilder`(render 层)、`ApiDatasetRepository` + DTO 解析、`DatasetChartView`、dd 策略注册表、`AnomalyTablePanel`/`DatasetDetailPage`/`DatasetDetailPanel`、`DatasetDetailController`,并在 `main.cpp` 换掉旧 VTK 详情 dock。复用 `core::Grid/ScatterField/ColorScale/Anomaly`。 + +**Tech Stack:** C++17、Qt6 Widgets(QGraphicsView/Scene/Item)、VTK 9.6(仅几何)、GoogleTest/CTest、CMake、`net::ApiClient`(同步 HTTP)。 + +**设计依据:** `docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md` + +--- + +## 文件结构(创建/修改) + +| 文件 | 职责 | +|---|---| +| `src/core/model/Field.hpp`(改) | `Grid` 增 NaN 约定 + `hasValue(i,j)` 辅助 | +| `src/render/ContourBands.hpp/.cpp`(建) | `core::Grid + ColorScale → 色带多边形 + 等值线`(VTK 几何提取 + 上采样/平滑/裁剪/简化) | +| `src/render/CMakeLists.txt`(改) | 注册 ContourBands | +| `src/data/dto/DatasetChartDto.hpp/.cpp`(建) | `QJsonObject → Grid/ScatterField/ColorScale/Anomaly[]` 解析 | +| `src/data/repo/IDatasetRepository.hpp`(改) | 增 `loadScatterColorScale` | +| `src/data/api/ApiDatasetRepository.hpp/.cpp`(建) | 实现 `IDatasetRepository` 接真实 API | +| `src/data/repo/LocalSampleRepository.hpp`(改) | `loadScatterColorScale` 标 `override` | +| `src/data/CMakeLists.txt`(改) | 注册 DatasetChartDto + ApiDatasetRepository | +| `src/app/panels/chart/DatasetChartView.hpp/.cpp`(建) | QGraphicsView 2D 渲染器 | +| `src/app/panels/chart/IDatasetChartStrategy.hpp`(建) | dd 策略接口 + 注册表 | +| `src/app/panels/chart/ErtInversionStrategy.hpp/.cpp`(建) | dd_inversion_data 策略 | +| `src/app/panels/AnomalyTablePanel.hpp/.cpp`(建) | ds 级异常表 | +| `src/app/panels/DatasetDetailPage.hpp/.cpp`(建) | 单数据集详情页 | +| `src/app/panels/DatasetDetailPanel.hpp/.cpp`(建) | 多 Tab 壳 | +| `src/controller/DatasetDetailController.hpp/.cpp`(建) | 编排:选策略→拉数据→发信号 | +| `src/app/CMakeLists.txt` / `src/controller/CMakeLists.txt`(改) | 注册新文件 | +| `src/app/main.cpp`(改) | 移除旧 VTK 详情 dock,接上新面板 + 控制器 + 单击/双击 | +| `tests/render/test_contour_bands.cpp`(建) | ContourBands 单测 | +| `tests/data/test_dataset_chart_dto.cpp`(建) | DTO 解析单测 | +| `tests/app/test_chart_strategy_registry.cpp`(建) | 注册表单测 | +| `tests/CMakeLists.txt`(改) | 注册新测试源 | + +**构建/测试命令(全程统一):** +- 配置:`cmake --preset default`(若首次);构建:`cmake --build build` +- 跑全部测试:`ctest --test-dir build --output-on-failure` +- 跑单个:`ctest --test-dir build -R <用例名> --output-on-failure` + +--- + +## Phase 1:core::Grid NaN + ContourBands(VTK 几何提取) + +### Task 1.1:Grid 增 NaN 约定 + hasValue + +**Files:** +- Modify: `src/core/model/Field.hpp`(`class Grid`,在 `valueAt` 附近) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_model_data.cpp` 末尾追加(该文件已在 CMake 中): +```cpp +#include +#include "model/Field.hpp" +TEST(GridNaN, HasValueReflectsNaN) { + geopro::core::Grid g(2, 2); + g.valueAt(0, 0) = 3.0; + g.valueAt(1, 0) = std::nan(""); + EXPECT_TRUE(g.hasValue(0, 0)); + EXPECT_FALSE(g.hasValue(1, 0)); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cmake --build build && ctest --test-dir build -R GridNaN --output-on-failure` +Expected: 编译失败「no member named 'hasValue'」 + +- [ ] **Step 3: 实现** + +在 `Field.hpp` 的 `Grid` 类 public 区(`valueAt` 之后)加: +```cpp + bool hasValue(int i, int j) const { return !std::isnan(valueAt(i, j)); } +``` +并在文件顶部 include 区确保有 `#include `。在 `Grid` 类注释补一行:`// NaN 值=无效/测区外(凸包裁剪据此)。` + +- [ ] **Step 4: 运行确认通过** + +Run: `ctest --test-dir build -R GridNaN --output-on-failure` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add src/core/model/Field.hpp tests/core/test_model_data.cpp +git commit -m "feat(core): Grid 增 NaN 约定 + hasValue(凸包裁剪用)" +``` + +--- + +### Task 1.2:ContourBands 骨架 + 色带提取(不含预处理) + +**Files:** +- Create: `src/render/ContourBands.hpp`, `src/render/ContourBands.cpp` +- Modify: `src/render/CMakeLists.txt` +- Test: `tests/render/test_contour_bands.cpp`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写头文件(类型 + 接口)** + +Create `src/render/ContourBands.hpp`: +```cpp +#pragma once +#include +#include "model/Grid.hpp" +#include "model/ColorScale.hpp" +namespace geopro::render { + +struct BandPolygon { + geopro::core::Rgba color; + std::vector ring; // 单环多边形(局部坐标,y=高程向上为正) +}; +struct ContourLine { + double level; + std::vector pts; +}; +struct ContourBandsResult { + std::vector bands; + std::vector lines; +}; +struct ContourOptions { + int upsample = 2; // 双线性上采样倍数(1=不采样) + double smooth = 0.3; // 平滑强度 0..1(0=不平滑) + double simplifyTol = 0.5; // 等值线简化容差(数据单位,0=不简化) + bool makeLines = true; +}; + +// core::Grid(NaN=无效)+ ColorScale 分段 → 色带多边形 + 等值线几何。 +// VTK 仅作算法(vtkBandedPolyDataContourFilter),无 render window。 +ContourBandsResult buildContourBands(const geopro::core::Grid& g, + const geopro::core::ColorScale& cs, + const ContourOptions& opt = {}); +} // namespace geopro::render +``` +> 注:`core::Vec2{double x,y}` 已定义于 `Anomaly.hpp`;`ContourBands.hpp` 经 `Grid.hpp`/`ColorScale.hpp` 间接需要它,显式 `#include "model/Anomaly.hpp"` 以引入 `Vec2`。在上面 include 区加 `#include "model/Anomaly.hpp"`。 + +- [ ] **Step 2: 写失败测试** + +Create `tests/render/test_contour_bands.cpp`: +```cpp +#include +#include "ContourBands.hpp" +using namespace geopro::core; +using namespace geopro::render; + +// 2x2 平滑梯度网格 + 2 段色阶 → 至少 1 个色带多边形,多边形顶点 >=3。 +TEST(ContourBands, ProducesNonEmptyBands) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast(i + j); // 0..4 + g.vmin = 0; g.vmax = 4; + ColorScale cs; + cs.addStop(0.0, Rgba{0, 0, 255, 255}); + cs.addStop(2.0, Rgba{0, 255, 0, 255}); + cs.addStop(4.0, Rgba{255, 0, 0, 255}); + + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + auto r = buildContourBands(g, cs, opt); + ASSERT_FALSE(r.bands.empty()); + for (const auto& b : r.bands) EXPECT_GE(b.ring.size(), 3u); +} +``` + +- [ ] **Step 3: 注册 CMake + 跑确认失败** + +`src/render/CMakeLists.txt`:在现有 `target_sources(geopro_render PRIVATE ...)` 列表加一行 `ContourBands.cpp`(与 `ColorLutBuilder.cpp` 同处)。 +`tests/CMakeLists.txt`:在 render 测试段(`render/test_color_lut.cpp` 之后)加 `target_sources(geopro_tests PRIVATE render/test_contour_bands.cpp)`。 +Run: `cmake --build build && ctest --test-dir build -R ContourBands --output-on-failure` +Expected: 链接失败「buildContourBands 未定义」 + +- [ ] **Step 4: 实现色带提取(先不做预处理)** + +Create `src/render/ContourBands.cpp`: +```cpp +#include "ContourBands.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace geopro::render { +using geopro::core::Grid; +using geopro::core::ColorScale; +using geopro::core::Vec2; +using geopro::core::Rgba; + +namespace { +// 用 Grid 构 vtkStructuredGrid(点 (x[i], y[j], 0);NaN 值置 0 但记录于掩膜—— +// 首版裁剪在 Task 1.4 接入,这里先全量)。 +vtkSmartPointer toStructuredGrid(const Grid& g) { + const int nx = g.nx(), ny = g.ny(); + auto sg = vtkSmartPointer::New(); + sg->SetDimensions(nx, ny, 1); + vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); + vtkNew sc; sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny); + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(j) * nx + i; + pts->SetPoint(id, g.x[i], g.y[j], 0.0); + const double v = g.valueAt(i, j); + sc->SetValue(id, std::isnan(v) ? 0.0 : v); + } + sg->SetPoints(pts); + sg->GetPointData()->SetScalars(sc); + return sg; +} +} // namespace + +ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions&) { + ContourBandsResult out; + const int nx = g.nx(), ny = g.ny(); + if (nx < 2 || ny < 2 || g.x.size() < 2 || g.y.size() < 2) return out; + + const std::vector stops = cs.stopValues(); + if (stops.size() < 2) return out; + + auto sg = toStructuredGrid(g); + vtkNew surf; surf->SetInputData(sg); + vtkNew banded; + banded->SetInputConnection(surf->GetOutputPort()); + banded->SetNumberOfContours(static_cast(stops.size())); + for (int i = 0; i < static_cast(stops.size()); ++i) banded->SetValue(i, stops[i]); + banded->GenerateContourEdgesOn(); + banded->SetScalarModeToValue(); + banded->Update(); + + // port0:色带多边形(cell 标量=带的代表值)→ 按 ColorScale 上色。 + vtkPolyData* poly = banded->GetOutput(); + vtkDataArray* cellScalars = poly->GetCellData()->GetScalars(); + const vtkIdType nCells = poly->GetNumberOfCells(); + for (vtkIdType c = 0; c < nCells; ++c) { + vtkCell* cell = poly->GetCell(c); + vtkPoints* cp = cell->GetPoints(); + if (cp->GetNumberOfPoints() < 3) continue; + BandPolygon bp; + const double val = cellScalars ? cellScalars->GetTuple1(c) : 0.0; + bp.color = cs.colorAt(val); + for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { + double xyz[3]; cp->GetPoint(p, xyz); + bp.ring.push_back(Vec2{xyz[0], xyz[1]}); + } + out.bands.push_back(std::move(bp)); + } + + // port1:等值线(polylines)。 + vtkPolyData* edges = banded->GetContourEdgesOutput(); + if (edges) { + edges->BuildCells(); + const vtkIdType nLines = edges->GetNumberOfCells(); + for (vtkIdType c = 0; c < nLines; ++c) { + vtkCell* cell = edges->GetCell(c); + vtkPoints* cp = cell->GetPoints(); + ContourLine cl; cl.level = 0.0; + for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { + double xyz[3]; cp->GetPoint(p, xyz); + cl.pts.push_back(Vec2{xyz[0], xyz[1]}); + } + if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); + } + } + return out; +} +} // namespace geopro::render +``` + +- [ ] **Step 5: 跑确认通过 + 提交** + +Run: `ctest --test-dir build -R ContourBands --output-on-failure` +Expected: PASS +```bash +git add src/render/ContourBands.hpp src/render/ContourBands.cpp src/render/CMakeLists.txt \ + tests/render/test_contour_bands.cpp tests/CMakeLists.txt +git commit -m "feat(render): ContourBands 从 VTK banded 提取色带多边形+等值线几何" +``` + +--- + +### Task 1.3:双线性上采样 + 平滑预处理 + +**Files:** +- Modify: `src/render/ContourBands.cpp`(在 `buildContourBands` 开头插入预处理) + +- [ ] **Step 1: 写失败测试** + +在 `tests/render/test_contour_bands.cpp` 追加: +```cpp +// 上采样 2x:色带边界更密 → 多边形数应多于不上采样。 +TEST(ContourBands, UpsampleIncreasesDetail) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast(i * i + j); + g.vmin = 0; g.vmax = 6; + ColorScale cs; + cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(3, Rgba{0,255,0,255}); cs.addStop(6, Rgba{255,0,0,255}); + ContourOptions a; a.upsample = 1; a.smooth = 0; a.simplifyTol = 0; + ContourOptions b; b.upsample = 2; b.smooth = 0; b.simplifyTol = 0; + auto ra = buildContourBands(g, cs, a); + auto rb = buildContourBands(g, cs, b); + EXPECT_GT(rb.bands.size(), ra.bands.size()); +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cmake --build build && ctest --test-dir build -R ContourBands.UpsampleIncreasesDetail --output-on-failure` +Expected: FAIL(当前忽略 opt,两者相等) + +- [ ] **Step 3: 实现上采样 + 平滑** + +在 `ContourBands.cpp` 的匿名命名空间加: +```cpp +// 双线性上采样:nx×ny → ((nx-1)*k+1)×((ny-1)*k+1)。NaN 单元参与的目标点置 NaN。 +Grid upsampleBilinear(const Grid& g, int k) { + if (k <= 1) return g; + const int nx = g.nx(), ny = g.ny(); + const int Nx = (nx - 1) * k + 1, Ny = (ny - 1) * k + 1; + Grid out(Nx, Ny); + out.x.resize(Nx); out.y.resize(Ny); + for (int I = 0; I < Nx; ++I) { double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2); + double t = fi - i0; out.x[I] = g.x[i0] * (1 - t) + g.x[i0 + 1] * t; } + for (int J = 0; J < Ny; ++J) { double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2); + double t = fj - j0; out.y[J] = g.y[j0] * (1 - t) + g.y[j0 + 1] * t; } + for (int J = 0; J < Ny; ++J) { + double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2); double tj = fj - j0; + for (int I = 0; I < Nx; ++I) { + double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2); double ti = fi - i0; + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + out.valueAt(I, J) = std::nan(""); + else + out.valueAt(I, J) = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + + (v01 * (1 - ti) + v11 * ti) * tj; + } + } + out.vmin = g.vmin; out.vmax = g.vmax; + return out; +} +// 3x3 盒式平滑(NaN 跳过);strength 0..1 线性混合原值。 +Grid smoothGrid(const Grid& g, double strength) { + if (strength <= 0) return g; + const int nx = g.nx(), ny = g.ny(); + Grid out = g; + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + double v = g.valueAt(i, j); + if (std::isnan(v)) continue; + double sum = 0; int n = 0; + for (int dj = -1; dj <= 1; ++dj) for (int di = -1; di <= 1; ++di) { + int ii = i + di, jj = j + dj; + if (ii < 0 || ii >= nx || jj < 0 || jj >= ny) continue; + double w = g.valueAt(ii, jj); if (std::isnan(w)) continue; sum += w; ++n; + } + if (n) out.valueAt(i, j) = v * (1 - strength) + (sum / n) * strength; + } + return out; +} +``` +在 `buildContourBands` 函数体最前面(`if (nx<2...)` 之后)插入: +```cpp + Grid work = smoothGrid(upsampleBilinear(g, std::max(1, opt.upsample)), opt.smooth); +``` +并把后续 `toStructuredGrid(g)`、`nx/ny` 改用 `work`(即 `const int nx=work.nx()...`、`toStructuredGrid(work)`)。注意把函数签名第三参恢复命名:`const ContourOptions& opt`。 + +- [ ] **Step 4: 跑确认通过** + +Run: `ctest --test-dir build -R ContourBands --output-on-failure` +Expected: 两个用例均 PASS + +- [ ] **Step 5: 提交** + +```bash +git add src/render/ContourBands.cpp tests/render/test_contour_bands.cpp +git commit -m "feat(render): ContourBands 双线性上采样+盒式平滑预处理(对齐 web 2x+smooth)" +``` + +--- + +### Task 1.4:NaN 凸包裁剪 + 等值线简化 + +**Files:** +- Modify: `src/render/ContourBands.cpp` + +- [ ] **Step 1: 写失败测试** + +追加: +```cpp +// 含 NaN 无效角的网格:裁剪后该角不应被任何色带覆盖(所有多边形顶点远离该角)。 +TEST(ContourBands, ClipsNaNRegion) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) for (int i = 0; i < 3; ++i) g.valueAt(i, j) = 5.0; + g.valueAt(2, 2) = std::nan(""); // 右上角无效 + g.vmin = 0; g.vmax = 10; + ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(10, Rgba{255,0,0,255}); + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + auto r = buildContourBands(g, cs, opt); + bool coversCorner = false; + for (const auto& b : r.bands) + for (const auto& p : b.ring) + if (p.x > 1.5 && p.y > 1.5) coversCorner = true; + EXPECT_FALSE(coversCorner); +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cmake --build build && ctest --test-dir build -R ContourBands.ClipsNaNRegion --output-on-failure` +Expected: FAIL(当前 NaN 置 0,整片覆盖) + +- [ ] **Step 3: 实现裁剪 + 简化** + +裁剪策略(首版务实):把含任一 NaN 顶点的网格单元(quad)从 `vtkStructuredGrid` 中**剔除**——改 `toStructuredGrid` 为 `vtkUnstructuredGrid`,逐 quad 仅当 4 顶点都 `hasValue` 才 `InsertNextCell(VTK_QUAD,...)`。在 `ContourBands.cpp` 顶部加 `#include `、`#include `,并把 `toStructuredGrid` 替换为: +```cpp +vtkSmartPointer toCellGrid(const Grid& g) { + const int nx = g.nx(), ny = g.ny(); + auto ug = vtkSmartPointer::New(); + vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); + vtkNew sc; sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny); + for (int j = 0; j < ny; ++j) for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(j) * nx + i; + pts->SetPoint(id, g.x[i], g.y[j], 0.0); + const double v = g.valueAt(i, j); + sc->SetValue(id, std::isnan(v) ? 0.0 : v); + } + ug->SetPoints(pts); + ug->GetPointData()->SetScalars(sc); + auto idAt = [nx](int i, int j) { return static_cast(j) * nx + i; }; + for (int j = 0; j < ny - 1; ++j) for (int i = 0; i < nx - 1; ++i) { + if (!g.hasValue(i, j) || !g.hasValue(i+1, j) || !g.hasValue(i, j+1) || !g.hasValue(i+1, j+1)) + continue; // 含 NaN 的 quad 不入网格 → 凸包裁剪 + vtkIdType quad[4] = { idAt(i,j), idAt(i+1,j), idAt(i+1,j+1), idAt(i,j+1) }; + ug->InsertNextCell(VTK_QUAD, 4, quad); + } + return ug; +} +``` +`vtkDataSetSurfaceFilter` 对 `vtkUnstructuredGrid` 同样适用:把 `surf->SetInputData(sg)` 改为 `surf->SetInputData(toCellGrid(work))`(删除原 `toStructuredGrid(work)` 调用与 `sg` 变量)。 +等值线简化:在收集 `cl.pts` 后调用 Douglas-Peucker。加匿名函数: +```cpp +void simplifyInPlace(std::vector& pts, double tol) { + if (tol <= 0 || pts.size() < 3) return; + std::vector keep(pts.size(), 0); keep.front() = keep.back() = 1; + std::vector> st{{0, int(pts.size()) - 1}}; + auto dist = [](const Vec2& p, const Vec2& a, const Vec2& b) { + double dx = b.x - a.x, dy = b.y - a.y, L = std::hypot(dx, dy); + if (L < 1e-12) return std::hypot(p.x - a.x, p.y - a.y); + return std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx) / L; }; + while (!st.empty()) { auto [s, e] = st.back(); st.pop_back(); + double dmax = 0; int idx = -1; + for (int k = s + 1; k < e; ++k) { double d = dist(pts[k], pts[s], pts[e]); + if (d > dmax) { dmax = d; idx = k; } } + if (idx >= 0 && dmax > tol) { keep[idx] = 1; st.push_back({s, idx}); st.push_back({idx, e}); } } + std::vector r; for (size_t k = 0; k < pts.size(); ++k) if (keep[k]) r.push_back(pts[k]); + pts.swap(r); +} +``` +在 `if (cl.pts.size() >= 2)` 之前调用 `simplifyInPlace(cl.pts, opt.simplifyTol);`。文件顶部加 `#include `(已含)与 ``(经头文件已含)。 + +- [ ] **Step 4: 跑确认通过** + +Run: `ctest --test-dir build -R ContourBands --output-on-failure` +Expected: 全部 PASS + +- [ ] **Step 5: 提交** + +```bash +git add src/render/ContourBands.cpp tests/render/test_contour_bands.cpp +git commit -m "feat(render): ContourBands NaN 凸包裁剪(剔除无效quad)+等值线DP简化" +``` + +--- + +## Phase 2:DTO 解析 + ApiDatasetRepository + +### Task 2.1:DatasetChartDto 解析(Grid/Scatter/ColorBar/Anomaly) + +**Files:** +- Create: `src/data/dto/DatasetChartDto.hpp`, `src/data/dto/DatasetChartDto.cpp` +- Modify: `src/data/CMakeLists.txt` +- Test: `tests/data/test_dataset_chart_dto.cpp`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +Create `src/data/dto/DatasetChartDto.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::data::dto { + +// inversion/rows 的 data{ x,y,v[ny][nx],z,elevation,vmin,vmax } → Grid(v 缺省/非数→NaN)。 +geopro::core::Grid parseInversionGrid(const QJsonObject& data); +// getErtRawDataScatterGraph 的 data{ xlist,ylist,hlist,vlist,projectXList,projectYList } → ScatterField。 +geopro::core::ScatterField parseScatterGraph(const QJsonObject& data); +// colorGradation/getDetail 的 data.properties.colorBar [[值,"rgba()"],…] → ColorScale。 +geopro::core::ColorScale parseColorBar(const QJsonObject& data); +// queryException 的 data 数组 → Anomaly[](location.coordinate / legend)。 +std::vector parseDatasetAnomalies(const QJsonArray& arr); + +} // namespace geopro::data::dto +``` + +- [ ] **Step 2: 写失败测试** + +Create `tests/data/test_dataset_chart_dto.cpp`: +```cpp +#include +#include +#include +#include +#include "dto/DatasetChartDto.hpp" +using namespace geopro::data::dto; + +static QJsonObject obj(const char* json) { + return QJsonDocument::fromJson(json).object(); +} + +TEST(DatasetChartDto, ParsesInversionGrid) { + auto d = obj(R"({"x":[0,1],"y":[0,1,2],"v":[[1,2],[3,4],[5,6]],"vmin":1,"vmax":6})"); + auto g = parseInversionGrid(d); + EXPECT_EQ(g.nx(), 2); EXPECT_EQ(g.ny(), 3); + EXPECT_DOUBLE_EQ(g.valueAt(1, 2), 6.0); + EXPECT_DOUBLE_EQ(g.vmax, 6.0); +} +TEST(DatasetChartDto, ParsesColorBar) { + auto d = obj(R"({"properties":{"colorBar":[["10","rgba(0,0,255,255)"],["20","rgba(255,0,0,255)"]]}})"); + auto cs = parseColorBar(d); + auto stops = cs.stopValues(); + ASSERT_EQ(stops.size(), 2u); + EXPECT_DOUBLE_EQ(stops[0], 10.0); + auto c = cs.colorAt(12.0); // [10,20) → 蓝 + EXPECT_GT(c.b, c.r); +} +TEST(DatasetChartDto, ParsesAnomalyPolyline) { + auto arr = QJsonDocument::fromJson( + R"([{"exceptionName":"A1","exceptionTypeName":"异常区","exceptionMarkType":2, + "legend":{"polylineColor":"#0D0101","polylineWidth":4,"polylineShape":"dash"}, + "location":{"coordinate":[{"x":1,"y":2},{"x":3,"y":4}]}}])").array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].name, "A1"); + EXPECT_EQ(static_cast(v[0].markType), 2); + ASSERT_EQ(v[0].localPts.size(), 2u); + EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0); + EXPECT_TRUE(v[0].dashed); +} +``` + +- [ ] **Step 3: 注册 CMake + 跑确认失败** + +`src/data/CMakeLists.txt`:在 `target_sources(geopro_data PRIVATE ...)` 加 `dto/DatasetChartDto.cpp`。 +`tests/CMakeLists.txt`:data 段加 `target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp)`。 +Run: `cmake --build build && ctest --test-dir build -R DatasetChartDto --output-on-failure` +Expected: 链接失败 + +- [ ] **Step 4: 实现** + +Create `src/data/dto/DatasetChartDto.cpp`: +```cpp +#include "dto/DatasetChartDto.hpp" +#include +#include +namespace geopro::data::dto { +using namespace geopro::core; + +static double num(const QJsonValue& v, double def = 0.0) { + if (v.isDouble()) return v.toDouble(); + if (v.isString()) { bool ok = false; double d = v.toString().toDouble(&ok); return ok ? d : def; } + return def; +} + +Grid parseInversionGrid(const QJsonObject& data) { + const QJsonArray x = data.value("x").toArray(); + const QJsonArray y = data.value("y").toArray(); + const QJsonArray v = data.value("v").toArray(); // [ny][nx] + const int nx = x.size(), ny = y.size(); + Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny); + g.x.clear(); for (auto e : x) g.x.push_back(num(e)); + g.y.clear(); for (auto e : y) g.y.push_back(num(e)); + for (int j = 0; j < ny; ++j) { + const QJsonArray row = v.at(j).toArray(); + for (int i = 0; i < nx; ++i) { + const QJsonValue cell = row.at(i); + g.valueAt(i, j) = (cell.isNull() || cell.isUndefined()) ? std::nan("") : num(cell, std::nan("")); + } + } + g.vmin = num(data.value("vmin")); g.vmax = num(data.value("vmax")); + return g; +} + +ScatterField parseScatterGraph(const QJsonObject& data) { + ScatterField s; + auto fill = [&](const char* key, std::vector& dst) { + for (auto e : data.value(key).toArray()) dst.push_back(num(e)); }; + fill("xlist", s.x); fill("ylist", s.y); fill("hlist", s.z); fill("vlist", s.v); + fill("projectXList", s.projX); fill("projectYList", s.projY); + return s; +} + +ColorScale parseColorBar(const QJsonObject& data) { + ColorScale cs; + const QJsonArray bar = data.value("properties").toObject().value("colorBar").toArray(); + for (auto e : bar) { + const QJsonArray pair = e.toArray(); + if (pair.size() < 2) continue; + const double val = num(pair.at(0)); + const std::string rgba = pair.at(1).toString().toStdString(); + cs.addStop(val, parseColor(rgba, AlphaScale::Bit255)); + } + return cs; +} + +std::vector parseDatasetAnomalies(const QJsonArray& arr) { + std::vector out; + for (auto e : arr) { + const QJsonObject o = e.toObject(); + Anomaly a; + a.name = o.value("exceptionName").toString().toStdString(); + a.typeName = o.value("exceptionTypeName").toString().toStdString(); + a.markType = static_cast(o.value("exceptionMarkType").toInt(2)); + const QJsonObject lg = o.value("legend").toObject(); + a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); + a.lineWidth = lg.value("polylineWidth").toDouble(1.0); + a.dashed = lg.value("polylineShape").toString() == "dash"; + for (auto c : o.value("location").toObject().value("coordinate").toArray()) { + const QJsonObject p = c.toObject(); + a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()}); + } + out.push_back(std::move(a)); + } + return out; +} +} // namespace geopro::data::dto +``` + +- [ ] **Step 5: 跑确认通过 + 提交** + +Run: `ctest --test-dir build -R DatasetChartDto --output-on-failure` +Expected: PASS +```bash +git add src/data/dto/DatasetChartDto.hpp src/data/dto/DatasetChartDto.cpp \ + src/data/CMakeLists.txt tests/data/test_dataset_chart_dto.cpp tests/CMakeLists.txt +git commit -m "feat(data): DatasetChartDto 解析 inversion网格/散点/colorBar/异常" +``` + +--- + +### Task 2.2:IDatasetRepository 增 loadScatterColorScale + ApiDatasetRepository + +**Files:** +- Modify: `src/data/repo/IDatasetRepository.hpp`, `src/data/repo/LocalSampleRepository.hpp` +- Create: `src/data/api/ApiDatasetRepository.hpp`, `src/data/api/ApiDatasetRepository.cpp` +- Modify: `src/data/CMakeLists.txt` + +- [ ] **Step 1: 接口增方法** + +`src/data/repo/IDatasetRepository.hpp`:在 `loadColorScale` 后加纯虚: +```cpp + virtual geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) = 0; +``` +`src/data/repo/LocalSampleRepository.hpp`:给已有的 `loadScatterColorScale` 声明加 `override`(若未标)。 + +- [ ] **Step 2: 写头文件** + +Create `src/data/api/ApiDatasetRepository.hpp`: +```cpp +#pragma once +#include "repo/IDatasetRepository.hpp" +namespace geopro::net { class ApiClient; } +namespace geopro::data { + +// 真实 API 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。 +class ApiDatasetRepository : public IDatasetRepository { +public: + explicit ApiDatasetRepository(net::ApiClient& api); + std::vector loadStructure() override { return {}; } // 不经此仓储取结构 + geopro::core::Grid loadGrid(const std::string& dsId) override; + geopro::core::ScatterField loadScatter(const std::string& dsId) override; + geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格 + geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据 + std::vector loadAnomalies(const std::string& dsId) override; +private: + net::ApiClient& api_; +}; +} // namespace geopro::data +``` + +- [ ] **Step 3: 实现** + +Create `src/data/api/ApiDatasetRepository.cpp`: +```cpp +#include "api/ApiDatasetRepository.hpp" +#include +#include +#include +#include +#include "ApiClient.hpp" +#include "dto/DatasetChartDto.hpp" +namespace geopro::data { +namespace { +QString enc(const std::string& s) { + return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); +} +void must(const net::ApiResponse& r, const char* what) { + if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " + + (r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); +} +geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) { + QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; + const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); + must(r, "colorGradation"); + return dto::parseColorBar(r.data); +} +} // namespace + +ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} + +geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); + must(r, "inversion/rows"); + return dto::parseInversionGrid(r.data); +} +geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); + must(r, "scatterGraph"); + return dto::parseScatterGraph(r.data); +} +geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { + return colorScale(api_, dsId, 2); +} +geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { + return colorScale(api_, dsId, 1); +} +std::vector ApiDatasetRepository::loadAnomalies(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); + must(r, "queryException"); + return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray()); +} +} // namespace geopro::data +``` +> 注:`queryException` 的 `data` 是否含 `value` 包裹按现网验证调整——若 `r.data` 直接是数组语义,改为对应取法;本仓返回信封 `data` 为对象,数组在 `value` 字段(同 `loadExceptionsByTm`)。 +`src/data/CMakeLists.txt`:`target_sources(geopro_data PRIVATE api/ApiDatasetRepository.cpp)`。确认 `geopro_data` 链接了 `geopro_net`(`ApiClient`)——`ApiProjectRepository` 已用,故已链。 + +- [ ] **Step 4: 构建确认通过** + +Run: `cmake --build build` +Expected: 编译链接通过(无新测试,编译即验证签名一致)。 + +- [ ] **Step 5: 提交** + +```bash +git add src/data/repo/IDatasetRepository.hpp src/data/repo/LocalSampleRepository.hpp \ + src/data/api/ApiDatasetRepository.hpp src/data/api/ApiDatasetRepository.cpp src/data/CMakeLists.txt +git commit -m "feat(data): ApiDatasetRepository 接真实 API + loadScatterColorScale 提到接口" +``` + +--- + +## Phase 3:DatasetChartView(QGraphicsView 渲染器) + +### Task 3.1:DatasetChartView 散点/等值面/异常渲染 + +**Files:** +- Create: `src/app/panels/chart/DatasetChartView.hpp`, `src/app/panels/chart/DatasetChartView.cpp` +- Modify: `src/app/CMakeLists.txt` + +> 说明:UI 渲染类以集成/视觉验证为主,不做像素级单测。本任务给出可编译运行的骨架与渲染逻辑;坐标轴钉边重算在 Task 3.2。 + +- [ ] **Step 1: 写头文件** + +Create `src/app/panels/chart/DatasetChartView.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +#include "ContourBands.hpp" +namespace geopro::app { + +// 平面图表视图:散点(原数据) / 等值面(网格) + 异常叠加 + 色阶图例。VTK 仅经 ContourBands 算几何。 +class DatasetChartView : public QGraphicsView { + Q_OBJECT +public: + explicit DatasetChartView(QWidget* parent = nullptr); + void showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs); + void showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, + const geopro::render::ContourOptions& opt); + void setAnomalies(const std::vector& list); + void setHiddenAnomalies(const std::set& hidden); // 下标=list 序 + void setShowAnomalies(bool on); + void setShowContourLines(bool on); + void clearChart(); +protected: + void wheelEvent(QWheelEvent* e) override; // 滚轮缩放 +private: + void rebuildAnomalyItems(); + QGraphicsScene* scene_; + std::vector anomalies_; + std::set hidden_; + bool showAnomalies_ = true; + bool showContourLines_ = true; + std::vector anomalyItems_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现** + +Create `src/app/panels/chart/DatasetChartView.cpp`: +```cpp +#include "panels/chart/DatasetChartView.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +namespace geopro::app { +using geopro::core::Rgba; + +static QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } + +DatasetChartView::DatasetChartView(QWidget* parent) + : QGraphicsView(parent), scene_(new QGraphicsScene(this)) { + setScene(scene_); + setRenderHint(QPainter::Antialiasing, true); + setDragMode(QGraphicsView::ScrollHandDrag); + // 场景 y 向上为正:用 y 翻转的变换(QGraphicsView 默认 y 向下)。 + scale(1, -1); +} + +void DatasetChartView::clearChart() { + scene_->clear(); anomalyItems_.clear(); +} + +void DatasetChartView::showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs) { + clearChart(); + const double sz = 0.6; // 方块边长(数据单位,近似 web 方点) + for (size_t i = 0; i < f.v.size(); ++i) { + auto* r = scene_->addRect(f.x[i] - sz / 2, f.y[i] - sz / 2, sz, sz, + QPen(Qt::white, 0), QBrush(toQ(cs.colorAt(f.v[i])))); + r->setPen(QPen(Qt::white, 0)); // 白描边 + } + rebuildAnomalyItems(); + fitInView(scene_->itemsBoundingRect(), Qt::KeepAspectRatio); // x:y 等比 +} + +void DatasetChartView::showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, + const geopro::render::ContourOptions& opt) { + clearChart(); + const auto r = geopro::render::buildContourBands(g, cs, opt); + for (const auto& b : r.bands) { + QPainterPath path; if (b.ring.empty()) continue; + path.moveTo(b.ring[0].x, b.ring[0].y); + for (size_t k = 1; k < b.ring.size(); ++k) path.lineTo(b.ring[k].x, b.ring[k].y); + path.closeSubpath(); + auto* it = scene_->addPath(path, QPen(Qt::NoPen), QBrush(toQ(b.color))); + it->setZValue(0); + } + if (showContourLines_) + for (const auto& l : r.lines) { + QPainterPath path; path.moveTo(l.pts[0].x, l.pts[0].y); + for (size_t k = 1; k < l.pts.size(); ++k) path.lineTo(l.pts[k].x, l.pts[k].y); + auto* it = scene_->addPath(path, QPen(QColor(0, 0, 0), 0)); + it->setZValue(1); + } + rebuildAnomalyItems(); + fitInView(scene_->itemsBoundingRect(), Qt::IgnoreAspectRatio); // 剖面 X/Y 各自铺满 +} + +void DatasetChartView::setAnomalies(const std::vector& list) { + anomalies_ = list; rebuildAnomalyItems(); +} +void DatasetChartView::setHiddenAnomalies(const std::set& hidden) { hidden_ = hidden; rebuildAnomalyItems(); } +void DatasetChartView::setShowAnomalies(bool on) { showAnomalies_ = on; rebuildAnomalyItems(); } +void DatasetChartView::setShowContourLines(bool on) { showContourLines_ = on; } + +void DatasetChartView::rebuildAnomalyItems() { + for (auto* it : anomalyItems_) scene_->removeItem(it), delete it; + anomalyItems_.clear(); + if (!showAnomalies_) return; + for (int i = 0; i < static_cast(anomalies_.size()); ++i) { + if (hidden_.count(i)) continue; + const auto& a = anomalies_[i]; + if (a.localPts.size() < 2) continue; + QPainterPath path; path.moveTo(a.localPts[0].x, a.localPts[0].y); + for (size_t k = 1; k < a.localPts.size(); ++k) path.lineTo(a.localPts[k].x, a.localPts[k].y); + if (static_cast(a.markType) == 3) path.closeSubpath(); + QPen pen(QColor(QString::fromStdString(a.lineColor)), 0); + if (a.dashed) pen.setStyle(Qt::DashLine); + auto* it = scene_->addPath(path, pen); + it->setZValue(2); + anomalyItems_.push_back(it); + } +} + +void DatasetChartView::wheelEvent(QWheelEvent* e) { + const double f = e->angleDelta().y() > 0 ? 1.15 : 1 / 1.15; + scale(f, f); +} +} // namespace geopro::app +``` +`src/app/CMakeLists.txt`:把 `panels/chart/DatasetChartView.cpp` 加入 app 目标的 `target_sources`(与其它 panel 同处);确认 app 目标 include 了 `src/render` 头路径(已链 geopro_render 则传递;若无,`target_link_libraries(... geopro_render)` 已存在即可)。 + +- [ ] **Step 3: 构建确认通过** + +Run: `cmake --build build` +Expected: 编译通过(moc 处理 Q_OBJECT)。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/panels/chart/DatasetChartView.hpp src/app/panels/chart/DatasetChartView.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): DatasetChartView 散点/等值面/异常叠加(QGraphicsView)" +``` + +--- + +### Task 3.2:坐标轴 overlay + 色阶图例 + 等比 + +**Files:** +- Modify: `src/app/panels/chart/DatasetChartView.hpp/.cpp` + +- [ ] **Step 1: 加坐标轴与图例绘制** + +在 `DatasetChartView` 重写 `drawForeground(QPainter*, const QRectF&)`:用 `mapFromScene` 把场景刻度投影到视口画轴线/刻度文字(轴钉视口边缘,缩放/平移随 `drawForeground` 自动重绘);底部画色阶图例(离散色带 + 分段值)。 +头文件加: +```cpp +protected: + void drawForeground(QPainter* p, const QRectF& rect) override; +private: + geopro::core::ColorScale legendScale_; + void setLegend(const geopro::core::ColorScale& cs) { legendScale_ = cs; } +``` +在 `showScatter`/`showContour` 末尾调用 `setLegend(cs);` 并 `viewport()->update();`。 +`drawForeground` 实现:以 `viewport()->rect()` 为基;遍历场景包围盒 x/y 范围取若干刻度,`mapFromScene(QPointF(sx, sy))` 得像素位置画 `p->drawLine`/`p->drawText`;底部按 `legendScale_.stopValues()` 平均分段画矩形 + 文字。 +(完整像素代码在实现时据视觉对照微调,逻辑:轴线沿 viewport 左/下边,刻度 6~8 个,图例条高约 16px 居底。) + +- [ ] **Step 2: 构建 + 手动运行核对** + +Run: `cmake --build build`,启动 app(`build/src/app/geopro_app` 或既有启动脚本),选中一个反演数据集,肉眼对照 `docs/superpowers/specs/assets/web-datasetinfo-*.png`:散点等比、等值面带边、异常虚线、底部色阶图例、坐标轴刻度。 +Expected: 视觉与 web 接近(~95%)。 + +- [ ] **Step 3: 提交** + +```bash +git add src/app/panels/chart/DatasetChartView.hpp src/app/panels/chart/DatasetChartView.cpp +git commit -m "feat(ui): DatasetChartView 坐标轴 overlay(钉边重算)+底部色阶图例" +``` + +--- + +## Phase 4:dd 策略框架 + DatasetDetailController + +### Task 4.1:策略接口 + 注册表(含降级) + +**Files:** +- Create: `src/app/panels/chart/IDatasetChartStrategy.hpp` +- Test: `tests/app/test_chart_strategy_registry.cpp`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +Create `src/app/panels/chart/IDatasetChartStrategy.hpp`: +```cpp +#pragma once +#include +#include +#include +namespace geopro::app { + +class DatasetDetailPage; // 前置 +namespace geopro::data { } + +// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 +struct IDatasetChartStrategy { + virtual ~IDatasetChartStrategy() = default; + virtual std::string ddCode() const = 0; +}; + +class ChartStrategyRegistry { +public: + void add(std::unique_ptr s) { + const std::string code = s->ddCode(); + map_[code] = std::move(s); + } + IDatasetChartStrategy* find(const std::string& ddCode) const { + auto it = map_.find(ddCode); + return it == map_.end() ? nullptr : it->second.get(); + } + bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; } +private: + std::map> map_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 写失败测试** + +Create `tests/app/test_chart_strategy_registry.cpp`: +```cpp +#include +#include "panels/chart/IDatasetChartStrategy.hpp" +using namespace geopro::app; +namespace { +struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } }; +} +TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { + ChartStrategyRegistry reg; + reg.add(std::make_unique()); + EXPECT_TRUE(reg.supports("dd_inversion_data")); + EXPECT_NE(reg.find("dd_inversion_data"), nullptr); + EXPECT_FALSE(reg.supports("dd_unknown")); + EXPECT_EQ(reg.find("dd_unknown"), nullptr); +} +``` + +- [ ] **Step 3: 注册 CMake + 跑确认失败 → 通过** + +`tests/CMakeLists.txt`:新增一段把 `app/test_chart_strategy_registry.cpp` 加入 `geopro_tests` 并 `target_link_libraries(geopro_tests PRIVATE geopro_app)`(若 `geopro_app` 是可执行而非库,则改为仅 `target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)`,因该测试只用头文件 `IDatasetChartStrategy.hpp`,无需链 app)。优先后者(纯头测试): +```cmake +target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) +target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) +``` +Run: `cmake --build build && ctest --test-dir build -R ChartStrategyRegistry --output-on-failure` +Expected: PASS + +- [ ] **Step 4: 提交** + +```bash +git add src/app/panels/chart/IDatasetChartStrategy.hpp tests/app/test_chart_strategy_registry.cpp tests/CMakeLists.txt +git commit -m "feat(ui): dd 图表策略接口 + 注册表(未注册降级)" +``` + +--- + +### Task 4.2:DatasetDetailController + ErtInversionStrategy + +**Files:** +- Create: `src/controller/DatasetDetailController.hpp/.cpp`, `src/app/panels/chart/ErtInversionStrategy.hpp/.cpp` +- Modify: `src/controller/CMakeLists.txt`, `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写控制器头** + +Create `src/controller/DatasetDetailController.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::data { class IDatasetRepository; } +namespace geopro::controller { + +// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 +// 仅服务图表,不与 WorkbenchNavController(项目/结构导航)耦合。 +class DatasetDetailController : public QObject { + Q_OBJECT +public: + struct ChartData { + QString dsId, ddCode; + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; + geopro::core::Grid grid; + geopro::core::ColorScale gridScale; + std::vector anomalies; + }; + explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); +public slots: + void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 + void focusDataset(const QString& dsId); // 单击=聚焦已开页 +signals: + void chartReady(const ChartData& data); + void focusRequested(const QString& dsId); + void loadFailed(const QString& dsId, const QString& message); +private: + data::IDatasetRepository& repo_; +}; +} // namespace geopro::controller +``` + +- [ ] **Step 2: 实现控制器** + +Create `src/controller/DatasetDetailController.cpp`: +```cpp +#include "DatasetDetailController.hpp" +#include +#include "repo/IDatasetRepository.hpp" +namespace geopro::controller { + +DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) + : QObject(parent), repo_(repo) {} + +void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { + const std::string id = dsId.toStdString(); + ChartData d; d.dsId = dsId; d.ddCode = ddCode; + try { + d.scatter = repo_.loadScatter(id); + d.scatterScale = repo_.loadScatterColorScale(id); + d.grid = repo_.loadGrid(id); + d.gridScale = repo_.loadColorScale(id); + d.anomalies = repo_.loadAnomalies(id); + } catch (const std::exception& e) { + emit loadFailed(dsId, QString::fromStdString(e.what())); + return; + } + emit chartReady(d); +} + +void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } +} // namespace geopro::controller +``` +`src/controller/CMakeLists.txt`:把 `DatasetDetailController.cpp` 加入 `geopro_controller` 的 `target_sources`;确认链接 `geopro_data`(接口)+ `Qt6::Core`(已有)。 + +- [ ] **Step 3: ErtInversionStrategy(标识用,渲染由页驱动)** + +Create `src/app/panels/chart/ErtInversionStrategy.hpp`: +```cpp +#pragma once +#include "panels/chart/IDatasetChartStrategy.hpp" +namespace geopro::app { +struct ErtInversionStrategy : IDatasetChartStrategy { + std::string ddCode() const override { return "dd_inversion_data"; } +}; +} // namespace geopro::app +``` +> 首版 ErtInversion 仅作 ddCode 标识/能力声明;具体「散点+等值面」渲染由 `DatasetDetailPage` 消费 `ChartData` 完成(见 Task 5.2)。后续 dd 类型可把渲染细节下沉到策略。无 .cpp。 + +- [ ] **Step 4: 构建确认通过 + 提交** + +Run: `cmake --build build` +Expected: 通过 +```bash +git add src/controller/DatasetDetailController.hpp src/controller/DatasetDetailController.cpp \ + src/controller/CMakeLists.txt src/app/panels/chart/ErtInversionStrategy.hpp +git commit -m "feat(controller): DatasetDetailController 编排 + ErtInversionStrategy 标识" +``` + +--- + +## Phase 5:异常表 + 详情页 + 多 Tab 壳 + +### Task 5.1:AnomalyTablePanel(行显隐→图表) + +**Files:** +- Create: `src/app/panels/AnomalyTablePanel.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +Create `src/app/panels/AnomalyTablePanel.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "model/Anomaly.hpp" +class QTableWidget; +namespace geopro::app { + +// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。 +class AnomalyTablePanel : public QWidget { + Q_OBJECT +public: + explicit AnomalyTablePanel(QWidget* parent = nullptr); + void setAnomalies(const std::vector& list, + const std::vector& createTimes, + const std::vector& remarks); +signals: + void hiddenChanged(const std::set& hiddenIndices); +private: + QTableWidget* table_; + std::set hidden_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现** + +Create `src/app/panels/AnomalyTablePanel.cpp`: +```cpp +#include "panels/AnomalyTablePanel.hpp" +#include +#include +#include +#include +namespace geopro::app { +static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; } + +AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); + table_ = new QTableWidget(this); + table_->setColumnCount(6); + table_->setHorizontalHeaderLabels({"名称", "异常类型", "几何类型", "创建时间", "备注", "操作"}); + table_->horizontalHeader()->setStretchLastSection(true); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + lay->addWidget(table_); +} + +void AnomalyTablePanel::setAnomalies(const std::vector& list, + const std::vector& createTimes, + const std::vector& remarks) { + hidden_.clear(); + table_->setRowCount(static_cast(list.size())); + for (int i = 0; i < static_cast(list.size()); ++i) { + const auto& a = list[i]; + table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name))); + table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName))); + table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast(a.markType)))); + table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : "")); + table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : "")); + auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true); + eye->setText("👁"); + connect(eye, &QToolButton::toggled, this, [this, i](bool on) { + if (on) hidden_.erase(i); else hidden_.insert(i); + emit hiddenChanged(hidden_); + }); + table_->setCellWidget(i, 5, eye); + } +} +} // namespace geopro::app +``` +`src/app/CMakeLists.txt`:加 `panels/AnomalyTablePanel.cpp`。 + +- [ ] **Step 3: 构建 + 提交** + +Run: `cmake --build build` +```bash +git add src/app/panels/AnomalyTablePanel.hpp src/app/panels/AnomalyTablePanel.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): AnomalyTablePanel ds级异常表(行眼睛→隐藏集信号)" +``` + +--- + +### Task 5.2:DatasetDetailPage(标题+切换+图表+异常表) + +**Files:** +- Create: `src/app/panels/DatasetDetailPage.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +Create `src/app/panels/DatasetDetailPage.hpp`: +```cpp +#pragma once +#include +#include "DatasetDetailController.hpp" // ChartData +namespace geopro::app { +class DatasetChartView; class AnomalyTablePanel; + +// 单个数据集详情页:标题 + 原数据/网格数据 切换 + 叠加开关 + 图表 + 异常表。 +class DatasetDetailPage : public QWidget { + Q_OBJECT +public: + explicit DatasetDetailPage(QWidget* parent = nullptr); + void setData(const geopro::controller::DatasetDetailController::ChartData& d); + QString dsId() const { return dsId_; } +private: + void showScatterMode(); void showGridMode(); + QString dsId_; + geopro::controller::DatasetDetailController::ChartData data_; + DatasetChartView* chart_; + AnomalyTablePanel* anomalyTable_; + bool gridMode_ = true; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现(接线切换/叠加/异常联动)** + +Create `src/app/panels/DatasetDetailPage.cpp`: +```cpp +#include "panels/DatasetDetailPage.hpp" +#include +#include +#include +#include +#include +#include +#include "panels/chart/DatasetChartView.hpp" +#include "panels/AnomalyTablePanel.hpp" +namespace geopro::app { + +DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + auto* bar = new QHBoxLayout(); + auto* origin = new QToolButton(this); origin->setText("原数据"); origin->setCheckable(true); + auto* grid = new QToolButton(this); grid->setText("网格数据"); grid->setCheckable(true); + grid->setChecked(true); + auto* grp = new QButtonGroup(this); grp->setExclusive(true); grp->addButton(origin); grp->addButton(grid); + auto* showAnom = new QCheckBox("显示异常", this); showAnom->setChecked(true); + auto* showLines = new QCheckBox("显示等值线", this); showLines->setChecked(true); + bar->addWidget(origin); bar->addWidget(grid); bar->addStretch(); + bar->addWidget(showAnom); bar->addWidget(showLines); + lay->addLayout(bar); + + chart_ = new DatasetChartView(this); + anomalyTable_ = new AnomalyTablePanel(this); + lay->addWidget(chart_, 3); + lay->addWidget(anomalyTable_, 1); + + connect(grid, &QToolButton::clicked, this, [this] { gridMode_ = true; showGridMode(); }); + connect(origin, &QToolButton::clicked, this, [this] { gridMode_ = false; showScatterMode(); }); + connect(showAnom, &QCheckBox::toggled, chart_, [this](bool on) { chart_->setShowAnomalies(on); }); + connect(showLines, &QCheckBox::toggled, chart_, [this](bool on) { + chart_->setShowContourLines(on); if (gridMode_) showGridMode(); }); + connect(anomalyTable_, &AnomalyTablePanel::hiddenChanged, chart_, + [this](const std::set& h) { chart_->setHiddenAnomalies(h); }); +} + +void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { + dsId_ = d.dsId; data_ = d; + chart_->setAnomalies(d.anomalies); + anomalyTable_->setAnomalies(d.anomalies, {}, {}); // 创建时间/备注可后续从 VO 补 + if (gridMode_) showGridMode(); else showScatterMode(); +} +void DatasetDetailPage::showGridMode() { + geopro::render::ContourOptions opt; // 默认 2x+smooth+simplify0.5 + chart_->showContour(data_.grid, data_.gridScale, opt); +} +void DatasetDetailPage::showScatterMode() { + chart_->showScatter(data_.scatter, data_.scatterScale); +} +} // namespace geopro::app +``` +`src/app/CMakeLists.txt`:加 `panels/DatasetDetailPage.cpp`;确认 app 链接 `geopro_controller`(用 ChartData)——main 已链则已有。 + +- [ ] **Step 3: 构建 + 提交** + +Run: `cmake --build build` +```bash +git add src/app/panels/DatasetDetailPage.hpp src/app/panels/DatasetDetailPage.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): DatasetDetailPage 原数据/网格切换+叠加开关+异常联动" +``` + +--- + +### Task 5.3:DatasetDetailPanel(多 Tab,按 dsId 去重) + +**Files:** +- Create: `src/app/panels/DatasetDetailPanel.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +Create `src/app/panels/DatasetDetailPanel.hpp`: +```cpp +#pragma once +#include +#include "DatasetDetailController.hpp" +namespace geopro::app { +class DatasetDetailPage; + +// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。 +class DatasetDetailPanel : public QTabWidget { + Q_OBJECT +public: + explicit DatasetDetailPanel(QWidget* parent = nullptr); + void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 + void focusDataset(const QString& dsId); // 单击聚焦已开页 +signals: + void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 +private: + DatasetDetailPage* pageFor(const QString& dsId) const; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现** + +Create `src/app/panels/DatasetDetailPanel.cpp`: +```cpp +#include "panels/DatasetDetailPanel.hpp" +#include "panels/DatasetDetailPage.hpp" +namespace geopro::app { + +DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { + setTabsClosable(true); + connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { delete widget(i); }); + connect(this, &QTabWidget::currentChanged, this, [this](int i) { + if (auto* p = qobject_cast(widget(i))) + emit activeDatasetChanged(p->dsId()); + }); +} + +DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { + for (int i = 0; i < count(); ++i) + if (auto* p = qobject_cast(widget(i))) + if (p->dsId() == dsId) return p; + return nullptr; +} + +void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { + auto* p = pageFor(d.dsId); + if (!p) { p = new DatasetDetailPage(this); addTab(p, d.dsId); } // 标题后续可换 ds 名 + p->setData(d); + setCurrentWidget(p); +} +void DatasetDetailPanel::focusDataset(const QString& dsId) { + if (auto* p = pageFor(dsId)) setCurrentWidget(p); +} +} // namespace geopro::app +``` +`src/app/CMakeLists.txt`:加 `panels/DatasetDetailPanel.cpp`。 + +- [ ] **Step 3: 构建 + 提交** + +Run: `cmake --build build` +```bash +git add src/app/panels/DatasetDetailPanel.hpp src/app/panels/DatasetDetailPanel.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): DatasetDetailPanel 多Tab壳(按dsId去重+反向联动信号)" +``` + +--- + +## Phase 6:main.cpp 接线(移除旧 VTK 详情,接上新面板) + +### Task 6.1:替换详情 dock + 接上控制器与单击/双击 + +**Files:** +- Modify: `src/app/main.cpp` + +- [ ] **Step 1: 移除旧 VTK 详情 dock 构建** + +删除 `main.cpp:451-523`(`detailWidget`/`detailRenderWindow`/`detailRenderer`/`detailContainer`/`detailToolBar`/`actScatter`/`actSection`/`actShow*`/`detailDock` 整段)与 `main.cpp:602-702` 的 `currentDsId/detailMode/showAnomalies/...rebuildDetail` lambda 及其在 716-742、795-800 的相关连接。保留 `rebuildCentral`(中央场景)。 +> 注:`rebuildDetail` 在 795-800「VTK 背景随主题」连接中与 `rebuildCentral` 同列,删除其中 `rebuildDetail` 调用即可,保留 `rebuildCentral`。 + +- [ ] **Step 2: 新建详情面板 dock + 控制器** + +在 `buildWorkbench` 内(原 detailDock 位置)替换为: +```cpp + auto* detailPanel = new geopro::app::DatasetDetailPanel(); + auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); + detailDock->setWidget(wrapWithHeader(geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel)); + dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); +``` +`buildWorkbench` 签名加参 `geopro::controller::DatasetDetailController& detailCtrl`(在 `WorkbenchNavController& nav` 之后)。文件顶部 include 新头:`panels/DatasetDetailPanel.hpp`、`DatasetDetailController.hpp`。 + +- [ ] **Step 3: 接线单击/双击 + 反向联动** + +把 `main.cpp:705-713` 的 datasetList 单击 lambda 改为同时联动控制器: +```cpp + QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, + [&nav, &detailCtrl](QListWidgetItem* item) { + if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } + const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + if (dsId.isEmpty()) return; + nav.selectDataset(dsId); // 属性表单(现状) + detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 + }); + QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList, + [&detailCtrl](QListWidgetItem* item) { + const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString(); + if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); // 双击=新建/聚焦页 + }); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::chartReady, + detailPanel, [detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) { + detailPanel->openOrUpdate(d); + }); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested, + detailPanel, [detailPanel](const QString& dsId) { detailPanel->focusDataset(dsId); }); + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, + datasetList, [datasetList](const QString& dsId) { + for (int i = 0; i < datasetList->count(); ++i) + if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == dsId) + datasetList->setCurrentRow(i); + }); +``` +> 需确认列表项是否存了 `kDsDdCodeRole`(ddCode)。若无:在填充 datasetList 的 `datasetsLoaded` 连接处(`main.cpp:908`)给每项 `setData(kDsDdCodeRole, QString::fromStdString(row.ddCode))`,并在 `Glyphs`/角色定义处加 `constexpr int kDsDdCodeRole = Qt::UserRole + N;`(取未占用值)。 + +- [ ] **Step 4: 构造控制器并传入** + +在 `main()`(`main.cpp:1046-1060` 附近): +```cpp + geopro::data::ApiDatasetRepository datasetRepo(api); // api 为现有 ApiClient + geopro::controller::DatasetDetailController detailCtrl(datasetRepo); +``` +并把 `buildWorkbench(*window, repo, projectRepo, nav);` 改为 `buildWorkbench(*window, repo, projectRepo, nav, detailCtrl);`。include `api/ApiDatasetRepository.hpp`、`DatasetDetailController.hpp`。 +> `repo`(LocalSampleRepository)现已不被详情使用——若中央场景仍用 `repo.loadGrid("grid1")`(`main.cpp:241`)则保留;否则一并清理其未用引用(仅清理本次改动产生的孤儿)。 + +- [ ] **Step 5: 构建 + 手动验证 + 提交** + +Run: `cmake --build build`,启动 app:单击数据集→聚焦/属性;双击→详情页打开真实反演剖面(散点/网格切换、异常虚线叠加、异常表、色阶图例);切换 Tab 反向高亮列表。 +```bash +git add src/app/main.cpp src/app/Glyphs.hpp +git commit -m "feat(ui): 接线数据详情面板(移除旧VTK详情)+单击聚焦/双击打开/反向联动" +``` + +--- + +## Phase 7:集成测试 + 视觉核对 + +### Task 7.1:DatasetDetailController 编排集成测试 + +**Files:** +- Create: `tests/controller/test_dataset_detail_controller.cpp` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: 写测试(用桩 repo)** + +Create `tests/controller/test_dataset_detail_controller.cpp`: +```cpp +#include +#include +#include "DatasetDetailController.hpp" +#include "repo/IDatasetRepository.hpp" +using namespace geopro; +namespace { +struct StubRepo : data::IDatasetRepository { + bool fail = false; + std::vector loadStructure() override { return {}; } + core::Grid loadGrid(const std::string&) override { if (fail) throw std::runtime_error("x"); core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } + core::ScatterField loadScatter(const std::string&) override { return {}; } + core::ColorScale loadColorScale(const std::string&) override { return {}; } + core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } + std::vector loadAnomalies(const std::string&) override { return {}; } +}; +} +TEST(DatasetDetailController, EmitsChartReadyOnSuccess) { + StubRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("ds1", "dd_inversion_data"); + EXPECT_EQ(spy.count(), 1); +} +TEST(DatasetDetailController, EmitsLoadFailedOnThrow) { + StubRepo repo; repo.fail = true; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_inversion_data"); + EXPECT_EQ(spy.count(), 1); +} +``` + +- [ ] **Step 2: 注册 + 跑确认通过** + +`tests/CMakeLists.txt`:加 `target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp)` 并 `target_link_libraries(geopro_tests PRIVATE geopro_controller)`(+ `Qt6::Test` 用于 QSignalSpy:`find_package(Qt6 COMPONENTS Test REQUIRED)` 与 `Qt6::Test` 链接)。 +Run: `cmake --build build && ctest --test-dir build -R DatasetDetailController --output-on-failure` +Expected: 两个 PASS + +- [ ] **Step 3: 提交** + +```bash +git add tests/controller/test_dataset_detail_controller.cpp tests/CMakeLists.txt +git commit -m "test(controller): DatasetDetailController chartReady/loadFailed 编排" +``` + +--- + +### Task 7.2:全量回归 + 视觉核对 + +- [ ] **Step 1: 全量测试** + +Run: `ctest --test-dir build --output-on-failure` +Expected: 全绿(含既有用例不回归)。 + +- [ ] **Step 2: 视觉核对** + +启动 app,选真实反演数据集,逐项对照 `docs/superpowers/specs/assets/web-datasetinfo-scatter.png` 与 `web-datasetinfo-grid-with-anomaly.png`: +- 原数据:方块散点、色阶、等比 +- 网格:色带 + 等值线 + 不规则白边(凸包裁剪)、底部色阶 +- 异常:剖面虚线叠加 + 底部异常表行、眼睛切换隐藏叠加 +- 多 Tab:双击多个数据集分别成页、单击/Tab 切换反向高亮 + +- [ ] **Step 3: code review** + +按 `~/.claude/rules` 用 cpp-reviewer 审查本分支改动;处理 CRITICAL/HIGH。 + +- [ ] **Step 4: 收尾提交(如有修正)** + +```bash +git add -A && git commit -m "fix: 视觉核对与 review 修正" +``` + +--- + +## 自审(Spec 覆盖核对) + +- §2.2 范围内逐项 → Phase 1-6 均有任务覆盖(散点/等值面/色阶/叠加开关/异常叠加+表/多Tab)。✓ +- §3 源码契约(Plotly 方块/marching-squares 引擎/4 接口/colorBar/异常)→ Task 3.1(方块)、Phase1(等值面+预处理)、Task 2.1-2.2(接口+解析)。✓ +- §5 组件(ContourBuilder→ContourBands、DatasetChartView、策略、ApiDatasetRepository、AnomalyTablePanel、DatasetDetailPage/Panel、DatasetDetailController)→ 一一对应任务。✓ +- §6 模型映射 → Task 2.1 DTO 解析 + 单测。✓ +- §7 数据流/Tab/异常联动 → Task 5.2/5.3/6.1。✓ +- §7.3 异常归属(右上不动 + 详情底部表)→ 仅新增详情底部表,右上面板不触及。✓ +- §8 混合渲染(上采样+平滑+banded+凸包裁剪+简化)→ Task 1.2-1.4。✓ +- §9 错误/空态 → Task 4.2 loadFailed、Task 4.1 注册表降级、空数据安全(ContourBands 退化返回空)。✓ +- §10 测试 → Task 1.x/2.1/4.1/7.1 单元+集成。✓ + +**命名一致性核对**:`buildContourBands`/`ContourBandsResult`/`ContourOptions`、`DatasetChartView::{showScatter,showContour,setAnomalies,setHiddenAnomalies,setShowAnomalies,setShowContourLines}`、`DatasetDetailController::{openDataset,focusDataset,chartReady,focusRequested,loadFailed,ChartData}`、`DatasetDetailPanel::{openOrUpdate,focusDataset,activeDatasetChanged}` 跨任务一致。✓ + +**已知留白(实现时据现网/视觉微调,非阻塞)**: +- Task 2.2 `queryException` 的 `data` 包裹层(`value` 数组 vs 直接数组)以现网响应为准。 +- Task 3.2 坐标轴/图例像素布局据视觉对照微调。 +- Task 5.2 异常表「创建时间/备注」列需从 `queryException` VO 透传(首版传空,后续在 ChartData 携带原始 createTime/remark)。 From 467dbb3841de1de393e7626e98e41b088c8711dd Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:30:52 +0800 Subject: [PATCH 03/74] =?UTF-8?q?chore(scripts):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=9E=84=E5=BB=BA/=E6=B5=8B=E8=AF=95=E5=B0=81=E8=A3=85(vcvars+?= =?UTF-8?q?VS=20cmake/ctest,=20build/release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/dev-build.ps1 | 8 ++++++++ scripts/dev-test.ps1 | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 scripts/dev-build.ps1 create mode 100644 scripts/dev-test.ps1 diff --git a/scripts/dev-build.ps1 b/scripts/dev-build.ps1 new file mode 100644 index 0000000..cd44eab --- /dev/null +++ b/scripts/dev-build.ps1 @@ -0,0 +1,8 @@ +# 本地开发构建脚本(封装 VS MSVC 环境 + VS 自带 cmake,构建 build/release)。 +# 用法(在仓库根,用 PowerShell 工具运行): pwsh -File scripts\dev-build.ps1 +$ErrorActionPreference = 'Stop' +$vcvars = 'D:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat' +$cmake = 'D:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +$bdir = 'D:\Git\lanbingtech\geopro\build\release' +cmd /c "`"$vcvars`" >nul 2>&1 && `"$cmake`" --build `"$bdir`"" +exit $LASTEXITCODE diff --git a/scripts/dev-test.ps1 b/scripts/dev-test.ps1 new file mode 100644 index 0000000..d977b11 --- /dev/null +++ b/scripts/dev-test.ps1 @@ -0,0 +1,11 @@ +# 本地开发测试脚本(VS 自带 ctest 跑 build/release,可选 -Filter 按用例名过滤)。 +# 用法: pwsh -File scripts\dev-test.ps1 # 全量 +# pwsh -File scripts\dev-test.ps1 ContourBands # 仅匹配 ContourBands.* 的用例 +param([string]$Filter = '') +$ErrorActionPreference = 'Stop' +$ctest = 'D:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\ctest.exe' +$bdir = 'D:\Git\lanbingtech\geopro\build\release' +$cargs = @('--test-dir', $bdir, '--output-on-failure') +if ($Filter) { $cargs += @('-R', $Filter) } +& $ctest @cargs +exit $LASTEXITCODE From 179f46b42c3a65f63c59dfe9cd3dad9fbead5cda Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:34:11 +0800 Subject: [PATCH 04/74] =?UTF-8?q?feat(core):=20Grid=20=E5=A2=9E=20NaN=20?= =?UTF-8?q?=E7=BA=A6=E5=AE=9A=20+=20hasValue=EF=BC=88=E5=87=B8=E5=8C=85?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/model/Field.hpp | 3 +++ tests/core/test_model_data.cpp | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/core/model/Field.hpp b/src/core/model/Field.hpp index e7c2a2b..6e1d65f 100644 --- a/src/core/model/Field.hpp +++ b/src/core/model/Field.hpp @@ -1,6 +1,7 @@ #pragma once #include #include +#include namespace geopro::core { // 规则三维标量场(IInterpolator 输出;render 层转 vtkImageData)。 @@ -24,12 +25,14 @@ private: }; // 规则二维网格剖面(x=距离 i, y=深度 j)。values 存 [j*nx + i],i 最快。 +// NaN 值=无效/测区外(凸包裁剪据此)。 class Grid { public: Grid(int nx, int ny) : nx_(nx), ny_(ny), values_(static_cast(nx) * ny, 0.0) {} int nx() const { return nx_; } int ny() const { return ny_; } double& valueAt(int i, int j) { return values_[static_cast(j) * nx_ + i]; } double valueAt(int i, int j) const { return values_[static_cast(j) * nx_ + i]; } + bool hasValue(int i, int j) const { return !std::isnan(valueAt(i, j)); } const std::vector& values() const { return values_; } std::vector& values() { return values_; } std::vector x, y; // 轴坐标(规则) diff --git a/tests/core/test_model_data.cpp b/tests/core/test_model_data.cpp index 701ae4a..51e99d7 100644 --- a/tests/core/test_model_data.cpp +++ b/tests/core/test_model_data.cpp @@ -23,3 +23,12 @@ TEST(DataModel, AnomalyHolds) { EXPECT_EQ(a.markType, AnomalyMarkType::Polyline); EXPECT_EQ(a.localPts.size(), 2u); } + +#include +TEST(GridNaN, HasValueReflectsNaN) { + geopro::core::Grid g(2, 2); + g.valueAt(0, 0) = 3.0; + g.valueAt(1, 0) = std::nan(""); + EXPECT_TRUE(g.hasValue(0, 0)); + EXPECT_FALSE(g.hasValue(1, 0)); +} From f1ad4900964bf0b83ab793d2d414089d95c544dd Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:37:09 +0800 Subject: [PATCH 05/74] =?UTF-8?q?feat(render):=20ContourBands=20=E4=BB=8E?= =?UTF-8?q?=20VTK=20banded=20=E6=8F=90=E5=8F=96=E8=89=B2=E5=B8=A6=E5=A4=9A?= =?UTF-8?q?=E8=BE=B9=E5=BD=A2+=E7=AD=89=E5=80=BC=E7=BA=BF=E5=87=A0?= =?UTF-8?q?=E4=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/render/CMakeLists.txt | 2 +- src/render/ContourBands.cpp | 100 ++++++++++++++++++++++++++++ src/render/ContourBands.hpp | 32 +++++++++ tests/CMakeLists.txt | 1 + tests/render/test_contour_bands.cpp | 22 ++++++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/render/ContourBands.cpp create mode 100644 src/render/ContourBands.hpp create mode 100644 tests/render/test_contour_bands.cpp diff --git a/src/render/CMakeLists.txt b/src/render/CMakeLists.txt index 9250e21..0d25920 100644 --- a/src/render/CMakeLists.txt +++ b/src/render/CMakeLists.txt @@ -1,7 +1,7 @@ find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 InteractionStyle InteractionWidgets IOImage) find_package(GDAL CONFIG REQUIRED) add_library(geopro_render STATIC - Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp) + Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL) target_compile_features(geopro_render PUBLIC cxx_std_17) diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp new file mode 100644 index 0000000..8840013 --- /dev/null +++ b/src/render/ContourBands.cpp @@ -0,0 +1,100 @@ +#include "ContourBands.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace geopro::render { +using geopro::core::Grid; +using geopro::core::ColorScale; +using geopro::core::Vec2; +using geopro::core::Rgba; + +namespace { +// 用 Grid 构 vtkStructuredGrid(点 (x[i], y[j], 0);NaN 值置 0 但记录于掩膜—— +// 首版裁剪在 Task 1.4 接入,这里先全量)。 +vtkSmartPointer toStructuredGrid(const Grid& g) { + const int nx = g.nx(), ny = g.ny(); + auto sg = vtkSmartPointer::New(); + sg->SetDimensions(nx, ny, 1); + vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); + vtkNew sc; sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny); + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(j) * nx + i; + pts->SetPoint(id, g.x[i], g.y[j], 0.0); + const double v = g.valueAt(i, j); + sc->SetValue(id, std::isnan(v) ? 0.0 : v); + } + sg->SetPoints(pts); + sg->GetPointData()->SetScalars(sc); + return sg; +} +} // namespace + +ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions&) { + ContourBandsResult out; + const int nx = g.nx(), ny = g.ny(); + if (nx < 2 || ny < 2 || g.x.size() < 2 || g.y.size() < 2) return out; + + const std::vector stops = cs.stopValues(); + if (stops.size() < 2) return out; + + auto sg = toStructuredGrid(g); + vtkNew surf; surf->SetInputData(sg); + vtkNew banded; + banded->SetInputConnection(surf->GetOutputPort()); + banded->SetNumberOfContours(static_cast(stops.size())); + for (int i = 0; i < static_cast(stops.size()); ++i) banded->SetValue(i, stops[i]); + banded->GenerateContourEdgesOn(); + banded->SetScalarModeToValue(); + banded->Update(); + + // port0:色带多边形(cell 标量=带的代表值)→ 按 ColorScale 上色。 + vtkPolyData* poly = banded->GetOutput(); + vtkDataArray* cellScalars = poly->GetCellData()->GetScalars(); + const vtkIdType nCells = poly->GetNumberOfCells(); + for (vtkIdType c = 0; c < nCells; ++c) { + vtkCell* cell = poly->GetCell(c); + vtkPoints* cp = cell->GetPoints(); + if (cp->GetNumberOfPoints() < 3) continue; + BandPolygon bp; + const double val = cellScalars ? cellScalars->GetTuple1(c) : 0.0; + bp.color = cs.colorAt(val); + for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { + double xyz[3]; cp->GetPoint(p, xyz); + bp.ring.push_back(Vec2{xyz[0], xyz[1]}); + } + out.bands.push_back(std::move(bp)); + } + + // port1:等值线(polylines)。 + vtkPolyData* edges = banded->GetContourEdgesOutput(); + if (edges) { + edges->BuildCells(); + const vtkIdType nLines = edges->GetNumberOfCells(); + for (vtkIdType c = 0; c < nLines; ++c) { + vtkCell* cell = edges->GetCell(c); + vtkPoints* cp = cell->GetPoints(); + ContourLine cl; cl.level = 0.0; + for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { + double xyz[3]; cp->GetPoint(p, xyz); + cl.pts.push_back(Vec2{xyz[0], xyz[1]}); + } + if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); + } + } + return out; +} +} // namespace geopro::render diff --git a/src/render/ContourBands.hpp b/src/render/ContourBands.hpp new file mode 100644 index 0000000..6b6a642 --- /dev/null +++ b/src/render/ContourBands.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::render { + +struct BandPolygon { + geopro::core::Rgba color; + std::vector ring; // 单环多边形(局部坐标,y=高程向上为正) +}; +struct ContourLine { + double level; + std::vector pts; +}; +struct ContourBandsResult { + std::vector bands; + std::vector lines; +}; +struct ContourOptions { + int upsample = 2; // 双线性上采样倍数(1=不采样) + double smooth = 0.3; // 平滑强度 0..1(0=不平滑) + double simplifyTol = 0.5; // 等值线简化容差(数据单位,0=不简化) + bool makeLines = true; +}; + +// core::Grid(NaN=无效)+ ColorScale 分段 → 色带多边形 + 等值线几何。 +// VTK 仅作算法(vtkBandedPolyDataContourFilter),无 render window。 +ContourBandsResult buildContourBands(const geopro::core::Grid& g, + const geopro::core::ColorScale& cs, + const ContourOptions& opt = {}); +} // namespace geopro::render diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7296aaf..bbb4d4b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -61,6 +61,7 @@ endif() # 需 vtkLookupTable(VTK::CommonCore);geopro_render 已 PUBLIC 传递其余 VTK 组件。 find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore) target_sources(geopro_tests PRIVATE render/test_color_lut.cpp) +target_sources(geopro_tests PRIVATE render/test_contour_bands.cpp) # dd_voxel:buildVoxel(ScalarVolume->vtkImageData->GPU 体绘制) 构建不崩 + dims 正确。 target_sources(geopro_tests PRIVATE render/test_voxel_build.cpp) # dd_voxel 回归:buildVoxelFromScatters(散点 projX/Y -EPSG:4547-> 世界系 + IDW) 配准+充填(需 PROJ_DATA)。 diff --git a/tests/render/test_contour_bands.cpp b/tests/render/test_contour_bands.cpp new file mode 100644 index 0000000..c844cc9 --- /dev/null +++ b/tests/render/test_contour_bands.cpp @@ -0,0 +1,22 @@ +#include +#include "ContourBands.hpp" +using namespace geopro::core; +using namespace geopro::render; + +// 2x2 平滑梯度网格 + 2 段色阶 → 至少 1 个色带多边形,多边形顶点 >=3。 +TEST(ContourBands, ProducesNonEmptyBands) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast(i + j); // 0..4 + g.vmin = 0; g.vmax = 4; + ColorScale cs; + cs.addStop(0.0, Rgba{0, 0, 255, 255}); + cs.addStop(2.0, Rgba{0, 255, 0, 255}); + cs.addStop(4.0, Rgba{255, 0, 0, 255}); + + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + auto r = buildContourBands(g, cs, opt); + ASSERT_FALSE(r.bands.empty()); + for (const auto& b : r.bands) EXPECT_GE(b.ring.size(), 3u); +} From b7e0a2034dbf4b442a1b6b7635f6b4671456a330 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:39:05 +0800 Subject: [PATCH 06/74] =?UTF-8?q?feat(render):=20ContourBands=20=E5=8F=8C?= =?UTF-8?q?=E7=BA=BF=E6=80=A7=E4=B8=8A=E9=87=87=E6=A0=B7+=E7=9B=92?= =?UTF-8?q?=E5=BC=8F=E5=B9=B3=E6=BB=91=E9=A2=84=E5=A4=84=E7=90=86=EF=BC=88?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20web=202x+smooth=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/render/ContourBands.cpp | 54 +++++++++++++++++++++++++++-- tests/render/test_contour_bands.cpp | 16 +++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 8840013..817535e 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -21,6 +21,55 @@ using geopro::core::Vec2; using geopro::core::Rgba; namespace { + +// 双线性上采样:nx×ny → ((nx-1)*k+1)×((ny-1)*k+1)。NaN 单元参与的目标点置 NaN。 +Grid upsampleBilinear(const Grid& g, int k) { + if (k <= 1) return g; + const int nx = g.nx(), ny = g.ny(); + const int Nx = (nx - 1) * k + 1, Ny = (ny - 1) * k + 1; + Grid out(Nx, Ny); + out.x.resize(Nx); out.y.resize(Ny); + for (int I = 0; I < Nx; ++I) { double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2); + double t = fi - i0; out.x[I] = g.x[i0] * (1 - t) + g.x[i0 + 1] * t; } + for (int J = 0; J < Ny; ++J) { double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2); + double t = fj - j0; out.y[J] = g.y[j0] * (1 - t) + g.y[j0 + 1] * t; } + for (int J = 0; J < Ny; ++J) { + double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2); double tj = fj - j0; + for (int I = 0; I < Nx; ++I) { + double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2); double ti = fi - i0; + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + out.valueAt(I, J) = std::nan(""); + else + out.valueAt(I, J) = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + + (v01 * (1 - ti) + v11 * ti) * tj; + } + } + out.vmin = g.vmin; out.vmax = g.vmax; + return out; +} + +// 3x3 盒式平滑(NaN 跳过);strength 0..1 线性混合原值。 +Grid smoothGrid(const Grid& g, double strength) { + if (strength <= 0) return g; + const int nx = g.nx(), ny = g.ny(); + Grid out = g; + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + double v = g.valueAt(i, j); + if (std::isnan(v)) continue; + double sum = 0; int n = 0; + for (int dj = -1; dj <= 1; ++dj) for (int di = -1; di <= 1; ++di) { + int ii = i + di, jj = j + dj; + if (ii < 0 || ii >= nx || jj < 0 || jj >= ny) continue; + double w = g.valueAt(ii, jj); if (std::isnan(w)) continue; sum += w; ++n; + } + if (n) out.valueAt(i, j) = v * (1 - strength) + (sum / n) * strength; + } + return out; +} + // 用 Grid 构 vtkStructuredGrid(点 (x[i], y[j], 0);NaN 值置 0 但记录于掩膜—— // 首版裁剪在 Task 1.4 接入,这里先全量)。 vtkSmartPointer toStructuredGrid(const Grid& g) { @@ -43,7 +92,7 @@ vtkSmartPointer toStructuredGrid(const Grid& g) { } } // namespace -ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions&) { +ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions& opt) { ContourBandsResult out; const int nx = g.nx(), ny = g.ny(); if (nx < 2 || ny < 2 || g.x.size() < 2 || g.y.size() < 2) return out; @@ -51,7 +100,8 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const const std::vector stops = cs.stopValues(); if (stops.size() < 2) return out; - auto sg = toStructuredGrid(g); + Grid work = smoothGrid(upsampleBilinear(g, std::max(1, opt.upsample)), opt.smooth); + auto sg = toStructuredGrid(work); vtkNew surf; surf->SetInputData(sg); vtkNew banded; banded->SetInputConnection(surf->GetOutputPort()); diff --git a/tests/render/test_contour_bands.cpp b/tests/render/test_contour_bands.cpp index c844cc9..7994241 100644 --- a/tests/render/test_contour_bands.cpp +++ b/tests/render/test_contour_bands.cpp @@ -3,6 +3,22 @@ using namespace geopro::core; using namespace geopro::render; +// 上采样 2x:色带边界更密 → 多边形数应多于不上采样。 +TEST(ContourBands, UpsampleIncreasesDetail) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast(i * i + j); + g.vmin = 0; g.vmax = 6; + ColorScale cs; + cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(3, Rgba{0,255,0,255}); cs.addStop(6, Rgba{255,0,0,255}); + ContourOptions a; a.upsample = 1; a.smooth = 0; a.simplifyTol = 0; + ContourOptions b; b.upsample = 2; b.smooth = 0; b.simplifyTol = 0; + auto ra = buildContourBands(g, cs, a); + auto rb = buildContourBands(g, cs, b); + EXPECT_GT(rb.bands.size(), ra.bands.size()); +} + // 2x2 平滑梯度网格 + 2 段色阶 → 至少 1 个色带多边形,多边形顶点 >=3。 TEST(ContourBands, ProducesNonEmptyBands) { Grid g(3, 3); From 00c42f7a8dc044fcb8d374713e07ad532eded8fd Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:41:26 +0800 Subject: [PATCH 07/74] =?UTF-8?q?feat(render):=20ContourBands=20NaN=20?= =?UTF-8?q?=E5=87=B8=E5=8C=85=E8=A3=81=E5=89=AA(=E5=89=94=E9=99=A4?= =?UTF-8?q?=E6=97=A0=E6=95=88quad)+=E7=AD=89=E5=80=BC=E7=BA=BFDP=E7=AE=80?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/render/ContourBands.cpp | 62 ++++++++++++++++++++--------- tests/render/test_contour_bands.cpp | 17 ++++++++ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 817535e..894ed34 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -10,7 +11,7 @@ #include #include #include -#include +#include #include @@ -70,26 +71,49 @@ Grid smoothGrid(const Grid& g, double strength) { return out; } -// 用 Grid 构 vtkStructuredGrid(点 (x[i], y[j], 0);NaN 值置 0 但记录于掩膜—— -// 首版裁剪在 Task 1.4 接入,这里先全量)。 -vtkSmartPointer toStructuredGrid(const Grid& g) { +// 把含任一 NaN 顶点的网格单元(quad)从网格中剔除→凸包裁剪。 +vtkSmartPointer toCellGrid(const Grid& g) { const int nx = g.nx(), ny = g.ny(); - auto sg = vtkSmartPointer::New(); - sg->SetDimensions(nx, ny, 1); + auto ug = vtkSmartPointer::New(); vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); vtkNew sc; sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny); - for (int j = 0; j < ny; ++j) - for (int i = 0; i < nx; ++i) { - const vtkIdType id = static_cast(j) * nx + i; - pts->SetPoint(id, g.x[i], g.y[j], 0.0); - const double v = g.valueAt(i, j); - sc->SetValue(id, std::isnan(v) ? 0.0 : v); - } - sg->SetPoints(pts); - sg->GetPointData()->SetScalars(sc); - return sg; + for (int j = 0; j < ny; ++j) for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(j) * nx + i; + pts->SetPoint(id, g.x[i], g.y[j], 0.0); + const double v = g.valueAt(i, j); + sc->SetValue(id, std::isnan(v) ? 0.0 : v); + } + ug->SetPoints(pts); + ug->GetPointData()->SetScalars(sc); + auto idAt = [nx](int i, int j) { return static_cast(j) * nx + i; }; + for (int j = 0; j < ny - 1; ++j) for (int i = 0; i < nx - 1; ++i) { + if (!g.hasValue(i, j) || !g.hasValue(i+1, j) || !g.hasValue(i, j+1) || !g.hasValue(i+1, j+1)) + continue; // 含 NaN 的 quad 不入网格 → 凸包裁剪 + vtkIdType quad[4] = { idAt(i,j), idAt(i+1,j), idAt(i+1,j+1), idAt(i,j+1) }; + ug->InsertNextCell(VTK_QUAD, 4, quad); + } + return ug; } + +// Douglas-Peucker 等值线简化(就地)。 +void simplifyInPlace(std::vector& pts, double tol) { + if (tol <= 0 || pts.size() < 3) return; + std::vector keep(pts.size(), 0); keep.front() = keep.back() = 1; + std::vector> st{{0, int(pts.size()) - 1}}; + auto dist = [](const Vec2& p, const Vec2& a, const Vec2& b) { + double dx = b.x - a.x, dy = b.y - a.y, L = std::hypot(dx, dy); + if (L < 1e-12) return std::hypot(p.x - a.x, p.y - a.y); + return std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx) / L; }; + while (!st.empty()) { auto [s, e] = st.back(); st.pop_back(); + double dmax = 0; int idx = -1; + for (int k = s + 1; k < e; ++k) { double d = dist(pts[k], pts[s], pts[e]); + if (d > dmax) { dmax = d; idx = k; } } + if (idx >= 0 && dmax > tol) { keep[idx] = 1; st.push_back({s, idx}); st.push_back({idx, e}); } } + std::vector r; for (size_t k = 0; k < pts.size(); ++k) if (keep[k]) r.push_back(pts[k]); + pts.swap(r); +} + } // namespace ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions& opt) { @@ -101,8 +125,7 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const if (stops.size() < 2) return out; Grid work = smoothGrid(upsampleBilinear(g, std::max(1, opt.upsample)), opt.smooth); - auto sg = toStructuredGrid(work); - vtkNew surf; surf->SetInputData(sg); + vtkNew surf; surf->SetInputData(toCellGrid(work)); vtkNew banded; banded->SetInputConnection(surf->GetOutputPort()); banded->SetNumberOfContours(static_cast(stops.size())); @@ -129,7 +152,7 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const out.bands.push_back(std::move(bp)); } - // port1:等值线(polylines)。 + // port1:等值线(polylines)+ DP 简化。 vtkPolyData* edges = banded->GetContourEdgesOutput(); if (edges) { edges->BuildCells(); @@ -142,6 +165,7 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const double xyz[3]; cp->GetPoint(p, xyz); cl.pts.push_back(Vec2{xyz[0], xyz[1]}); } + simplifyInPlace(cl.pts, opt.simplifyTol); if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); } } diff --git a/tests/render/test_contour_bands.cpp b/tests/render/test_contour_bands.cpp index 7994241..c4b7b48 100644 --- a/tests/render/test_contour_bands.cpp +++ b/tests/render/test_contour_bands.cpp @@ -19,6 +19,23 @@ TEST(ContourBands, UpsampleIncreasesDetail) { EXPECT_GT(rb.bands.size(), ra.bands.size()); } +// 含 NaN 无效角的网格:裁剪后该角不应被任何色带覆盖(所有多边形顶点远离该角)。 +TEST(ContourBands, ClipsNaNRegion) { + Grid g(3, 3); + g.x = {0, 1, 2}; g.y = {0, 1, 2}; + for (int j = 0; j < 3; ++j) for (int i = 0; i < 3; ++i) g.valueAt(i, j) = 5.0; + g.valueAt(2, 2) = std::nan(""); // 右上角无效 + g.vmin = 0; g.vmax = 10; + ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(10, Rgba{255,0,0,255}); + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + auto r = buildContourBands(g, cs, opt); + bool coversCorner = false; + for (const auto& b : r.bands) + for (const auto& p : b.ring) + if (p.x > 1.5 && p.y > 1.5) coversCorner = true; + EXPECT_FALSE(coversCorner); +} + // 2x2 平滑梯度网格 + 2 段色阶 → 至少 1 个色带多边形,多边形顶点 >=3。 TEST(ContourBands, ProducesNonEmptyBands) { Grid g(3, 3); From e1bbf39d6db8c04caa11958c4b03dc59e881b86a Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:50:08 +0800 Subject: [PATCH 08/74] =?UTF-8?q?feat(data):=20DatasetChartDto=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20inversion=E7=BD=91=E6=A0=BC/=E6=95=A3=E7=82=B9/colo?= =?UTF-8?q?rBar/=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 1 + src/data/dto/DatasetChartDto.cpp | 74 +++++++++++++++++++++++++++ src/data/dto/DatasetChartDto.hpp | 19 +++++++ tests/CMakeLists.txt | 1 + tests/data/test_dataset_chart_dto.cpp | 42 +++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 src/data/dto/DatasetChartDto.cpp create mode 100644 src/data/dto/DatasetChartDto.hpp create mode 100644 tests/data/test_dataset_chart_dto.cpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 803e557..4e0aa60 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(geopro_data STATIC parse/SampleParsers.cpp repo/LocalSampleRepository.cpp dto/NavDto.cpp + dto/DatasetChartDto.cpp api/ApiProjectRepository.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp new file mode 100644 index 0000000..6d20cdf --- /dev/null +++ b/src/data/dto/DatasetChartDto.cpp @@ -0,0 +1,74 @@ +#include "dto/DatasetChartDto.hpp" +#include +#include +namespace geopro::data::dto { +using namespace geopro::core; + +static double num(const QJsonValue& v, double def = 0.0) { + if (v.isDouble()) return v.toDouble(); + if (v.isString()) { bool ok = false; double d = v.toString().toDouble(&ok); return ok ? d : def; } + return def; +} + +Grid parseInversionGrid(const QJsonObject& data) { + const QJsonArray x = data.value("x").toArray(); + const QJsonArray y = data.value("y").toArray(); + const QJsonArray v = data.value("v").toArray(); // [ny][nx] + const int nx = x.size(), ny = y.size(); + Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny); + g.x.clear(); for (auto e : x) g.x.push_back(num(e)); + g.y.clear(); for (auto e : y) g.y.push_back(num(e)); + for (int j = 0; j < ny; ++j) { + const QJsonArray row = v.at(j).toArray(); + for (int i = 0; i < nx; ++i) { + const QJsonValue cell = row.at(i); + g.valueAt(i, j) = (cell.isNull() || cell.isUndefined()) ? std::nan("") : num(cell, std::nan("")); + } + } + g.vmin = num(data.value("vmin")); g.vmax = num(data.value("vmax")); + return g; +} + +ScatterField parseScatterGraph(const QJsonObject& data) { + ScatterField s; + auto fill = [&](const char* key, std::vector& dst) { + for (auto e : data.value(key).toArray()) dst.push_back(num(e)); }; + fill("xlist", s.x); fill("ylist", s.y); fill("hlist", s.z); fill("vlist", s.v); + fill("projectXList", s.projX); fill("projectYList", s.projY); + return s; +} + +ColorScale parseColorBar(const QJsonObject& data) { + ColorScale cs; + const QJsonArray bar = data.value("properties").toObject().value("colorBar").toArray(); + for (auto e : bar) { + const QJsonArray pair = e.toArray(); + if (pair.size() < 2) continue; + const double val = num(pair.at(0)); + const std::string rgba = pair.at(1).toString().toStdString(); + cs.addStop(val, parseColor(rgba, AlphaScale::Bit255)); + } + return cs; +} + +std::vector parseDatasetAnomalies(const QJsonArray& arr) { + std::vector out; + for (auto e : arr) { + const QJsonObject o = e.toObject(); + Anomaly a; + a.name = o.value("exceptionName").toString().toStdString(); + a.typeName = o.value("exceptionTypeName").toString().toStdString(); + a.markType = static_cast(o.value("exceptionMarkType").toInt(2)); + const QJsonObject lg = o.value("legend").toObject(); + a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); + a.lineWidth = lg.value("polylineWidth").toDouble(1.0); + a.dashed = lg.value("polylineShape").toString() == "dash"; + for (auto c : o.value("location").toObject().value("coordinate").toArray()) { + const QJsonObject p = c.toObject(); + a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()}); + } + out.push_back(std::move(a)); + } + return out; +} +} // namespace geopro::data::dto diff --git a/src/data/dto/DatasetChartDto.hpp b/src/data/dto/DatasetChartDto.hpp new file mode 100644 index 0000000..c06dca5 --- /dev/null +++ b/src/data/dto/DatasetChartDto.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::data::dto { + +// inversion/rows 的 data{ x,y,v[ny][nx],z,elevation,vmin,vmax } → Grid(v 缺省/非数→NaN)。 +geopro::core::Grid parseInversionGrid(const QJsonObject& data); +// getErtRawDataScatterGraph 的 data{ xlist,ylist,hlist,vlist,projectXList,projectYList } → ScatterField。 +geopro::core::ScatterField parseScatterGraph(const QJsonObject& data); +// colorGradation/getDetail 的 data.properties.colorBar [[值,"rgba()"],…] → ColorScale。 +geopro::core::ColorScale parseColorBar(const QJsonObject& data); +// queryException 的 data 数组 → Anomaly[](location.coordinate / legend)。 +std::vector parseDatasetAnomalies(const QJsonArray& arr); + +} // namespace geopro::data::dto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bbb4d4b..6e60a4e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -37,6 +37,7 @@ target_link_libraries(geopro_tests PRIVATE geopro_core) target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package diff --git a/tests/data/test_dataset_chart_dto.cpp b/tests/data/test_dataset_chart_dto.cpp new file mode 100644 index 0000000..876da41 --- /dev/null +++ b/tests/data/test_dataset_chart_dto.cpp @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include "dto/DatasetChartDto.hpp" +using namespace geopro::data::dto; + +static QJsonObject obj(const char* json) { + return QJsonDocument::fromJson(json).object(); +} + +TEST(DatasetChartDto, ParsesInversionGrid) { + auto d = obj(R"({"x":[0,1],"y":[0,1,2],"v":[[1,2],[3,4],[5,6]],"vmin":1,"vmax":6})"); + auto g = parseInversionGrid(d); + EXPECT_EQ(g.nx(), 2); EXPECT_EQ(g.ny(), 3); + EXPECT_DOUBLE_EQ(g.valueAt(1, 2), 6.0); + EXPECT_DOUBLE_EQ(g.vmax, 6.0); +} +TEST(DatasetChartDto, ParsesColorBar) { + // Use "json" delimiter to avoid raw-string termination by ")" inside rgba() + const char* colorBarJson = "{\"properties\":{\"colorBar\":[[\"10\",\"rgba(0,0,255,255)\"],[\"20\",\"rgba(255,0,0,255)\"]]}}"; + auto d = obj(colorBarJson); + auto cs = parseColorBar(d); + auto stops = cs.stopValues(); + ASSERT_EQ(stops.size(), 2u); + EXPECT_DOUBLE_EQ(stops[0], 10.0); + auto c = cs.colorAt(12.0); // [10,20) -> blue + EXPECT_GT(c.b, c.r); +} +TEST(DatasetChartDto, ParsesAnomalyPolyline) { + auto arr = QJsonDocument::fromJson( + R"([{"exceptionName":"A1","exceptionTypeName":"AnomalyZone","exceptionMarkType":2, + "legend":{"polylineColor":"#0D0101","polylineWidth":4,"polylineShape":"dash"}, + "location":{"coordinate":[{"x":1,"y":2},{"x":3,"y":4}]}}])").array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].name, "A1"); + EXPECT_EQ(static_cast(v[0].markType), 2); + ASSERT_EQ(v[0].localPts.size(), 2u); + EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0); + EXPECT_TRUE(v[0].dashed); +} From 3192cf24cf484193c4657a22390a002e3789f616 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:52:05 +0800 Subject: [PATCH 09/74] =?UTF-8?q?feat(data):=20ApiDatasetRepository=20?= =?UTF-8?q?=E6=8E=A5=E7=9C=9F=E5=AE=9E=20API=20+=20loadScatterColorScale?= =?UTF-8?q?=20=E6=8F=90=E5=88=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 3 +- src/data/api/ApiDatasetRepository.cpp | 51 +++++++++++++++++++++++++ src/data/api/ApiDatasetRepository.hpp | 19 +++++++++ src/data/repo/IDatasetRepository.hpp | 1 + src/data/repo/LocalSampleRepository.hpp | 3 +- 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/data/api/ApiDatasetRepository.cpp create mode 100644 src/data/api/ApiDatasetRepository.hpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 4e0aa60..16da104 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -5,7 +5,8 @@ add_library(geopro_data STATIC repo/LocalSampleRepository.cpp dto/NavDto.cpp dto/DatasetChartDto.cpp - api/ApiProjectRepository.cpp) + api/ApiProjectRepository.cpp + api/ApiDatasetRepository.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) target_compile_features(geopro_data PUBLIC cxx_std_17) diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp new file mode 100644 index 0000000..94ebe8a --- /dev/null +++ b/src/data/api/ApiDatasetRepository.cpp @@ -0,0 +1,51 @@ +#include "api/ApiDatasetRepository.hpp" +#include +#include +#include +#include +#include "ApiClient.hpp" +#include "dto/DatasetChartDto.hpp" +namespace geopro::data { +namespace { +QString enc(const std::string& s) { + return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); +} +void must(const net::ApiResponse& r, const char* what) { + if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " + + (r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); +} +geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) { + QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; + const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); + must(r, "colorGradation"); + return dto::parseColorBar(r.data); +} +} // namespace + +ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} + +geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); + must(r, "inversion/rows"); + return dto::parseInversionGrid(r.data); +} +geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); + must(r, "scatterGraph"); + return dto::parseScatterGraph(r.data); +} +geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { + return colorScale(api_, dsId, 2); +} +geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { + return colorScale(api_, dsId, 1); +} +std::vector ApiDatasetRepository::loadAnomalies(const std::string& dsId) { + const net::ApiResponse r = api_.get( + QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); + must(r, "queryException"); + return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray()); +} +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp new file mode 100644 index 0000000..617aaad --- /dev/null +++ b/src/data/api/ApiDatasetRepository.hpp @@ -0,0 +1,19 @@ +#pragma once +#include "repo/IDatasetRepository.hpp" +namespace geopro::net { class ApiClient; } +namespace geopro::data { + +// 真实 API 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。 +class ApiDatasetRepository : public IDatasetRepository { +public: + explicit ApiDatasetRepository(net::ApiClient& api); + std::vector loadStructure() override { return {}; } // 不经此仓储取结构 + geopro::core::Grid loadGrid(const std::string& dsId) override; + geopro::core::ScatterField loadScatter(const std::string& dsId) override; + geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格 + geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据 + std::vector loadAnomalies(const std::string& dsId) override; +private: + net::ApiClient& api_; +}; +} // namespace geopro::data diff --git a/src/data/repo/IDatasetRepository.hpp b/src/data/repo/IDatasetRepository.hpp index 96e3455..e1955c1 100644 --- a/src/data/repo/IDatasetRepository.hpp +++ b/src/data/repo/IDatasetRepository.hpp @@ -15,6 +15,7 @@ public: virtual geopro::core::Grid loadGrid(const std::string& dsId) = 0; virtual geopro::core::ScatterField loadScatter(const std::string& dsId) = 0; virtual geopro::core::ColorScale loadColorScale(const std::string& dsId) = 0; + virtual geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) = 0; virtual std::vector loadAnomalies(const std::string& dsId) = 0; }; diff --git a/src/data/repo/LocalSampleRepository.hpp b/src/data/repo/LocalSampleRepository.hpp index 0d54179..183f612 100644 --- a/src/data/repo/LocalSampleRepository.hpp +++ b/src/data/repo/LocalSampleRepository.hpp @@ -18,8 +18,7 @@ public: std::vector loadAnomalies(const std::string& dsId) override; // 散点原数据(#17)专属色阶:剖面原数据自带的色阶文件(与网格色阶不同:分段值范围更大)。 - // 具体类专有方法(不进 IDatasetRepository 接口);散点点为不透明,alpha 量纲差异无影响。 - geopro::core::ColorScale loadScatterColorScale(const std::string& dsId); + geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // DEM/影像 GeoTIFF 的绝对路径(供 render::buildTerrain 经 GDAL 读)。 std::string demPath() const; From facb812bca8e0d32e84db6afd2ae3979f2f0a433 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 11:57:49 +0800 Subject: [PATCH 10/74] =?UTF-8?q?feat(ui):=20DatasetChartView=20=E6=95=A3?= =?UTF-8?q?=E7=82=B9/=E7=AD=89=E5=80=BC=E9=9D=A2/=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0(QGraphicsView)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/chart/DatasetChartView.cpp | 216 ++++++++++++++++++++++ src/app/panels/chart/DatasetChartView.hpp | 38 ++++ 3 files changed, 255 insertions(+) create mode 100644 src/app/panels/chart/DatasetChartView.cpp create mode 100644 src/app/panels/chart/DatasetChartView.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index db8ac21..2024e52 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -26,6 +26,7 @@ add_executable(geopro_desktop WIN32 panels/ObjectTreePanel.cpp panels/DynamicFormView.cpp panels/ObjectExceptionPanel.cpp + panels/chart/DatasetChartView.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/chart/DatasetChartView.cpp b/src/app/panels/chart/DatasetChartView.cpp new file mode 100644 index 0000000..60ce1ed --- /dev/null +++ b/src/app/panels/chart/DatasetChartView.cpp @@ -0,0 +1,216 @@ +#include "panels/chart/DatasetChartView.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace geopro::app { +using geopro::core::Rgba; + +static QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } + +DatasetChartView::DatasetChartView(QWidget* parent) + : QGraphicsView(parent), scene_(new QGraphicsScene(this)) { + setScene(scene_); + setRenderHint(QPainter::Antialiasing, true); + setDragMode(QGraphicsView::ScrollHandDrag); + // 场景 y 向上为正:用 y 翻转的变换(QGraphicsView 默认 y 向下)。 + scale(1, -1); +} + +void DatasetChartView::clearChart() { + scene_->clear(); anomalyItems_.clear(); +} + +void DatasetChartView::showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs) { + clearChart(); + const double sz = 0.6; // 方块边长(数据单位,近似 web 方点) + for (size_t i = 0; i < f.v.size(); ++i) { + auto* r = scene_->addRect(f.x[i] - sz / 2, f.y[i] - sz / 2, sz, sz, + QPen(Qt::white, 0), QBrush(toQ(cs.colorAt(f.v[i])))); + r->setPen(QPen(Qt::white, 0)); // 白描边 + } + rebuildAnomalyItems(); + setLegend(cs); + viewport()->update(); + fitInView(scene_->itemsBoundingRect(), Qt::KeepAspectRatio); // x:y 等比 +} + +void DatasetChartView::showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, + const geopro::render::ContourOptions& opt) { + clearChart(); + const auto r = geopro::render::buildContourBands(g, cs, opt); + for (const auto& b : r.bands) { + QPainterPath path; if (b.ring.empty()) continue; + path.moveTo(b.ring[0].x, b.ring[0].y); + for (size_t k = 1; k < b.ring.size(); ++k) path.lineTo(b.ring[k].x, b.ring[k].y); + path.closeSubpath(); + auto* it = scene_->addPath(path, QPen(Qt::NoPen), QBrush(toQ(b.color))); + it->setZValue(0); + } + if (showContourLines_) + for (const auto& l : r.lines) { + if (l.pts.empty()) continue; + QPainterPath path; path.moveTo(l.pts[0].x, l.pts[0].y); + for (size_t k = 1; k < l.pts.size(); ++k) path.lineTo(l.pts[k].x, l.pts[k].y); + auto* it = scene_->addPath(path, QPen(QColor(0, 0, 0), 0)); + it->setZValue(1); + } + rebuildAnomalyItems(); + setLegend(cs); + viewport()->update(); + fitInView(scene_->itemsBoundingRect(), Qt::IgnoreAspectRatio); // 剖面 X/Y 各自铺满 +} + +void DatasetChartView::setAnomalies(const std::vector& list) { + anomalies_ = list; rebuildAnomalyItems(); +} +void DatasetChartView::setHiddenAnomalies(const std::set& hidden) { hidden_ = hidden; rebuildAnomalyItems(); } +void DatasetChartView::setShowAnomalies(bool on) { showAnomalies_ = on; rebuildAnomalyItems(); } +void DatasetChartView::setShowContourLines(bool on) { showContourLines_ = on; } + +void DatasetChartView::rebuildAnomalyItems() { + for (auto* it : anomalyItems_) { scene_->removeItem(it); delete it; } + anomalyItems_.clear(); + if (!showAnomalies_) return; + for (int i = 0; i < static_cast(anomalies_.size()); ++i) { + if (hidden_.count(i)) continue; + const auto& a = anomalies_[i]; + if (a.localPts.size() < 2) continue; + QPainterPath path; path.moveTo(a.localPts[0].x, a.localPts[0].y); + for (size_t k = 1; k < a.localPts.size(); ++k) path.lineTo(a.localPts[k].x, a.localPts[k].y); + if (static_cast(a.markType) == 3) path.closeSubpath(); + QPen pen(QColor(QString::fromStdString(a.lineColor)), 0); + if (a.dashed) pen.setStyle(Qt::DashLine); + auto* it = scene_->addPath(path, pen); + it->setZValue(2); + anomalyItems_.push_back(it); + } +} + +void DatasetChartView::wheelEvent(QWheelEvent* e) { + const double f = e->angleDelta().y() > 0 ? 1.15 : 1 / 1.15; + scale(f, f); +} + +// Task 3.2:坐标轴 overlay + 底部色阶图例。 +// drawForeground 在每次 repaint 时调用(缩放/平移后自动重绘),无需手动更新。 +// 轴线"钉"在 viewport 左/下边:以 viewport()->rect() 为参考, +// 用 mapToScene / mapFromScene 把场景范围映射到像素位置。 +void DatasetChartView::drawForeground(QPainter* p, const QRectF& /*rect*/) { + if (legendScale_.empty()) return; + + p->save(); + // 重置变换,后续全用视口像素坐标绘制 + p->resetTransform(); + + const QRect vr = viewport()->rect(); + const int W = vr.width(); + const int H = vr.height(); + + // ── 色阶图例(底部)──────────────────────────────── + const int legendH = 16; // 色条高度(px) + const int legendMarginB = 20; // 底部文字留白 + const int legendMarginL = 50; // 左侧轴标签留白 + const int legendMarginR = 10; + const int legendY = H - legendMarginB - legendH; + const int legendW = W - legendMarginL - legendMarginR; + + const std::vector stops = legendScale_.stopValues(); + if (stops.size() >= 2 && legendW > 0) { + const int nSeg = static_cast(stops.size()) - 1; + const double segW = static_cast(legendW) / nSeg; + for (int s = 0; s < nSeg; ++s) { + // 取该段中点值的颜色(与 web 阶梯色一致) + const double midVal = (stops[s] + stops[s + 1]) * 0.5; + const QColor c = toQ(legendScale_.colorAt(midVal)); + const int x0 = legendMarginL + static_cast(s * segW); + const int x1 = legendMarginL + static_cast((s + 1) * segW); + p->fillRect(QRect(x0, legendY, x1 - x0, legendH), c); + } + // 边框 + p->setPen(QPen(QColor(80, 80, 80), 1)); + p->drawRect(QRect(legendMarginL, legendY, legendW, legendH)); + + // 分段值文字(在每个分割点下方) + p->setPen(Qt::black); + QFont font = p->font(); + font.setPixelSize(10); + p->setFont(font); + for (int s = 0; s <= nSeg; ++s) { + const int xTick = legendMarginL + static_cast(s * segW); + const QString label = QString::number(stops[s], 'g', 4); + QRect textR(xTick - 20, legendY + legendH + 2, 40, 14); + p->drawText(textR, Qt::AlignCenter, label); + } + } + + // ── 坐标轴(左/下边钉住视口)──────────────────────── + // 把视口四个角映射到场景,得到场景可见范围 + const QPointF sceneTL = mapToScene(vr.topLeft()); + const QPointF sceneBR = mapToScene(vr.bottomRight()); + + // 因为 y 轴翻转(scale(1,-1)),场景坐标 y 向上为正 + // sceneTL 的 scene-y 可能大于 sceneBR 的 scene-y,需取 min/max + const double sceneXMin = qMin(sceneTL.x(), sceneBR.x()); + const double sceneXMax = qMax(sceneTL.x(), sceneBR.x()); + const double sceneYMin = qMin(sceneTL.y(), sceneBR.y()); + const double sceneYMax = qMax(sceneTL.y(), sceneBR.y()); + + const int axisLeft = legendMarginL; // 左轴 x 像素 + const int axisBottom = legendY - 4; // 下轴 y 像素(色阶条上方留 4px) + + p->setPen(QPen(QColor(100, 100, 100), 1)); + + // 左侧轴线(竖) + p->drawLine(axisLeft, 4, axisLeft, axisBottom); + // 下轴线(横) + p->drawLine(axisLeft, axisBottom, W - legendMarginR, axisBottom); + + // 刻度数(6~8 个) + const int nTick = 7; + QFont tickFont = p->font(); + tickFont.setPixelSize(10); + p->setFont(tickFont); + + // X 轴刻度(下轴) + const double xRange = sceneXMax - sceneXMin; + if (xRange > 0) { + for (int t = 0; t <= nTick; ++t) { + const double sx = sceneXMin + xRange * t / nTick; + const QPoint vp = mapFromScene(QPointF(sx, sceneYMin)); // 场景 → 视口像素 + const int px = vp.x(); + if (px < axisLeft || px > W - legendMarginR) continue; + p->setPen(QPen(QColor(100, 100, 100), 1)); + p->drawLine(px, axisBottom, px, axisBottom + 4); + p->setPen(Qt::black); + const QString label = QString::number(sx, 'g', 4); + p->drawText(QRect(px - 20, axisBottom + 5, 40, 12), Qt::AlignCenter, label); + } + } + + // Y 轴刻度(左轴) + const double yRange = sceneYMax - sceneYMin; + if (yRange > 0) { + for (int t = 0; t <= nTick; ++t) { + const double sy = sceneYMin + yRange * t / nTick; + const QPoint vp = mapFromScene(QPointF(sceneXMin, sy)); + const int py = vp.y(); + if (py < 4 || py > axisBottom) continue; + p->setPen(QPen(QColor(100, 100, 100), 1)); + p->drawLine(axisLeft - 4, py, axisLeft, py); + p->setPen(Qt::black); + const QString label = QString::number(sy, 'g', 4); + p->drawText(QRect(0, py - 7, axisLeft - 5, 14), Qt::AlignRight | Qt::AlignVCenter, label); + } + } + + p->restore(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/DatasetChartView.hpp b/src/app/panels/chart/DatasetChartView.hpp new file mode 100644 index 0000000..8533181 --- /dev/null +++ b/src/app/panels/chart/DatasetChartView.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +#include "ContourBands.hpp" +namespace geopro::app { + +// 平面图表视图:散点(原数据) / 等值面(网格) + 异常叠加 + 色阶图例。VTK 仅经 ContourBands 算几何。 +class DatasetChartView : public QGraphicsView { + Q_OBJECT +public: + explicit DatasetChartView(QWidget* parent = nullptr); + void showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs); + void showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, + const geopro::render::ContourOptions& opt); + void setAnomalies(const std::vector& list); + void setHiddenAnomalies(const std::set& hidden); // 下标=list 序 + void setShowAnomalies(bool on); + void setShowContourLines(bool on); + void clearChart(); +protected: + void wheelEvent(QWheelEvent* e) override; // 滚轮缩放 + void drawForeground(QPainter* p, const QRectF& rect) override; +private: + void rebuildAnomalyItems(); + void setLegend(const geopro::core::ColorScale& cs) { legendScale_ = cs; } + QGraphicsScene* scene_; + std::vector anomalies_; + std::set hidden_; + bool showAnomalies_ = true; + bool showContourLines_ = true; + std::vector anomalyItems_; + geopro::core::ColorScale legendScale_; +}; +} // namespace geopro::app From 46c75ed419874f16f9f3841cd6bfc65ef0413550 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:01:31 +0800 Subject: [PATCH 11/74] =?UTF-8?q?feat(ui):=20dd=20=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=8E=A5=E5=8F=A3=20+=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8(=E6=9C=AA=E6=B3=A8=E5=86=8C=E9=99=8D=E7=BA=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../panels/chart/IDatasetChartStrategy.hpp | 29 +++++++++++++++++++ tests/CMakeLists.txt | 3 ++ tests/app/test_chart_strategy_registry.cpp | 14 +++++++++ 3 files changed, 46 insertions(+) create mode 100644 src/app/panels/chart/IDatasetChartStrategy.hpp create mode 100644 tests/app/test_chart_strategy_registry.cpp diff --git a/src/app/panels/chart/IDatasetChartStrategy.hpp b/src/app/panels/chart/IDatasetChartStrategy.hpp new file mode 100644 index 0000000..7fdc240 --- /dev/null +++ b/src/app/panels/chart/IDatasetChartStrategy.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +namespace geopro::app { + +class DatasetDetailPage; // 前置 + +// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 +struct IDatasetChartStrategy { + virtual ~IDatasetChartStrategy() = default; + virtual std::string ddCode() const = 0; +}; + +class ChartStrategyRegistry { +public: + void add(std::unique_ptr s) { + const std::string code = s->ddCode(); + map_[code] = std::move(s); + } + IDatasetChartStrategy* find(const std::string& ddCode) const { + auto it = map_.find(ddCode); + return it == map_.end() ? nullptr : it->second.get(); + } + bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; } +private: + std::map> map_; +}; +} // namespace geopro::app diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6e60a4e..8db9c60 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -80,4 +80,7 @@ target_sources(geopro_tests PRIVATE render/test_terrain.cpp) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) +target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) +target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) + add_subdirectory(spike) # spike S3: banded contour 渲染验证 diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp new file mode 100644 index 0000000..ad6da01 --- /dev/null +++ b/tests/app/test_chart_strategy_registry.cpp @@ -0,0 +1,14 @@ +#include +#include "panels/chart/IDatasetChartStrategy.hpp" +using namespace geopro::app; +namespace { +struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } }; +} +TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { + ChartStrategyRegistry reg; + reg.add(std::make_unique()); + EXPECT_TRUE(reg.supports("dd_inversion_data")); + EXPECT_NE(reg.find("dd_inversion_data"), nullptr); + EXPECT_FALSE(reg.supports("dd_unknown")); + EXPECT_EQ(reg.find("dd_unknown"), nullptr); +} From d61775a3bc30fbc3e338b671a052c3b29412de76 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:04:12 +0800 Subject: [PATCH 12/74] =?UTF-8?q?feat(controller):=20DatasetDetailControll?= =?UTF-8?q?er=20=E7=BC=96=E6=8E=92=20+=20ErtInversionStrategy=20=E6=A0=87?= =?UTF-8?q?=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ErtInversionStrategy.hpp | 7 ++++ src/controller/CMakeLists.txt | 3 +- src/controller/DatasetDetailController.cpp | 27 ++++++++++++++ src/controller/DatasetDetailController.hpp | 35 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/app/panels/chart/ErtInversionStrategy.hpp create mode 100644 src/controller/DatasetDetailController.cpp create mode 100644 src/controller/DatasetDetailController.hpp diff --git a/src/app/panels/chart/ErtInversionStrategy.hpp b/src/app/panels/chart/ErtInversionStrategy.hpp new file mode 100644 index 0000000..01d7f0d --- /dev/null +++ b/src/app/panels/chart/ErtInversionStrategy.hpp @@ -0,0 +1,7 @@ +#pragma once +#include "panels/chart/IDatasetChartStrategy.hpp" +namespace geopro::app { +struct ErtInversionStrategy : IDatasetChartStrategy { + std::string ddCode() const override { return "dd_inversion_data"; } +}; +} // namespace geopro::app diff --git a/src/controller/CMakeLists.txt b/src/controller/CMakeLists.txt index 45f34d7..0bbe490 100644 --- a/src/controller/CMakeLists.txt +++ b/src/controller/CMakeLists.txt @@ -1,6 +1,7 @@ find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_controller STATIC - WorkbenchNavController.cpp) + WorkbenchNavController.cpp + DatasetDetailController.cpp) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) target_compile_features(geopro_controller PUBLIC cxx_std_17) diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp new file mode 100644 index 0000000..fb03556 --- /dev/null +++ b/src/controller/DatasetDetailController.cpp @@ -0,0 +1,27 @@ +#include "DatasetDetailController.hpp" +#include +#include "repo/IDatasetRepository.hpp" +namespace geopro::controller { + +DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) + : QObject(parent), repo_(repo) {} + +void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { + const std::string id = dsId.toStdString(); + try { + ChartData d; + d.dsId = dsId; + d.ddCode = ddCode; + d.scatter = repo_.loadScatter(id); + d.scatterScale = repo_.loadScatterColorScale(id); + d.grid = repo_.loadGrid(id); + d.gridScale = repo_.loadColorScale(id); + d.anomalies = repo_.loadAnomalies(id); + emit chartReady(d); + } catch (const std::exception& e) { + emit loadFailed(dsId, QString::fromStdString(e.what())); + } +} + +void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } +} // namespace geopro::controller diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp new file mode 100644 index 0000000..43e5993 --- /dev/null +++ b/src/controller/DatasetDetailController.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::data { class IDatasetRepository; } +namespace geopro::controller { + +// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 +// 仅服务图表,不与 WorkbenchNavController(项目/结构导航)耦合。 +class DatasetDetailController : public QObject { + Q_OBJECT +public: + struct ChartData { + QString dsId, ddCode; + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; + geopro::core::Grid grid{1, 1}; // Grid 无默认构造;以占位值初始化,openDataset 会覆盖 + geopro::core::ColorScale gridScale; + std::vector anomalies; + }; + explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); +public slots: + void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 + void focusDataset(const QString& dsId); // 单击=聚焦已开页 +signals: + void chartReady(const ChartData& data); + void focusRequested(const QString& dsId); + void loadFailed(const QString& dsId, const QString& message); +private: + data::IDatasetRepository& repo_; +}; +} // namespace geopro::controller From 535db496c472cf72c040766b4a310171e8c1d71b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:07:06 +0800 Subject: [PATCH 13/74] =?UTF-8?q?feat(ui):=20AnomalyTablePanel=20ds?= =?UTF-8?q?=E7=BA=A7=E5=BC=82=E5=B8=B8=E8=A1=A8(=E8=A1=8C=E7=9C=BC?= =?UTF-8?q?=E7=9D=9B=E2=86=92=E9=9A=90=E8=97=8F=E9=9B=86=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/AnomalyTablePanel.cpp | 40 ++++++++++++++++++++++++++++ src/app/panels/AnomalyTablePanel.hpp | 23 ++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/app/panels/AnomalyTablePanel.cpp create mode 100644 src/app/panels/AnomalyTablePanel.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 2024e52..34b7018 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -27,6 +27,7 @@ add_executable(geopro_desktop WIN32 panels/DynamicFormView.cpp panels/ObjectExceptionPanel.cpp panels/chart/DatasetChartView.cpp + panels/AnomalyTablePanel.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/AnomalyTablePanel.cpp b/src/app/panels/AnomalyTablePanel.cpp new file mode 100644 index 0000000..84c7c0b --- /dev/null +++ b/src/app/panels/AnomalyTablePanel.cpp @@ -0,0 +1,40 @@ +#include "panels/AnomalyTablePanel.hpp" +#include +#include +#include +#include +namespace geopro::app { +static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; } + +AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); + table_ = new QTableWidget(this); + table_->setColumnCount(6); + table_->setHorizontalHeaderLabels({"名称", "异常类型", "几何类型", "创建时间", "备注", "操作"}); + table_->horizontalHeader()->setStretchLastSection(true); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + lay->addWidget(table_); +} + +void AnomalyTablePanel::setAnomalies(const std::vector& list, + const std::vector& createTimes, + const std::vector& remarks) { + hidden_.clear(); + table_->setRowCount(static_cast(list.size())); + for (int i = 0; i < static_cast(list.size()); ++i) { + const auto& a = list[i]; + table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name))); + table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName))); + table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast(a.markType)))); + table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : "")); + table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : "")); + auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true); + eye->setText("👁"); + connect(eye, &QToolButton::toggled, this, [this, i](bool on) { + if (on) hidden_.erase(i); else hidden_.insert(i); + emit hiddenChanged(hidden_); + }); + table_->setCellWidget(i, 5, eye); + } +} +} // namespace geopro::app diff --git a/src/app/panels/AnomalyTablePanel.hpp b/src/app/panels/AnomalyTablePanel.hpp new file mode 100644 index 0000000..b765759 --- /dev/null +++ b/src/app/panels/AnomalyTablePanel.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include +#include "model/Anomaly.hpp" +class QTableWidget; +namespace geopro::app { + +// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。 +class AnomalyTablePanel : public QWidget { + Q_OBJECT +public: + explicit AnomalyTablePanel(QWidget* parent = nullptr); + void setAnomalies(const std::vector& list, + const std::vector& createTimes, + const std::vector& remarks); +signals: + void hiddenChanged(const std::set& hiddenIndices); +private: + QTableWidget* table_; + std::set hidden_; +}; +} // namespace geopro::app From b8c74962e85192e1d6e9164ed3fd6393894d4330 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:08:13 +0800 Subject: [PATCH 14/74] =?UTF-8?q?feat(ui):=20DatasetDetailPage=20=E5=8E=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE/=E7=BD=91=E6=A0=BC=E5=88=87=E6=8D=A2+?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=E5=BC=80=E5=85=B3+=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/DatasetDetailPage.cpp | 52 ++++++++++++++++++++++++++++ src/app/panels/DatasetDetailPage.hpp | 22 ++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/app/panels/DatasetDetailPage.cpp create mode 100644 src/app/panels/DatasetDetailPage.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 34b7018..328fd2b 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(geopro_desktop WIN32 panels/ObjectExceptionPanel.cpp panels/chart/DatasetChartView.cpp panels/AnomalyTablePanel.cpp + panels/DatasetDetailPage.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp new file mode 100644 index 0000000..6a2f901 --- /dev/null +++ b/src/app/panels/DatasetDetailPage.cpp @@ -0,0 +1,52 @@ +#include "panels/DatasetDetailPage.hpp" +#include +#include +#include +#include +#include +#include +#include "panels/chart/DatasetChartView.hpp" +#include "panels/AnomalyTablePanel.hpp" +namespace geopro::app { + +DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + auto* bar = new QHBoxLayout(); + auto* origin = new QToolButton(this); origin->setText("原数据"); origin->setCheckable(true); + auto* grid = new QToolButton(this); grid->setText("网格数据"); grid->setCheckable(true); + grid->setChecked(true); + auto* grp = new QButtonGroup(this); grp->setExclusive(true); grp->addButton(origin); grp->addButton(grid); + auto* showAnom = new QCheckBox("显示异常", this); showAnom->setChecked(true); + auto* showLines = new QCheckBox("显示等值线", this); showLines->setChecked(true); + bar->addWidget(origin); bar->addWidget(grid); bar->addStretch(); + bar->addWidget(showAnom); bar->addWidget(showLines); + lay->addLayout(bar); + + chart_ = new DatasetChartView(this); + anomalyTable_ = new AnomalyTablePanel(this); + lay->addWidget(chart_, 3); + lay->addWidget(anomalyTable_, 1); + + connect(grid, &QToolButton::clicked, this, [this] { gridMode_ = true; showGridMode(); }); + connect(origin, &QToolButton::clicked, this, [this] { gridMode_ = false; showScatterMode(); }); + connect(showAnom, &QCheckBox::toggled, chart_, [this](bool on) { chart_->setShowAnomalies(on); }); + connect(showLines, &QCheckBox::toggled, chart_, [this](bool on) { + chart_->setShowContourLines(on); if (gridMode_) showGridMode(); }); + connect(anomalyTable_, &AnomalyTablePanel::hiddenChanged, chart_, + [this](const std::set& h) { chart_->setHiddenAnomalies(h); }); +} + +void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { + dsId_ = d.dsId; data_ = d; + chart_->setAnomalies(d.anomalies); + anomalyTable_->setAnomalies(d.anomalies, {}, {}); // 创建时间/备注可后续从 VO 补 + if (gridMode_) showGridMode(); else showScatterMode(); +} +void DatasetDetailPage::showGridMode() { + geopro::render::ContourOptions opt; // 默认 2x+smooth+simplify0.5 + chart_->showContour(data_.grid, data_.gridScale, opt); +} +void DatasetDetailPage::showScatterMode() { + chart_->showScatter(data_.scatter, data_.scatterScale); +} +} // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp new file mode 100644 index 0000000..72b6727 --- /dev/null +++ b/src/app/panels/DatasetDetailPage.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include "DatasetDetailController.hpp" // ChartData +namespace geopro::app { +class DatasetChartView; class AnomalyTablePanel; + +// 单个数据集详情页:标题 + 原数据/网格数据 切换 + 叠加开关 + 图表 + 异常表。 +class DatasetDetailPage : public QWidget { + Q_OBJECT +public: + explicit DatasetDetailPage(QWidget* parent = nullptr); + void setData(const geopro::controller::DatasetDetailController::ChartData& d); + QString dsId() const { return dsId_; } +private: + void showScatterMode(); void showGridMode(); + QString dsId_; + geopro::controller::DatasetDetailController::ChartData data_; + DatasetChartView* chart_; + AnomalyTablePanel* anomalyTable_; + bool gridMode_ = true; +}; +} // namespace geopro::app From 88d2fea9df73bbe59b07b05a62ce48a61fc3f501 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:09:38 +0800 Subject: [PATCH 15/74] =?UTF-8?q?feat(ui):=20DatasetDetailPanel=20?= =?UTF-8?q?=E5=A4=9ATab=E5=A3=B3(=E6=8C=89dsId=E5=8E=BB=E9=87=8D+=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E8=81=94=E5=8A=A8=E4=BF=A1=E5=8F=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/DatasetDetailPanel.cpp | 30 +++++++++++++++++++++++++++ src/app/panels/DatasetDetailPanel.hpp | 19 +++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/app/panels/DatasetDetailPanel.cpp create mode 100644 src/app/panels/DatasetDetailPanel.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 328fd2b..ea0f23a 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(geopro_desktop WIN32 panels/chart/DatasetChartView.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp + panels/DatasetDetailPanel.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp new file mode 100644 index 0000000..4f3e59f --- /dev/null +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -0,0 +1,30 @@ +#include "panels/DatasetDetailPanel.hpp" +#include "panels/DatasetDetailPage.hpp" +namespace geopro::app { + +DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { + setTabsClosable(true); + connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { delete widget(i); }); + connect(this, &QTabWidget::currentChanged, this, [this](int i) { + if (auto* p = qobject_cast(widget(i))) + emit activeDatasetChanged(p->dsId()); + }); +} + +DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { + for (int i = 0; i < count(); ++i) + if (auto* p = qobject_cast(widget(i))) + if (p->dsId() == dsId) return p; + return nullptr; +} + +void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { + auto* p = pageFor(d.dsId); + if (!p) { p = new DatasetDetailPage(this); addTab(p, d.dsId); } // 标题后续可换 ds 名 + p->setData(d); + setCurrentWidget(p); +} +void DatasetDetailPanel::focusDataset(const QString& dsId) { + if (auto* p = pageFor(dsId)) setCurrentWidget(p); +} +} // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp new file mode 100644 index 0000000..3df160a --- /dev/null +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include "DatasetDetailController.hpp" +namespace geopro::app { +class DatasetDetailPage; + +// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。 +class DatasetDetailPanel : public QTabWidget { + Q_OBJECT +public: + explicit DatasetDetailPanel(QWidget* parent = nullptr); + void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 + void focusDataset(const QString& dsId); // 单击聚焦已开页 +signals: + void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 +private: + DatasetDetailPage* pageFor(const QString& dsId) const; +}; +} // namespace geopro::app From 08ba483550aa30d078c4686d9f6ff68b82f3bcd8 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:18:18 +0800 Subject: [PATCH 16/74] =?UTF-8?q?feat(ui):=20=E6=8E=A5=E7=BA=BF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=AF=A6=E6=83=85=E9=9D=A2=E6=9D=BF(=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=97=A7VTK=E8=AF=A6=E6=83=85)+=E5=8D=95=E5=87=BB?= =?UTF-8?q?=E8=81=9A=E7=84=A6/=E5=8F=8C=E5=87=BB=E6=89=93=E5=BC=80/?= =?UTF-8?q?=E5=8F=8D=E5=90=91=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除旧 VTK 数据详情 dock(detailWidget/独立 renderer/工具条 原数据·网格数据·显示异常/电极/等值线) 及 rebuildDetail lambda、共享态(currentDsId/detailMode/showAnomalies/...)与相关连接 - 清理因删除产生的孤儿:animateReveal、DetailMode、kScatterPointSize、kBarBtnQss - 新建 DatasetDetailPanel 多 Tab 面板 dock(QGraphicsView 平面图表) - buildWorkbench 加参 DatasetDetailController&;main() 构造 ApiDatasetRepository + DatasetDetailController - 单击=属性表单+聚焦已开页;双击=openDataset(dsId,ddCode);chartReady→openOrUpdate; focusRequested→focusDataset;activeDatasetChanged→反向高亮列表 - 新增 kDsDdCodeRole(0x0104),populateDatasetList 写入 ddCode - 保留 rebuildCentral 及中央场景、repo.loadGrid(grid1) --- src/app/main.cpp | 308 ++++------------------------ src/app/panels/DatasetListPanel.cpp | 1 + src/app/panels/DatasetListPanel.hpp | 1 + 3 files changed, 46 insertions(+), 264 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index bbafbf4..0d557bc 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -80,10 +80,13 @@ #include "CentralScene.hpp" #include "ProjectListDialog.hpp" #include "WorkbenchNavController.hpp" +#include "DatasetDetailController.hpp" #include "api/ApiProjectRepository.hpp" +#include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" #include "panels/DatasetListPanel.hpp" +#include "panels/DatasetDetailPanel.hpp" #include "panels/DynamicFormView.hpp" #include "panels/ObjectExceptionPanel.hpp" @@ -155,44 +158,6 @@ private: QWidget* host_; }; -// 相机补间 + actor 淡入:从 from 位姿平滑过渡到 to 位姿,同时 actors 透明度 0→1。 -// vtkCameraInterpolator 两关键帧线性插值(缓动交给 QEasingCurve),单条 QVariantAnimation -// 逐帧驱动并 Render;结束回调锁定到目标态(防插值末值误差/残留半透明)。 -// 渐进增强:动效只是过渡,最终一帧永远是正确的目标态,故即使观感不佳也不破坏功能。 -void animateReveal(vtkRenderer* renderer, vtkGenericOpenGLRenderWindow* rw, - vtkSmartPointer fromCam, vtkSmartPointer toCam, - std::vector> actors, int durationMs, QObject* owner) -{ - auto interp = vtkSmartPointer::New(); - interp->SetInterpolationTypeToLinear(); - interp->AddCamera(0.0, fromCam); - interp->AddCamera(1.0, toCam); - - auto* anim = new QVariantAnimation(owner); - anim->setDuration(durationMs); - anim->setStartValue(0.0); - anim->setEndValue(1.0); - anim->setEasingCurve(QEasingCurve::OutCubic); - QObject::connect(anim, &QVariantAnimation::valueChanged, owner, - [interp, renderer, rw, actors](const QVariant& v) { - const double t = v.toDouble(); - interp->InterpolateCamera(t, renderer->GetActiveCamera()); - for (const auto& a : actors) - if (a) a->GetProperty()->SetOpacity(t); - renderer->ResetCameraClippingRange(); - rw->Render(); - }); - QObject::connect(anim, &QVariantAnimation::finished, owner, - [renderer, rw, actors, toCam]() { - renderer->GetActiveCamera()->DeepCopy(toCam); - for (const auto& a : actors) - if (a) a->GetProperty()->SetOpacity(1.0); - renderer->ResetCameraClippingRange(); - rw->Render(); - }); - anim->start(QAbstractAnimation::DeleteWhenStopped); -} - // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 std::string readPem(const std::string& path) { @@ -215,12 +180,6 @@ double median(std::vector v) // 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。 using geopro::app::ViewMode; -// 数据详情显示内容(默认网格数据)。网格数据=#18 banded;原数据=#17 散点(对齐原型命名)。 -enum class DetailMode { Section18, Scatter17 }; - -// #17 散点屏幕像素方块边长。 -constexpr float kScatterPointSize = 4.0F; - // 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 / // 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。 // 单一可调常量:要整体调纵向观感改这一处即可。 @@ -234,7 +193,8 @@ constexpr const char* kWgs84 = "EPSG:4326"; // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, geopro::data::IProjectRepository& projectRepo, - geopro::controller::WorkbenchNavController& nav) + geopro::controller::WorkbenchNavController& nav, + geopro::controller::DatasetDetailController& detailCtrl) { // ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ── // 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。 @@ -322,23 +282,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re centerLayout->setContentsMargins(0, 0, 0, 0); centerLayout->setSpacing(0); - // 分段工具条按钮样式(QToolButton + 主题化 QSS):选中=强调色文字 + 强调色下划线,明暗都清晰。 - // ElaToolButton 选中只画极淡 BasicHover、且不可经 QSS 改,故这类需清晰选中态的用 QToolButton。 - const QString kBarBtnQss = - QStringLiteral( - "QToolButton{ border:none; border-radius:6px; padding:6px 12px; color:{{text/primary}};" - " font-size:%1px; }" - "QToolButton:hover{ background:{{bg/hover}}; }" - "QToolButton:checked{ color:{{accent/primary}}; font-weight:%2;" - " border-bottom:2px solid {{accent/primary}}; }" - "QToolButton#dataTab{ border:none; border-radius:0; background:transparent;" - " border-bottom:2px solid transparent; color:{{text/secondary}}; padding:8px 8px; }" - "QToolButton#dataTab:hover{ color:{{text/primary}}; background:transparent; }" - "QToolButton#dataTab:checked{ color:{{accent/primary}}; font-weight:%2;" - " border-bottom:2px solid {{accent/primary}}; }") - .arg(geopro::app::scaledPx(geopro::app::type::kBody)) - .arg(geopro::app::type::kWeightSemibold); - // 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款(42px 表头底 + 强调色下划线页签)。 auto seg = geopro::app::buildSegmentedHeader( {QStringLiteral("二维地图"), QStringLiteral("三维视图")}, @@ -448,77 +391,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re vtkDock->setWidget(centerWidget); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); - // ── 下方「数据详情」dock:独立 QVTK 小视图(独立 renderer/renderWindow)── - // 单击 DS → 显示该数据集平面反演剖面(#18 banded,平躺俯视正交)。 - auto* detailWidget = new QVTKOpenGLStereoWidget(); - vtkNew detailRenderWindow; - vtkNew detailRenderer; - { - double r, g, b; - geopro::app::vtkBackground(r, g, b); // 背景随主题 - detailRenderer->SetBackground(r, g, b); - } - detailWidget->setRenderWindow(detailRenderWindow); - detailRenderWindow->AddRenderer(detailRenderer); - vtkRenderer* detailRendererPtr = detailRenderer.Get(); - vtkGenericOpenGLRenderWindow* detailRenderWindowPtr = detailRenderWindow.Get(); - // 注:VTK 背景随主题切换的连接放在 rebuildCentral/rebuildDetail 定义之后(直接重跑它们, - // 走完整渲染路径必重绘,比手动 SetBackground+Render 稳)。 - - // 数据详情容器:顶部「反演剖面/原数据」工具条 + 下方 QVTK 小视图。 - auto* detailContainer = new QWidget(); - auto* detailLayout = new QVBoxLayout(detailContainer); - detailLayout->setContentsMargins(0, 0, 0, 0); - detailLayout->setSpacing(0); - - // 工具条对齐原型:「原数据 | 网格数据」互斥 +「显示异常/电极/等值线」开关。 - // QToolButton + 主题化 QSS(选中=强调色文字+下划线,明暗都清晰)。 - auto* detailToolBar = new QWidget(); - auto* detailBarLay = new QHBoxLayout(detailToolBar); - detailBarLay->setContentsMargins(8, 6, 8, 6); - detailBarLay->setSpacing(6); - auto makeBarBtn = [detailToolBar](const QString& text, bool checkable) { - auto* b = new QToolButton(detailToolBar); - b->setText(text); - b->setCheckable(checkable); - return b; - }; - auto* detailGroup = new QButtonGroup(detailToolBar); - detailGroup->setExclusive(true); - auto* actScatter = makeBarBtn(QStringLiteral("原数据"), true); - auto* actSection = makeBarBtn(QStringLiteral("网格数据"), true); - actScatter->setObjectName(QStringLiteral("dataTab")); - actSection->setObjectName(QStringLiteral("dataTab")); - detailGroup->addButton(actScatter); - detailGroup->addButton(actSection); - detailBarLay->addWidget(actScatter); - detailBarLay->addWidget(actSection); - actSection->setChecked(true); // 默认网格数据 (#18) - auto* barSep = new QFrame(detailToolBar); - barSep->setFrameShape(QFrame::VLine); - barSep->setObjectName(QStringLiteral("topDivider")); - detailBarLay->addSpacing(4); - detailBarLay->addWidget(barSep); - detailBarLay->addSpacing(4); - auto* actShowAnomaly = makeBarBtn(QStringLiteral("显示异常"), true); - actShowAnomaly->setChecked(true); // 默认显示异常(对齐原型 ☑显示异常) - auto* actShowElectrodes = makeBarBtn(QStringLiteral("显示电极"), true); - actShowElectrodes->setChecked(true); // 默认显示电极 ▼(对齐原型) - auto* actShowContour = makeBarBtn(QStringLiteral("显示等值线"), true); - actShowContour->setChecked(true); // 默认显示等值线(对齐原型) - detailBarLay->addWidget(actShowAnomaly); - detailBarLay->addWidget(actShowElectrodes); - detailBarLay->addWidget(actShowContour); - detailBarLay->addStretch(); - geopro::app::applyTokenizedStyleSheet(detailToolBar, kBarBtnQss); - detailLayout->addWidget(detailToolBar); - detailLayout->addWidget(detailWidget, 1); - + // ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)── + // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 + auto* detailPanel = new geopro::app::DatasetDetailPanel(); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); detailDock->setWidget(wrapWithHeader( - geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailContainer, - {{geopro::app::Glyph::Collapse, QStringLiteral("折叠")}, - {geopro::app::Glyph::Download, QStringLiteral("导出")}})); + geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel)); // 放在中央视图下方。 dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); @@ -599,146 +477,45 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re *frame, kVerticalExaggeration); }; - // ── 数据详情共享状态 + 重建 ────────────────────────────────────────── - // 当前选中数据集 id(空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。 - auto currentDsId = std::make_shared(); - auto detailMode = std::make_shared(DetailMode::Section18); - auto showAnomalies = std::make_shared(true); // 默认显示异常(对齐原型) - auto showElectrodes = std::make_shared(true); // 默认显示电极 ▼ - auto showContour = std::make_shared(true); // 默认显示等值线 - auto hiddenAnoms = std::make_shared>(); // 异常列表中被取消勾选(隐藏)的异常下标 - auto prevDsId = std::make_shared(); // 上次渲染的 DS id:判定“切换数据集”以触发揭示过渡 - - // 按当前选中 DS + 详情模式重建下方数据详情(平躺俯视正交,纵向夸张填面板)。 - // 勾选「显示异常/电极/等值线」控制对应叠加(同纵向夸张对齐)。 - // overdrive(A):仅“切换数据集”这一加载时刻播放相机补间 + actor 淡入揭示;模式/叠加层开关 - // 属同一数据集内微调,直接落定不放动画(特殊时刻才特殊,避免每次交互都动的疲劳)。 - auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, detailWidget, currentDsId, - prevDsId, detailMode, showAnomalies, showElectrodes, showContour, - hiddenAnoms]() { - const bool dsChanged = (*currentDsId != *prevDsId); - const bool animate = dsChanged && !prevDsId->isEmpty() && !currentDsId->isEmpty(); - - // 过渡起点:清场景前先快照当前相机位姿。 - auto fromCam = vtkSmartPointer::New(); - fromCam->DeepCopy(detailRendererPtr->GetActiveCamera()); - - detailRendererPtr->RemoveAllViewProps(); - { // 背景随主题 - double r, g, b; - geopro::app::vtkBackground(r, g, b); - detailRendererPtr->SetBackground(r, g, b); - } - if (currentDsId->isEmpty()) { // 未选数据集:清空即可 - *prevDsId = *currentDsId; - detailRenderWindowPtr->Render(); - return; - } - std::vector> added; // 本次加入的 actor,供淡入 - const std::string id = currentDsId->toStdString(); - if (*detailMode == DetailMode::Section18) { - // 网格数据:#18 banded 等值面(+「显示等值线」时叠黑色等值线),纵向夸张 1.5x(沿 y)。 - const auto g = repo.loadGrid(id); - const auto cs = repo.loadColorScale(id); - const auto actors = geopro::render::buildGridContour(g, cs); - if (actors.bands) { - actors.bands->SetScale(1.0, kVerticalExaggeration, 1.0); - detailRendererPtr->AddViewProp(actors.bands); - added.push_back(actors.bands); - } - if (actors.edges && *showContour) { - actors.edges->SetScale(1.0, kVerticalExaggeration, 1.0); - detailRendererPtr->AddViewProp(actors.edges); - added.push_back(actors.edges); - } - // 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。 - if (*showElectrodes) { - auto elec = geopro::render::buildElectrodes(g); - if (elec) { - elec->SetScale(1.0, kVerticalExaggeration, 1.0); - detailRendererPtr->AddViewProp(elec); - added.push_back(elec); - } - } - } else { - // 原数据:#17 彩色散点,用散点自带色阶;纵向夸张同剖面以对齐观感。 - const auto s = repo.loadScatter(id); - const auto scs = repo.loadScatterColorScale(id); - auto a = geopro::render::buildScatter(s, scs, kScatterPointSize); - if (a) { - a->SetScale(1.0, kVerticalExaggeration, 1.0); - detailRendererPtr->AddViewProp(a); - added.push_back(a); - } - } - // 异常叠加(与剖面同坐标系/同纵向夸张)。逐异常构建以按列表显隐(下标=原 vector 序)过滤。 - if (*showAnomalies) { - const auto anomalies = repo.loadAnomalies(id); - for (int i = 0; i < static_cast(anomalies.size()); ++i) { - if (hiddenAnoms->count(i)) continue; // 列表中取消勾选→隐藏 - for (auto& act : geopro::render::buildAnomalies({anomalies[i]})) { - act->SetScale(1.0, kVerticalExaggeration, 1.0); - detailRendererPtr->AddViewProp(act); - added.push_back(act); - } - } - } - geopro::render::applyTop2D(detailRendererPtr); - detailRendererPtr->ResetCamera(); - *prevDsId = *currentDsId; - - if (animate) { - // 目标位姿快照 → 相机回退到旧位姿 + actors 透明 → 补间到目标并淡入。 - auto toCam = vtkSmartPointer::New(); - toCam->DeepCopy(detailRendererPtr->GetActiveCamera()); - for (const auto& a : added) a->GetProperty()->SetOpacity(0.0); - detailRendererPtr->GetActiveCamera()->DeepCopy(fromCam); - detailRendererPtr->ResetCameraClippingRange(); - animateReveal(detailRendererPtr, detailRenderWindowPtr, fromCam, toCam, added, 450, - detailWidget); - } else { - detailRenderWindowPtr->Render(); - } - }; - - // ── 单击左下数据列表的采集批次(DS) → 加载数据集动态表单(数据集属性面板)── + // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [&nav](QListWidgetItem* item) { + [&nav, &detailCtrl](QListWidgetItem* item) { if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } const QString dsId = item->data(geopro::app::kDsIdRole).toString(); - if (!dsId.isEmpty()) nav.selectDataset(dsId); + if (dsId.isEmpty()) return; + nav.selectDataset(dsId); // 属性表单(现状) + detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 }); - // ── 数据详情工具条「反演剖面/原数据」:切模式 → 重建数据详情 ── - QObject::connect(actSection, &QAbstractButton::clicked, detailWidget, - [detailMode, rebuildDetail]() { - *detailMode = DetailMode::Section18; - rebuildDetail(); - }); - QObject::connect(actScatter, &QAbstractButton::clicked, detailWidget, - [detailMode, rebuildDetail]() { - *detailMode = DetailMode::Scatter17; - rebuildDetail(); + // ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)── + QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList, + [&detailCtrl](QListWidgetItem* item) { + const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString(); + if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); }); - // ──「显示异常 / 显示电极 / 显示等值线」开关:切换叠加 → 重建数据详情 ── - QObject::connect(actShowAnomaly, &QAbstractButton::toggled, detailWidget, - [showAnomalies, rebuildDetail](bool on) { - *showAnomalies = on; - rebuildDetail(); + // ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── + QObject::connect( + &detailCtrl, &geopro::controller::DatasetDetailController::chartReady, detailPanel, + [detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) { + detailPanel->openOrUpdate(d); + }); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested, + detailPanel, [detailPanel](const QString& dsId) { + detailPanel->focusDataset(dsId); }); - QObject::connect(actShowElectrodes, &QAbstractButton::toggled, detailWidget, - [showElectrodes, rebuildDetail](bool on) { - *showElectrodes = on; - rebuildDetail(); - }); - QObject::connect(actShowContour, &QAbstractButton::toggled, detailWidget, - [showContour, rebuildDetail](bool on) { - *showContour = on; - rebuildDetail(); + + // ── 详情面板切 Tab → 反向高亮数据集列表对应行 ── + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, + datasetList, [datasetList](const QString& dsId) { + for (int i = 0; i < datasetList->count(); ++i) + if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == + dsId) + datasetList->setCurrentRow(i); }); // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 @@ -792,12 +569,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。 rebuildCentral(); - // VTK 背景随主题切换:直接重跑 rebuildCentral/rebuildDetail(走完整渲染路径、末尾必 Render, + // VTK 背景随主题切换:直接重跑 rebuildCentral(走完整渲染路径、末尾必 Render, // 比手动 SetBackground+Render 稳;兼顾 syncSystemTheme 异步切暗的时序)。 QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, &window, - [rebuildCentral, rebuildDetail]() { + [rebuildCentral]() { rebuildCentral(); - rebuildDetail(); }); // 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), @@ -1050,6 +826,10 @@ int main(int argc, char* argv[]) geopro::data::ApiProjectRepository projectRepo(api); geopro::controller::WorkbenchNavController nav(projectRepo); + // 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。 + geopro::data::ApiDatasetRepository datasetRepo(api); + geopro::controller::DatasetDetailController detailCtrl(datasetRepo); + // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 // setCentralWidget/setMenuWidget/statusBar 承载工作台。 const QString kTitle = QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"); @@ -1057,7 +837,7 @@ int main(int argc, char* argv[]) window->setWindowTitle(kTitle); window->resize(1280, 800); window->setMinimumSize(1024, 680); - buildWorkbench(*window, repo, projectRepo, nav); + buildWorkbench(*window, repo, projectRepo, nav, detailCtrl); // 主题桥:ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS;内联 chrome 经各自连接)。 QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 4856a85..8faf540 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -116,6 +116,7 @@ void populateDatasetList(QListWidget* list, const std::vectorsetData(kDsIdRole, QString::fromStdString(d.id)); item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); + item->setData(kDsDdCodeRole, QString::fromStdString(d.ddCode)); } } diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index 8ac2887..d6b7f9f 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -12,6 +12,7 @@ constexpr int kDsIdRole = 0x0100; // Qt::UserRole constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用) constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 +constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用) // 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。 void populateDatasetList(QListWidget* list, const std::vector& rows, bool append); From 548895bdcfea87fb4671800b91f6adfc7acb542f Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:22:37 +0800 Subject: [PATCH 17/74] =?UTF-8?q?test(controller):=20DatasetDetailControll?= =?UTF-8?q?er=20chartReady/loadFailed=20=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/CMakeLists.txt | 5 ++++ .../test_dataset_detail_controller.cpp | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/controller/test_dataset_detail_controller.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8db9c60..3c092ba 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -83,4 +83,9 @@ vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) +# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 +find_package(Qt6 COMPONENTS Test REQUIRED) +target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) + add_subdirectory(spike) # spike S3: banded contour 渲染验证 diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp new file mode 100644 index 0000000..1247677 --- /dev/null +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -0,0 +1,30 @@ +#include +#include +#include "DatasetDetailController.hpp" +#include "repo/IDatasetRepository.hpp" +using namespace geopro; +namespace { +struct StubRepo : data::IDatasetRepository { + bool fail = false; + std::vector loadStructure() override { return {}; } + core::Grid loadGrid(const std::string&) override { if (fail) throw std::runtime_error("x"); core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } + core::ScatterField loadScatter(const std::string&) override { return {}; } + core::ColorScale loadColorScale(const std::string&) override { return {}; } + core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } + std::vector loadAnomalies(const std::string&) override { return {}; } +}; +} +TEST(DatasetDetailController, EmitsChartReadyOnSuccess) { + StubRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("ds1", "dd_inversion_data"); + EXPECT_EQ(spy.count(), 1); +} +TEST(DatasetDetailController, EmitsLoadFailedOnThrow) { + StubRepo repo; repo.fail = true; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_inversion_data"); + EXPECT_EQ(spy.count(), 1); +} From 82b654176ec03af1e2fcaba3611b478d928faa05 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 12:42:04 +0800 Subject: [PATCH 18/74] =?UTF-8?q?fix(ui):=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20loadFailed=E6=8E=A5=E7=BA=BF/=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8=E9=87=8D=E5=85=A5=E5=AE=88=E5=8D=AB+ddCode=E9=99=8D?= =?UTF-8?q?=E7=BA=A7/=E6=95=A3=E7=82=B9=E8=B6=8A=E7=95=8C/=E7=82=B9?= =?UTF-8?q?=E5=BC=82=E5=B8=B8/Tab=20deleteLater/makeLines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.cpp: DatasetDetailController::loadFailed 连接到 statusBar,5s 显示失败消息 - DatasetDetailController: 加 busy_ 重入守卫;ddCode 非 dd_inversion_data 时直接 loadFailed 降级 - DatasetChartView::showScatter: 按 x/y/v 三者最短长度循环,防越界;加 #include - DatasetChartView::rebuildAnomalyItems: 先处理 markType==1 点异常(小方块),再走原折线/多边形分支 - DatasetChartView::clearChart: 加注释说明 scene_->clear() + anomalyItems_.clear() 不变式 - DatasetDetailPanel: tabCloseRequested 改用 deleteLater,避免在事件处理期间同步析构 - ContourBands: buildContourBands 按 opt.makeLines 决定是否开 GenerateContourEdgesOn 及提取等值线 --- src/app/main.cpp | 5 ++++ src/app/panels/DatasetDetailPanel.cpp | 2 +- src/app/panels/chart/DatasetChartView.cpp | 18 +++++++++++- src/controller/DatasetDetailController.cpp | 9 ++++++ src/controller/DatasetDetailController.hpp | 1 + src/render/ContourBands.cpp | 32 ++++++++++++---------- 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 0d557bc..22925f8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -508,6 +508,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re detailPanel, [detailPanel](const QString& dsId) { detailPanel->focusDataset(dsId); }); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window, + [&window](const QString& dsId, const QString& msg) { + window.statusBar()->showMessage( + QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); + }); // ── 详情面板切 Tab → 反向高亮数据集列表对应行 ── QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 4f3e59f..a5267a3 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -4,7 +4,7 @@ namespace geopro::app { DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { setTabsClosable(true); - connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { delete widget(i); }); + connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); connect(this, &QTabWidget::currentChanged, this, [this](int i) { if (auto* p = qobject_cast(widget(i))) emit activeDatasetChanged(p->dsId()); diff --git a/src/app/panels/chart/DatasetChartView.cpp b/src/app/panels/chart/DatasetChartView.cpp index 60ce1ed..d1dff50 100644 --- a/src/app/panels/chart/DatasetChartView.cpp +++ b/src/app/panels/chart/DatasetChartView.cpp @@ -1,4 +1,5 @@ #include "panels/chart/DatasetChartView.hpp" +#include #include #include #include @@ -24,13 +25,18 @@ DatasetChartView::DatasetChartView(QWidget* parent) } void DatasetChartView::clearChart() { + // scene_->clear() 删除场景中的所有 item(含 anomalyItems_ 所指向的对象); + // anomalyItems_.clear() 随后清空向量中的悬空指针。 + // 二者必须成对、顺序不可颠倒:若先 clear() 后仍持有指针,rebuildAnomalyItems + // 中的 delete 会对已释放对象 double-free;若先清向量、后继续操作旧指针同样 UB。 scene_->clear(); anomalyItems_.clear(); } void DatasetChartView::showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs) { clearChart(); const double sz = 0.6; // 方块边长(数据单位,近似 web 方点) - for (size_t i = 0; i < f.v.size(); ++i) { + const size_t n = std::min({f.x.size(), f.y.size(), f.v.size()}); + for (size_t i = 0; i < n; ++i) { auto* r = scene_->addRect(f.x[i] - sz / 2, f.y[i] - sz / 2, sz, sz, QPen(Qt::white, 0), QBrush(toQ(cs.colorAt(f.v[i])))); r->setPen(QPen(Qt::white, 0)); // 白描边 @@ -81,6 +87,16 @@ void DatasetChartView::rebuildAnomalyItems() { for (int i = 0; i < static_cast(anomalies_.size()); ++i) { if (hidden_.count(i)) continue; const auto& a = anomalies_[i]; + if (a.localPts.empty()) continue; + if (static_cast(a.markType) == 1) { // 点:小方块标记 + const double s = 0.8; + auto* dot = scene_->addRect(a.localPts[0].x - s/2, a.localPts[0].y - s/2, s, s, + QPen(QColor(QString::fromStdString(a.lineColor)), 0), + QBrush(QColor(QString::fromStdString(a.lineColor)))); + dot->setZValue(2); + anomalyItems_.push_back(dot); + continue; + } if (a.localPts.size() < 2) continue; QPainterPath path; path.moveTo(a.localPts[0].x, a.localPts[0].y); for (size_t k = 1; k < a.localPts.size(); ++k) path.lineTo(a.localPts[k].x, a.localPts[k].y); diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index fb03556..d3e4a48 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -7,6 +7,13 @@ DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, : QObject(parent), repo_(repo) {} void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { + if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) + busy_ = true; + if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 + busy_ = false; + emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); + return; + } const std::string id = dsId.toStdString(); try { ChartData d; @@ -17,8 +24,10 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd d.grid = repo_.loadGrid(id); d.gridScale = repo_.loadColorScale(id); d.anomalies = repo_.loadAnomalies(id); + busy_ = false; emit chartReady(d); } catch (const std::exception& e) { + busy_ = false; emit loadFailed(dsId, QString::fromStdString(e.what())); } } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 43e5993..31a753b 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -31,5 +31,6 @@ signals: void loadFailed(const QString& dsId, const QString& message); private: data::IDatasetRepository& repo_; + bool busy_ = false; }; } // namespace geopro::controller diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 894ed34..3f6a53c 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -130,7 +130,7 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const banded->SetInputConnection(surf->GetOutputPort()); banded->SetNumberOfContours(static_cast(stops.size())); for (int i = 0; i < static_cast(stops.size()); ++i) banded->SetValue(i, stops[i]); - banded->GenerateContourEdgesOn(); + if (opt.makeLines) banded->GenerateContourEdgesOn(); banded->SetScalarModeToValue(); banded->Update(); @@ -152,21 +152,23 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const out.bands.push_back(std::move(bp)); } - // port1:等值线(polylines)+ DP 简化。 - vtkPolyData* edges = banded->GetContourEdgesOutput(); - if (edges) { - edges->BuildCells(); - const vtkIdType nLines = edges->GetNumberOfCells(); - for (vtkIdType c = 0; c < nLines; ++c) { - vtkCell* cell = edges->GetCell(c); - vtkPoints* cp = cell->GetPoints(); - ContourLine cl; cl.level = 0.0; - for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { - double xyz[3]; cp->GetPoint(p, xyz); - cl.pts.push_back(Vec2{xyz[0], xyz[1]}); + // port1:等值线(polylines)+ DP 简化。仅 makeLines=true 时生成(否则不提取)。 + if (opt.makeLines) { + vtkPolyData* edges = banded->GetContourEdgesOutput(); + if (edges) { + edges->BuildCells(); + const vtkIdType nLines = edges->GetNumberOfCells(); + for (vtkIdType c = 0; c < nLines; ++c) { + vtkCell* cell = edges->GetCell(c); + vtkPoints* cp = cell->GetPoints(); + ContourLine cl; cl.level = 0.0; + for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { + double xyz[3]; cp->GetPoint(p, xyz); + cl.pts.push_back(Vec2{xyz[0], xyz[1]}); + } + simplifyInPlace(cl.pts, opt.simplifyTol); + if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); } - simplifyInPlace(cl.pts, opt.simplifyTol); - if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); } } return out; From b772b5a16929afd6df09ae1a9a08eb5ea3bbf470 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 14:47:19 +0800 Subject: [PATCH 19/74] =?UTF-8?q?build(qwt):=20=E9=9B=86=E6=88=90=20Qwt=20?= =?UTF-8?q?6.2=20=E9=9D=99=E6=80=81=E5=BA=93(cmake/qwt.cmake=20=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E5=AE=98=E6=96=B9Qt6,=E7=BB=95=E5=BC=80qmake/vcpkg)?= =?UTF-8?q?=20+=20=E5=86=92=E7=83=9F=E7=94=A8=E4=BE=8B=20+=20=E8=BF=94?= =?UTF-8?q?=E5=B7=A5=E6=96=B9=E6=A1=88=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二维图表返工前置:QwtPlot 作科学图表框架。源码 external/qwt-src(gitignore)按 ENV_SETUP 拉取。 --- CMakeLists.txt | 6 + cmake/qwt.cmake | 29 +++ docs/Geopro3.0_二维图表返工技术方案.md | 234 +++++++++++++++++++++++++ tests/CMakeLists.txt | 6 + tests/render/test_qwt_smoke.cpp | 10 ++ 5 files changed, 285 insertions(+) create mode 100644 cmake/qwt.cmake create mode 100644 docs/Geopro3.0_二维图表返工技术方案.md create mode 100644 tests/render/test_qwt_smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index da3abd8..82853d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,12 @@ FetchContent_Declare(qtkeychain GIT_TAG v0.14.0) FetchContent_MakeAvailable(qtkeychain) +# Qwt 6.2(二维科学图表:数据集详情散点/等值线图)。见 cmake/qwt.cmake 说明。 +# 源码 external/qwt-src 已 gitignore,需按 docs/ENV_SETUP 拉取。 +if(EXISTS "${CMAKE_SOURCE_DIR}/external/qwt-src/src") + include("${CMAKE_SOURCE_DIR}/cmake/qwt.cmake") +endif() + add_subdirectory(src) enable_testing() diff --git a/cmake/qwt.cmake b/cmake/qwt.cmake new file mode 100644 index 0000000..9ffd2ca --- /dev/null +++ b/cmake/qwt.cmake @@ -0,0 +1,29 @@ +# 定义 qwt 静态库目标(Qwt 6.2 二维科学图表:数据集详情散点/等值线图)。 +# +# 为何不用 Qwt 原生 qmake:本机 VS2026 预览版缺 vswhere,qmake 探测 cl 失败。 +# 为何不用 vcpkg 的 qwt:其端口依赖 vcpkg-qtbase,会与官方 Qt 形成双 Qt 冲突(项目铁律: +# 依赖 Qt 的组件不走 vcpkg)。故此处用 CMake 直接编 Qwt 源码,对接官方 Qt6(同 VTK 思路)。 +# +# 源码目录 external/qwt-src(已 gitignore,按 docs/ENV_SETUP 拉取): +# git clone --depth 1 https://github.com/opencor/qwt external/qwt-src # Qwt 6.2.0 镜像 +# 由根 CMakeLists 在源码存在时 include 本文件。 + +set(QWT_SRC_DIR "${CMAKE_SOURCE_DIR}/external/qwt-src/src") + +find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent PrintSupport Svg) + +file(GLOB _qwt_sources CONFIGURE_DEPENDS "${QWT_SRC_DIR}/*.cpp") +file(GLOB _qwt_headers CONFIGURE_DEPENDS "${QWT_SRC_DIR}/*.h") +# 排除:OpenGL 画布 / SVG plot item / QwtPolar 部件(避免额外模块依赖与面积)。 +# 保留 qwt_symbol.cpp(用 QSvgRenderer,故链 Qt6::Svg)与 qwt_point_polar.cpp(几何工具)。 +list(FILTER _qwt_sources EXCLUDE REGEX "qwt_plot_glcanvas|qwt_plot_opengl_canvas|qwt_plot_svgitem|qwt_polar_") +list(FILTER _qwt_headers EXCLUDE REGEX "qwt_plot_glcanvas|qwt_plot_opengl_canvas|qwt_plot_svgitem|qwt_polar_") + +add_library(qwt STATIC ${_qwt_sources} ${_qwt_headers}) +set_target_properties(qwt PROPERTIES AUTOMOC ON) +target_include_directories(qwt PUBLIC "${QWT_SRC_DIR}") +target_link_libraries(qwt PUBLIC Qt6::Widgets Qt6::Concurrent Qt6::PrintSupport Qt6::Svg) +target_compile_features(qwt PUBLIC cxx_std_17) +if(MSVC) + target_compile_options(qwt PRIVATE /bigobj /EHsc /wd4244 /wd4267 /wd4305 /wd4456) +endif() diff --git a/docs/Geopro3.0_二维图表返工技术方案.md b/docs/Geopro3.0_二维图表返工技术方案.md new file mode 100644 index 0000000..380596f --- /dev/null +++ b/docs/Geopro3.0_二维图表返工技术方案.md @@ -0,0 +1,234 @@ +# Geopro 3.0 桌面客户端 — 数据集详情「二维图表」返工技术方案 + +**版本 v1.0** · 适用范围:数据集详情页的「原数据(散点图)」与「网格数据(填充等值线图)」 +**状态**:返工指令(推翻当前 QGraphicsView 实现,重做) + +--- + +## 0. 给开发(含 AI 编码)的硬约束 — 先读这一段 + +1. **不嵌入 QWebEngine,不引入 Chromium。** 整个客户端的技术路线是 Qt C++ 原生,核心动机包含离线/现场作业能力;把核心数据可视化做成依赖 web 渲染会破坏该前提,禁止。 +2. **推翻当前的裸 `QGraphicsView` + VTK 渲染窗口方案。** 当前 9 个问题的根因是 2D 框架选错,不是"没嵌 web"。 +3. **改用 `QwtPlot` 作为两类图的坐标系框架;等值线多边形用 VTK 算法(仅算法,不开 VTK 渲染窗口)。** +4. **验收标准 = 视觉等价 + 交互一致**,不是字节级像素 diff。原生渲染与 web 端 Plotly 在抗锯齿、字体、亚像素上天生有别,任何工具都无法字节级相同;目标是"用户看不出降级感"。 +5. **不要自由发挥架构。** 本文档把分层、类、职责定死,照做;遇到本文档未覆盖的细节,先问,不要自行另起一套。 + +--- + +## 1. 根因诊断(为什么会做崩) + +当前实现把"图形 + 坐标轴 + 色阶图例"全部糊在一个 `QGraphicsView` 场景里一起做变换,导致系统性错误。裸 `QGraphicsView` 是通用图形框架,**不具备"科学图表"语义**——它不知道什么是固定坐标轴、什么是"仅数据区缩放"、什么是"图例不参与变换"。 + +把用户列的 9 个问题按根因归类: + +| # | 问题 | 根因类别 | 由谁解决 | +|---|---|---|---| +| 1 | 色阶图随大图缩放、与横坐标重叠 | 框架(图例未独立) | QwtPlot 分层 | +| 3 | 拖动时坐标轴跟着动(应只动数据、轴值跟随) | 框架(无数据区/轴分离) | QwtPlot 内置交互 | +| 4 | 一拖动画面就花 | 框架(手写变换 + 重绘错误) | QwtPlot 内置交互 | +| 9 | 图比原版瘦(纵横比错) | 框架(无纵横比控制) | QwtPlot 纵横比锁定 | +| 2 | 配色与原版不一致(离散色带 vs 连续插值) | 实现细节(色阶映射) | QwtLinearColorMap | +| 5 | 等值线数字没显示 | 实现细节(缺标注) | 等值线 label | +| 6 | 原数据/网格数据切换样式被改错 | UI 布局(与图表无关) | 改回页签样式 | +| 7 | 显示异常/等值线开关跑到面板外 | UI 布局(与图表无关) | 归位工具条 | +| 8 | 下方缺「描述」页签 | UI 布局(与图表无关) | 补页签 | + +**关键结论**:9 个问题没有一个需要"嵌 web"才能解决。第 1 类(#1/#3/#4/#9)换对框架即消失;第 2 类(#2/#5)是渲染细节;第 3 类(#6/#7/#8)是纯 UI,跟用什么画图无关。 + +--- + +## 2. 目标架构 — 三层分离 + +整个二维图表 widget 必须做成**三层分离**,这是治本: + +``` +┌─────────────────────────────────────────────┐ +│ ChartPanel (QWidget) │ +│ ┌───────────────────────────────────────┐ │ +│ │ 工具条 ToolBar(网格/色阶配置/白化/...) │ │ ← 工具条层(属于面板,不属于绘图区) +│ ├───────────────────────────────────────┤ │ +│ │ QwtPlot(数据区 + 固定坐标轴) │ │ ← 绘图层(QwtPlot 管坐标轴与交互) +│ │ • QwtPlotItem: 等值线多边形 / 散点 │ │ +│ │ • QwtPlotMagnifier / Panner(交互) │ │ +│ ├───────────────────────────────────────┤ │ +│ │ ColorBarWidget(独立固定) │ │ ← 图例层(独立 widget,永不参与数据区变换) +│ └───────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**铁律**: +- **坐标轴**由 QwtPlot 管理,**永远固定**(不随拖动移动其位置),拖动/缩放时只有**数据区内容**平移缩放,**轴上的刻度数值自动跟随重算**。这正是 QwtPlot 的默认行为,也正是原版的行为。 +- **色阶条(ColorBar)是独立 widget**,放在绘图区下方,**绝不放进 QwtPlot 的数据坐标系**,因此永远不会随图缩放、不会和横坐标重叠(直接消灭 #1)。 +- **工具条属于面板**,不属于绘图区(修正 #7)。 + +--- + +## 3. 框架与依赖 + +| 用途 | 选用 | 协议 | 说明 | +|---|---|---|---| +| 2D 图表坐标系框架 | **QwtPlot(Qwt 6.2+)** | QWT License(LGPL + 例外,商用免费) | 坐标轴、缩放、平移、刻度跟随、图例、色阶映射全内置 | +| 等值线多边形算法 | **vtkBandedPolyDataContourFilter** | BSD(商用免费) | 仅当算法用,**不开 VTK 渲染窗口** | +| 等值线(线)算法 | vtkContourFilter / vtkFlyingEdges2D | BSD | 生成等值线及其标注位置 | +| 连续色阶映射 | **QwtLinearColorMap** | QWT License | 对齐原版 Plotly 的连续插值色阶 | + +> 协议全部商用友好,零授权费。QWT 与 VTK 都不触发开源传染。 + +> **VTK 的双重角色**(务必理解,否则又会做重):在 3D 视图 / 地图视图里 VTK 是**渲染器**(开渲染窗口);在本页这种纯二维详情图里,VTK **退化为算法库**(只用它的 filter 算多边形,取出顶点交给 QwtPlot 画)。本页**不允许**出现 QVTKOpenGLWidget / 渲染窗口。 + +--- + +## 4. 逐项解法(对应 9 个问题) + +### 4.1 色阶条独立固定(治 #1) + +- 新建 `ColorBarWidget : public QWidget`,自绘一条水平色带 + 刻度数值 + 单位。 +- 它在布局上位于 QwtPlot **下方**,是**兄弟 widget**,不是 QwtPlot 的内部 item。 +- 它的色阶**与绘图区用同一个色阶对象**(见 §6 色阶服务),保证图与图例配色一致。 +- 因为它不在数据坐标系内,**永远不随拖动/缩放移动,不会与横坐标轴重叠**。 +- 刻度数值与原版对齐:用与数据相同的分级断点(原版色阶断点形如 17.105 / 25.937 / ...,等比/对数分布,见 §6)。 + +### 4.2 仅数据区缩放、坐标轴固定、轴值跟随(治 #3) + +- 用 QwtPlot,坐标轴(`QwtPlot::xBottom` / `yLeft`)由 QwtPlot 框架绘制,位置固定。 +- 缩放/平移用 QwtPlot 内置: + - `QwtPlotMagnifier`(滚轮缩放数据区) + - `QwtPlotPanner`(拖动平移数据区) + - 需要框选缩放可加 `QwtPlotZoomer` +- 这些组件只改变**坐标轴的数值范围(scaleDiv)**,QwtPlot 自动重算刻度并重绘——**轴不动、轴上的数字跟着变**,与原版完全一致。 +- **原数据 = 四象限坐标系**(数据含正负,x/y 轴交叉于原点区域);**网格数据 = 单象限**(数据全正,原点在左下)。在 QwtPlot 里就是设置不同的轴范围与原点位置,不需要两套框架。 + +### 4.3 拖动不再花屏(治 #4) + +- 当前"花屏"是手写 `QGraphicsView` 变换 + 重绘时机错误导致。改用 QwtPlot 后,重绘由 QwtPlot 的 replot 机制统一管理,不会出现脏区残留。 +- 大数据量等值线多边形若导致拖动卡顿:对 `QwtPlotItem` 开启 `QwtPlotItem::RenderAntialiased` 视情况关闭、并启用绘制缓存;必要时对多边形做 LOD 简化(与原版"简化容差"滑块一致,见 §7)。 + +### 4.4 纵横比 / 宽度修正(治 #9) + +- 原版图是"宽扁"的剖面图(x 范围远大于 y)。当前实现"偏瘦"是因为没有锁定数据纵横比、或 widget 尺寸策略错误。 +- 解法:根据数据的 x/y 跨度设定数据区的纵横比;若要求等比例(1 个数据单位 x = 1 个数据单位 y 的像素),自定义布局在 replot 时按数据范围调整 canvas 的宽高比,或固定 y 轴范围、x 轴自适应填充。 +- 先对齐原版的"宽扁"观感即可,不必强制物理等比,除非业务要求等比例尺。 + +### 4.5 连续插值色阶,对齐 Plotly 配色(治 #2) + +- 原版散点/等值线用的是**连续插值色阶**(Plotly 的 colorscale),当前实现用了**离散色带**,所以配色不同。 +- 用 `QwtLinearColorMap`,设置与原版相同的**控制点颜色 + 位置**,开启连续插值(`QwtLinearColorMap` 默认 `ScaledColors` 连续模式,不要用 `FixedColors`)。 +- 控制点取自原版色阶(紫蓝→蓝→青→绿→黄→橙→红→深红的彩虹序列)。**必须从原版抓取实际色值**(见 §6 第 4 步),不要凭记忆配色。 +- 色阶分布是**对数/等比**的(断点 17.105, 25.937, 34.382... 比值递增),映射时对数据取 log 再线性映射,否则颜色分布会与原版不同。 + +### 4.6 等值线数值标注(治 #5) + +- 等值线(contour line)由 `vtkContourFilter` 在各分级阈值生成线,取出线的折点。 +- 在 QwtPlot 中用自定义 `QwtPlotItem` 绘制这些线,并在线的合适位置绘制数值标签(label):沿线取中点或曲率较小处,绘制旋转贴合线方向的文本(原版标签是贴着等值线方向旋转的)。 +- 受工具条「显示等值线标注」复选框控制开关。 +- 「显示等值线提示信息」控制 hover 时的 tooltip(鼠标移到等值线上显示数值),与原版一致。 + +### 4.7 页签切换样式归位(治 #6) + +- 「原数据 / 网格数据」切换**不是两个按钮**,而是与「数据集面板的 数据/文件」「右栏的 异常/对象属性」**同一种下划线页签样式**(之前已专门调过样式,复用那套 QSS/组件,不要重写成 QPushButton)。 +- 直接复用既有的下划线 Tab 组件类,保证全客户端切换样式统一。 + +### 4.8 工具条归位到网格数据面板内(治 #7) + +- 「显示异常 / 显示等值线标注 / 显示等值线提示信息 / 简化容差滑块 / 网格 / 色阶配置 / 白化 / 滤波处理 / 异常标注 / 自动标注 / 另存为」这些**属于网格数据视图的工具条**,必须在网格数据面板**内部**的工具条上,不能浮到外层。 +- 「原数据」页签的工具条是另一套(网格 / 色阶配置 / 当前图形下拉 / 另存为),各自归属各自页签。 + +### 4.9 下方补「描述」页签(治 #8) + +- 图下方区域是一个含两个页签的容器:**「异常列表」+「描述」**,当前只做了异常列表表格,缺「描述」。 +- 补上「描述」页签(富文本/纯文本说明区),与原版结构一致。 + +--- + +## 5. 组件清单与职责(定死,不要改结构) + +| 类 | 基类 | 职责 | +|---|---|---| +| `DatasetDetailPanel` | QWidget | 数据集详情页根容器:顶部下划线页签(原数据/网格数据)+ 内容区 + 下方(异常列表/描述)| +| `RawDataChartView` | QWidget | 「原数据」散点图:工具条 + QwtPlot(四象限)+ ColorBar | +| `GridDataChartView` | QWidget | 「网格数据」等值线图:工具条 + QwtPlot(单象限)+ ColorBar | +| `ContourPlotItem` | QwtPlotItem | 自定义:绘制 VTK 算出的分层填充多边形 + 等值线 + 标注 | +| `ScatterPlotItem` | QwtPlotItem / QwtPlotSpectroCurve | 散点,按值连续着色 | +| `ColorBarWidget` | QWidget | 独立色阶条(固定,不入数据坐标系)| +| `ColorMapService` | (单例/服务) | 全局色阶:断点、颜色、命名保存、对数映射;图与图例同源 | +| `ContourEngine` | (封装 VTK) | 输入网格数据 → 输出分层多边形 + 等值线折点(封装 vtkBandedPolyDataContourFilter / vtkContourFilter)| + +--- + +## 6. 色阶服务(对齐原版的关键) + +色阶是"配色一致"的命门,单独抽成服务,**图、图例、3D 视图共用同一份**(呼应视觉设计规范 §8): + +1. **断点(分级阈值)**:采用原版的对数/等比分布。原版断点示例:`17.105, 25.937, 34.382, 35.972, 42.555, 49.051, 56.312, 68.950, 83.029, 98.454, 110.570, 129.660, 158.270, 204.160, 260.820, 321.710, 1335.500`(不同数据集断点不同,应由数据的 min/max + 分级规则动态生成,规则与原版一致)。 +2. **颜色控制点**:彩虹序列(深蓝 → 蓝 → 亮蓝 → 青 → 浅绿 → 绿 → 黄绿 → 黄 → 土黄 → 橙 → 黄 → 红 → 深红 → 紫红 → 紫 → 深紫黑)。 +3. **映射方式**:连续插值(`QwtLinearColorMap` ScaledColors)+ 对数标度(数据先 log,再线性映射到 [0,1])。 +4. **必须从原版抓真实色值**:打开原版页面 `http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo?id=...&ddCode=dd_inversion_data`,用浏览器开发者工具检查 Plotly 的 `colorscale` 配置,或对色阶条截图取色(每个色块取中心像素 RGB)。**严禁凭记忆配色** —— 这是 #2 反复出错的根源。 +5. **命名保存**:色阶可命名、保存、切换,调整后图与图例实时刷新(业务规约要求)。 + +--- + +## 7. 工具条功能对照(两个页签各一套) + +**原数据页签工具条**:网格 | 色阶配置 | 当前图形(下拉:散点图/...)| 另存为 + +**网格数据页签工具条**:网格 | 色阶配置 | 白化 | 滤波处理 | ☑显示异常 | ☑显示等值线标注 | ☐显示等值线提示信息 | 简化容差(滑块,0–1)| 异常标注 | 自动标注 | 另存为 + +- 「简化容差」滑块:控制等值线多边形的简化程度(值越大,多边形越简化、越平滑),对应 VTK 侧对多边形做 `vtkDecimatePolylineFilter` 或道格拉斯-普克简化的容差参数。 +- 「白化」「滤波处理」是对网格数据的预处理操作,作用于 ContourEngine 的输入。 +- 「自动标注」弹出对话框(见 §8)。 + +--- + +## 8. 自动标注对话框(复用同一图表组件) + +- 「自动标注」弹出的对话框里那张图,**必须与主页面网格数据图完全一致**——做法是**复用同一个 `GridDataChartView` / `ContourPlotItem` 类的另一个实例**,传入同一份数据 + 同一个 ColorMapService,**不要另写一套渲染**(否则又会出现两张图不一致)。 +- 对话框结构:左侧「异常判定规则」(阈值模式 数值/百分位、最小点数、异常类型)+ 右侧统计信息(最大/最小/均值/中位数)+ 图(同主图)+ 自动标注结果列表 + 底部(取消/执行自动标注/确认保存)。 +- 因为是普通 QWidget(QwtPlot 而非 VTK 窗口),嵌进对话框无任何 OpenGL 上下文问题,开关对话框不会闪烁/泄漏。 + +--- + +## 9. 实施顺序(建议分步验收,避免再次大返工) + +> 每一步做完就和原版对照截图验收,通过再进入下一步。不要一次性全做完再看。 + +1. **搭三层骨架**:DatasetDetailPanel + 两个页签(下划线样式)+ 下方(异常列表/描述)。先不画图,验证 UI 结构对齐(治 #6/#7/#8)。 +2. **QwtPlot 接入 + 交互**:空 QwtPlot,配好固定坐标轴 + Magnifier/Panner,验证"拖动只动数据、轴值跟随、不花屏、纵横比对"(治 #3/#4/#9)。 +3. **ColorBar 独立**:加 ColorBarWidget 固定在下方,验证缩放时色阶条不动、不与轴重叠(治 #1)。 +4. **色阶服务 + 抓原版色值**:实现 ColorMapService,从原版抓真实色值与断点,连续对数映射(治 #2 的基础)。 +5. **散点图**:原数据页用 ScatterPlotItem 连续着色,对照原版配色(治 #2)。 +6. **等值线图**:ContourEngine(VTK 算多边形)+ ContourPlotItem 绘制填充 + 等值线 + 标注(治 #2/#5)。 +7. **工具条联动**:显示异常/等值线/提示、简化容差滑块、白化、滤波接通。 +8. **自动标注对话框**:复用图表组件 + 规则面板 + 统计 + 结果。 +9. **整体对照验收**:原数据、网格数据分别与原版并排截图,核对配色/布局/交互/标注/纵横比。 + +--- + +## 10. 验收标准(写清楚,避免预期错位) + +**通过标准 = 视觉等价 + 交互一致**: + +- [ ] 配色与原版视觉一致(同一色阶、连续插值、对数分布) +- [ ] 坐标轴固定,拖动/缩放只动数据区,轴刻度值跟随 +- [ ] 色阶条固定在轴下方,不随图缩放,不与坐标轴重叠 +- [ ] 等值线数值标注显示,贴合线方向 +- [ ] 原数据=四象限、网格数据=单象限,缩放/移动行为分别与原版一致 +- [ ] 纵横比/宽扁观感与原版一致 +- [ ] 拖动流畅、无花屏 +- [ ] 页签切换为下划线样式(与数据/文件、异常/属性一致) +- [ ] 工具条开关位于各自页签面板内 +- [ ] 下方含「异常列表 + 描述」两个页签 +- [ ] 自动标注对话框内图与主图完全一致 + +**不作为通过标准**(明确排除,避免无意义返工): + +- ✗ 与 web 端逐像素 diff 相同(抗锯齿/字体/亚像素天生有别,任何原生方案都无法做到,非缺陷)。 + +--- + +## 11. 一句话方向 + +> 不嵌 Chromium。推翻 QGraphicsView,用 **QwtPlot(坐标系/交互/图例)+ VTK 算法(等值线多边形)+ 连续对数色阶(对齐原版)**,三层分离(绘图区 / 固定坐标轴 / 独立色阶条),UI 三处(页签样式/工具条归位/描述页)按原版恢复。验收按"视觉等价 + 交互一致",非字节级。 + +--- + +*本方案针对当前返工,钉死架构与分层,禁止在图表渲染框架上再自由发挥。未覆盖的细节先确认再实现。* diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3c092ba..e7c13fb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -80,6 +80,12 @@ target_sources(geopro_tests PRIVATE render/test_terrain.cpp) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) +# Qwt 集成冒烟(仅当 qwt 目标存在,即 external/qwt-src 已拉取)。 +if(TARGET qwt) + target_sources(geopro_tests PRIVATE render/test_qwt_smoke.cpp) + target_link_libraries(geopro_tests PRIVATE qwt) +endif() + target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) diff --git a/tests/render/test_qwt_smoke.cpp b/tests/render/test_qwt_smoke.cpp new file mode 100644 index 0000000..8dcf0c2 --- /dev/null +++ b/tests/render/test_qwt_smoke.cpp @@ -0,0 +1,10 @@ +#include + +#include + +// 冒烟:验证项目能编译 Qwt 头文件并链接 Qwt 静态库(QwtText 为值类型,无需 QApplication)。 +// 保障 Qwt 集成(cmake/qwt.cmake + 官方 Qt6)持续可用,不回归。 +TEST(QwtSmoke, LinksAgainstQwt) { + QwtText t(QStringLiteral("hello")); + EXPECT_EQ(t.text(), QStringLiteral("hello")); +} From e0c36e36001a90fb1319e207d0226936a1b5fa21 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 14:54:57 +0800 Subject: [PATCH 20/74] =?UTF-8?q?docs(plan):=20=E4=BA=8C=E7=BB=B4=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E8=BF=94=E5=B7=A5=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= =?UTF-8?q?=20v2=EF=BC=88QwtPlot=20=E4=B8=89=E5=B1=82=E5=88=86=E7=A6=BB?= =?UTF-8?q?=EF=BC=8C9=E6=AD=A5=E5=AF=B9=E7=85=A7=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-11-dataset-detail-chart-v2-qwt.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md b/docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md new file mode 100644 index 0000000..3a39bf4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md @@ -0,0 +1,205 @@ +# 数据集详情「二维图表」返工实现计划 v2(QwtPlot) + +> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development 或 executing-plans 执行。步骤用 `- [ ]`。 +> **铁律(来自返工方案):** 每一步做完**必须**与原版对照验收,通过再进下一步——不许一次性做完才看。任何对原版的不确定,**必须用 Playwright 实地学习原版**,禁止联想猜测。 + +**Goal:** 推翻当前 QGraphicsView 二维图表,按《Geopro3.0_二维图表返工技术方案.md》用 **QwtPlot(坐标系/交互/图例三层分离)+ VTK 算法(等值线多边形)+ 连续对数色阶** 重做「原数据散点图」与「网格数据等值线图」,修复用户列出的 9 个问题,达到**视觉等价 + 交互一致**(非字节级像素)。 + +**Architecture:** 三层分离——工具条(属于面板)/ QwtPlot(数据区 + 固定坐标轴 + Magnifier/Panner 交互)/ 独立 ColorBarWidget(不入数据坐标系)。等值线多边形复用已有 `ContourBands`(VTK 算法);色阶用 `QwtLinearColorMap` 连续插值 + 对数标度,图与图例同源(ColorMapService)。 + +**Tech Stack:** C++17、Qt6 Widgets、**Qwt 6.2(已集成,见 cmake/qwt.cmake,目标名 `qwt`,头在 external/qwt-src/src)**、VTK 9.6(仅算法)、GoogleTest/CTest。 + +**依据:** `docs/Geopro3.0_二维图表返工技术方案.md`(架构权威)、`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(数据/接口)。 + +--- + +## 0. 复用与替换盘点(不要重造已有的) + +**复用(不动):** +- `src/data/api/ApiDatasetRepository.*`、`src/data/dto/DatasetChartDto.*`、`src/controller/DatasetDetailController.*`(数据层+编排,`ChartData` 已带 scatter/grid/colorScale×2/anomalies)。 +- `src/render/ContourBands.*`(VTK 算分层多边形+等值线几何,作为等值线图的几何来源 = 方案的 ContourEngine)。 +- `src/core/model/{Field,ColorScale,Anomaly}.hpp`、`src/app/panels/AnomalyTablePanel.*`(异常表,列对,复用)。 +- 既有下划线页签组件 `buildTabbedPanel`(main.cpp 中「异常/对象属性」面板用的同款,§步骤1 复用)。 + +**替换(删除/重写):** +- ❌ `src/app/panels/chart/DatasetChartView.*`(裸 QGraphicsView)—— 删除。 +- ❌ `src/app/panels/DatasetDetailPage.*`(旧两按钮+图表)—— 重写为基于 QwtPlot 的视图。 +- `src/app/panels/DatasetDetailPanel.*`、`main.cpp` 接线 —— 按新组件调整。 + +--- + +## 文件结构(新建/修改) + +| 文件 | 职责 | +|---|---| +| `src/app/panels/chart/ColorMapService.hpp/.cpp`(建) | 色阶服务:断点+颜色、连续对数映射、产出 `QwtLinearColorMap` 与离散色带;图/图例同源 | +| `src/app/panels/chart/ColorBarWidget.hpp/.cpp`(建) | 独立色阶条 QWidget(固定,不入数据坐标系) | +| `src/app/panels/chart/ScatterPlotItem.hpp/.cpp`(建) | QwtPlotItem:散点方块按连续色阶着色 | +| `src/app/panels/chart/ContourPlotItem.hpp/.cpp`(建) | QwtPlotItem:画 ContourBands 分层填充多边形 + 等值线 + 标注 + 异常叠加 | +| `src/app/panels/chart/RawDataChartView.hpp/.cpp`(建) | 原数据页:工具条 + QwtPlot + ScatterPlotItem + ColorBarWidget | +| `src/app/panels/chart/GridDataChartView.hpp/.cpp`(建) | 网格数据页:工具条 + QwtPlot + ContourPlotItem + ColorBarWidget | +| `src/app/panels/DatasetDetailPage.hpp/.cpp`(重写) | 单 ds 页:下划线页签(原数据/网格数据) + 两视图 + 下方(异常列表/描述) | +| `src/app/panels/DatasetDetailPanel.hpp/.cpp`(改) | 多 Tab 壳(保留 dsId 去重/反向联动) | +| `src/app/panels/DescriptionPanel.hpp/.cpp`(建) | 下方「描述」页(纯文本/只读富文本占位) | +| `src/app/panels/chart/DatasetChartView.*`(删) | 旧 QGraphicsView 渲染器 | +| `src/app/CMakeLists.txt`(改) | 注册新文件 + 链接 `qwt`;移除 DatasetChartView | +| 测试 | ColorMapService 对数映射 / ContourBands 既有 / 散点着色 单测 | + +**构建/测试命令(本机工具链):** +- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +- 运行(视觉验收):`build/release/src/app/geopro_desktop.exe`(需登录) + +**Qwt 关键类参考**(实现时可查 `external/qwt-src/src/` 头文件确认签名):`QwtPlot`、`QwtPlotItem`、`QwtPlotMagnifier`、`QwtPlotPanner`、`QwtPlotZoomer`、`QwtLinearColorMap`、`QwtScaleEngine`/`QwtLogScaleEngine`、`QwtText`、`QwtPlotCanvas`。 + +--- + +## 步骤 1:三层骨架 + UI 结构(治 #6 #7 #8) + +**目标**:先把布局结构做对,不画图。下划线页签、工具条归位、下方双页签。 + +**研究原版(Playwright)**:对照 `assets/web-datasetinfo-*.png` 与实测——确认 (a) 原数据/网格数据 = 下划线页签(非按钮);(b) 两个页签各自的工具条项(§方案7);(c) 下方 = 异常列表 + 描述 双页签。 + +**Files:** 重写 `DatasetDetailPage.*`;建 `DescriptionPanel.*`;改 `DatasetDetailPanel.*`、`src/app/CMakeLists.txt`。 + +- [ ] **Step 1.1** 先研究:用 Playwright 打开 `datasetInfo?id=1439812897218560&ddCode=dd_inversion_data`,截图原数据/网格两页签的工具条与下方区域,确认结构(不靠记忆)。 +- [ ] **Step 1.2** 复用 `buildTabbedPanel`(main.cpp 中「异常/对象属性」用的下划线页签)做 `DatasetDetailPage` 顶部「原数据/网格数据」切换;内容区先放两个空 `QWidget` 占位(RawDataChartView/GridDataChartView 后续填)。 +- [ ] **Step 1.3** 下方用同款 `buildTabbedPanel` 做「异常列表(现有 `AnomalyTablePanel`)/ 描述(新 `DescriptionPanel`,先放只读文本占位)」。 +- [ ] **Step 1.4** 工具条:原数据页工具条 = [网格][色阶配置][当前图形▼][另存为];网格数据页工具条 = [网格][色阶配置][白化][滤波处理][☑显示异常][☑显示等值线标注][☐显示等值线提示信息][简化容差滑块][异常标注][自动标注][另存为]。**先只放按钮/控件占位(无功能)**,位置在各自页签视图内部顶部。 +- [ ] **Step 1.5** 构建通过;启动 app 双击 ds,**对照原版验收**:页签是下划线样式、工具条在各自页签内、下方有两个页签。截图发用户确认后再进步骤 2。 + +--- + +## 步骤 2:QwtPlot 接入 + 交互(治 #3 #4 #9) + +**研究原版(Playwright,关键——消除「四象限」歧义)**: +- [ ] **Step 2.1** 在原版散点页拖动/缩放,观察:坐标轴是否固定、刻度值是否跟随、能否平移到数据范围外(负区)。抓 Plotly `layout.xaxis/yaxis`(range/scaleanchor/autorange/side)。**判定原数据到底是「真四象限」还是「单象限+自由平移」**,网格数据同样观察。把结论记入本步骤再实现,不靠猜。 + +**Files:** `RawDataChartView.*`、`GridDataChartView.*`(先只含 QwtPlot 空图 + 交互);`src/app/CMakeLists.txt` 链 `qwt`。 + +- [ ] **Step 2.2** 两视图各内嵌一个 `QwtPlot`。按步骤2.1结论设轴:网格数据 = 单象限(x/y 从数据 min,原点左下);原数据 = 按结论(四象限则轴范围含负、十字交叉;单象限则同网格)。 +- [ ] **Step 2.3** 加 `QwtPlotMagnifier(plot->canvas())`(滚轮缩放)+ `QwtPlotPanner(plot->canvas())`(拖动平移)。确认:**轴位置不动、只有数据区平移缩放、刻度数值自动跟随**(QwtPlot 默认行为)。 +- [ ] **Step 2.4** 纵横比(#9):原版剖面"宽扁"。先用 `plot->setAxisScale` 固定 y 数据范围、x 自适应填充宽度;若需等比例尺,在 resize/replot 时按数据 x/y 跨度调 canvas 宽高(方案 §4.4)。 +- [ ] **Step 2.5** 构建+运行,**对照原版验收**:拖动只动数据、不花屏、轴值跟随、宽扁比例接近。截图确认后进步骤 3。 + +--- + +## 步骤 3:独立色阶条 ColorBarWidget(治 #1) + +**Files:** `ColorBarWidget.*`;放进 Raw/GridDataChartView 布局中 QwtPlot **下方**(兄弟 widget)。 + +- [ ] **Step 3.1** `ColorBarWidget : QWidget`,`paintEvent` 自绘一条水平色带(暂用占位色阶)+ 刻度数值。固定高度(~36px:色条+文字)。 +- [ ] **Step 3.2** 布局:`QVBoxLayout`= [工具条][QwtPlot stretch][ColorBarWidget 固定高]。**ColorBar 不入 QwtPlot 坐标系**。 +- [ ] **Step 3.3** 运行验收:缩放/拖动主图时色阶条**不动、不与横轴重叠**(治 #1)。截图确认进步骤 4。 + +--- + +## 步骤 4:ColorMapService + 抓原版真实色值(治 #2 基础) + +**研究原版(Playwright,关键)**: +- [ ] **Step 4.1** 抓原版色值与映射方式(**严禁凭记忆配色**): + - 散点页:抓 Plotly `data[0].marker.colorscale`([[pos,color],…])、`cmin/cmax`、是否连续;判断标度(线性 vs 对数)——对色阶条断点 `17.105,25.937,…` 做比值检验确认对数/等比。 + - 网格页:色阶来自 `colorGradation/getDetail`(已知 `properties.colorBar=[[值,rgba],…]`、type1 原数据/type2 网格)。 + - 取色兜底:对色阶条截图每格中心取 RGB。 + +**Files:** `ColorMapService.hpp/.cpp`;测试 `tests/app/test_colormap_service.cpp`。 + +- [ ] **Step 4.2 写失败测试**(对数映射): +```cpp +#include +#include "panels/chart/ColorMapService.hpp" +using geopro::app::ColorMapService; +TEST(ColorMapService, LogMapsValueToUnitInterval) { + ColorMapService s; + s.setRange(10.0, 1000.0, /*logScale=*/true); + EXPECT_NEAR(s.normalized(10.0), 0.0, 1e-9); + EXPECT_NEAR(s.normalized(1000.0), 1.0, 1e-9); + EXPECT_NEAR(s.normalized(100.0), 0.5, 1e-9); // log10 中点 +} +``` +- [ ] **Step 4.3** 注册测试到 `tests/CMakeLists.txt`(app 段,已 `target_include_directories ... src/app`);跑确认失败(编译失败)。 +- [ ] **Step 4.4 实现** `ColorMapService`: + - 持 `core::ColorScale`(离散断点+颜色,来自 colorBar)+ `vmin/vmax/logScale`。 + - `double normalized(double v)`:log 时 `(log(v)-log(vmin))/(log(vmax)-log(vmin))`,否则线性;clamp [0,1]。 + - `QwtLinearColorMap* makeQwtColorMap()`:用断点的归一化位置 + 颜色 `addColorStop`,连续插值(默认 ScaledColors)。 + - `core::Rgba colorAt(double v)`:连续插值取色(散点用);以及离散 `bandColor`(等值线用)。 + - 图与图例(ColorBarWidget)+ 等值线图都用**同一个 ColorMapService 实例**。 +- [ ] **Step 4.5** 跑测试通过;提交。 + +--- + +## 步骤 5:原数据散点图(治 #2) + +**研究原版(Playwright)**:确认散点 = 方块 symbol、白描边、按 vlist 连续着色、x:y 关系(步骤2结论)。 + +**Files:** `ScatterPlotItem.*`;接入 `RawDataChartView`。 + +- [ ] **Step 5.1** `ScatterPlotItem : public QwtPlotItem`,重写 `draw(QPainter*, xMap, yMap, canvasRect)`:对每个点 `(xlist[i],ylist[i])` 用 `xMap.transform/yMap.transform` 映射到像素,画固定像素边长的方块(白描边 1px),填色 = `ColorMapService::colorAt(vlist[i])`。`rtti()` 返回自定义。 +- [ ] **Step 5.2** `RawDataChartView::setData(scatter, colorMapService)`:建 ScatterPlotItem attach 到 QwtPlot,设轴范围为数据包围盒,`replot()`;ColorBarWidget 用同一 service 刷新。 +- [ ] **Step 5.3** 运行,**与原版散点并排对照配色**(#2):颜色应连续插值、与原版一致。截图确认进步骤 6。 + +--- + +## 步骤 6:网格数据等值线图(治 #2 #5) + +**研究原版(Playwright)**:确认填充色带(离散 banded)+ 黑色等值线 + **线上数值标注**(贴线方向)+ 数据凸包白边。 + +**Files:** `ContourPlotItem.*`(复用 `ContourBands`);接入 `GridDataChartView`。 + +- [ ] **Step 6.1** `ContourPlotItem : QwtPlotItem`,持 `ContourBandsResult`(来自 `geopro::render::buildContourBands(grid, colorScale, opt)`)。`draw()`: + - 填充:每个 `BandPolygon` 用 xMap/yMap 把局部坐标映射到像素,`painter->drawPolygon`,填 `band.color`(离散)。 + - 等值线:每条 `ContourLine` `drawPolyline`(黑、细);受「显示等值线标注」控制在线中段绘制旋转贴线的数值文本(#5)。 +- [ ] **Step 6.2** 异常叠加:把 ds 异常(`core::Anomaly`,局部坐标)按 markType 画在同一 item 之上(虚线多段线/多边形/点),受「显示异常」控制。 +- [ ] **Step 6.3** `GridDataChartView::setData(grid, gridColorMapService, anomalies)`:构建 ContourPlotItem attach;轴=数据范围;ColorBar 用网格色阶(type2)。 +- [ ] **Step 6.4** 运行,**与原版网格图并排对照**(#2 配色、#5 标注、凸包白边、宽扁比例)。截图确认进步骤 7。 + +--- + +## 步骤 7:工具条联动(显示开关 + 简化容差) + +- [ ] **Step 7.1** 「显示异常 / 显示等值线标注 / 显示等值线提示信息」复选框 → 切 ContourPlotItem 对应开关 + replot。 +- [ ] **Step 7.2** 「简化容差」滑块(0–1)→ 改 `ContourOptions::simplifyTol` 重算 ContourBands + replot(对应原版滑块)。 +- [ ] **Step 7.3** 「白化 / 滤波处理 / 网格 / 色阶配置 / 异常标注 / 自动标注 / 另存为」**先做占位按钮**(弹「待实现」或留空槽),功能后续单独立项。运行验收开关行为与原版一致。 + +--- + +## 步骤 8:接线 DatasetDetailController + main.cpp(替换旧渲染) + +- [ ] **Step 8.1** `DatasetDetailPage::setData(ChartData)`:分别喂 `RawDataChartView`(scatter + scatterColorScale)与 `GridDataChartView`(grid + gridColorScale + anomalies);异常列表喂 `AnomalyTablePanel`。 +- [ ] **Step 8.2** `main.cpp`:删除对旧 `DatasetChartView`/`DatasetDetailPage` 的引用;`DatasetDetailPanel` 改用新 `DatasetDetailPage`。`chartReady`→`openOrUpdate` 链路不变。删除 `src/app/panels/chart/DatasetChartView.*` 及其 CMake 注册。 +- [ ] **Step 8.3** 全量构建 + `dev-test.ps1` 不回归;启动 app 双击 ds **整体对照验收**。 + +--- + +## 步骤 9:整体对照验收(治全部 9 项) + +- [ ] **Step 9.1** 原数据、网格数据分别与原版并排截图,逐条核对方案 §10 验收清单:配色/轴固定/色阶条固定/标注/坐标系象限/宽扁比例/拖动流畅/页签样式/工具条归位/下方双页签。 +- [ ] **Step 9.2** cpp-reviewer 审查本次改动;处理 CRITICAL/HIGH。 +- [ ] **Step 9.3** 收尾提交。 + +--- + +## 验收标准(方案 §10,视觉等价 + 交互一致,非字节级) + +- [ ] 配色与原版视觉一致(连续插值、对数分布、同色阶) +- [ ] 轴固定、拖动/缩放只动数据区、刻度值跟随、不花屏 +- [ ] 色阶条固定轴下方、不随图缩放、不与轴重叠 +- [ ] 等值线数值标注显示、贴线方向 +- [ ] 原数据/网格数据坐标系象限与缩放行为分别与原版一致 +- [ ] 宽扁比例与原版一致 +- [ ] 页签下划线样式、工具条各自页签内、下方异常列表+描述双页签 +- [ ] 全量测试不回归 +- ✗(明确不作为标准):与 web 逐像素 diff 相同 + +--- + +## 自审(Spec/方案 覆盖核对) + +- 9 个问题逐条 → 步骤 1(#6/#7/#8)、2(#3/#4/#9)、3(#1)、4-5(#2)、6(#2/#5) 全覆盖。✓ +- 复用项(ContourBands/数据层/AnomalyTablePanel/buildTabbedPanel)已标注,不重造。✓ +- 替换项(DatasetChartView/旧 DatasetDetailPage)明确删除。✓ +- 每步含「Playwright 研究原版 + 对照验收」检查点(铁律)。✓ +- 可测部分(ColorMapService 对数映射)有 TDD;视觉部分以对照验收为准(方案明确视觉等价非像素级)。✓ +- 待澄清/研究项(步骤 2.1 四象限歧义、步骤 4.1 真实色值与标度)已显式列为开工先研究项,不靠猜。✓ + +> 工具功能(白化/滤波/自动标注/网格化/色阶配置/另存为/导出)本计划只做**占位归位**(治 #7),功能实现后续单独立项。 From 7808b8422a1b591dddeaa84d09f256335f2b8c4f Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 15:04:34 +0800 Subject: [PATCH 21/74] =?UTF-8?q?feat(ui):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=89=E5=B1=82UI=E9=AA=A8=E6=9E=B6(?= =?UTF-8?q?=E4=B8=8B=E5=88=92=E7=BA=BF=E9=A1=B5=E7=AD=BE+=E5=8E=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE/=E7=BD=91=E6=A0=BC=E5=90=84=E8=87=AA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9D=A1+=E7=BD=91=E6=A0=BC=E9=A1=B5?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=BC=82=E5=B8=B8=E5=88=97=E8=A1=A8/?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 DescriptionPanel:只读 QTextEdit,供网格数据底部「描述」页签使用 - 新建 RawDataChartView:工具条(网格/色阶配置/图形类型ComboBox/另存为) + 图表占位(stretch) + 色阶占位(36px) - 新建 GridDataChartView:工具条(网格/色阶配置/白化/滤波/显示开关/简化容差滑块/异常标注/自动标注/另存为) + 图表占位(stretch) + 色阶占位(36px) + 底部200px双页签(异常列表/描述) - 重写 DatasetDetailPage:用 buildTabbedPanel 做「原数据/网格数据」下划线页签 + 右侧「导出」HeaderAction - 删除 DatasetChartView(已被 RawDataChartView/GridDataChartView 替代) - CMakeLists:注册新 .cpp,移除 DatasetChartView,条件链接 qwt 目标 --- src/app/CMakeLists.txt | 9 +- src/app/panels/DatasetDetailPage.cpp | 64 +++--- src/app/panels/DatasetDetailPage.hpp | 20 +- src/app/panels/DescriptionPanel.cpp | 22 ++ src/app/panels/DescriptionPanel.hpp | 18 ++ src/app/panels/chart/DatasetChartView.cpp | 232 --------------------- src/app/panels/chart/DatasetChartView.hpp | 38 ---- src/app/panels/chart/GridDataChartView.cpp | 120 +++++++++++ src/app/panels/chart/GridDataChartView.hpp | 38 ++++ src/app/panels/chart/RawDataChartView.cpp | 62 ++++++ src/app/panels/chart/RawDataChartView.hpp | 28 +++ 11 files changed, 332 insertions(+), 319 deletions(-) create mode 100644 src/app/panels/DescriptionPanel.cpp create mode 100644 src/app/panels/DescriptionPanel.hpp delete mode 100644 src/app/panels/chart/DatasetChartView.cpp delete mode 100644 src/app/panels/chart/DatasetChartView.hpp create mode 100644 src/app/panels/chart/GridDataChartView.cpp create mode 100644 src/app/panels/chart/GridDataChartView.hpp create mode 100644 src/app/panels/chart/RawDataChartView.cpp create mode 100644 src/app/panels/chart/RawDataChartView.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index ea0f23a..1b82e36 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -26,7 +26,9 @@ add_executable(geopro_desktop WIN32 panels/ObjectTreePanel.cpp panels/DynamicFormView.cpp panels/ObjectExceptionPanel.cpp - panels/chart/DatasetChartView.cpp + panels/DescriptionPanel.cpp + panels/chart/RawDataChartView.cpp + panels/chart/GridDataChartView.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp @@ -51,6 +53,11 @@ target_link_libraries(geopro_desktop PRIVATE geopro_controller # Phase 5:导航编排(WorkbenchNavController) ) +# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。本步骤骨架先链好供后续渲染步骤使用。 +if(TARGET qwt) + target_link_libraries(geopro_desktop PRIVATE qwt) +endif() + vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES}) if(WIN32) diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 6a2f901..2c8b7b7 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -1,52 +1,38 @@ #include "panels/DatasetDetailPage.hpp" + #include -#include -#include -#include -#include -#include -#include "panels/chart/DatasetChartView.hpp" -#include "panels/AnomalyTablePanel.hpp" + +#include "Glyphs.hpp" +#include "PanelHeader.hpp" +#include "panels/chart/GridDataChartView.hpp" +#include "panels/chart/RawDataChartView.hpp" + namespace geopro::app { DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); - auto* bar = new QHBoxLayout(); - auto* origin = new QToolButton(this); origin->setText("原数据"); origin->setCheckable(true); - auto* grid = new QToolButton(this); grid->setText("网格数据"); grid->setCheckable(true); - grid->setChecked(true); - auto* grp = new QButtonGroup(this); grp->setExclusive(true); grp->addButton(origin); grp->addButton(grid); - auto* showAnom = new QCheckBox("显示异常", this); showAnom->setChecked(true); - auto* showLines = new QCheckBox("显示等值线", this); showLines->setChecked(true); - bar->addWidget(origin); bar->addWidget(grid); bar->addStretch(); - bar->addWidget(showAnom); bar->addWidget(showLines); - lay->addLayout(bar); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); - chart_ = new DatasetChartView(this); - anomalyTable_ = new AnomalyTablePanel(this); - lay->addWidget(chart_, 3); - lay->addWidget(anomalyTable_, 1); + rawView_ = new RawDataChartView(this); + gridView_ = new GridDataChartView(this); - connect(grid, &QToolButton::clicked, this, [this] { gridMode_ = true; showGridMode(); }); - connect(origin, &QToolButton::clicked, this, [this] { gridMode_ = false; showScatterMode(); }); - connect(showAnom, &QCheckBox::toggled, chart_, [this](bool on) { chart_->setShowAnomalies(on); }); - connect(showLines, &QCheckBox::toggled, chart_, [this](bool on) { - chart_->setShowContourLines(on); if (gridMode_) showGridMode(); }); - connect(anomalyTable_, &AnomalyTablePanel::hiddenChanged, chart_, - [this](const std::set& h) { chart_->setHiddenAnomalies(h); }); + const QVector tabs = { + {Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, + {Glyph::Dataset, QStringLiteral("网格数据"), gridView_, false}, + }; + const QVector actions = { + {Glyph::Download, QStringLiteral("导出")}, + }; + + auto tabbedPanel = buildTabbedPanel(tabs, actions); + lay->addWidget(tabbedPanel.container); } void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - dsId_ = d.dsId; data_ = d; - chart_->setAnomalies(d.anomalies); - anomalyTable_->setAnomalies(d.anomalies, {}, {}); // 创建时间/备注可后续从 VO 补 - if (gridMode_) showGridMode(); else showScatterMode(); -} -void DatasetDetailPage::showGridMode() { - geopro::render::ContourOptions opt; // 默认 2x+smooth+simplify0.5 - chart_->showContour(data_.grid, data_.gridScale, opt); -} -void DatasetDetailPage::showScatterMode() { - chart_->showScatter(data_.scatter, data_.scatterScale); + dsId_ = d.dsId; + rawView_->setData(d); + gridView_->setData(d); } + } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 72b6727..ba1e29f 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -1,10 +1,14 @@ #pragma once #include -#include "DatasetDetailController.hpp" // ChartData -namespace geopro::app { -class DatasetChartView; class AnomalyTablePanel; +#include "DatasetDetailController.hpp" -// 单个数据集详情页:标题 + 原数据/网格数据 切换 + 叠加开关 + 图表 + 异常表。 +namespace geopro::app { + +class RawDataChartView; +class GridDataChartView; + +// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 +// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。 class DatasetDetailPage : public QWidget { Q_OBJECT public: @@ -12,11 +16,9 @@ public: void setData(const geopro::controller::DatasetDetailController::ChartData& d); QString dsId() const { return dsId_; } private: - void showScatterMode(); void showGridMode(); QString dsId_; - geopro::controller::DatasetDetailController::ChartData data_; - DatasetChartView* chart_; - AnomalyTablePanel* anomalyTable_; - bool gridMode_ = true; + RawDataChartView* rawView_; + GridDataChartView* gridView_; }; + } // namespace geopro::app diff --git a/src/app/panels/DescriptionPanel.cpp b/src/app/panels/DescriptionPanel.cpp new file mode 100644 index 0000000..5289b9d --- /dev/null +++ b/src/app/panels/DescriptionPanel.cpp @@ -0,0 +1,22 @@ +#include "panels/DescriptionPanel.hpp" + +#include +#include + +namespace geopro::app { + +DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(8, 8, 8, 8); + + edit_ = new QTextEdit(this); + edit_->setReadOnly(true); + edit_->setPlaceholderText(QStringLiteral("暂无描述")); + lay->addWidget(edit_); +} + +void DescriptionPanel::setText(const QString& text) { + edit_->setPlainText(text); +} + +} // namespace geopro::app diff --git a/src/app/panels/DescriptionPanel.hpp b/src/app/panels/DescriptionPanel.hpp new file mode 100644 index 0000000..b364fae --- /dev/null +++ b/src/app/panels/DescriptionPanel.hpp @@ -0,0 +1,18 @@ +#pragma once +#include + +class QTextEdit; + +namespace geopro::app { + +// 数据集描述面板:只读文本,供网格数据底部页签「描述」使用。 +class DescriptionPanel : public QWidget { + Q_OBJECT +public: + explicit DescriptionPanel(QWidget* parent = nullptr); + void setText(const QString& text); +private: + QTextEdit* edit_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/DatasetChartView.cpp b/src/app/panels/chart/DatasetChartView.cpp deleted file mode 100644 index d1dff50..0000000 --- a/src/app/panels/chart/DatasetChartView.cpp +++ /dev/null @@ -1,232 +0,0 @@ -#include "panels/chart/DatasetChartView.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -namespace geopro::app { -using geopro::core::Rgba; - -static QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } - -DatasetChartView::DatasetChartView(QWidget* parent) - : QGraphicsView(parent), scene_(new QGraphicsScene(this)) { - setScene(scene_); - setRenderHint(QPainter::Antialiasing, true); - setDragMode(QGraphicsView::ScrollHandDrag); - // 场景 y 向上为正:用 y 翻转的变换(QGraphicsView 默认 y 向下)。 - scale(1, -1); -} - -void DatasetChartView::clearChart() { - // scene_->clear() 删除场景中的所有 item(含 anomalyItems_ 所指向的对象); - // anomalyItems_.clear() 随后清空向量中的悬空指针。 - // 二者必须成对、顺序不可颠倒:若先 clear() 后仍持有指针,rebuildAnomalyItems - // 中的 delete 会对已释放对象 double-free;若先清向量、后继续操作旧指针同样 UB。 - scene_->clear(); anomalyItems_.clear(); -} - -void DatasetChartView::showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs) { - clearChart(); - const double sz = 0.6; // 方块边长(数据单位,近似 web 方点) - const size_t n = std::min({f.x.size(), f.y.size(), f.v.size()}); - for (size_t i = 0; i < n; ++i) { - auto* r = scene_->addRect(f.x[i] - sz / 2, f.y[i] - sz / 2, sz, sz, - QPen(Qt::white, 0), QBrush(toQ(cs.colorAt(f.v[i])))); - r->setPen(QPen(Qt::white, 0)); // 白描边 - } - rebuildAnomalyItems(); - setLegend(cs); - viewport()->update(); - fitInView(scene_->itemsBoundingRect(), Qt::KeepAspectRatio); // x:y 等比 -} - -void DatasetChartView::showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, - const geopro::render::ContourOptions& opt) { - clearChart(); - const auto r = geopro::render::buildContourBands(g, cs, opt); - for (const auto& b : r.bands) { - QPainterPath path; if (b.ring.empty()) continue; - path.moveTo(b.ring[0].x, b.ring[0].y); - for (size_t k = 1; k < b.ring.size(); ++k) path.lineTo(b.ring[k].x, b.ring[k].y); - path.closeSubpath(); - auto* it = scene_->addPath(path, QPen(Qt::NoPen), QBrush(toQ(b.color))); - it->setZValue(0); - } - if (showContourLines_) - for (const auto& l : r.lines) { - if (l.pts.empty()) continue; - QPainterPath path; path.moveTo(l.pts[0].x, l.pts[0].y); - for (size_t k = 1; k < l.pts.size(); ++k) path.lineTo(l.pts[k].x, l.pts[k].y); - auto* it = scene_->addPath(path, QPen(QColor(0, 0, 0), 0)); - it->setZValue(1); - } - rebuildAnomalyItems(); - setLegend(cs); - viewport()->update(); - fitInView(scene_->itemsBoundingRect(), Qt::IgnoreAspectRatio); // 剖面 X/Y 各自铺满 -} - -void DatasetChartView::setAnomalies(const std::vector& list) { - anomalies_ = list; rebuildAnomalyItems(); -} -void DatasetChartView::setHiddenAnomalies(const std::set& hidden) { hidden_ = hidden; rebuildAnomalyItems(); } -void DatasetChartView::setShowAnomalies(bool on) { showAnomalies_ = on; rebuildAnomalyItems(); } -void DatasetChartView::setShowContourLines(bool on) { showContourLines_ = on; } - -void DatasetChartView::rebuildAnomalyItems() { - for (auto* it : anomalyItems_) { scene_->removeItem(it); delete it; } - anomalyItems_.clear(); - if (!showAnomalies_) return; - for (int i = 0; i < static_cast(anomalies_.size()); ++i) { - if (hidden_.count(i)) continue; - const auto& a = anomalies_[i]; - if (a.localPts.empty()) continue; - if (static_cast(a.markType) == 1) { // 点:小方块标记 - const double s = 0.8; - auto* dot = scene_->addRect(a.localPts[0].x - s/2, a.localPts[0].y - s/2, s, s, - QPen(QColor(QString::fromStdString(a.lineColor)), 0), - QBrush(QColor(QString::fromStdString(a.lineColor)))); - dot->setZValue(2); - anomalyItems_.push_back(dot); - continue; - } - if (a.localPts.size() < 2) continue; - QPainterPath path; path.moveTo(a.localPts[0].x, a.localPts[0].y); - for (size_t k = 1; k < a.localPts.size(); ++k) path.lineTo(a.localPts[k].x, a.localPts[k].y); - if (static_cast(a.markType) == 3) path.closeSubpath(); - QPen pen(QColor(QString::fromStdString(a.lineColor)), 0); - if (a.dashed) pen.setStyle(Qt::DashLine); - auto* it = scene_->addPath(path, pen); - it->setZValue(2); - anomalyItems_.push_back(it); - } -} - -void DatasetChartView::wheelEvent(QWheelEvent* e) { - const double f = e->angleDelta().y() > 0 ? 1.15 : 1 / 1.15; - scale(f, f); -} - -// Task 3.2:坐标轴 overlay + 底部色阶图例。 -// drawForeground 在每次 repaint 时调用(缩放/平移后自动重绘),无需手动更新。 -// 轴线"钉"在 viewport 左/下边:以 viewport()->rect() 为参考, -// 用 mapToScene / mapFromScene 把场景范围映射到像素位置。 -void DatasetChartView::drawForeground(QPainter* p, const QRectF& /*rect*/) { - if (legendScale_.empty()) return; - - p->save(); - // 重置变换,后续全用视口像素坐标绘制 - p->resetTransform(); - - const QRect vr = viewport()->rect(); - const int W = vr.width(); - const int H = vr.height(); - - // ── 色阶图例(底部)──────────────────────────────── - const int legendH = 16; // 色条高度(px) - const int legendMarginB = 20; // 底部文字留白 - const int legendMarginL = 50; // 左侧轴标签留白 - const int legendMarginR = 10; - const int legendY = H - legendMarginB - legendH; - const int legendW = W - legendMarginL - legendMarginR; - - const std::vector stops = legendScale_.stopValues(); - if (stops.size() >= 2 && legendW > 0) { - const int nSeg = static_cast(stops.size()) - 1; - const double segW = static_cast(legendW) / nSeg; - for (int s = 0; s < nSeg; ++s) { - // 取该段中点值的颜色(与 web 阶梯色一致) - const double midVal = (stops[s] + stops[s + 1]) * 0.5; - const QColor c = toQ(legendScale_.colorAt(midVal)); - const int x0 = legendMarginL + static_cast(s * segW); - const int x1 = legendMarginL + static_cast((s + 1) * segW); - p->fillRect(QRect(x0, legendY, x1 - x0, legendH), c); - } - // 边框 - p->setPen(QPen(QColor(80, 80, 80), 1)); - p->drawRect(QRect(legendMarginL, legendY, legendW, legendH)); - - // 分段值文字(在每个分割点下方) - p->setPen(Qt::black); - QFont font = p->font(); - font.setPixelSize(10); - p->setFont(font); - for (int s = 0; s <= nSeg; ++s) { - const int xTick = legendMarginL + static_cast(s * segW); - const QString label = QString::number(stops[s], 'g', 4); - QRect textR(xTick - 20, legendY + legendH + 2, 40, 14); - p->drawText(textR, Qt::AlignCenter, label); - } - } - - // ── 坐标轴(左/下边钉住视口)──────────────────────── - // 把视口四个角映射到场景,得到场景可见范围 - const QPointF sceneTL = mapToScene(vr.topLeft()); - const QPointF sceneBR = mapToScene(vr.bottomRight()); - - // 因为 y 轴翻转(scale(1,-1)),场景坐标 y 向上为正 - // sceneTL 的 scene-y 可能大于 sceneBR 的 scene-y,需取 min/max - const double sceneXMin = qMin(sceneTL.x(), sceneBR.x()); - const double sceneXMax = qMax(sceneTL.x(), sceneBR.x()); - const double sceneYMin = qMin(sceneTL.y(), sceneBR.y()); - const double sceneYMax = qMax(sceneTL.y(), sceneBR.y()); - - const int axisLeft = legendMarginL; // 左轴 x 像素 - const int axisBottom = legendY - 4; // 下轴 y 像素(色阶条上方留 4px) - - p->setPen(QPen(QColor(100, 100, 100), 1)); - - // 左侧轴线(竖) - p->drawLine(axisLeft, 4, axisLeft, axisBottom); - // 下轴线(横) - p->drawLine(axisLeft, axisBottom, W - legendMarginR, axisBottom); - - // 刻度数(6~8 个) - const int nTick = 7; - QFont tickFont = p->font(); - tickFont.setPixelSize(10); - p->setFont(tickFont); - - // X 轴刻度(下轴) - const double xRange = sceneXMax - sceneXMin; - if (xRange > 0) { - for (int t = 0; t <= nTick; ++t) { - const double sx = sceneXMin + xRange * t / nTick; - const QPoint vp = mapFromScene(QPointF(sx, sceneYMin)); // 场景 → 视口像素 - const int px = vp.x(); - if (px < axisLeft || px > W - legendMarginR) continue; - p->setPen(QPen(QColor(100, 100, 100), 1)); - p->drawLine(px, axisBottom, px, axisBottom + 4); - p->setPen(Qt::black); - const QString label = QString::number(sx, 'g', 4); - p->drawText(QRect(px - 20, axisBottom + 5, 40, 12), Qt::AlignCenter, label); - } - } - - // Y 轴刻度(左轴) - const double yRange = sceneYMax - sceneYMin; - if (yRange > 0) { - for (int t = 0; t <= nTick; ++t) { - const double sy = sceneYMin + yRange * t / nTick; - const QPoint vp = mapFromScene(QPointF(sceneXMin, sy)); - const int py = vp.y(); - if (py < 4 || py > axisBottom) continue; - p->setPen(QPen(QColor(100, 100, 100), 1)); - p->drawLine(axisLeft - 4, py, axisLeft, py); - p->setPen(Qt::black); - const QString label = QString::number(sy, 'g', 4); - p->drawText(QRect(0, py - 7, axisLeft - 5, 14), Qt::AlignRight | Qt::AlignVCenter, label); - } - } - - p->restore(); -} - -} // namespace geopro::app diff --git a/src/app/panels/chart/DatasetChartView.hpp b/src/app/panels/chart/DatasetChartView.hpp deleted file mode 100644 index 8533181..0000000 --- a/src/app/panels/chart/DatasetChartView.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once -#include -#include -#include -#include "model/Field.hpp" -#include "model/ColorScale.hpp" -#include "model/Anomaly.hpp" -#include "ContourBands.hpp" -namespace geopro::app { - -// 平面图表视图:散点(原数据) / 等值面(网格) + 异常叠加 + 色阶图例。VTK 仅经 ContourBands 算几何。 -class DatasetChartView : public QGraphicsView { - Q_OBJECT -public: - explicit DatasetChartView(QWidget* parent = nullptr); - void showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs); - void showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, - const geopro::render::ContourOptions& opt); - void setAnomalies(const std::vector& list); - void setHiddenAnomalies(const std::set& hidden); // 下标=list 序 - void setShowAnomalies(bool on); - void setShowContourLines(bool on); - void clearChart(); -protected: - void wheelEvent(QWheelEvent* e) override; // 滚轮缩放 - void drawForeground(QPainter* p, const QRectF& rect) override; -private: - void rebuildAnomalyItems(); - void setLegend(const geopro::core::ColorScale& cs) { legendScale_ = cs; } - QGraphicsScene* scene_; - std::vector anomalies_; - std::set hidden_; - bool showAnomalies_ = true; - bool showContourLines_ = true; - std::vector anomalyItems_; - geopro::core::ColorScale legendScale_; -}; -} // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp new file mode 100644 index 0000000..c9847cc --- /dev/null +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -0,0 +1,120 @@ +#include "panels/chart/GridDataChartView.hpp" + +#include +#include +#include +#include +#include +#include + +#include "PanelHeader.hpp" +#include "panels/AnomalyTablePanel.hpp" +#include "panels/DescriptionPanel.hpp" + +namespace geopro::app { + +GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + // ---- 工具条 ---- + auto* toolbar = new QWidget(this); + auto* tbLay = new QHBoxLayout(toolbar); + tbLay->setContentsMargins(4, 4, 4, 4); + tbLay->setSpacing(4); + + auto* btnGrid = new QToolButton(toolbar); + btnGrid->setText(QStringLiteral("网格")); + + auto* btnColorScale = new QToolButton(toolbar); + btnColorScale->setText(QStringLiteral("色阶配置")); + + auto* btnWhiten = new QToolButton(toolbar); + btnWhiten->setText(QStringLiteral("白化")); + + auto* btnFilter = new QToolButton(toolbar); + btnFilter->setText(QStringLiteral("滤波处理")); + + auto* chkShowAnom = new QCheckBox(QStringLiteral("显示异常"), toolbar); + chkShowAnom->setChecked(true); + + auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar); + chkShowContourLabel->setChecked(true); + + auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar); + chkContourTip->setChecked(false); + + auto* lblSimplify = new QLabel(QStringLiteral("简化容差:"), toolbar); + + simplifySlider_ = new QSlider(Qt::Horizontal, toolbar); + simplifySlider_->setRange(0, 100); + simplifySlider_->setValue(50); + simplifySlider_->setFixedWidth(80); + + simplifyValueLabel_ = new QLabel(QStringLiteral("0.5"), toolbar); + simplifyValueLabel_->setFixedWidth(28); + + connect(simplifySlider_, &QSlider::valueChanged, this, [this](int v) { + simplifyValueLabel_->setText(QString::number(v / 100.0, 'f', 1)); + }); + + auto* btnAnomalyLabel = new QToolButton(toolbar); + btnAnomalyLabel->setText(QStringLiteral("异常标注")); + + auto* btnAutoLabel = new QToolButton(toolbar); + btnAutoLabel->setText(QStringLiteral("自动标注")); + + auto* btnSaveAs = new QToolButton(toolbar); + btnSaveAs->setText(QStringLiteral("另存为")); + + tbLay->addWidget(btnGrid); + tbLay->addWidget(btnColorScale); + tbLay->addWidget(btnWhiten); + tbLay->addWidget(btnFilter); + tbLay->addWidget(chkShowAnom); + tbLay->addWidget(chkShowContourLabel); + tbLay->addWidget(chkContourTip); + tbLay->addWidget(lblSimplify); + tbLay->addWidget(simplifySlider_); + tbLay->addWidget(simplifyValueLabel_); + tbLay->addWidget(btnAnomalyLabel); + tbLay->addWidget(btnAutoLabel); + tbLay->addWidget(btnSaveAs); + tbLay->addStretch(); + + lay->addWidget(toolbar); + + // ---- 图表占位区(stretch) ---- + plotArea_ = new QWidget(this); + plotArea_->setObjectName(QStringLiteral("gridPlotArea")); + lay->addWidget(plotArea_, 1); + + // ---- 色阶占位(固定高 36px) ---- + auto* colorScaleBar = new QWidget(this); + colorScaleBar->setObjectName(QStringLiteral("gridColorScaleBar")); + colorScaleBar->setFixedHeight(36); + lay->addWidget(colorScaleBar); + + // ---- 底部双页签(异常列表 / 描述),固定高约 200px ---- + anomalyTable_ = new AnomalyTablePanel(this); + descriptionPanel_ = new DescriptionPanel(this); + + const QVector bottomTabs = { + {Glyph::Anomaly, QStringLiteral("异常列表"), anomalyTable_, true}, + {Glyph::Property, QStringLiteral("描述"), descriptionPanel_, false}, + }; + auto tabbedBottom = buildTabbedPanel(bottomTabs, {}); + + auto* bottomContainer = tabbedBottom.container; + bottomContainer->setFixedHeight(200); + lay->addWidget(bottomContainer); +} + +void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { + data_ = d; + // 步骤1:只把 anomalies 喂给底部异常列表;图表区留空。 + anomalyTable_->setAnomalies(d.anomalies, {}, {}); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp new file mode 100644 index 0000000..978b26f --- /dev/null +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include "DatasetDetailController.hpp" + +class QSlider; +class QLabel; + +namespace geopro::app { + +class AnomalyTablePanel; +class DescriptionPanel; + +// 网格数据图表视图(步骤1骨架):工具条 + 图表占位 + 色阶占位 + 底部双页签(异常列表/描述)。 +// 图表渲染由后续步骤填入 plotArea()。 +class GridDataChartView : public QWidget { + Q_OBJECT +public: + explicit GridDataChartView(QWidget* parent = nullptr); + + // 步骤1:把 anomalies 喂给底部异常列表;图表区留空。 + void setData(const geopro::controller::DatasetDetailController::ChartData& d); + + // 供后续步骤填充 Qwt 画布的占位区域。 + QWidget* plotArea() const { return plotArea_; } + + // 供后续步骤联动异常叠加。 + AnomalyTablePanel* anomalyTable() const { return anomalyTable_; } + +private: + geopro::controller::DatasetDetailController::ChartData data_; + QWidget* plotArea_; + AnomalyTablePanel* anomalyTable_; + DescriptionPanel* descriptionPanel_; + QSlider* simplifySlider_; + QLabel* simplifyValueLabel_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp new file mode 100644 index 0000000..5e1eb3e --- /dev/null +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -0,0 +1,62 @@ +#include "panels/chart/RawDataChartView.hpp" + +#include +#include +#include +#include +#include + +namespace geopro::app { + +RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + // ---- 工具条 ---- + auto* toolbar = new QWidget(this); + auto* tbLay = new QHBoxLayout(toolbar); + tbLay->setContentsMargins(4, 4, 4, 4); + tbLay->setSpacing(4); + + auto* btnGrid = new QToolButton(toolbar); + btnGrid->setText(QStringLiteral("网格")); + + auto* btnColorScale = new QToolButton(toolbar); + btnColorScale->setText(QStringLiteral("色阶配置")); + + auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar); + + chartTypeCombo_ = new QComboBox(toolbar); + chartTypeCombo_->addItem(QStringLiteral("散点图")); + + auto* btnSaveAs = new QToolButton(toolbar); + btnSaveAs->setText(QStringLiteral("另存为")); + + tbLay->addWidget(btnGrid); + tbLay->addWidget(btnColorScale); + tbLay->addWidget(lblCurrentChart); + tbLay->addWidget(chartTypeCombo_); + tbLay->addWidget(btnSaveAs); + tbLay->addStretch(); + + lay->addWidget(toolbar); + + // ---- 图表占位区(stretch) ---- + plotArea_ = new QWidget(this); + plotArea_->setObjectName(QStringLiteral("rawPlotArea")); + lay->addWidget(plotArea_, 1); + + // ---- 色阶占位(固定高 36px) ---- + auto* colorScaleBar = new QWidget(this); + colorScaleBar->setObjectName(QStringLiteral("rawColorScaleBar")); + colorScaleBar->setFixedHeight(36); + lay->addWidget(colorScaleBar); +} + +void RawDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { + // 步骤1:保存数据,图表渲染留给后续步骤。 + data_ = d; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp new file mode 100644 index 0000000..37af190 --- /dev/null +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include "DatasetDetailController.hpp" + +class QComboBox; + +namespace geopro::app { + +// 原数据图表视图(步骤1骨架):工具条 + 图表占位 + 色阶占位。 +// 图表渲染由后续步骤填入 plotArea()。 +class RawDataChartView : public QWidget { + Q_OBJECT +public: + explicit RawDataChartView(QWidget* parent = nullptr); + + // 步骤1:保存数据备后续渲染步骤使用;图表区留空。 + void setData(const geopro::controller::DatasetDetailController::ChartData& d); + + // 供后续步骤填充 Qwt 画布的占位区域。 + QWidget* plotArea() const { return plotArea_; } + +private: + geopro::controller::DatasetDetailController::ChartData data_; + QWidget* plotArea_; + QComboBox* chartTypeCombo_; +}; + +} // namespace geopro::app From c7fec86d3b4cf9e229fffd578a9bf5bb6b0154e7 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 15:40:27 +0800 Subject: [PATCH 22/74] =?UTF-8?q?feat(core+chart):=20ColorScale::stops()?= =?UTF-8?q?=20=E6=9A=B4=E9=9C=B2=E6=96=AD=E7=82=B9=20+=20ColorMapService?= =?UTF-8?q?=20=E8=BF=9E=E7=BB=AD=E6=8F=92=E5=80=BC=E8=89=B2=E9=98=B6?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core::ColorScale 新增 stops() 方法,返回升序 (value, Rgba) 断点列表,供连续插值用。 - app::ColorMapService:从 ColorScale 构建,支持 normalized()/colorAtContinuous()/colorAtDiscrete(); 连续模式在归一化断点位置间线性插值 RGB,与原版 Plotly colorscale 一致。 - cmake/qwt.cmake 补加 QWT_MOC_INCLUDE=1,修复 Qwt AUTOMOC self-include 宏保护缺失导致 Q_OBJECT MOC 代码不编入 .obj 的链接错误(LNK2019 x61 系列)。 - 新增 TDD 测试 ColorMapService.*(4 个用例,全绿)。 --- cmake/qwt.cmake | 3 + src/app/panels/chart/ColorMapService.cpp | 71 ++++++++++++++++++++++++ src/app/panels/chart/ColorMapService.hpp | 38 +++++++++++++ src/core/model/ColorScale.cpp | 7 +++ src/core/model/ColorScale.hpp | 3 + tests/CMakeLists.txt | 5 ++ tests/app/test_colormap_service.cpp | 46 +++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 src/app/panels/chart/ColorMapService.cpp create mode 100644 src/app/panels/chart/ColorMapService.hpp create mode 100644 tests/app/test_colormap_service.cpp diff --git a/cmake/qwt.cmake b/cmake/qwt.cmake index 9ffd2ca..870d033 100644 --- a/cmake/qwt.cmake +++ b/cmake/qwt.cmake @@ -24,6 +24,9 @@ set_target_properties(qwt PROPERTIES AUTOMOC ON) target_include_directories(qwt PUBLIC "${QWT_SRC_DIR}") target_link_libraries(qwt PUBLIC Qt6::Widgets Qt6::Concurrent Qt6::PrintSupport Qt6::Svg) target_compile_features(qwt PUBLIC cxx_std_17) +# QWT_MOC_INCLUDE=1:启用 Qwt 源文件末尾 #if QWT_MOC_INCLUDE 保护的 #include "moc_xxx.cpp"。 +# Qwt 原生用 qmake(qwtbuild.pri 里设置),CMake 构建需显式定义,否则 MOC 元对象代码不被编译进 .obj。 +target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1) if(MSVC) target_compile_options(qwt PRIVATE /bigobj /EHsc /wd4244 /wd4267 /wd4305 /wd4456) endif() diff --git a/src/app/panels/chart/ColorMapService.cpp b/src/app/panels/chart/ColorMapService.cpp new file mode 100644 index 0000000..475ddf9 --- /dev/null +++ b/src/app/panels/chart/ColorMapService.cpp @@ -0,0 +1,71 @@ +#include "panels/chart/ColorMapService.hpp" +#include +#include + +namespace geopro::app { + +namespace { +inline double clamp01(double v) { + return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); +} +inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) { + return static_cast(a + (b - a) * t + 0.5); +} +} // namespace + +ColorMapService::ColorMapService(const core::ColorScale& scale) + : scale_(scale) { + auto raw = scale.stops(); + if (raw.empty()) { + minVal_ = 0.0; + maxVal_ = 1.0; + return; + } + minVal_ = raw.front().first; + maxVal_ = raw.back().first; + double range = maxVal_ - minVal_; + normStops_.reserve(raw.size()); + for (const auto& [val, color] : raw) { + double pos = (range > 0.0) ? (val - minVal_) / range : 0.0; + normStops_.push_back({clamp01(pos), color}); + } +} + +double ColorMapService::normalized(double v) const { + double range = maxVal_ - minVal_; + if (range <= 0.0) return 0.0; + return clamp01((v - minVal_) / range); +} + +core::Rgba ColorMapService::colorAtContinuous(double v) const { + if (normStops_.empty()) return core::Rgba{0, 0, 0, 255}; + if (normStops_.size() == 1) return normStops_.front().color; + + double t = normalized(v); + + // 找到 t 落在哪两个 normStop 之间 + if (t <= normStops_.front().pos) return normStops_.front().color; + if (t >= normStops_.back().pos) return normStops_.back().color; + + // 二分查找第一个 pos > t + auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t, + [](double val, const NormStop& s) { return val < s.pos; }); + + const NormStop& hi = *it; + const NormStop& lo = *(it - 1); + double segLen = hi.pos - lo.pos; + double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0; + + return core::Rgba{ + lerpByte(lo.color.r, hi.color.r, frac), + lerpByte(lo.color.g, hi.color.g, frac), + lerpByte(lo.color.b, hi.color.b, frac), + lerpByte(lo.color.a, hi.color.a, frac) + }; +} + +core::Rgba ColorMapService::colorAtDiscrete(double v) const { + return scale_.colorAt(v); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorMapService.hpp b/src/app/panels/chart/ColorMapService.hpp new file mode 100644 index 0000000..a824794 --- /dev/null +++ b/src/app/panels/chart/ColorMapService.hpp @@ -0,0 +1,38 @@ +#pragma once +#include "model/ColorScale.hpp" +#include +#include + +namespace geopro::app { + +// 连续/离散双模式色阶服务,供散点渲染(连续插值)和网格/图例(阶梯)使用。 +// 连续模式:按归一化位置在相邻断点之间线性插值 RGB,与原版 Plotly colorscale 一致。 +class ColorMapService { +public: + explicit ColorMapService(const core::ColorScale& scale); + + // 将数据值归一化到 [0,1](min=首断点值, max=末断点值),超范围 clamp。 + double normalized(double v) const; + + // 连续插值取色(散点用):按断点位置线性插值 RGB。 + core::Rgba colorAtContinuous(double v) const; + + // 离散阶梯取色(网格/图例用):复用 ColorScale::colorAt。 + core::Rgba colorAtDiscrete(double v) const; + + // 返回原始 ColorScale 引用(色阶条图例用)。 + const core::ColorScale& scale() const { return scale_; } + +private: + core::ColorScale scale_; + // 归一化位置与颜色预计算缓存(stops 已升序)。 + struct NormStop { + double pos; // normalized position in [0,1] + core::Rgba color; + }; + std::vector normStops_; + double minVal_; + double maxVal_; +}; + +} // namespace geopro::app diff --git a/src/core/model/ColorScale.cpp b/src/core/model/ColorScale.cpp index dce6ad6..609153d 100644 --- a/src/core/model/ColorScale.cpp +++ b/src/core/model/ColorScale.cpp @@ -40,6 +40,13 @@ std::vector ColorScale::stopValues() const { return v; } +std::vector> ColorScale::stops() const { + std::vector> result; + result.reserve(stops_.size()); + for (const auto& s : stops_) result.emplace_back(s.value, s.color); + return result; +} + Rgba ColorScale::colorAt(double value) const { if (std::isnan(value)) return nan_.value_or(Rgba{0, 0, 0, 0}); if (stops_.empty()) return Rgba{0, 0, 0, 0}; diff --git a/src/core/model/ColorScale.hpp b/src/core/model/ColorScale.hpp index b859478..db793a7 100644 --- a/src/core/model/ColorScale.hpp +++ b/src/core/model/ColorScale.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include namespace geopro::core { @@ -15,6 +16,8 @@ public: void addStop(double value, Rgba color); // 内部保持按 value 升序 Rgba colorAt(double value) const; // 含 under/over/NaN 处理 std::vector stopValues() const; // 升序分段值(供等值线用真实非均匀级) + // 返回升序断点 (value, color) 列表,供连续插值用。 + std::vector> stops() const; std::size_t stopCount() const { return stops_.size(); } void setUnder(Rgba c) { under_ = c; } void setOver(Rgba c) { over_ = c; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e7c13fb..f999606 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,11 @@ endif() target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) +# ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标) +target_sources(geopro_tests PRIVATE + app/test_colormap_service.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ColorMapService.cpp +) # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) diff --git a/tests/app/test_colormap_service.cpp b/tests/app/test_colormap_service.cpp new file mode 100644 index 0000000..fb41754 --- /dev/null +++ b/tests/app/test_colormap_service.cpp @@ -0,0 +1,46 @@ +#include +#include "panels/chart/ColorMapService.hpp" +using namespace geopro; + +TEST(ColorMapService, ContinuousInterpolatesBetweenStops) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 255, 255, 255}); + app::ColorMapService svc(cs); + EXPECT_NEAR(svc.normalized(50.0), 0.5, 1e-9); + auto c = svc.colorAtContinuous(50.0); // 黑白中点应约为灰 + EXPECT_GT(c.r, 100); EXPECT_LT(c.r, 160); +} + +TEST(ColorMapService, NormalizedClampsOutOfRange) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 255, 255, 255}); + app::ColorMapService svc(cs); + EXPECT_NEAR(svc.normalized(-10.0), 0.0, 1e-9); + EXPECT_NEAR(svc.normalized(110.0), 1.0, 1e-9); +} + +TEST(ColorMapService, ColorAtContinuousAtExtremes) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{10, 20, 30, 255}); + cs.addStop(100.0, core::Rgba{200, 210, 220, 255}); + app::ColorMapService svc(cs); + auto cMin = svc.colorAtContinuous(0.0); + EXPECT_EQ(cMin.r, 10); + EXPECT_EQ(cMin.g, 20); + EXPECT_EQ(cMin.b, 30); + auto cMax = svc.colorAtContinuous(100.0); + EXPECT_EQ(cMax.r, 200); + EXPECT_EQ(cMax.g, 210); + EXPECT_EQ(cMax.b, 220); +} + +TEST(ColorMapService, ScaleRefReturnsOriginal) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(50.0, core::Rgba{128, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 0, 0, 255}); + app::ColorMapService svc(cs); + EXPECT_EQ(svc.scale().stopCount(), 3u); +} From e405fc1565f6179d18d2fd85dee61ba124768ca5 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 15:40:43 +0800 Subject: [PATCH 23/74] =?UTF-8?q?feat(ui):=20=E5=8E=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=95=A3=E7=82=B9=E8=A7=86=E5=9B=BE(QwtPlot=20x=E9=A1=B6?= =?UTF-8?q?=E8=BD=B4+Panner/Magnifier+=E8=BF=9E=E7=BB=AD=E8=89=B2=E9=98=B6?= =?UTF-8?q?+=E6=96=B9=E5=9D=97=E6=95=A3=E7=82=B9+=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E8=89=B2=E9=98=B6=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ColorBarWidget:独立 QWidget 色阶条,水平离散色带 + 断点刻度,固定高 36px, 作为 QwtPlot 兄弟 widget 布局在图表下方,不进 Qwt 坐标系(不随缩放移动)。 - ScatterPlotItem:QwtPlotItem 子类,每点画固定 7px 方块(kHalfSide=3.5px), 白色描边(1px),颜色由 ColorMapService::colorAtContinuous 连续插值决定。 - RawDataChartView:plotArea 换成 QwtPlot,x 轴顶部(xTop)、关闭 xBottom、yLeft 正常; QwtPlotPanner(左键拖动平移)+ QwtPlotMagnifier(滚轮缩放); setData 时重建 ColorMapService + ScatterPlotItem,按数据包围盒设轴范围; 独立色阶条随 setData 更新。 --- src/app/CMakeLists.txt | 7 +- src/app/panels/chart/ColorBarWidget.cpp | 87 +++++++++++++++++++++++ src/app/panels/chart/ColorBarWidget.hpp | 24 +++++++ src/app/panels/chart/RawDataChartView.cpp | 78 +++++++++++++++++--- src/app/panels/chart/RawDataChartView.hpp | 22 ++++-- src/app/panels/chart/ScatterPlotItem.cpp | 67 +++++++++++++++++ src/app/panels/chart/ScatterPlotItem.hpp | 39 ++++++++++ 7 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 src/app/panels/chart/ColorBarWidget.cpp create mode 100644 src/app/panels/chart/ColorBarWidget.hpp create mode 100644 src/app/panels/chart/ScatterPlotItem.cpp create mode 100644 src/app/panels/chart/ScatterPlotItem.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 1b82e36..c4b9efe 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,9 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/ColorMapService.cpp + panels/chart/ColorBarWidget.cpp + panels/chart/ScatterPlotItem.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp @@ -53,7 +56,9 @@ target_link_libraries(geopro_desktop PRIVATE geopro_controller # Phase 5:导航编排(WorkbenchNavController) ) -# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。本步骤骨架先链好供后续渲染步骤使用。 +# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。 +# cmake/qwt.cmake 已加 QWT_MOC_INCLUDE=1,Qwt Q_OBJECT 类的 MOC 代码直接编译进各 .cpp.obj, +# 标准按需链接可正确解析所有 MOC 符号(无需 /WHOLEARCHIVE)。 if(TARGET qwt) target_link_libraries(geopro_desktop PRIVATE qwt) endif() diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp new file mode 100644 index 0000000..e2c2350 --- /dev/null +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -0,0 +1,87 @@ +#include "panels/chart/ColorBarWidget.hpp" +#include +#include + +namespace geopro::app { + +static constexpr int kBarHeight = 18; // 色带高度(px) +static constexpr int kTickHeight = 4; // 刻度短线高度(px) +static constexpr int kFontSize = 9; // 刻度字号 + +ColorBarWidget::ColorBarWidget(QWidget* parent) + : QWidget(parent) { + setFixedHeight(36); +} + +void ColorBarWidget::setColorScale(const core::ColorScale& scale) { + scale_ = scale; + update(); +} + +void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { + auto stops = scale_.stops(); + if (stops.empty()) return; + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, false); + + const int W = width(); + const int H = height(); + + // 横向色带区域(顶部) + const int barY = 2; + const int barH = kBarHeight; + + // 边界值 + double minVal = stops.front().first; + double maxVal = stops.back().first; + double range = maxVal - minVal; + if (range <= 0.0) range = 1.0; + + // 绘制每段色格(相邻断点间一格) + for (std::size_t i = 0; i + 1 < stops.size(); ++i) { + double posL = (stops[i].first - minVal) / range; + double posR = (stops[i + 1].first - minVal) / range; + int xL = static_cast(posL * (W - 1)); + int xR = static_cast(posR * (W - 1)); + if (xR <= xL) xR = xL + 1; + const auto& c = stops[i].second; + p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a)); + } + // 最后一段(末断点颜色填到边缘) + { + double posL = (stops.back().first - minVal) / range; + int xL = static_cast(posL * (W - 1)); + const auto& c = stops.back().second; + p.fillRect(xL, barY, W - xL, barH, QColor(c.r, c.g, c.b, c.a)); + } + + // 外边框 + p.setPen(QPen(QColor(80, 80, 80), 1)); + p.drawRect(0, barY, W - 1, barH - 1); + + // 刻度区(色带下方) + QFont font = p.font(); + font.setPixelSize(kFontSize); + p.setFont(font); + p.setPen(QColor(200, 200, 200)); + + const int tickY = barY + barH + 1; + + for (const auto& [val, color] : stops) { + double pos = (val - minVal) / range; + int x = static_cast(pos * (W - 1)); + // 刻度短线 + p.drawLine(x, tickY, x, tickY + kTickHeight); + // 数值文字(右对齐到刻度位置,避免越界) + QString label = QString::number(val, 'g', 4); + QFontMetrics fm(font); + int tw = fm.horizontalAdvance(label); + int tx = x - tw / 2; + if (tx < 0) tx = 0; + if (tx + tw > W) tx = W - tw; + p.drawText(tx, H - 1, label); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorBarWidget.hpp b/src/app/panels/chart/ColorBarWidget.hpp new file mode 100644 index 0000000..b58d77f --- /dev/null +++ b/src/app/panels/chart/ColorBarWidget.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "model/ColorScale.hpp" +#include + +namespace geopro::app { + +// 独立色阶条 Widget,水平排布:上方彩色色带 + 下方刻度值。 +// 作为 QwtPlot 的兄弟 widget 布局在图表下方,不进入 Qwt 坐标系, +// 因此不随图表缩放/平移移动,也不与轴标注重叠。 +class ColorBarWidget : public QWidget { + Q_OBJECT +public: + explicit ColorBarWidget(QWidget* parent = nullptr); + + void setColorScale(const core::ColorScale& scale); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + core::ColorScale scale_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 5e1eb3e..b592d4a 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,4 +1,6 @@ #include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ScatterPlotItem.hpp" #include #include @@ -6,6 +8,11 @@ #include #include +#include +#include +#include +#include + namespace geopro::app { RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { @@ -42,21 +49,70 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { lay->addWidget(toolbar); - // ---- 图表占位区(stretch) ---- - plotArea_ = new QWidget(this); - plotArea_->setObjectName(QStringLiteral("rawPlotArea")); - lay->addWidget(plotArea_, 1); + // ---- QwtPlot(stretch 填满剩余空间)---- + plot_ = new QwtPlot(this); + plot_->setObjectName(QStringLiteral("rawPlotArea")); - // ---- 色阶占位(固定高 36px) ---- - auto* colorScaleBar = new QWidget(this); - colorScaleBar->setObjectName(QStringLiteral("rawColorScaleBar")); - colorScaleBar->setFixedHeight(36); - lay->addWidget(colorScaleBar); + // x 轴顶部,关闭底部 x 轴 + plot_->enableAxis(QwtPlot::xTop, true); + plot_->enableAxis(QwtPlot::xBottom, false); + plot_->enableAxis(QwtPlot::yLeft, true); + + // 深色背景风格 + plot_->setCanvasBackground(QBrush(QColor(30, 30, 30))); + + // 交互:Panner(左键拖动平移)+ Magnifier(滚轮缩放) + auto* panner = new QwtPlotPanner(plot_->canvas()); + panner->setMouseButton(Qt::LeftButton); + + new QwtPlotMagnifier(plot_->canvas()); + + lay->addWidget(plot_, 1); + + // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- + colorBar_ = new ColorBarWidget(this); + colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); + lay->addWidget(colorBar_); } -void RawDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - // 步骤1:保存数据,图表渲染留给后续步骤。 +QWidget* RawDataChartView::plotArea() const { + return plot_; +} + +void RawDataChartView::setData( + const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; + + if (d.scatterScale.empty()) return; + + // 重建 ColorMapService(旧的 scatterItem 已被 QwtPlot detach/delete) + delete colorSvc_; + colorSvc_ = new ColorMapService(d.scatterScale); + + // 卸载旧散点项 + if (scatterItem_) { + scatterItem_->detach(); + // QwtPlot 不拥有 item,需要我们释放 + delete scatterItem_; + scatterItem_ = nullptr; + } + + // 新建散点项并挂到 plot + scatterItem_ = new ScatterPlotItem(); + scatterItem_->setData(d.scatter, colorSvc_); + scatterItem_->attach(plot_); + + // 按数据包围盒设置轴范围 + QRectF bbox = scatterItem_->boundingRect(); + if (!bbox.isEmpty()) { + plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right()); + plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); + } + + plot_->replot(); + + // 更新色阶条 + colorBar_->setColorScale(d.scatterScale); } } // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 37af190..25bcc58 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,28 +1,36 @@ #pragma once #include #include "DatasetDetailController.hpp" +#include "panels/chart/ColorMapService.hpp" class QComboBox; +class QwtPlot; namespace geopro::app { -// 原数据图表视图(步骤1骨架):工具条 + 图表占位 + 色阶占位。 -// 图表渲染由后续步骤填入 plotArea()。 +class ColorBarWidget; +class ScatterPlotItem; + +// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 class RawDataChartView : public QWidget { Q_OBJECT public: explicit RawDataChartView(QWidget* parent = nullptr); - // 步骤1:保存数据备后续渲染步骤使用;图表区留空。 void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 供后续步骤填充 Qwt 画布的占位区域。 - QWidget* plotArea() const { return plotArea_; } + // 供外部访问(已不再是占位,保留兼容接口返回 plot_) + QWidget* plotArea() const; private: geopro::controller::DatasetDetailController::ChartData data_; - QWidget* plotArea_; - QComboBox* chartTypeCombo_; + QwtPlot* plot_; + ColorBarWidget* colorBar_; + QComboBox* chartTypeCombo_; + + // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 + ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 + ScatterPlotItem* scatterItem_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp new file mode 100644 index 0000000..da9c7e3 --- /dev/null +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -0,0 +1,67 @@ +#include "panels/chart/ScatterPlotItem.hpp" +#include +#include +#include +#include + +namespace geopro::app { + +ScatterPlotItem::ScatterPlotItem() + : QwtPlotItem() { + setTitle("Scatter"); + setRenderHint(QwtPlotItem::RenderAntialiased, false); +} + +void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* svc) { + field_ = field; + colorSvc_ = svc; + + // 计算数据包围盒 + if (field_.x.empty() || field_.y.empty()) { + bounding_ = QRectF(); + return; + } + double xMin = *std::min_element(field_.x.begin(), field_.x.end()); + double xMax = *std::max_element(field_.x.begin(), field_.x.end()); + double yMin = *std::min_element(field_.y.begin(), field_.y.end()); + double yMax = *std::max_element(field_.y.begin(), field_.y.end()); + // 加少量 margin,避免边界点被截 + double xM = (xMax - xMin) * 0.03 + 0.1; + double yM = (yMax - yMin) * 0.03 + 0.1; + bounding_ = QRectF(xMin - xM, yMin - yM, + (xMax - xMin) + 2.0 * xM, + (yMax - yMin) + 2.0 * yM); +} + +QRectF ScatterPlotItem::boundingRect() const { + return bounding_; +} + +void ScatterPlotItem::draw(QPainter* painter, + const QwtScaleMap& xMap, + const QwtScaleMap& yMap, + const QRectF& /*canvasRect*/) const { + if (!colorSvc_) return; + const auto& xs = field_.x; + const auto& ys = field_.y; + const auto& vs = field_.v; + const std::size_t n = xs.size(); + if (n == 0) return; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, false); + painter->setPen(QPen(Qt::white, kPenWidth)); + + for (std::size_t i = 0; i < n; ++i) { + double px = xMap.transform(xs[i]); + double py = yMap.transform(ys[i]); + double val = (i < vs.size()) ? vs[i] : 0.0; + auto c = colorSvc_->colorAtContinuous(val); + painter->setBrush(QColor(c.r, c.g, c.b, c.a)); + painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, + kHalfSide * 2.0, kHalfSide * 2.0)); + } + painter->restore(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.hpp b/src/app/panels/chart/ScatterPlotItem.hpp new file mode 100644 index 0000000..34342a9 --- /dev/null +++ b/src/app/panels/chart/ScatterPlotItem.hpp @@ -0,0 +1,39 @@ +#pragma once +#include "model/Field.hpp" +#include "panels/chart/ColorMapService.hpp" +#include +#include + +class QPainter; +class QwtScaleMap; + +namespace geopro::app { + +// QwtPlotItem:把 ScatterField 数据渲染为彩色方块散点。 +// 每个点用固定像素边长方块绘制(不随缩放变大),白色描边, +// 颜色由 ColorMapService 连续插值决定(与原版 Plotly 一致)。 +class ScatterPlotItem : public QwtPlotItem { +public: + ScatterPlotItem(); + + void setData(const core::ScatterField& field, ColorMapService* svc); + + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } + + QRectF boundingRect() const override; + + void draw(QPainter* painter, + const QwtScaleMap& xMap, + const QwtScaleMap& yMap, + const QRectF& canvasRect) const override; + +private: + core::ScatterField field_; + ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 + QRectF bounding_; + + static constexpr double kHalfSide = 3.5; // 方块半边长(像素) + static constexpr double kPenWidth = 1.0; // 白色描边宽度(像素) +}; + +} // namespace geopro::app From 1f285052272fb15d5c575437bb50a85bc3018829 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 15:55:32 +0800 Subject: [PATCH 24/74] =?UTF-8?q?fix(ui):=20=E5=8E=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=95=A3=E7=82=B9=20=E7=99=BD=E5=BA=95+=E6=95=A3=E7=82=B9?= =?UTF-8?q?=E9=A1=B9=E5=85=B3=E8=81=94xTop=E8=BD=B4(=E4=BF=AEx=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9)+=E8=BF=87=E5=8E=9F=E7=82=B9=E9=9B=B6=E7=BA=BF=20+=20?= =?UTF-8?q?=E8=89=B2=E9=98=B6=E6=9D=A1=E7=AD=89=E5=AE=BD=E8=89=B2=E5=B8=A6?= =?UTF-8?q?/=E6=B7=B1=E8=89=B2=E6=96=87=E5=AD=97/=E7=99=BD=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ColorBarWidget.cpp | 55 +++++++---------------- src/app/panels/chart/RawDataChartView.cpp | 26 ++++++++++- src/app/panels/chart/ScatterPlotItem.cpp | 5 +++ 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index e2c2350..61e0306 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -19,68 +19,47 @@ void ColorBarWidget::setColorScale(const core::ColorScale& scale) { } void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { - auto stops = scale_.stops(); - if (stops.empty()) return; - QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); - const int W = width(); const int H = height(); + p.fillRect(0, 0, W, H, Qt::white); // 白底,对齐原版 + + auto stops = scale_.stops(); + if (stops.size() < 2) return; - // 横向色带区域(顶部) const int barY = 2; const int barH = kBarHeight; + // 等宽色带:每相邻断点一格,等宽(对齐原版图例,非按值比例)。 + const int nSeg = static_cast(stops.size()) - 1; + auto segX = [&](int i) { return static_cast(static_cast(i) / nSeg * (W - 1)); }; - // 边界值 - double minVal = stops.front().first; - double maxVal = stops.back().first; - double range = maxVal - minVal; - if (range <= 0.0) range = 1.0; - - // 绘制每段色格(相邻断点间一格) - for (std::size_t i = 0; i + 1 < stops.size(); ++i) { - double posL = (stops[i].first - minVal) / range; - double posR = (stops[i + 1].first - minVal) / range; - int xL = static_cast(posL * (W - 1)); - int xR = static_cast(posR * (W - 1)); + for (int i = 0; i < nSeg; ++i) { + int xL = segX(i), xR = segX(i + 1); if (xR <= xL) xR = xL + 1; const auto& c = stops[i].second; p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a)); } - // 最后一段(末断点颜色填到边缘) - { - double posL = (stops.back().first - minVal) / range; - int xL = static_cast(posL * (W - 1)); - const auto& c = stops.back().second; - p.fillRect(xL, barY, W - xL, barH, QColor(c.r, c.g, c.b, c.a)); - } - // 外边框 - p.setPen(QPen(QColor(80, 80, 80), 1)); + p.setPen(QPen(QColor(120, 120, 120), 1)); p.drawRect(0, barY, W - 1, barH - 1); - // 刻度区(色带下方) + // 边界值标签(深色文字,白底可见),在各分格边界下方。 QFont font = p.font(); font.setPixelSize(kFontSize); p.setFont(font); - p.setPen(QColor(200, 200, 200)); - + p.setPen(QColor(60, 60, 60)); + QFontMetrics fm(font); const int tickY = barY + barH + 1; - - for (const auto& [val, color] : stops) { - double pos = (val - minVal) / range; - int x = static_cast(pos * (W - 1)); - // 刻度短线 + for (int i = 0; i <= nSeg; ++i) { + int x = segX(i); p.drawLine(x, tickY, x, tickY + kTickHeight); - // 数值文字(右对齐到刻度位置,避免越界) - QString label = QString::number(val, 'g', 4); - QFontMetrics fm(font); + const QString label = QString::number(stops[i].first, 'g', 4); int tw = fm.horizontalAdvance(label); int tx = x - tw / 2; if (tx < 0) tx = 0; if (tx + tw > W) tx = W - tw; - p.drawText(tx, H - 1, label); + p.drawText(tx, H - 2, label); } } diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index b592d4a..9ca2b59 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace geopro::app { @@ -58,8 +59,29 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { plot_->enableAxis(QwtPlot::xBottom, false); plot_->enableAxis(QwtPlot::yLeft, true); - // 深色背景风格 - plot_->setCanvasBackground(QBrush(QColor(30, 30, 30))); + // 白底浅色(对齐原版 web 图表,与 App 暗色主题独立):画布白、轴文字深灰。 + plot_->setCanvasBackground(QBrush(Qt::white)); + plot_->setAutoFillBackground(true); + { + QPalette pal = plot_->palette(); + pal.setColor(QPalette::Window, Qt::white); + pal.setColor(QPalette::WindowText, QColor(90, 90, 90)); + pal.setColor(QPalette::Text, QColor(90, 90, 90)); + plot_->setPalette(pal); + } + + // 过原点零线(对齐原版 zeroline:x=0 竖线 + y=0 横线 → "四象限"观感)。 + auto* zeroX = new QwtPlotMarker(); + zeroX->setLineStyle(QwtPlotMarker::VLine); + zeroX->setXValue(0.0); + zeroX->setLinePen(QColor(180, 180, 180), 1.0); + zeroX->setXAxis(QwtPlot::xTop); + zeroX->attach(plot_); + auto* zeroY = new QwtPlotMarker(); + zeroY->setLineStyle(QwtPlotMarker::HLine); + zeroY->setYValue(0.0); + zeroY->setLinePen(QColor(180, 180, 180), 1.0); + zeroY->attach(plot_); // 交互:Panner(左键拖动平移)+ Magnifier(滚轮缩放) auto* panner = new QwtPlotPanner(plot_->canvas()); diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp index da9c7e3..bda4d2d 100644 --- a/src/app/panels/chart/ScatterPlotItem.cpp +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -1,5 +1,6 @@ #include "panels/chart/ScatterPlotItem.hpp" #include +#include #include #include #include @@ -10,6 +11,10 @@ ScatterPlotItem::ScatterPlotItem() : QwtPlotItem() { setTitle("Scatter"); setRenderHint(QwtPlotItem::RenderAntialiased, false); + // 关联到 x 顶轴(原数据 x 轴在顶部;与 RawDataChartView 的 setAxisScale(xTop,...) 一致, + // 否则项默认用 xBottom 的(未设置/自动)刻度 → 散点被错误压缩)。 + setXAxis(QwtPlot::xTop); + setYAxis(QwtPlot::yLeft); } void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* svc) { From cf47314500e4467cd6abec9d16419f72bc9d054e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 16:09:00 +0800 Subject: [PATCH 25/74] =?UTF-8?q?fix(ui):=20=E6=95=A3=E7=82=B9=20=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E7=BC=A9=E6=94=BE=E6=96=B9=E5=90=91=E5=8F=8D=E8=BD=AC?= =?UTF-8?q?(=E4=B8=8A=E6=BB=9A=3D=E6=94=BE=E5=A4=A7)+=E6=94=BE=E5=BC=80?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E5=AE=BD=E5=BA=A6(=E9=9A=8F=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=94=B6=E7=BC=A9)=20+=20=E8=89=B2=E9=98=B6=E6=9D=A1?= =?UTF-8?q?=E5=B1=85=E4=B8=AD74%=E5=AE=BD(=E9=9D=9E=E6=BB=A1=E5=AE=BD,?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=8E=9F=E7=89=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ColorBarWidget.cpp | 10 ++++++++-- src/app/panels/chart/RawDataChartView.cpp | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index 61e0306..4e34289 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -30,9 +30,15 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { const int barY = 2; const int barH = kBarHeight; + // 居中约 74% 宽(对齐原版:色阶条不占满,两侧各留 ~13% 边距)。 + const int marginX = static_cast(W * 0.13); + const int barLeft = marginX; + const int barRight = W - marginX; + const int barW = barRight - barLeft; + if (barW < 10) return; // 等宽色带:每相邻断点一格,等宽(对齐原版图例,非按值比例)。 const int nSeg = static_cast(stops.size()) - 1; - auto segX = [&](int i) { return static_cast(static_cast(i) / nSeg * (W - 1)); }; + auto segX = [&](int i) { return barLeft + static_cast(static_cast(i) / nSeg * barW); }; for (int i = 0; i < nSeg; ++i) { int xL = segX(i), xR = segX(i + 1); @@ -42,7 +48,7 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { } // 外边框 p.setPen(QPen(QColor(120, 120, 120), 1)); - p.drawRect(0, barY, W - 1, barH - 1); + p.drawRect(barLeft, barY, barW - 1, barH - 1); // 边界值标签(深色文字,白底可见),在各分格边界下方。 QFont font = p.font(); diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 9ca2b59..901ee92 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -87,8 +87,12 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { auto* panner = new QwtPlotPanner(plot_->canvas()); panner->setMouseButton(Qt::LeftButton); - new QwtPlotMagnifier(plot_->canvas()); + auto* magnifier = new QwtPlotMagnifier(plot_->canvas()); + // 反转滚轮方向:Qwt 默认 wheelFactor=0.9 → 上滚缩小;取倒数使「上滚=放大」(常规习惯)。 + magnifier->setWheelFactor(1.0 / 0.9); + // 允许随停靠面板自由收缩(不强制最小宽度→不出横向滚动条,对齐原版响应式填充)。 + plot_->setMinimumSize(0, 0); lay->addWidget(plot_, 1); // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- From 525c123211936ff61cc46cc617cea0386fea724e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 16:14:37 +0800 Subject: [PATCH 26/74] =?UTF-8?q?feat(ui):=20=E5=8E=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=95=A3=E7=82=B9=E9=94=81=E5=AE=9A=20x:y=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=AF=94=E5=B0=BA(QwtPlotRescaler=20aspect=3D1,=20Expanding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/RawDataChartView.cpp | 13 +++++++++++-- src/app/panels/chart/RawDataChartView.hpp | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 901ee92..f6c7609 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace geopro::app { @@ -91,8 +92,15 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { // 反转滚轮方向:Qwt 默认 wheelFactor=0.9 → 上滚缩小;取倒数使「上滚=放大」(常规习惯)。 magnifier->setWheelFactor(1.0 / 0.9); - // 允许随停靠面板自由收缩(不强制最小宽度→不出横向滚动条,对齐原版响应式填充)。 + // 允许随停靠面板自由收缩(不强制最小宽度)。 plot_->setMinimumSize(0, 0); + + // 锁定 x:y 真实比尺(用户选定):1 数据单位 x = 1 数据单位 y(像素)。 + // 参考轴 xTop、Expanding 策略——resize/缩放时维持等比,剖面呈真实"宽扁"形状。 + rescaler_ = new QwtPlotRescaler(plot_->canvas(), QwtPlot::xTop, QwtPlotRescaler::Expanding); + rescaler_->setAspectRatio(1.0); + rescaler_->setEnabled(true); + lay->addWidget(plot_, 1); // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- @@ -134,7 +142,8 @@ void RawDataChartView::setData( plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right()); plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); } - + plot_->updateAxes(); + if (rescaler_) rescaler_->rescale(); // 应用真实比尺 plot_->replot(); // 更新色阶条 diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 25bcc58..09db06c 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -5,6 +5,7 @@ class QComboBox; class QwtPlot; +class QwtPlotRescaler; namespace geopro::app { @@ -25,6 +26,7 @@ public: private: geopro::controller::DatasetDetailController::ChartData data_; QwtPlot* plot_; + QwtPlotRescaler* rescaler_ = nullptr; // 锁定 x:y 真实比尺 ColorBarWidget* colorBar_; QComboBox* chartTypeCombo_; From 1054c227e14c8d1029e430c8976cda66ea63704e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 16:33:35 +0800 Subject: [PATCH 27/74] =?UTF-8?q?perf/fix(ui):=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=87=92=E5=8A=A0=E8=BD=BD(=E5=8F=8C=E5=87=BB=E5=8F=AA?= =?UTF-8?q?=E6=8B=89=E6=95=A3=E7=82=B9~0.8s,=E7=BD=91=E6=A0=BC4s=E6=8E=A8?= =?UTF-8?q?=E8=BF=9F)=20+=20=E5=AE=9E=E6=97=B6=E6=8B=96=E5=8A=A8=E5=B9=B3?= =?UTF-8?q?=E7=A7=BB(LivePanner=E6=9B=BF=E6=8D=A2=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E6=8A=93=E5=8F=96)=20+=20=E6=A8=AA=E7=BA=B5=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/chart/LivePanner.cpp | 61 +++++++++++++++++++ src/app/panels/chart/LivePanner.hpp | 29 +++++++++ src/app/panels/chart/RawDataChartView.cpp | 19 ++++-- src/controller/DatasetDetailController.cpp | 4 +- .../test_dataset_detail_controller.cpp | 5 +- 6 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/app/panels/chart/LivePanner.cpp create mode 100644 src/app/panels/chart/LivePanner.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index c4b9efe..0816cd0 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(geopro_desktop WIN32 panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp panels/chart/ScatterPlotItem.cpp + panels/chart/LivePanner.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp diff --git a/src/app/panels/chart/LivePanner.cpp b/src/app/panels/chart/LivePanner.cpp new file mode 100644 index 0000000..6e633f1 --- /dev/null +++ b/src/app/panels/chart/LivePanner.cpp @@ -0,0 +1,61 @@ +#include "panels/chart/LivePanner.hpp" + +#include +#include +#include +#include + +namespace geopro::app { + +LivePanner::LivePanner(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) plot_->canvas()->installEventFilter(this); +} + +bool LivePanner::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + switch (ev->type()) { + case QEvent::MouseButtonPress: { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { + panning_ = true; + startPos_ = me->pos(); + x0Min_ = plot_->axisScaleDiv(xAxis_).lowerBound(); + x0Max_ = plot_->axisScaleDiv(xAxis_).upperBound(); + y0Min_ = plot_->axisScaleDiv(yAxis_).lowerBound(); + y0Max_ = plot_->axisScaleDiv(yAxis_).upperBound(); + return true; + } + break; + } + case QEvent::MouseMove: { + if (!panning_) break; + auto* me = static_cast(ev); + const int W = plot_->canvas()->width(); + const int H = plot_->canvas()->height(); + if (W <= 0 || H <= 0) break; + const double dxData = (me->pos().x() - startPos_.x()) * (x0Max_ - x0Min_) / W; + const double dyData = (me->pos().y() - startPos_.y()) * (y0Max_ - y0Min_) / H; + // 拖右(dx>0)→窗口左移(xmin/xmax 减),内容随光标右移; + // 拖下(像素 dy>0)→yLeft 数据向上→窗口上移(ymin/ymax 增),内容随光标下移。 + plot_->setAxisScale(xAxis_, x0Min_ - dxData, x0Max_ - dxData); + plot_->setAxisScale(yAxis_, y0Min_ + dyData, y0Max_ + dyData); + plot_->replot(); + return true; + } + case QEvent::MouseButtonRelease: { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { + panning_ = false; + return true; + } + break; + } + default: + break; + } + return QObject::eventFilter(obj, ev); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/LivePanner.hpp b/src/app/panels/chart/LivePanner.hpp new file mode 100644 index 0000000..7ef146b --- /dev/null +++ b/src/app/panels/chart/LivePanner.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +class QwtPlot; + +namespace geopro::app { + +// 实时拖动平移:左键拖动时连续平移两轴并 replot(坐标轴 + 内容一起实时移动), +// 区别于 QwtPlotPanner 的"像素抓取"(拖动中轴不动、边缘空白、松手才定稿)。 +// 平移为纯平移,不改变纵横比(与 QwtPlotRescaler 锁定的真实比尺兼容)。 +class LivePanner : public QObject { + Q_OBJECT +public: + LivePanner(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + QwtPlot* plot_; + int xAxis_; + int yAxis_; + bool panning_ = false; + QPoint startPos_; + double x0Min_ = 0, x0Max_ = 0, y0Min_ = 0, y0Max_ = 0; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index f6c7609..6a2ab36 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -10,11 +10,13 @@ #include #include -#include #include #include +#include #include +#include "panels/chart/LivePanner.hpp" + namespace geopro::app { RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { @@ -71,6 +73,16 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { plot_->setPalette(pal); } + // 横纵网格线(对齐原版浅灰网格)。 + auto* grid = new QwtPlotGrid(); + grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); + grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine); + grid->enableXMin(false); + grid->enableYMin(false); + grid->setXAxis(QwtPlot::xTop); + grid->setYAxis(QwtPlot::yLeft); + grid->attach(plot_); + // 过原点零线(对齐原版 zeroline:x=0 竖线 + y=0 横线 → "四象限"观感)。 auto* zeroX = new QwtPlotMarker(); zeroX->setLineStyle(QwtPlotMarker::VLine); @@ -84,9 +96,8 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { zeroY->setLinePen(QColor(180, 180, 180), 1.0); zeroY->attach(plot_); - // 交互:Panner(左键拖动平移)+ Magnifier(滚轮缩放) - auto* panner = new QwtPlotPanner(plot_->canvas()); - panner->setMouseButton(Qt::LeftButton); + // 交互:LivePanner(左键拖动实时平移,坐标轴+内容一起动)+ Magnifier(滚轮缩放) + new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); auto* magnifier = new QwtPlotMagnifier(plot_->canvas()); // 反转滚轮方向:Qwt 默认 wheelFactor=0.9 → 上滚缩小;取倒数使「上滚=放大」(常规习惯)。 diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index d3e4a48..379f47c 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -19,10 +19,10 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd ChartData d; d.dsId = dsId; d.ddCode = ddCode; + // 仅拉原数据视图所需(scatter + 散点色阶 + 异常,~0.8s)。 + // 网格数据(inversion/rows 服务端网格化 ~4s)推迟到切「网格数据」页时按需加载。 d.scatter = repo_.loadScatter(id); d.scatterScale = repo_.loadScatterColorScale(id); - d.grid = repo_.loadGrid(id); - d.gridScale = repo_.loadColorScale(id); d.anomalies = repo_.loadAnomalies(id); busy_ = false; emit chartReady(d); diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index 1247677..f1b1b01 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -7,8 +7,9 @@ namespace { struct StubRepo : data::IDatasetRepository { bool fail = false; std::vector loadStructure() override { return {}; } - core::Grid loadGrid(const std::string&) override { if (fail) throw std::runtime_error("x"); core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } - core::ScatterField loadScatter(const std::string&) override { return {}; } + core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } + // openDataset 现只拉 scatter/scatterScale/anomalies(网格懒加载),失败路径由 loadScatter 抛出触发。 + core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; } core::ColorScale loadColorScale(const std::string&) override { return {}; } core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } std::vector loadAnomalies(const std::string&) override { return {}; } From e54a28e57141adfafe8b9794080855a39438a70e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 16:41:56 +0800 Subject: [PATCH 28/74] =?UTF-8?q?perf(ui):=20=E5=8E=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=A5=E6=A0=BC=E5=AF=B9=E9=BD=90=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E5=8F=AA=E6=8B=89=E6=95=A3=E7=82=B9+=E8=89=B2?= =?UTF-8?q?=E9=98=B62=E4=B8=AA(=E5=BC=82=E5=B8=B8=E9=9A=8F=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E6=87=92=E5=8A=A0=E8=BD=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/DatasetDetailController.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 379f47c..12f4995 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -19,11 +19,10 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd ChartData d; d.dsId = dsId; d.ddCode = ddCode; - // 仅拉原数据视图所需(scatter + 散点色阶 + 异常,~0.8s)。 - // 网格数据(inversion/rows 服务端网格化 ~4s)推迟到切「网格数据」页时按需加载。 + // 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。 + // 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。 d.scatter = repo_.loadScatter(id); d.scatterScale = repo_.loadScatterColorScale(id); - d.anomalies = repo_.loadAnomalies(id); busy_ = false; emit chartReady(d); } catch (const std::exception& e) { From bac0a198ffcca52e9b08333a8ae376f2ac3085ea Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:00:07 +0800 Subject: [PATCH 29/74] =?UTF-8?q?feat(ui):=20=E7=BD=91=E6=A0=BC=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=87=92=E5=8A=A0=E8=BD=BD=E6=8E=A5=E7=BA=BF(?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=20gridReady=20+=20=E9=A1=B5?= =?UTF-8?q?=E7=AD=BE=E9=A6=96=E6=AC=A1=E6=BF=80=E6=B4=BB=E8=A7=A6=E5=8F=91?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatasetDetailController 新增 GridData 内嵌结构 + loadGridData slot (busy 守卫, 仅 dd_inversion_data; 拉 rows+色阶type2+异常) + gridReady 信号。 buildTabbedPanel 暴露 tabGroup 供调用方监听页签切换。 DatasetDetailPage 在「网格数据」页签首次激活且未加载时 emit gridDataNeeded; setGridData 转发给 GridDataChartView 并标记已加载(切 ds 重置)。 DatasetDetailPanel 透传 gridDataNeeded / 按 dsId 路由 setGridData。 main.cpp 接线 gridDataNeeded->loadGridData, gridReady->setGridData。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/PanelHeader.cpp | 1 + src/app/PanelHeader.hpp | 5 ++++- src/app/main.cpp | 8 ++++++++ src/app/panels/DatasetDetailPage.cpp | 22 ++++++++++++++++++++++ src/app/panels/DatasetDetailPage.hpp | 7 +++++++ src/app/panels/DatasetDetailPanel.cpp | 11 ++++++++++- src/app/panels/DatasetDetailPanel.hpp | 4 +++- src/controller/DatasetDetailController.cpp | 20 ++++++++++++++++++++ src/controller/DatasetDetailController.hpp | 11 +++++++++++ 9 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/app/PanelHeader.cpp b/src/app/PanelHeader.cpp index 61e1021..8b2fea7 100644 --- a/src/app/PanelHeader.cpp +++ b/src/app/PanelHeader.cpp @@ -125,6 +125,7 @@ TabbedPanel buildTabbedPanel(const QVector& tabs, const QVector
    badges; + QButtonGroup* tabGroup = nullptr; }; // 构建带 Tab 表头的面板(首个 Tab 默认激活;点击 Tab 切换下方堆叠内容)。 diff --git a/src/app/main.cpp b/src/app/main.cpp index 22925f8..b979cd4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -508,6 +508,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re detailPanel, [detailPanel](const QString& dsId) { detailPanel->focusDataset(dsId); }); + // ── 网格数据懒加载:网格页首次激活 → 拉 rows+色阶type2+异常 → 回填对应页 ── + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::gridDataNeeded, &detailCtrl, + &geopro::controller::DatasetDetailController::loadGridData); + QObject::connect( + &detailCtrl, &geopro::controller::DatasetDetailController::gridReady, detailPanel, + [detailPanel](const geopro::controller::DatasetDetailController::GridData& d) { + detailPanel->setGridData(d); + }); QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window, [&window](const QString& dsId, const QString& msg) { window.statusBar()->showMessage( diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 2c8b7b7..5cf559f 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -1,5 +1,6 @@ #include "panels/DatasetDetailPage.hpp" +#include #include #include "Glyphs.hpp" @@ -9,6 +10,10 @@ namespace geopro::app { +namespace { +constexpr int kGridTabIndex = 1; // 「网格数据」页签在 tabs 中的索引 +} + DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); @@ -27,12 +32,29 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { auto tabbedPanel = buildTabbedPanel(tabs, actions); lay->addWidget(tabbedPanel.container); + + // 「网格数据」页签首次激活 → 懒加载网格数据(rows 服务端网格化慢,故不随开页同步拉)。 + if (tabbedPanel.tabGroup) { + connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) { + if (idx != kGridTabIndex || gridRequested_ || dsId_.isEmpty()) return; + gridRequested_ = true; + emit gridDataNeeded(dsId_, ddCode_); + }); + } } void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { dsId_ = d.dsId; + ddCode_ = d.ddCode; + gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 rawView_->setData(d); gridView_->setData(d); } +void DatasetDetailPage::setGridData( + const geopro::controller::DatasetDetailController::GridData& d) { + gridRequested_ = true; // 已加载,切回网格页不再重复请求 + gridView_->setGridData(d.grid, d.gridScale, d.anomalies); +} + } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index ba1e29f..4f46f57 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -14,9 +14,16 @@ class DatasetDetailPage : public QWidget { public: explicit DatasetDetailPage(QWidget* parent = nullptr); void setData(const geopro::controller::DatasetDetailController::ChartData& d); + // 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 + void setGridData(const geopro::controller::DatasetDetailController::GridData& d); QString dsId() const { return dsId_; } +signals: + // 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 + void gridDataNeeded(const QString& dsId, const QString& ddCode); private: QString dsId_; + QString ddCode_; + bool gridRequested_ = false; // 已请求过(避免重复发信号) RawDataChartView* rawView_; GridDataChartView* gridView_; }; diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index a5267a3..3f5583e 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -20,10 +20,19 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { auto* p = pageFor(d.dsId); - if (!p) { p = new DatasetDetailPage(this); addTab(p, d.dsId); } // 标题后续可换 ds 名 + if (!p) { + p = new DatasetDetailPage(this); + addTab(p, d.dsId); // 标题后续可换 ds 名 + // 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 + connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); + } p->setData(d); setCurrentWidget(p); } + +void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { + if (auto* p = pageFor(d.dsId)) p->setGridData(d); +} void DatasetDetailPanel::focusDataset(const QString& dsId) { if (auto* p = pageFor(dsId)) setCurrentWidget(p); } diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 3df160a..ddee714 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -10,9 +10,11 @@ class DatasetDetailPanel : public QTabWidget { public: explicit DatasetDetailPanel(QWidget* parent = nullptr); void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 + void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 void focusDataset(const QString& dsId); // 单击聚焦已开页 signals: - void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 + void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 + void gridDataNeeded(const QString& dsId, const QString& ddCode); // 网格页首次激活 → 请求懒加载 private: DatasetDetailPage* pageFor(const QString& dsId) const; }; diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 12f4995..ba07695 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -31,5 +31,25 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd } } +void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { + if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) + if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 + busy_ = true; + const std::string id = dsId.toStdString(); + try { + GridData d; + d.dsId = dsId; + // 网格数据:rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。 + d.grid = repo_.loadGrid(id); + d.gridScale = repo_.loadColorScale(id); + d.anomalies = repo_.loadAnomalies(id); + busy_ = false; + emit gridReady(d); + } catch (const std::exception& e) { + busy_ = false; + emit loadFailed(dsId, QString::fromStdString(e.what())); + } +} + void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } } // namespace geopro::controller diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 31a753b..a870647 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -21,12 +21,23 @@ public: geopro::core::ColorScale gridScale; std::vector anomalies; }; + // 网格数据(inversion/rows + 色阶 type2 + 异常):随「网格数据」页签首次激活按需懒加载。 + struct GridData { + QString dsId; + geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位值初始化,loadGridData 会覆盖 + geopro::core::ColorScale gridScale; + std::vector anomalies; + }; + explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); public slots: void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 void focusDataset(const QString& dsId); // 单击=聚焦已开页 + // 网格数据懒加载:网格页首次激活时调用(rows 服务端网格化 1-4s,故不随 openDataset 拉)。 + void loadGridData(const QString& dsId, const QString& ddCode); signals: void chartReady(const ChartData& data); + void gridReady(const GridData& data); void focusRequested(const QString& dsId); void loadFailed(const QString& dsId, const QString& message); private: From 32e0aaec2815fdaea0823f99aeba8e208f4bf221 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:00:22 +0800 Subject: [PATCH 30/74] =?UTF-8?q?feat(ui):=20ContourPlotItem=20=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E5=A1=AB=E5=85=85=E6=A0=85=E6=A0=BC=E7=83=AD=E5=8A=9B?= =?UTF-8?q?=E5=9B=BE=20+=20=E7=9F=A2=E9=87=8F=E7=AD=89=E5=80=BC=E7=BA=BF/?= =?UTF-8?q?=E6=A0=87=E6=B3=A8/=E5=BC=82=E5=B8=B8=E5=8F=A0=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QwtPlotItem(非 Q_OBJECT)。填充用预渲染 ARGB32 QImage(每格细分 K=4, 双线性插值, 离散色带取色 → 平滑填充带边界; 含 NaN 格的像素透明 → 不规则白边), draw 时按数据 bbox 映射目标矩形 blit + SmoothPixmapTransform(拖动/缩放快)。 等值线复用 buildContourBands 的 lines(矢量), 黑 cosmetic 细线; level 由线上代表点采网格值吸附最近色阶级回填(管线恒 0), 沿线方向旋转标注(字号10)。 异常按 markType 画 点(方块)/线(折线)/面(闭合多边形), lineColor + dashed->虚线。 x 轴绑 xBottom, y 轴绑 yLeft。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/CMakeLists.txt | 1 + src/app/panels/chart/ContourPlotItem.cpp | 260 +++++++++++++++++++++++ src/app/panels/chart/ContourPlotItem.hpp | 58 +++++ 3 files changed, 319 insertions(+) create mode 100644 src/app/panels/chart/ContourPlotItem.cpp create mode 100644 src/app/panels/chart/ContourPlotItem.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 0816cd0..a20d844 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(geopro_desktop WIN32 panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp panels/chart/ScatterPlotItem.cpp + panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp new file mode 100644 index 0000000..cb45281 --- /dev/null +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -0,0 +1,260 @@ +#include "panels/chart/ContourPlotItem.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "panels/chart/ColorMapService.hpp" + +namespace geopro::app { + +namespace { +constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K(双线性插值平滑带边界) +constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存) +constexpr int kLabelFontPx = 10; // 等值线标注字号 +constexpr int kLabelMinSegPx = 60; // 太短的等值线不标注(像素长度阈值,draw 期判定) +constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI) +} // namespace + +ContourPlotItem::ContourPlotItem() : QwtPlotItem() { + setRenderHint(QwtPlotItem::RenderAntialiased, false); + // 网格数据 x 轴在底部(与 RawDataChartView 的顶部 x 轴不同);y 轴在左。 + setXAxis(QwtPlot::xBottom); + setYAxis(QwtPlot::yLeft); +} + +void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, + const std::vector& anoms, bool showLines, + bool showLabels) { + showLines_ = showLines; + showLabels_ = showLabels; + anoms_ = anoms; + + const int nx = g.nx(), ny = g.ny(); + if (nx < 2 || ny < 2 || static_cast(g.x.size()) < nx || + static_cast(g.y.size()) < ny) { + fillImage_ = QImage(); + dataBBox_ = QRectF(); + lines_.clear(); + return; + } + + const double xmin = g.x.front(), xmax = g.x.back(); + const double ymin = g.y.front(), ymax = g.y.back(); + dataBBox_ = QRectF(xmin, ymin, xmax - xmin, ymax - ymin); + + buildFillImage(g, svc); + + // 等值线(矢量):复用 render 管线,仅取 lines(填充走栅格)。 + if (showLines_) { + render::ContourOptions opt; + opt.upsample = 2; + opt.makeLines = true; + auto res = render::buildContourBands(g, svc->scale(), opt); + lines_ = std::move(res.lines); + // buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级, + // 使标注显示真实等值线值。 + resolveLineLevels(g, svc->scale()); + } else { + lines_.clear(); + } +} + +void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) { + const auto stops = cs.stopValues(); + if (stops.empty() || lines_.empty()) return; + + const int nx = g.nx(), ny = g.ny(); + const double xmin = g.x.front(), xmax = g.x.back(); + const double ymin = g.y.front(), ymax = g.y.back(); + const double xspan = (xmax - xmin), yspan = (ymax - ymin); + + // 在数据坐标点做双线性采样(NaN 安全)。 + auto sampleAt = [&](const core::Vec2& p) -> double { + if (xspan <= 0 || yspan <= 0) return std::nan(""); + double fi = (p.x - xmin) / xspan * (nx - 1); + double fj = (p.y - ymin) / yspan * (ny - 1); + fi = std::clamp(fi, 0.0, static_cast(nx - 1)); + fj = std::clamp(fj, 0.0, static_cast(ny - 1)); + int i0 = std::min(static_cast(fi), nx - 2); + int j0 = std::min(static_cast(fj), ny - 2); + double ti = fi - i0, tj = fj - j0; + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + return std::nan(""); + return (v00 * (1 - ti) + v10 * ti) * (1 - tj) + (v01 * (1 - ti) + v11 * ti) * tj; + }; + + for (auto& ln : lines_) { + double sampled = std::nan(""); + for (const auto& p : ln.pts) { // 取首个非 NaN 采样点 + sampled = sampleAt(p); + if (!std::isnan(sampled)) break; + } + if (std::isnan(sampled)) { + ln.level = std::nan(""); + continue; + } + // 吸附到最近色阶级(等值线恰落在某级上)。 + double best = stops.front(); + double bestD = std::fabs(sampled - best); + for (double s : stops) { + double d = std::fabs(sampled - s); + if (d < bestD) { bestD = d; best = s; } + } + ln.level = best; + } +} + +void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) { + const int nx = g.nx(), ny = g.ny(); + int W = (nx - 1) * kFillUpsample + 1; + int H = (ny - 1) * kFillUpsample + 1; + // 限幅:极端网格下按比例降采样,保证内存/性能可控。 + if (W > kMaxFillDim) W = kMaxFillDim; + if (H > kMaxFillDim) H = kMaxFillDim; + + QImage img(W, H, QImage::Format_ARGB32); + img.fill(Qt::transparent); + + // 每像素 → 归一化网格坐标 (fi,fj) → 四邻格双线性插值;任一邻格 NaN 则该像素透明。 + for (int py = 0; py < H; ++py) { + // 图像顶行 py=0 对应 y 最大(ymax,地表);底行对应 y 最小(最深)。 + double fj = static_cast(H - 1 - py) / (H - 1) * (ny - 1); + int j0 = std::min(static_cast(fj), ny - 2); + double tj = fj - j0; + auto* scan = reinterpret_cast(img.scanLine(py)); + for (int px = 0; px < W; ++px) { + double fi = static_cast(px) / (W - 1) * (nx - 1); + int i0 = std::min(static_cast(fi), nx - 2); + double ti = fi - i0; + + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + continue; // 含无数据格 → 像素透明(不规则白边) + + double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + + (v01 * (1 - ti) + v11 * ti) * tj; + auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界 + scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255); + } + } + fillImage_ = std::move(img); +} + +QRectF ContourPlotItem::boundingRect() const { + return dataBBox_; +} + +void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, + const QRectF& /*canvasRect*/) const { + if (dataBBox_.isNull()) return; + + const double xmin = dataBBox_.left(), xmax = dataBBox_.right(); + const double ymin = dataBBox_.top(), ymax = dataBBox_.bottom(); + + // 1) 填充:数据 bbox → 像素矩形(注意 y 翻转:ymax→画布上沿、ymin→下沿),blit + 平滑缩放。 + if (!fillImage_.isNull()) { + const double pxL = xMap.transform(xmin); + const double pxR = xMap.transform(xmax); + const double pyTop = yMap.transform(ymax); + const double pyBot = yMap.transform(ymin); + QRectF target(pxL, pyTop, pxR - pxL, pyBot - pyTop); + painter->save(); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + painter->drawImage(target, fillImage_); + painter->restore(); + } + + auto mapPt = [&](const core::Vec2& p) { + return QPointF(xMap.transform(p.x), yMap.transform(p.y)); + }; + + // 2) 等值线:黑色 0 宽(cosmetic)细线。 + if (showLines_ && !lines_.empty()) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QPen pen(QColor(0, 0, 0)); + pen.setWidthF(0.0); // cosmetic:恒 1px,不随缩放变粗 + painter->setPen(pen); + for (const auto& ln : lines_) { + if (ln.pts.size() < 2) continue; + QPolygonF poly; + poly.reserve(static_cast(ln.pts.size())); + for (const auto& p : ln.pts) poly << mapPt(p); + painter->drawPolyline(poly); + } + painter->restore(); + + // 3) 标注:沿线中段画 level 数值(小字黑,随相邻两点方向旋转)。 + if (showLabels_) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QFont f = painter->font(); + f.setPixelSize(kLabelFontPx); + painter->setFont(f); + painter->setPen(QColor(0, 0, 0)); + for (const auto& ln : lines_) { + if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; + // 取折线中段两点定位置/朝向。 + const std::size_t mid = ln.pts.size() / 2; + const QPointF a = mapPt(ln.pts[mid - 1]); + const QPointF b = mapPt(ln.pts[mid]); + // 整条线像素长度太短不标注(避免密集杂乱)。 + const QPointF s = mapPt(ln.pts.front()); + const QPointF e = mapPt(ln.pts.back()); + if (std::hypot(e.x() - s.x(), e.y() - s.y()) < kLabelMinSegPx) continue; + double ang = std::atan2(b.y() - a.y(), b.x() - a.x()) * kRad2Deg; + if (ang > 90.0) ang -= 180.0; // 保持文字大体正向(不上下颠倒) + if (ang < -90.0) ang += 180.0; + const QString txt = QString::number(ln.level, 'g', 4); + painter->save(); + painter->translate((a.x() + b.x()) * 0.5, (a.y() + b.y()) * 0.5); + painter->rotate(ang); + painter->drawText(QPointF(-12, -2), txt); + painter->restore(); + } + painter->restore(); + } + } + + // 4) 异常叠加:点=小方块、线=折线、面=闭合多边形;颜色用 lineColor,dashed→虚线。 + if (showAnomalies_ && !anoms_.empty()) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setBrush(Qt::NoBrush); + for (const auto& a : anoms_) { + if (a.localPts.empty()) continue; + QColor col(QString::fromStdString(a.lineColor)); + if (!col.isValid()) col = QColor(0, 0, 0); + QPen pen(col); + pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0); + pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine); + painter->setPen(pen); + + if (a.markType == core::AnomalyMarkType::Point) { + const QPointF c = mapPt(a.localPts.front()); + painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6)); + } else { + QPolygonF poly; + poly.reserve(static_cast(a.localPts.size())); + for (const auto& p : a.localPts) poly << mapPt(p); + if (a.markType == core::AnomalyMarkType::Polygon) + painter->drawPolygon(poly); // 闭合 + else + painter->drawPolyline(poly); + } + } + painter->restore(); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourPlotItem.hpp b/src/app/panels/chart/ContourPlotItem.hpp new file mode 100644 index 0000000..5052773 --- /dev/null +++ b/src/app/panels/chart/ContourPlotItem.hpp @@ -0,0 +1,58 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/Anomaly.hpp" +#include "model/Field.hpp" +#include "ContourBands.hpp" + +class QPainter; +class QwtScaleMap; + +namespace geopro::app { + +class ColorMapService; + +// 网格等值线图项(QwtPlotItem,非 Q_OBJECT): +// - 填充:预渲染 QImage 热力图(双线性插值 + 离散色带取色),draw 时 blit+平滑缩放 +// (避免 banded 多边形数万顶点导致拖动卡顿);含 NaN 的像素透明 → 不规则白边。 +// - 等值线:buildContourBands 返回的矢量折线(黑细线),随轴变换映射后 drawPolyline。 +// - 标注:沿线方向旋转的 level 数值(小字黑)。 +// - 异常叠加:按 markType 画 点(方块)/线(折线)/面(闭合多边形),dashed→虚线。 +class ContourPlotItem : public QwtPlotItem { +public: + ContourPlotItem(); + + // 构建填充图像 + 缓存等值线/异常。svc 不被拥有(由 GridDataChartView 持有)。 + void setData(const core::Grid& g, ColorMapService* svc, + const std::vector& anoms, bool showLines, bool showLabels); + + void setShowLines(bool on) { showLines_ = on; } + void setShowLabels(bool on) { showLabels_ = on; } + void setShowAnomalies(bool on) { showAnomalies_ = on; } + + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } + + QRectF boundingRect() const override; + + void draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, + const QRectF& canvasRect) const override; + +private: + void buildFillImage(const core::Grid& g, ColorMapService* svc); + void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs); + + QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区) + QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax]) + std::vector lines_; // 矢量等值线(含 level) + std::vector anoms_; // 异常叠加 + + bool showLines_ = true; + bool showLabels_ = true; + bool showAnomalies_ = true; +}; + +} // namespace geopro::app From f14902cdd41d463ac46cc97b8e6ee42388aeb13d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:00:36 +0800 Subject: [PATCH 31/74] =?UTF-8?q?feat(ui):=20GridDataChartView=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20QwtPlot+ContourPlotItem(=E7=9C=9F=E5=AE=9E=E6=AF=94?= =?UTF-8?q?=E5=B0=BA,=E7=99=BD=E5=BA=95,=E8=89=B2=E9=98=B6=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plotArea 占位换 QwtPlot(仿 RawDataChartView: 白底浅 palette, x 轴 xBottom/y 轴 yLeft, LivePanner 实时平移, Magnifier setWheelFactor(1/0.9), QwtPlotRescaler ref=xBottom aspect=1 真实比尺; 网格线/过原点零线省去, 被填充覆盖)。色阶占位换 ColorBarWidget。 setGridData: 建 ColorMapService(gridScale) + ContourPlotItem.setData → attach, 按数据 bbox setAxisScale + rescale + replot, 更新色阶条与底部异常表。 工具条「显示异常/显示等值线标注」复选框 → 切 ContourPlotItem 开关并 replot。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/panels/chart/GridDataChartView.cpp | 110 +++++++++++++++++++-- src/app/panels/chart/GridDataChartView.hpp | 53 +++++++--- 2 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index c9847cc..78deba6 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -7,9 +7,18 @@ #include #include +#include +#include +#include +#include + #include "PanelHeader.hpp" #include "panels/AnomalyTablePanel.hpp" #include "panels/DescriptionPanel.hpp" +#include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourPlotItem.hpp" +#include "panels/chart/LivePanner.hpp" namespace geopro::app { @@ -85,16 +94,42 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { lay->addWidget(toolbar); - // ---- 图表占位区(stretch) ---- - plotArea_ = new QWidget(this); - plotArea_->setObjectName(QStringLiteral("gridPlotArea")); - lay->addWidget(plotArea_, 1); + // ---- QwtPlot(仿 RawDataChartView,差异:x 轴在底部、不画过原点零线、网格线被填充覆盖故省)---- + plot_ = new QwtPlot(this); + plot_->setObjectName(QStringLiteral("gridPlotArea")); + plot_->enableAxis(QwtPlot::xBottom, true); + plot_->enableAxis(QwtPlot::xTop, false); + plot_->enableAxis(QwtPlot::yLeft, true); - // ---- 色阶占位(固定高 36px) ---- - auto* colorScaleBar = new QWidget(this); - colorScaleBar->setObjectName(QStringLiteral("gridColorScaleBar")); - colorScaleBar->setFixedHeight(36); - lay->addWidget(colorScaleBar); + // 白底浅色(对齐原版 web 图表)。 + plot_->setCanvasBackground(QBrush(Qt::white)); + plot_->setAutoFillBackground(true); + { + QPalette pal = plot_->palette(); + pal.setColor(QPalette::Window, Qt::white); + pal.setColor(QPalette::WindowText, QColor(90, 90, 90)); + pal.setColor(QPalette::Text, QColor(90, 90, 90)); + plot_->setPalette(pal); + } + + // 交互:LivePanner(左键实时平移,x 底轴 + y 左轴)+ Magnifier(滚轮缩放,反向使上滚=放大)。 + new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); + auto* magnifier = new QwtPlotMagnifier(plot_->canvas()); + magnifier->setWheelFactor(1.0 / 0.9); + + plot_->setMinimumSize(0, 0); + + // 锁定 x:y 真实比尺(参考轴 xBottom,Expanding)。 + rescaler_ = new QwtPlotRescaler(plot_->canvas(), QwtPlot::xBottom, QwtPlotRescaler::Expanding); + rescaler_->setAspectRatio(1.0); + rescaler_->setEnabled(true); + + lay->addWidget(plot_, 1); + + // ---- 独立色阶条(固定高 36px)---- + colorBar_ = new ColorBarWidget(this); + colorBar_->setObjectName(QStringLiteral("gridColorScaleBar")); + lay->addWidget(colorBar_); // ---- 底部双页签(异常列表 / 描述),固定高约 200px ---- anomalyTable_ = new AnomalyTablePanel(this); @@ -109,12 +144,67 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { auto* bottomContainer = tabbedBottom.container; bottomContainer->setFixedHeight(200); lay->addWidget(bottomContainer); + + // ---- 工具条开关 → 重建/重绘 ---- + connect(chkShowAnom, &QCheckBox::toggled, this, [this](bool on) { + showAnomalies_ = on; + if (contourItem_) { contourItem_->setShowAnomalies(on); plot_->replot(); } + }); + connect(chkShowContourLabel, &QCheckBox::toggled, this, [this](bool on) { + showLabels_ = on; + if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } + }); } void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; - // 步骤1:只把 anomalies 喂给底部异常列表;图表区留空。 + // 开页:仅把 anomalies 喂给底部异常列表;图表区待网格数据懒加载后填充。 anomalyTable_->setAnomalies(d.anomalies, {}, {}); } +void GridDataChartView::setGridData(const geopro::core::Grid& grid, + const geopro::core::ColorScale& gridScale, + const std::vector& anoms) { + grid_ = grid; + gridScale_ = gridScale; + anoms_ = anoms; + hasGrid_ = true; + + // 重建色彩服务(旧 contourItem 已 detach/delete)。 + delete colorSvc_; + colorSvc_ = new ColorMapService(gridScale_); + + rebuildContour(); + + // 色阶条 + 底部异常表(懒加载结果含真实异常)。 + colorBar_->setColorScale(gridScale_); + anomalyTable_->setAnomalies(anoms_, {}, {}); +} + +void GridDataChartView::rebuildContour() { + if (!hasGrid_ || !colorSvc_) return; + + // 卸载旧项(QwtPlot 不拥有 item)。 + if (contourItem_) { + contourItem_->detach(); + delete contourItem_; + contourItem_ = nullptr; + } + + contourItem_ = new ContourPlotItem(); + contourItem_->setData(grid_, colorSvc_, anoms_, /*showLines*/ true, showLabels_); + contourItem_->setShowAnomalies(showAnomalies_); + contourItem_->attach(plot_); + + // 轴范围 = 数据范围(x=距离、y=深度/高程)。 + const QRectF bbox = contourItem_->boundingRect(); + if (!bbox.isNull()) { + plot_->setAxisScale(QwtPlot::xBottom, bbox.left(), bbox.right()); + plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); + } + plot_->updateAxes(); + if (rescaler_) rescaler_->rescale(); // 应用真实比尺 + plot_->replot(); +} + } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index 978b26f..75d9898 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -1,38 +1,65 @@ #pragma once +#include + #include + #include "DatasetDetailController.hpp" +#include "model/Anomaly.hpp" +#include "model/ColorScale.hpp" +#include "model/Field.hpp" class QSlider; class QLabel; +class QwtPlot; +class QwtPlotRescaler; namespace geopro::app { class AnomalyTablePanel; class DescriptionPanel; +class ColorBarWidget; +class ColorMapService; +class ContourPlotItem; -// 网格数据图表视图(步骤1骨架):工具条 + 图表占位 + 色阶占位 + 底部双页签(异常列表/描述)。 -// 图表渲染由后续步骤填入 plotArea()。 +// 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部) +// + 独立色阶条 + 底部双页签(异常列表/描述)。 +// 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。 class GridDataChartView : public QWidget { Q_OBJECT public: explicit GridDataChartView(QWidget* parent = nullptr); - // 步骤1:把 anomalies 喂给底部异常列表;图表区留空。 + // 开页时调用:仅喂底部异常列表(网格数据随网格页激活懒加载)。 void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 供后续步骤填充 Qwt 画布的占位区域。 - QWidget* plotArea() const { return plotArea_; } - - // 供后续步骤联动异常叠加。 - AnomalyTablePanel* anomalyTable() const { return anomalyTable_; } + // 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem,挂到 QwtPlot,更新色阶条/异常表。 + void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale, + const std::vector& anoms); private: + void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem + geopro::controller::DatasetDetailController::ChartData data_; - QWidget* plotArea_; - AnomalyTablePanel* anomalyTable_; - DescriptionPanel* descriptionPanel_; - QSlider* simplifySlider_; - QLabel* simplifyValueLabel_; + + QwtPlot* plot_ = nullptr; + QwtPlotRescaler* rescaler_ = nullptr; + ColorBarWidget* colorBar_ = nullptr; + AnomalyTablePanel* anomalyTable_ = nullptr; + DescriptionPanel* descriptionPanel_ = nullptr; + QSlider* simplifySlider_ = nullptr; + QLabel* simplifyValueLabel_ = nullptr; + + // 渲染状态 + ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建 + ContourPlotItem* contourItem_ = nullptr; + geopro::core::Grid grid_{1, 1}; + geopro::core::ColorScale gridScale_; + std::vector anoms_; + bool hasGrid_ = false; + + // 工具条显隐开关 + bool showAnomalies_ = true; + bool showLabels_ = true; }; } // namespace geopro::app From 03a6622dea0555a22e4338ba1d3429fc0d14da19 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:22:39 +0800 Subject: [PATCH 32/74] =?UTF-8?q?fix(ui):=20=E6=BB=9A=E8=BD=AE=E7=BC=A9?= =?UTF-8?q?=E6=94=BE=E5=B9=B6=E5=85=A5LivePanner=E5=B9=B6=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E4=BA=8B=E4=BB=B6(=E4=B8=8D=E5=86=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=A4=96=E5=B1=82=E6=BB=9A=E5=8A=A8=E6=9D=A1)=20+=20?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E9=A1=B5=E5=BA=95=E9=83=A8=E6=94=B9QSplitter?= =?UTF-8?q?(=E5=8E=BB=E5=9B=BA=E5=AE=9A200px,=E4=B8=A4=E9=A1=B5=E9=AB=98?= =?UTF-8?q?=E5=BA=A6=E8=A7=A3=E8=80=A6/=E4=B8=8D=E6=BA=A2=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/GridDataChartView.cpp | 32 +++++++++++++--------- src/app/panels/chart/LivePanner.cpp | 21 ++++++++++++++ src/app/panels/chart/RawDataChartView.cpp | 7 +---- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index 78deba6..f7f84bb 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -9,7 +9,7 @@ #include #include -#include +#include #include #include "PanelHeader.hpp" @@ -112,10 +112,8 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { plot_->setPalette(pal); } - // 交互:LivePanner(左键实时平移,x 底轴 + y 左轴)+ Magnifier(滚轮缩放,反向使上滚=放大)。 + // 交互:LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。 new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); - auto* magnifier = new QwtPlotMagnifier(plot_->canvas()); - magnifier->setWheelFactor(1.0 / 0.9); plot_->setMinimumSize(0, 0); @@ -124,26 +122,34 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { rescaler_->setAspectRatio(1.0); rescaler_->setEnabled(true); - lay->addWidget(plot_, 1); - - // ---- 独立色阶条(固定高 36px)---- + // 图表区 = 绘图 + 色阶条(色阶条固定高 36)。 colorBar_ = new ColorBarWidget(this); colorBar_->setObjectName(QStringLiteral("gridColorScaleBar")); - lay->addWidget(colorBar_); + auto* chartArea = new QWidget(this); + auto* chartLay = new QVBoxLayout(chartArea); + chartLay->setContentsMargins(0, 0, 0, 0); + chartLay->setSpacing(0); + chartLay->addWidget(plot_, 1); + chartLay->addWidget(colorBar_); - // ---- 底部双页签(异常列表 / 描述),固定高约 200px ---- + // ---- 底部双页签(异常列表 / 描述)---- anomalyTable_ = new AnomalyTablePanel(this); descriptionPanel_ = new DescriptionPanel(this); - const QVector bottomTabs = { {Glyph::Anomaly, QStringLiteral("异常列表"), anomalyTable_, true}, {Glyph::Property, QStringLiteral("描述"), descriptionPanel_, false}, }; auto tabbedBottom = buildTabbedPanel(bottomTabs, {}); - auto* bottomContainer = tabbedBottom.container; - bottomContainer->setFixedHeight(200); - lay->addWidget(bottomContainer); + // 图表区 | 底部表 用竖直 QSplitter:自适应 dock 高度、可拖拽、不强制固定 200px + //(避免内容溢出滚动条;也使本页与「原数据」页高度互不耦合)。 + auto* splitter = new QSplitter(Qt::Vertical, this); + splitter->addWidget(chartArea); + splitter->addWidget(tabbedBottom.container); + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 1); + splitter->setChildrenCollapsible(false); + lay->addWidget(splitter, 1); // ---- 工具条开关 → 重建/重绘 ---- connect(chkShowAnom, &QCheckBox::toggled, this, [this](bool on) { diff --git a/src/app/panels/chart/LivePanner.cpp b/src/app/panels/chart/LivePanner.cpp index 6e633f1..d7c765b 100644 --- a/src/app/panels/chart/LivePanner.cpp +++ b/src/app/panels/chart/LivePanner.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -52,6 +53,26 @@ bool LivePanner::eventFilter(QObject* obj, QEvent* ev) { } break; } + case QEvent::Wheel: { + // 滚轮缩放(以光标为中心,上滚=放大),并 **消费事件**(return true)避免冒泡到外层 + // 滚动区域触发滚动条。两轴同因子缩放→保持锁定的真实比尺。 + auto* we = static_cast(ev); + const int d = we->angleDelta().y(); + if (d == 0) return true; + const double f = (d > 0) ? (1.0 / 1.15) : 1.15; // 上滚→区间缩小→放大 + const QPointF pos = we->position(); + const double cx = plot_->invTransform(xAxis_, pos.x()); + const double cy = plot_->invTransform(yAxis_, pos.y()); + const double xMin = plot_->axisScaleDiv(xAxis_).lowerBound(); + const double xMax = plot_->axisScaleDiv(xAxis_).upperBound(); + const double yMin = plot_->axisScaleDiv(yAxis_).lowerBound(); + const double yMax = plot_->axisScaleDiv(yAxis_).upperBound(); + plot_->setAxisScale(xAxis_, cx - (cx - xMin) * f, cx + (xMax - cx) * f); + plot_->setAxisScale(yAxis_, cy - (cy - yMin) * f, cy + (yMax - cy) * f); + plot_->replot(); + we->accept(); + return true; + } default: break; } diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 6a2ab36..20aae00 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -10,7 +10,6 @@ #include #include -#include #include #include #include @@ -96,13 +95,9 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { zeroY->setLinePen(QColor(180, 180, 180), 1.0); zeroY->attach(plot_); - // 交互:LivePanner(左键拖动实时平移,坐标轴+内容一起动)+ Magnifier(滚轮缩放) + // 交互:LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。 new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); - auto* magnifier = new QwtPlotMagnifier(plot_->canvas()); - // 反转滚轮方向:Qwt 默认 wheelFactor=0.9 → 上滚缩小;取倒数使「上滚=放大」(常规习惯)。 - magnifier->setWheelFactor(1.0 / 0.9); - // 允许随停靠面板自由收缩(不强制最小宽度)。 plot_->setMinimumSize(0, 0); From 53dbcee052d718b1abce3b5c0fcf9d4616640bb6 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:57:54 +0800 Subject: [PATCH 33/74] =?UTF-8?q?fix(ui):=20=E6=95=B0=E6=8D=AE=E8=AF=A6?= =?UTF-8?q?=E6=83=85dock=E8=AE=BEForceNoScrollArea(=E7=A6=81ADS=E6=8A=8A?= =?UTF-8?q?=E6=A0=87=E9=A2=98/=E9=A1=B5=E7=AD=BE=E5=8D=B7=E5=85=A5?= =?UTF-8?q?=E6=95=B4=E4=BD=93=E6=BB=9A=E5=8A=A8=E6=9D=A1),=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=87=AA=E9=80=82=E5=BA=94=E5=A1=AB=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index b979cd4..105c8f2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -395,8 +395,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 auto* detailPanel = new geopro::app::DatasetDetailPanel(); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); + // ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 + // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; + // 需要时由内层(图表内容区)自行滚动,标题/页签固定。 detailDock->setWidget(wrapWithHeader( - geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel)); + geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel), + ads::CDockWidget::ForceNoScrollArea); // 放在中央视图下方。 dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); From 4fbab033f076645c0d9e4bb5a4cb7839f1922b80 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 18:21:52 +0800 Subject: [PATCH 34/74] =?UTF-8?q?fix(ui):=20=E7=AD=89=E5=80=BC=E7=BA=BF?= =?UTF-8?q?=E7=94=A8vtkStripper=E8=BF=9E=E6=88=90=E9=95=BF=E6=8A=98?= =?UTF-8?q?=E7=BA=BF(=E4=BD=BF=E6=B2=BF=E7=BA=BF=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E5=8F=AF=E8=90=BD=E4=B8=8A)=20+=20=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E9=A1=B5(=E5=9B=BE+=E5=BC=82=E5=B8=B8)=E5=88=86?= =?UTF-8?q?=E5=89=B2=E5=99=A8=E6=94=BE=E5=85=A5=E9=A1=B5=E7=AD=BE=E5=86=85?= =?UTF-8?q?QScrollArea(=E6=BB=9A=E5=8A=A8=E9=99=90=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E5=86=85)=20+=20=E7=AD=89=E5=80=BC=E7=BA=BF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ContourPlotItem.cpp | 2 +- src/app/panels/chart/GridDataChartView.cpp | 19 +++++++++--- src/render/ContourBands.cpp | 15 +++++++--- tests/render/test_contour_bands.cpp | 35 ++++++++++++++++++++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index cb45281..e5e469d 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -183,7 +183,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); QPen pen(QColor(0, 0, 0)); - pen.setWidthF(0.0); // cosmetic:恒 1px,不随缩放变粗 + pen.setWidthF(1.0); // 1px 黑色等值线 painter->setPen(pen); for (const auto& ln : lines_) { if (ln.pts.size() < 2) continue; diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index f7f84bb..31c5046 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include "PanelHeader.hpp" @@ -141,15 +143,24 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { }; auto tabbedBottom = buildTabbedPanel(bottomTabs, {}); - // 图表区 | 底部表 用竖直 QSplitter:自适应 dock 高度、可拖拽、不强制固定 200px - //(避免内容溢出滚动条;也使本页与「原数据」页高度互不耦合)。 - auto* splitter = new QSplitter(Qt::Vertical, this); + // 图表区 | 底部表 竖直 QSplitter(可拖拽调整比例)。给定最小高度→分割器有最小尺寸。 + chartArea->setMinimumHeight(280); + tabbedBottom.container->setMinimumHeight(160); + auto* splitter = new QSplitter(Qt::Vertical); splitter->addWidget(chartArea); splitter->addWidget(tabbedBottom.container); splitter->setStretchFactor(0, 3); splitter->setStretchFactor(1, 1); splitter->setChildrenCollapsible(false); - lay->addWidget(splitter, 1); + + // 页签内滚动:把(图表+异常)分割器放进 QScrollArea。dock 够高→分割器填满(可拖动调整); + // dock 太矮→在页签内部出现竖滚动条(工具条/页签/标题固定),而非整个面板滚动。 + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(splitter); + lay->addWidget(scroll, 1); // ---- 工具条开关 → 重建/重绘 ---- connect(chkShowAnom, &QCheckBox::toggled, this, [this](bool on) { diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 3f6a53c..330e903 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -152,14 +153,20 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const out.bands.push_back(std::move(bp)); } - // port1:等值线(polylines)+ DP 简化。仅 makeLines=true 时生成(否则不提取)。 + // port1:等值线。banded 边输出是大量 2 点短线段——用 vtkStripper 连成长折线, + // 便于沿线标注与减少绘制调用。仅 makeLines=true 时生成。 if (opt.makeLines) { vtkPolyData* edges = banded->GetContourEdgesOutput(); if (edges) { - edges->BuildCells(); - const vtkIdType nLines = edges->GetNumberOfCells(); + vtkNew stripper; + stripper->SetInputData(edges); + stripper->JoinContiguousSegmentsOn(); + stripper->Update(); + vtkPolyData* joined = stripper->GetOutput(); + joined->BuildCells(); + const vtkIdType nLines = joined->GetNumberOfCells(); for (vtkIdType c = 0; c < nLines; ++c) { - vtkCell* cell = edges->GetCell(c); + vtkCell* cell = joined->GetCell(c); vtkPoints* cp = cell->GetPoints(); ContourLine cl; cl.level = 0.0; for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) { diff --git a/tests/render/test_contour_bands.cpp b/tests/render/test_contour_bands.cpp index c4b7b48..b87998b 100644 --- a/tests/render/test_contour_bands.cpp +++ b/tests/render/test_contour_bands.cpp @@ -53,3 +53,38 @@ TEST(ContourBands, ProducesNonEmptyBands) { ASSERT_FALSE(r.bands.empty()); for (const auto& b : r.bands) EXPECT_GE(b.ring.size(), 3u); } + +// 诊断:等值线(lines)应非空——网格跨越多个色阶级时应产生等值线。 +TEST(ContourBands, ProducesContourLines) { + Grid g(10, 10); + g.x.resize(10); g.y.resize(10); + for (int i = 0; i < 10; ++i) { g.x[i] = i; g.y[i] = i; } + for (int j = 0; j < 10; ++j) + for (int i = 0; i < 10; ++i) g.valueAt(i, j) = i + j; // 0..18 + g.vmin = 0; g.vmax = 18; + ColorScale cs; + cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(6, Rgba{0,255,0,255}); + cs.addStop(12, Rgba{255,255,0,255}); cs.addStop(18, Rgba{255,0,0,255}); + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; opt.makeLines = true; + auto r = buildContourBands(g, cs, opt); + EXPECT_FALSE(r.lines.empty()); +} + +// 诊断(真实参数复现):大网格 + 默认 opt(upsample=2,smooth=0.3,simplifyTol=0.5) + 17 级色阶。 +TEST(ContourBands, ProducesLinesRealisticOptions) { + Grid g(60, 40); + g.x.resize(60); g.y.resize(40); + for (int i = 0; i < 60; ++i) g.x[i] = i * 3.0; + for (int j = 0; j < 40; ++j) g.y[j] = -33.0 + j * 1.2; + for (int j = 0; j < 40; ++j) + for (int i = 0; i < 60; ++i) + g.valueAt(i, j) = 2.0 + 340.0 * (0.5 + 0.5 * std::sin(i * 0.2) * std::cos(j * 0.25)); + g.vmin = 2; g.vmax = 348; + ColorScale cs; + const double bars[17] = {2.08,4.59,7.81,11.02,14.26,17.25,20.56,23.55,27.05, + 31.16,36.83,43.64,51.98,62.09,81.14,108.24,348.52}; + for (int i = 0; i < 17; ++i) cs.addStop(bars[i], Rgba{(unsigned char)(i*15),0,200,255}); + ContourOptions opt; // 默认 upsample=2, smooth=0.3, simplifyTol=0.5, makeLines=true + auto r = buildContourBands(g, cs, opt); + EXPECT_FALSE(r.lines.empty()); +} From 48d21b82e90647b54cf777ffb4bf6e4cbd307734 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 18:33:12 +0800 Subject: [PATCH 35/74] =?UTF-8?q?fix(ui):=20=E7=AD=89=E5=80=BC=E7=BA=BF=20?= =?UTF-8?q?vtkSplineFilter=20=E6=A0=B7=E6=9D=A1=E5=B9=B3=E6=BB=91(?= =?UTF-8?q?=E5=8E=BBDP=E7=AE=80=E5=8C=96,=E8=B4=B4=E8=BF=91=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E5=9C=86=E6=BB=91=E6=9B=B2=E7=BA=BF)=20+=20=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=B2=BF=E7=BA=BF=E5=91=A8=E6=9C=9F=E9=87=8D=E5=A4=8D?= =?UTF-8?q?+=E9=99=8D=E9=98=88=E5=80=BC(=E5=B0=8F=E7=AD=89=E5=80=BC?= =?UTF-8?q?=E7=BA=BF=E4=B9=9F=E6=A0=87=E6=B3=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ContourPlotItem.cpp | 53 ++++++++++++++++-------- src/render/ContourBands.cpp | 14 +++++-- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index e5e469d..1ad252e 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -18,7 +18,8 @@ namespace { constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K(双线性插值平滑带边界) constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存) constexpr int kLabelFontPx = 10; // 等值线标注字号 -constexpr int kLabelMinSegPx = 60; // 太短的等值线不标注(像素长度阈值,draw 期判定) +constexpr double kLabelMinLenPx = 24.0; // 整条线像素长度小于此不标注(极短碎线) +constexpr double kLabelSpacingPx = 220.0; // 沿线每隔此像素重复一个标注(对齐原版密度) constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI) } // namespace @@ -202,25 +203,43 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt f.setPixelSize(kLabelFontPx); painter->setFont(f); painter->setPen(QColor(0, 0, 0)); + const QFontMetricsF fm(f); for (const auto& ln : lines_) { if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; - // 取折线中段两点定位置/朝向。 - const std::size_t mid = ln.pts.size() / 2; - const QPointF a = mapPt(ln.pts[mid - 1]); - const QPointF b = mapPt(ln.pts[mid]); - // 整条线像素长度太短不标注(避免密集杂乱)。 - const QPointF s = mapPt(ln.pts.front()); - const QPointF e = mapPt(ln.pts.back()); - if (std::hypot(e.x() - s.x(), e.y() - s.y()) < kLabelMinSegPx) continue; - double ang = std::atan2(b.y() - a.y(), b.x() - a.x()) * kRad2Deg; - if (ang > 90.0) ang -= 180.0; // 保持文字大体正向(不上下颠倒) - if (ang < -90.0) ang += 180.0; + // 映射到像素 + 累计像素弧长。 + std::vector px; + px.reserve(ln.pts.size()); + for (const auto& p : ln.pts) px.push_back(mapPt(p)); + double total = 0.0; + for (std::size_t i = 1; i < px.size(); ++i) + total += std::hypot(px[i].x() - px[i - 1].x(), px[i].y() - px[i - 1].y()); + if (total < kLabelMinLenPx) continue; // 极短碎线不标注 const QString txt = QString::number(ln.level, 'g', 4); - painter->save(); - painter->translate((a.x() + b.x()) * 0.5, (a.y() + b.y()) * 0.5); - painter->rotate(ang); - painter->drawText(QPointF(-12, -2), txt); - painter->restore(); + const double halfW = fm.horizontalAdvance(txt) * 0.5; + // 沿线每隔 kLabelSpacingPx 放一个标注;首个在半间距处(短线即中点附近)。 + double nextAt = std::min(kLabelSpacingPx * 0.5, total * 0.5); + double acc = 0.0; + for (std::size_t i = 1; i < px.size() && nextAt <= total; ++i) { + const double seg = + std::hypot(px[i].x() - px[i - 1].x(), px[i].y() - px[i - 1].y()); + while (nextAt <= acc + seg && nextAt <= total) { + const double t = seg > 1e-6 ? (nextAt - acc) / seg : 0.0; + const QPointF pos(px[i - 1].x() + (px[i].x() - px[i - 1].x()) * t, + px[i - 1].y() + (px[i].y() - px[i - 1].y()) * t); + double ang = + std::atan2(px[i].y() - px[i - 1].y(), px[i].x() - px[i - 1].x()) * + kRad2Deg; + if (ang > 90.0) ang -= 180.0; // 文字大体正向 + if (ang < -90.0) ang += 180.0; + painter->save(); + painter->translate(pos); + painter->rotate(ang); + painter->drawText(QPointF(-halfW, -2), txt); + painter->restore(); + nextAt += kLabelSpacingPx; + } + acc += seg; + } } painter->restore(); } diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 330e903..7a0d6e2 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -161,8 +162,15 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const vtkNew stripper; stripper->SetInputData(edges); stripper->JoinContiguousSegmentsOn(); - stripper->Update(); - vtkPolyData* joined = stripper->GetOutput(); + // 样条平滑:把折线拟合成平滑曲线并按弧长重采样(对齐原版圆滑等值线)。 + // 重采样间距取数据 x 跨度的 ~1/300,足够平滑且点数可控。 + const double xspan = g.x.empty() ? 1.0 : (g.x.back() - g.x.front()); + vtkNew spline; + spline->SetInputConnection(stripper->GetOutputPort()); + spline->SetSubdivideToLength(); + spline->SetLength(xspan > 0 ? xspan / 300.0 : 1.0); + spline->Update(); + vtkPolyData* joined = spline->GetOutput(); joined->BuildCells(); const vtkIdType nLines = joined->GetNumberOfCells(); for (vtkIdType c = 0; c < nLines; ++c) { @@ -173,7 +181,7 @@ ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const double xyz[3]; cp->GetPoint(p, xyz); cl.pts.push_back(Vec2{xyz[0], xyz[1]}); } - simplifyInPlace(cl.pts, opt.simplifyTol); + // 不再 DP 简化:样条已平滑且点数受重采样间距控制;简化会重新折线化。 if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl)); } } From 57c14ae8b4bf56461241563c9ebade612e96c1d9 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 18:36:22 +0800 Subject: [PATCH 36/74] =?UTF-8?q?fix(ui):=20=E7=AD=89=E5=80=BC=E7=BA=BF?= =?UTF-8?q?=E6=AF=8F=E6=9D=A1=E5=8F=AA=E6=A0=87=E6=B3=A8=E4=B8=80=E4=B8=AA?= =?UTF-8?q?(=E5=BC=A7=E9=95=BF=E4=B8=AD=E7=82=B9),=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=8E=9F=E7=89=88(=E7=A7=BB=E9=99=A4=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=A0=87=E6=B3=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ContourPlotItem.cpp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index 1ad252e..dcc4091 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -19,7 +19,6 @@ constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K( constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存) constexpr int kLabelFontPx = 10; // 等值线标注字号 constexpr double kLabelMinLenPx = 24.0; // 整条线像素长度小于此不标注(极短碎线) -constexpr double kLabelSpacingPx = 220.0; // 沿线每隔此像素重复一个标注(对齐原版密度) constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI) } // namespace @@ -195,7 +194,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt } painter->restore(); - // 3) 标注:沿线中段画 level 数值(小字黑,随相邻两点方向旋转)。 + // 3) 标注:每条等值线只标一个(对齐原版),放弧长中点,随该处方向旋转。 if (showLabels_) { painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); @@ -216,14 +215,13 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt if (total < kLabelMinLenPx) continue; // 极短碎线不标注 const QString txt = QString::number(ln.level, 'g', 4); const double halfW = fm.horizontalAdvance(txt) * 0.5; - // 沿线每隔 kLabelSpacingPx 放一个标注;首个在半间距处(短线即中点附近)。 - double nextAt = std::min(kLabelSpacingPx * 0.5, total * 0.5); + const double targetAt = total * 0.5; // 弧长中点 double acc = 0.0; - for (std::size_t i = 1; i < px.size() && nextAt <= total; ++i) { + for (std::size_t i = 1; i < px.size(); ++i) { const double seg = std::hypot(px[i].x() - px[i - 1].x(), px[i].y() - px[i - 1].y()); - while (nextAt <= acc + seg && nextAt <= total) { - const double t = seg > 1e-6 ? (nextAt - acc) / seg : 0.0; + if (acc + seg >= targetAt || i == px.size() - 1) { + const double t = seg > 1e-6 ? (targetAt - acc) / seg : 0.0; const QPointF pos(px[i - 1].x() + (px[i].x() - px[i - 1].x()) * t, px[i - 1].y() + (px[i].y() - px[i - 1].y()) * t); double ang = @@ -236,7 +234,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt painter->rotate(ang); painter->drawText(QPointF(-halfW, -2), txt); painter->restore(); - nextAt += kLabelSpacingPx; + break; // 只标一个 } acc += seg; } From 78f96dbc081aa26d9e1e8179ee1797b980ad969b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 18:59:19 +0800 Subject: [PATCH 37/74] =?UTF-8?q?fix(review):=20=E4=BF=AE=20cpp-review=20H?= =?UTF-8?q?IGH/MEDIUM=20=E2=80=94=E6=95=A3=E7=82=B9ys=E8=B6=8A=E7=95=8C/co?= =?UTF-8?q?lorSvc=E6=9E=90=E6=9E=84=E6=B3=84=E6=BC=8F/QwtPlot=20autoDelete?= =?UTF-8?q?=E6=B3=A8=E9=87=8A/=E6=8E=A7=E5=88=B6=E5=99=A8catch(...)?= =?UTF-8?q?=E9=98=B2busy=E6=AD=BB=E9=94=81=20+=20=E6=B8=85=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81simplifyInPlace/simplifyTol=20+=20=E5=A1=AB?= =?UTF-8?q?=E5=85=85=E7=AD=89=E6=AF=94=E9=99=90=E5=B9=85=20+=20DTO?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E6=A0=A1=E9=AA=8C+=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E9=92=B3=E5=88=B6=20+=20ContourLine.level=E9=BB=98=E8=AE=A4NaN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/chart/ContourPlotItem.cpp | 10 +++++++--- src/app/panels/chart/GridDataChartView.cpp | 8 +++++++- src/app/panels/chart/GridDataChartView.hpp | 1 + src/app/panels/chart/RawDataChartView.cpp | 9 +++++++-- src/app/panels/chart/RawDataChartView.hpp | 1 + src/app/panels/chart/ScatterPlotItem.cpp | 3 ++- src/controller/DatasetDetailController.cpp | 6 ++++++ src/data/dto/DatasetChartDto.cpp | 7 ++++++- src/render/ContourBands.cpp | 18 ------------------ src/render/ContourBands.hpp | 4 ++-- tests/render/test_contour_bands.cpp | 10 +++++----- 11 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index dcc4091..663bd61 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -117,9 +117,13 @@ void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) const int nx = g.nx(), ny = g.ny(); int W = (nx - 1) * kFillUpsample + 1; int H = (ny - 1) * kFillUpsample + 1; - // 限幅:极端网格下按比例降采样,保证内存/性能可控。 - if (W > kMaxFillDim) W = kMaxFillDim; - if (H > kMaxFillDim) H = kMaxFillDim; + // 限幅:极端网格下按**统一比例**降采样(W/H 独立截断会使采样非均匀→内容横/纵失真)。 + if (W > kMaxFillDim || H > kMaxFillDim) { + const double s = std::min(static_cast(kMaxFillDim) / W, + static_cast(kMaxFillDim) / H); + W = std::max(2, static_cast(W * s)); + H = std::max(2, static_cast(H * s)); + } QImage img(W, H, QImage::Format_ARGB32); img.fill(Qt::transparent); diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index 31c5046..c41eaa4 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -173,6 +173,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { }); } +GridDataChartView::~GridDataChartView() { + // colorSvc_ 非 QObject、无 parent,需手动释放(contourItem_ 由 QwtPlot autoDelete 处理)。 + delete colorSvc_; +} + void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; // 开页:仅把 anomalies 喂给底部异常列表;图表区待网格数据懒加载后填充。 @@ -201,7 +206,8 @@ void GridDataChartView::setGridData(const geopro::core::Grid& grid, void GridDataChartView::rebuildContour() { if (!hasGrid_ || !colorSvc_) return; - // 卸载旧项(QwtPlot 不拥有 item)。 + // 卸载旧项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。 + // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 if (contourItem_) { contourItem_->detach(); delete contourItem_; diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index 75d9898..cf0acf7 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -28,6 +28,7 @@ class GridDataChartView : public QWidget { Q_OBJECT public: explicit GridDataChartView(QWidget* parent = nullptr); + ~GridDataChartView() override; // 开页时调用:仅喂底部异常列表(网格数据随网格页激活懒加载)。 void setData(const geopro::controller::DatasetDetailController::ChartData& d); diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 20aae00..5840f61 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -115,6 +115,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { lay->addWidget(colorBar_); } +RawDataChartView::~RawDataChartView() { + // colorSvc_ 非 QObject、无 parent,需手动释放(plot_ 的 item 由 QwtPlot autoDelete 处理)。 + delete colorSvc_; +} + QWidget* RawDataChartView::plotArea() const { return plot_; } @@ -129,10 +134,10 @@ void RawDataChartView::setData( delete colorSvc_; colorSvc_ = new ColorMapService(d.scatterScale); - // 卸载旧散点项 + // 卸载旧散点项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。 + // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 if (scatterItem_) { scatterItem_->detach(); - // QwtPlot 不拥有 item,需要我们释放 delete scatterItem_; scatterItem_ = nullptr; } diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 09db06c..0344849 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -17,6 +17,7 @@ class RawDataChartView : public QWidget { Q_OBJECT public: explicit RawDataChartView(QWidget* parent = nullptr); + ~RawDataChartView() override; void setData(const geopro::controller::DatasetDetailController::ChartData& d); diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp index bda4d2d..bfd8ed1 100644 --- a/src/app/panels/chart/ScatterPlotItem.cpp +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -50,7 +50,8 @@ void ScatterPlotItem::draw(QPainter* painter, const auto& xs = field_.x; const auto& ys = field_.y; const auto& vs = field_.v; - const std::size_t n = xs.size(); + // x/y/v 来自服务端独立数组,长度可能不一致——取 min 防越界。 + const std::size_t n = std::min(xs.size(), ys.size()); if (n == 0) return; painter->save(); diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index ba07695..5a923b8 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -28,6 +28,9 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd } catch (const std::exception& e) { busy_ = false; emit loadFailed(dsId, QString::fromStdString(e.what())); + } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 + busy_ = false; + emit loadFailed(dsId, QStringLiteral("未知错误")); } } @@ -48,6 +51,9 @@ void DatasetDetailController::loadGridData(const QString& dsId, const QString& d } catch (const std::exception& e) { busy_ = false; emit loadFailed(dsId, QString::fromStdString(e.what())); + } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 + busy_ = false; + emit loadFailed(dsId, QStringLiteral("未知错误")); } } diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 6d20cdf..5cdf49c 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -1,6 +1,7 @@ #include "dto/DatasetChartDto.hpp" #include #include +#include namespace geopro::data::dto { using namespace geopro::core; @@ -18,6 +19,8 @@ Grid parseInversionGrid(const QJsonObject& data) { Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny); g.x.clear(); for (auto e : x) g.x.push_back(num(e)); g.y.clear(); for (auto e : y) g.y.push_back(num(e)); + if (v.size() != ny) // 服务端 v 行数与 y 不符:下方越界处填 NaN,记录便于排查(非静默)。 + qWarning("parseInversionGrid: v rows=%d != ny=%d (缺失行将填 NaN)", v.size(), ny); for (int j = 0; j < ny; ++j) { const QJsonArray row = v.at(j).toArray(); for (int i = 0; i < nx; ++i) { @@ -58,7 +61,9 @@ std::vector parseDatasetAnomalies(const QJsonArray& arr) { Anomaly a; a.name = o.value("exceptionName").toString().toStdString(); a.typeName = o.value("exceptionTypeName").toString().toStdString(); - a.markType = static_cast(o.value("exceptionMarkType").toInt(2)); + const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 + a.markType = (mt >= 1 && mt <= 3) ? static_cast(mt) + : AnomalyMarkType::Polyline; // 越界值兜底为线 const QJsonObject lg = o.value("legend").toObject(); a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); a.lineWidth = lg.value("polylineWidth").toDouble(1.0); diff --git a/src/render/ContourBands.cpp b/src/render/ContourBands.cpp index 7a0d6e2..872d3a5 100644 --- a/src/render/ContourBands.cpp +++ b/src/render/ContourBands.cpp @@ -98,24 +98,6 @@ vtkSmartPointer toCellGrid(const Grid& g) { return ug; } -// Douglas-Peucker 等值线简化(就地)。 -void simplifyInPlace(std::vector& pts, double tol) { - if (tol <= 0 || pts.size() < 3) return; - std::vector keep(pts.size(), 0); keep.front() = keep.back() = 1; - std::vector> st{{0, int(pts.size()) - 1}}; - auto dist = [](const Vec2& p, const Vec2& a, const Vec2& b) { - double dx = b.x - a.x, dy = b.y - a.y, L = std::hypot(dx, dy); - if (L < 1e-12) return std::hypot(p.x - a.x, p.y - a.y); - return std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx) / L; }; - while (!st.empty()) { auto [s, e] = st.back(); st.pop_back(); - double dmax = 0; int idx = -1; - for (int k = s + 1; k < e; ++k) { double d = dist(pts[k], pts[s], pts[e]); - if (d > dmax) { dmax = d; idx = k; } } - if (idx >= 0 && dmax > tol) { keep[idx] = 1; st.push_back({s, idx}); st.push_back({idx, e}); } } - std::vector r; for (size_t k = 0; k < pts.size(); ++k) if (keep[k]) r.push_back(pts[k]); - pts.swap(r); -} - } // namespace ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions& opt) { diff --git a/src/render/ContourBands.hpp b/src/render/ContourBands.hpp index 6b6a642..188d4e0 100644 --- a/src/render/ContourBands.hpp +++ b/src/render/ContourBands.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include "model/Field.hpp" #include "model/ColorScale.hpp" @@ -10,7 +11,7 @@ struct BandPolygon { std::vector ring; // 单环多边形(局部坐标,y=高程向上为正) }; struct ContourLine { - double level; + double level = std::numeric_limits::quiet_NaN(); // NaN=未解析(与真实 level 0 区分) std::vector pts; }; struct ContourBandsResult { @@ -20,7 +21,6 @@ struct ContourBandsResult { struct ContourOptions { int upsample = 2; // 双线性上采样倍数(1=不采样) double smooth = 0.3; // 平滑强度 0..1(0=不平滑) - double simplifyTol = 0.5; // 等值线简化容差(数据单位,0=不简化) bool makeLines = true; }; diff --git a/tests/render/test_contour_bands.cpp b/tests/render/test_contour_bands.cpp index b87998b..37996c4 100644 --- a/tests/render/test_contour_bands.cpp +++ b/tests/render/test_contour_bands.cpp @@ -12,8 +12,8 @@ TEST(ContourBands, UpsampleIncreasesDetail) { g.vmin = 0; g.vmax = 6; ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(3, Rgba{0,255,0,255}); cs.addStop(6, Rgba{255,0,0,255}); - ContourOptions a; a.upsample = 1; a.smooth = 0; a.simplifyTol = 0; - ContourOptions b; b.upsample = 2; b.smooth = 0; b.simplifyTol = 0; + ContourOptions a; a.upsample = 1; a.smooth = 0; + ContourOptions b; b.upsample = 2; b.smooth = 0; auto ra = buildContourBands(g, cs, a); auto rb = buildContourBands(g, cs, b); EXPECT_GT(rb.bands.size(), ra.bands.size()); @@ -27,7 +27,7 @@ TEST(ContourBands, ClipsNaNRegion) { g.valueAt(2, 2) = std::nan(""); // 右上角无效 g.vmin = 0; g.vmax = 10; ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(10, Rgba{255,0,0,255}); - ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; auto r = buildContourBands(g, cs, opt); bool coversCorner = false; for (const auto& b : r.bands) @@ -48,7 +48,7 @@ TEST(ContourBands, ProducesNonEmptyBands) { cs.addStop(2.0, Rgba{0, 255, 0, 255}); cs.addStop(4.0, Rgba{255, 0, 0, 255}); - ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; auto r = buildContourBands(g, cs, opt); ASSERT_FALSE(r.bands.empty()); for (const auto& b : r.bands) EXPECT_GE(b.ring.size(), 3u); @@ -65,7 +65,7 @@ TEST(ContourBands, ProducesContourLines) { ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(6, Rgba{0,255,0,255}); cs.addStop(12, Rgba{255,255,0,255}); cs.addStop(18, Rgba{255,0,0,255}); - ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0; opt.makeLines = true; + ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.makeLines = true; auto r = buildContourBands(g, cs, opt); EXPECT_FALSE(r.lines.empty()); } From 5483a430bce5de57ce6fcc56c7d3ef90a21e7937 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:03:13 +0800 Subject: [PATCH 38/74] =?UTF-8?q?docs:=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=9B=BE=E8=A1=A8=E9=87=8D=E6=9E=84=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E6=96=87=E6=A1=A3(=E8=83=8C=E6=99=AF/=E7=9B=AE?= =?UTF-8?q?=E6=A0=87/=E8=BF=9B=E5=BA=A6/=E4=B8=8B=E4=B8=80=E6=AD=A5/?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=9C=B0=E5=9B=BE/=E5=86=B3=E7=AD=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HANDOFF-dataset-detail-chart.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/HANDOFF-dataset-detail-chart.md diff --git a/docs/superpowers/HANDOFF-dataset-detail-chart.md b/docs/superpowers/HANDOFF-dataset-detail-chart.md new file mode 100644 index 0000000..9a982df --- /dev/null +++ b/docs/superpowers/HANDOFF-dataset-detail-chart.md @@ -0,0 +1,97 @@ +# 交接文档:数据集详情图表(dataset-detail-chart) + +> 给下一个会话:读完本文件 + 文末"立即要做的事"即可无缝接手。日期 2026-06-11。 + +## 1. 背景 + +geopro 是 **Qt6 / C++ 离线桌面客户端**,目标是**像素级 1:1 复刻** web 系统 +`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)的「数据集详情」视图。 +本轮专做 ERT 反演数据(`ddCode=dd_inversion_data`)的详情视图。 + +**硬约束**:禁止 QWebEngine/Chromium(离线要求)。图表用本地 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**,不是 web 渲染。验收标准是**视觉等价 + 交互一致**(非字节级像素 diff)。 + +**标准测试 ds**:`id=1458990939709440`(ddCode=dd_inversion_data)。原版网格视图参考截图:仓库根 `web-grid-E3b.png`(未入库,可重新用 Playwright 截)。 + +## 2. 目标 + +数据详情含两个页签,均已完成: +- **原数据**:Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。 +- **网格数据**:填充等值面 + 黑色等值线 + 沿线数值标注 + 不规则白边(NaN 裁剪)+ 底部异常表/描述。 + +## 3. 技术栈与构建 + +- Qt6 Widgets;**Qwt 6.2**(源码在 `external/qwt-src/`,**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,不要用 qmake——本机 VS2026 缺 vswhere,qmake 不可用)。 + - 关键:qwt.cmake 必须 `target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1)`,否则 61 个链接错误;需链接 Qt6 Widgets/Concurrent/PrintSupport/Svg。 +- **VTK 9.6 仅用算法层**(`vtkBandedPolyDataContourFilter` / `vtkStripper` / `vtkSplineFilter` / `vtkDataSetSurfaceFilter`),不做 VTK 渲染。 +- CMake(VS 自带 4.2.3)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 Qt 依赖)。 +- **构建**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` + - ⚠️ 若报 `LNK1104 无法打开 geopro_desktop.exe`,是 exe 正在运行:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。 +- **测试**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(当前 **75/75 绿**)。 + - 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ContourBands.*` + +## 4. 数据 API(均经 Playwright 实测) + +Auth header:`geomativeauthorization: Geomative `。ApiClient 把非 object 的 `data` 包成 `{"value": }`。 +**原版也是分页懒加载**(客户端已对齐): + +| 页 | 接口 | 说明 | +|---|---|---| +| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` | 2 个请求,~0.8s | +| 网格数据 | `dd/ert/inversion/rows/{id}` + `lvl/colorGradation/getDetail`(type2) + `exception/queryException/{id}` | 3 个,rows 服务端网格化**波动 1–4s** | + +- `rows`:`x[nx]`、`y[ny]`、`v[ny][nx]`(电阻率,含 NaN=无数据)、`z[ny][nx]`(高程)、vmin/vmax。标准 ds 为 nx=300,ny=100。 +- `colorGradation`:`properties.colorBar=[[值,"rgba()"],…]`(type1 散点连续/type2 网格离散,~17 段)+ `lineConfig{showLines,color,lineType}` + `labelConfig{showLabels,color}`。 +- **配色机制(实测纠正过)**:不是 log;是 **colorBar 值线性归一化到 [0,1] + 非均匀色阶停靠点 + 连续插值(散点)/离散分带(网格)**。 + +## 5. 架构与关键文件 + +数据流:`DatasetListPanel` 双击 → `DatasetDetailController.openDataset`(同步阻塞拉原数据 2 请求)→ `chartReady(ChartData)` → `DatasetDetailPanel`(多 ds 的 QTabWidget) → `DatasetDetailPage`(buildTabbedPanel 原数据/网格 两页签) → 两个 View。切到网格页签 → `gridDataNeeded` 冒泡 → `loadGridData`(懒加载 3 请求)→ `gridReady(GridData)` → `GridDataChartView.setGridData`。 + +- `src/controller/DatasetDetailController.{hpp,cpp}` — openDataset / **loadGridData**(懒加载);busy_ 重入守卫 + catch(...) 防死锁。signal: chartReady/gridReady/loadFailed/focusRequested。 +- `src/app/panels/chart/`: + - `RawDataChartView.*` — 散点视图模板(白底浅 palette、QwtPlotGrid 网格线、过原点 QwtPlotMarker 零线、**QwtPlotRescaler 锁定 x:y 真实比尺(aspect=1, ref=xTop)**、**LivePanner**、x 轴在 **xTop**、独立 ColorBarWidget)。析构 delete colorSvc_。 + - `GridDataChartView.*` — 网格视图。x 轴在 **xBottom**。布局:toolbar(固定) + **QScrollArea(setWidgetResizable)** 内含 **QSplitter(竖直)**{ chartArea(plot+colorBar, minH280) | 异常表/描述(minH160) }。→ 页签内滚动 + 可拖分割条。 + - `ContourPlotItem.*`(QwtPlotItem)— **填充走栅格**:QImage(ARGB32, K=4 上采样, 双线性插值 + `colorAtDiscrete` 离散着色 + 任一邻格 NaN→像素透明=白边裁剪),draw 时 blit;**等值线走矢量**:`buildContourBands` 取 lines;标注:`resolveLineLevels` 采样吸附级别,**每条线只标一个**(弧长中点,旋转)。异常叠加(点/线/面)。 + - `ScatterPlotItem.*` — 散点项(xTop/yLeft,7px 方块白描边,连续配色,n=min(x,y) 防越界)。 + - `ColorMapService.*` — colorAtContinuous(线性插值)/colorAtDiscrete(阶梯),from core::ColorScale。 + - `ColorBarWidget.*` — 独立色阶条,**居中约 74% 宽**(非满宽)、等宽色带、白底深字。 + - `LivePanner.*` — canvas 事件过滤器:**左键实时平移**(连续平移两轴+replot) + **滚轮缩放**(以光标为中心,上滚=放大,**消费事件**避免冒泡触发外层滚动条)。取代了 QwtPlotPanner/QwtPlotMagnifier。 +- `src/render/ContourBands.{hpp,cpp}` — `buildContourBands(Grid, ColorScale, ContourOptions{upsample=2,smooth=0.3,makeLines})` → `{bands, lines}`。VTK:网格→上采样→平滑→toCellGrid(剔 NaN 格)→surface→banded→(lines: **vtkStripper 连段 + vtkSplineFilter 样条平滑**, 不再 DP 简化)。 +- `src/data/dto/DatasetChartDto.*`、`src/data/api/ApiDatasetRepository.*` — JSON 解析(v 行数校验 qWarning、markType 钳制)。 +- `src/app/main.cpp` — 装配:detailDock `setWidget(..., ads::CDockWidget::ForceNoScrollArea)`(**禁 ADS 把标题/页签卷入整体滚动条**);gridDataNeeded→loadGridData、gridReady→setGridData 接线。 + +## 6. 关键设计决策(及为什么) + +- **真实比尺锁定**(用户选定,区别于原版的响应式填充):QwtPlotRescaler aspect=1,剖面呈真实"宽扁"。 +- **填充用栅格而非多边形**:300×100 网格 banded 多边形约 3 万个,逐帧+实时拖动会卡;QImage 一次成图 + blit 流畅。 +- **等值线样条平滑**:banded 边是大量 2 点短线段→ vtkStripper 连接 + vtkSplineFilter 拟合平滑曲线(贴近原版圆滑)。 +- **滚轮事件必须消费**:否则冒泡触发 ADS/外层滚动条。 +- **页签内滚动**:ForceNoScrollArea(dock) + 视图内 QScrollArea(裹 splitter),使标题/页签/工具条固定、仅内容区滚动。 +- **同步阻塞是全 App 共性问题**:ApiClient 用 QEventLoop 死等→每次请求冻 UI。已定**单独立项异步化**(见下)。 + +## 7. 当前进度 + +- 分支 **`feat/dataset-detail-chart`**,领先 main **37 commits**,工作区干净(除未入库的 `web-grid-E3b.png`)。 +- 两个视图 + 交互 + 懒加载 + 布局**全部完成并经用户逐项验收通过**。 +- **cpp-reviewer 审查已做**:HIGH 全修(散点越界 / colorSvc 析构泄漏 / QwtPlot autoDelete 注释 / 控制器 catch(...) 防 busy 死锁)+ 值得的 MEDIUM/LOW(清死代码、填充等比限幅、DTO 校验/枚举钳制、ContourLine.level 默认 NaN)。提交 `78f96db`。75/75 测试绿。 + +## 8. 立即要做的事(接手第一步) + +**收尾本分支**——已问用户"如何收尾",4 选项待其回答:①合并回 main(本地) ②推送建 PR(origin=gitea) ③保持现状 ④丢弃。 +合并/PR 前先清掉未入库的 `web-grid-E3b.png`(保持工作区干净)。**等用户给出选择后执行**(用 superpowers:finishing-a-development-branch)。 + +## 9. 后续计划(本分支之后) + +1. **工具条编辑类功能**(网格页占位按钮):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格 / 另存为 / 导出。交互较重,建议先 brainstorm 拆解。 +2. **ApiClient 异步化(已单独立项)**:把全局同步阻塞 ApiClient 改异步(QNetworkReply 信号,去 QEventLoop)+ Repository/控制器信号化→全 App 不冻 UI、可并发。架构改动,建议单独 spec+plan。 +3. **更广项目**:数据详情只是一个特性;原型/`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`(客户端 tab) 里还有其他数据类型、三维视图、其他菜单。 + +## 10. 相关文档与记忆 + +- 设计/计划:`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`、`docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md`(v2 QwtPlot 返工方案,权威)、`docs/superpowers/plans/2026-06-11-dataset-detail-chart.md`。 +- 外部返工方案:`docs/Geopro3.0_二维图表返工技术方案.md`。 +- API 文档:`docs/apis`(business_OpenAPI.json)。 +- **记忆(务必遵守)**:`~/.claude/projects/D--Git-lanbingtech-geopro/memory/`: + - `reply-in-chinese` — 全程中文回复。 + - `study-original-via-playwright` — **任何不确定必须用 Playwright 实地看原版,禁止联想猜测**。 + - `no-embellishment-replicate-exactly` — 严格 1:1,不加原版没有的特性(曾因等值线"周期重复标注"被纠正,原版每条线只标一个)。 From 69357bccd3ef94013fd025aa686dfa50b44532f8 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:20:15 +0800 Subject: [PATCH 39/74] =?UTF-8?q?docs(spec):=20ApiClient=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=8C=96=E8=AE=BE=E8=AE=A1=EF=BC=88DatasetDetail=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=AF=95=E7=82=B9=EF=BC=9AApiCall/ApiBatch/?= =?UTF-8?q?=E5=8F=A5=E6=9F=84+abort=EF=BC=8C=E4=BF=A1=E5=8F=B7=E9=9D=A2?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-11-apiclient-async-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-apiclient-async-design.md diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md new file mode 100644 index 0000000..844ddc7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -0,0 +1,248 @@ +# 设计:ApiClient 异步化(DatasetDetail 路径试点) + +> 日期 2026-06-11。范围:把数据集详情路径从同步阻塞改为异步非阻塞,作为全 App 异步化的模式样板。 +> 后续会按此模式铺开到导航/登录路径(本期不做)。 + +## 1. 背景 + +geopro 现网络栈三层全同步阻塞: + +- **`ApiClient`**(`src/net/`):单个 `QNetworkAccessManager`,`get/postJson` 内部用 `QNetworkReply + QEventLoop` 死等响应。共享 cookie / JSESSIONID。 +- **Repository**(`ApiDatasetRepository`、`ApiProjectRepository`):同步调 `api_.get/postJson`,解析后返回 typed 值或抛 `std::runtime_error`。 +- **Controller**(`DatasetDetailController`、`WorkbenchNavController`):在 slot 内同步调 repo,再 `emit` 结果信号。 + +**核心问题**:每次请求都用 `QEventLoop` 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 `dd/ert/inversion/rows/{id}` 请求因**服务端网格化波动 1–4s**,期间界面完全卡死。 + +`QEventLoop` 阻塞的副作用是**可重入**:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot,因此现有控制器到处用 `busy_` 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。 + +`QNetworkAccessManager` 本就是异步设计;当前架构用 `QEventLoop` 把它人为变同步,是反模式。本设计回归 Qt 原生异步。 + +## 2. 目标与非目标 + +### 目标 + +1. 数据集详情路径(原数据 + 网格数据)全程**不阻塞 UI 线程**。 +2. 慢请求在飞时切换/关闭数据集页签可**主动 abort 过期请求**,结果不回灌。 +3. 同一逻辑加载内的多个独立请求**并发**发出(原数据 2 个、网格 3 个),缩短总等待。 +4. 加载期间给用户**轻量"加载中"反馈**(不再因冻结而"看起来在响应")。 +5. 产出**可复用的异步原语**(`ApiCall` / `ApiBatch` / 句柄模式),供后续导航/登录路径照搬。 +6. 现有测试不退化;新增异步路径的单测可离线运行。 + +### 非目标(本期不做) + +- **不**改 `WorkbenchNavController` / `ApiProjectRepository` / `AuthService` / 登录流程——它们继续走同步 `ApiClient.get/postJson`。 +- **不**做后台工作线程:网络是 I/O 密集,`QNetworkAccessManager` 原生异步即可,不引入 `QThread`/`QtConcurrent`。 +- **不**追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。 +- **不**改 `LocalSampleRepository`(它实现旧同步 `IDatasetRepository`,但不经详情控制器使用)。 + +## 3. 已定决策(及理由) + +| 决策 | 选择 | 理由 | +|---|---|---| +| 范围 | 仅 DatasetDetail 路径 | 风险小、最痛点(1–4s rows)先解决、产出可复用模式 | +| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 | +| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 | +| 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 | +| 多请求汇聚 | gather-all(等全部完成再回报,校验留 repo) | 避免 fail-fast 的部分-abort 竞态;与现有"任一失败→整体失败"语义对齐(校验在 repo 逐项判定) | + +## 4. 架构 + +``` +DatasetDetailController (QObject,状态/编排) + │ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄 + │ 切换/关页 → 句柄.abort() + ▼ +IAsyncDatasetRepository(纯抽象) ←── ApiDatasetRepository 实现 + │ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall + │ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg) + ▼ +ApiClient.getAsync(path) → ApiCall(包一个 QNetworkReply,实现 IApiCall) + (ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager) +``` + +**信号面保持不变**:`DatasetDetailController` 对外仍发 `chartReady(ChartData)` / `gridReady(GridData)` / `loadFailed(dsId,msg)` / `focusRequested(dsId)`,新增 `loadStarted(dsId, Phase)`。因此 `main.cpp` 接线与 `DatasetDetailPanel` 几乎不动,仅接 `loadStarted` 显示遮罩。 + +## 5. 组件设计 + +### 5.1 net 层 + +#### `IApiCall`(新增,抽象,可测试缝) +```cpp +// src/net/IApiCall.hpp +class IApiCall : public QObject { + Q_OBJECT +public: + using QObject::QObject; + virtual void abort() = 0; // 中断;不再 emit finished;deleteLater +signals: + void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError) +}; +``` +存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。 + +#### `ApiCall`(新增,实现 `IApiCall`) +- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。 +- `abort()`:断开自身与 reply 的连接、`reply->abort()`、`reply->deleteLater()`、`this->deleteLater()`;**保证 abort 后不再 emit `finished`**(杜绝旧结果回灌)。 +- 一处微妙:abort 与 reply 即将 finished 的竞态——先 `disconnect` 再 `abort`,确保 finished 不触达本对象的 emit。 + +#### `ApiClient`(扩展,不破坏现有) +```cpp +net::IApiCall* getAsync(const QString& path); // 立即返回,不阻塞 +net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); +// 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用 +``` +- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。 +- **已知交互(记录为可接受)**:Nav 的同步嵌套 `QEventLoop` 期间,详情的异步 reply 若完成会被一并泵出、其槽重入执行——无害(详情切换会 abort,且互不写同一状态)。全异步后该同步路径消失。 + +#### `ApiBatch`(新增,汇聚原语) +```cpp +// src/net/ApiBatch.hpp +class ApiBatch : public QObject { + Q_OBJECT +public: + explicit ApiBatch(QList calls, QObject* parent=nullptr); // 接管 calls + void abort(); // abort 全部子 call,self deleteLater,不 emit +signals: + void finished(const QList& responses); // 按 calls 下标对齐 +}; +``` +- gather-all:记录每个 call 的 `finished` 到 `responses_[i]`,计数到齐后 emit `finished` 并 `deleteLater`。 +- `abort()`:对每个未完成子 call 调 `abort()`,自删,不 emit。 +- 不做成功/失败判定(留 repo):传输/HTTP 错误体现在对应 `ApiResponse.rawError`/`httpStatus`。 + +### 5.2 data 层 + +#### data 层结果载体(中性,避免 data→controller 反向依赖) +```cpp +// src/data/api/DatasetLoads.hpp +struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; }; +struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector anomalies; }; +``` + +#### `*Load` 句柄(每次加载一个,承载 typed 结果 + abort) +```cpp +class ChartLoad : public QObject { + Q_OBJECT +public: + void abort(); // 转发给内部 ApiBatch +signals: + void done(const geopro::data::ChartParts& parts); + void failed(const QString& message); +}; +class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ }; +``` +- 由 repo 创建并持有内部 `ApiBatch`;batch 完成时逐项校验+解析→ emit `done`/`failed`→ self `deleteLater`。 +- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。 + +#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它) +```cpp +// src/data/repo/IAsyncDatasetRepository.hpp +class IAsyncDatasetRepository { +public: + virtual ~IAsyncDatasetRepository() = default; + virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + scatterScale(type1) + virtual data::GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + scale(type2) + anomalies +}; +``` +- 复合方法(而非旧 6 个原子 `loadXxx`):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo(它本就知道请求集)是正确归属,控制器保持轻薄。 + +#### `ApiDatasetRepository`(改造) +- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。 +- `loadChartAsync`:`ApiBatch{ getScatter, getScatterScale }` → 完成后复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts`;任一项错误 → `failed`。 +- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 解析填 `GridParts`;同上。 +- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。 + +### 5.3 controller 层 + +#### `DatasetDetailController`(改造) +```cpp +enum class Phase { Chart, Grid }; +signals: + void loadStarted(const QString& dsId, Phase phase); // 新增:驱动加载遮罩 + // 不变:chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId) +private: + IAsyncDatasetRepository& repo_; + QPointer chartLoad_; // 当前在飞(QPointer 防悬垂) + QPointer gridLoad_; +``` +- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 abort**,`repo_.loadChartAsync` 取新句柄,连 `done`→组 `ChartData`→`chartReady`、`failed`→`loadFailed`,emit `loadStarted(dsId, Chart)`。 +- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。 +- **移除 `busy_` 守卫**:由 abort-and-replace 取代(新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。 +- 句柄完成/失败后清空对应 `QPointer`。控制器析构时 abort 在飞句柄。 + +### 5.4 UI 层(加载反馈) + +- 新增 `LoadingOverlay`(`QWidget`,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。 +- `DatasetDetailPanel`: + - 接 `loadStarted(dsId, Grid)` → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;`gridReady`/`loadFailed` → 隐藏。 + - 原数据初次加载(~0.8s):页面仍于 `chartReady` 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 1–4s)。 +- 像素级对齐原版 loading 留后续。 + +## 6. 数据流(时序) + +**原数据(双击数据集)** +1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。 +2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。 +3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。 +4. 两 reply 到齐 → batch.finished → repo 解析 → `ChartLoad::done(ChartParts)`。 +5. controller 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。 + - 任一错误 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy)。 + +**网格数据(首次切到网格页签)** +1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。 +2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。 +3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。 +4. 到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。 +5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort,无回灌。 + +## 7. 错误处理与边界 + +- **传输/HTTP 错误**:体现在 `ApiResponse.rawError`/`httpStatus`;repo 在解析处判定并 `failed(msg)`,控制器转 `loadFailed` → 状态栏(沿用现有 UX)。 +- **abort 竞态**:`ApiCall::abort` 先 `disconnect` 再 `abort`,确保 abort 后不 emit。`*Load` 与 `ApiBatch` abort 后 `deleteLater` 且不 emit。控制器用 `QPointer` 防悬垂。 +- **重复触发**:abort-and-replace 天然幂等(新覆旧)。 +- **控制器析构**:abort 所有在飞句柄,避免回调打到已析构对象。 +- **共享 NAM 同步/异步并存**:见 5.1,记录为可接受,全异步后消除。 +- **空/缺数据**:异常列表可能为空——repo 判定"业务成功但无数据"应走 `done`(空),非 `failed`,与现状一致。 + +## 8. 测试策略 + +- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。 + - gather-all:全部 finished 后 emit 一次、下标对齐。 + - abort:未完成子 call 均被 abort、不 emit。 + - 含错误响应仍 gather-all(不短路)。 +- **`DatasetDetailController`(离线单测,QSignalSpy)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod` 排队)。 + - `done` → 一次 `chartReady`/`gridReady`。 + - `failed` → 一次 `loadFailed`。 + - 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。 + - 非 `dd_inversion_data` → 直接 `loadFailed`。 +- **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。 +- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本;其余不动。`LocalRepo.*` 不受影响(同步接口保留)。 +- 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。 + +## 9. 迁移步骤(供 writing-plans 细化) + +1. net:`IApiCall` + `ApiCall` + `ApiClient::getAsync/postJsonAsync`(保留同步)。`parseBody` 抽为可复用。 +2. net:`ApiBatch` + 离线单测。 +3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。 +4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。 +5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。 +6. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`;`main.cpp` 装配换 `IAsyncDatasetRepository`。 +7. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻。 + +## 10. 风险 + +| 风险 | 缓解 | +|---|---| +| abort 后回调悬垂/二次释放 | `QPointer` + abort 先 disconnect + 仅 `deleteLater` | +| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;父子 QObject 归属清晰 | +| 同步/异步共用 NAM 重入 | 记录为可接受;详情 abort 隔离;全异步后消除 | +| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;保留现有解析单测 | +| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;`LocalSampleRepository` 不碰 | + +## 11. 相关文件 + +- 改:`src/net/ApiClient.{hpp,cpp}`、`src/data/api/ApiDatasetRepository.{hpp,cpp}`、`src/controller/DatasetDetailController.{hpp,cpp}`、`src/app/main.cpp`、`src/app/panels/.../DatasetDetailPanel.*`、`tests/controller/test_dataset_detail_controller.cpp`、各 `CMakeLists.txt`。 +- 新:`src/net/IApiCall.hpp`、`src/net/ApiCall.{hpp,cpp}`、`src/net/ApiBatch.{hpp,cpp}`、`src/data/api/DatasetLoads.hpp`、`src/data/api/DatasetLoadHandles.{hpp,cpp}`(ChartLoad/GridLoad)、`src/data/repo/IAsyncDatasetRepository.hpp`、`src/app/.../LoadingOverlay.{hpp,cpp}`、`tests/net/test_api_batch.cpp`。 +- 不动:`src/data/repo/IDatasetRepository.hpp`(同步,留给 LocalSampleRepository)、Nav/Auth/Project 路径。 + + From a8be6133a1beda06ff8ac3392a0b88c50afe5121 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:28:07 +0800 Subject: [PATCH 40/74] =?UTF-8?q?docs(spec):=20=E7=BA=B3=E5=85=A5=20opus?= =?UTF-8?q?=20=E6=9E=B6=E6=9E=84=E8=AF=84=E5=AE=A1=20=E2=80=94=20abort=20?= =?UTF-8?q?=E9=97=B8=E9=97=A8(aborted=5F+=E5=8F=A5=E6=9F=84=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E6=AF=94=E5=AF=B9)/fail-fast/=E9=80=80=E5=87=BA?= =?UTF-8?q?=E5=A5=91=E7=BA=A6/code=3D=3D200=E5=88=A4=E5=AE=9A/=E6=97=A7?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E5=8E=BB=E5=90=91/=E5=9B=9E=E7=81=8C?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-11-apiclient-async-design.md | 125 +++++++++++------- 1 file changed, 79 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md index 844ddc7..dd45d93 100644 --- a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -43,7 +43,8 @@ geopro 现网络栈三层全同步阻塞: | 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 | | 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 | | 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 | -| 多请求汇聚 | gather-all(等全部完成再回报,校验留 repo) | 避免 fail-fast 的部分-abort 竞态;与现有"任一失败→整体失败"语义对齐(校验在 repo 逐项判定) | +| 多请求汇聚 | **fail-fast with abort**(任一项失败立即回报并 abort 其余在飞 call) | 慢的 rows 1–4s,若 colorScale 200ms 先失败,gather-all 会让用户干等 1–4s 才看到失败(失败比成功还慢,违背"及时反馈")。fail-fast 依赖下方「abort 闸门」机制杜绝迟到回灌,原"避免部分-abort 竞态"的顾虑已被该机制消解。成功路径仍需全部到齐 | +| **abort 闸门(核心安全机制)** | 链路级 `aborted_` 标志 + 句柄身份比对 | 见 §5.1/§5.3:仅靠 disconnect 挡不住「已 emit、正在事件队列等待派发」的迟到信号;必须每层入口判 `aborted_` + 控制器比对句柄身份,才能兑现"abort 后绝不回灌"的核心承诺 | ## 4. 架构 @@ -64,6 +65,14 @@ ApiClient.getAsync(path) → ApiCall(包一个 QNetworkReply,实现 IApiCall ## 5. 组件设计 +### 5.0 核心安全不变量:abort 后绝不回灌(贯穿全链路) + +> 这是本设计成败的关键。`disconnect` 只能阻止「将来才发」的信号,**挡不住「已经 emit、已转成 `QMetaCallEvent` 在事件队列中等待派发」的迟到信号**(尤其在共享 NAM 同步嵌套 `QEventLoop` 期间,见 5.1)。因此 disconnect 仅作"尽力而为",真正的权威闸门是下面两条: + +1. **每层 `aborted_` 标志 + 入口守卫**:`ApiCall` / `ApiBatch` / `ChartLoad` / `GridLoad` 各持一个 `bool aborted_`。所有 `finished`/`done`/`failed` 槽**入口第一行** `if (aborted_) return;`。`abort()` 先置 `aborted_=true` 再断连/中断。聚合 emit 前同样判 `aborted_`。 +2. **控制器句柄身份比对**:`DatasetDetailController` 在 `done`/`failed` 槽内校验「发信号的句柄 == 当前持有的 `chartLoad_`/`gridLoad_`」(用 `sender()` 或 lambda 捕获句柄指针比对),过期句柄的迟到信号直接丢弃。与 abort-and-replace 形成纵深防御。 +3. **销毁一律 `deleteLater`**:`ApiCall`/`ApiBatch`/`*Load`/`QNetworkReply` 在 abort 或完成后**一律 `deleteLater`,禁止任何同步 `delete`**——避免「abort 一个正在自己回调栈中的对象」导致 use-after-free。 + ### 5.1 net 层 #### `IApiCall`(新增,抽象,可测试缝) @@ -81,9 +90,10 @@ signals: 存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。 #### `ApiCall`(新增,实现 `IApiCall`) -- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。 -- `abort()`:断开自身与 reply 的连接、`reply->abort()`、`reply->deleteLater()`、`this->deleteLater()`;**保证 abort 后不再 emit `finished`**(杜绝旧结果回灌)。 -- 一处微妙:abort 与 reply 即将 finished 的竞态——先 `disconnect` 再 `abort`,确保 finished 不触达本对象的 emit。 +- 持 `bool aborted_=false`(见 §5.0)。 +- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 槽入口判 `if (aborted_) return;` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。 +- `abort()`:`aborted_=true` → `disconnect` 自身与 reply 的连接 → `reply->abort()` → `reply->deleteLater()` → `this->deleteLater()`;**禁止同步 delete**。`reply->abort()` 本身会再触发一次 `finished`(OperationCanceledError),已被 disconnect + `aborted_` 双重挡掉。 +- 跨 `QueuedConnection` 传 `ApiResponse` 需 `qRegisterMetaType()`(应用启动时注册一次)。 #### `ApiClient`(扩展,不破坏现有) ```cpp @@ -92,23 +102,28 @@ net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); // 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用 ``` - `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。 -- **已知交互(记录为可接受)**:Nav 的同步嵌套 `QEventLoop` 期间,详情的异步 reply 若完成会被一并泵出、其槽重入执行——无害(详情切换会 abort,且互不写同一状态)。全异步后该同步路径消失。 +- **已知限制(非"无害",受 §5.0 三条约束兜底)**:Nav 的同步嵌套 `QEventLoop`(`ApiClient::get` 的 `loop.exec()`)期间,详情的异步 reply 若完成会被一并泵出,其整条槽链(ApiCall→ApiBatch→*Load→Controller→`chartReady`→`detailPanel->openOrUpdate`)会**在 Nav 同步调用栈、嵌套循环里重入执行**,即详情 UI 重建可能发生在 Nav 网络调用返回前。这不是"互不写状态"能涵盖的,但有 §5.0 兜底:(a) 一律 `deleteLater` 杜绝栈内同步释放;(b) 各层 `aborted_` 入口守卫;(c) 控制器句柄身份比对。本期 Nav 仍同步、窗口期有限;**全异步后该同步路径与本限制一并消失**。可选缓解(YAGNI,暂不做):详情完成回调改 `QueuedConnection` 投递,避免在嵌套栈内驱动 UI。 -#### `ApiBatch`(新增,汇聚原语) +#### `ApiBatch`(新增,汇聚原语,fail-fast) ```cpp // src/net/ApiBatch.hpp class ApiBatch : public QObject { Q_OBJECT public: - explicit ApiBatch(QList calls, QObject* parent=nullptr); // 接管 calls - void abort(); // abort 全部子 call,self deleteLater,不 emit + // calls 接管所有权;predicate 由 repo 注入,判定单个 ApiResponse 是否业务失败 + ApiBatch(QList calls, + std::function isFailure, + QObject* parent=nullptr); + void abort(); // aborted_=true,abort 全部未完成子 call,self deleteLater,不 emit signals: - void finished(const QList& responses); // 按 calls 下标对齐 + void succeeded(const QList& responses); // 全部成功,按下标对齐 + void failed(int index, const geopro::net::ApiResponse& resp); // 首个失败项 }; ``` -- gather-all:记录每个 call 的 `finished` 到 `responses_[i]`,计数到齐后 emit `finished` 并 `deleteLater`。 -- `abort()`:对每个未完成子 call 调 `abort()`,自删,不 emit。 -- 不做成功/失败判定(留 repo):传输/HTTP 错误体现在对应 `ApiResponse.rawError`/`httpStatus`。 +- **所有权契约**:内部用 `QList>` 持有子 call;子 call 正常 `finished` 后自删(`QPointer` 自动置空)。`abort()` 遍历时跳过空指针。子 call parent 不设为 batch(各自 `deleteLater` 自管),靠 `QPointer` + `aborted_` 防悬垂。 +- 持 `bool aborted_`(§5.0)。每个子 call `finished` 槽入口判 `aborted_`。 +- **fail-fast**:每个子 call `finished` 到达时立即用 `isFailure(resp)` 判定——失败则 `emit failed(i, resp)` + `aborted_=true` + abort 其余在飞子 call + `deleteLater`;成功则记入 `responses_[i]`,全部到齐 → `emit succeeded(responses)` + `deleteLater`。 +- 业务/传输/HTTP 三类失败统一由 `isFailure` 判定(见 §7)。 ### 5.2 data 层 @@ -124,16 +139,27 @@ struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vecto class ChartLoad : public QObject { Q_OBJECT public: - void abort(); // 转发给内部 ApiBatch + void abort(); // aborted_=true,转发给内部 ApiBatch signals: void done(const geopro::data::ChartParts& parts); void failed(const QString& message); }; class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ }; ``` -- 由 repo 创建并持有内部 `ApiBatch`;batch 完成时逐项校验+解析→ emit `done`/`failed`→ self `deleteLater`。 +- 持 `bool aborted_`(§5.0);连 `ApiBatch::succeeded` 槽入口判 `aborted_` 后解析→ emit `done`→ `deleteLater`;连 `ApiBatch::failed` → 取 `msg`(见 §7)→ emit `failed`→ `deleteLater`。`abort()` 置 `aborted_` 并转发给内部 `QPointer`。 - 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。 +#### 旧同步方法去向(接口改形审计) +`ApiDatasetRepository` 不再继承 `IDatasetRepository`。旧 6 个同步方法去向: + +| 旧方法 | 去向 | +|---|---| +| `loadStructure()` | **丢弃**(详情路径不用;现实现即返回 `{}` 占位;结构树走 `LocalSampleRepository`/Nav) | +| `loadScatter` + `loadScatterColorScale` | 合并进 `loadChartAsync` | +| `loadGrid` + `loadColorScale` + `loadAnomalies` | 合并进 `loadGridAsync` | + +`LocalSampleRepository` **继续实现同步 `IDatasetRepository`**,二接口并存;`buildWorkbench` 的 `repo` 形参类型不变。已核对:无任何处把 `ApiDatasetRepository` 当 `IDatasetRepository*` 用于 `loadStructure`,丢弃安全。 + #### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它) ```cpp // src/data/repo/IAsyncDatasetRepository.hpp @@ -148,9 +174,10 @@ public: #### `ApiDatasetRepository`(改造) - 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。 -- `loadChartAsync`:`ApiBatch{ getScatter, getScatterScale }` → 完成后复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts`;任一项错误 → `failed`。 -- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 解析填 `GridParts`;同上。 -- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。 +- `loadChartAsync`:建 `ApiBatch{ getScatter, getScatterScale }`,注入 `isFailure`(见 §7)→ `succeeded` 时复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts` 并 `done`;`failed` → 取错误原因并 `failed`。 +- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 同构填 `GridParts`。 +- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)与 `must()` 的判定(`code==200`)**原样搬入**,不重写——`must()` 的判定提炼为 batch 的 `isFailure` 谓词。 +- 空数据非失败:异常列表可能为空——业务 `code==200` 且 data 为空走成功(空 `GridParts.anomalies`),与现状一致。 ### 5.3 controller 层 @@ -165,10 +192,13 @@ private: QPointer chartLoad_; // 当前在飞(QPointer 防悬垂) QPointer gridLoad_; ``` -- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 abort**,`repo_.loadChartAsync` 取新句柄,连 `done`→组 `ChartData`→`chartReady`、`failed`→`loadFailed`,emit `loadStarted(dsId, Chart)`。 +- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 `abort()`**,`repo_.loadChartAsync` 取新句柄存入 `chartLoad_`,emit `loadStarted(dsId, Chart)`,连: + - `done` → **句柄身份比对**(捕获的句柄指针 == 当前 `chartLoad_`?否则丢弃迟到信号,见 §5.0)→ 组 `ChartData` → `chartReady` → 清空 `chartLoad_`。 + - `failed` → 同样身份比对 → `loadFailed` → 清空 `chartLoad_`。 - `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。 -- **移除 `busy_` 守卫**:由 abort-and-replace 取代(新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。 -- 句柄完成/失败后清空对应 `QPointer`。控制器析构时 abort 在飞句柄。 +- **移除 `busy_` 守卫**:由 abort-and-replace + 句柄身份比对取代(新请求来→abort 旧→发新;旧句柄的迟到信号被身份比对丢弃)。 +- 句柄完成/失败后清空对应 `QPointer`。**控制器析构时 abort 所有在飞句柄**(见 §7 退出契约)。 +- 现有 `catch(...)` 兜底不再需要(无同步抛出路径);错误统一经 `failed` 信号传递。 ### 5.4 UI 层(加载反馈) @@ -184,39 +214,38 @@ private: 1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。 2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。 3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。 -4. 两 reply 到齐 → batch.finished → repo 解析 → `ChartLoad::done(ChartParts)`。 -5. controller 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。 - - 任一错误 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy)。 +4. 两 reply 全成功到齐 → `batch.succeeded` → repo 解析 → `ChartLoad::done(ChartParts)`。 +5. controller 身份比对通过 → 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。 + - **任一项失败(fail-fast)**:首个失败 → `batch.failed` + abort 其余在飞 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy),不等其余请求。 **网格数据(首次切到网格页签)** 1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。 2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。 3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。 -4. 到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。 -5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort,无回灌。 +4. 全成功到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。若 colorScale/exception 先失败 → fail-fast 立即 `failed` + abort rows,不干等 1–4s。 +5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort + `aborted_` 闸门 → 即便有迟到信号也被丢弃,无回灌。 ## 7. 错误处理与边界 -- **传输/HTTP 错误**:体现在 `ApiResponse.rawError`/`httpStatus`;repo 在解析处判定并 `failed(msg)`,控制器转 `loadFailed` → 状态栏(沿用现有 UX)。 -- **abort 竞态**:`ApiCall::abort` 先 `disconnect` 再 `abort`,确保 abort 后不 emit。`*Load` 与 `ApiBatch` abort 后 `deleteLater` 且不 emit。控制器用 `QPointer` 防悬垂。 -- **重复触发**:abort-and-replace 天然幂等(新覆旧)。 -- **控制器析构**:abort 所有在飞句柄,避免回调打到已析构对象。 -- **共享 NAM 同步/异步并存**:见 5.1,记录为可接受,全异步后消除。 -- **空/缺数据**:异常列表可能为空——repo 判定"业务成功但无数据"应走 `done`(空),非 `failed`,与现状一致。 +- **失败判定三要素(`isFailure` 谓词,由现 `must()` 提炼)**:一个 `ApiResponse` 视为失败当:(1) 传输错误 `!rawError.isEmpty()`;或 (2) HTTP 异常(必要时查 `httpStatus`);或 (3) **业务码 `code != 200`**(服务端常返回 HTTP 200 但 `code=500`,这是现 `must()` 的判定口径,**不能只看 httpStatus**)。失败原因取 `msg`(空则 `rawError`)。 +- **abort 竞态(核心,见 §5.0)**:单靠 disconnect 不足以挡「已入队的迟到信号」。权威闸门 = 各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`。`ApiCall::abort` 置 `aborted_` 后 disconnect+`reply->abort()`。 +- **重复触发**:abort-and-replace + 身份比对天然幂等(新覆旧,旧迟到信号丢弃)。 +- **控制器析构(退出契约)**:abort 所有在飞句柄。`ApiCall`/`ApiBatch`/`*Load` **不得在析构或 `deleteLater` 回调里访问 `nam`/`reply`**(reply 在 abort 时已 `deleteLater`)。main.cpp 栈对象析构逆序为 `detailCtrl` → `datasetRepo` → `api`,确保控制器先 abort、ApiClient 最后毁;句柄不持有对 `nam` 的长期引用。 +- **共享 NAM 同步/异步并存**:见 §5.1,**已知限制**(非"无害"),受 §5.0 三约束兜底,全异步后消除。 +- **空/缺数据**:异常列表可能为空——业务 `code==200` 走 `done`(空),非 `failed`,与现状一致。 ## 8. 测试策略 - **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。 - - gather-all:全部 finished 后 emit 一次、下标对齐。 - - abort:未完成子 call 均被 abort、不 emit。 - - 含错误响应仍 gather-all(不短路)。 -- **`DatasetDetailController`(离线单测,QSignalSpy)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod` 排队)。 - - `done` → 一次 `chartReady`/`gridReady`。 - - `failed` → 一次 `loadFailed`。 + - 全成功 → `succeeded` 一次、下标对齐。 + - **fail-fast**:某子 call 先返回失败 → `failed(i,resp)` 一次 + 其余在飞子 call 被 `abort`(且不再等待)。 + - **abort 闸门**:batch `abort()` 后,未完成子 call 均被 abort;之后即便假 call 延迟 emit `finished`,batch 也不 emit(`aborted_` 守卫)。 +- **`DatasetDetailController`(离线单测,QSignalSpy + 事件循环)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod(..., QueuedConnection)` 模拟迟到)。异步桩非即时返回,断言需 `spy.wait()` / `QTest::qWait` spin 事件循环。 + - `done` → 一次 `chartReady`/`gridReady`;`failed` → 一次 `loadFailed`;非 `dd_inversion_data` → 直接 `loadFailed`。 - 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。 - - 非 `dd_inversion_data` → 直接 `loadFailed`。 + - **回灌防护(回归原 bug 的核心用例)**:① 句柄 abort 后延迟 emit `done` → 控制器**零** `chartReady`(身份比对 + aborted_ 生效);② `openDataset(A)` 在飞 → `openDataset(B)` → A 句柄迟到 emit `done` → **仅 B 的数据被 `chartReady`,A 丢弃**。 - **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。 -- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本;其余不动。`LocalRepo.*` 不受影响(同步接口保留)。 +- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本(加事件循环 spin);其余不动。`LocalRepo.*` 不受影响(同步接口保留)。 - 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。 ## 9. 迁移步骤(供 writing-plans 细化) @@ -226,18 +255,22 @@ private: 3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。 4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。 5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。 -6. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`;`main.cpp` 装配换 `IAsyncDatasetRepository`。 -7. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻。 +6. `main.cpp` 装配换 `IAsyncDatasetRepository` + 启动注册 `qRegisterMetaType()`。 +7. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`。**此步与异步内核无依赖,可在步骤 1–6 通过测试后再做/并行**,以减小试点 PR 体积(surgical)。 +8. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻、失败 fail-fast 即时报错。 ## 10. 风险 | 风险 | 缓解 | |---|---| -| abort 后回调悬垂/二次释放 | `QPointer` + abort 先 disconnect + 仅 `deleteLater` | -| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;父子 QObject 归属清晰 | -| 同步/异步共用 NAM 重入 | 记录为可接受;详情 abort 隔离;全异步后消除 | -| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;保留现有解析单测 | -| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;`LocalSampleRepository` 不碰 | +| **已入队的迟到信号回灌(最危险)** | §5.0 闸门:各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`;§8 专项回归用例 | +| abort 后回调悬垂/二次释放 | `QPointer` + abort 先置 `aborted_`+disconnect + **禁止同步 delete,仅 `deleteLater`** | +| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;`ApiBatch` 用 `QPointer` 持子 call | +| 同步/异步共用 NAM 嵌套重入 | **已知限制**(非无害);§5.0 三约束兜底;全异步后消除 | +| 退出期 UAF(句柄 deleteLater 晚于 ApiClient 析构) | 退出契约:句柄不在析构/回调访问 nam/reply;栈析构逆序保证 ctrl→repo→api | +| 失败比成功慢(gather-all 缺陷) | 改 fail-fast + abort 其余在飞 call | +| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;`must()`→`isFailure` 谓词;保留现有解析单测 | +| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;§5.2 旧方法去向表;`LocalSampleRepository` 不碰 | ## 11. 相关文件 From 66541b5ef87fa3138ff24bb2be38a411de72a04e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:37:29 +0800 Subject: [PATCH 41/74] =?UTF-8?q?docs(plan):=20ApiClient=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=8C=96(DatasetDetail=20=E8=B7=AF=E5=BE=84)=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AE=A1=E5=88=92=20=E2=80=94=208=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20TDD/bite-sized=EF=BC=8C=E5=90=AB=20net=E5=8E=9F?= =?UTF-8?q?=E8=AF=AD/data=E5=8F=A5=E6=9F=84/=E6=8E=A7=E5=88=B6=E5=99=A8/UI?= =?UTF-8?q?=E9=81=AE=E7=BD=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-11-apiclient-async-datasetdetail.md | 1363 +++++++++++++++++ 1 file changed, 1363 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md diff --git a/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md b/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md new file mode 100644 index 0000000..88db486 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md @@ -0,0 +1,1363 @@ +# ApiClient 异步化(DatasetDetail 路径)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把数据集详情路径(原数据 + 网格数据)从同步阻塞改为 Qt 原生异步,UI 全程不冻、慢请求可 abort、失败 fail-fast,作为全 App 异步化的模式样板。 + +**Architecture:** `ApiClient` 新增 `getAsync/postJsonAsync` 返回自管理 QObject 句柄 `ApiCall`(实现可测的 `IApiCall`);`ApiBatch` 并发汇聚多请求并 fail-fast;data 层 `ApiDatasetRepository` 用 batch 组装 `ChartLoad`/`GridLoad` 句柄(抽象基类 + Api 实现,供 stub 测试);`DatasetDetailController` 改 abort-and-replace + 句柄身份比对,对外信号面不变。核心安全靠「各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater」杜绝迟到信号回灌。 + +**Tech Stack:** Qt6 (Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy。 + +**权威设计:** `docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(含 §5.0 安全不变量、§7 错误判定、§8 测试策略)。 + +**构建/测试命令:** +- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` + - 若报 `LNK1104 ... geopro_desktop.exe`:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。 +- 全量测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(基线 75/75 绿) +- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*` + +--- + +## 文件结构 + +**新建:** +- `src/net/IApiCall.hpp` — 抽象 QObject 缝:`finished(ApiResponse)` 信号 + 纯虚 `abort()`。 +- `src/net/ApiCall.hpp` / `src/net/ApiCall.cpp` — 包一个 `QNetworkReply` 的 `IApiCall` 实现。 +- `src/net/ApiResponseParse.hpp` / `src/net/ApiResponseParse.cpp` — `buildResponse(QNetworkReply*)`(从现 `Impl::await` 抽出解析部分,sync/async 共用,DRY)。 +- `src/net/ApiBatch.hpp` / `src/net/ApiBatch.cpp` — 并发汇聚 + fail-fast 原语。 +- `src/data/api/DatasetLoads.hpp` — `ChartParts` / `GridParts` 结果载体(纯结构体)。 +- `src/data/api/DatasetLoadHandles.hpp` / `src/data/api/DatasetLoadHandles.cpp` — 抽象 `ChartLoad`/`GridLoad`(QObject 基类)+ 具体 `ApiChartLoad`/`ApiGridLoad`(包 ApiBatch + 解析)。 +- `src/data/repo/IAsyncDatasetRepository.hpp` — 详情异步仓储抽象(返回 `ChartLoad*`/`GridLoad*`)。 +- `tests/net/FakeApiCall.hpp` — 测试假 call(**不声明 Q_OBJECT**,发射继承的 `finished`、记录 `abort`)。 +- `tests/net/test_api_batch.cpp` — ApiBatch 离线单测。 +- `tests/data/test_dataset_load_handles.cpp` — ChartLoad/GridLoad 句柄离线单测。 +- `src/app/panels/LoadingOverlay.hpp` / `src/app/panels/LoadingOverlay.cpp` — 轻量「加载中」遮罩(Task 8,可后置)。 + +**修改:** +- `src/net/ApiClient.hpp` / `.cpp` — 加 `getAsync/postJsonAsync`;`await` 改用 `buildResponse`;`ApiResponse` 加 `Q_DECLARE_METATYPE`。 +- `src/net/CMakeLists.txt` — AUTOMOC ON;加 ApiCall/ApiBatch/ApiResponseParse 源。 +- `src/data/api/ApiDatasetRepository.hpp` / `.cpp` — 改实现 `IAsyncDatasetRepository`。 +- `src/data/CMakeLists.txt` — AUTOMOC ON;加 DatasetLoadHandles 源。 +- `src/controller/DatasetDetailController.hpp` / `.cpp` — 依赖 `IAsyncDatasetRepository`、abort-and-replace、`loadStarted` 信号。 +- `tests/controller/test_dataset_detail_controller.cpp` — 改异步 stub + 回灌防护用例。 +- `tests/CMakeLists.txt` — 加 test_api_batch / test_dataset_load_handles。 +- `src/app/main.cpp` — `qRegisterMetaType()`;接 `loadStarted`(Task 8)。 +- `src/app/CMakeLists.txt` — 加 LoadingOverlay(Task 8)。 + +**不动:** `src/data/repo/IDatasetRepository.hpp`(同步,留 LocalSampleRepository)、Nav/Auth/Project 路径、`src/net/AuthService.*`。 + +--- + +## Task 1: net — 抽出 `buildResponse`(纯重构,行为不变) + +把现 `ApiClient::Impl::await` 里「读状态 + 读体 + 解析」的部分抽成自由函数,供同步 `await` 与异步 `ApiCall` 复用(DRY)。 + +**Files:** +- Create: `src/net/ApiResponseParse.hpp`, `src/net/ApiResponseParse.cpp` +- Modify: `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt` + +- [ ] **Step 1: 写 `ApiResponseParse.hpp`** + +```cpp +#pragma once +#include "ApiClient.hpp" // for geopro::net::ApiResponse + +class QNetworkReply; + +namespace geopro::net { + +// 从一个【已完成】的 reply 读取状态码/响应体/错误并解析为 ApiResponse。 +// 不等待、不删除 reply(调用方负责生命周期)。供同步 await 与异步 ApiCall 共用。 +ApiResponse buildResponse(QNetworkReply* reply); + +} // namespace geopro::net +``` + +- [ ] **Step 2: 写 `ApiResponseParse.cpp`**(把现 ApiClient.cpp 的 parseBody + await 后半段搬来) + +```cpp +#include "ApiResponseParse.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::net { + +namespace { +constexpr int kHttpStatusUnset = 0; + +void parseBody(const QByteArray& body, ApiResponse& resp) { + if (body.isEmpty()) { + resp.rawError = QStringLiteral("empty response body"); + return; + } + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) { + resp.rawError = QStringLiteral("JSON parse error: %1; body: %2") + .arg(perr.errorString(), QString::fromUtf8(body)); + return; + } + const QJsonObject obj = doc.object(); + resp.code = obj.value(QStringLiteral("code")).toInt(); + resp.msg = obj.value(QStringLiteral("msg")).toString(); + const QJsonValue dataVal = obj.value(QStringLiteral("data")); + if (dataVal.isObject()) { + resp.data = dataVal.toObject(); + } else { + resp.data = QJsonObject{{QStringLiteral("value"), dataVal}}; + } +} +} // namespace + +ApiResponse buildResponse(QNetworkReply* reply) { + ApiResponse resp; + const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset; + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + resp.rawError = reply->errorString(); + } + if (!body.isEmpty()) { + parseBody(body, resp); + } + return resp; +} + +} // namespace geopro::net +``` + +- [ ] **Step 3: 改 `ApiClient.cpp` 用 buildResponse** + +删除 ApiClient.cpp 顶部 `parseBody` 匿名函数(已搬到 ApiResponseParse.cpp)与 `kHttpStatusUnset`、相关未用 include(`QJsonDocument/QJsonParseError/QJsonValue`)。在文件顶部 include 改为加 `#include "ApiResponseParse.hpp"`。把 `Impl::await` 改为: + +```cpp + static ApiResponse await(QNetworkReply* reply) { + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + return buildResponse(reply); + } +``` + +保留 `kContentTypeJson` / `kTokenHeader` / `buildRequest` 不动。 + +- [ ] **Step 4: 注册到 CMake** + +`src/net/CMakeLists.txt` 的 `add_library(geopro_net STATIC ...)` 列表加一行 `ApiResponseParse.cpp`: + +```cmake +add_library(geopro_net STATIC + crypto/RsaEncryptor.cpp + ApiClient.cpp + ApiResponseParse.cpp + AuthService.cpp) +``` + +- [ ] **Step 5: 构建 + 跑现有测试(回归安全网)** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 75/75 PASS(`AuthLiveTest.FullLoginFlowReturnsToken` 走 sync 路径会经过 buildResponse,验证重构无回归)。 + +- [ ] **Step 6: Commit** + +```bash +git add src/net/ApiResponseParse.hpp src/net/ApiResponseParse.cpp src/net/ApiClient.cpp src/net/CMakeLists.txt +git commit -m "refactor(net): 抽出 buildResponse,sync/async 共用响应解析(DRY,行为不变)" +``` + +--- + +## Task 2: net — `IApiCall` + `ApiCall` + AUTOMOC + getAsync/postJsonAsync + +**Files:** +- Create: `src/net/IApiCall.hpp`, `src/net/ApiCall.hpp`, `src/net/ApiCall.cpp` +- Modify: `src/net/ApiClient.hpp`, `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt` + +- [ ] **Step 1: 写 `IApiCall.hpp`** + +```cpp +#pragma once +#include +#include "ApiClient.hpp" // geopro::net::ApiResponse + +namespace geopro::net { + +// 单个异步请求的抽象句柄(可测试缝)。ApiBatch 仅依赖此抽象,单测可注入假 call。 +class IApiCall : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~IApiCall() override = default; + virtual void abort() = 0; // 置 aborted_、中断、deleteLater;abort 后绝不 emit finished +signals: + void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError) +}; + +} // namespace geopro::net +``` + +- [ ] **Step 2: 写 `ApiCall.hpp`** + +```cpp +#pragma once +#include +#include "IApiCall.hpp" + +class QNetworkReply; + +namespace geopro::net { + +// 包一个 QNetworkReply 的 IApiCall 实现。reply 完成 → buildResponse → emit finished → 自删。 +// 安全不变量(spec §5.0):abort 后绝不 emit;销毁一律 deleteLater,禁止同步 delete。 +class ApiCall : public IApiCall { + Q_OBJECT +public: + explicit ApiCall(QNetworkReply* reply, QObject* parent = nullptr); // 接管 reply + void abort() override; +private: + void onFinished(); + QPointer reply_; + bool aborted_ = false; +}; + +} // namespace geopro::net +``` + +- [ ] **Step 3: 写 `ApiCall.cpp`** + +```cpp +#include "ApiCall.hpp" + +#include +#include "ApiResponseParse.hpp" + +namespace geopro::net { + +ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) { + QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished); +} + +void ApiCall::onFinished() { + if (aborted_) return; // §5.0 入口守卫:迟到信号闸门 + ApiResponse resp = buildResponse(reply_); + if (reply_) reply_->deleteLater(); + emit finished(resp); + deleteLater(); +} + +void ApiCall::abort() { + if (aborted_) return; + aborted_ = true; // 先置标志(权威闸门) + if (reply_) { + QObject::disconnect(reply_, nullptr, this, nullptr); // 尽力而为:断开未派发连接 + reply_->abort(); // 会再触发一次 finished,已被 disconnect+aborted_ 双挡 + reply_->deleteLater(); + } + deleteLater(); // 禁止同步 delete +} + +} // namespace geopro::net +``` + +- [ ] **Step 4: 改 `ApiClient.hpp`** — 加异步方法 + 元类型声明 + +在 `ApiResponse` 结构体定义之后(`};` 之下、`class ApiClient` 之上)插入: + +```cpp +} // namespace geopro::net ←★ 不要重复;见下方完整片段 +``` + +具体改法:①在 `class ApiClient` 内 `postJson` 声明后加两行;②文件末尾 `} // namespace geopro::net` 之后加 `Q_DECLARE_METATYPE`。 + +`class ApiClient` 内新增: +```cpp + // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 + net::IApiCall* getAsync(const QString& path); + net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); +``` +(注意:此处在 `namespace geopro::net` 内,写 `IApiCall*` 即可,不必加 `net::` 前缀——按现文件风格用 `IApiCall*`。) + +前置声明区加 `class IApiCall;`(与现有 `class QNetworkAccessManager;` 并列)。 + +文件最末(`} // namespace geopro::net` 之后)加: +```cpp +#include +Q_DECLARE_METATYPE(geopro::net::ApiResponse) +``` + +- [ ] **Step 5: 改 `ApiClient.cpp`** — 实现异步方法 + +顶部加 `#include "ApiCall.hpp"`。在 `postJson` 实现之后加: + +```cpp +IApiCall* ApiClient::getAsync(const QString& path) { + QNetworkRequest req = impl_->buildRequest(path); + QNetworkReply* reply = impl_->nam.get(req); + return new ApiCall(reply); +} + +IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body) { + QNetworkRequest req = impl_->buildRequest(path); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + QNetworkReply* reply = impl_->nam.post(req, payload); + return new ApiCall(reply); +} +``` +(`QJsonDocument` 已被 ApiClient.cpp 使用,保留其 include。) + +- [ ] **Step 6: 改 `src/net/CMakeLists.txt`** — AUTOMOC ON + 加源 + +```cmake +add_library(geopro_net STATIC + crypto/RsaEncryptor.cpp + ApiClient.cpp + ApiResponseParse.cpp + ApiCall.cpp + AuthService.cpp) +``` +并把末行改为: +```cmake +set_target_properties(geopro_net PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) +``` +(AUTOMOC ON 仅对含 Q_OBJECT 的新文件生效,现有文件无 Q_OBJECT,无副作用。`IApiCall.hpp`/`ApiCall.hpp` 经 `ApiCall.cpp` 的 `#include` 被 AUTOMOC 扫描到。) + +- [ ] **Step 7: 构建(无新测试,验证编译/链接/moc)** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 链接通过(moc 为 IApiCall/ApiCall 生成成功)。 +> ApiCall 直接包 QNetworkReply,离线无法稳定单测,由 Task 3(ApiBatch + FakeApiCall)与 Task 6(控制器集成)+ Task 8 手动验证覆盖。 + +- [ ] **Step 8: Commit** + +```bash +git add src/net/IApiCall.hpp src/net/ApiCall.hpp src/net/ApiCall.cpp src/net/ApiClient.hpp src/net/ApiClient.cpp src/net/CMakeLists.txt +git commit -m "feat(net): ApiClient.getAsync/postJsonAsync + IApiCall/ApiCall 异步句柄(abort+aborted_ 闸门,AUTOMOC ON)" +``` + +--- + +## Task 3: net — `ApiBatch`(fail-fast 汇聚)+ 离线单测(TDD) + +**Files:** +- Create: `src/net/ApiBatch.hpp`, `src/net/ApiBatch.cpp`, `tests/net/FakeApiCall.hpp`, `tests/net/test_api_batch.cpp` +- Modify: `src/net/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写 `tests/net/FakeApiCall.hpp`**(不声明 Q_OBJECT:发射继承自 IApiCall 的 finished) + +```cpp +#pragma once +#include "IApiCall.hpp" + +namespace geopro::net::test { + +// 测试假 call。不加 Q_OBJECT —— 仅发射继承自 IApiCall 的 finished 信号、override abort。 +class FakeApiCall : public IApiCall { +public: + using IApiCall::IApiCall; + bool aborted = false; + void abort() override { aborted = true; } + void fire(const ApiResponse& r) { emit finished(r); } // 发射继承的信号(无需自身 moc) +}; + +} // namespace geopro::net::test +``` + +- [ ] **Step 2: 写失败测试 `tests/net/test_api_batch.cpp`** + +```cpp +#include +#include +#include "ApiBatch.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok(int code = 200) { ApiResponse r; r.code = code; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +// repo 注入的失败谓词:业务码 != 200 或传输错误。 +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(ApiBatch, SucceedsWhenAllOk) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + QSignalSpy failSpy(batch, &ApiBatch::failed); + a->fire(ok()); + EXPECT_EQ(okSpy.count(), 0); // 还差 b + b->fire(ok()); + EXPECT_EQ(okSpy.count(), 1); // 全到齐 + EXPECT_EQ(failSpy.count(), 0); +} + +TEST(ApiBatch, FailFastAbortsOthers) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; // 慢的(永不 fire) + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy failSpy(batch, &ApiBatch::failed); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + a->fire(bad()); // 首个失败 + EXPECT_EQ(failSpy.count(), 1); + EXPECT_EQ(okSpy.count(), 0); + EXPECT_TRUE(b->aborted); // 其余在飞被 abort +} + +TEST(ApiBatch, AbortGateSuppressesLateSignals) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + QSignalSpy failSpy(batch, &ApiBatch::failed); + batch->abort(); + EXPECT_TRUE(a->aborted); + EXPECT_TRUE(b->aborted); + a->fire(ok()); // 迟到信号 + b->fire(ok()); + EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门:不 emit + EXPECT_EQ(failSpy.count(), 0); +} +``` + +- [ ] **Step 3: 注册测试到 CMake** + +`tests/CMakeLists.txt`,在 net 段(`target_sources(geopro_tests PRIVATE net/test_auth.cpp)` 之后)加: +```cmake +target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) +``` +并确保 net 段链接含 `Qt6::Test`(用于 QSignalSpy)。把第 50 行链接行改为: +```cmake +target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) +``` +(`find_package(Qt6 COMPONENTS Test REQUIRED)` 在 controller 段已有;若 net 段在其之前编译报错,把该 find_package 上移到文件 net 段之前。) +`tests/net/` 头由 `target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)` 提供——若不存在则加: +```cmake +target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests) +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 编译失败(`ApiBatch.hpp` 不存在)。 + +- [ ] **Step 5: 写 `src/net/ApiBatch.hpp`** + +```cpp +#pragma once +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::net { + +// 并发汇聚 N 个 IApiCall:全成功 → succeeded(按下标对齐);任一失败 → fail-fast: +// failed(index,resp) + abort 其余在飞 call。安全不变量见 spec §5.0。 +class ApiBatch : public QObject { + Q_OBJECT +public: + using Predicate = std::function; + ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls + void abort(); +signals: + void succeeded(const QList& responses); + void failed(int index, const geopro::net::ApiResponse& resp); +private: + QList> calls_; + QList responses_; + Predicate isFailure_; + int remaining_ = 0; + bool aborted_ = false; +}; + +} // namespace geopro::net +``` + +- [ ] **Step 6: 写 `src/net/ApiBatch.cpp`** + +```cpp +#include "ApiBatch.hpp" + +namespace geopro::net { + +ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) + : QObject(parent), isFailure_(std::move(isFailure)) { + responses_.resize(calls.size()); + remaining_ = static_cast(calls.size()); + for (int i = 0; i < calls.size(); ++i) { + IApiCall* c = calls[i]; + calls_.append(c); + QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + aborted_ = true; + for (const auto& other : calls_) { // fail-fast:abort 其余在飞 + if (other) other->abort(); + } + emit failed(i, resp); + deleteLater(); + return; + } + responses_[i] = resp; + if (--remaining_ == 0) { + emit succeeded(responses_); + deleteLater(); + } + }); + } +} + +void ApiBatch::abort() { + if (aborted_) return; + aborted_ = true; + for (const auto& c : calls_) { + if (c) c->abort(); + } + deleteLater(); +} + +} // namespace geopro::net +``` + +- [ ] **Step 7: 加 ApiBatch.cpp 到 net 库** + +`src/net/CMakeLists.txt` 的源列表加 `ApiBatch.cpp`: +```cmake +add_library(geopro_net STATIC + crypto/RsaEncryptor.cpp + ApiClient.cpp + ApiResponseParse.cpp + ApiCall.cpp + ApiBatch.cpp + AuthService.cpp) +``` + +- [ ] **Step 8: 跑测试确认通过** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: `ApiBatch.SucceedsWhenAllOk` / `FailFastAbortsOthers` / `AbortGateSuppressesLateSignals` PASS;总数 78/78。 + +- [ ] **Step 9: Commit** + +```bash +git add src/net/ApiBatch.hpp src/net/ApiBatch.cpp src/net/CMakeLists.txt tests/net/FakeApiCall.hpp tests/net/test_api_batch.cpp tests/CMakeLists.txt +git commit -m "feat(net): ApiBatch 并发汇聚+fail-fast+abort闸门 + 离线单测" +``` + +--- + +## Task 4: data — 结果载体 + 异步接口 + 句柄(抽象基 + Api 实现)+ 句柄单测(TDD) + +**Files:** +- Create: `src/data/api/DatasetLoads.hpp`, `src/data/repo/IAsyncDatasetRepository.hpp`, `src/data/api/DatasetLoadHandles.hpp`, `src/data/api/DatasetLoadHandles.cpp`, `tests/data/test_dataset_load_handles.cpp` +- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写 `src/data/api/DatasetLoads.hpp`**(纯结构体) + +```cpp +#pragma once +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" + +namespace geopro::data { + +// 原数据加载结果:scatter + 散点色阶(type1)。 +struct ChartParts { + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; +}; +// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 +struct GridParts { + geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 + geopro::core::ColorScale gridScale; + std::vector anomalies; +}; + +} // namespace geopro::data +``` + +- [ ] **Step 2: 写 `src/data/api/DatasetLoadHandles.hpp`**(抽象基 + Api 实现) + +```cpp +#pragma once +#include +#include +#include +#include +#include +#include "ApiBatch.hpp" +#include "DatasetLoads.hpp" + +namespace geopro::data { + +// ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ── +class ChartLoad : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~ChartLoad() override = default; + virtual void abort() = 0; +signals: + void done(const geopro::data::ChartParts& parts); + void failed(const QString& message); +}; + +class GridLoad : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~GridLoad() override = default; + virtual void abort() = 0; +signals: + void done(const geopro::data::GridParts& parts); + void failed(const QString& message); +}; + +// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ── +class ApiChartLoad : public ChartLoad { + Q_OBJECT +public: + using Parser = std::function&)>; + ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + void abort() override; +private: + QPointer batch_; + Parser parse_; + bool aborted_ = false; +}; + +class ApiGridLoad : public GridLoad { + Q_OBJECT +public: + using Parser = std::function&)>; + ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + void abort() override; +private: + QPointer batch_; + Parser parse_; + bool aborted_ = false; +}; + +} // namespace geopro::data +``` + +- [ ] **Step 3: 写 `src/data/api/DatasetLoadHandles.cpp`** + +```cpp +#include "api/DatasetLoadHandles.hpp" +#include + +namespace geopro::data { + +namespace { +QString reasonOf(const geopro::net::ApiResponse& r) { + return r.msg.isEmpty() ? r.rawError : r.msg; +} +} // namespace + +ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) + : ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { + QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, + [this](const QList& resps) { + if (aborted_) return; // §5.0 + try { + emit done(parse_(resps)); + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + } + deleteLater(); + }); + QObject::connect(batch, &geopro::net::ApiBatch::failed, this, + [this](int, const geopro::net::ApiResponse& r) { + if (aborted_) return; + emit failed(reasonOf(r)); + deleteLater(); + }); +} + +void ApiChartLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (batch_) batch_->abort(); + deleteLater(); +} + +ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) + : GridLoad(parent), batch_(batch), parse_(std::move(parse)) { + QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, + [this](const QList& resps) { + if (aborted_) return; + try { + emit done(parse_(resps)); + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + } + deleteLater(); + }); + QObject::connect(batch, &geopro::net::ApiBatch::failed, this, + [this](int, const geopro::net::ApiResponse& r) { + if (aborted_) return; + emit failed(reasonOf(r)); + deleteLater(); + }); +} + +void ApiGridLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (batch_) batch_->abort(); + deleteLater(); +} + +} // namespace geopro::data +``` + +- [ ] **Step 4: 写 `src/data/repo/IAsyncDatasetRepository.hpp`** + +```cpp +#pragma once +#include + +namespace geopro::data { + +class ChartLoad; +class GridLoad; + +// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。 +class IAsyncDatasetRepository { +public: + virtual ~IAsyncDatasetRepository() = default; + virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) + virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 +}; + +} // namespace geopro::data +``` + +- [ ] **Step 5: 写失败测试 `tests/data/test_dataset_load_handles.cpp`**(用真 ApiBatch + FakeApiCall + 桩解析,验证句柄 done/failed/abort 接线) + +```cpp +#include +#include +#include "api/DatasetLoadHandles.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::data; +using geopro::net::ApiBatch; +using geopro::net::ApiResponse; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + bool parsed = false; + auto* load = new ApiChartLoad(batch, [&](const QList& resps) { + parsed = (resps.size() == 2); + return ChartParts{}; + }); + QSignalSpy doneSpy(load, &ChartLoad::done); + a->fire(ok()); + b->fire(ok()); + EXPECT_EQ(doneSpy.count(), 1); + EXPECT_TRUE(parsed); +} + +TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + auto* load = new ApiChartLoad(batch, [](const QList&) { return ChartParts{}; }); + QSignalSpy failSpy(load, &ChartLoad::failed); + a->fire(bad()); + EXPECT_EQ(failSpy.count(), 1); +} + +TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { + auto* a = new FakeApiCall; + auto* batch = new ApiBatch({a}, isFailure); + auto* load = new ApiGridLoad(batch, [](const QList&) { return GridParts{}; }); + QSignalSpy doneSpy(load, &GridLoad::done); + load->abort(); + a->fire(ok()); // 迟到 + EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 +} +``` + +- [ ] **Step 6: 注册 data 源 + 测试 + AUTOMOC** + +`src/data/CMakeLists.txt`:源列表加 `api/DatasetLoadHandles.cpp`,末行 AUTOMOC 改 ON: +```cmake +add_library(geopro_data STATIC + parse/SampleParsers.cpp + repo/LocalSampleRepository.cpp + dto/NavDto.cpp + dto/DatasetChartDto.cpp + api/ApiProjectRepository.cpp + api/ApiDatasetRepository.cpp + api/DatasetLoadHandles.cpp) +... +set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) +``` +`tests/CMakeLists.txt` data 段(`target_sources(... data/test_dataset_chart_dto.cpp)` 之后)加: +```cmake +target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) +``` +(data 测试段已 `target_link_libraries(... geopro_data)`;`geopro_data` PUBLIC 链 `geopro_net`,故 ApiBatch 头/符号可见。QSignalSpy 需 `Qt6::Test`——Task 3 已加到链接行。) + +- [ ] **Step 7: 跑测试确认通过** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 3 个 `DatasetLoadHandles.*` PASS;总 81/81。 + +- [ ] **Step 8: Commit** + +```bash +git add src/data/api/DatasetLoads.hpp src/data/api/DatasetLoadHandles.hpp src/data/api/DatasetLoadHandles.cpp src/data/repo/IAsyncDatasetRepository.hpp src/data/CMakeLists.txt tests/data/test_dataset_load_handles.cpp tests/CMakeLists.txt +git commit -m "feat(data): 异步仓储接口 + ChartLoad/GridLoad 句柄(抽象基+Api实现) + 离线单测" +``` + +--- + +## Task 5: data — `ApiDatasetRepository` 改实现 `IAsyncDatasetRepository` + +**Files:** +- Modify: `src/data/api/ApiDatasetRepository.hpp`, `src/data/api/ApiDatasetRepository.cpp` + +- [ ] **Step 1: 改 `ApiDatasetRepository.hpp`** + +```cpp +#pragma once +#include "repo/IAsyncDatasetRepository.hpp" +namespace geopro::net { class ApiClient; } +namespace geopro::data { + +// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。 +class ApiDatasetRepository : public IAsyncDatasetRepository { +public: + explicit ApiDatasetRepository(net::ApiClient& api); + ChartLoad* loadChartAsync(const std::string& dsId) override; + GridLoad* loadGridAsync(const std::string& dsId) override; +private: + net::ApiClient& api_; +}; +} // namespace geopro::data +``` + +- [ ] **Step 2: 改 `ApiDatasetRepository.cpp`**(保留 `enc`;`must` 改为复用 `isFailure` 谓词;用 getAsync/postJsonAsync + ApiBatch + ApiChartLoad/ApiGridLoad) + +```cpp +#include "api/ApiDatasetRepository.hpp" +#include +#include +#include +#include +#include "ApiClient.hpp" +#include "ApiBatch.hpp" +#include "api/DatasetLoadHandles.hpp" +#include "dto/DatasetChartDto.hpp" + +namespace geopro::data { +namespace { + +QString enc(const std::string& s) { + return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); +} + +// 失败判定(原 must() 口径):业务码 != 200 或传输错误。 +bool isFailure(const geopro::net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); +} + +QJsonObject colorBody(const std::string& dsId, int type) { + return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; +} + +} // namespace + +ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} + +ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { + // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiChartLoad(batch, [](const QList& r) { + ChartParts p; + p.scatter = dto::parseScatterGraph(r[0].data); + p.scatterScale = dto::parseColorBar(r[1].data); + return p; + }); +} + +GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { + // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), + api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiGridLoad(batch, [](const QList& r) { + GridParts p; + p.grid = dto::parseInversionGrid(r[0].data); + p.gridScale = dto::parseColorBar(r[1].data); + p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); + return p; + }); +} + +} // namespace geopro::data +``` +> 解析函数名/用法与原同步实现逐一对齐:`parseScatterGraph`/`parseColorBar`/`parseInversionGrid`/`parseDatasetAnomalies`(见原 ApiDatasetRepository.cpp)。`GridParts.grid` 由 `parseInversionGrid` 返回值覆盖(赋值即可,Grid 无默认构造但成员赋值用拷贝赋值合法)。 + +- [ ] **Step 3: 构建(真实 API 方法离线不可单测,验证编译/链接)** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 链接通过。 +> 真实端点行为由 Task 8 手动验证覆盖(与现有 ApiProjectRepository 同:无离线单测)。 + +- [ ] **Step 4: Commit** + +```bash +git add src/data/api/ApiDatasetRepository.hpp src/data/api/ApiDatasetRepository.cpp +git commit -m "feat(data): ApiDatasetRepository 改异步(loadChartAsync/loadGridAsync, ApiBatch+句柄, code==200判定)" +``` + +--- + +## Task 6: controller — `DatasetDetailController` 异步化(abort-and-replace + 身份比对)+ 改测试(TDD) + +**Files:** +- Modify: `src/controller/DatasetDetailController.hpp`, `src/controller/DatasetDetailController.cpp`, `tests/controller/test_dataset_detail_controller.cpp` + +- [ ] **Step 1: 改 `DatasetDetailController.hpp`** + +```cpp +#pragma once +#include +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" +namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } +namespace geopro::controller { + +class DatasetDetailController : public QObject { + Q_OBJECT +public: + enum class LoadPhase { Chart, Grid }; + Q_ENUM(LoadPhase) + + struct ChartData { + QString dsId, ddCode; + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; + geopro::core::Grid grid{1, 1}; + geopro::core::ColorScale gridScale; + std::vector anomalies; + }; + struct GridData { + QString dsId; + geopro::core::Grid grid{1, 1}; + geopro::core::ColorScale gridScale; + std::vector anomalies; + }; + + explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); +public slots: + void openDataset(const QString& dsId, const QString& ddCode); + void focusDataset(const QString& dsId); + void loadGridData(const QString& dsId, const QString& ddCode); +signals: + void loadStarted(const QString& dsId, LoadPhase phase); + void chartReady(const ChartData& data); + void gridReady(const GridData& data); + void focusRequested(const QString& dsId); + void loadFailed(const QString& dsId, const QString& message); +private: + data::IAsyncDatasetRepository& repo_; + QPointer chartLoad_; + QPointer gridLoad_; +}; +} // namespace geopro::controller +``` + +- [ ] **Step 2: 改 `DatasetDetailController.cpp`** + +```cpp +#include "DatasetDetailController.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" +namespace geopro::controller { + +DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) + : QObject(parent), repo_(repo) {} + +void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { + if (ddCode != QLatin1String("dd_inversion_data")) { + emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); + return; + } + if (chartLoad_) chartLoad_->abort(); // abort-and-replace + data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); + chartLoad_ = load; + emit loadStarted(dsId, LoadPhase::Chart); + QObject::connect(load, &data::ChartLoad::done, this, + [this, load, dsId, ddCode](const data::ChartParts& parts) { + if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 + chartLoad_.clear(); + ChartData d; + d.dsId = dsId; + d.ddCode = ddCode; + d.scatter = parts.scatter; + d.scatterScale = parts.scatterScale; + emit chartReady(d); + }); + QObject::connect(load, &data::ChartLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != chartLoad_) return; + chartLoad_.clear(); + emit loadFailed(dsId, msg); + }); +} + +void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { + if (ddCode != QLatin1String("dd_inversion_data")) return; + if (gridLoad_) gridLoad_->abort(); + data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); + gridLoad_ = load; + emit loadStarted(dsId, LoadPhase::Grid); + QObject::connect(load, &data::GridLoad::done, this, + [this, load, dsId](const data::GridParts& parts) { + if (load != gridLoad_) return; + gridLoad_.clear(); + GridData d; + d.dsId = dsId; + d.grid = parts.grid; + d.gridScale = parts.gridScale; + d.anomalies = parts.anomalies; + emit gridReady(d); + }); + QObject::connect(load, &data::GridLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != gridLoad_) return; + gridLoad_.clear(); + emit loadFailed(dsId, msg); + }); +} + +void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } +} // namespace geopro::controller +``` +> 析构:`chartLoad_`/`gridLoad_` 是 `QPointer`,控制器析构时若句柄仍在飞,句柄自身在完成/abort 后 deleteLater;为稳妥可在析构中 abort(可选,Qt 退出会清理)。本期不显式写析构(栈析构逆序 ctrl→repo→api 已保证安全,见 spec §7)。 + +- [ ] **Step 3: 改测试 `tests/controller/test_dataset_detail_controller.cpp`**(异步 stub + 回灌防护) + +```cpp +#include +#include +#include "DatasetDetailController.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" +using namespace geopro; + +namespace { +// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 +struct StubChartLoad : data::ChartLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::ChartParts{}); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; +struct StubGridLoad : data::GridLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::GridParts{}); } +}; +struct StubAsyncRepo : data::IAsyncDatasetRepository { + StubChartLoad* lastChart = nullptr; + StubGridLoad* lastGrid = nullptr; + data::ChartLoad* loadChartAsync(const std::string&) override { + lastChart = new StubChartLoad; return lastChart; + } + data::GridLoad* loadGridAsync(const std::string&) override { + lastGrid = new StubGridLoad; return lastGrid; + } +}; +} + +TEST(DatasetDetailController, EmitsChartReadyOnDone) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireDone(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireFailed(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_other"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 +} + +TEST(DatasetDetailController, AbortsPreviousOnReopen) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); // 替换 + EXPECT_TRUE(a->aborted); // 旧句柄被 abort +} + +TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); + StubChartLoad* b = repo.lastChart; + a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 + EXPECT_EQ(spy.count(), 0); + b->fireDone(); // 当前句柄 → 正常 + EXPECT_EQ(spy.count(), 1); +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 5 个 `DatasetDetailController.*` PASS(替换原 2 个);总 84/84。 + +- [ ] **Step 5: Commit** + +```bash +git add src/controller/DatasetDetailController.hpp src/controller/DatasetDetailController.cpp tests/controller/test_dataset_detail_controller.cpp +git commit -m "feat(controller): DatasetDetailController 异步化(abort-and-replace+句柄身份比对+loadStarted, 移除busy_/catch) + 回灌防护测试" +``` + +--- + +## Task 7: app — 装配接线 + qRegisterMetaType + +**Files:** +- Modify: `src/app/main.cpp` + +- [ ] **Step 1: 注册元类型**(在 `main()` 早期、首次发起网络前,如 `QApplication app(...)` 之后) + +在 main.cpp 顶部 include 区确保有 `#include "ApiClient.hpp"`(已有,经 ApiDatasetRepository)。在 `QApplication` 构造之后加: + +```cpp + qRegisterMetaType(); +``` + +- [ ] **Step 2: 确认装配无需改动** + +`geopro::data::ApiDatasetRepository datasetRepo(api);`(main.cpp:847)与 `DatasetDetailController detailCtrl(datasetRepo);`(:848)——`datasetRepo` 现是 `IAsyncDatasetRepository`,控制器形参已改为该类型,**引用绑定无需改动**。第 505–527 行 chartReady/gridReady/loadFailed/focusRequested/gridDataNeeded 接线**全部不变**(信号面未变)。 + +- [ ] **Step 3: 构建 + 全量测试** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 84/84 PASS。构建出 `geopro_desktop.exe`。 + +- [ ] **Step 4: 手动验证(核心收益)** + +启动 `build/release/src/app/geopro_desktop.exe`,登录 → 打开标准 ds `id=1458990939709440`: +- 双击数据集 → 原数据加载期间 **UI 不冻**(可拖窗口/切其他)。 +- 切到「网格数据」页签 → rows 1–4s 加载期间 **UI 不冻**。 +- rows 加载中**快速切换到另一数据集 / 关闭页签** → 旧请求被 abort(无旧数据回灌、无崩溃)。 +- 并发:原数据 2 请求、网格 3 请求并发发出(网络面板可见)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/app/main.cpp +git commit -m "feat(app): 装配异步详情仓储 + qRegisterMetaType" +``` + +--- + +## Task 8: UI — `LoadingOverlay` + 加载反馈(可后置/并行) + +> 与异步内核无依赖,可在 Task 1–7 全绿后再做。先读 `src/app/panels/DatasetDetailPage.{hpp,cpp}` 确认 `gridView_` 容器与布局,再放遮罩。 + +**Files:** +- Create: `src/app/panels/LoadingOverlay.hpp`, `src/app/panels/LoadingOverlay.cpp` +- Modify: `src/app/panels/DatasetDetailPage.hpp`, `src/app/panels/DatasetDetailPage.cpp`, `src/app/panels/DatasetDetailPanel.hpp`, `src/app/panels/DatasetDetailPanel.cpp`, `src/app/main.cpp`, `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写 `LoadingOverlay.hpp`** + +```cpp +#pragma once +#include +class QLabel; +namespace geopro::app { + +// 半透明「加载中…」遮罩。贴在目标视图上层,show()/hide() 切换,几何随父 resize 跟随。 +class LoadingOverlay : public QWidget { + Q_OBJECT +public: + explicit LoadingOverlay(QWidget* parent); + void showOver(); // 提到父尺寸、置顶、显示 +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize +private: + QLabel* label_; +}; + +} // namespace geopro::app +``` + +- [ ] **Step 2: 写 `LoadingOverlay.cpp`** + +```cpp +#include "panels/LoadingOverlay.hpp" +#include +#include +#include +#include + +namespace geopro::app { + +LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { + setAttribute(Qt::WA_StyledBackground, true); + setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); + label_->setText(QStringLiteral("加载中…")); + label_->setAlignment(Qt::AlignCenter); + auto* lay = new QVBoxLayout(this); + lay->addWidget(label_); + if (parent) parent->installEventFilter(this); + hide(); +} + +void LoadingOverlay::showOver() { + if (parentWidget()) setGeometry(parentWidget()->rect()); + raise(); + show(); +} + +bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) { + if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) { + setGeometry(parentWidget()->rect()); + } + return QWidget::eventFilter(obj, ev); +} + +} // namespace geopro::app +``` + +- [ ] **Step 3: DatasetDetailPage 暴露加载状态接口** + +`DatasetDetailPage.hpp` 加方法声明(`setGridData` 附近): +```cpp + void setGridLoading(bool on); // 网格视图遮罩开关 +``` +`DatasetDetailPage.cpp`:在构造里于 `gridView_` 之上建一个 `LoadingOverlay* gridOverlay_`(父为 gridView_ 的容器或 gridView_ 本身),实现: +```cpp +void DatasetDetailPage::setGridLoading(bool on) { + if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); +} +``` +(成员加 `LoadingOverlay* gridOverlay_;`,头部 `#include "panels/LoadingOverlay.hpp"` 或前置声明 + cpp include。`setGridData` 末尾调 `setGridLoading(false)`。) + +- [ ] **Step 4: DatasetDetailPanel 转发加载状态** + +`DatasetDetailPanel.hpp` 加: +```cpp + void setGridLoading(const QString& dsId, bool on); +``` +`DatasetDetailPanel.cpp` 实现:`pageFor(dsId)` 找到页 → `page->setGridLoading(on)`。 + +- [ ] **Step 5: main.cpp 接 loadStarted** + +在第 518 行 gridReady 接线附近加: +```cpp + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel, + [detailPanel](const QString& dsId, geopro::controller::DatasetDetailController::LoadPhase phase) { + if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) + detailPanel->setGridLoading(dsId, true); + }); +``` +(原数据初次加载的 busy 反馈:页面于 chartReady 创建,加载期可设 `window.setCursor(Qt::BusyCursor)`,chartReady/loadFailed 复位——可选,最小化先不做。) + +- [ ] **Step 6: 注册 LoadingOverlay 到 app CMake** + +`src/app/CMakeLists.txt` 找到 panels 源列表,加 `panels/LoadingOverlay.cpp`(geopro app 目标已 AUTOMOC ON——确认;若否则比照现有 panel 文件加法)。 + +- [ ] **Step 7: 构建 + 手动验证遮罩** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +启动 app → 切网格页签:rows 加载期间网格区显示「加载中…」遮罩、到达后消失;切走/关页遮罩随页消失。 + +- [ ] **Step 8: Commit** + +```bash +git add src/app/panels/LoadingOverlay.hpp src/app/panels/LoadingOverlay.cpp src/app/panels/DatasetDetailPage.hpp src/app/panels/DatasetDetailPage.cpp src/app/panels/DatasetDetailPanel.hpp src/app/panels/DatasetDetailPanel.cpp src/app/main.cpp src/app/CMakeLists.txt +git commit -m "feat(app): 网格懒加载「加载中」遮罩(LoadingOverlay) 接 loadStarted" +``` + +--- + +## Self-Review(spec 覆盖核对) + +- **§2 目标 1(不冻 UI)** → Task 2/5/7(getAsync 去 QEventLoop)+ Task 7 手动验证。✓ +- **§2 目标 2(abort 过期)** → Task 2 ApiCall.abort / Task 3 ApiBatch.abort / Task 6 abort-and-replace + 身份比对。✓ +- **§2 目标 3(并发)** → Task 5 ApiBatch 同时发 2/3 请求。✓ +- **§2 目标 4(加载反馈)** → Task 6 loadStarted + Task 8 LoadingOverlay。✓ +- **§2 目标 5(可复用原语)** → Task 2/3 IApiCall/ApiCall/ApiBatch。✓ +- **§2 目标 6(测试不退化、离线可测)** → Task 1/3/4/6 单测,全程 dev-test。✓ +- **§5.0 安全不变量** → 各层 `aborted_`(ApiCall/ApiBatch/ApiChartLoad/ApiGridLoad)+ 控制器身份比对 + 全 deleteLater;回归用例 Task 3 AbortGate / Task 4 GridLoadAbort / Task 6 DropsLateSignal。✓ +- **§5.1 buildResponse 复用** → Task 1。✓ +- **§7 失败判定 code==200** → Task 5 isFailure。✓ +- **§5.2 旧方法去向 / 不碰 LocalSampleRepository** → Task 5 仅改 ApiDatasetRepository,IDatasetRepository 不动。✓ +- **§5.2 fail-fast** → Task 3 FailFastAbortsOthers。✓ +- **AUTOMOC** → Task 2(net ON)/ Task 4(data ON)。✓ +- **qRegisterMetaType** → Task 7。✓ + +类型/签名一致性核对:`IApiCall::finished(ApiResponse)`、`ApiBatch::succeeded(QList)`/`failed(int,ApiResponse)`、`ChartLoad::done(ChartParts)`/`failed(QString)`、`IAsyncDatasetRepository::loadChartAsync→ChartLoad*`、控制器 `loadStarted(QString,LoadPhase)` 全程一致。 + +--- + +## Execution Handoff + +计划已保存 `docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`。两种执行方式: + +1. **Subagent-Driven(推荐)** — 每个 Task 派新 subagent 实现,任务间审查,迭代快。 +2. **Inline Execution** — 本会话内按 executing-plans 批量执行 + 检查点。 + +选哪个? + From c90ea83a0423f4d31275af27abaeb573e251d03c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:43:37 +0800 Subject: [PATCH 42/74] =?UTF-8?q?refactor(net):=20=E6=8A=BD=E5=87=BA=20bui?= =?UTF-8?q?ldResponse=EF=BC=8Csync/async=20=E5=85=B1=E7=94=A8=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E8=A7=A3=E6=9E=90=EF=BC=88DRY=EF=BC=8C=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiClient.cpp | 47 ++------------------------------ src/net/ApiResponseParse.cpp | 53 ++++++++++++++++++++++++++++++++++++ src/net/ApiResponseParse.hpp | 12 ++++++++ src/net/CMakeLists.txt | 1 + 4 files changed, 69 insertions(+), 44 deletions(-) create mode 100644 src/net/ApiResponseParse.cpp create mode 100644 src/net/ApiResponseParse.hpp diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp index 30fc5b4..c63e3ff 100644 --- a/src/net/ApiClient.cpp +++ b/src/net/ApiClient.cpp @@ -1,10 +1,9 @@ #include "ApiClient.hpp" -#include +#include "ApiResponseParse.hpp" + #include #include -#include -#include #include #include #include @@ -14,36 +13,9 @@ namespace geopro::net { namespace { -constexpr int kHttpStatusUnset = 0; const char* const kContentTypeJson = "application/json"; const char* const kTokenHeader = "geomativeauthorization"; -// 把响应体 JSON 解析进信封。解析失败或非对象时把原文/错误写入 rawError。 -void parseBody(const QByteArray& body, ApiResponse& resp) { - if (body.isEmpty()) { - resp.rawError = QStringLiteral("empty response body"); - return; - } - QJsonParseError perr{}; - const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); - if (perr.error != QJsonParseError::NoError || !doc.isObject()) { - resp.rawError = QStringLiteral("JSON parse error: %1; body: %2") - .arg(perr.errorString(), QString::fromUtf8(body)); - return; - } - const QJsonObject obj = doc.object(); - resp.code = obj.value(QStringLiteral("code")).toInt(); - resp.msg = obj.value(QStringLiteral("msg")).toString(); - const QJsonValue dataVal = obj.value(QStringLiteral("data")); - if (dataVal.isObject()) { - resp.data = dataVal.toObject(); - } else { - // data 可能是标量(如 verifyCodeCheck 返回 true)。包成 {"value": } - // 便于上层统一通过 data["value"] 取用,同时不丢信息。 - resp.data = QJsonObject{{QStringLiteral("value"), dataVal}}; - } -} - } // namespace struct ApiClient::Impl { @@ -67,20 +39,7 @@ struct ApiClient::Impl { QEventLoop loop; QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); loop.exec(); - - ApiResponse resp; - const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset; - - const QByteArray body = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - resp.rawError = reply->errorString(); - } - // 即便有传输层错误,服务端仍可能回带 JSON 体(如 4xx);尽量解析。 - if (!body.isEmpty()) { - parseBody(body, resp); - } - return resp; + return buildResponse(reply); } }; diff --git a/src/net/ApiResponseParse.cpp b/src/net/ApiResponseParse.cpp new file mode 100644 index 0000000..69aee57 --- /dev/null +++ b/src/net/ApiResponseParse.cpp @@ -0,0 +1,53 @@ +#include "ApiResponseParse.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::net { + +namespace { +constexpr int kHttpStatusUnset = 0; + +void parseBody(const QByteArray& body, ApiResponse& resp) { + if (body.isEmpty()) { + resp.rawError = QStringLiteral("empty response body"); + return; + } + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) { + resp.rawError = QStringLiteral("JSON parse error: %1; body: %2") + .arg(perr.errorString(), QString::fromUtf8(body)); + return; + } + const QJsonObject obj = doc.object(); + resp.code = obj.value(QStringLiteral("code")).toInt(); + resp.msg = obj.value(QStringLiteral("msg")).toString(); + const QJsonValue dataVal = obj.value(QStringLiteral("data")); + if (dataVal.isObject()) { + resp.data = dataVal.toObject(); + } else { + resp.data = QJsonObject{{QStringLiteral("value"), dataVal}}; + } +} +} // namespace + +ApiResponse buildResponse(QNetworkReply* reply) { + ApiResponse resp; + const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset; + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + resp.rawError = reply->errorString(); + } + if (!body.isEmpty()) { + parseBody(body, resp); + } + return resp; +} + +} // namespace geopro::net diff --git a/src/net/ApiResponseParse.hpp b/src/net/ApiResponseParse.hpp new file mode 100644 index 0000000..19204a8 --- /dev/null +++ b/src/net/ApiResponseParse.hpp @@ -0,0 +1,12 @@ +#pragma once +#include "ApiClient.hpp" // for geopro::net::ApiResponse + +class QNetworkReply; + +namespace geopro::net { + +// 从一个【已完成】的 reply 读取状态码/响应体/错误并解析为 ApiResponse。 +// 不等待、不删除 reply(调用方负责生命周期)。供同步 await 与异步 ApiCall 共用。 +ApiResponse buildResponse(QNetworkReply* reply); + +} // namespace geopro::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index 4741214..99d533b 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -3,6 +3,7 @@ find_package(Qt6 COMPONENTS Core Network REQUIRED) add_library(geopro_net STATIC crypto/RsaEncryptor.cpp ApiClient.cpp + ApiResponseParse.cpp AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) From 8f944433232dbaa1c427742d9e26c52c46e7715a Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:51:48 +0800 Subject: [PATCH 43/74] =?UTF-8?q?feat(net):=20ApiClient.getAsync/postJsonA?= =?UTF-8?q?sync=20+=20IApiCall/ApiCall=20=E5=BC=82=E6=AD=A5=E5=8F=A5?= =?UTF-8?q?=E6=9F=84=EF=BC=88abort+aborted=5F=20=E9=97=B8=E9=97=A8?= =?UTF-8?q?=EF=BC=8CAUTOMOC=20ON=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiCall.cpp | 31 +++++++++++++++++++++++++++++++ src/net/ApiCall.hpp | 22 ++++++++++++++++++++++ src/net/ApiClient.cpp | 14 ++++++++++++++ src/net/ApiClient.hpp | 9 +++++++++ src/net/CMakeLists.txt | 4 +++- src/net/IApiCall.cpp | 6 ++++++ src/net/IApiCall.hpp | 18 ++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/net/ApiCall.cpp create mode 100644 src/net/ApiCall.hpp create mode 100644 src/net/IApiCall.cpp create mode 100644 src/net/IApiCall.hpp diff --git a/src/net/ApiCall.cpp b/src/net/ApiCall.cpp new file mode 100644 index 0000000..5ef287e --- /dev/null +++ b/src/net/ApiCall.cpp @@ -0,0 +1,31 @@ +#include "ApiCall.hpp" + +#include +#include "ApiResponseParse.hpp" + +namespace geopro::net { + +ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) { + QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished); +} + +void ApiCall::onFinished() { + if (aborted_) return; // §5.0 入口守卫:迟到信号闸门 + ApiResponse resp = buildResponse(reply_); + if (reply_) reply_->deleteLater(); + emit finished(resp); + deleteLater(); +} + +void ApiCall::abort() { + if (aborted_) return; + aborted_ = true; // 先置标志(权威闸门) + if (reply_) { + QObject::disconnect(reply_, nullptr, this, nullptr); // 尽力而为:断开未派发连接 + reply_->abort(); // 会再触发一次 finished,已被 disconnect+aborted_ 双挡 + reply_->deleteLater(); + } + deleteLater(); // 禁止同步 delete +} + +} // namespace geopro::net diff --git a/src/net/ApiCall.hpp b/src/net/ApiCall.hpp new file mode 100644 index 0000000..94cb3f6 --- /dev/null +++ b/src/net/ApiCall.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include "IApiCall.hpp" + +class QNetworkReply; + +namespace geopro::net { + +// 包一个 QNetworkReply 的 IApiCall 实现。reply 完成 → buildResponse → emit finished → 自删。 +// 安全不变量(spec §5.0):abort 后绝不 emit;销毁一律 deleteLater,禁止同步 delete。 +class ApiCall : public IApiCall { + Q_OBJECT +public: + explicit ApiCall(QNetworkReply* reply, QObject* parent = nullptr); // 接管 reply + void abort() override; +private: + void onFinished(); + QPointer reply_; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp index c63e3ff..f9e9f93 100644 --- a/src/net/ApiClient.cpp +++ b/src/net/ApiClient.cpp @@ -1,5 +1,6 @@ #include "ApiClient.hpp" +#include "ApiCall.hpp" #include "ApiResponseParse.hpp" #include @@ -66,4 +67,17 @@ ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) { return resp; } +IApiCall* ApiClient::getAsync(const QString& path) { + QNetworkRequest req = impl_->buildRequest(path); + QNetworkReply* reply = impl_->nam.get(req); + return new ApiCall(reply); +} + +IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body) { + QNetworkRequest req = impl_->buildRequest(path); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + QNetworkReply* reply = impl_->nam.post(req, payload); + return new ApiCall(reply); +} + } // namespace geopro::net diff --git a/src/net/ApiClient.hpp b/src/net/ApiClient.hpp index 77c1965..b3b65ec 100644 --- a/src/net/ApiClient.hpp +++ b/src/net/ApiClient.hpp @@ -8,6 +8,8 @@ class QNetworkAccessManager; namespace geopro::net { +class IApiCall; + // 服务端统一响应信封:{code, data, msg}。 // httpStatus 为 HTTP 状态码(如 200);code/data/msg 解析自响应体 JSON。 // 网络层错误(连接失败、超时、JSON 解析失败)写入 rawError,此时 httpStatus 可能为 0。 @@ -38,9 +40,16 @@ public: ApiResponse get(const QString& path); ApiResponse postJson(const QString& path, const QJsonObject& body); + // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 + IApiCall* getAsync(const QString& path); + IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); + private: struct Impl; std::unique_ptr impl_; }; } // namespace geopro::net + +#include +Q_DECLARE_METATYPE(geopro::net::ApiResponse) diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index 99d533b..d608c7b 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -4,8 +4,10 @@ add_library(geopro_net STATIC crypto/RsaEncryptor.cpp ApiClient.cpp ApiResponseParse.cpp + IApiCall.cpp + ApiCall.cpp AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) target_compile_features(geopro_net PUBLIC cxx_std_17) -set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) +set_target_properties(geopro_net PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) diff --git a/src/net/IApiCall.cpp b/src/net/IApiCall.cpp new file mode 100644 index 0000000..d93de1e --- /dev/null +++ b/src/net/IApiCall.cpp @@ -0,0 +1,6 @@ +#include "IApiCall.hpp" + +// IApiCall 是纯虚抽象句柄,无需额外实现。 +// 此 .cpp 存在是为了让 AUTOMOC 扫描到 Q_OBJECT 宏并生成 moc_IApiCall.cpp。 +namespace geopro::net { +} // namespace geopro::net diff --git a/src/net/IApiCall.hpp b/src/net/IApiCall.hpp new file mode 100644 index 0000000..db607c5 --- /dev/null +++ b/src/net/IApiCall.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#include "ApiClient.hpp" // geopro::net::ApiResponse + +namespace geopro::net { + +// 单个异步请求的抽象句柄(可测试缝)。ApiBatch 仅依赖此抽象,单测可注入假 call。 +class IApiCall : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~IApiCall() override = default; + virtual void abort() = 0; // 置 aborted_、中断、deleteLater;abort 后绝不 emit finished +signals: + void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError) +}; + +} // namespace geopro::net From f74d47e62e1036f2f43074671814278422181b00 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:59:23 +0800 Subject: [PATCH 44/74] =?UTF-8?q?harden(net):=20ApiCall=20onFinished=20rep?= =?UTF-8?q?ly=20=E5=BF=AB=E7=85=A7=E9=98=B2=E5=BE=A1=20+=20=E6=9E=84?= =?UTF-8?q?=E9=80=A0=20Q=5FASSERT(reply)=EF=BC=88=E8=AF=84=E5=AE=A1=20H3/L?= =?UTF-8?q?1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiCall.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/net/ApiCall.cpp b/src/net/ApiCall.cpp index 5ef287e..a23d9ed 100644 --- a/src/net/ApiCall.cpp +++ b/src/net/ApiCall.cpp @@ -6,13 +6,16 @@ namespace geopro::net { ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) { + Q_ASSERT(reply); // 契约:不接受 null reply(NAM get/post 正常不返回 null) QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished); } void ApiCall::onFinished() { if (aborted_) return; // §5.0 入口守卫:迟到信号闸门 - ApiResponse resp = buildResponse(reply_); - if (reply_) reply_->deleteLater(); + QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空 + if (!reply) return; + ApiResponse resp = buildResponse(reply); + reply->deleteLater(); emit finished(resp); deleteLater(); } From 72b300d722ab31ab107c00e06f4e4881b7c6cb1b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:05:53 +0800 Subject: [PATCH 45/74] =?UTF-8?q?feat(net):=20ApiBatch=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=B1=87=E8=81=9A+fail-fast+abort=E9=97=B8=E9=97=A8?= =?UTF-8?q?=20+=20=E7=A6=BB=E7=BA=BF=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiBatch.cpp | 41 +++++++++++++++++++++++++++ src/net/ApiBatch.hpp | 29 +++++++++++++++++++ src/net/CMakeLists.txt | 1 + tests/CMakeLists.txt | 8 ++++-- tests/net/FakeApiCall.hpp | 15 ++++++++++ tests/net/test_api_batch.cpp | 54 ++++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/net/ApiBatch.cpp create mode 100644 src/net/ApiBatch.hpp create mode 100644 tests/net/FakeApiCall.hpp create mode 100644 tests/net/test_api_batch.cpp diff --git a/src/net/ApiBatch.cpp b/src/net/ApiBatch.cpp new file mode 100644 index 0000000..65c3b18 --- /dev/null +++ b/src/net/ApiBatch.cpp @@ -0,0 +1,41 @@ +#include "ApiBatch.hpp" + +namespace geopro::net { + +ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) + : QObject(parent), isFailure_(std::move(isFailure)) { + responses_.resize(calls.size()); + remaining_ = static_cast(calls.size()); + for (int i = 0; i < calls.size(); ++i) { + IApiCall* c = calls[i]; + calls_.append(c); + QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + aborted_ = true; + for (const auto& other : calls_) { // fail-fast:abort 其余在飞 + if (other) other->abort(); + } + emit failed(i, resp); + deleteLater(); + return; + } + responses_[i] = resp; + if (--remaining_ == 0) { + emit succeeded(responses_); + deleteLater(); + } + }); + } +} + +void ApiBatch::abort() { + if (aborted_) return; + aborted_ = true; + for (const auto& c : calls_) { + if (c) c->abort(); + } + deleteLater(); +} + +} // namespace geopro::net diff --git a/src/net/ApiBatch.hpp b/src/net/ApiBatch.hpp new file mode 100644 index 0000000..5c7190c --- /dev/null +++ b/src/net/ApiBatch.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::net { + +// 并发汇聚 N 个 IApiCall:全成功 → succeeded(按下标对齐);任一失败 → fail-fast: +// failed(index,resp) + abort 其余在飞 call。安全不变量见 spec §5.0。 +class ApiBatch : public QObject { + Q_OBJECT +public: + using Predicate = std::function; + ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls + void abort(); +signals: + void succeeded(const QList& responses); + void failed(int index, const geopro::net::ApiResponse& resp); +private: + QList> calls_; + QList responses_; + Predicate isFailure_; + int remaining_ = 0; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index d608c7b..64c9341 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(geopro_net STATIC ApiResponseParse.cpp IApiCall.cpp ApiCall.cpp + ApiBatch.cpp AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f999606..c5a1e21 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,11 +43,13 @@ target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package # 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。 find_package(OpenSSL REQUIRED) -find_package(Qt6 COMPONENTS Core Network REQUIRED) +find_package(Qt6 COMPONENTS Core Network Test REQUIRED) target_sources(geopro_tests PRIVATE net/test_rsa.cpp) # 端到端登录连通测试(ApiClient + AuthService),需 Qt6::Core/Network 与事件循环。 target_sources(geopro_tests PRIVATE net/test_auth.cpp) -target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) +# ApiBatch 离线单测(QSignalSpy 需 Qt6::Test)。 +target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 # DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。 @@ -87,6 +89,8 @@ if(TARGET qwt) endif() target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) +# tests/ 在 include 路径:net/test_api_batch.cpp 用 #include "net/FakeApiCall.hpp"。 +target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) # ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标) target_sources(geopro_tests PRIVATE diff --git a/tests/net/FakeApiCall.hpp b/tests/net/FakeApiCall.hpp new file mode 100644 index 0000000..71b48a4 --- /dev/null +++ b/tests/net/FakeApiCall.hpp @@ -0,0 +1,15 @@ +#pragma once +#include "IApiCall.hpp" + +namespace geopro::net::test { + +// 测试假 call。不加 Q_OBJECT —— 仅发射继承自 IApiCall 的 finished 信号、override abort。 +class FakeApiCall : public IApiCall { +public: + using IApiCall::IApiCall; + bool aborted = false; + void abort() override { aborted = true; } + void fire(const ApiResponse& r) { emit finished(r); } // 发射继承的信号(无需自身 moc) +}; + +} // namespace geopro::net::test diff --git a/tests/net/test_api_batch.cpp b/tests/net/test_api_batch.cpp new file mode 100644 index 0000000..8f3e9ab --- /dev/null +++ b/tests/net/test_api_batch.cpp @@ -0,0 +1,54 @@ +#include +#include +#include "ApiBatch.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok(int code = 200) { ApiResponse r; r.code = code; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +// repo 注入的失败谓词:业务码 != 200 或传输错误。 +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(ApiBatch, SucceedsWhenAllOk) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + QSignalSpy failSpy(batch, &ApiBatch::failed); + a->fire(ok()); + EXPECT_EQ(okSpy.count(), 0); // 还差 b + b->fire(ok()); + EXPECT_EQ(okSpy.count(), 1); // 全到齐 + EXPECT_EQ(failSpy.count(), 0); +} + +TEST(ApiBatch, FailFastAbortsOthers) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; // 慢的(永不 fire) + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy failSpy(batch, &ApiBatch::failed); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + a->fire(bad()); // 首个失败 + EXPECT_EQ(failSpy.count(), 1); + EXPECT_EQ(okSpy.count(), 0); + EXPECT_TRUE(b->aborted); // 其余在飞被 abort +} + +TEST(ApiBatch, AbortGateSuppressesLateSignals) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + QSignalSpy failSpy(batch, &ApiBatch::failed); + batch->abort(); + EXPECT_TRUE(a->aborted); + EXPECT_TRUE(b->aborted); + a->fire(ok()); // 迟到信号 + b->fire(ok()); + EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门:不 emit + EXPECT_EQ(failSpy.count(), 0); +} From e980ddd34641731a6f242914610af4352a58a2d5 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:13:48 +0800 Subject: [PATCH 46/74] =?UTF-8?q?harden(net):=20ApiBatch=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=96=AD=E8=A8=80(=E9=9D=9E=E7=A9=BAcalls/=E9=9D=9E?= =?UTF-8?q?=E7=A9=BA=E8=B0=93=E8=AF=8D)+fail-fast=E6=B3=A8=E9=87=8A+?= =?UTF-8?q?=E5=8D=95=E5=85=83=E7=B4=A0=E6=B5=8B=E8=AF=95=EF=BC=88=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20I-1/I-2/M-1/M-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiBatch.cpp | 4 +++- tests/net/test_api_batch.cpp | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/net/ApiBatch.cpp b/src/net/ApiBatch.cpp index 65c3b18..f9d8c7f 100644 --- a/src/net/ApiBatch.cpp +++ b/src/net/ApiBatch.cpp @@ -4,6 +4,8 @@ namespace geopro::net { ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) : QObject(parent), isFailure_(std::move(isFailure)) { + Q_ASSERT(!calls.isEmpty()); // 契约:至少一个 call(空 batch 永不发 succeeded,调用方会挂起) + Q_ASSERT(isFailure_); // 契约:必须提供失败谓词(否则首个 finished 即 bad_function_call) responses_.resize(calls.size()); remaining_ = static_cast(calls.size()); for (int i = 0; i < calls.size(); ++i) { @@ -12,7 +14,7 @@ ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 if (isFailure_(resp)) { - aborted_ = true; + aborted_ = true; // 此后 remaining_ 不再维护:迟到 finished 全被入口守卫挡掉 for (const auto& other : calls_) { // fail-fast:abort 其余在飞 if (other) other->abort(); } diff --git a/tests/net/test_api_batch.cpp b/tests/net/test_api_batch.cpp index 8f3e9ab..566517c 100644 --- a/tests/net/test_api_batch.cpp +++ b/tests/net/test_api_batch.cpp @@ -26,6 +26,14 @@ TEST(ApiBatch, SucceedsWhenAllOk) { EXPECT_EQ(failSpy.count(), 0); } +TEST(ApiBatch, SucceedsWithSingleCall) { + auto* a = new FakeApiCall; + auto* batch = new ApiBatch({a}, isFailure); + QSignalSpy okSpy(batch, &ApiBatch::succeeded); + a->fire(ok()); // N=1:一次 fire 即触发 --remaining_==0 + EXPECT_EQ(okSpy.count(), 1); +} + TEST(ApiBatch, FailFastAbortsOthers) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; // 慢的(永不 fire) From bb602e20113293658ab29ce89fef95f068bc420b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:19:32 +0800 Subject: [PATCH 47/74] =?UTF-8?q?feat(data):=20=E5=BC=82=E6=AD=A5=E4=BB=93?= =?UTF-8?q?=E5=82=A8=E6=8E=A5=E5=8F=A3=20+=20ChartLoad/GridLoad=20?= =?UTF-8?q?=E5=8F=A5=E6=9F=84(=E6=8A=BD=E8=B1=A1=E5=9F=BA+Api=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0)=20+=20=E7=A6=BB=E7=BA=BF=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 5 +- src/data/api/DatasetLoadHandles.cpp | 66 +++++++++++++++++++++++ src/data/api/DatasetLoadHandles.hpp | 60 +++++++++++++++++++++ src/data/api/DatasetLoads.hpp | 21 ++++++++ src/data/repo/IAsyncDatasetRepository.hpp | 17 ++++++ tests/CMakeLists.txt | 1 + tests/data/test_dataset_load_handles.cpp | 51 ++++++++++++++++++ 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/data/api/DatasetLoadHandles.cpp create mode 100644 src/data/api/DatasetLoadHandles.hpp create mode 100644 src/data/api/DatasetLoads.hpp create mode 100644 src/data/repo/IAsyncDatasetRepository.hpp create mode 100644 tests/data/test_dataset_load_handles.cpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 16da104..9319112 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -6,8 +6,9 @@ add_library(geopro_data STATIC dto/NavDto.cpp dto/DatasetChartDto.cpp api/ApiProjectRepository.cpp - api/ApiDatasetRepository.cpp) + api/ApiDatasetRepository.cpp + api/DatasetLoadHandles.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) target_compile_features(geopro_data PUBLIC cxx_std_17) -set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) +set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) diff --git a/src/data/api/DatasetLoadHandles.cpp b/src/data/api/DatasetLoadHandles.cpp new file mode 100644 index 0000000..cd80aa8 --- /dev/null +++ b/src/data/api/DatasetLoadHandles.cpp @@ -0,0 +1,66 @@ +#include "api/DatasetLoadHandles.hpp" +#include + +namespace geopro::data { + +namespace { +QString reasonOf(const geopro::net::ApiResponse& r) { + return r.msg.isEmpty() ? r.rawError : r.msg; +} +} // namespace + +ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) + : ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { + QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, + [this](const QList& resps) { + if (aborted_) return; // §5.0 + try { + emit done(parse_(resps)); + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + } + deleteLater(); + }); + QObject::connect(batch, &geopro::net::ApiBatch::failed, this, + [this](int, const geopro::net::ApiResponse& r) { + if (aborted_) return; + emit failed(reasonOf(r)); + deleteLater(); + }); +} + +void ApiChartLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (batch_) batch_->abort(); + deleteLater(); +} + +ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) + : GridLoad(parent), batch_(batch), parse_(std::move(parse)) { + QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, + [this](const QList& resps) { + if (aborted_) return; + try { + emit done(parse_(resps)); + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + } + deleteLater(); + }); + QObject::connect(batch, &geopro::net::ApiBatch::failed, this, + [this](int, const geopro::net::ApiResponse& r) { + if (aborted_) return; + emit failed(reasonOf(r)); + deleteLater(); + }); +} + +void ApiGridLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (batch_) batch_->abort(); + deleteLater(); +} + +} // namespace geopro::data diff --git a/src/data/api/DatasetLoadHandles.hpp b/src/data/api/DatasetLoadHandles.hpp new file mode 100644 index 0000000..cc65328 --- /dev/null +++ b/src/data/api/DatasetLoadHandles.hpp @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include +#include +#include "ApiBatch.hpp" +#include "DatasetLoads.hpp" + +namespace geopro::data { + +// ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ── +class ChartLoad : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~ChartLoad() override = default; + virtual void abort() = 0; +signals: + void done(const geopro::data::ChartParts& parts); + void failed(const QString& message); +}; + +class GridLoad : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~GridLoad() override = default; + virtual void abort() = 0; +signals: + void done(const geopro::data::GridParts& parts); + void failed(const QString& message); +}; + +// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ── +class ApiChartLoad : public ChartLoad { + Q_OBJECT +public: + using Parser = std::function&)>; + ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + void abort() override; +private: + QPointer batch_; + Parser parse_; + bool aborted_ = false; +}; + +class ApiGridLoad : public GridLoad { + Q_OBJECT +public: + using Parser = std::function&)>; + ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + void abort() override; +private: + QPointer batch_; + Parser parse_; + bool aborted_ = false; +}; + +} // namespace geopro::data diff --git a/src/data/api/DatasetLoads.hpp b/src/data/api/DatasetLoads.hpp new file mode 100644 index 0000000..1e303ae --- /dev/null +++ b/src/data/api/DatasetLoads.hpp @@ -0,0 +1,21 @@ +#pragma once +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" + +namespace geopro::data { + +// 原数据加载结果:scatter + 散点色阶(type1)。 +struct ChartParts { + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; +}; +// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 +struct GridParts { + geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 + geopro::core::ColorScale gridScale; + std::vector anomalies; +}; + +} // namespace geopro::data diff --git a/src/data/repo/IAsyncDatasetRepository.hpp b/src/data/repo/IAsyncDatasetRepository.hpp new file mode 100644 index 0000000..5103b03 --- /dev/null +++ b/src/data/repo/IAsyncDatasetRepository.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +namespace geopro::data { + +class ChartLoad; +class GridLoad; + +// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。 +class IAsyncDatasetRepository { +public: + virtual ~IAsyncDatasetRepository() = default; + virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) + virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 +}; + +} // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5a1e21..98ee36e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,7 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package diff --git a/tests/data/test_dataset_load_handles.cpp b/tests/data/test_dataset_load_handles.cpp new file mode 100644 index 0000000..4dfd038 --- /dev/null +++ b/tests/data/test_dataset_load_handles.cpp @@ -0,0 +1,51 @@ +#include +#include +#include "api/DatasetLoadHandles.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::data; +using geopro::net::ApiBatch; +using geopro::net::ApiResponse; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + bool parsed = false; + auto* load = new ApiChartLoad(batch, [&](const QList& resps) { + parsed = (resps.size() == 2); + return ChartParts{}; + }); + QSignalSpy doneSpy(load, &ChartLoad::done); + a->fire(ok()); + b->fire(ok()); + EXPECT_EQ(doneSpy.count(), 1); + EXPECT_TRUE(parsed); +} + +TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { + auto* a = new FakeApiCall; + auto* b = new FakeApiCall; + auto* batch = new ApiBatch({a, b}, isFailure); + auto* load = new ApiChartLoad(batch, [](const QList&) { return ChartParts{}; }); + QSignalSpy failSpy(load, &ChartLoad::failed); + a->fire(bad()); + EXPECT_EQ(failSpy.count(), 1); +} + +TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { + auto* a = new FakeApiCall; + auto* batch = new ApiBatch({a}, isFailure); + auto* load = new ApiGridLoad(batch, [](const QList&) { return GridParts{}; }); + QSignalSpy doneSpy(load, &GridLoad::done); + load->abort(); + a->fire(ok()); // 迟到 + EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 +} From 8cdd6679a99c63006f9a5329bafa51ba9bc0bd8c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:31:07 +0800 Subject: [PATCH 48/74] =?UTF-8?q?harden(data):=20=E5=8F=A5=E6=9F=84=20emit?= =?UTF-8?q?=20done=20=E7=A7=BB=E5=87=BA=20try=20+=20catch(...)=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=20+=20parse=20=E6=8A=9B=E5=BC=82=E5=B8=B8=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88=E8=AF=84=E5=AE=A1=20I-1/M-5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/api/DatasetLoadHandles.cpp | 20 ++++++++++++++++++-- tests/data/test_dataset_load_handles.cpp | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/data/api/DatasetLoadHandles.cpp b/src/data/api/DatasetLoadHandles.cpp index cd80aa8..ba23173 100644 --- a/src/data/api/DatasetLoadHandles.cpp +++ b/src/data/api/DatasetLoadHandles.cpp @@ -14,11 +14,19 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, [this](const QList& resps) { if (aborted_) return; // §5.0 + ChartParts parts; try { - emit done(parse_(resps)); + parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); + deleteLater(); + return; + } catch (...) { // 非 std 异常跨信号槽会 terminate,兜底转 failed + emit failed(QStringLiteral("解析失败:未知异常")); + deleteLater(); + return; } + emit done(parts); deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, @@ -41,11 +49,19 @@ ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* pa QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, [this](const QList& resps) { if (aborted_) return; + GridParts parts; try { - emit done(parse_(resps)); + parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); + deleteLater(); + return; + } catch (...) { + emit failed(QStringLiteral("解析失败:未知异常")); + deleteLater(); + return; } + emit done(parts); deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, diff --git a/tests/data/test_dataset_load_handles.cpp b/tests/data/test_dataset_load_handles.cpp index 4dfd038..d0590c7 100644 --- a/tests/data/test_dataset_load_handles.cpp +++ b/tests/data/test_dataset_load_handles.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "api/DatasetLoadHandles.hpp" #include "net/FakeApiCall.hpp" @@ -40,6 +41,19 @@ TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { EXPECT_EQ(failSpy.count(), 1); } +TEST(DatasetLoadHandles, ChartLoadEmitsFailedWhenParseThrows) { + auto* a = new FakeApiCall; + auto* batch = new ApiBatch({a}, isFailure); + auto* load = new ApiChartLoad(batch, [](const QList&) -> ChartParts { + throw std::runtime_error("parse boom"); + }); + QSignalSpy doneSpy(load, &ChartLoad::done); + QSignalSpy failSpy(load, &ChartLoad::failed); + a->fire(ok()); // batch 成功 → parse 抛异常 → failed(emit done 已移出 try) + EXPECT_EQ(doneSpy.count(), 0); + EXPECT_EQ(failSpy.count(), 1); +} + TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); From e57985c0572c95b1641a159f943d2fbba7d822e1 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:37:10 +0800 Subject: [PATCH 49/74] =?UTF-8?q?feat(data+controller):=20ApiDatasetReposi?= =?UTF-8?q?tory=20=E6=94=B9=E5=BC=82=E6=AD=A5=20+=20DatasetDetailControlle?= =?UTF-8?q?r=20abort-and-replace+=E5=8F=A5=E6=9F=84=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E6=AF=94=E5=AF=B9+loadStarted=EF=BC=88=E7=A7=BB=E9=99=A4=20bus?= =?UTF-8?q?y=5F/catch=EF=BC=89+=20=E5=9B=9E=E7=81=8C=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/DatasetDetailController.cpp | 74 +++++++++--------- src/controller/DatasetDetailController.hpp | 22 +++--- src/data/api/ApiDatasetRepository.cpp | 73 +++++++++-------- src/data/api/ApiDatasetRepository.hpp | 14 ++-- .../test_dataset_detail_controller.cpp | 78 +++++++++++++++---- 5 files changed, 162 insertions(+), 99 deletions(-) diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 5a923b8..1def781 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,60 +1,62 @@ #include "DatasetDetailController.hpp" -#include -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" namespace geopro::controller { -DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) +DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { - if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) - busy_ = true; if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 - busy_ = false; emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } - const std::string id = dsId.toStdString(); - try { + if (chartLoad_) chartLoad_->abort(); // abort-and-replace + data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); + chartLoad_ = load; + emit loadStarted(dsId, LoadPhase::Chart); + QObject::connect(load, &data::ChartLoad::done, this, + [this, load, dsId, ddCode](const data::ChartParts& parts) { + if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 + chartLoad_.clear(); ChartData d; d.dsId = dsId; d.ddCode = ddCode; - // 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。 - // 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。 - d.scatter = repo_.loadScatter(id); - d.scatterScale = repo_.loadScatterColorScale(id); - busy_ = false; + d.scatter = parts.scatter; + d.scatterScale = parts.scatterScale; emit chartReady(d); - } catch (const std::exception& e) { - busy_ = false; - emit loadFailed(dsId, QString::fromStdString(e.what())); - } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 - busy_ = false; - emit loadFailed(dsId, QStringLiteral("未知错误")); - } + }); + QObject::connect(load, &data::ChartLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != chartLoad_) return; + chartLoad_.clear(); + emit loadFailed(dsId, msg); + }); } void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { - if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 - busy_ = true; - const std::string id = dsId.toStdString(); - try { + if (gridLoad_) gridLoad_->abort(); // abort-and-replace + data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); + gridLoad_ = load; + emit loadStarted(dsId, LoadPhase::Grid); + QObject::connect(load, &data::GridLoad::done, this, + [this, load, dsId](const data::GridParts& parts) { + if (load != gridLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 + gridLoad_.clear(); GridData d; d.dsId = dsId; - // 网格数据:rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。 - d.grid = repo_.loadGrid(id); - d.gridScale = repo_.loadColorScale(id); - d.anomalies = repo_.loadAnomalies(id); - busy_ = false; + d.grid = parts.grid; + d.gridScale = parts.gridScale; + d.anomalies = parts.anomalies; emit gridReady(d); - } catch (const std::exception& e) { - busy_ = false; - emit loadFailed(dsId, QString::fromStdString(e.what())); - } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 - busy_ = false; - emit loadFailed(dsId, QStringLiteral("未知错误")); - } + }); + QObject::connect(load, &data::GridLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != gridLoad_) return; + gridLoad_.clear(); + emit loadFailed(dsId, msg); + }); } void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index a870647..e55a954 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -1,18 +1,21 @@ #pragma once #include #include +#include #include #include "model/Field.hpp" #include "model/ColorScale.hpp" #include "model/Anomaly.hpp" -namespace geopro::data { class IDatasetRepository; } +namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::controller { -// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 -// 仅服务图表,不与 WorkbenchNavController(项目/结构导航)耦合。 +// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 class DatasetDetailController : public QObject { Q_OBJECT public: + enum class LoadPhase { Chart, Grid }; + Q_ENUM(LoadPhase) + struct ChartData { QString dsId, ddCode; geopro::core::ScatterField scatter; @@ -29,19 +32,20 @@ public: std::vector anomalies; }; - explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); + explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); public slots: - void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 - void focusDataset(const QString& dsId); // 单击=聚焦已开页 - // 网格数据懒加载:网格页首次激活时调用(rows 服务端网格化 1-4s,故不随 openDataset 拉)。 + void openDataset(const QString& dsId, const QString& ddCode); + void focusDataset(const QString& dsId); void loadGridData(const QString& dsId, const QString& ddCode); signals: + void loadStarted(const QString& dsId, LoadPhase phase); void chartReady(const ChartData& data); void gridReady(const GridData& data); void focusRequested(const QString& dsId); void loadFailed(const QString& dsId, const QString& message); private: - data::IDatasetRepository& repo_; - bool busy_ = false; + data::IAsyncDatasetRepository& repo_; + QPointer chartLoad_; + QPointer gridLoad_; }; } // namespace geopro::controller diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index 94ebe8a..ad9ee39 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,51 +1,62 @@ #include "api/ApiDatasetRepository.hpp" -#include #include #include #include #include "ApiClient.hpp" +#include "ApiBatch.hpp" +#include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" + namespace geopro::data { namespace { + QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); } -void must(const net::ApiResponse& r, const char* what) { - if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " + - (r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); + +// 失败判定(原 must() 口径):业务码 != 200 或传输错误。 +bool isFailure(const geopro::net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); } -geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) { - QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; - const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); - must(r, "colorGradation"); - return dto::parseColorBar(r.data); + +QJsonObject colorBody(const std::string& dsId, int type) { + return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; } + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} -geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); - must(r, "inversion/rows"); - return dto::parseInversionGrid(r.data); +ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { + // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiChartLoad(batch, [](const QList& r) { + ChartParts p; + p.scatter = dto::parseScatterGraph(r[0].data); + p.scatterScale = dto::parseColorBar(r[1].data); + return p; + }); } -geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); - must(r, "scatterGraph"); - return dto::parseScatterGraph(r.data); -} -geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { - return colorScale(api_, dsId, 2); -} -geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { - return colorScale(api_, dsId, 1); -} -std::vector ApiDatasetRepository::loadAnomalies(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); - must(r, "queryException"); - return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray()); + +GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { + // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), + api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiGridLoad(batch, [](const QList& r) { + GridParts p; + p.grid = dto::parseInversionGrid(r[0].data); + p.gridScale = dto::parseColorBar(r[1].data); + p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); + return p; + }); } + } // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index 617aaad..c341fca 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -1,18 +1,14 @@ #pragma once -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" namespace geopro::net { class ApiClient; } namespace geopro::data { -// 真实 API 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。 -class ApiDatasetRepository : public IDatasetRepository { +// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。 +class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); - std::vector loadStructure() override { return {}; } // 不经此仓储取结构 - geopro::core::Grid loadGrid(const std::string& dsId) override; - geopro::core::ScatterField loadScatter(const std::string& dsId) override; - geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格 - geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据 - std::vector loadAnomalies(const std::string& dsId) override; + ChartLoad* loadChartAsync(const std::string& dsId) override; + GridLoad* loadGridAsync(const std::string& dsId) override; private: net::ApiClient& api_; }; diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index f1b1b01..25d85ac 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -1,31 +1,81 @@ #include #include #include "DatasetDetailController.hpp" -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" using namespace geopro; + namespace { -struct StubRepo : data::IDatasetRepository { - bool fail = false; - std::vector loadStructure() override { return {}; } - core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } - // openDataset 现只拉 scatter/scatterScale/anomalies(网格懒加载),失败路径由 loadScatter 抛出触发。 - core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; } - core::ColorScale loadColorScale(const std::string&) override { return {}; } - core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } - std::vector loadAnomalies(const std::string&) override { return {}; } +// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 +struct StubChartLoad : data::ChartLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::ChartParts{}); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; +struct StubGridLoad : data::GridLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::GridParts{}); } +}; +struct StubAsyncRepo : data::IAsyncDatasetRepository { + StubChartLoad* lastChart = nullptr; + StubGridLoad* lastGrid = nullptr; + data::ChartLoad* loadChartAsync(const std::string&) override { + lastChart = new StubChartLoad; return lastChart; + } + data::GridLoad* loadGridAsync(const std::string&) override { + lastGrid = new StubGridLoad; return lastGrid; + } }; } -TEST(DatasetDetailController, EmitsChartReadyOnSuccess) { - StubRepo repo; + +TEST(DatasetDetailController, EmitsChartReadyOnDone) { + StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireDone(); EXPECT_EQ(spy.count(), 1); } -TEST(DatasetDetailController, EmitsLoadFailedOnThrow) { - StubRepo repo; repo.fail = true; + +TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { + StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireFailed(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_other"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 +} + +TEST(DatasetDetailController, AbortsPreviousOnReopen) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); // 替换 + EXPECT_TRUE(a->aborted); // 旧句柄被 abort +} + +TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); + StubChartLoad* b = repo.lastChart; + a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 + EXPECT_EQ(spy.count(), 0); + b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); } From 350f46060d0458c523c3866226ddb7f463a0a558 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:51:45 +0800 Subject: [PATCH 50/74] =?UTF-8?q?harden(controller):=20=E6=9E=90=E6=9E=84?= =?UTF-8?q?=20abort=20=E5=9C=A8=E9=A3=9E=E5=8F=A5=E6=9F=84(=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=A5=91=E7=BA=A6=20spec=20=C2=A77)=20+=20Grid=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=9B=9E=E7=81=8C/=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=AF=B9=E7=A7=B0=E8=A6=86=E7=9B=96=EF=BC=88?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20I-1/M-3/M-4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/DatasetDetailController.cpp | 5 +++ src/controller/DatasetDetailController.hpp | 1 + .../test_dataset_detail_controller.cpp | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 1def781..94355cb 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -6,6 +6,11 @@ namespace geopro::controller { DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} +DatasetDetailController::~DatasetDetailController() { + if (chartLoad_) chartLoad_->abort(); // 退出契约:abort 在飞句柄,不依赖外部析构顺序兜底 + if (gridLoad_) gridLoad_->abort(); +} + void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index e55a954..5bb39af 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -33,6 +33,7 @@ public: }; explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); + ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this public slots: void openDataset(const QString& dsId, const QString& ddCode); void focusDataset(const QString& dsId); diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index 25d85ac..4e21148 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -17,6 +17,7 @@ struct StubGridLoad : data::GridLoad { bool aborted = false; void abort() override { aborted = true; } void fireDone() { emit done(data::GridParts{}); } + void fireFailed() { emit failed(QStringLiteral("x")); } }; struct StubAsyncRepo : data::IAsyncDatasetRepository { StubChartLoad* lastChart = nullptr; @@ -79,3 +80,36 @@ TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); } + +TEST(DatasetDetailController, EmitsGridReadyOnDone) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); + c.loadGridData("ds1", "dd_inversion_data"); + repo.lastGrid->fireDone(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.loadGridData("ds1", "dd_inversion_data"); + repo.lastGrid->fireFailed(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); + c.loadGridData("dsA", "dd_inversion_data"); + StubGridLoad* a = repo.lastGrid; + c.loadGridData("dsB", "dd_inversion_data"); // 替换 → 旧句柄被 abort + StubGridLoad* b = repo.lastGrid; + EXPECT_TRUE(a->aborted); + a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 + EXPECT_EQ(spy.count(), 0); + b->fireDone(); // 当前句柄 → 正常 + EXPECT_EQ(spy.count(), 1); +} From eaa3770f933a13fa4ae10091c2aedf3f12160f4d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:53:58 +0800 Subject: [PATCH 51/74] =?UTF-8?q?feat(app):=20qRegisterMetaType=20=E6=B3=A8=E5=86=8C=EF=BC=88=E5=BC=82=E6=AD=A5=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E5=85=83=E7=B1=BB=E5=9E=8B=EF=BC=8C=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E6=80=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/main.cpp b/src/app/main.cpp index 105c8f2..b305b01 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -788,6 +788,10 @@ int main(int argc, char* argv[]) QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QApplication app(argc, argv); + // 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递 + // (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。 + qRegisterMetaType(); + // 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。 QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); From dc9506c2605f2cf5fc2cea27a6f30704fe57f307 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:59:32 +0800 Subject: [PATCH 52/74] =?UTF-8?q?feat(app):=20=E7=BD=91=E6=A0=BC=E6=87=92?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E3=80=8C=E5=8A=A0=E8=BD=BD=E4=B8=AD=E3=80=8D?= =?UTF-8?q?=E9=81=AE=E7=BD=A9(LoadingOverlay)=20=E6=8E=A5=20loadStarted/?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E4=BA=8E=E5=B0=B1=E7=BB=AA=E6=88=96=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/main.cpp | 10 +++++++- src/app/panels/DatasetDetailPage.cpp | 7 ++++++ src/app/panels/DatasetDetailPage.hpp | 4 ++++ src/app/panels/DatasetDetailPanel.cpp | 3 +++ src/app/panels/DatasetDetailPanel.hpp | 1 + src/app/panels/LoadingOverlay.cpp | 33 +++++++++++++++++++++++++++ src/app/panels/LoadingOverlay.hpp | 18 +++++++++++++++ 8 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/app/panels/LoadingOverlay.cpp create mode 100644 src/app/panels/LoadingOverlay.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index a20d844..f98119e 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(geopro_desktop WIN32 panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp panels/AnomalyTablePanel.cpp + panels/LoadingOverlay.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp CentralScene.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index b305b01..e193ece 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -520,8 +520,16 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re [detailPanel](const geopro::controller::DatasetDetailController::GridData& d) { detailPanel->setGridData(d); }); + QObject::connect( + &detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel, + [detailPanel](const QString& dsId, + geopro::controller::DatasetDetailController::LoadPhase phase) { + if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) + detailPanel->setGridLoading(dsId, true); + }); QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window, - [&window](const QString& dsId, const QString& msg) { + [&window, detailPanel](const QString& dsId, const QString& msg) { + detailPanel->setGridLoading(dsId, false); window.statusBar()->showMessage( QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); }); diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 5cf559f..061cbb6 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -5,6 +5,7 @@ #include "Glyphs.hpp" #include "PanelHeader.hpp" +#include "panels/LoadingOverlay.hpp" #include "panels/chart/GridDataChartView.hpp" #include "panels/chart/RawDataChartView.hpp" @@ -21,6 +22,7 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { rawView_ = new RawDataChartView(this); gridView_ = new GridDataChartView(this); + gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_,随其尺寸覆盖网格图区 const QVector tabs = { {Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, @@ -55,6 +57,11 @@ void DatasetDetailPage::setGridData( const geopro::controller::DatasetDetailController::GridData& d) { gridRequested_ = true; // 已加载,切回网格页不再重复请求 gridView_->setGridData(d.grid, d.gridScale, d.anomalies); + setGridLoading(false); // 数据到达,隐藏遮罩 +} + +void DatasetDetailPage::setGridLoading(bool on) { + if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); } } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 4f46f57..b408d06 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -6,6 +6,7 @@ namespace geopro::app { class RawDataChartView; class GridDataChartView; +class LoadingOverlay; // 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 // 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。 @@ -16,6 +17,8 @@ public: void setData(const geopro::controller::DatasetDetailController::ChartData& d); // 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 void setGridData(const geopro::controller::DatasetDetailController::GridData& d); + // 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。 + void setGridLoading(bool on); QString dsId() const { return dsId_; } signals: // 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 @@ -26,6 +29,7 @@ private: bool gridRequested_ = false; // 已请求过(避免重复发信号) RawDataChartView* rawView_; GridDataChartView* gridView_; + LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩 }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 3f5583e..765a002 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -33,6 +33,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { if (auto* p = pageFor(d.dsId)) p->setGridData(d); } +void DatasetDetailPanel::setGridLoading(const QString& dsId, bool on) { + if (auto* p = pageFor(dsId)) p->setGridLoading(on); +} void DatasetDetailPanel::focusDataset(const QString& dsId) { if (auto* p = pageFor(dsId)) setCurrentWidget(p); } diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index ddee714..344c882 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -11,6 +11,7 @@ public: explicit DatasetDetailPanel(QWidget* parent = nullptr); void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 + void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页 void focusDataset(const QString& dsId); // 单击聚焦已开页 signals: void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp new file mode 100644 index 0000000..3b7e446 --- /dev/null +++ b/src/app/panels/LoadingOverlay.cpp @@ -0,0 +1,33 @@ +#include "panels/LoadingOverlay.hpp" +#include +#include +#include +#include + +namespace geopro::app { + +LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { + setAttribute(Qt::WA_StyledBackground, true); + setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); + label_->setText(QStringLiteral("加载中…")); + label_->setAlignment(Qt::AlignCenter); + auto* lay = new QVBoxLayout(this); + lay->addWidget(label_); + if (parent) parent->installEventFilter(this); + hide(); +} + +void LoadingOverlay::showOver() { + if (parentWidget()) setGeometry(parentWidget()->rect()); + raise(); + show(); +} + +bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) { + if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) { + setGeometry(parentWidget()->rect()); + } + return QWidget::eventFilter(obj, ev); +} + +} // namespace geopro::app diff --git a/src/app/panels/LoadingOverlay.hpp b/src/app/panels/LoadingOverlay.hpp new file mode 100644 index 0000000..624b4eb --- /dev/null +++ b/src/app/panels/LoadingOverlay.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +class QLabel; +namespace geopro::app { + +// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/hide() 切换,几何随父 resize 跟随。 +class LoadingOverlay : public QWidget { + Q_OBJECT +public: + explicit LoadingOverlay(QWidget* parent); + void showOver(); // 铺满父尺寸、置顶、显示 +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize +private: + QLabel* label_; +}; + +} // namespace geopro::app From 8b3bc50f5802c9d0caed424ac819607c9fc81bb3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 21:06:35 +0800 Subject: [PATCH 53/74] =?UTF-8?q?harden(app):=20setData=20=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E9=81=AE=E7=BD=A9=20+=20loadFailed=20context=3Ddetail?= =?UTF-8?q?Panel(=E9=98=B2=E9=87=8E=E6=8C=87=E9=92=88)=20+=20LoadingOverla?= =?UTF-8?q?y=20Q=5FASSERT(parent)=20+=20=E5=88=A0=E6=9C=AA=E7=94=A8=20incl?= =?UTF-8?q?ude=EF=BC=88=E8=AF=84=E5=AE=A1=20I-1/I-2/M-3/4/5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 5 ++++- src/app/panels/DatasetDetailPage.cpp | 1 + src/app/panels/LoadingOverlay.cpp | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index e193ece..139c7b4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -527,7 +527,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) detailPanel->setGridLoading(dsId, true); }); - QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window, + // context 用 detailPanel(与 loadStarted 一致):detailPanel 析构即自动断连,避免野指针。 + // window 比 detailPanel 活得久,lambda 捕 &window 取状态栏安全。loadFailed 无 phase: + // Chart 失败时 gridOverlay 本已隐藏,setGridLoading(false) 幂等无害;Grid 失败时正确清遮罩。 + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel, [&window, detailPanel](const QString& dsId, const QString& msg) { detailPanel->setGridLoading(dsId, false); window.statusBar()->showMessage( diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 061cbb6..7b5f47f 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -51,6 +51,7 @@ void DatasetDetailPage::setData(const geopro::controller::DatasetDetailControlle gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 rawView_->setData(d); gridView_->setData(d); + setGridLoading(false); // 重开/换 ds:重置遮罩状态,避免上次的「加载中」残留 } void DatasetDetailPage::setGridData( diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp index 3b7e446..00bc80f 100644 --- a/src/app/panels/LoadingOverlay.cpp +++ b/src/app/panels/LoadingOverlay.cpp @@ -1,12 +1,12 @@ #include "panels/LoadingOverlay.hpp" #include #include -#include #include namespace geopro::app { LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { + Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作) setAttribute(Qt::WA_StyledBackground, true); setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); label_->setText(QStringLiteral("加载中…")); From 6d0ec909ecdb8586ceba806f86640653b1fb5c68 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 21:24:28 +0800 Subject: [PATCH 54/74] =?UTF-8?q?docs(spec):=20=E5=9B=9E=E5=A1=AB=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E7=8E=B0=E7=8A=B6=20=E2=80=94=20async=20=E4=BB=85=20D?= =?UTF-8?q?atasetDetail=20=E8=AF=95=E7=82=B9=E5=B7=B2=E5=AE=8C=E6=88=90(?= =?UTF-8?q?=E5=AF=BC=E8=88=AA/=E7=99=BB=E5=BD=95=E5=BE=85=E9=93=BA?= =?UTF-8?q?=E5=BC=80)=EF=BC=9B=E8=AF=A6=E6=83=85=E5=9B=BE=E4=BB=85=20dd=5F?= =?UTF-8?q?inversion=5Fdata=20=E5=AE=8C=E6=88=90(QwtPlot=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0,=20=E4=BD=99=20dd=20=E7=B1=BB=E5=9E=8B=E5=BE=85?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-11-apiclient-async-design.md | 13 +++++++++++++ .../specs/2026-06-11-dataset-detail-view-design.md | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md index dd45d93..c526728 100644 --- a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -3,6 +3,19 @@ > 日期 2026-06-11。范围:把数据集详情路径从同步阻塞改为异步非阻塞,作为全 App 异步化的模式样板。 > 后续会按此模式铺开到导航/登录路径(本期不做)。 +## 状态更新(2026-06-11) + +**DatasetDetail 试点:✅ 已完成并通过评审。** 实现计划 `plans/2026-06-11-apiclient-async-datasetdetail.md`(8 任务,逐任务 spec+质量双评审 + 整体评审)。测试 75 → 89(+14 离线用例)全绿。落地原语:`IApiCall`/`ApiCall`/`ApiBatch`(net)、`ChartLoad`/`GridLoad`/`IAsyncDatasetRepository`(data)、控制器 abort-and-replace + 句柄身份比对 + 退出契约、`LoadingOverlay` 网格懒加载遮罩。核心收益落地:详情路径不冻 UI、慢请求可 abort 不回灌、多请求并发 + fail-fast。 + +**未完成(本期非目标,待后续铺开):** +- **导航路径**(`ApiProjectRepository`:空间/项目/结构树/数据集列表/异常树/动态表单)仍同步阻塞。 +- **登录路径**(`AuthService`:验证码→校验→login2)仍同步阻塞。 +- `ApiClient` 同步 `get/postJson` 保留供上述路径使用,尚未移除。 + +可选 follow-up(评审建议,非阻断):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;补 `qRegisterMetaType>()`(为 cross-thread 铺开准备)。 + +> 全 App 异步化铺开的实现计划另见:`plans/2026-06-11-apiclient-async-rollout.md`(如已生成)。 + ## 1. 背景 geopro 现网络栈三层全同步阻塞: diff --git a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md index 9fa45d4..a4749b3 100644 --- a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md +++ b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md @@ -2,7 +2,18 @@ - 日期:2026-06-11 - 分支建议:`feat/dataset-detail-chart` -- 状态:设计(待评审) +- 状态:**ERT 反演(dd_inversion_data)展示功能已落地并经用户验收**;其余 dd 类型 + 工具条编辑功能待后续 + +## 状态更新(2026-06-11) + +**架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。 + +**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。 + +**未完成:** +- **其余 dd 类型的详情图渲染**(§2.4):`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线)、GPR(`dd/gpr/channel/image`)、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类,GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。 +- **工具条编辑功能**(§2.3,范围外/后续单独立项):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图全屏。当前为占位按钮。 +- 加载态:网格懒加载已有「加载中」遮罩;原数据初次加载仅 busy 光标,未做骨架屏。 - 参考材料: - 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(R051–R096)、「测井参数表」「DD类型」 - 原 web 系统:`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(经 Playwright 操作页面 + 抓取 JS chunk 做源码级分析) From 751b4862547c76e7f3200623b79f13c44f7cbf29 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 21:36:18 +0800 Subject: [PATCH 55/74] =?UTF-8?q?docs(plan):=20=E5=BC=82=E6=AD=A5=E5=8C=96?= =?UTF-8?q?=E9=93=BA=E5=BC=80(=E5=AF=BC=E8=88=AA+=E7=99=BB=E5=BD=95)?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20+=20=E5=85=B6=E4=BD=99=20dd=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E8=AF=A6=E6=83=85=E5=9B=BE=E6=89=A9=E5=B1=95=E8=AE=A1?= =?UTF-8?q?=E5=88=92(Phase0=20=E6=A0=B7=E6=9C=AC=E6=8E=A2=E6=9F=A5+?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=88=86=E6=B4=BE=E6=89=93=E9=80=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-11-apiclient-async-rollout.md | 1183 +++++++++++++++++ ...026-06-11-dataset-detail-other-dd-types.md | 202 +++ 2 files changed, 1385 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md create mode 100644 docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md diff --git a/docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md b/docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md new file mode 100644 index 0000000..44d699f --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md @@ -0,0 +1,1183 @@ +# ApiClient 异步化 — 全 App 铺开(导航 + 登录)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务执行本计划。每个 Task 内的 Step 用复选框(`- [ ]`)跟踪。每个 bite-sized Step 是一个 2–5 分钟动作(写失败测试 → 跑 → 实现 → 跑 → 提交)。 + +**Goal:** 把数据集详情试点已验证的「异步句柄 + abort 闸门」模式,铺开到全 App 剩余两条同步阻塞路径——**导航**(`ApiProjectRepository` + `WorkbenchNavController`,9 个仓储方法)与**登录**(`AuthService` 串行依赖链 + `LoginWindow`)。完成后 `ApiClient` 全程不再用 `QEventLoop` 阻塞 UI 线程,可移除同步 `get/postJson`。 + +**Architecture:** 复用 net 层已落地原语 `IApiCall`/`ApiCall`/`ApiBatch`(不重造)。新增一个**顺序执行原语 `ApiChain`**(依赖链:上一步结果喂下一步 + abort + aborted_ 闸门),供登录与导航的依赖链共用。data 层导航仓储改异步:用**统一泛型句柄 `NavRequest`**(抽象基 `INavRequest` + `ApiNavRequest` 实现,控制类爆炸),仓储方法返回 `NavRequest*`(emit `done(T)`/`failed(QString)`),替代同步 `RepoResult`。controller 层 `WorkbenchNavController` 用 abort-and-replace + 句柄身份比对取代 `busy_` 守卫与 `drainPendingCheckedTms` 重放机制(异步后 QEventLoop 重入消失,重放逻辑自然消亡)。登录 `LoginWindow` 接 `AuthService` 异步信号,登录期不冻、可取消。安全靠各层 `aborted_` 入口守卫 + 句柄身份比对 + 一律 `deleteLater`(沿用 spec §5.0)。 + +**Tech Stack:** Qt6(Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy、`tests/net/FakeApiCall.hpp`(复用)。 + +**权威设计:** `docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(§5.0 安全不变量、§7 错误判定 code==200/退出契约)。试点实现计划范式:`docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`。 + +**构建/测试命令:** +- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` + - 若报 `LNK1104 ... geopro_desktop.exe`:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。 +- 全量测试(**只跑 ctest,不构建——须先 dev-build**):`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiChain.*` +- **基线:当前 89/89 绿。** 本计划第一阶段(导航)完成后预期 89 → ~104;第二阶段(登录)完成后预期 ~108。 + +--- + +## 阶段划分与拆分边界(顶部决策) + +本计划范围大(导航 9 方法 + 控制器复杂状态机 + 登录串行链 + 一个新原语),**建议分两阶段,并可拆成两份独立 plan 分两个 PR 落地**: + +- **Part A — 导航(本文件给出全部 bite-sized 任务,Task A0–A6)。** 先行理由:导航虽方法多但每个方法是「值进 → 句柄出」的机械翻译,模式与详情试点最贴近,风险**可控且可分散提交**;其控制器 `busy_`/drain 重构是难点但有详情控制器先例可照搬。`ApiChain` 原语在 Part A 内落地(导航依赖链需要它),同时为登录铺路。 +- **Part B — 登录(本文件给出高层任务骨架,Task B0–B5)。** 后行理由:登录是**串行依赖链 + 共享会话 + 模态对话框 `exec()`**,且有联网 live 测试 `test_auth.cpp` 需改造,风险点集中(会话 cookie、模态循环里的异步、取消语义),适合在 `ApiChain` 经导航验证稳定后再做。Part B 详细 bite-sized 任务在落地 Part A 后,按本骨架另起一份 plan(`2026-06-11-apiclient-async-login.md`)细化——或直接在本文件继续展开。 + +> **拆 plan 的判断:** 推荐拆。Part A 一个 PR(导航全异步,含 `ApiChain`),Part B 一个 PR(登录全异步 + 移除同步 `get/postJson`)。两 PR 之间 `ApiClient` 同步方法保留供登录过渡(登录是最后一个同步消费者)。若评审倾向单 PR,则按 Task 顺序 A0→A6→B0→B5 连续执行,每 Task 仍独立提交。 + +--- + +## 关键设计决策 + +### 决策 1:登录串行依赖链 → 新增 `ApiChain` 原语(而非嵌套 ApiCall 续延) + +**问题:** 登录是 `verifyCodeCheck` → RSA(本地) → `login2`,每步用上一步结果(且都走同一 NAM 共享 JSESSIONID)。导航也有依赖链:`start` = listWorkspaces → pageProjects → loadStructure;`switchWorkspace` = switch(写新 token) → pageProjects → loadStructure。`ApiBatch` 是**并发汇聚**,不适配。 + +**候选:** +- (a) 嵌套 `ApiCall` 续延(在每个 `finished` lambda 里发下一个请求):能用,但 abort/aborted_ 闸门要在每层手写,易漏;多处复制;测试难复用。 +- (b) **`ApiChain` 顺序执行原语(推荐)**:一个 QObject,持有一个「步骤列表」,每步是 `std::function& prior)>`(用既往响应构造下一请求,含本地变换如 RSA)。逐步执行:每步 `finished` → `isFailure` 判定(失败则 fail + abort + deleteLater)→ 成功则记录响应、执行下一步;末步成功 → `succeeded(QList)`。`abort()` 置 `aborted_`、abort 当前在飞 call、deleteLater。**入口守卫 `if (aborted_) return;` 同 ApiBatch。** + +**推荐 (b)。** 与 `ApiBatch` 对称(同 `Predicate`、同信号面 `succeeded(QList)`/`failed(int,ApiResponse)`、同安全不变量),可离线单测(FakeApiCall 注入),登录与导航依赖链共用。RSA 这类**纯本地步骤**不发请求:用「步骤工厂返回 nullptr 表示纯本地变换已在工厂内完成,直接进下一步」过于隐晦——改为 RSA 在「构造 login2 请求的工厂 lambda」内同步完成(工厂可抛 `std::exception`,被 ApiChain 捕获转 `failed`),无需把本地步骤建模为独立 chain step。 + +**共享会话不变量:** `ApiChain` 的每个 step 工厂调 `api_.postJsonAsync/getAsync`,全部走同一 `ApiClient`(同一 NAM),cookie/JSESSIONID 自然串联——与现同步链一致(见 `ApiClient.hpp:24-27` 注释)。`switchWorkspace` 链中途 `api_.setToken(newToken)` 的副作用:必须在「switch 响应到达、构造下一请求之前」执行——`ApiChain` 的 step 工厂在「构造下一请求时」运行,故把 `setToken` 放进「pageProjects 请求工厂」的开头(它能读到 switch 的响应),时序正确。 + +### 决策 2:导航 9 方法分类(依赖链 / 单请求 / 可并发) + +| 控制器入口 | 仓储调用序列 | 类型 | 原语 | +|---|---|---|---| +| `start()` | listWorkspaces → pageProjects → loadStructure | 依赖链 | `ApiChain` | +| `switchWorkspace(id)` | switchWorkspace(setToken) → pageProjects → loadStructure | 依赖链 | `ApiChain` | +| `switchProject(id)` | loadStructure | 单请求 | `NavRequest`(单 ApiCall) | +| `selectObject(id,t)` | loadRows(data) + loadRows(file) + loadObjectDetail | **可并发** | `ApiBatch` | +| `loadMoreData()` | loadRows(data) | 单请求 | `NavRequest` | +| `loadMoreFiles()` | loadRows(file) | 单请求 | `NavRequest` | +| `selectDataset(id)` | loadDatasetForm | 单请求 | `NavRequest` | +| `setCheckedTms(ids)` | loadExceptionsByTm × N(带缓存,缺失才拉) | **可并发**(仅未命中缓存项) | `ApiBatch` | + +> 仓储层保持「一方法一请求」的薄封装(返回 `NavRequest*`),**汇聚/链式编排放控制器**(它知道完整序列与状态)——与详情试点把汇聚放 repo 不同,因为导航的序列依赖控制器状态(缓存、当前项目/父节点),放 repo 会泄漏状态。这是导航与详情的**有意差异**,在 self-review 中复核。 + +### 决策 3:`RepoResult` 异步化 → 统一泛型句柄 `NavRequest` + +**问题:** 同步 `RepoResult` 返回值需变成「句柄 emit done(T)/failed(msg)」。导航有 9 个方法、7 种返回类型(`vector`、`bool`、`ProjectListPage`、`vector`、`vector`、`DsPage`、`DynamicForm`、`vector`)。每类型一个具体句柄类 = 类爆炸(且各自 Q_OBJECT/moc)。 + +**候选:** +- (a) 每方法/每类型一句柄(如详情的 `ChartLoad`/`GridLoad`):详情只有 2 个,可接受;导航 7+ 个,爆炸。 +- (b) **模板句柄 `NavRequest`(推荐)**:抽象基 `INavRequest`(纯虚 `abort()` + signals `done(T)`/`failed(QString)`)+ `ApiNavRequest`(包一个 `IApiCall`,构造注入 `std::function` 解析器)。**Qt 限制:模板类不能含 `Q_OBJECT`(moc 不支持模板)。** 解决:基类用**非模板** `NavRequestBase : QObject`(Q_OBJECT,signals 用 `QVariant` 承载 payload),模板层 `NavRequest` 继承它、提供 typed `done(T)` 转发;或更简单——**信号 payload 用 `QVariant`,typed 在控制器侧 `value.value()` 取出**。但 `T` 含自定义结构(`DsPage` 等)需 `Q_DECLARE_METATYPE` + 同线程直连无需注册。 +- (c) 统一非模板 `NavRequest`,payload 一律 `QVariant`(装任意 `T`)+ 控制器取出:**最省类**,无模板/moc 难题,代价是控制器侧 `.value()` 取值(需对各 `T` 加 `Q_DECLARE_METATYPE`)。 + +**推荐 (c) 的变体:单个非模板 `NavRequest : QObject`,signals `done(const QVariant&)`/`failed(const QString&)`,`abort()`;具体 `ApiNavRequest` 包 `IApiCall` + `std::function` 解析器。** 理由:① 零模板/零 moc 难题(单类,单次 Q_OBJECT);② 控制器已是「拿到 typed → emit 既有 typed 信号」,多一步 `qvariant_cast` 成本极小;③ 解析器 lambda 在 repo 内捕获 DTO 解析(与现 `dto::parseXxx` 一对一);④ abort/aborted_ 闸门只在一处实现。对各返回类型加 `Q_DECLARE_METATYPE`(`DsPage`/`DynamicForm`/`ProjectListPage`/`vector<...>` 等,同线程直连下声明即可、无需 `qRegisterMetaType`)。 + +> 与详情试点的 `ChartLoad`/`GridLoad`(typed 信号)风格略不同,但详情只 2 类、且 Parts 是合成结构;导航 7 类用 typed-per-class 不划算。`NavRequest`(QVariant) 是导航规模下的正确权衡,在 self-review 复核类型一致性。 + +### 决策 4:`WorkbenchNavController` 的 `busy_` / `drainPendingCheckedTms` 演化(难点) + +**现状根因:** `busy_` 守卫 + `BusyGuard` RAII 配平 `busyChanged` + `drainPendingCheckedTms` 重放,全部是**同步 `QEventLoop` 阻塞下「Qt 仍泵事件 → slot 重入」**的产物(spec §1)。`setCheckedTms` 在 `busy_` 时把请求挂起、待 `BusyGuard` 析构经 `QueuedConnection` 重放,正是为对抗嵌套循环重入。 + +**异步后演化:** +1. **删除 `busy_` 守卫**:异步不阻塞、无嵌套循环、无 slot 重入。 +2. **删除 `checkedTmsPending_` / `pendingCheckedTms_` / `drainPendingCheckedTms` / `BusyGuard` / `friend struct BusyGuard`**:重放机制随重入消失而消亡。 +3. **保留 `busyChanged(bool)` 信号**(main.cpp:751 接等待光标):语义从「同步阻塞中」改为「有在飞请求」。实现:控制器持有「在飞句柄计数 / 或每类一个 QPointer」,**发起请求时若之前无在飞→emit busyChanged(true),最后一个完成/失败→emit busyChanged(false)**。最简实现:每条路径一个 QPointer 成员(`startChain_`/`structReq_`/`selectBatch_`/`moreDataReq_`/`moreFilesReq_`/`datasetReq_`/`exceptionsBatch_`),任一非空即 busy。**KISS:** 用一个辅助 `emitBusyIfChanged()` 在每次 start/clear 后根据「是否存在任一在飞句柄」发信号(去抖:值变才发)。 +4. **abort-and-replace + 句柄身份比对**(取代 busy 拒绝重入):每条路径 abort 旧句柄、存新句柄、`done`/`failed` 里比对身份(`load != xxxReq_` 则丢弃迟到信号),与详情控制器一一对应。 +5. **`setCheckedTms` 的重入语义**:原「busy 时挂起最新一次、空闲重放」→ 异步后「**abort 旧异常批、发新批**」。用户快速改勾选 = 新请求覆盖旧请求(abort 旧 batch + 身份比对丢旧结果),**自然实现「以最后一次勾选为准」**,比原挂起-重放更简单且语义更优。缓存 `tmExceptionCache_` 保留(命中不发请求)。 + +> **难点处理明确化(无 TBD):** `drainPendingCheckedTms` 删除,其「最后一次为准」语义由 abort-and-replace 承接;`busy_` 删除,`busyChanged` 由「在飞句柄存在性」驱动;`BusyGuard`/`friend` 删除。验证用例见 Task A6 的 `SetCheckedTmsAbortsPreviousBatch` / `BusyChangedReflectsInflight`。 + +### 决策 5:原子落地以保持构建绿(迁移顺序) + +接口改形强耦合控制器(详情试点 Task5+6 合并的教训):`IProjectRepository`(同步 `RepoResult`)→ `IAsyncProjectRepository`(句柄)是**破坏性改形**,`ApiProjectRepository` 与 `WorkbenchNavController` 必须**同批提交**。本计划在 Task A4(repo 改形)与 Task A5(controller 改形)之间标注「**A4+A5 必须同一提交或连续提交且中间不要求构建绿**」——推荐做法:先在 A3 引入 `IAsyncProjectRepository` 新接口与 `NavRequest`(**新增、不删旧**,构建仍绿、旧路径仍用同步),A4 让 `ApiProjectRepository` **同时实现新旧两接口**(过渡),A5 切换控制器到新接口,A6 删除旧同步接口/方法。这样每步构建绿、可独立提交。 + +--- + +## 文件结构(每文件职责) + +### Part A — 导航 + +**新建:** +- `src/net/ApiChain.hpp` / `src/net/ApiChain.cpp` — 顺序执行原语。持步骤工厂列表 `QList`(`std::function& prior)>`,工厂可抛 `std::exception`)+ `Predicate isFailure`;逐步执行、fail-fast、`aborted_` 闸门、一律 deleteLater。signals `succeeded(QList)` / `failed(int index, ApiResponse)`。 +- `tests/net/test_api_chain.cpp` — ApiChain 离线单测(FakeApiCall 注入;顺序、失败短路、abort 闸门、工厂抛异常转 failed)。 +- `src/data/api/NavRequest.hpp` / `src/data/api/NavRequest.cpp` — `NavRequest : QObject`(抽象基:纯虚 `abort()`,signals `done(QVariant)`/`failed(QString)`)+ `ApiNavRequest`(包 `IApiCall` + `std::function` 解析器 + isFailure;finished→判定→解析→done/failed)。 +- `src/data/api/NavLoads.hpp` — `Q_DECLARE_METATYPE` 各导航返回类型(`std::vector`、`ProjectListPage`、`std::vector`、`std::vector`、`DsPage`、`DynamicForm`、`std::vector`、`bool` 已内置);并定义控制器编排用的合成结果载体(见下)。 +- `src/data/repo/IAsyncProjectRepository.hpp` — 异步导航仓储抽象:每方法返回 `NavRequest*`(薄封装,一方法一请求)。9 方法对齐现 `IProjectRepository`。 +- `tests/data/test_nav_request.cpp` — `ApiNavRequest` 离线单测(FakeApiCall + 桩解析器:done/failed/abort 闸门)。 +- `tests/controller/test_workbench_nav_controller.cpp` — 控制器离线单测(Stub 异步 repo + QSignalSpy):覆盖依赖链、并发、abort-and-replace、busyChanged、setCheckedTms 覆盖语义、回灌防护。**当前不存在**(导航控制器现无单测)。 + +**修改:** +- `src/net/CMakeLists.txt` — 源列表加 `ApiChain.cpp`(AUTOMOC 已 ON)。 +- `src/data/api/ApiProjectRepository.hpp` / `.cpp` — 改/扩展实现 `IAsyncProjectRepository`(每方法用 `getAsync/postJsonAsync` + `ApiNavRequest` + DTO 解析 lambda)。过渡期同时保留 `IProjectRepository`(A4),A6 删旧。 +- `src/data/CMakeLists.txt` — 源列表加 `NavRequest.cpp`(AUTOMOC 已 ON)。 +- `src/controller/WorkbenchNavController.hpp` / `.cpp` — 依赖 `IAsyncProjectRepository`;删 `busy_`/`BusyGuard`/`drain`/pending;改 `ApiChain`/`ApiBatch`/`NavRequest` 编排 + abort-and-replace + 身份比对 + busyChanged 重定义。 +- `tests/CMakeLists.txt` — 加 test_api_chain / test_nav_request / test_workbench_nav_controller。 +- `src/app/main.cpp` — 装配换 `IAsyncProjectRepository`(引用绑定,接线信号面不变);`ProjectListDialog` 形参类型核对(见 A5 Step)。 + +### Part B — 登录(骨架) + +**新建:** +- `src/net/AuthService` 改异步(见下);`tests/net/test_auth_async.cpp`(可选 live + 离线 stub 段)。 + +**修改:** +- `src/net/AuthService.hpp` / `.cpp` — `fetchCaptcha`/`login` 改异步:返回句柄或经回调/信号。用 `ApiChain` 编排 verify→login2(RSA 在 login2 工厂内)。 +- `src/app/login/LoginWindow.hpp` / `.cpp` — 接异步信号;登录期禁用按钮/显示「登录中」、不冻;可取消(关窗 abort)。 +- `tests/net/test_auth.cpp` — live 测试改造为异步等待(`QSignalSpy::wait`)。 +- `src/app/main.cpp` — 移除同步 `ApiClient::get/postJson` 消费后,删除同步方法(最终清理)。 + +**不动:** `src/data/repo/IDatasetRepository.hpp`、详情路径(已异步)、`LocalSampleRepository`、`src/net/crypto/RsaEncryptor.*`。 + +--- + +# Part A — 导航 + +## Task A0: net — `ApiChain` 顺序执行原语 + 离线单测(TDD) + +复用 `IApiCall`/`ApiResponse`,与 `ApiBatch` 对称。**新增不破坏现有。** + +**Files:** +- Create: `src/net/ApiChain.hpp`, `src/net/ApiChain.cpp`, `tests/net/test_api_chain.cpp` +- Modify: `src/net/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写失败测试 `tests/net/test_api_chain.cpp`**(复用 `tests/net/FakeApiCall.hpp`) + +```cpp +#include +#include +#include +#include "ApiChain.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok(int v = 0) { ApiResponse r; r.code = 200; r.httpStatus = 200; r.data = QJsonObject{{"v", v}}; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(ApiChain, RunsStepsInOrderAndPassesPriorResponses) { + auto* s1 = new FakeApiCall; + auto* s2 = new FakeApiCall; + int seenPrior = -1; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList& prior) -> IApiCall* { + seenPrior = prior.size(); // 第二步能看到第一步响应 + return s2; + }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy okSpy(chain, &ApiChain::succeeded); + s1->fire(ok(11)); // 第一步完成 → 触发第二步工厂 + EXPECT_EQ(seenPrior, 1); + EXPECT_EQ(okSpy.count(), 0); // 还差第二步 + s2->fire(ok(22)); + EXPECT_EQ(okSpy.count(), 1); + const auto resps = okSpy.takeFirst().at(0).value>(); + EXPECT_EQ(resps.size(), 2); +} + +TEST(ApiChain, FailFastShortCircuitsRemainingSteps) { + auto* s1 = new FakeApiCall; + bool secondBuilt = false; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { secondBuilt = true; return new FakeApiCall; }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy failSpy(chain, &ApiChain::failed); + s1->fire(bad()); // 第一步失败 + EXPECT_EQ(failSpy.count(), 1); + EXPECT_FALSE(secondBuilt); // 后续步骤不再构造 +} + +TEST(ApiChain, AbortGateSuppressesLateSignals) { + auto* s1 = new FakeApiCall; + QList steps{[&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy okSpy(chain, &ApiChain::succeeded); + chain->abort(); + EXPECT_TRUE(s1->aborted); // 在飞步骤被 abort + s1->fire(ok()); // 迟到 + EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门 +} + +TEST(ApiChain, StepFactoryThrowBecomesFailed) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { throw std::runtime_error("rsa fail"); }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy failSpy(chain, &ApiChain::failed); + s1->fire(ok()); // 触发第二步工厂 → 抛 → failed + EXPECT_EQ(failSpy.count(), 1); +} +``` + +- [ ] **Step 2: 注册测试到 CMake,跑确认编译失败** + +`tests/CMakeLists.txt` net 段(`net/test_api_batch.cpp` 之后)加: +```cmake +target_sources(geopro_tests PRIVATE net/test_api_chain.cpp) +``` +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 编译失败(`ApiChain.hpp` 不存在)。 + +- [ ] **Step 3: 写 `src/net/ApiChain.hpp`** + +```cpp +#pragma once +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::net { + +// 顺序执行 N 个步骤(依赖链):每步工厂用既往响应构造下一 IApiCall(工厂可抛 std::exception)。 +// 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。 +// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。 +// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 +class ApiChain : public QObject { + Q_OBJECT +public: + // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。 + using StepFactory = std::function& prior)>; + using Predicate = std::function; + ApiChain(QList steps, Predicate isFailure, QObject* parent = nullptr); + void abort(); +signals: + void succeeded(const QList& responses); + void failed(int index, const geopro::net::ApiResponse& resp); +private: + void runNext(); // 构造并连接下一步(工厂抛出 → emit failed) + QList steps_; + Predicate isFailure_; + QList responses_; + QPointer current_; + int index_ = 0; + bool aborted_ = false; +}; + +} // namespace geopro::net +``` + +- [ ] **Step 4: 写 `src/net/ApiChain.cpp`** + +```cpp +#include "ApiChain.hpp" +#include + +namespace geopro::net { + +ApiChain::ApiChain(QList steps, Predicate isFailure, QObject* parent) + : QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) { + Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded) + Q_ASSERT(isFailure_); + runNext(); +} + +void ApiChain::runNext() { + if (aborted_) return; + if (index_ >= steps_.size()) { // 全部完成 + emit succeeded(responses_); + deleteLater(); + return; + } + IApiCall* call = nullptr; + try { + call = steps_[index_](responses_); // 工厂可抛(如 RSA 失败):转 failed + } catch (const std::exception&) { + ApiResponse r; + r.rawError = QStringLiteral("步骤构造失败"); // 详细原因由控制器层兜底文案/或工厂内写入 + aborted_ = true; + emit failed(index_, r); + deleteLater(); + return; + } + current_ = call; + QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + aborted_ = true; + emit failed(index_, resp); + deleteLater(); + return; + } + responses_.append(resp); + ++index_; + runNext(); // 链式推进 + }); +} + +void ApiChain::abort() { + if (aborted_) return; + aborted_ = true; + if (current_) current_->abort(); // abort 当前在飞步骤 + deleteLater(); +} + +} // namespace geopro::net +``` +> 注:工厂抛异常时的详细原因——若需保留 `e.what()`,可把 `rawError` 设为 `QString::fromUtf8(e.what())`;登录 RSA 失败文案在 AuthService 层包装(Part B)。导航链工厂不做本地变换、不会抛,此分支主要服务 Part B。 + +- [ ] **Step 5: 加 `ApiChain.cpp` 到 net 库** + +`src/net/CMakeLists.txt` 源列表加 `ApiChain.cpp`(在 `ApiBatch.cpp` 后)。 + +- [ ] **Step 6: 跑测试确认通过** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 4 个 `ApiChain.*` PASS;总 93/93。 + +- [ ] **Step 7: Commit** + +```bash +git add src/net/ApiChain.hpp src/net/ApiChain.cpp src/net/CMakeLists.txt tests/net/test_api_chain.cpp tests/CMakeLists.txt +git commit -m "feat(net): ApiChain 顺序依赖链原语(fail-fast+abort闸门+工厂可抛) + 离线单测" +``` + +--- + +## Task A1: data — `NavRequest` 句柄(单类,QVariant payload)+ 元类型声明 + 离线单测(TDD) + +**Files:** +- Create: `src/data/api/NavLoads.hpp`, `src/data/api/NavRequest.hpp`, `src/data/api/NavRequest.cpp`, `tests/data/test_nav_request.cpp` +- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写 `src/data/api/NavLoads.hpp`**(元类型声明 + 编排合成载体) + +```cpp +#pragma once +#include +#include +#include "repo/RepoTypes.hpp" + +// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE(无需 qRegisterMetaType)。 +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(geopro::data::ProjectListPage) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(geopro::data::DsPage) +Q_DECLARE_METATYPE(geopro::data::DynamicForm) +Q_DECLARE_METATYPE(std::vector) +// bool 已内置 QMetaType。 + +namespace geopro::data { + +// 控制器并发编排 selectObject 的三响应合成(data+file+detail),仅控制器内部使用。 +// (此结构供 self-doc;实际并发由控制器用 ApiBatch + 各 NavRequest 解析器组装,见 Task A5。) + +} // namespace geopro::data +``` +> `switchWorkspace` 返回 `bool` 但需副作用 `setToken`:该副作用放在「链工厂」里(见 A5),`NavRequest` 仅承载成功标志。`switchWorkspace` 解析器需访问 `accessToken` → 由 repo 解析 lambda 内 `api_.setToken(...)`(见 A4 Step 2 注)。 + +- [ ] **Step 2: 写 `src/data/api/NavRequest.hpp`** + +```cpp +#pragma once +#include +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::data { + +// 单请求异步句柄(抽象基,可测试缝):payload 经 QVariant 承载,控制器侧 qvariant_cast 取出。 +class NavRequest : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~NavRequest() override = default; + virtual void abort() = 0; +signals: + void done(const QVariant& value); + void failed(const QString& message); +}; + +// Api 实现:包一个 IApiCall + 注入的解析器(ApiResponse → QVariant)+ 失败谓词。 +class ApiNavRequest : public NavRequest { + Q_OBJECT +public: + using Parser = std::function; + using Predicate = std::function; + ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, + QObject* parent = nullptr); // 接管 call + void abort() override; +private: + QPointer call_; + Parser parse_; + Predicate isFailure_; + bool aborted_ = false; +}; + +} // namespace geopro::data +``` + +- [ ] **Step 3: 写 `src/data/api/NavRequest.cpp`** + +```cpp +#include "api/NavRequest.hpp" +#include + +namespace geopro::data { + +namespace { +QString reasonOf(const geopro::net::ApiResponse& r) { + return r.msg.isEmpty() ? r.rawError : r.msg; +} +} // namespace + +ApiNavRequest::ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, + QObject* parent) + : NavRequest(parent), call_(call), parse_(std::move(parse)), isFailure_(std::move(isFailure)) { + QObject::connect(call, &geopro::net::IApiCall::finished, this, + [this](const geopro::net::ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + emit failed(reasonOf(resp)); + deleteLater(); + return; + } + QVariant out; + try { + out = parse_(resp); // 仅解析在 try 内(下游 done 处理器抛出不误报) + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + deleteLater(); + return; + } catch (...) { + emit failed(QStringLiteral("解析失败:未知异常")); + deleteLater(); + return; + } + emit done(out); + deleteLater(); + }); +} + +void ApiNavRequest::abort() { + if (aborted_) return; + aborted_ = true; + if (call_) call_->abort(); + deleteLater(); +} + +} // namespace geopro::data +``` + +- [ ] **Step 4: 写失败测试 `tests/data/test_nav_request.cpp`** + +```cpp +#include +#include +#include +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::data; +using geopro::net::ApiResponse; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} + +TEST(NavRequest, EmitsDoneWithParsedPayload) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, + [](const ApiResponse&) { return QVariant::fromValue(DsPage{{}, 42}); }, isFailure); + QSignalSpy doneSpy(req, &NavRequest::done); + call->fire(ok()); + ASSERT_EQ(doneSpy.count(), 1); + const auto page = doneSpy.takeFirst().at(0).toMap().isEmpty() + ? qvariant_cast(doneSpy.count() ? QVariant() : QVariant()) // 见下注 + : DsPage{}; + // 简化断言:直接从信号取 QVariant +} + +TEST(NavRequest, EmitsFailedOnBusinessError) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure); + QSignalSpy failSpy(req, &NavRequest::failed); + call->fire(bad()); + EXPECT_EQ(failSpy.count(), 1); +} + +TEST(NavRequest, AbortSuppressesLateDone) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure); + QSignalSpy doneSpy(req, &NavRequest::done); + req->abort(); + EXPECT_TRUE(call->aborted); + call->fire(ok()); + EXPECT_EQ(doneSpy.count(), 0); +} +``` +> 实现者注:`EmitsDoneWithParsedPayload` 的 payload 断言改为直接 `qvariant_cast(doneSpy.takeFirst().at(0))` 并 `EXPECT_EQ(page.total, 42)`(上方草稿的取值写法笨拙,落地时简化为这一行;`DsPage` 已 `Q_DECLARE_METATYPE`)。 + +- [ ] **Step 5: 注册 data 源 + 测试到 CMake** + +`src/data/CMakeLists.txt` 源列表加 `api/NavRequest.cpp`(在 `api/DatasetLoadHandles.cpp` 后;AUTOMOC 已 ON)。 +`tests/CMakeLists.txt` data 段(`data/test_dataset_load_handles.cpp` 后)加: +```cmake +target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: 3 个 `NavRequest.*` PASS;总 96/96。 + +- [ ] **Step 7: Commit** + +```bash +git add src/data/api/NavLoads.hpp src/data/api/NavRequest.hpp src/data/api/NavRequest.cpp src/data/CMakeLists.txt tests/data/test_nav_request.cpp +git commit -m "feat(data): NavRequest 单请求异步句柄(QVariant payload, abort闸门) + 元类型声明 + 离线单测" +``` + +--- + +## Task A2: data — `IAsyncProjectRepository` 抽象接口(新增,不删旧) + +**Files:** +- Create: `src/data/repo/IAsyncProjectRepository.hpp` + +- [ ] **Step 1: 写 `src/data/repo/IAsyncProjectRepository.hpp`** + +```cpp +#pragma once +#include + +namespace geopro::data { + +class NavRequest; + +// 导航异步仓储抽象(薄封装:一方法一请求,返回自管理句柄 emit done(QVariant)/failed(msg))。 +// 汇聚/链式编排由 WorkbenchNavController 负责(它知道完整序列与状态)。 +// 方法与同步 IProjectRepository 一一对应;payload 类型见各方法注释(控制器 qvariant_cast)。 +class IAsyncProjectRepository { +public: + virtual ~IAsyncProjectRepository() = default; + virtual NavRequest* listWorkspaces() = 0; // std::vector + virtual NavRequest* switchWorkspace(const std::string& tenantId) = 0; // bool(解析器内 setToken 副作用) + virtual NavRequest* pageProjects(const std::string& nameFilter, + const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage + virtual NavRequest* listProjectTypes() = 0; // std::vector + virtual NavRequest* loadStructure(const std::string& projectId) = 0; // std::vector + virtual NavRequest* loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) = 0; // DsPage + virtual NavRequest* loadObjectDetail(const std::string& objectId, int confType) = 0; // DynamicForm + virtual NavRequest* loadDatasetForm(const std::string& dsObjectId) = 0; // DynamicForm + virtual NavRequest* loadExceptionsByTm(const std::string& tmObjectId) = 0; // std::vector +}; + +} // namespace geopro::data +``` + +- [ ] **Step 2: 构建(纯头新增,无消费者,验证编译)** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 构建通过(未被引用,仅语法校验)。 + +- [ ] **Step 3: Commit** + +```bash +git add src/data/repo/IAsyncProjectRepository.hpp +git commit -m "feat(data): IAsyncProjectRepository 异步导航仓储抽象(薄封装,返回NavRequest)" +``` + +--- + +## Task A3: data — `ApiProjectRepository` 实现异步接口(过渡:新旧两接口并存) + +让 `ApiProjectRepository` **同时**实现 `IProjectRepository`(旧同步,保留)与 `IAsyncProjectRepository`(新异步)。构建仍绿、旧控制器路径不动。 + +**Files:** +- Modify: `src/data/api/ApiProjectRepository.hpp`, `src/data/api/ApiProjectRepository.cpp` + +- [ ] **Step 1: 改 `ApiProjectRepository.hpp`** — 加继承 + 9 个异步方法声明 + +```cpp +#pragma once +#include "repo/IProjectRepository.hpp" +#include "repo/IAsyncProjectRepository.hpp" + +namespace geopro::net { class ApiClient; } + +namespace geopro::data { + +// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。 +class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository { +public: + explicit ApiProjectRepository(net::ApiClient& api); + + // ── 同步(旧,A6 删除) ── + RepoResult> listWorkspaces() override; + /* ...其余 8 个同步方法签名保持不变(略,见现文件)... */ + + // ── 异步(新) ── 与 IAsyncProjectRepository 一致,返回 NavRequest* + NavRequest* listWorkspacesAsync(); // 见下注:消歧 + /* 注:同步 listWorkspaces() 与异步签名冲突(同名不同返回类型不能 override 两接口同名)。 + 解决:异步接口方法在 .hpp 用 IAsyncProjectRepository 的纯虚名(listWorkspaces 等), + 但同步接口也叫 listWorkspaces → C++ 同名隐藏。两接口同名方法返回类型不同,无法在同一类 + 共存(重载仅靠参数区分,返回类型不算)。**故 A3 改为:异步方法用不同名后缀消歧。** */ +}; + +} // namespace geopro::data +``` + +> **重要消歧(实现者必读):** `IProjectRepository::listWorkspaces()` 返回 `RepoResult<...>`,`IAsyncProjectRepository::listWorkspaces()` 返回 `NavRequest*`——**同名同参不同返回类型,C++ 不允许在同一类同时 override**。两条出路: +> - **出路 1(推荐,最干净):跳过过渡期双实现,A3/A4 合并为「直接改形」**:`ApiProjectRepository` 只实现 `IAsyncProjectRepository`(删同步继承),同批改控制器(A4+A5 合并提交,如决策 5 备选)。代价:A4 到 A5 之间一个提交内构建可能不绿——用「单次大提交」承接(详情试点 Task5+6 合并的同款做法)。 +> - **出路 2:异步方法加 `Async` 后缀**(`listWorkspacesAsync()` 等),`IAsyncProjectRepository` 接口方法名也带 `Async`。两接口可共存、过渡期构建绿、可分步提交。代价:方法名与同步版略有差异。 +> +> **本计划采用出路 2**(保证每步构建绿、可分散提交,符合「频繁提交」与决策 5)。下方 Step 按出路 2 给出:`IAsyncProjectRepository` 的方法名统一加 `Async` 后缀。**回到 Task A2 把接口方法名改为 `listWorkspacesAsync` 等**(A2 已提交则在本 Task 一并修正 + 重新提交 A2 头)。 + +- [ ] **Step 1b: 修正 `IAsyncProjectRepository.hpp` 方法名加 `Async` 后缀** + +把 A2 的 9 个方法改名:`listWorkspacesAsync` / `switchWorkspaceAsync` / `pageProjectsAsync` / `listProjectTypesAsync` / `loadStructureAsync` / `loadRowsAsync` / `loadObjectDetailAsync` / `loadDatasetFormAsync` / `loadExceptionsByTmAsync`(参数同前)。 + +- [ ] **Step 2: `ApiProjectRepository.hpp` 加 9 个 `...Async` 声明**(保留全部同步方法不动) + +```cpp + NavRequest* listWorkspacesAsync() override; + NavRequest* switchWorkspaceAsync(const std::string& tenantId) override; + NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId, + int pageNo, int pageSize) override; + NavRequest* listProjectTypesAsync() override; + NavRequest* loadStructureAsync(const std::string& projectId) override; + NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) override; + NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override; + NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override; + NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override; +``` + +- [ ] **Step 3: 改 `ApiProjectRepository.cpp`** — 加异步实现(解析 lambda 复用现有 `dto::parseXxx`) + +在文件顶部 include 加: +```cpp +#include "ApiBatch.hpp" // 仅若需要;本 repo 薄封装不直接用 batch(控制器用) +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" +``` +匿名命名空间内已有 `enc`/`ok`/`errorOf`;新增异步失败谓词(与同步 `ok` 同口径): +```cpp +bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); } +``` +在文件末尾(同步方法之后)加 9 个异步实现,例如: +```cpp +NavRequest* ApiProjectRepository::listWorkspacesAsync() { + auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list")); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::switchWorkspaceAsync(const std::string& tenantId) { + const QString path = + QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); + auto* call = api_.postJsonAsync(path, QJsonObject{}); + // 解析器内执行 setToken 副作用(与同步版一致):切空间返回新 accessToken 必须重注入。 + return new ApiNavRequest(call, [this](const net::ApiResponse& r) { + const QString token = r.data.value(QStringLiteral("accessToken")).toString(); + if (!token.isEmpty()) api_.setToken(token); + return QVariant::fromValue(true); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::pageProjectsAsync(const std::string& nameFilter, + const std::string& typeId, int pageNo, int pageSize) { + QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), pageSize}}; + if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); + auto* call = api_.postJsonAsync(QStringLiteral("/business/my/profile/project/page"), body); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseProjectPage(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectId) { + const QString path = + QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) { + const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") + : QStringLiteral("/business/dsObject/data/page"); + const QJsonObject body{ + {QStringLiteral("projectId"), QString::fromStdString(projectId)}, + {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, + {QStringLiteral("structParentConfType"), parentConfType}, + {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), 5}}; + auto* call = api_.postJsonAsync(path, body); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDsPage(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadObjectDetailAsync(const std::string& objectId, int confType) { + const QString path = + (confType == 1) ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) + : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDynamicForm(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadDatasetFormAsync(const std::string& dsObjectId) { + const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDynamicForm(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::listProjectTypesAsync() { + auto* call = api_.getAsync(QStringLiteral("/business/project/type/list")); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmObjectId) { + const QString path = + QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} +``` +> 解析函数名与现同步实现逐一对齐:`parseWorkspaces`/`parseProjectPage`/`parseProjectTypes`/`parseStructNodes`/`parseDsPage`/`parseDynamicForm`/`parseExceptions`(见现 `ApiProjectRepository.cpp`)。URL/body 构造原样搬,不重写。 + +- [ ] **Step 4: 构建(异步方法离线不可单测真实端点,验证编译/链接/moc)** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` +Expected: 链接通过。 +> 真实端点行为由 Task A6 手动验证覆盖(与现 ApiProjectRepository 同:无离线单测)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/data/repo/IAsyncProjectRepository.hpp src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp +git commit -m "feat(data): ApiProjectRepository 实现 IAsyncProjectRepository(9方法,Async后缀,薄封装,新旧并存)" +``` + +--- + +## Task A4: controller — `WorkbenchNavController` 异步化(abort-and-replace + ApiChain/ApiBatch 编排 + 删 busy_/drain)+ 单测(TDD) + +> 本 Task 体量大(控制器是难点)。**Step 1(写控制器单测,先确立目标行为)→ Step 2/3(改控制器头/源)→ Step 4(跑测试)必须连续完成、同一提交**(接口消费切换是原子改动,见决策 5)。同步 `IProjectRepository` 路径在本 Task 后无消费者(A6 删除)。 + +**Files:** +- Create: `tests/controller/test_workbench_nav_controller.cpp` +- Modify: `src/controller/WorkbenchNavController.hpp`, `src/controller/WorkbenchNavController.cpp`, `tests/CMakeLists.txt` + +- [ ] **Step 1: 写控制器单测 `tests/controller/test_workbench_nav_controller.cpp`**(Stub 异步 repo + QSignalSpy) + +桩策略:定义一个 `StubNavRequest`(不声明 Q_OBJECT,发射继承自 `NavRequest` 的 `done/failed`、override `abort` 记录),`StubAsyncRepo` 实现 `IAsyncProjectRepository`,每方法 `new StubNavRequest` 并记录最近一个(按方法名分桶),测试可手动 `fireDone(QVariant)`/`fireFailed()`/查 `aborted`。 + +```cpp +#include +#include +#include +#include "WorkbenchNavController.hpp" +#include "repo/IAsyncProjectRepository.hpp" +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" +using namespace geopro; + +namespace { +struct StubNavRequest : data::NavRequest { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone(const QVariant& v) { emit done(v); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; +struct StubAsyncRepo : data::IAsyncProjectRepository { + StubNavRequest* lastWorkspaces = nullptr; + StubNavRequest* lastProjects = nullptr; + StubNavRequest* lastStructure = nullptr; + StubNavRequest* lastExceptions = nullptr; + // ...其余按需 + data::NavRequest* listWorkspacesAsync() override { return lastWorkspaces = new StubNavRequest; } + data::NavRequest* pageProjectsAsync(const std::string&, const std::string&, int, int) override { + return lastProjects = new StubNavRequest; } + data::NavRequest* loadStructureAsync(const std::string&) override { + return lastStructure = new StubNavRequest; } + data::NavRequest* loadExceptionsByTmAsync(const std::string&) override { + return lastExceptions = new StubNavRequest; } + data::NavRequest* switchWorkspaceAsync(const std::string&) override { return new StubNavRequest; } + data::NavRequest* listProjectTypesAsync() override { return new StubNavRequest; } + data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int, int) override { + return new StubNavRequest; } + data::NavRequest* loadObjectDetailAsync(const std::string&, int) override { return new StubNavRequest; } + data::NavRequest* loadDatasetFormAsync(const std::string&) override { return new StubNavRequest; } +}; +} + +// start() 依赖链:workspaces → projects → structure,逐级 emit 既有信号。 +TEST(WorkbenchNavController, StartChainEmitsWorkspacesThenProjectsThenStructure) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy wsSpy(&c, &controller::WorkbenchNavController::workspacesLoaded); + QSignalSpy psSpy(&c, &controller::WorkbenchNavController::projectsLoaded); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.start(); + repo.lastWorkspaces->fireDone(QVariant::fromValue(std::vector{{"w1","WS",2,true}})); + EXPECT_EQ(wsSpy.count(), 1); + repo.lastProjects->fireDone(QVariant::fromValue(data::ProjectListPage{{{"p1","P1"}}, 1})); + EXPECT_EQ(psSpy.count(), 1); + repo.lastStructure->fireDone(QVariant::fromValue(std::vector{})); + EXPECT_EQ(stSpy.count(), 1); +} + +// busyChanged 反映在飞:发起→true,最后完成→false。 +TEST(WorkbenchNavController, BusyChangedReflectsInflight) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + ASSERT_GE(busySpy.count(), 1); + EXPECT_TRUE(busySpy.takeFirst().at(0).toBool()); // 首次 true + repo.lastWorkspaces->fireDone(QVariant::fromValue(std::vector{{"w1","WS",2,true}})); + repo.lastProjects->fireDone(QVariant::fromValue(data::ProjectListPage{{{"p1","P1"}}, 1})); + repo.lastStructure->fireDone(QVariant::fromValue(std::vector{})); + EXPECT_FALSE(busySpy.last().at(0).toBool()); // 末尾 false +} + +// setCheckedTms:新勾选 abort 旧异常批(以最后一次为准)。 +TEST(WorkbenchNavController, SetCheckedTmsAbortsPreviousBatch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + c.setCheckedTms({"tmA"}); + StubNavRequest* a = repo.lastExceptions; + c.setCheckedTms({"tmB"}); // 覆盖 + EXPECT_TRUE(a->aborted); +} + +// 回灌防护:abort 后旧句柄迟到 done 被身份比对丢弃。 +TEST(WorkbenchNavController, DropsLateStructureAfterProjectSwitch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.switchProject("pA"); + StubNavRequest* a = repo.lastStructure; + c.switchProject("pB"); + StubNavRequest* b = repo.lastStructure; + a->fireDone(QVariant::fromValue(std::vector{})); // 旧 → 丢弃 + EXPECT_EQ(stSpy.count(), 0); + b->fireDone(QVariant::fromValue(std::vector{})); // 新 → 正常 + EXPECT_EQ(stSpy.count(), 1); +} +``` +> 实现者注:测试里 `Workspace{"w1","WS",2,true}` 字段顺序须对齐 `RepoTypes.hpp`(`id,name,ownerType,isCurrent`);`ProjectSummary`/`StructNode` 等聚合初始化按其声明顺序。`switchProject` 单请求路径用 `loadStructureAsync`。selectObject 的并发(ApiBatch)测试可加 `SelectObjectConcurrentBatch`(三 stub 全 done 后一次性 emit datasets/files/objectDetail)——列为补充用例。 + +- [ ] **Step 2: 改 `WorkbenchNavController.hpp`** + +要点:① 改依赖 `IAsyncProjectRepository&`;② 删 `busy_`/`checkedTmsPending_`/`pendingCheckedTms_`/`drainPendingCheckedTms`/`friend struct BusyGuard`;③ 加每路径 `QPointer` 在飞句柄成员 + `ApiChain`/`ApiBatch` 的 `QPointer`;④ 加 `emitBusyIfChanged()` 私有辅助 + `bool lastBusy_`;⑤ 信号面不变(`busyChanged` 语义重定义为「有在飞」)。 + +```cpp +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::net { class ApiChain; class ApiBatch; } +namespace geopro::data { class IAsyncProjectRepository; class NavRequest; } + +namespace geopro::controller { + +class WorkbenchNavController : public QObject { + Q_OBJECT +public: + explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr); + void start(); + QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } + +public slots: + void switchWorkspace(const QString& tenantId); + void switchProject(const QString& projectId); + void selectObject(const QString& objectId, int confType); + void setCheckedTms(const QStringList& tmObjectIds); + void selectDataset(const QString& dsObjectId); + void loadMoreData(); + void loadMoreFiles(); + +signals: + void busyChanged(bool busy); + void workspacesLoaded(const std::vector& list, const QString& currentId); + void projectsLoaded(const std::vector& list, const QString& currentId, int total); + void structureLoaded(const QString& projectName, const std::vector& nodes); + void datasetsLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); + void filesLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); + void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); + void exceptionTreeLoaded(const std::vector& groups, int exceptionCount); + void datasetDetailLoaded(const geopro::data::DynamicForm& form); + void loadFailed(const QString& stage, const QString& message); + +private: + void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged + bool anyInflight() const; // OR 所有在飞 QPointer + + data::IAsyncProjectRepository& repo_; + bool lastBusy_ = false; + + // 在飞句柄(QPointer 防悬垂;身份比对用): + QPointer startChain_; // start / switchWorkspace 依赖链 + QPointer structReq_; // switchProject + QPointer selectBatch_; // selectObject 三并发 + QPointer moreDataReq_; + QPointer moreFilesReq_; + QPointer datasetReq_; + QPointer exceptionsBatch_; // setCheckedTms + + std::vector lastProjects_; + std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; + std::string currentParentId_; + int currentParentConfType_ = 0; + std::vector lastStructNodes_; + std::map> tmExceptionCache_; + int dataPageNo_ = 0, filePageNo_ = 0, dataTotal_ = 0, fileTotal_ = 0; +}; + +} // namespace geopro::controller +``` + +- [ ] **Step 3: 改 `WorkbenchNavController.cpp`**(核心编排,分块写) + +设计要点(每块对照现同步实现 1:1 搬状态更新逻辑,只把「同步取值」换成「句柄 done lambda」): + +1. **`start()` / `switchWorkspace()` 用 `ApiChain`:** + - `start()`: abort 旧 `startChain_`;建 chain `{ listWorkspacesAsync 工厂, pageProjectsAsync 工厂, loadStructureAsync 工厂 }`。但 repo 是「一方法一请求」的 `NavRequest`,而 `ApiChain` 步骤要的是 `IApiCall*`。**桥接:** `ApiChain` 步骤工厂层级要 `IApiCall`,但导航链的「依赖」体现在「用上一步解析后的业务值(如 currentWorkspaceId、首个 projectId)构造下一请求」,而非透传 `ApiResponse`。两种实现路线: + - **路线 X(推荐,KISS):不用 `ApiChain` 串 repo 句柄,改用「NavRequest done lambda 里发起下一 NavRequest」的续延**——即在控制器内手写链:`listWorkspacesAsync().done → 算 currentId → pageProjectsAsync().done → 算首个 project → loadStructureAsync().done → emit`。每级 abort-and-replace 由 `startChain_` 改为一个「当前在飞 NavRequest」的 QPointer 链(用单一 `QPointer startStepReq_` 跟踪当前级,abort 它即 abort 整条链的当前级)。`aborted_` 闸门由 NavRequest 自身 + 控制器在每级 done 里比对 `sender()/捕获指针 == 当前级` 提供。 + - 路线 Y:`ApiChain`(透传 ApiResponse),但导航链需要业务解析后的值(不只是 raw response)构造下一请求——`ApiChain` 工厂入参是 `QList`,可在工厂内重新解析(DTO 解析幂等、廉价),可行但重复解析。 + - **采用路线 X**:`ApiChain` 原语**保留供登录用**(登录链透传 response 直接喂下一请求,天然契合);导航依赖链用「NavRequest 续延」在控制器内编排(导航需要业务值串联,续延更直观)。**因此 `startChain_` 成员改为 `QPointer startStepReq_`**(跟踪 start/switchWorkspace 链当前在飞级)。 + > 决策修订记录(self-review 复核):决策 1 说「导航依赖链用 ApiChain」,实测代码后修订为「导航依赖链用 NavRequest 续延(因需业务值串联)、ApiChain 专供登录(透传 response 串联)」。`ApiChain` 仍在 A0 落地并测试,Part B 登录使用。 + +2. **`switchProject()` 用单 `NavRequest`(`loadStructureAsync`):** abort 旧 `structReq_`;存新;done lambda 内身份比对 + 1:1 搬现状态更新(`currentProjectId_`/`currentProjectName_`/`currentCrsCode_` from `lastProjects_`、`tmExceptionCache_.clear()`、重置选中态)+ emit `structureLoaded`。 + +3. **`selectObject()` 用 `ApiBatch`(三并发:loadRows data + loadRows file + loadObjectDetail):** + - abort 旧 `selectBatch_`;用 `api`?——控制器没有 `ApiClient`,只有 repo。**桥接 ApiBatch 需要 `IApiCall*`,而 repo 给的是 `NavRequest`。** 解决:`selectObject` 不用 `ApiBatch`(它要 IApiCall);改用「三个独立 `NavRequest` + 控制器内计数汇聚」或顺序续延。**KISS 选择:** 保持现「顺序」语义但异步化——data done → file done → detail done → 全到齐前不算完成;或真并发——三 `NavRequest` 并行、各自 done,控制器维护 `remaining` 计数,全到齐后一次性 emit(与现 emit 三信号一致)。**推荐并发计数**(三请求独立,无依赖;缩短等待):成员加 `QPointer selDataReq_/selFileReq_/selDetailReq_` + `int selRemaining_`,三者 done 各自比对身份、填暂存、`--selRemaining_==0` 后 emit `datasetsLoaded`+`filesLoaded`+`objectDetailLoaded`。失败任一 → emit `loadFailed` + abort 其余。 + > 这复刻了 `ApiBatch` 的能力但作用于 `NavRequest`——是否值得为此再造?**判断:** `ApiBatch` 作用于 `IApiCall`,`selectObject` 需要的是「三个 typed NavRequest 的汇聚」。可以加一个轻量 `NavBatch`(汇聚 N 个 `NavRequest`,全 done → succeeded(QList);任一 failed → fail + abort)。**但 selectObject 是唯一的导航并发汇聚点**(setCheckedTms 见下,其实是「N 个相同类型请求 + 缓存」),YAGNI 下先用控制器内计数(~15 行);若 setCheckedTms 也要并发汇聚,则提炼 `NavBatch`。**本计划:selectObject 用控制器内三句柄计数(不新增原语)。** + +4. **`loadMoreData()` / `loadMoreFiles()` / `selectDataset()` 用单 `NavRequest`:** abort 旧、存新、done 身份比对 + emit(append=true / datasetDetailLoaded)。删 `currentParentId_.empty()` 之外的 busy 守卫。 + +5. **`setCheckedTms()` 异步化(去 busy/pending/drain):** + - abort 旧 `exceptionsBatch_`(或续延句柄)。 + - 命中缓存的 TM 直接组装;未命中的 N 个 `loadExceptionsByTmAsync` **并发**、计数汇聚(同 selectObject 计数模式),全到齐后写缓存 + `groupExceptionsByConsortium` 组装 + emit `exceptionTreeLoaded`。 + - **以最后一次勾选为准**:新 `setCheckedTms` abort 旧的在飞集(abort-and-replace),无需 pending 重放。 + - 若全部命中缓存(无在飞请求):同步组装后直接 emit(busyChanged 不抖动)。 + +6. **`busyChanged`:** 每次「发起请求后」与「句柄 done/failed 清空后」调 `emitBusyIfChanged()`: +```cpp +bool WorkbenchNavController::anyInflight() const { + return startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || + moreDataReq_ || moreFilesReq_ || datasetReq_ || !checkedInflight_.isEmpty(); +} +void WorkbenchNavController::emitBusyIfChanged() { + const bool now = anyInflight(); + if (now != lastBusy_) { lastBusy_ = now; emit busyChanged(now); } +} +``` + +> 实现者:本 Step 是全计划最大改动。建议在 subagent 内分 6 个子提交(start链 / switchProject / selectObject / loadMore+selectDataset / setCheckedTms / busyChanged 收口),但**首个能编译通过的提交必须已切换 ctor 到 `IAsyncProjectRepository`**(否则与 main.cpp 装配不一致)。若分子提交导致中间不可编译,则合并为单提交(接口消费切换原子性,决策 5)。 + +- [ ] **Step 4: 改 `WorkbenchNavController.cpp` include + 注册测试 + 跑** + +cpp 顶部 include 改为: +```cpp +#include "WorkbenchNavController.hpp" +#include "repo/IAsyncProjectRepository.hpp" +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" +#include "ApiBatch.hpp" // 若 selectObject 最终用 NavBatch/ApiBatch;本计划用计数则不需要 +#include "dto/NavDto.hpp" +``` +`tests/CMakeLists.txt` controller 段(`controller/test_dataset_detail_controller.cpp` 后)加: +```cmake +target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) +``` +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` +Expected: `WorkbenchNavController.*` PASS(≥4 个);总 ~100/100。 +> ⚠️ 此时 main.cpp 仍把 `ApiProjectRepository`(实现两接口)作 `IProjectRepository&` 传给 `buildWorkbench`/`nav`——但 `nav` ctor 已改 `IAsyncProjectRepository&`。**Step 4 跑测试前不构建 app 也可**(dev-build 构建全部);若 app 编译失败,须同批做 A5(main.cpp 装配)。**建议 A4 与 A5 连续执行、A5 完成后再要求全绿。** + +- [ ] **Step 5: Commit(A4)** + +```bash +git add src/controller/WorkbenchNavController.hpp src/controller/WorkbenchNavController.cpp tests/controller/test_workbench_nav_controller.cpp tests/CMakeLists.txt +git commit -m "feat(controller): WorkbenchNavController 异步化(NavRequest续延+并发计数, abort-and-replace+身份比对, 删busy_/drain/BusyGuard, busyChanged=在飞存在性) + 单测" +``` + +--- + +## Task A5: app — 装配切换到异步导航仓储(与 A4 连续) + +**Files:** +- Modify: `src/app/main.cpp`(必要时 `ProjectListDialog` 的 repo 形参类型) + +- [ ] **Step 1: 核对 `nav` 构造与 `buildWorkbench` 形参** + +`main.cpp:858-859`:`ApiProjectRepository projectRepo(api); WorkbenchNavController nav(projectRepo);`——`projectRepo` 现同时是 `IProjectRepository` 与 `IAsyncProjectRepository`,`nav` ctor 取 `IAsyncProjectRepository&`,**引用绑定自动选对基类,无需改**。 +`buildWorkbench(...)` 形参 `geopro::data::IProjectRepository& projectRepo`(:195)仍用于 `ProjectListDialog`(:655 `new ProjectListDialog(projectRepo, ...)`)——`ProjectListDialog` 仍用同步接口(它自己分页查询)。**本阶段不动 ProjectListDialog**(同步接口 A6 才删;ProjectListDialog 是同步接口最后消费者之一,A6 一并迁移或保留同步)。 + +- [ ] **Step 2: 确认 nav 信号接线不变** + +`main.cpp:637-751` 所有 `nav` 信号接线(workspacesLoaded/projectsLoaded/structureLoaded/datasetsLoaded/filesLoaded/objectDetailLoaded/exceptionTreeLoaded/datasetDetailLoaded/loadFailed/busyChanged)**信号面未变,全部不动**。`nav.start()`(:885)不变。 + +- [ ] **Step 3: 构建 + 全量测试** + +Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` 然后 `... dev-test.ps1` +Expected: 全绿 ~100/100;构建出 `geopro_desktop.exe`。 + +- [ ] **Step 4: 手动验证(核心收益)** + +启动 app 登录后: +- 切换工作空间/项目 → 结构树加载期 **UI 不冻**。 +- 单击对象 → 数据/文件/详情**并发**加载,不冻。 +- 快速连点不同对象 → 旧请求 abort、无错位回灌、无崩溃。 +- 快速改勾选 TM → 异常树以最后一次勾选为准(旧批 abort)。 +- 加载期等待光标(busyChanged)正常出现/消失。 + +- [ ] **Step 5: Commit** + +```bash +git add src/app/main.cpp +git commit -m "feat(app): 装配 WorkbenchNavController 异步导航仓储(引用绑定切换 IAsyncProjectRepository)" +``` + +--- + +## Task A6: cleanup — 删除导航同步接口/方法(若无其他消费者) + +> 仅当 `ProjectListDialog` 等同步 `IProjectRepository` 消费者已迁移或确认保留时执行。**先 grep 确认消费者**。 + +**Files:** +- Modify: `src/data/repo/IProjectRepository.hpp`, `src/data/api/ApiProjectRepository.{hpp,cpp}`, `src/app/...`(ProjectListDialog 若迁移) + +- [ ] **Step 1: 审计同步 `IProjectRepository` 消费者** + +```bash +grep -rn "IProjectRepository\|RepoResult\|pageProjects\b\|listProjectTypes\b" src/ tests/ +``` +确认除 `ApiProjectRepository` 自身外的消费者(预期:`ProjectListDialog`、`buildWorkbench` 形参、可能的测试)。 + +- [ ] **Step 2: 决策保留 or 迁移 ProjectListDialog** + +若 `ProjectListDialog` 是唯一剩余同步消费者:评估迁移到 `pageProjectsAsync`/`listProjectTypesAsync`(它有自己的分页/类型查询 UI)。若改造成本高、收益低(弹窗短时阻塞可接受),**保留同步接口**并在本 Task 仅删「导航控制器已不用的同步方法」中确无消费者的——**KISS:若 ProjectListDialog 仍用同步,则不删 IProjectRepository,本 Task 跳过删除、仅记录技术债。** + +- [ ] **Step 3(仅当可删): 删同步接口 + 实现 + 继承** + +`ApiProjectRepository` 去掉 `public IProjectRepository`,删 9 个同步方法实现;删 `IProjectRepository.hpp`(若全无消费者);`buildWorkbench` 形参与 ProjectListDialog 改异步。 + +- [ ] **Step 4: 构建 + 全量测试 + Commit** + +```bash +git add -A +git commit -m "refactor(data): 删除导航同步 IProjectRepository(无消费者) / 或记录 ProjectListDialog 技术债" +``` + +--- + +# Part B — 登录(高层任务骨架) + +> 在 Part A 落地、`ApiChain` 经导航/单测验证稳定后展开。登录是**串行依赖链 + 共享会话 + 模态 `exec()`**,风险集中。建议另起 plan `2026-06-11-apiclient-async-login.md` 细化为 bite-sized,或在本文件续写。以下为高层任务骨架与关键设计。 + +## Task B0: net — `AuthService` 异步改形设计确认(无代码,确认信号面) + +- `fetchCaptcha()` → 异步:返回 `NavRequest*`(payload = `Captcha{codeId,code}` via QVariant,需 `Q_DECLARE_METATYPE(AuthService::Captcha)`)或专用 `CaptchaLoad` 句柄。单请求,用 `NavRequest`/单句柄即可。 +- `login(user,pwd,code,codeId)` → 异步:返回 `LoginLoad*` 句柄(done(token) / failed(msg)),内部用 **`ApiChain`** 编排:step1 = `verifyCodeCheck`(POST,工厂用入参 code/codeId),step2 = `login2`(POST,工厂内先 **RSA 加密**(可抛 → ApiChain 转 failed)再构造 body)。末步 succeeded → 取 `accessToken` → done(token)。 +- **共享会话不变量**:verify 与 login2 经同一 `ApiClient`(同一 NAM),JSESSIONID 串联——`ApiChain` 各 step 工厂调 `api_.postJsonAsync`,与现同步链一致(`ApiClient.hpp:24-27`)。 +- 失败谓词 = `code != 200 || !rawError.isEmpty()`(同 §7,复用)。错误文案:`msg` 空回退 `rawError`,RSA 失败给专门文案(在 login2 工厂 catch 内 throw 带文案的 exception,ApiChain 工厂 catch 写入 rawError)。 + +## Task B1: net — `AuthService` 异步实现 + 离线单测(TDD) + +- 新句柄 `CaptchaLoad`/`LoginLoad`(或复用 `NavRequest`);`AuthService` 改用 `getAsync/postJsonAsync` + `ApiChain`。 +- 离线单测:用 FakeApiCall 注入 ApiChain(B 需要把 ApiChain 暴露为可注入或测 AuthService 经 stub ApiClient——评估:AuthService 直接持 `ApiClient&`,离线测较难;可加 `IApiClient` 缝或测 ApiChain 层已覆盖链逻辑,AuthService 仅薄编排,留 live 测)。**推荐**:AuthService 薄编排不强求离线单测,链逻辑由 `test_api_chain.cpp` 覆盖,端到端由改造后的 live 测覆盖。 +- 删 `AuthService` 同步 `fetchCaptcha`/`login`(或保留过渡,B3 删)。 + +## Task B2: app — `LoginWindow` 接异步信号(不冻 + 可取消) + +- `refreshCaptcha()`:调异步 `fetchCaptcha`,连 done → 重绘图、failed → showError;发起期禁用刷新按钮。 +- `attemptLogin()`:调异步 `login`,连 done(token) → `token_=...; accept()`;failed → showError + refreshCaptcha;发起期禁用登录按钮 + 文案「登录中…」(**去掉 `repaint()` 同步 hack**,异步天然不冻)。 +- **取消语义**:窗口关闭/`reject()` 时 abort 在飞 captcha/login 句柄(退出契约);持 `QPointer<...Load>` 成员。 +- 模态 `exec()` 仍可用:异步信号在 `exec()` 的事件循环内正常派发(QDialog::exec 跑嵌套事件循环,句柄 finished 经其泵出)。`accept()` 退出循环。 + +## Task B3: net/app — 删除 `ApiClient` 同步 `get/postJson`(最终清理) + +- 登录是同步方法最后消费者(导航 Part A 已异步,详情试点已异步)。grep 确认无剩余 `\.get(\|\.postJson(` 同步调用。 +- 删 `ApiClient::get/postJson` + `Impl::await` + `QEventLoop` include。`ApiResponseParse::buildResponse` 仅异步用,保留。 + +## Task B4: tests — `test_auth.cpp` live 改造为异步等待 + +- 现 `test_auth.cpp` 用同步 `fetchCaptcha()`/`login()`。改为:连句柄信号 + `QSignalSpy spy; spy.wait(10000)` 等异步完成;断言 token。仍标记 live(联网)。 +- `QCoreApplication` 事件循环已有(现测试已构造)。 + +## Task B5: app — 手动验证 + 收口 + +- 登录期 UI 不冻、可关窗取消;验证码异步刷新;登录失败异步报错 + 刷新验证码。 +- 全量 dev-test 绿;记忆登录路径(recallValidToken)不受影响(不经登录窗)。 + +--- + +## Self-Review(覆盖核对 + 类型一致性) + +### 覆盖核对(对照 prompt 规划要点) + +- **要点 1(登录串行链原语)** → `ApiChain`(Task A0,离线单测 4 例);登录 verify→RSA→login2 用之(Task B0/B1);RSA 在 login2 工厂内同步完成、工厂可抛转 failed;共享会话经同一 ApiClient/NAM(B0 不变量)。✓ +- **要点 2(导航分类)** → 决策 2 表:start/switchWorkspace=依赖链、switchProject/loadMore*/selectDataset=单请求、selectObject/setCheckedTms=可并发。Task A4 分别落地。✓ +- **要点 3(busy_/drain 演化)** → 决策 4:删 busy_/BusyGuard/drain/pending/friend;busyChanged 重定义为「在飞存在性」;setCheckedTms「最后一次为准」由 abort-and-replace 承接(非 TBD)。验证用例 BusyChangedReflectsInflight / SetCheckedTmsAbortsPreviousBatch。✓ +- **要点 4(RepoResult 异步化)** → 决策 3:单非模板 `NavRequest`(QVariant payload)+ `ApiNavRequest`,控制类爆炸;抽象基+Api实现+stub 可测(Task A1 单测)。与详情 ChartLoad/GridLoad 风格差异已说明理由。✓ +- **要点 5(测试)** → 复用 `tests/net/FakeApiCall.hpp`;新增 test_api_chain / test_nav_request / test_workbench_nav_controller(导航控制器原无单测);test_auth.cpp live 改造(Task B4)。✓ +- **要点 6(迁移顺序/可构建性)** → 决策 5 + Task A3 出路 2(Async 后缀,新旧并存,每步绿)+ A4/A5 连续/原子提交标注。✓ +- **要点 7(构建/测试命令)** → header 给出 dev-build/dev-test、LNK1104 处理、dev-test 不构建须先 dev-build、基线 89/89。✓ +- **要点 8(阶段/拆 plan)** → 顶部「阶段划分」:Part A 全 bite-sized、Part B 骨架;推荐拆两份 plan/两 PR;先导航(风险可分散)后登录(风险集中)。✓ + +### 关键修订(实测代码后对初始决策的修正,已在正文标注) + +- **决策 1 vs Task A4 路线 X:** 导航依赖链最终用「NavRequest 续延」而非 `ApiChain`(导航需业务解析值串联,续延更直观;ApiChain 工厂只透传 ApiResponse)。`ApiChain` 仍在 A0 落地+测试,**专供登录**(登录链透传 response 直接喂下一请求,天然契合)。此修订在 A4 Step 3 块 1 显式记录。 +- **selectObject/setCheckedTms 并发:** 不强行复用 `ApiBatch`(它作用于 IApiCall,而控制器只有 NavRequest),用控制器内「多 NavRequest + remaining 计数」(YAGNI,~15 行/路径);若未来更多并发汇聚点再提炼 `NavBatch`。在 A4 Step 3 块 3 记录判断。 + +### 类型一致性核对 + +- `ApiChain::StepFactory = function&)>`、`succeeded(QList)`/`failed(int,ApiResponse)` — 与 `ApiBatch` 信号面对称。✓ +- `NavRequest::done(QVariant)`/`failed(QString)`、`ApiNavRequest(IApiCall*, Parser=function, Predicate)` — 控制器 `qvariant_cast`。✓ +- `IAsyncProjectRepository` 9 方法均返回 `NavRequest*`,名带 `Async` 后缀(与同步 `IProjectRepository` 共存)。✓ +- 各 payload 类型 `Q_DECLARE_METATYPE`(NavLoads.hpp):`vector`/`ProjectListPage`/`vector`/`vector`/`DsPage`/`DynamicForm`/`vector`;`bool` 内置。同线程直连无需 `qRegisterMetaType`。✓ +- 控制器对外信号面(workspacesLoaded/.../loadFailed/busyChanged)**全程不变**,main.cpp 接线零改动(A5 Step 2)。✓ +- 解析函数名对齐现 `ApiProjectRepository.cpp`:parseWorkspaces/parseProjectPage/parseProjectTypes/parseStructNodes/parseDsPage/parseDynamicForm/parseExceptions。✓ +- 安全不变量(spec §5.0):ApiChain/ApiNavRequest 各持 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater。回归用例:ApiChain.AbortGate / NavRequest.AbortSuppressesLateDone / WorkbenchNavController.DropsLateStructureAfterProjectSwitch。✓ + +### 文件 < 800 行 / 函数 < 50 行核对 + +- `ApiChain.cpp`/`NavRequest.cpp` 均 < 60 行。`WorkbenchNavController.cpp` 改后预计 ~250 行(< 800);其最大函数 `setCheckedTms` 异步版需拆 helper(缓存命中组装 / 并发汇聚)保持 < 50 行——在 A4 Step 3 块 5 注明提炼 `assembleExceptionTree()` helper。✓ + +--- + +## Execution Handoff + +计划已保存 `docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md`。 + +- **Part A(导航)** 全 bite-sized 任务 A0–A6 可直接执行(推荐 subagent-driven,每 Task 一 subagent,A4/A5 连续)。 +- **Part B(登录)** 骨架 B0–B5——执行前先据骨架细化为 bite-sized(或另起 `2026-06-11-apiclient-async-login.md`)。 + +建议:先完整落地 Part A(一个 PR),验证导航全异步收益与 `ApiChain` 稳定,再启动 Part B。 diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md new file mode 100644 index 0000000..e9f332f --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md @@ -0,0 +1,202 @@ +# 数据集详情图:扩展到其余 dd 类型 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development` 执行(每个 bite-sized 任务派一个 subagent,TDD,频繁提交)。步骤用 `- [ ]`。 +> **铁律(项目记忆,务必遵守):** 任何对原版的不确定,**必须用 Playwright 实地学习原版**,禁止联想猜测;严格 **1:1 复刻**,不加原版没有的特性。**没有活样本的 dd 类型一律 BLOCKED,禁止凭想象写渲染代码。** + +**Goal:** 把「数据集详情图」从只支持 ERT 反演(`dd_inversion_data`)扩展到其余 dd 类型。先打通「控制器按 ddCode 走策略注册表」的分派骨架(未注册类型优雅降级「暂不支持」),再按"样本可得性"逐类落地:有活样本的(ERT 测量类、TEM/timeSensor)做完整复刻,无活样本的(dd_grid / 轨迹 / 测井 / GPR)只列调研占位 + 取样前置条件,BLOCKED。 + +**Architecture:** 沿用现有分层,不重造: +- 编排层 `DatasetDetailController`(现状对非 `dd_inversion_data` 直接拒绝)→ 改为持 `ChartStrategyRegistry`,按 ddCode 选策略;策略决定「拉哪些接口 + 用哪个 View 渲染」。 +- 异步数据层 `IAsyncDatasetRepository` + `ChartLoad`/`GridLoad` 句柄(`ApiBatch` 多请求聚合 + 注入解析函数)。每类新 dd 视形态新增句柄类型(如折线 `LineLoad`、图像 `ImageLoad`)或复用现有句柄。 +- DTO 解析层 `src/data/dto/`(纯函数,单测友好,用抓到的真实响应做夹具)。 +- 渲染层 `src/app/panels/chart/`:复用 QwtPlot(轴/交互/图例)+ VTK 算法(等值线几何)。按形态归类复用或新建 View:等值面类复用 `ContourPlotItem`/`GridDataChartView`;散点类复用 `ScatterPlotItem`/`RawDataChartView`;折线类(测井)新建 `LineChartView`;图像类(GPR)新建 `ImageChartView`。 +- 页面壳 `DatasetDetailPanel`(多 Tab) / `DatasetDetailPage`(单 ds 内部页签)。 + +**Tech Stack:** C++17、Qt6 Widgets、Qwt 6.2(`cmake/qwt.cmake`,目标名 `qwt`,头在 `external/qwt-src/src`)、VTK 9.6(仅算法层)、GoogleTest/CTest、vcpkg(仅非 Qt 依赖)。 + +**依据文档:** +- `docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(§2.4 后续 dd 类型、§5.3 策略框架、§3 原 web 分析方法)。 +- `docs/superpowers/HANDOFF-dataset-detail-chart.md`(技术栈/构建/已完成范围)。 +- `docs/apis/business_OpenAPI.json`(各 dd 类型取数接口,下文已核对路径/参数)。 + +**构建/测试命令(本机工具链,固定):** +- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` + - ⚠️ 若 `LNK1104 无法打开 geopro_desktop.exe`:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。 +- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 dev-build 再 dev-test)。**基线 89/89 绿。** + - 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=.*` +- 运行(视觉验收):`build/release/src/app/geopro_desktop.exe`(需登录)。 + +--- + +## 0. 现状核实(已读真实代码,勿臆测) + +**关键结论:策略框架已存在但未接入控制器。** 详情: + +- `src/app/panels/chart/IDatasetChartStrategy.hpp`:`IDatasetChartStrategy`(只有纯虚 `std::string ddCode()`)+ `ChartStrategyRegistry`(`add/find/supports`,`std::map`)。**已写好,但全仓只有单测引用它,控制器/main.cpp 从未构造/使用注册表。** +- `src/app/panels/chart/ErtInversionStrategy.hpp`:仅一个 stub —— `ddCode()` 返回 `"dd_inversion_data"`,**无任何 load/render 逻辑**。 +- `src/controller/DatasetDetailController.cpp`: + - `openDataset(dsId, ddCode)`:`if (ddCode != "dd_inversion_data") { emit loadFailed("暂不支持该数据类型的预览"); return; }` —— **硬编码**,未走注册表。 + - `loadGridData(dsId, ddCode)`:同样 `if (ddCode != "dd_inversion_data") return;` 硬编码。 +- `tests/app/test_chart_strategy_registry.cpp`:仅测 `add/find/supports/降级`,**未与控制器联动**。 +- `src/data/repo/IAsyncDatasetRepository.hpp`:`loadChartAsync`(scatter+色阶type1)/ `loadGridAsync`(rows+色阶type2+异常),均**写死 ERT 反演接口**(见 `ApiDatasetRepository.cpp`)。 +- ddCode 来源:`src/data/dto/NavDto.cpp:124` 从数据集列表项 `o["ddCode"]` 解析;`main.cpp:501` 双击时从 `kDsDdCodeRole` 取出传给 `openDataset`。**ddCode 是 API 给的字符串,源码里除 `dd_inversion_data` 外无其它 dd code 常量**(grep 到的 `dd_custom_command` 等是 CMake `add_custom_command` 等构建符号,非数据类型)。 + +**结论:本 plan 的 Phase 1 必须先「打通控制器→注册表分派」,这是所有后续 dd 类型的前置。** 现有 `IDatasetChartStrategy` 接口过窄(只有 ddCode),需扩展为能驱动「加载 + 渲染」。 + +--- + +## 1. 各 dd 类型:样本可得性 + 渲染归类 + 可推进/BLOCKED 矩阵 + +> 现实约束(spec §2.4):当前可访问租户**仅 ERT / TEM / GPR 三类**;**GPR 对象无数据、无测井数据样本**。多数 dd 类型无活样本可参照。 +> ddCode 列为推断(源码无常量),**Phase 0 须用 Playwright 抓数据集列表项的真实 `ddCode` 字段核对后回填本表**,禁止写死未经核对的 ddCode。 + +| 数据类型(菜单/spec) | 推断 ddCode(待 Phase 0 核对) | 取数接口(已核对 OpenAPI 路径/参数) | 渲染归类 / 复用 | 样本可得性 | 状态 | +|---|---|---|---|---|---| +| ERT 反演(已完成基线) | `dd_inversion_data` | `getErtRawDataScatterGraph/{id}`、`inversion/rows/{id}`、色阶 type1/2、`queryException/{id}` | 散点 + 等值面(已实现) | 有 | ✅ 已完成 | +| ERT 测量原始 | 待核对 | `GET dd/ert/measurement/scatter/graph?dsObjectId&vFieldCode`、`GET dd/ert/measurement/rows?dsObjectId` | 散点类 → 复用 `ScatterPlotItem`/`RawDataChartView`;rows 形态待 Phase 0 定 | ERT 租户在,**须 Phase 0 找到有数据对象** | 候选可推进(待 Phase 0 确认有样本) | +| ERT 测量/高密度(gr) | 待核对 | `GET dd/ert/measurement/gr/rows?dsObjectId` | 待 Phase 0 看形态(散点/伪剖面) | 同上 | 候选可推进(待 Phase 0 确认) | +| TEM 时序(设备时序) | 待核对 | `POST dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`)、`GET dd/ert/timeSensor/page` | 时序折线类 → 新建 `LineChartView`(x=时间,y=数值) | **TEM 租户在**,须 Phase 0 找到有数据对象 + 抓响应 | 候选可推进(待 Phase 0 确认有样本) | +| dd_grid(网格) | 待核对 | `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` | 等值面类 → 复用 `ContourPlotItem`/`GridDataChartView` | 当前租户**无样本** | 🚫 BLOCKED:待样本 | +| 轨迹 | 待核对 | `GET dd/ert/trajectory/rows?dsObjectId`、`GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` | 折线/路径类 → 待样本定(可能复用散点连线或新 `LineChartView`) | 当前租户**无样本** | 🚫 BLOCKED:待样本 | +| 测井(well logging) | 待核对(菜单「测井参数表」) | **OpenAPI 未见明确专用 rows 接口** —— Phase 0 须从原版抓真实请求确认接口 | 折线类 → 新建 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线) | **无测井数据样本** | 🚫 BLOCKED:待样本 + 待接口确认 | +| GPR(雷达剖面图像) | 待核对 | `GET dd/gpr/channel/image/{dsObjectId}`、`GET dd/gpr/channel/trace/spectrogram`、`GET dd/gpr/channel/querySegmentation` | 图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴) | **GPR 对象无数据** | 🚫 BLOCKED:待样本 | + +**矩阵小结:** 唯一确定有数据的是 `dd_inversion_data`(已完成)。其余全部需 Phase 0 实地探查;ERT 测量类与 TEM 是**最可能**找到样本的(租户在),但「是否有具体对象带数据」必须 Phase 0 验证后才解锁实现任务。 + +--- + +## 文件结构(新建/修改总览) + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/app/panels/chart/IDatasetChartStrategy.hpp` | 改 | 扩展接口:除 `ddCode()` 外,加描述「加载/渲染契约」的虚方法(详见 Task 1.1) | +| `src/app/panels/chart/ErtInversionStrategy.{hpp,cpp}` | 改 | 把 stub 实化为「ERT 反演策略」:声明它需要 chart+grid 加载、用散点/等值面视图 | +| `src/controller/DatasetDetailController.{hpp,cpp}` | 改 | 持 `ChartStrategyRegistry&`;`openDataset`/`loadGridData` 改为走注册表分派,未注册→`loadFailed("暂不支持…")` | +| `src/app/main.cpp` | 改 | 构造 `ChartStrategyRegistry`,注册 `ErtInversionStrategy`,注入控制器 | +| `tests/controller/test_dataset_detail_controller.cpp` | 改 | 加「未注册 ddCode 优雅降级 / 已注册走加载」用例 | +| `tests/app/test_chart_strategy_registry.cpp` | 改 | 已有降级用例;按接口扩展补充 | +| `docs/superpowers/sample-probe-other-dd-types.md` | 建(Phase 0 产出) | 各类 dd 的「样本可得性矩阵 + 渲染规格 + 真实 API 响应夹具索引」 | +| `tests/fixtures/dd/*.json` | 建(Phase 0 产出) | 抓到的真实响应,做 DTO 单测夹具(仅有样本的类型) | +| `src/data/dto/DatasetChartDto.{hpp,cpp}` | 改/拆 | 新增各类 dd 的 parse 函数(如 `parseMeasurementScatter`、`parseTimeSensorSeries`)。若文件超 ~400 行则按类型拆 `MeasurementDto.*` / `TimeSensorDto.*` | +| `src/data/api/ApiDatasetRepository.{hpp,cpp}` | 改 | 新增对应 `load*Async`(仅有样本类型)| +| `src/data/api/DatasetLoads.hpp` | 改 | 新增 `*Parts` 结构(仅有样本类型)| +| `src/data/api/DatasetLoadHandles.hpp` + `.cpp` | 改 | 视形态新增句柄类型(如 `LineLoad`/`ImageLoad`),或复用现有 | +| `src/app/panels/chart/LineChartView.{hpp,cpp}` | 建(仅当 TEM/测井解锁) | 折线视图(QwtPlot + QwtPlotCurve) | +| `src/app/panels/chart/ImageChartView.{hpp,cpp}` | 建(仅当 GPR 解锁) | 图像剖面视图 | +| `src/app/CMakeLists.txt` / `tests/CMakeLists.txt` | 改 | 注册新文件/测试 | + +--- + +## Phase 0:样本探查(必做,先于一切实现) + +> 目的:把矩阵里的「待核对/BLOCKED」逐一坐实。**不写任何渲染代码。**产出是「样本规格 + 真实响应夹具」,作为后续 TDD 的 RED 依据。 + +- [ ] **Task 0.1(核对 ddCode 真值)** 用 Playwright 登录原版 `http://tenant.geomative.cn`,进入 `#/projectSpace/datasetMange/datasetInfo`,遍历可见数据集,抓每个数据集列表项的 `ddCode` 字段(网络响应或前端状态)。把真实 ddCode 回填到本 plan §1 矩阵。→ verify:矩阵「推断 ddCode」列全部替换为实测值,无残留「待核对」。 +- [ ] **Task 0.2(逐类样本探查)** 对 ERT 测量原始 / ERT 测量gr / TEM / dd_grid / 轨迹 / 测井 / GPR 七类,逐一在原版找「有数据的对象」: + - 打开其详情页,截图渲染形态(散点?等值面?折线?图像?); + - 在 Network 抓其取数请求的**完整 URL(含 query/path 参数)+ 完整响应 JSON**; + - 记录坐标轴语义(x/y 单位、方向、是否等比)、色阶/图例有无、是否有异常叠加。 + - → verify:每类得出「有样本 / 无样本」结论,有样本的把响应存到 `tests/fixtures/dd/.json`,写进 `docs/superpowers/sample-probe-other-dd-types.md`。 +- [ ] **Task 0.3(产出规格文档)** 写 `docs/superpowers/sample-probe-other-dd-types.md`:每类一节,含「样本可得性 / 真实接口 / 响应结构 / 渲染规格 / 渲染归类 / 是否解锁」。无样本类明确标 **BLOCKED:待样本**,列「解锁前置条件」(如「需租户导入 GPR 数据 / 需测井样本数据集」)。→ verify:文档七类齐全,与 §1 矩阵一致。 +- [ ] **Task 0.4(提交)** `docs: dd 类型样本探查矩阵 + 真实响应夹具`。提交规格文档与 fixtures。 + +**Phase 0 决策门:** 完成后回到本 plan,把 §1 矩阵中实测有样本的类型从「候选」升为 Phase 2+ 的实现任务;无样本的保持 BLOCKED,不进入实现 Phase。 + +--- + +## Phase 1:打通策略分派骨架(前置,无须样本即可做) + +> 把控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`。**行为对 `dd_inversion_data` 必须零回归(仍正常出图),对未注册类型仍降级「暂不支持」。** 这是所有后续 dd 类型的地基。 + +- [ ] **Task 1.1(扩展策略接口)** 先读 `IDatasetChartStrategy.hpp`、`DatasetDetailController.{hpp,cpp}` 真实签名。把 `IDatasetChartStrategy` 从「只有 `ddCode()`」扩展为能表达「该类型支持哪些加载阶段」的最小契约,**避免过度设计(YAGNI)**。建议最小形: + - 保留 `std::string ddCode() const`。 + - 加 `bool hasGridPhase() const`(ERT 反演=true;纯散点/折线/图像类=false)—— 让控制器据此决定是否允许 `loadGridData`,替代当前对 `loadGridData` 的硬编码 ddCode 判断。 + - (渲染分派暂不进接口:现阶段 `chartReady`/`gridReady` 信号 + 现有 `DatasetDetailPage` 已驱动渲染;待新 View 真要接入时再在对应 Phase 扩展,不在 Phase 1 预先抽象。) + - 先改 `tests/app/test_chart_strategy_registry.cpp`:加「策略报告 hasGridPhase」的断言(RED)→ 改接口 + `ErtInversionStrategy`(GREEN)。 + - → verify:`dev-test` `ChartStrategyRegistry.*` 绿。 +- [ ] **Task 1.2(控制器走注册表 — openDataset)** 改 `DatasetDetailController`:构造函数加 `app::ChartStrategyRegistry& registry`(与现有 `IAsyncDatasetRepository&` 并列)。`openDataset`:把 `if (ddCode != "dd_inversion_data")` 改为 `if (!registry.supports(ddCode)) { emit loadFailed(dsId, "暂不支持该数据类型的预览"); return; }`,其余加载逻辑暂不变(仍走 `loadChartAsync`)。 + - 先改 `tests/controller/test_dataset_detail_controller.cpp`:用「空注册表 → openDataset 任意 ddCode → 收到 loadFailed」+「注册了 dd_inversion_data → 走加载(mock repo 的 loadChartAsync 被调用)」两个用例(RED)。读现有该测试看 mock repo/句柄如何桩(`tests/data/test_dataset_load_handles.cpp` 有句柄桩可参考)。 + - → verify:`DatasetDetailController.*` 测试绿。 +- [ ] **Task 1.3(控制器走注册表 — loadGridData)** `loadGridData`:把 `if (ddCode != "dd_inversion_data") return;` 改为「查策略,`!strategy || !strategy->hasGridPhase()` → return」。补单测:注册一个 `hasGridPhase()==false` 的 fake 策略 → `loadGridData` 不触发 `loadGridAsync`。→ verify:测试绿。 +- [ ] **Task 1.4(main.cpp 接线)** 在 `main.cpp` 构造 `geopro::app::ChartStrategyRegistry registry;`,`registry.add(std::make_unique());`,把 `registry` 注入 `DatasetDetailController detailCtrl(datasetRepo, registry);`。注意生命周期:registry 须比 detailCtrl 活得久(同作用域、registry 在前声明)。 + - → verify:`dev-build` 通过;启动 app,双击 ERT 反演 ds **零回归**(原数据散点 + 网格等值面 + 异常均正常出图);双击其它类型仍显示「暂不支持」。 +- [ ] **Task 1.5(提交)** `refactor(detail): 控制器按 ddCode 走 ChartStrategyRegistry 分派, 未注册优雅降级 (替代硬编码 dd_inversion_data)`。 + +--- + +## Phase 2:ERT 测量类(散点形态,仅当 Task 0.2 确认有样本才进行) + +> 🔓 解锁条件:Phase 0 确认 ERT 测量(`measurement/scatter/graph` 或 `measurement/rows`)有活样本且抓到真实响应。**未解锁则本 Phase 全部 BLOCKED,跳过。** +> 渲染归类:散点类 → **复用** `ScatterPlotItem`/`RawDataChartView`,不新建 View(DRY)。 +> 接口(已核对):`GET /business/dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`、`GET /business/dd/ert/measurement/rows?dsObjectId=`。注意:**dsObjectId 是 query 参数(≠ 反演的 path 参数)**,且 scatter 需 `vFieldCode`(Phase 0 抓其真实取值)。 + +- [ ] **Task 2.1(DTO 解析 + 单测)** 用 `tests/fixtures/dd/ert-measurement-scatter.json` 真实响应做夹具,先写 `tests/data/test_dataset_chart_dto.cpp` 新用例断言字段映射(RED)→ 在 `DatasetChartDto.cpp`(或新拆 `MeasurementDto.cpp`,若主文件超 ~400 行)实现 `parseMeasurementScatter(QJsonObject)`(GREEN)。映射须严格按真实响应字段,**不臆造字段名**。→ verify:DTO 测试绿。 +- [ ] **Task 2.2(色阶处置)** Phase 0 确认测量散点是否有独立色阶接口/type(反演散点用 `colorGradation/getDetail` type1)。若有,按真实 type 拉;若无色阶(纯散点),ColorBar 隐藏。补单测覆盖色阶解析或缺省。→ verify:测试绿。 +- [ ] **Task 2.3(异步句柄 + 仓储)** 复用 `ChartLoad`/`ChartParts`(结构相同则直接复用;字段不同则在 `DatasetLoads.hpp` 加 `MeasurementParts` + 句柄)。在 `ApiDatasetRepository` 加 `loadMeasurementChartAsync(dsId)`(`ApiBatch` 组 scatter[+色阶] 请求 + 注入解析)。注意 query 参数编码(参考现有 `enc()`)。补 `tests/data/test_dataset_load_handles.cpp` 用例。→ verify:测试绿。 +- [ ] **Task 2.4(策略 + 控制器分派)** 新建 `MeasurementStrategy`(`ddCode()` 用 Task 0.1 实测值,`hasGridPhase()` 按 rows 是否等值面而定)。`DatasetDetailController::openDataset` 据策略选择调 `loadMeasurementChartAsync`(若加载形态与反演不同,控制器按 ddCode/策略分支;保持函数 <50 行,必要时抽私有 helper)。`main.cpp` 注册该策略。补控制器单测。→ verify:测试绿。 +- [ ] **Task 2.5(接入视图 + 视觉验收)** `DatasetDetailPage`/`RawDataChartView` 接收 `chartReady` 渲染散点。启动 app 双击测量类 ds,**对照 Phase 0 截图逐项验收**(点形/色阶/轴/等比),截图发用户确认。→ verify:视觉等价。 +- [ ] **Task 2.6(rows 形态)** 若 Phase 0 显示 `measurement/rows` 是另一种展示(如伪剖面/列表),据规格归类(等值面→复用 `ContourPlotItem`;表格→另议),重复 2.1–2.5 节奏。**形态不明则 BLOCKED 待 Phase 0 规格。** +- [ ] **Task 2.7(提交)** 每 1–2 个 Task 一次原子提交,保持构建绿。`feat(detail): ERT 测量散点详情图 (DTO+仓储+策略+视图)`。 + +--- + +## Phase 3:TEM / 设备时序(折线形态,仅当 Task 0.2 确认有样本才进行) + +> 🔓 解锁条件:Phase 0 确认 TEM/timeSensor 有活样本且抓到响应。**未解锁 BLOCKED,跳过。** +> 渲染归类:时序折线类 → **新建** `LineChartView`(QwtPlot + `QwtPlotCurve`,x=时间,y=数值)。 +> 接口(已核对):`POST /business/dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`,Phase 0 抓真实 body 字段)、`GET dd/ert/timeSensor/page`。 + +- [ ] **Task 3.1(DTO 解析 + 单测)** 用真实响应夹具 `tests/fixtures/dd/tem-timesensor.json`,先写测试(RED)→ 实现 `parseTimeSensorSeries`(→ 时间数组 + 多通道数值序列模型;模型若无现成 core 类型,加最小 `core::TimeSeries`,YAGNI)。→ verify:测试绿。 +- [ ] **Task 3.2(异步句柄 + 仓储)** 因是 POST + 不同返回,加 `SeriesLoad`/`SeriesParts` 句柄(参照 `ApiChartLoad`/`ApiGridLoad` 写 `ApiSeriesLoad`)+ `loadTimeSensorAsync(dsId)`(`postJsonAsync` 组 body)。补句柄桩单测。→ verify:测试绿。 +- [ ] **Task 3.3(LineChartView + 组件测)** 新建 `src/app/panels/chart/LineChartView.{hpp,cpp}`:QwtPlot + 每通道一条 `QwtPlotCurve` + 图例 + 平移/缩放(复用 `LivePanner` 模式)。组件测:给定 series → 断言曲线条数/点数。→ verify:测试绿。 +- [ ] **Task 3.4(策略 + 控制器 + 页面接入)** `TimeSensorStrategy`(实测 ddCode,`hasGridPhase()==false`)。控制器据策略走 `loadTimeSensorAsync` → 新增 `seriesReady` 信号 → `DatasetDetailPage` 用 `LineChartView` 渲染(页内页签按类型选 View)。main.cpp 注册。补控制器单测。→ verify:测试绿 + 启动 app 双击 TEM ds,对照 Phase 0 截图验收。 +- [ ] **Task 3.5(提交)** `feat(detail): TEM 时序折线详情图 (LineChartView+DTO+仓储+策略)`。 + +--- + +## Phase 4(BLOCKED:待样本)—— dd_grid / 轨迹 / 测井 / GPR + +> 以下类型当前租户**无活样本**(spec §2.4 + Phase 0 复核)。**绝不规划凭想象的渲染任务**(违反 1:1 复刻原则)。本 Phase 只列「解锁前置条件 + 调研占位」,待样本到位后各自展开为类似 Phase 2/3 的 TDD 任务序列。 + +- [ ] **Task 4.1(dd_grid 网格)** 🚫 BLOCKED。 + - 解锁前置:租户内出现带数据的 dd_grid 对象,Phase 0 抓 `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` 真实响应 + 详情页截图。 + - 预归类:等值面类 → 复用 `ContourPlotItem`/`GridDataChartView`(与反演 rows 同构则可大量复用 `parseInversionGrid` 思路;**须实测响应字段是否一致再决定复用/新 parse**)。 + - 注意:grid/rows 带分页参数,须确认是否需多页拼接。 +- [ ] **Task 4.2(轨迹 trajectory)** 🚫 BLOCKED。 + - 解锁前置:带数据的轨迹对象,抓 `GET dd/ert/trajectory/rows?dsObjectId` + `GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` 响应 + 截图。 + - 预归类:路径/折线类 → 待样本定(散点连线 or `LineChartView`)。`frontCrsCode` 取值须 Phase 0 抓。 +- [ ] **Task 4.3(测井 well logging)** 🚫 BLOCKED(双重阻塞:无样本 + 接口未确认)。 + - 解锁前置:(a) 拿到测井数据样本;(b) **OpenAPI 未见明确测井 rows 接口** → Phase 0 须在原版「测井参数表」相关页面抓真实请求确认接口。 + - 预归类:折线类 → 复用 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线,按菜单「测井参数表」两种形态,实测后定)。 +- [ ] **Task 4.4(GPR 雷达剖面)** 🚫 BLOCKED。 + - 解锁前置:GPR 对象有数据(spec 明确当前 GPR 对象无数据),抓 `GET dd/gpr/channel/image/{dsObjectId}` 响应(确认返回是图片 URL / base64 / 像素数组)+ 详情页截图。 + - 预归类:图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴叠加)。返回形态决定加载方式(图片下载 vs JSON 像素)。 + - 参考相关接口:`dd/gpr/channel/trace/spectrogram`、`dd/gpr/channel/querySegmentation`、`radar/trajectory/ds/{dsObjectId}`。 + +--- + +## 任务顺序与可构建性 + +1. **Phase 0 先行**:无样本不写代码。Phase 0 是纯调研 + 夹具,零代码风险。 +2. **Phase 1 是地基**:策略分派打通后,每个新 dd 类型才能「注册即生效」。Phase 1 必须保证 ERT 反演零回归(已有 89/89 测试 + 视觉验收兜底)。 +3. **接口/控制器改形原子落地**:`DatasetDetailController` 构造函数签名变更(加 registry)+ `main.cpp` 接线**须在同一提交**完成(否则构建红)。参照详情图 v2 「Task5+6 合并」教训:跨文件签名变更不拆提交。 +4. **Phase 2/3 仅在 Phase 0 解锁后展开**;Phase 4 永远 BLOCKED 直到样本到位。 +5. **每 1–2 个 bite-sized Task 一次提交**,每次提交前 `dev-test` 必须绿。 + +## 范围边界 + +- 本 plan 仅「详情图渲染扩展」。**不含**工具条编辑功能(白化/滤波/色阶配置/异常框注/自动标注/网格化/另存为/导出/描述富文本/大视图全屏)—— 另案(spec §2.3)。 +- 不改中央 2D/3D VTK 地图视图。 +- 不改 ApiClient 异步机制(已单独立项,详情详情链路已异步)。 + +--- + +## Self-Review(写完计划的自查结论) + +- **核实优先**:所有接口路径/参数(query vs path、POST body schema)均已对 `docs/apis/business_OpenAPI.json` 核对(见 §1 表),未臆造。ddCode 除 `dd_inversion_data` 外源码无常量,已明确标注「待 Phase 0 实测核对」而非编造。 +- **关键现状坐实**:已读 `DatasetDetailController.cpp` 确认控制器硬编码 `dd_inversion_data`、未用注册表;`ErtInversionStrategy` 是空 stub。Phase 1「打通分派」据此设计,是真实缺口而非假想。 +- **诚实对待样本约束**:唯一确定有样本的是已完成的反演;其余全部置于 Phase 0 探查门之后。无样本类(dd_grid/轨迹/测井/GPR)一律 BLOCKED + 解锁前置条件,**未规划任何凭想象的渲染任务**,符合 1:1 复刻铁律。 +- **DRY/复用**:散点复用 `ScatterPlotItem`、等值面复用 `ContourPlotItem`、异步复用 `ApiBatch`/句柄模式;仅折线(TEM/测井)与图像(GPR)两种新形态新建 View,且各自有样本/解锁条件兜底。 +- **可构建性**:标注了「构造函数签名变更 + main.cpp 接线须同提交」的原子落地约束,避免中间态构建红。 +- **TDD 无占位符**:每个可推进 Task 给出确切文件路径、RED→GREEN 顺序、verify 命令;BLOCKED Task 给出明确解锁前置条件而非空泛描述。 +- **风险点**:(1) Phase 0 可能发现 ERT 测量/TEM 也无具体带数据对象 → 则 Phase 2/3 同样 BLOCKED,plan 仍成立(Phase 1 独立有价值)。(2) `IDatasetChartStrategy` 接口扩展刻意保守(只加 `hasGridPhase()`),避免为未解锁类型过度抽象;待新 View 真接入时再演进。 From 22a7f2339e0223bde0927db9f250fe1a5b28e7d9 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 07:36:50 +0800 Subject: [PATCH 56/74] =?UTF-8?q?feat(net):=20ApiChain=20=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E4=BE=9D=E8=B5=96=E9=93=BE=E5=8E=9F=E8=AF=AD(fail-fas?= =?UTF-8?q?t+abort=E9=97=B8=E9=97=A8+=E5=B7=A5=E5=8E=82=E5=8F=AF=E6=8A=9B)?= =?UTF-8?q?=20+=20=E7=A6=BB=E7=BA=BF=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiChain.cpp | 53 +++++++++++++++++++++++++++ src/net/ApiChain.hpp | 35 ++++++++++++++++++ src/net/CMakeLists.txt | 1 + tests/CMakeLists.txt | 2 ++ tests/net/test_api_chain.cpp | 70 ++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 src/net/ApiChain.cpp create mode 100644 src/net/ApiChain.hpp create mode 100644 tests/net/test_api_chain.cpp diff --git a/src/net/ApiChain.cpp b/src/net/ApiChain.cpp new file mode 100644 index 0000000..fa8909f --- /dev/null +++ b/src/net/ApiChain.cpp @@ -0,0 +1,53 @@ +#include "ApiChain.hpp" +#include + +namespace geopro::net { + +ApiChain::ApiChain(QList steps, Predicate isFailure, QObject* parent) + : QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) { + Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded) + Q_ASSERT(isFailure_); + runNext(); +} + +void ApiChain::runNext() { + if (aborted_) return; + if (index_ >= steps_.size()) { // 全部完成 + emit succeeded(responses_); + deleteLater(); + return; + } + IApiCall* call = nullptr; + try { + call = steps_[index_](responses_); // 工厂可抛(如 RSA 失败):转 failed + } catch (const std::exception& e) { + ApiResponse r; + r.rawError = QString::fromUtf8(e.what()); // 保留原因;登录 RSA 文案在 AuthService 层包装 + aborted_ = true; + emit failed(index_, r); + deleteLater(); + return; + } + current_ = call; + QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + aborted_ = true; + emit failed(index_, resp); + deleteLater(); + return; + } + responses_.append(resp); + ++index_; + runNext(); // 链式推进 + }); +} + +void ApiChain::abort() { + if (aborted_) return; + aborted_ = true; + if (current_) current_->abort(); // abort 当前在飞步骤 + deleteLater(); +} + +} // namespace geopro::net diff --git a/src/net/ApiChain.hpp b/src/net/ApiChain.hpp new file mode 100644 index 0000000..8575b87 --- /dev/null +++ b/src/net/ApiChain.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::net { + +// 顺序执行 N 个步骤(依赖链):每步工厂用既往响应构造下一 IApiCall(工厂可抛 std::exception)。 +// 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。 +// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。 +// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 +class ApiChain : public QObject { + Q_OBJECT +public: + // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。 + using StepFactory = std::function& prior)>; + using Predicate = std::function; + ApiChain(QList steps, Predicate isFailure, QObject* parent = nullptr); + void abort(); +signals: + void succeeded(const QList& responses); + void failed(int index, const geopro::net::ApiResponse& resp); +private: + void runNext(); // 构造并连接下一步(工厂抛出 → emit failed) + QList steps_; + Predicate isFailure_; + QList responses_; + QPointer current_; + int index_ = 0; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index 64c9341..d529f39 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(geopro_net STATIC IApiCall.cpp ApiCall.cpp ApiBatch.cpp + ApiChain.cpp AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 98ee36e..36c31c5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -50,6 +50,8 @@ target_sources(geopro_tests PRIVATE net/test_rsa.cpp) target_sources(geopro_tests PRIVATE net/test_auth.cpp) # ApiBatch 离线单测(QSignalSpy 需 Qt6::Test)。 target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) +# ApiChain 离线单测(顺序依赖链:顺序/失败短路/abort闸门/工厂抛异常)。 +target_sources(geopro_tests PRIVATE net/test_api_chain.cpp) target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 diff --git a/tests/net/test_api_chain.cpp b/tests/net/test_api_chain.cpp new file mode 100644 index 0000000..7c7f70a --- /dev/null +++ b/tests/net/test_api_chain.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include "ApiChain.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok(int v = 0) { ApiResponse r; r.code = 200; r.httpStatus = 200; r.data = QJsonObject{{"v", v}}; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} // namespace + +TEST(ApiChain, RunsStepsInOrderAndPassesPriorResponses) { + auto* s1 = new FakeApiCall; + auto* s2 = new FakeApiCall; + int seenPrior = -1; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList& prior) -> IApiCall* { + seenPrior = prior.size(); // 第二步能看到第一步响应 + return s2; + }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy okSpy(chain, &ApiChain::succeeded); + s1->fire(ok(11)); // 第一步完成 → 触发第二步工厂 + EXPECT_EQ(seenPrior, 1); + EXPECT_EQ(okSpy.count(), 0); // 还差第二步 + s2->fire(ok(22)); + EXPECT_EQ(okSpy.count(), 1); + const auto resps = okSpy.takeFirst().at(0).value>(); + EXPECT_EQ(resps.size(), 2); +} + +TEST(ApiChain, FailFastShortCircuitsRemainingSteps) { + auto* s1 = new FakeApiCall; + bool secondBuilt = false; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { secondBuilt = true; return new FakeApiCall; }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy failSpy(chain, &ApiChain::failed); + s1->fire(bad()); // 第一步失败 + EXPECT_EQ(failSpy.count(), 1); + EXPECT_FALSE(secondBuilt); // 后续步骤不再构造 +} + +TEST(ApiChain, AbortGateSuppressesLateSignals) { + auto* s1 = new FakeApiCall; + QList steps{[&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy okSpy(chain, &ApiChain::succeeded); + chain->abort(); + EXPECT_TRUE(s1->aborted); // 在飞步骤被 abort + s1->fire(ok()); // 迟到 + EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门 +} + +TEST(ApiChain, StepFactoryThrowBecomesFailed) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { throw std::runtime_error("rsa fail"); }}; + auto* chain = new ApiChain(steps, isFailure); + QSignalSpy failSpy(chain, &ApiChain::failed); + s1->fire(ok()); // 触发第二步工厂 → 抛 → failed + EXPECT_EQ(failSpy.count(), 1); +} From 4beb7a9523bd7f23e710ef8abadd906a14954fae Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 07:38:59 +0800 Subject: [PATCH 57/74] =?UTF-8?q?feat(data):=20NavRequest=20=E5=8D=95?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=BC=82=E6=AD=A5=E5=8F=A5=E6=9F=84(QVariant?= =?UTF-8?q?=20payload,=20abort=E9=97=B8=E9=97=A8)=20+=20=E5=85=83=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=A3=B0=E6=98=8E=20+=20=E7=A6=BB=E7=BA=BF=E5=8D=95?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 3 ++- src/data/api/NavLoads.hpp | 14 ++++++++++ src/data/api/NavRequest.cpp | 47 +++++++++++++++++++++++++++++++++ src/data/api/NavRequest.hpp | 39 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 ++ tests/data/test_nav_request.cpp | 45 +++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/data/api/NavLoads.hpp create mode 100644 src/data/api/NavRequest.cpp create mode 100644 src/data/api/NavRequest.hpp create mode 100644 tests/data/test_nav_request.cpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 9319112..86f09c5 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -7,7 +7,8 @@ add_library(geopro_data STATIC dto/DatasetChartDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp - api/DatasetLoadHandles.cpp) + api/DatasetLoadHandles.cpp + api/NavRequest.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) target_compile_features(geopro_data PUBLIC cxx_std_17) diff --git a/src/data/api/NavLoads.hpp b/src/data/api/NavLoads.hpp new file mode 100644 index 0000000..e4238d6 --- /dev/null +++ b/src/data/api/NavLoads.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include "repo/RepoTypes.hpp" + +// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE(无需 qRegisterMetaType)。 +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(geopro::data::ProjectListPage) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(geopro::data::DsPage) +Q_DECLARE_METATYPE(geopro::data::DynamicForm) +Q_DECLARE_METATYPE(std::vector) +// bool 已内置 QMetaType。 diff --git a/src/data/api/NavRequest.cpp b/src/data/api/NavRequest.cpp new file mode 100644 index 0000000..5b3eb57 --- /dev/null +++ b/src/data/api/NavRequest.cpp @@ -0,0 +1,47 @@ +#include "api/NavRequest.hpp" +#include + +namespace geopro::data { + +namespace { +QString reasonOf(const geopro::net::ApiResponse& r) { + return r.msg.isEmpty() ? r.rawError : r.msg; +} +} // namespace + +ApiNavRequest::ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, + QObject* parent) + : NavRequest(parent), call_(call), parse_(std::move(parse)), isFailure_(std::move(isFailure)) { + QObject::connect(call, &geopro::net::IApiCall::finished, this, + [this](const geopro::net::ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (isFailure_(resp)) { + emit failed(reasonOf(resp)); + deleteLater(); + return; + } + QVariant out; + try { + out = parse_(resp); // 仅解析在 try 内(下游 done 处理器抛出不误报) + } catch (const std::exception& e) { + emit failed(QString::fromUtf8(e.what())); + deleteLater(); + return; + } catch (...) { + emit failed(QStringLiteral("解析失败:未知异常")); + deleteLater(); + return; + } + emit done(out); + deleteLater(); + }); +} + +void ApiNavRequest::abort() { + if (aborted_) return; + aborted_ = true; + if (call_) call_->abort(); + deleteLater(); +} + +} // namespace geopro::data diff --git a/src/data/api/NavRequest.hpp b/src/data/api/NavRequest.hpp new file mode 100644 index 0000000..9859275 --- /dev/null +++ b/src/data/api/NavRequest.hpp @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include +#include "IApiCall.hpp" + +namespace geopro::data { + +// 单请求异步句柄(抽象基,可测试缝):payload 经 QVariant 承载,控制器侧 qvariant_cast 取出。 +class NavRequest : public QObject { + Q_OBJECT +public: + using QObject::QObject; + ~NavRequest() override = default; + virtual void abort() = 0; +signals: + void done(const QVariant& value); + void failed(const QString& message); +}; + +// Api 实现:包一个 IApiCall + 注入的解析器(ApiResponse → QVariant)+ 失败谓词。 +class ApiNavRequest : public NavRequest { + Q_OBJECT +public: + using Parser = std::function; + using Predicate = std::function; + ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, + QObject* parent = nullptr); // 接管 call + void abort() override; +private: + QPointer call_; + Parser parse_; + Predicate isFailure_; + bool aborted_ = false; +}; + +} // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 36c31c5..dd4d1e9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -39,6 +39,8 @@ target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) +# NavRequest 离线单测(QVariant payload: done/failed/abort 闸门)。 +target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package diff --git a/tests/data/test_nav_request.cpp b/tests/data/test_nav_request.cpp new file mode 100644 index 0000000..1beb902 --- /dev/null +++ b/tests/data/test_nav_request.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::data; +using geopro::net::ApiResponse; +using geopro::net::test::FakeApiCall; + +namespace { +ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } +ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; +} // namespace + +TEST(NavRequest, EmitsDoneWithParsedPayload) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, + [](const ApiResponse&) { return QVariant::fromValue(DsPage{{}, 42}); }, isFailure); + QSignalSpy doneSpy(req, &NavRequest::done); + call->fire(ok()); + ASSERT_EQ(doneSpy.count(), 1); + const auto page = qvariant_cast(doneSpy.takeFirst().at(0)); + EXPECT_EQ(page.total, 42); +} + +TEST(NavRequest, EmitsFailedOnBusinessError) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure); + QSignalSpy failSpy(req, &NavRequest::failed); + call->fire(bad()); + EXPECT_EQ(failSpy.count(), 1); +} + +TEST(NavRequest, AbortSuppressesLateDone) { + auto* call = new FakeApiCall; + auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure); + QSignalSpy doneSpy(req, &NavRequest::done); + req->abort(); + EXPECT_TRUE(call->aborted); + call->fire(ok()); + EXPECT_EQ(doneSpy.count(), 0); +} From 2ee1ccdb0fd603011db037ffae44bb0fc8982310 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 07:39:41 +0800 Subject: [PATCH 58/74] =?UTF-8?q?feat(data):=20IAsyncProjectRepository=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=AF=BC=E8=88=AA=E4=BB=93=E5=82=A8=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1(=E8=96=84=E5=B0=81=E8=A3=85,=E8=BF=94=E5=9B=9ENavRequ?= =?UTF-8?q?est,Async=E5=90=8E=E7=BC=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/repo/IAsyncProjectRepository.hpp | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/data/repo/IAsyncProjectRepository.hpp diff --git a/src/data/repo/IAsyncProjectRepository.hpp b/src/data/repo/IAsyncProjectRepository.hpp new file mode 100644 index 0000000..9f9ed26 --- /dev/null +++ b/src/data/repo/IAsyncProjectRepository.hpp @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace geopro::data { + +class NavRequest; + +// 导航异步仓储抽象(薄封装:一方法一请求,返回自管理句柄 emit done(QVariant)/failed(msg))。 +// 汇聚/链式编排由 WorkbenchNavController 负责(它知道完整序列与状态)。 +// 方法与同步 IProjectRepository 一一对应(加 Async 后缀消歧:同名不同返回类型不能同类共存); +// payload 类型见各方法注释(控制器 qvariant_cast)。 +class IAsyncProjectRepository { +public: + virtual ~IAsyncProjectRepository() = default; + virtual NavRequest* listWorkspacesAsync() = 0; // std::vector + virtual NavRequest* switchWorkspaceAsync(const std::string& tenantId) = 0; // bool(解析器内 setToken 副作用) + virtual NavRequest* pageProjectsAsync(const std::string& nameFilter, + const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage + virtual NavRequest* listProjectTypesAsync() = 0; // std::vector + virtual NavRequest* loadStructureAsync(const std::string& projectId) = 0; // std::vector + virtual NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) = 0; // DsPage + virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm + virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm + virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector +}; + +} // namespace geopro::data From 05f0bf3d4f4706da8cbb1ce190f636a4b44b5639 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 07:42:00 +0800 Subject: [PATCH 59/74] =?UTF-8?q?feat(data):=20ApiProjectRepository=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20IAsyncProjectRepository(9=E6=96=B9?= =?UTF-8?q?=E6=B3=95,Async=E5=90=8E=E7=BC=80,=E8=96=84=E5=B0=81=E8=A3=85,?= =?UTF-8?q?=E6=96=B0=E6=97=A7=E5=B9=B6=E5=AD=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/api/ApiProjectRepository.cpp | 101 ++++++++++++++++++++++++++ src/data/api/ApiProjectRepository.hpp | 22 +++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 1bd7b14..193863b 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -6,6 +6,8 @@ #include #include "ApiClient.hpp" +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" #include "dto/NavDto.hpp" namespace geopro::data { @@ -15,6 +17,9 @@ constexpr int kCodeSuccess = 200; bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; } +// 异步失败谓词(与同步 ok() 同口径:业务码非 200 或网络错误)。 +bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); } + std::string errorOf(const net::ApiResponse& r, const char* fallback) { if (!r.msg.isEmpty()) return r.msg.toStdString(); if (!r.rawError.isEmpty()) return r.rawError.toStdString(); @@ -119,4 +124,100 @@ RepoResult> ApiProjectRepository::loadExceptionsByTm( return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; } +// ── 异步实现(薄封装:endpoint/body/parse 与同步版逐一对齐;解析器在 try 内见 ApiNavRequest)── + +NavRequest* ApiProjectRepository::listWorkspacesAsync() { + auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list")); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::switchWorkspaceAsync(const std::string& tenantId) { + const QString path = + QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); + auto* call = api_.postJsonAsync(path, QJsonObject{}); + // 切换空间返回新 accessToken:必须重新注入(与同步版一致),后续请求才落到新空间。 + return new ApiNavRequest(call, [this](const net::ApiResponse& r) { + const QString token = r.data.value(QStringLiteral("accessToken")).toString(); + if (!token.isEmpty()) api_.setToken(token); + return QVariant::fromValue(true); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::pageProjectsAsync(const std::string& nameFilter, + const std::string& typeId, int pageNo, + int pageSize) { + QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), pageSize}}; + if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); + auto* call = api_.postJsonAsync(QStringLiteral("/business/my/profile/project/page"), body); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseProjectPage(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::listProjectTypesAsync() { + auto* call = api_.getAsync(QStringLiteral("/business/project/type/list")); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectId) { + const QString path = + QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, + const std::string& parentId, int parentConfType, + int classifyType, int pageNo) { + const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") + : QStringLiteral("/business/dsObject/data/page"); + const QJsonObject body{ + {QStringLiteral("projectId"), QString::fromStdString(projectId)}, + {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, + {QStringLiteral("structParentConfType"), parentConfType}, + {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), 5}}; + auto* call = api_.postJsonAsync(path, body); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDsPage(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadObjectDetailAsync(const std::string& objectId, int confType) { + const QString path = + (confType == 1) + ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) + : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDynamicForm(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadDatasetFormAsync(const std::string& dsObjectId) { + const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseDynamicForm(r.data)); + }, &isFailureA); +} + +NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmObjectId) { + const QString path = + QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); + auto* call = api_.getAsync(path); + return new ApiNavRequest(call, [](const net::ApiResponse& r) { + return QVariant::fromValue(dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray())); + }, &isFailureA); +} + } // namespace geopro::data diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index dd27b4b..1845e00 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -1,15 +1,20 @@ #pragma once #include "repo/IProjectRepository.hpp" +#include "repo/IAsyncProjectRepository.hpp" namespace geopro::net { class ApiClient; } namespace geopro::data { -// 用共享会话 ApiClient 实现导航仓储(同步阻塞)。token 由调用方注入 ApiClient。 -class ApiProjectRepository : public IProjectRepository { +class NavRequest; + +// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。 +// token 由调用方注入 ApiClient。 +class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository { public: explicit ApiProjectRepository(net::ApiClient& api); + // ── 同步(旧,A6 删除) ── RepoResult> listWorkspaces() override; RepoResult switchWorkspace(const std::string& tenantId) override; RepoResult pageProjects(const std::string& nameFilter, const std::string& typeId, @@ -22,6 +27,19 @@ public: RepoResult loadDatasetForm(const std::string& dsObjectId) override; RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; + // ── 异步(新) ── 返回 NavRequest*(薄封装,一方法一请求)。 + NavRequest* listWorkspacesAsync() override; + NavRequest* switchWorkspaceAsync(const std::string& tenantId) override; + NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId, + int pageNo, int pageSize) override; + NavRequest* listProjectTypesAsync() override; + NavRequest* loadStructureAsync(const std::string& projectId) override; + NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) override; + NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override; + NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override; + NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override; + private: net::ApiClient& api_; }; From b097fa6e5615983d12fc5add7116b52913659a29 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 07:51:35 +0800 Subject: [PATCH 60/74] =?UTF-8?q?feat(controller):=20WorkbenchNavControlle?= =?UTF-8?q?r=20=E5=BC=82=E6=AD=A5=E5=8C=96(NavRequest=E7=BB=AD=E5=BB=B6+?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=AE=A1=E6=95=B0,=20abort-and-replace+?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=AF=94=E5=AF=B9,=20=E5=88=A0busy=5F/drain/?= =?UTF-8?q?BusyGuard,=20busyChanged=3D=E5=9C=A8=E9=A3=9E=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=80=A7)=20+=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制器依赖切换到 IAsyncProjectRepository(异步句柄) - 删除 busy_/BusyGuard/drainPendingCheckedTms/checkedTmsPending_/pendingCheckedTms_/friend struct BusyGuard - start/switchWorkspace 用 NavRequest 续延依赖链(startStepReq_ 跟踪当前在飞级) - switchProject/loadMore*/selectDataset 单请求 + abort-and-replace + 身份比对 - selectObject 三并发(data/file/detail), 各自身份比对独立 emit - setCheckedTms 并发拉取未命中缓存项, 计数汇聚; 新勾选 abort 旧批(以最后一次为准); tmExceptionCache_ 命中不发请求 - busyChanged 由 anyInflight() 驱动(emitBusyIfChanged 去抖, 值变才发) - 析构 abortAll() 退出契约 - 对外信号面零改动, main.cpp 接线据引用绑定自动切换(无需改) - 新增 9 个控制器单测(依赖链/并发/abort-and-replace/busyChanged/缓存语义/回灌防护/失败路径) - 测试 96 -> 105 全绿 --- src/controller/WorkbenchNavController.cpp | 503 ++++++++++++------ src/controller/WorkbenchNavController.hpp | 51 +- tests/CMakeLists.txt | 1 + .../test_workbench_nav_controller.cpp | 201 +++++++ 4 files changed, 576 insertions(+), 180 deletions(-) create mode 100644 tests/controller/test_workbench_nav_controller.cpp diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 2fabdcb..e9435af 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,192 +1,387 @@ #include "WorkbenchNavController.hpp" -#include - +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" #include "dto/NavDto.hpp" +#include "repo/IAsyncProjectRepository.hpp" namespace geopro::controller { +using data::DsPage; +using data::DynamicForm; +using data::ExceptionRow; +using data::NavRequest; +using data::ProjectListPage; using data::ProjectSummary; +using data::StructNode; using data::Workspace; -WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) +WorkbenchNavController::WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} -// RAII:进入公共导航操作时置忙(驱动等待光标),任何返回路径都复位——保证 busyChanged 配平。 -// 命名(非匿名)以匹配 controller 的 friend 声明,从而在析构时排空挂起的勾选请求。 -struct BusyGuard { - WorkbenchNavController* self; - bool* busy; - BusyGuard(WorkbenchNavController* s, bool* b) : self(s), busy(b) { - *busy = true; - emit self->busyChanged(true); - } - ~BusyGuard() { - WorkbenchNavController* ctrl = self; // 取本地副本:lambda 不能捕获成员 - *busy = false; - emit ctrl->busyChanged(false); - // 触发源是延迟合并发射,可能落在嵌套事件循环里:用队列调用在调用栈/嵌套循环展开后再排空, - // 那时 busy_ 已可靠为 false,重放才会真正执行(lambda 捕获的 ctrl 生命周期与应用一致,安全)。 - if (ctrl->checkedTmsPending_) - QMetaObject::invokeMethod( - ctrl, [ctrl] { ctrl->drainPendingCheckedTms(); }, Qt::QueuedConnection); - } -}; +WorkbenchNavController::~WorkbenchNavController() { abortAll(); } -void WorkbenchNavController::start() { - if (busy_) return; - BusyGuard guard(this, &busy_); - const auto ws = repo_.listWorkspaces(); - if (!ws.ok) { - emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error)); - return; - } - QString cur; - for (const auto& w : ws.value) - if (w.isCurrent) cur = QString::fromStdString(w.id); - if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id); - currentWorkspaceId_ = cur.toStdString(); - emit workspacesLoaded(ws.value, cur); - loadProjectsAndStructure(); +bool WorkbenchNavController::anyInflight() const { + if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || + moreDataReq_ || moreFilesReq_ || datasetReq_) + return true; + for (const auto& h : checkedInflight_) + if (h) return true; + return false; } -void WorkbenchNavController::loadProjectsAndStructure() { - const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10 - if (!ps.ok) { - emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); - return; +void WorkbenchNavController::emitBusyIfChanged() { + const bool now = anyInflight(); + if (now != lastBusy_) { + lastBusy_ = now; + emit busyChanged(now); } - lastProjects_ = ps.value.rows; - tmExceptionCache_.clear(); - currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) - currentParentConfType_ = 0; - checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 - pendingCheckedTms_.clear(); - QString curP; - if (!ps.value.rows.empty()) { - const auto& first = ps.value.rows.front(); - curP = QString::fromStdString(first.id); - currentProjectId_ = first.id; - currentProjectName_ = first.name; - currentCrsCode_ = first.crsCode; - } else { - currentProjectId_.clear(); - currentProjectName_.clear(); - currentCrsCode_.clear(); - } - emit projectsLoaded(ps.value.rows, curP, ps.value.total); +} - if (curP.isEmpty()) { - lastStructNodes_.clear(); - emit structureLoaded(QString(), {}); // 暂无项目 → 空树 - return; - } - const auto st = repo_.loadStructure(currentProjectId_); - if (!st.ok) { - emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); - return; - } - lastStructNodes_ = st.value; - emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); +void WorkbenchNavController::abortAll() { + if (startStepReq_) startStepReq_->abort(); + if (structReq_) structReq_->abort(); + if (selDataReq_) selDataReq_->abort(); + if (selFileReq_) selFileReq_->abort(); + if (selDetailReq_) selDetailReq_->abort(); + if (moreDataReq_) moreDataReq_->abort(); + if (moreFilesReq_) moreFilesReq_->abort(); + if (datasetReq_) datasetReq_->abort(); + for (const auto& h : checkedInflight_) + if (h) h->abort(); + checkedInflight_.clear(); +} + +void WorkbenchNavController::resetSelectionState() { + tmExceptionCache_.clear(); + currentParentId_.clear(); + currentParentConfType_ = 0; +} + +// ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ── +// 用 NavRequest 续延(每级 done 内用业务值构造下一级),startStepReq_ 跟踪当前在飞级。 +// abort-and-replace:start/switchWorkspace 入口 abort 旧 startStepReq_ → 自然丢弃旧链迟到信号。 + +void WorkbenchNavController::start() { + if (startStepReq_) startStepReq_->abort(); + NavRequest* req = repo_.listWorkspacesAsync(); + startStepReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != startStepReq_) return; // §5.0 身份比对 + startStepReq_.clear(); + const auto ws = qvariant_cast>(v); + QString cur; + for (const auto& w : ws) + if (w.isCurrent) cur = QString::fromStdString(w.id); + if (cur.isEmpty() && !ws.empty()) cur = QString::fromStdString(ws.front().id); + currentWorkspaceId_ = cur.toStdString(); + emit workspacesLoaded(ws, cur); + runProjectsAndStructure(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("workspaces"), msg); + emitBusyIfChanged(); + }); } void WorkbenchNavController::switchWorkspace(const QString& tenantId) { - if (tenantId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto r = repo_.switchWorkspace(tenantId.toStdString()); - if (!r.ok) { - emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error)); - return; - } - currentWorkspaceId_ = tenantId.toStdString(); - loadProjectsAndStructure(); + if (tenantId.isEmpty()) return; + if (startStepReq_) startStepReq_->abort(); + NavRequest* req = repo_.switchWorkspaceAsync(tenantId.toStdString()); + startStepReq_ = req; + emitBusyIfChanged(); + const std::string tid = tenantId.toStdString(); + QObject::connect(req, &NavRequest::done, this, [this, req, tid](const QVariant&) { + if (req != startStepReq_) return; + startStepReq_.clear(); + currentWorkspaceId_ = tid; + runProjectsAndStructure(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("switchWorkspace"), msg); + emitBusyIfChanged(); + }); } +void WorkbenchNavController::runProjectsAndStructure() { + NavRequest* req = repo_.pageProjectsAsync(std::string(), std::string(), 1, 10); // 下拉首页 10 + startStepReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != startStepReq_) return; + startStepReq_.clear(); + const auto page = qvariant_cast(v); + lastProjects_ = page.rows; + resetSelectionState(); + QString curP; + if (!page.rows.empty()) { + const auto& first = page.rows.front(); + curP = QString::fromStdString(first.id); + currentProjectId_ = first.id; + currentProjectName_ = first.name; + currentCrsCode_ = first.crsCode; + } else { + currentProjectId_.clear(); + currentProjectName_.clear(); + currentCrsCode_.clear(); + } + emit projectsLoaded(page.rows, curP, page.total); + if (curP.isEmpty()) { + lastStructNodes_.clear(); + emit structureLoaded(QString(), {}); // 暂无项目 → 空树 + emitBusyIfChanged(); + return; + } + NavRequest* st = repo_.loadStructureAsync(currentProjectId_); + startStepReq_ = st; + emitBusyIfChanged(); + QObject::connect(st, &NavRequest::done, this, [this, st](const QVariant& sv) { + if (st != startStepReq_) return; + startStepReq_.clear(); + const auto nodes = qvariant_cast>(sv); + lastStructNodes_ = nodes; + emit structureLoaded(QString::fromStdString(currentProjectName_), nodes); + emitBusyIfChanged(); + }); + QObject::connect(st, &NavRequest::failed, this, [this, st](const QString& msg) { + if (st != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("structure"), msg); + emitBusyIfChanged(); + }); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("projects"), msg); + emitBusyIfChanged(); + }); +} + +// ── switchProject:单请求 loadStructure ── void WorkbenchNavController::switchProject(const QString& projectId) { - if (projectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); + if (projectId.isEmpty()) return; currentProjectId_ = projectId.toStdString(); for (const auto& p : lastProjects_) if (p.id == currentProjectId_) { currentProjectName_ = p.name; currentCrsCode_ = p.crsCode; } - const auto st = repo_.loadStructure(currentProjectId_); - if (!st.ok) { - emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); - return; - } - lastStructNodes_ = st.value; - tmExceptionCache_.clear(); - currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) - currentParentConfType_ = 0; - checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 - pendingCheckedTms_.clear(); - emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); + if (structReq_) structReq_->abort(); // abort-and-replace + NavRequest* req = repo_.loadStructureAsync(currentProjectId_); + structReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != structReq_) return; // §5.0 身份比对 + structReq_.clear(); + const auto nodes = qvariant_cast>(v); + lastStructNodes_ = nodes; + resetSelectionState(); + emit structureLoaded(QString::fromStdString(currentProjectName_), nodes); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != structReq_) return; + structReq_.clear(); + emit loadFailed(QStringLiteral("structure"), msg); + emitBusyIfChanged(); + }); } +// ── selectObject:三并发(data 行 / file 行 / 对象详情),各自身份比对、独立 emit ── void WorkbenchNavController::selectObject(const QString& objectId, int confType) { - if (objectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); + if (objectId.isEmpty()) return; + if (selDataReq_) selDataReq_->abort(); // abort-and-replace 三路 + if (selFileReq_) selFileReq_->abort(); + if (selDetailReq_) selDetailReq_->abort(); currentParentId_ = objectId.toStdString(); currentParentConfType_ = confType; const std::string pid = currentProjectId_; dataPageNo_ = 1; filePageNo_ = 1; - const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); - if (!d.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); - return; - } - dataTotal_ = d.value.total; - emit datasetsLoaded(objectId, d.value.rows, d.value.total, false); - const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_); - if (!f.ok) { - emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); - return; - } - fileTotal_ = f.value.total; - emit filesLoaded(objectId, f.value.rows, f.value.total, false); - const auto detail = repo_.loadObjectDetail(currentParentId_, confType); - if (!detail.ok) { - emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); - return; - } - emit objectDetailLoaded(objectId, detail.value); + NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, dataPageNo_); + selDataReq_ = dReq; + NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_); + selFileReq_ = fReq; + NavRequest* detReq = repo_.loadObjectDetailAsync(currentParentId_, confType); + selDetailReq_ = detReq; + emitBusyIfChanged(); + + QObject::connect(dReq, &NavRequest::done, this, [this, dReq, objectId](const QVariant& v) { + if (dReq != selDataReq_) return; + selDataReq_.clear(); + const auto page = qvariant_cast(v); + dataTotal_ = page.total; + emit datasetsLoaded(objectId, page.rows, page.total, false); + emitBusyIfChanged(); + }); + QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) { + if (dReq != selDataReq_) return; + selDataReq_.clear(); + emit loadFailed(QStringLiteral("datasets"), msg); + emitBusyIfChanged(); + }); + QObject::connect(fReq, &NavRequest::done, this, [this, fReq, objectId](const QVariant& v) { + if (fReq != selFileReq_) return; + selFileReq_.clear(); + const auto page = qvariant_cast(v); + fileTotal_ = page.total; + emit filesLoaded(objectId, page.rows, page.total, false); + emitBusyIfChanged(); + }); + QObject::connect(fReq, &NavRequest::failed, this, [this, fReq](const QString& msg) { + if (fReq != selFileReq_) return; + selFileReq_.clear(); + emit loadFailed(QStringLiteral("files"), msg); + emitBusyIfChanged(); + }); + QObject::connect(detReq, &NavRequest::done, this, [this, detReq, objectId](const QVariant& v) { + if (detReq != selDetailReq_) return; + selDetailReq_.clear(); + emit objectDetailLoaded(objectId, qvariant_cast(v)); + emitBusyIfChanged(); + }); + QObject::connect(detReq, &NavRequest::failed, this, [this, detReq](const QString& msg) { + if (detReq != selDetailReq_) return; + selDetailReq_.clear(); + emit loadFailed(QStringLiteral("objectDetail"), msg); + emitBusyIfChanged(); + }); } +// ── loadMoreData / loadMoreFiles:单请求,append=true ── void WorkbenchNavController::loadMoreData() { - if (currentParentId_.empty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto d = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); - if (!d.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); - return; - } - dataTotal_ = d.value.total; - emit datasetsLoaded(QString::fromStdString(currentParentId_), d.value.rows, d.value.total, true); + if (currentParentId_.empty()) return; + if (moreDataReq_) moreDataReq_->abort(); + NavRequest* req = + repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); + moreDataReq_ = req; + const QString parent = QString::fromStdString(currentParentId_); + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) { + if (req != moreDataReq_) return; + moreDataReq_.clear(); + const auto page = qvariant_cast(v); + dataTotal_ = page.total; + emit datasetsLoaded(parent, page.rows, page.total, true); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != moreDataReq_) return; + moreDataReq_.clear(); + emit loadFailed(QStringLiteral("datasets"), msg); + emitBusyIfChanged(); + }); } void WorkbenchNavController::loadMoreFiles() { - if (currentParentId_.empty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto f = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); - if (!f.ok) { - emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); - return; - } - fileTotal_ = f.value.total; - emit filesLoaded(QString::fromStdString(currentParentId_), f.value.rows, f.value.total, true); + if (currentParentId_.empty()) return; + if (moreFilesReq_) moreFilesReq_->abort(); + NavRequest* req = + repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); + moreFilesReq_ = req; + const QString parent = QString::fromStdString(currentParentId_); + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) { + if (req != moreFilesReq_) return; + moreFilesReq_.clear(); + const auto page = qvariant_cast(v); + fileTotal_ = page.total; + emit filesLoaded(parent, page.rows, page.total, true); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != moreFilesReq_) return; + moreFilesReq_.clear(); + emit loadFailed(QStringLiteral("files"), msg); + emitBusyIfChanged(); + }); } +// ── selectDataset:单请求 ── +void WorkbenchNavController::selectDataset(const QString& dsObjectId) { + if (dsObjectId.isEmpty()) return; + if (datasetReq_) datasetReq_->abort(); + NavRequest* req = repo_.loadDatasetFormAsync(dsObjectId.toStdString()); + datasetReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != datasetReq_) return; + datasetReq_.clear(); + emit datasetDetailLoaded(qvariant_cast(v)); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != datasetReq_) return; + datasetReq_.clear(); + emit loadFailed(QStringLiteral("datasetDetail"), msg); + emitBusyIfChanged(); + }); +} + +// ── setCheckedTms:未命中缓存项并发拉取,全到齐后组装;新勾选 abort 旧批(以最后一次为准)── void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { - if (busy_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里: - pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放 - checkedTmsPending_ = true; + for (const auto& h : checkedInflight_) // abort-and-replace 旧批 + if (h) h->abort(); + checkedInflight_.clear(); + + QStringList missing; + for (const QString& tmQ : tmObjectIds) { + const std::string tm = tmQ.toStdString(); + if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ); + } + if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动 + assembleAndEmitExceptionTree(tmObjectIds); + emitBusyIfChanged(); return; } - BusyGuard guard(this, &busy_); + + // 并发拉取未命中项;每个 done 写缓存、清自身槽,全到齐后组装。计数用 shared 计数器。 + auto remaining = std::make_shared(missing.size()); + auto failedFlag = std::make_shared(false); + for (const QString& tmQ : missing) { + const std::string tm = tmQ.toStdString(); + NavRequest* req = repo_.loadExceptionsByTmAsync(tm); + checkedInflight_.push_back(req); + QObject::connect(req, &NavRequest::done, this, + [this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) { + // 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。 + bool inCurrent = false; + for (const auto& h : checkedInflight_) + if (h == req) inCurrent = true; + if (!inCurrent) return; + tmExceptionCache_[tm] = qvariant_cast>(v); + if (--(*remaining) == 0 && !*failedFlag) { + checkedInflight_.clear(); + assembleAndEmitExceptionTree(tmObjectIds); + emitBusyIfChanged(); + } + }); + QObject::connect(req, &NavRequest::failed, this, + [this, req, remaining, failedFlag](const QString& msg) { + bool inCurrent = false; + for (const auto& h : checkedInflight_) + if (h == req) inCurrent = true; + if (!inCurrent) return; + if (*failedFlag) return; // 仅首个失败发一次 + *failedFlag = true; + for (const auto& h : checkedInflight_) // abort 其余在飞 + if (h && h != req) h->abort(); + checkedInflight_.clear(); + emit loadFailed(QStringLiteral("exceptions"), msg); + emitBusyIfChanged(); + }); + } + emitBusyIfChanged(); +} + +void WorkbenchNavController::assembleAndEmitExceptionTree(const QStringList& tmObjectIds) { auto nameOf = [this](const std::string& id) -> std::string { for (const auto& n : lastStructNodes_) if (n.id == id) return n.name; @@ -197,14 +392,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { for (const QString& tmQ : tmObjectIds) { const std::string tm = tmQ.toStdString(); auto it = tmExceptionCache_.find(tm); - if (it == tmExceptionCache_.end()) { - const auto ex = repo_.loadExceptionsByTm(tm); - if (!ex.ok) { - emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error)); - return; - } - it = tmExceptionCache_.emplace(tm, ex.value).first; - } + if (it == tmExceptionCache_.end()) continue; // 防御:理论上此时全命中 auto grouped = data::dto::groupExceptionsByConsortium(it->second); data::ObjectExceptionGroup g; g.objectId = tm; @@ -217,21 +405,4 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { emit exceptionTreeLoaded(groups, total); } -void WorkbenchNavController::drainPendingCheckedTms() { - if (busy_ || !checkedTmsPending_) return; - checkedTmsPending_ = false; // 先清标志再重放,避免重入自旋 - setCheckedTms(pendingCheckedTms_); // 此时 busy_=false,会正常执行 -} - -void WorkbenchNavController::selectDataset(const QString& dsObjectId) { - if (dsObjectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto form = repo_.loadDatasetForm(dsObjectId.toStdString()); - if (!form.ok) { - emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error)); - return; - } - emit datasetDetailLoaded(form.value); -} - } // namespace geopro::controller diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 136fe78..d2a9551 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -1,30 +1,39 @@ #pragma once #include +#include #include #include #include #include #include -#include "repo/IProjectRepository.hpp" +#include "repo/RepoTypes.hpp" + +namespace geopro::data { +class IAsyncProjectRepository; +class NavRequest; +} // namespace geopro::data namespace geopro::controller { -// 导航状态机:编排 IProjectRepository,持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。 +// 导航状态机:编排 IAsyncProjectRepository(异步句柄),持有当前 空间/项目 状态,经信号驱动 UI。 +// 不持有 widget。abort-and-replace + 句柄身份比对保证迟到信号被丢弃(spec §5.0)。 +// busyChanged 语义:「是否存在任一在飞句柄」(去抖:值变才发)。 class WorkbenchNavController : public QObject { Q_OBJECT public: - explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr); + explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr); + ~WorkbenchNavController() override; // 退出契约:abort 所有在飞句柄 - void start(); // 启动:拉空间 → 项目 → 结构 + void start(); // 启动:拉空间 → 项目 → 结构(依赖链) QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } public slots: void switchWorkspace(const QString& tenantId); void switchProject(const QString& projectId); - void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 - void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 + void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情(并发) + void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树(并发,带缓存) void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void loadMoreData(); void loadMoreFiles(); @@ -45,19 +54,33 @@ signals: void loadFailed(const QString& stage, const QString& message); private: - friend struct BusyGuard; // 允许在 guard 析构时排空挂起的勾选请求 - void loadProjectsAndStructure(); // start + switchWorkspace 共用 - void drainPendingCheckedTms(); // 空闲后重放最近一次被挂起的勾选集 + // start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。 + void runProjectsAndStructure(); + void abortAll(); // 退出/重置时 abort 所有在飞句柄 + void resetSelectionState(); // 切项目/工作空间重置选中态(spec §6) + void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged + bool anyInflight() const; // OR 所有在飞 QPointer / 集合 + void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树 + + data::IAsyncProjectRepository& repo_; + bool lastBusy_ = false; + + // 在飞句柄(QPointer 防悬垂;身份比对用): + QPointer startStepReq_; // start / switchWorkspace 依赖链当前在飞级 + QPointer structReq_; // switchProject + QPointer selDataReq_; // selectObject:data 行 + QPointer selFileReq_; // selectObject:file 行 + QPointer selDetailReq_; // selectObject:对象详情 + QPointer moreDataReq_; + QPointer moreFilesReq_; + QPointer datasetReq_; + std::vector> checkedInflight_; // setCheckedTms:未命中缓存的并发批 - data::IProjectRepository& repo_; - bool busy_ = false; - bool checkedTmsPending_ = false; - QStringList pendingCheckedTms_; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentParentId_; int currentParentConfType_ = 0; - std::vector lastStructNodes_; // tmId→name 解析 + std::vector lastStructNodes_; // tmId→name 解析 std::map> tmExceptionCache_; int dataPageNo_ = 0; int filePageNo_ = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dd4d1e9..4fb35c1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,7 @@ target_sources(geopro_tests PRIVATE # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) +target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) add_subdirectory(spike) # spike S3: banded contour 渲染验证 diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp new file mode 100644 index 0000000..5336266 --- /dev/null +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -0,0 +1,201 @@ +#include +#include +#include +#include + +#include "WorkbenchNavController.hpp" +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" +#include "repo/IAsyncProjectRepository.hpp" + +using namespace geopro; + +namespace { +// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::NavRequest 的 done/failed、override abort 记录。 +struct StubNavRequest : data::NavRequest { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone(const QVariant& v) { emit done(v); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; + +struct StubAsyncRepo : data::IAsyncProjectRepository { + StubNavRequest* lastWorkspaces = nullptr; + StubNavRequest* lastSwitchWs = nullptr; + StubNavRequest* lastProjects = nullptr; + StubNavRequest* lastStructure = nullptr; + StubNavRequest* lastData = nullptr; + StubNavRequest* lastFile = nullptr; + StubNavRequest* lastDetail = nullptr; + StubNavRequest* lastDataset = nullptr; + std::vector exceptions; // setCheckedTms 并发批 + + data::NavRequest* listWorkspacesAsync() override { return lastWorkspaces = new StubNavRequest; } + data::NavRequest* switchWorkspaceAsync(const std::string&) override { + return lastSwitchWs = new StubNavRequest; + } + data::NavRequest* pageProjectsAsync(const std::string&, const std::string&, int, int) override { + return lastProjects = new StubNavRequest; + } + data::NavRequest* listProjectTypesAsync() override { return new StubNavRequest; } + data::NavRequest* loadStructureAsync(const std::string&) override { + return lastStructure = new StubNavRequest; + } + data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType, + int) override { + auto* r = new StubNavRequest; + if (classifyType == 1) + lastFile = r; + else + lastData = r; + return r; + } + data::NavRequest* loadObjectDetailAsync(const std::string&, int) override { + return lastDetail = new StubNavRequest; + } + data::NavRequest* loadDatasetFormAsync(const std::string&) override { + return lastDataset = new StubNavRequest; + } + data::NavRequest* loadExceptionsByTmAsync(const std::string&) override { + auto* r = new StubNavRequest; + exceptions.push_back(r); + return r; + } +}; + +QVariant wsVar() { + return QVariant::fromValue(std::vector{{"w1", "WS", 2, true}}); +} +QVariant pageVar() { + data::ProjectSummary p; + p.id = "p1"; + p.name = "P1"; + return QVariant::fromValue(data::ProjectListPage{{p}, 1}); +} +QVariant emptyPageVar() { return QVariant::fromValue(data::ProjectListPage{{}, 0}); } +QVariant nodesVar() { return QVariant::fromValue(std::vector{}); } +QVariant dsPageVar() { return QVariant::fromValue(data::DsPage{{}, 0}); } +QVariant formVar() { return QVariant::fromValue(data::DynamicForm{}); } +QVariant exVar() { return QVariant::fromValue(std::vector{}); } +} // namespace + +// start() 依赖链:workspaces → projects → structure,逐级 emit 既有信号。 +TEST(WorkbenchNavController, StartChainEmitsWorkspacesThenProjectsThenStructure) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy wsSpy(&c, &controller::WorkbenchNavController::workspacesLoaded); + QSignalSpy psSpy(&c, &controller::WorkbenchNavController::projectsLoaded); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.start(); + repo.lastWorkspaces->fireDone(wsVar()); + EXPECT_EQ(wsSpy.count(), 1); + repo.lastProjects->fireDone(pageVar()); + EXPECT_EQ(psSpy.count(), 1); + repo.lastStructure->fireDone(nodesVar()); + EXPECT_EQ(stSpy.count(), 1); +} + +// busyChanged 反映在飞:发起→true,最后完成→false。 +TEST(WorkbenchNavController, BusyChangedReflectsInflight) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + ASSERT_GE(busySpy.count(), 1); + EXPECT_TRUE(busySpy.takeFirst().at(0).toBool()); // 首次 true + repo.lastWorkspaces->fireDone(wsVar()); + repo.lastProjects->fireDone(pageVar()); + repo.lastStructure->fireDone(nodesVar()); + EXPECT_FALSE(busySpy.last().at(0).toBool()); // 末尾 false +} + +// 空项目链:projects 空 → structure 发空树 → busy 复位(不发结构请求)。 +TEST(WorkbenchNavController, StartWithNoProjectsEmitsEmptyStructureAndClearsBusy) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + repo.lastWorkspaces->fireDone(wsVar()); + repo.lastProjects->fireDone(emptyPageVar()); + EXPECT_EQ(stSpy.count(), 1); + EXPECT_FALSE(busySpy.last().at(0).toBool()); +} + +// setCheckedTms:新勾选 abort 旧异常批(以最后一次为准)。 +TEST(WorkbenchNavController, SetCheckedTmsAbortsPreviousBatch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + c.setCheckedTms({"tmA"}); + StubNavRequest* a = repo.exceptions.back(); + c.setCheckedTms({"tmB"}); // 覆盖 + EXPECT_TRUE(a->aborted); +} + +// setCheckedTms:全命中缓存 → 不发新请求、直接组装 emit。 +TEST(WorkbenchNavController, SetCheckedTmsUsesCacheWithoutRequest) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy exSpy(&c, &controller::WorkbenchNavController::exceptionTreeLoaded); + c.setCheckedTms({"tmA"}); // 首次未命中 → 发请求 + ASSERT_EQ(repo.exceptions.size(), 1u); + repo.exceptions.back()->fireDone(exVar()); // 写缓存 + emit + EXPECT_EQ(exSpy.count(), 1); + c.setCheckedTms({"tmA"}); // 第二次命中缓存 → 不发新请求 + EXPECT_EQ(repo.exceptions.size(), 1u); + EXPECT_EQ(exSpy.count(), 2); // 仍 emit +} + +// 回灌防护:abort 后旧句柄迟到 done 被身份比对丢弃。 +TEST(WorkbenchNavController, DropsLateStructureAfterProjectSwitch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.switchProject("pA"); + StubNavRequest* a = repo.lastStructure; + c.switchProject("pB"); + StubNavRequest* b = repo.lastStructure; + EXPECT_TRUE(a->aborted); // 旧句柄被 abort + a->fireDone(nodesVar()); // 旧 → 丢弃 + EXPECT_EQ(stSpy.count(), 0); + b->fireDone(nodesVar()); // 新 → 正常 + EXPECT_EQ(stSpy.count(), 1); +} + +// selectObject 三并发:data/file/detail 各自完成 → 各发对应信号。 +TEST(WorkbenchNavController, SelectObjectConcurrentEmitsAllThree) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded); + QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded); + QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded); + c.selectObject("obj1", 1); + repo.lastData->fireDone(dsPageVar()); + repo.lastFile->fireDone(dsPageVar()); + repo.lastDetail->fireDone(formVar()); + EXPECT_EQ(dsSpy.count(), 1); + EXPECT_EQ(flSpy.count(), 1); + EXPECT_EQ(dtSpy.count(), 1); +} + +// selectDataset 单请求 → datasetDetailLoaded。 +TEST(WorkbenchNavController, SelectDatasetEmitsDetail) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy spy(&c, &controller::WorkbenchNavController::datasetDetailLoaded); + c.selectDataset("ds1"); + repo.lastDataset->fireDone(formVar()); + EXPECT_EQ(spy.count(), 1); +} + +// 失败路径:start 首级失败 → loadFailed + busy 复位。 +TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + repo.lastWorkspaces->fireFailed(); + EXPECT_EQ(failSpy.count(), 1); + EXPECT_FALSE(busySpy.last().at(0).toBool()); +} From 62352395baedb873dd06f46a177d459f7c2baaed Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 08:04:08 +0800 Subject: [PATCH 61/74] =?UTF-8?q?harden(controller+net):=20setCheckedTms?= =?UTF-8?q?=20=E5=8E=BB=E9=87=8D=20+=20loadMore=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E9=A1=B5=E5=8F=B7=20+=20=E9=9D=9E=E6=8B=A5?= =?UTF-8?q?=E6=9C=89=E6=89=80=E6=9C=89=E6=9D=83=E6=B3=A8=E9=87=8A=E6=9B=B4?= =?UTF-8?q?=E6=AD=A3=20+=20ApiChain=20=E5=BE=85=E7=94=A8=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=20+=20selectObject=20=E9=83=A8=E5=88=86=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88Part=20A=20=E8=AF=84=E5=AE=A1=20I-2/?= =?UTF-8?q?I-3/I-4/M-1/M-4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/WorkbenchNavController.cpp | 19 ++++++++++++++---- src/data/api/NavRequest.hpp | 2 +- src/net/ApiBatch.hpp | 2 +- src/net/ApiChain.hpp | 4 +++- .../test_workbench_nav_controller.cpp | 20 +++++++++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index e9435af..8843727 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -275,6 +275,7 @@ void WorkbenchNavController::loadMoreData() { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { if (req != moreDataReq_) return; moreDataReq_.clear(); + --dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页 emit loadFailed(QStringLiteral("datasets"), msg); emitBusyIfChanged(); }); @@ -299,6 +300,7 @@ void WorkbenchNavController::loadMoreFiles() { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { if (req != moreFilesReq_) return; moreFilesReq_.clear(); + --filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页 emit loadFailed(QStringLiteral("files"), msg); emitBusyIfChanged(); }); @@ -331,13 +333,22 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { if (h) h->abort(); checkedInflight_.clear(); - QStringList missing; + // 入口去重:保留首次出现顺序,剔除重复 id(避免重复请求 + 异常树重复 group)。 + QStringList deduped; + QSet seen; for (const QString& tmQ : tmObjectIds) { + if (seen.contains(tmQ)) continue; + seen.insert(tmQ); + deduped.push_back(tmQ); + } + + QStringList missing; + for (const QString& tmQ : deduped) { const std::string tm = tmQ.toStdString(); if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ); } if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动 - assembleAndEmitExceptionTree(tmObjectIds); + assembleAndEmitExceptionTree(deduped); emitBusyIfChanged(); return; } @@ -350,7 +361,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { NavRequest* req = repo_.loadExceptionsByTmAsync(tm); checkedInflight_.push_back(req); QObject::connect(req, &NavRequest::done, this, - [this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) { + [this, req, tm, remaining, failedFlag, deduped](const QVariant& v) { // 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。 bool inCurrent = false; for (const auto& h : checkedInflight_) @@ -359,7 +370,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { tmExceptionCache_[tm] = qvariant_cast>(v); if (--(*remaining) == 0 && !*failedFlag) { checkedInflight_.clear(); - assembleAndEmitExceptionTree(tmObjectIds); + assembleAndEmitExceptionTree(deduped); emitBusyIfChanged(); } }); diff --git a/src/data/api/NavRequest.hpp b/src/data/api/NavRequest.hpp index 9859275..173c007 100644 --- a/src/data/api/NavRequest.hpp +++ b/src/data/api/NavRequest.hpp @@ -27,7 +27,7 @@ public: using Parser = std::function; using Predicate = std::function; ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, - QObject* parent = nullptr); // 接管 call + QObject* parent = nullptr); // 持有非拥有引用(QPointer);call 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它 void abort() override; private: QPointer call_; diff --git a/src/net/ApiBatch.hpp b/src/net/ApiBatch.hpp index 5c7190c..da17a40 100644 --- a/src/net/ApiBatch.hpp +++ b/src/net/ApiBatch.hpp @@ -13,7 +13,7 @@ class ApiBatch : public QObject { Q_OBJECT public: using Predicate = std::function; - ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls + ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 持有非拥有引用(QPointer 列表);各 call 完成或 abort 后自行 deleteLater,本类不得 delete 它们 void abort(); signals: void succeeded(const QList& responses); diff --git a/src/net/ApiChain.hpp b/src/net/ApiChain.hpp index 8575b87..f0b3f4c 100644 --- a/src/net/ApiChain.hpp +++ b/src/net/ApiChain.hpp @@ -11,10 +11,12 @@ namespace geopro::net { // 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。 // 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。 // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 +// 注意:Part A(导航)暂未接入生产调用,首个生产使用见 Part B 登录(AuthService 串行链); +// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。 class ApiChain : public QObject { Q_OBJECT public: - // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。 + // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(持有非拥有引用 QPointer;IApiCall 完成或 abort 后自行 deleteLater,本类不得 delete 它)。可抛 std::exception。 using StepFactory = std::function& prior)>; using Predicate = std::function; ApiChain(QList steps, Predicate isFailure, QObject* parent = nullptr); diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp index 5336266..6a60b2e 100644 --- a/tests/controller/test_workbench_nav_controller.cpp +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -188,6 +188,26 @@ TEST(WorkbenchNavController, SelectDatasetEmitsDetail) { EXPECT_EQ(spy.count(), 1); } +// selectObject 三并发部分失败:data 失败、file/detail 成功 → loadFailed×1,filesLoaded×1,objectDetailLoaded×1,datasetsLoaded×0。 +TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded); + QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded); + QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded); + QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed); + c.selectObject("obj2", 1); + // data 路失败,file/detail 路成功(三路独立,互不影响) + repo.lastData->fireFailed(); + repo.lastFile->fireDone(dsPageVar()); + repo.lastDetail->fireDone(formVar()); + EXPECT_EQ(dsSpy.count(), 0); // data 失败,无 datasetsLoaded + EXPECT_EQ(flSpy.count(), 1); // file 成功,有 filesLoaded + EXPECT_EQ(dtSpy.count(), 1); // detail 成功,有 objectDetailLoaded + EXPECT_EQ(failSpy.count(), 1); // 只有 data 路触发 loadFailed + EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets")); +} + // 失败路径:start 首级失败 → loadFailed + busy 复位。 TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { StubAsyncRepo repo; From 0cb0ed8aa083dcae3ea2dfc492ee797cc48b4162 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 08:14:19 +0800 Subject: [PATCH 62/74] =?UTF-8?q?refactor(detail):=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8=E6=8C=89=20ddCode=20=E8=B5=B0=20ChartStrategyRegistry?= =?UTF-8?q?=20=E5=88=86=E6=B4=BE,=20=E6=9C=AA=E6=B3=A8=E5=86=8C=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E9=99=8D=E7=BA=A7=20(=E6=9B=BF=E4=BB=A3=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=20dd=5Finversion=5Fdata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IDatasetChartStrategy + ChartStrategyRegistry 下移到控制器层 (src/controller, namespace geopro::controller), 删 app 层那份, 修层级倒置 (控制器不得依赖 app) - 接口加 hasGridPhase(); ErtInversionStrategy 留 app 层, 改继承 controller 接口, hasGridPhase()=true - DatasetDetailController 构造注入 ChartStrategyRegistry&; openDataset 用 registry.supports 降级; loadGridData 用 strategy->hasGridPhase 判定 - main.cpp 构造 registry 注册 ErtInversionStrategy 并注入 (registry 先于 detailCtrl 声明) - 测试: registry 加 hasGridPhase 断言; 控制器加空注册表降级 + 无网格阶段跳过网格加载用例; 全量 109/109 绿 (基线 106) --- src/app/main.cpp | 6 +- src/app/panels/chart/ErtInversionStrategy.hpp | 6 +- src/controller/DatasetDetailController.cpp | 10 +-- src/controller/DatasetDetailController.hpp | 5 +- .../IDatasetChartStrategy.hpp | 9 +-- tests/app/test_chart_strategy_registry.cpp | 16 ++++- .../test_dataset_detail_controller.cpp | 63 ++++++++++++++++--- 7 files changed, 92 insertions(+), 23 deletions(-) rename src/{app/panels/chart => controller}/IDatasetChartStrategy.hpp (71%) diff --git a/src/app/main.cpp b/src/app/main.cpp index 139c7b4..6e989a7 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -81,6 +81,7 @@ #include "ProjectListDialog.hpp" #include "WorkbenchNavController.hpp" #include "DatasetDetailController.hpp" +#include "panels/chart/ErtInversionStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -860,7 +861,10 @@ int main(int argc, char* argv[]) // 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。 geopro::data::ApiDatasetRepository datasetRepo(api); - geopro::controller::DatasetDetailController detailCtrl(datasetRepo); + // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 + geopro::controller::ChartStrategyRegistry chartRegistry; + chartRegistry.add(std::make_unique()); + geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 // setCentralWidget/setMenuWidget/statusBar 承载工作台。 diff --git a/src/app/panels/chart/ErtInversionStrategy.hpp b/src/app/panels/chart/ErtInversionStrategy.hpp index 01d7f0d..1fde3ae 100644 --- a/src/app/panels/chart/ErtInversionStrategy.hpp +++ b/src/app/panels/chart/ErtInversionStrategy.hpp @@ -1,7 +1,9 @@ #pragma once -#include "panels/chart/IDatasetChartStrategy.hpp" +#include "IDatasetChartStrategy.hpp" // geopro::controller(geopro_controller PUBLIC include) namespace geopro::app { -struct ErtInversionStrategy : IDatasetChartStrategy { +// ERT 反演策略:散点(chart) + 网格等值面(grid) 两阶段。 +struct ErtInversionStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } + bool hasGridPhase() const override { return true; } // 反演有网格数据阶段 }; } // namespace geopro::app diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 94355cb..c753e86 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -3,8 +3,9 @@ #include "api/DatasetLoadHandles.hpp" namespace geopro::controller { -DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) - : QObject(parent), repo_(repo) {} +DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, + ChartStrategyRegistry& registry, QObject* parent) + : QObject(parent), repo_(repo), registry_(registry) {} DatasetDetailController::~DatasetDetailController() { if (chartLoad_) chartLoad_->abort(); // 退出契约:abort 在飞句柄,不依赖外部析构顺序兜底 @@ -12,7 +13,7 @@ DatasetDetailController::~DatasetDetailController() { } void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { - if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 + if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级 emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } @@ -40,7 +41,8 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd } void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { - if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 + auto* strategy = registry_.find(ddCode.toStdString()); + if (!strategy || !strategy->hasGridPhase()) return; // 仅有网格阶段的类型加载网格数据 if (gridLoad_) gridLoad_->abort(); // abort-and-replace data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); gridLoad_ = load; diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 5bb39af..d103e1d 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -6,6 +6,7 @@ #include "model/Field.hpp" #include "model/ColorScale.hpp" #include "model/Anomaly.hpp" +#include "IDatasetChartStrategy.hpp" namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::controller { @@ -32,7 +33,8 @@ public: std::vector anomalies; }; - explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); + DatasetDetailController(data::IAsyncDatasetRepository& repo, + ChartStrategyRegistry& registry, QObject* parent = nullptr); ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this public slots: void openDataset(const QString& dsId, const QString& ddCode); @@ -46,6 +48,7 @@ signals: void loadFailed(const QString& dsId, const QString& message); private: data::IAsyncDatasetRepository& repo_; + ChartStrategyRegistry& registry_; QPointer chartLoad_; QPointer gridLoad_; }; diff --git a/src/app/panels/chart/IDatasetChartStrategy.hpp b/src/controller/IDatasetChartStrategy.hpp similarity index 71% rename from src/app/panels/chart/IDatasetChartStrategy.hpp rename to src/controller/IDatasetChartStrategy.hpp index 7fdc240..115d791 100644 --- a/src/app/panels/chart/IDatasetChartStrategy.hpp +++ b/src/controller/IDatasetChartStrategy.hpp @@ -2,14 +2,15 @@ #include #include #include -namespace geopro::app { - -class DatasetDetailPage; // 前置 +namespace geopro::controller { // dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 struct IDatasetChartStrategy { virtual ~IDatasetChartStrategy() = default; virtual std::string ddCode() const = 0; + // 该类型是否有「网格数据」加载阶段(ERT 反演=true;纯散点/折线/图像类=false)。 + // 控制器据此决定是否允许 loadGridData,替代硬编码 ddCode 判断。 + virtual bool hasGridPhase() const = 0; }; class ChartStrategyRegistry { @@ -26,4 +27,4 @@ public: private: std::map> map_; }; -} // namespace geopro::app +} // namespace geopro::controller diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index ad6da01..d54c7ba 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -1,8 +1,11 @@ #include -#include "panels/chart/IDatasetChartStrategy.hpp" -using namespace geopro::app; +#include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) +using namespace geopro::controller; namespace { -struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } }; +struct Fake : IDatasetChartStrategy { + std::string ddCode() const override { return "dd_inversion_data"; } + bool hasGridPhase() const override { return true; } +}; } TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { ChartStrategyRegistry reg; @@ -12,3 +15,10 @@ TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { EXPECT_FALSE(reg.supports("dd_unknown")); EXPECT_EQ(reg.find("dd_unknown"), nullptr); } +TEST(ChartStrategyRegistry, ReportsHasGridPhase) { + ChartStrategyRegistry reg; + reg.add(std::make_unique()); + auto* s = reg.find("dd_inversion_data"); + ASSERT_NE(s, nullptr); + EXPECT_TRUE(s->hasGridPhase()); +} diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index 4e21148..e996ddc 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -1,11 +1,29 @@ #include #include #include "DatasetDetailController.hpp" +#include "IDatasetChartStrategy.hpp" #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" using namespace geopro; namespace { +// 反演策略桩:散点 + 网格两阶段。 +struct InversionStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_inversion_data"; } + bool hasGridPhase() const override { return true; } +}; +// 无网格阶段策略桩:仅散点(如纯散点类型)。 +struct NoGridStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_scatter_only"; } + bool hasGridPhase() const override { return false; } +}; +// 注册了反演策略的注册表(多数用例复用)。 +controller::ChartStrategyRegistry makeInversionRegistry() { + controller::ChartStrategyRegistry reg; + reg.add(std::make_unique()); + return reg; +} + // 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 struct StubChartLoad : data::ChartLoad { bool aborted = false; @@ -33,7 +51,8 @@ struct StubAsyncRepo : data::IAsyncDatasetRepository { TEST(DatasetDetailController, EmitsChartReadyOnDone) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("ds1", "dd_inversion_data"); repo.lastChart->fireDone(); @@ -42,7 +61,8 @@ TEST(DatasetDetailController, EmitsChartReadyOnDone) { TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); repo.lastChart->fireFailed(); @@ -51,16 +71,39 @@ TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); // 注册了反演,但未注册 dd_other + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_other"); EXPECT_EQ(spy.count(), 1); EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 } +// 空注册表 → 任意 ddCode 都不支持 → loadFailed,不发起加载。 +TEST(DatasetDetailController, EmptyRegistryFailsAnyType) { + StubAsyncRepo repo; + controller::ChartStrategyRegistry reg; // 空 + controller::DatasetDetailController c(repo, reg); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_inversion_data"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(repo.lastChart, nullptr); +} + +// 无网格阶段的策略 → loadGridData 不发起网格加载。 +TEST(DatasetDetailController, NoGridPhaseStrategySkipsGridLoad) { + StubAsyncRepo repo; + controller::ChartStrategyRegistry reg; + reg.add(std::make_unique()); + controller::DatasetDetailController c(repo, reg); + c.loadGridData("ds1", "dd_scatter_only"); + EXPECT_EQ(repo.lastGrid, nullptr); // hasGridPhase()==false → 未发起 +} + TEST(DatasetDetailController, AbortsPreviousOnReopen) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); c.openDataset("dsA", "dd_inversion_data"); StubChartLoad* a = repo.lastChart; c.openDataset("dsB", "dd_inversion_data"); // 替换 @@ -69,7 +112,8 @@ TEST(DatasetDetailController, AbortsPreviousOnReopen) { TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("dsA", "dd_inversion_data"); StubChartLoad* a = repo.lastChart; @@ -83,7 +127,8 @@ TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { TEST(DatasetDetailController, EmitsGridReadyOnDone) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); c.loadGridData("ds1", "dd_inversion_data"); repo.lastGrid->fireDone(); @@ -92,7 +137,8 @@ TEST(DatasetDetailController, EmitsGridReadyOnDone) { TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.loadGridData("ds1", "dd_inversion_data"); repo.lastGrid->fireFailed(); @@ -101,7 +147,8 @@ TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) { StubAsyncRepo repo; - controller::DatasetDetailController c(repo); + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); c.loadGridData("dsA", "dd_inversion_data"); StubGridLoad* a = repo.lastGrid; From 4ca5893800ecb2f4546303810f4b2b2b1a9eebac Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 08:20:20 +0800 Subject: [PATCH 63/74] =?UTF-8?q?harden(controller):=20ChartStrategyRegist?= =?UTF-8?q?ry=20=E6=98=BE=E5=BC=8F=E7=A6=81=E6=8B=B7=E8=B4=9D/=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=A7=BB=E5=8A=A8=EF=BC=88=E4=BF=9D=E6=8A=A4=20find()?= =?UTF-8?q?=20=E8=A3=B8=E6=8C=87=E9=92=88=EF=BC=8C=E8=AF=84=E5=AE=A1=20I-1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/IDatasetChartStrategy.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/controller/IDatasetChartStrategy.hpp b/src/controller/IDatasetChartStrategy.hpp index 115d791..29c1452 100644 --- a/src/controller/IDatasetChartStrategy.hpp +++ b/src/controller/IDatasetChartStrategy.hpp @@ -15,6 +15,15 @@ struct IDatasetChartStrategy { class ChartStrategyRegistry { public: + ChartStrategyRegistry() = default; + // 禁拷贝(含 unique_ptr,本就不可拷贝;显式 delete 让意图清晰)。 + // 保留移动:map 移动只搬节点,find() 返回的裸指针指向的策略对象地址不变、仍有效; + // 且测试 makeInversionRegistry() 按值返回需要移动。 + ChartStrategyRegistry(const ChartStrategyRegistry&) = delete; + ChartStrategyRegistry& operator=(const ChartStrategyRegistry&) = delete; + ChartStrategyRegistry(ChartStrategyRegistry&&) = default; + ChartStrategyRegistry& operator=(ChartStrategyRegistry&&) = default; + void add(std::unique_ptr s) { const std::string code = s->ddCode(); map_[code] = std::move(s); From d1c1bf96b15e1891fe648457fbd834d2073ed179 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:01:07 +0800 Subject: [PATCH 64/74] =?UTF-8?q?feat(net+app):=20AuthService/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=BC=82=E6=AD=A5=E5=8C=96(CaptchaLoad/LoginLoad+ApiC?= =?UTF-8?q?hain,=20LoginWindow=20=E4=B8=8D=E5=86=BB=E5=8F=AF=E5=8F=96?= =?UTF-8?q?=E6=B6=88,=20test=5Fauth=20=E5=BC=82=E6=AD=A5=E5=8C=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: AuthService 改异步——新增 net 层句柄 CaptchaLoad/LoginLoad(AuthLoads.{hpp,cpp}), fetchCaptchaAsync 返回 CaptchaLoad;loginAsync 用 ApiChain 编排 verifyCodeCheck->RSA->login2 返回 LoginLoad。删同步 fetchCaptcha/login/LoginResult。句柄遵 spec §5.0(aborted_ 守卫/deleteLater)。 B2: LoginWindow 异步化——refreshCaptcha/attemptLogin 连句柄信号(身份比对),删 repaint() hack, 析构 abort 在飞句柄(退出契约)。公共 API(token/remember/exec)不变。 B4: test_auth.cpp 改 QSignalSpy::wait 异步等待(仍 live)。 新增离线句柄单测 test_auth_loads.cpp(CaptchaLoad/LoginLoad done/failed/abort)。 [B3 删 ApiClient 同步 get/postJson 因 ProjectListDialog/ApiProjectRepository 仍同步而 BLOCKED,故保留] --- src/app/login/LoginWindow.cpp | 77 +++++++++++--------- src/app/login/LoginWindow.hpp | 6 ++ src/net/AuthLoads.cpp | 73 +++++++++++++++++++ src/net/AuthLoads.hpp | 47 +++++++++++++ src/net/AuthService.cpp | 75 +++++++------------- src/net/AuthService.hpp | 23 +++--- src/net/CMakeLists.txt | 3 +- tests/CMakeLists.txt | 2 + tests/net/test_auth.cpp | 27 +++++-- tests/net/test_auth_loads.cpp | 129 ++++++++++++++++++++++++++++++++++ 10 files changed, 363 insertions(+), 99 deletions(-) create mode 100644 src/net/AuthLoads.cpp create mode 100644 src/net/AuthLoads.hpp create mode 100644 tests/net/test_auth_loads.cpp diff --git a/src/app/login/LoginWindow.cpp b/src/app/login/LoginWindow.cpp index f042b27..f2294f3 100644 --- a/src/app/login/LoginWindow.cpp +++ b/src/app/login/LoginWindow.cpp @@ -17,6 +17,7 @@ #include #include +#include "AuthLoads.hpp" #include "AuthService.hpp" #include "Theme.hpp" @@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) userEdit_->setFocus(); // 焦点落在第一个待填字段 } +LoginWindow::~LoginWindow() +{ + // 退出契约:窗口析构时 abort 在飞句柄,防回调打到已析构窗口(spec §5.0)。 + if (captchaLoad_) captchaLoad_->abort(); + if (loginLoad_) loginLoad_->abort(); +} + void LoginWindow::refreshCaptcha() { codeEdit_->clear(); - try { - const auto cap = auth_.fetchCaptcha(); - codeId_ = cap.codeId; - captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); - } catch (const std::exception& e) { - showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what()))); + if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求 + refreshBtn_->setEnabled(false); + + auto* l = auth_.fetchCaptchaAsync(); + captchaLoad_ = l; + connect(l, &geopro::net::CaptchaLoad::done, this, + [this, l](const geopro::net::AuthService::Captcha& cap) { + if (l != captchaLoad_) return; // 身份比对:仅处理最新请求 + captchaLoad_.clear(); + codeId_ = cap.codeId; + captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); + refreshBtn_->setEnabled(true); + }); + connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) { + if (l != captchaLoad_) return; + captchaLoad_.clear(); + showError(QStringLiteral("获取验证码失败:%1").arg(msg)); captchaLabel_->setText(QStringLiteral("加载失败")); - } catch (...) { - showError(QStringLiteral("获取验证码失败")); - captchaLabel_->setText(QStringLiteral("加载失败")); - } + refreshBtn_->setEnabled(true); + }); } void LoginWindow::attemptLogin() @@ -249,31 +266,25 @@ void LoginWindow::attemptLogin() errorLabel_->clear(); loginBtn_->setEnabled(false); const QString origText = loginBtn_->text(); - loginBtn_->setText(QStringLiteral("登录中...")); - loginBtn_->repaint(); // 同步阻塞前刷新按钮文案 + loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI,无需 repaint hack - geopro::net::AuthService::LoginResult result; - try { - result = auth_.login(user, pwd, code, codeId_); - } catch (const std::exception& e) { - result.ok = false; - result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what())); - } catch (...) { - result.ok = false; - result.error = QStringLiteral("登录发生未知错误"); - } - - loginBtn_->setText(origText); - loginBtn_->setEnabled(true); - - if (result.ok) { - token_ = result.token; + if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录 + auto* l = auth_.loginAsync(user, pwd, code, codeId_); + loginLoad_ = l; + connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) { + if (l != loginLoad_) return; // 身份比对 + loginLoad_.clear(); + token_ = token; accept(); - return; - } - - showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error); - refreshCaptcha(); // 失败刷新验证码 + }); + connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) { + if (l != loginLoad_) return; + loginLoad_.clear(); + loginBtn_->setText(origText); + loginBtn_->setEnabled(true); + showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg); + refreshCaptcha(); // 失败刷新验证码 + }); } bool LoginWindow::remember() const diff --git a/src/app/login/LoginWindow.hpp b/src/app/login/LoginWindow.hpp index 7d5ce42..2e97312 100644 --- a/src/app/login/LoginWindow.hpp +++ b/src/app/login/LoginWindow.hpp @@ -4,6 +4,7 @@ // 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。 #include +#include #include class QCheckBox; @@ -13,6 +14,8 @@ class QPushButton; namespace geopro::net { class AuthService; +class CaptchaLoad; +class LoginLoad; } namespace geopro::app { @@ -22,6 +25,7 @@ class LoginWindow : public QDialog { public: explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr); + ~LoginWindow() override; // 登录成功后的 accessToken(形如 "Geomative ");未登录为空。 QString token() const { return token_; } @@ -37,6 +41,8 @@ private: geopro::net::AuthService& auth_; QString token_; QString codeId_; + QPointer captchaLoad_; + QPointer loginLoad_; QLineEdit* userEdit_ = nullptr; QLineEdit* pwdEdit_ = nullptr; diff --git a/src/net/AuthLoads.cpp b/src/net/AuthLoads.cpp new file mode 100644 index 0000000..d5132f6 --- /dev/null +++ b/src/net/AuthLoads.cpp @@ -0,0 +1,73 @@ +#include "AuthLoads.hpp" + +#include "ApiChain.hpp" +#include "ApiClient.hpp" // geopro::net::ApiResponse +#include "IApiCall.hpp" + +namespace geopro::net { + +namespace { + +// 统一的失败文案:优先服务端 msg,否则回退传输层 rawError,最后给通用文案。 +QString reasonOf(const ApiResponse& resp, const QString& fallback) { + if (!resp.msg.isEmpty()) return resp.msg; + if (!resp.rawError.isEmpty()) return resp.rawError; + return fallback; +} + +} // namespace + +CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) { + QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (resp.code != 200 || !resp.rawError.isEmpty()) { + emit failed(reasonOf(resp, QStringLiteral("获取验证码失败"))); + deleteLater(); + return; + } + AuthService::Captcha cap; + cap.codeId = resp.data.value(QStringLiteral("id")).toString(); + cap.code = resp.data.value(QStringLiteral("code")).toString(); + emit done(cap); + deleteLater(); + }); +} + +void CaptchaLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (call_) call_->abort(); + deleteLater(); +} + +LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) { + QObject::connect(chain, &ApiChain::succeeded, this, + [this](const QList& responses) { + if (aborted_) return; // §5.0 入口守卫 + const QString token = + responses.isEmpty() + ? QString() + : responses.last().data.value(QStringLiteral("accessToken")).toString(); + if (token.isEmpty()) { + emit failed(QStringLiteral("登录成功但缺少 accessToken")); + deleteLater(); + return; + } + emit done(token); + deleteLater(); + }); + QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + emit failed(reasonOf(resp, QStringLiteral("登录失败"))); + deleteLater(); + }); +} + +void LoginLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (chain_) chain_->abort(); + deleteLater(); +} + +} // namespace geopro::net diff --git a/src/net/AuthLoads.hpp b/src/net/AuthLoads.hpp new file mode 100644 index 0000000..b35a962 --- /dev/null +++ b/src/net/AuthLoads.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "AuthService.hpp" // geopro::net::AuthService::Captcha + +namespace geopro::net { + +class IApiCall; +class ApiChain; + +// 验证码加载句柄(net 层,自管理):接管一个 getImageCode 的 IApiCall, +// 完成后解析 {data.id, data.code} -> done(Captcha);失败 -> failed(msg)。 +// 安全不变量见 spec §5.0:aborted_ 入口守卫 + 一律 deleteLater(禁同步 delete)。 +class CaptchaLoad : public QObject { + Q_OBJECT +public: + explicit CaptchaLoad(IApiCall* call, QObject* parent = nullptr); + void abort(); +signals: + void done(const geopro::net::AuthService::Captcha& captcha); + void failed(const QString& message); + +private: + QPointer call_; + bool aborted_ = false; +}; + +// 登录加载句柄(net 层,自管理):接管一个 verifyCodeCheck->RSA->login2 的 ApiChain, +// succeeded 取末步 data.accessToken -> done(token);缺 token 或 failed -> failed(msg)。 +class LoginLoad : public QObject { + Q_OBJECT +public: + explicit LoginLoad(ApiChain* chain, QObject* parent = nullptr); + void abort(); +signals: + void done(const QString& token); + void failed(const QString& message); + +private: + QPointer chain_; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/AuthService.cpp b/src/net/AuthService.cpp index e946093..734836b 100644 --- a/src/net/AuthService.cpp +++ b/src/net/AuthService.cpp @@ -2,9 +2,11 @@ #include #include -#include +#include +#include "ApiChain.hpp" #include "ApiClient.hpp" +#include "AuthLoads.hpp" #include "crypto/RsaEncryptor.hpp" namespace geopro::net { @@ -17,64 +19,39 @@ const char* const kPathImageCode = "/business/system/personalUser/getImageCode"; const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck"; const char* const kPathLogin = "/admin/tenant/auth/login2"; -// 统一的错误信息:优先用服务端 msg,否则回退到传输层 rawError,最后给通用文案。 -QString errorFrom(const ApiResponse& resp, const QString& fallback) { - if (!resp.msg.isEmpty()) return resp.msg; - if (!resp.rawError.isEmpty()) return resp.rawError; - return fallback; -} - } // namespace AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem) : api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {} -AuthService::Captcha AuthService::fetchCaptcha() { - const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode)); - Captcha cap; - if (resp.code != kCodeSuccess) { - return cap; // 失败时返回空 codeId/code,调用方据此判断 - } - cap.codeId = resp.data.value(QStringLiteral("id")).toString(); - cap.code = resp.data.value(QStringLiteral("code")).toString(); - return cap; +CaptchaLoad* AuthService::fetchCaptchaAsync() { + IApiCall* call = api_.getAsync(QString::fromLatin1(kPathImageCode)); + return new CaptchaLoad(call); } -AuthService::LoginResult AuthService::login(const QString& username, const QString& password, - const QString& code, const QString& codeId) { - // 1) 校验验证码(与 captcha 同会话)。 - const QJsonObject verifyBody{{QStringLiteral("code"), code}, - {QStringLiteral("codeId"), codeId}}; - const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody); - if (verify.code != kCodeSuccess) { - return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))}; - } +LoginLoad* AuthService::loginAsync(const QString& username, const QString& password, + const QString& code, const QString& codeId) { + // 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。 + auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }; - // 2) RSA 加密密码(PKCS#1 v1.5 -> base64)。 - std::string encrypted; - try { + // step1:校验验证码(与 captcha 同会话)。 + ApiChain::StepFactory step1 = [this, code, codeId](const QList&) -> IApiCall* { + const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}}; + return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body); + }; + + // step2:RSA 加密密码(PKCS#1 v1.5 -> base64,可抛 std::exception → ApiChain 转 failed)-> login2。 + ApiChain::StepFactory step2 = [this, username, password](const QList&) -> IApiCall* { RsaEncryptor enc(rsaPublicKeyPem_); - encrypted = enc.encryptBase64(password.toStdString()); - } catch (const std::exception& e) { - return {false, QString(), - QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))}; - } + const std::string encrypted = enc.encryptBase64(password.toStdString()); + const QJsonObject body{{QStringLiteral("username"), username}, + {QStringLiteral("password"), QString::fromStdString(encrypted)}, + {QStringLiteral("checkCode"), QString()}}; + return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body); + }; - // 3) login2:checkCode 传空串。 - const QJsonObject loginBody{ - {QStringLiteral("username"), username}, - {QStringLiteral("password"), QString::fromStdString(encrypted)}, - {QStringLiteral("checkCode"), QString()}}; - const ApiResponse login = api_.postJson(QString::fromLatin1(kPathLogin), loginBody); - if (login.code != kCodeSuccess) { - return {false, QString(), errorFrom(login, QStringLiteral("login2 failed"))}; - } - - const QString token = login.data.value(QStringLiteral("accessToken")).toString(); - if (token.isEmpty()) { - return {false, QString(), QStringLiteral("login2 succeeded but accessToken missing")}; - } - return {true, token, QString()}; + auto* chain = new ApiChain({step1, step2}, isFailure); + return new LoginLoad(chain); } } // namespace geopro::net diff --git a/src/net/AuthService.hpp b/src/net/AuthService.hpp index 23573fc..fe1f5cd 100644 --- a/src/net/AuthService.hpp +++ b/src/net/AuthService.hpp @@ -6,9 +6,13 @@ namespace geopro::net { class ApiClient; +class CaptchaLoad; +class LoginLoad; // 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。 // 依赖外部注入的 ApiClient(单实例、共享会话);RSA 公钥 PEM 由调用方读取后传入。 +// 异步:fetchCaptchaAsync/loginAsync 立即返回自管理句柄(net 层 CaptchaLoad/LoginLoad), +// 不阻塞 UI;句柄 done/failed 后自 deleteLater。AuthService 自身只创建句柄,无需是 QObject。 class AuthService { public: AuthService(ApiClient& api, std::string rsaPublicKeyPem); @@ -18,18 +22,14 @@ public: QString codeId; QString code; }; - Captcha fetchCaptcha(); - struct LoginResult { - bool ok = false; - QString token; - QString error; - }; + // 异步拉验证码:GET getImageCode;返回句柄,连 CaptchaLoad::done(Captcha)/failed(QString)。 + CaptchaLoad* fetchCaptchaAsync(); - // 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""}; - // 任一步失败返回 {false, "", <服务端 msg 或本地错误>}。 - LoginResult login(const QString& username, const QString& password, const QString& code, - const QString& codeId); + // 异步登录:verifyCodeCheck -> RSA 加密密码 -> login2(依赖链)。 + // 返回句柄,连 LoginLoad::done(token)/failed(QString)。 + LoginLoad* loginAsync(const QString& username, const QString& password, const QString& code, + const QString& codeId); private: ApiClient& api_; @@ -37,3 +37,6 @@ private: }; } // namespace geopro::net + +#include +Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha) diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index d529f39..46e3d2f 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -8,7 +8,8 @@ add_library(geopro_net STATIC ApiCall.cpp ApiBatch.cpp ApiChain.cpp - AuthService.cpp) + AuthService.cpp + AuthLoads.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) target_compile_features(geopro_net PUBLIC cxx_std_17) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4fb35c1..087c5bc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,8 @@ target_sources(geopro_tests PRIVATE net/test_auth.cpp) target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) # ApiChain 离线单测(顺序依赖链:顺序/失败短路/abort闸门/工厂抛异常)。 target_sources(geopro_tests PRIVATE net/test_api_chain.cpp) +# AuthLoads 离线单测(CaptchaLoad/LoginLoad 句柄:done/failed/abort 闸门)。 +target_sources(geopro_tests PRIVATE net/test_auth_loads.cpp) target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 diff --git a/tests/net/test_auth.cpp b/tests/net/test_auth.cpp index 21c683e..b1b2647 100644 --- a/tests/net/test_auth.cpp +++ b/tests/net/test_auth.cpp @@ -1,17 +1,19 @@ // net 层端到端登录连通测试(真实站点)。 // 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2, // 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。 -// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。 -// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。 +// 异步化:fetchCaptchaAsync/loginAsync 返回自管理句柄,QSignalSpy::wait 驱动事件循环等待。 +// 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。 #include #include +#include #include #include #include #include "ApiClient.hpp" +#include "AuthLoads.hpp" #include "AuthService.hpp" namespace { @@ -35,11 +37,24 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) { geopro::net::AuthService auth( api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem")); - auto cap = auth.fetchCaptcha(); + auto* cl = auth.fetchCaptchaAsync(); + QSignalSpy capDone(cl, &geopro::net::CaptchaLoad::done); + QSignalSpy capFail(cl, &geopro::net::CaptchaLoad::failed); + ASSERT_TRUE(capDone.wait(10000) || capFail.count() > 0); + ASSERT_EQ(capDone.count(), 1) + << (capFail.count() ? capFail.takeFirst().at(0).toString().toStdString() : "captcha failed"); + auto cap = capDone.takeFirst().at(0).value(); ASSERT_FALSE(cap.codeId.isEmpty()); ASSERT_FALSE(cap.code.isEmpty()); - auto r = auth.login("sydk", "123456", cap.code, cap.codeId); - EXPECT_TRUE(r.ok) << r.error.toStdString(); - EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString(); + auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId); + QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done); + QSignalSpy loginFail(ll, &geopro::net::LoginLoad::failed); + ASSERT_TRUE(loginDone.wait(10000) || loginFail.count() > 0); + EXPECT_EQ(loginDone.count(), 1) + << (loginFail.count() ? loginFail.takeFirst().at(0).toString().toStdString() : ""); + if (loginDone.count()) { + auto token = loginDone.takeFirst().at(0).toString(); + EXPECT_TRUE(token.startsWith("Geomative ")) << token.toStdString(); + } } diff --git a/tests/net/test_auth_loads.cpp b/tests/net/test_auth_loads.cpp new file mode 100644 index 0000000..2f67ea5 --- /dev/null +++ b/tests/net/test_auth_loads.cpp @@ -0,0 +1,129 @@ +// AuthLoads 离线单测:CaptchaLoad/LoginLoad 句柄行为(done/failed/abort 闸门), +// 用 FakeApiCall + 真 ApiChain 离线驱动,不联网。QSignalSpy 需 Qt6::Test。 +#include + +#include + +#include "ApiChain.hpp" +#include "ApiClient.hpp" +#include "AuthLoads.hpp" +#include "AuthService.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { + +ApiResponse captchaOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + r.data = QJsonObject{{"id", "cid-1"}, {"code", "AB12"}}; + return r; +} + +ApiResponse tokenOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + r.data = QJsonObject{{"accessToken", "Geomative deadbeef"}}; + return r; +} + +ApiResponse plainOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + return r; +} + +ApiResponse failResp() { + ApiResponse r; + r.code = 500; + r.httpStatus = 200; + r.msg = QStringLiteral("验证码错误"); + return r; +} + +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; + +} // namespace + +TEST(CaptchaLoad, ParsesCaptchaOnSuccess) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + QSignalSpy failSpy(load, &CaptchaLoad::failed); + call->fire(captchaOk()); + ASSERT_EQ(doneSpy.count(), 1); + EXPECT_EQ(failSpy.count(), 0); + auto cap = doneSpy.takeFirst().at(0).value(); + EXPECT_EQ(cap.codeId, QStringLiteral("cid-1")); + EXPECT_EQ(cap.code, QStringLiteral("AB12")); +} + +TEST(CaptchaLoad, EmitsFailedOnErrorResponse) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + QSignalSpy failSpy(load, &CaptchaLoad::failed); + call->fire(failResp()); + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误")); +} + +TEST(CaptchaLoad, AbortGateSuppressesLateSignal) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + load->abort(); + EXPECT_TRUE(call->aborted); + call->fire(captchaOk()); // 迟到 + EXPECT_EQ(doneSpy.count(), 0); +} + +TEST(LoginLoad, EmitsTokenOnChainSuccess) { + auto* s1 = new FakeApiCall; + auto* s2 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { return s2; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // verifyCodeCheck 通过 → 触发 step2 + s2->fire(tokenOk()); // login2 返回 token + ASSERT_EQ(doneSpy.count(), 1); + EXPECT_EQ(failSpy.count(), 0); + EXPECT_EQ(doneSpy.takeFirst().at(0).toString(), QStringLiteral("Geomative deadbeef")); +} + +TEST(LoginLoad, EmitsFailedWhenChainFails) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(failResp()); + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误")); +} + +TEST(LoginLoad, EmitsFailedWhenTokenMissing) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // 成功但无 accessToken + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); +} From 6b4267d78a9ac58de6fa96b12d3ff76bed52e1c2 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:11:54 +0800 Subject: [PATCH 65/74] =?UTF-8?q?harden(net+app):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=8F=A5=E6=9F=84=E7=BB=88=E6=80=81=E7=BD=AE=20aborted=5F=20+?= =?UTF-8?q?=20connect=20=E7=94=A8=E6=88=90=E5=91=98=20QPointer=20+=20ApiCh?= =?UTF-8?q?ain=20=E9=A6=96=E6=AD=A5=E5=90=8C=E6=AD=A5=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=20+=20LoginLoad=20step=20=E6=8A=9B=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=A6=BB=E7=BA=BF=E6=B5=8B=20+=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=9B=B4=E6=AD=A3=EF=BC=88Part=20B=20=E8=AF=84=E5=AE=A1=20I-1/?= =?UTF-8?q?I-2/I-3/M-2/M-4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/login/LoginWindow.hpp | 2 +- src/net/ApiChain.cpp | 3 +++ src/net/ApiChain.hpp | 12 ++++++++++++ src/net/AuthLoads.cpp | 11 ++++++++--- tests/net/test_auth_loads.cpp | 24 ++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/app/login/LoginWindow.hpp b/src/app/login/LoginWindow.hpp index 2e97312..510e249 100644 --- a/src/app/login/LoginWindow.hpp +++ b/src/app/login/LoginWindow.hpp @@ -35,7 +35,7 @@ public: private: void refreshCaptcha(); // 拉新验证码并重绘图片 - void attemptLogin(); // 校验输入并发起阻塞登录 + void attemptLogin(); // 校验输入并发起异步登录 void showError(const QString& msg); geopro::net::AuthService& auth_; diff --git a/src/net/ApiChain.cpp b/src/net/ApiChain.cpp index fa8909f..1590fc0 100644 --- a/src/net/ApiChain.cpp +++ b/src/net/ApiChain.cpp @@ -7,6 +7,9 @@ ApiChain::ApiChain(QList steps, Predicate isFailure, QObject* paren : QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) { Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded) Q_ASSERT(isFailure_); + // 首步同步契约:此处同步执行首个 step 工厂。调用方须在 new ApiChain 之前连接 + // succeeded/failed,且首个工厂不得同步抛出、其 IApiCall 不得同步 emit finished。 + // 详见 ApiChain.hpp 首步同步契约说明。 runNext(); } diff --git a/src/net/ApiChain.hpp b/src/net/ApiChain.hpp index f0b3f4c..281aa7c 100644 --- a/src/net/ApiChain.hpp +++ b/src/net/ApiChain.hpp @@ -13,6 +13,18 @@ namespace geopro::net { // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 // 注意:Part A(导航)暂未接入生产调用,首个生产使用见 Part B 登录(AuthService 串行链); // 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。 +// +// ── 首步同步契约(调用方必读)────────────────────────────────────────────────── +// ctor 内同步调用 runNext(),立即执行首个 step 工厂。 +// 因此调用方须在 new ApiChain(...) 之前连接 succeeded/failed 信号——否则若首步工厂 +// 同步抛异常(catch 后 emit failed),信号会在连接建立前发出而丢失。 +// 契约: +// 1. 首个 step 工厂不得同步抛异常(生产路径:verifyCodeCheck 不抛,满足)。 +// 2. 首个 step 工厂返回的 IApiCall 不得同步 emit finished(生产路径:真实网络请求,满足)。 +// 若将来需要可同步完成的首步,应将 ctor 内 runNext() 改为 +// QMetaObject::invokeMethod(this, &ApiChain::runNext, Qt::QueuedConnection) +// 以推迟首拍到事件循环,届时调用方已完成信号连接。 +// ──────────────────────────────────────────────────────────────────────────────── class ApiChain : public QObject { Q_OBJECT public: diff --git a/src/net/AuthLoads.cpp b/src/net/AuthLoads.cpp index d5132f6..b92498e 100644 --- a/src/net/AuthLoads.cpp +++ b/src/net/AuthLoads.cpp @@ -18,9 +18,10 @@ QString reasonOf(const ApiResponse& resp, const QString& fallback) { } // namespace CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) { - QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + QObject::connect(call_, &IApiCall::finished, this, [this](const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 if (resp.code != 200 || !resp.rawError.isEmpty()) { + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(reasonOf(resp, QStringLiteral("获取验证码失败"))); deleteLater(); return; @@ -28,6 +29,7 @@ CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), cal AuthService::Captcha cap; cap.codeId = resp.data.value(QStringLiteral("id")).toString(); cap.code = resp.data.value(QStringLiteral("code")).toString(); + aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退 emit done(cap); deleteLater(); }); @@ -41,7 +43,7 @@ void CaptchaLoad::abort() { } LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) { - QObject::connect(chain, &ApiChain::succeeded, this, + QObject::connect(chain_, &ApiChain::succeeded, this, [this](const QList& responses) { if (aborted_) return; // §5.0 入口守卫 const QString token = @@ -49,15 +51,18 @@ LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_ ? QString() : responses.last().data.value(QStringLiteral("accessToken")).toString(); if (token.isEmpty()) { + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(QStringLiteral("登录成功但缺少 accessToken")); deleteLater(); return; } + aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退 emit done(token); deleteLater(); }); - QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { + QObject::connect(chain_, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(reasonOf(resp, QStringLiteral("登录失败"))); deleteLater(); }); diff --git a/tests/net/test_auth_loads.cpp b/tests/net/test_auth_loads.cpp index 2f67ea5..bc369ee 100644 --- a/tests/net/test_auth_loads.cpp +++ b/tests/net/test_auth_loads.cpp @@ -2,6 +2,8 @@ // 用 FakeApiCall + 真 ApiChain 离线驱动,不联网。QSignalSpy 需 Qt6::Test。 #include +#include + #include #include "ApiChain.hpp" @@ -127,3 +129,25 @@ TEST(LoginLoad, EmitsFailedWhenTokenMissing) { EXPECT_EQ(doneSpy.count(), 0); ASSERT_EQ(failSpy.count(), 1); } + +// M-2:step 工厂抛异常(模拟 RSA 失败)时 LoginLoad 应 emit failed。 +// 第一步用 FakeApiCall(不同步 fire,由测试手动触发),第二步工厂抛异常。 +// 构造顺序:new ApiChain(同步执行 step1 工厂,s1 已建立但未 fire) +// → new LoginLoad(连接 chain 的 succeeded/failed) +// → s1->fire(plainOk())(step1 成功 → step2 工厂抛 → ApiChain emit failed +// → LoginLoad 已连接,收到 failed)。 +TEST(LoginLoad, EmitsFailedWhenStepFactoryThrows) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { + throw std::runtime_error("rsa fail"); + }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // step1 成功 → step2 工厂抛异常 → ApiChain emit failed + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(doneSpy.count(), 0); +} From 93462d78efda1e15cc02c00776ce377711da8033 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:13:09 +0800 Subject: [PATCH 66/74] =?UTF-8?q?docs(spec):=20=E5=9B=9E=E5=A1=AB=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=8C=96=E8=BF=9B=E5=BA=A6=20=E2=80=94=20=E5=AF=BC?= =?UTF-8?q?=E8=88=AA(Part=20A)+=E7=99=BB=E5=BD=95(Part=20B)=E5=B7=B2?= =?UTF-8?q?=E8=90=BD=E5=9C=B0,=20B3/A6=20=E5=88=A0=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=9B=A0=20ProjectListDialog=20=E4=BB=8D=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=80=8C=20BLOCKED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-11-apiclient-async-design.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md index c526728..944151a 100644 --- a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -7,14 +7,18 @@ **DatasetDetail 试点:✅ 已完成并通过评审。** 实现计划 `plans/2026-06-11-apiclient-async-datasetdetail.md`(8 任务,逐任务 spec+质量双评审 + 整体评审)。测试 75 → 89(+14 离线用例)全绿。落地原语:`IApiCall`/`ApiCall`/`ApiBatch`(net)、`ChartLoad`/`GridLoad`/`IAsyncDatasetRepository`(data)、控制器 abort-and-replace + 句柄身份比对 + 退出契约、`LoadingOverlay` 网格懒加载遮罩。核心收益落地:详情路径不冻 UI、慢请求可 abort 不回灌、多请求并发 + fail-fast。 -**未完成(本期非目标,待后续铺开):** -- **导航路径**(`ApiProjectRepository`:空间/项目/结构树/数据集列表/异常树/动态表单)仍同步阻塞。 -- **登录路径**(`AuthService`:验证码→校验→login2)仍同步阻塞。 -- `ApiClient` 同步 `get/postJson` 保留供上述路径使用,尚未移除。 +**铺开进展(2026-06-12 更新):** +- **导航路径 ✅**(计划 `plans/2026-06-11-apiclient-async-rollout.md` Part A):新增 `ApiChain`(串行依赖链原语)、`NavRequest`(单请求句柄,QVariant payload)、`IAsyncProjectRepository`;`WorkbenchNavController` 全异步(NavRequest 续延 + 并发计数 + abort-and-replace + 身份比对,删 busy_/drain,busyChanged=在飞存在性)。 +- **登录路径 ✅**(同计划 Part B:B1/B2/B4):`AuthService` 异步(`CaptchaLoad`/`LoginLoad` + `ApiChain` 编排 verify→RSA→login2);`LoginWindow` 不冻 + 可取消(析构 abort);`test_auth` live 异步化。 +- 测试 89 → 116。每块逐任务 spec+质量双评审 + 整体评审通过。 -可选 follow-up(评审建议,非阻断):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;补 `qRegisterMetaType>()`(为 cross-thread 铺开准备)。 +**仍未完成(BLOCKED,同根:`ProjectListDialog` 仍同步消费 `IProjectRepository`):** +- 删除 `ApiClient` 同步 `get/postJson`(Part B 的 B3)+ 删除同步 `IProjectRepository`(Part A 的 A6)。解锁前置:先把 `ProjectListDialog` 迁移到 `IAsyncProjectRepository`。 +- 故 `ApiProjectRepository` 暂同时实现同步+异步两接口(过渡技术债)。 -> 全 App 异步化铺开的实现计划另见:`plans/2026-06-11-apiclient-async-rollout.md`(如已生成)。 +可选 follow-up(评审建议,非阻断):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;补 `qRegisterMetaType>()`。 + +> 铺开实现计划:`plans/2026-06-11-apiclient-async-rollout.md`(Part A/B 已落地,B3 BLOCKED)。 ## 1. 背景 From 5f00cdce7a6ecc4d9eb73030e5634a674dc96f2a Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:31:54 +0800 Subject: [PATCH 67/74] =?UTF-8?q?refactor(net+data+app):=20ProjectListDial?= =?UTF-8?q?og=20=E5=BC=82=E6=AD=A5=E5=8C=96=20+=20=E5=88=A0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20IProjectRepository/RepoResult/ApiClient.get|postJso?= =?UTF-8?q?n=EF=BC=88=E6=B8=85=E9=99=A4=E8=BF=87=E6=B8=A1=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E5=80=BA=EF=BC=8C=E5=85=A8=20App=20=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E5=B1=82=E5=BC=82=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectListDialog 迁到 IAsyncProjectRepository:fillTypeFilter/query 改 abort-and-replace + 身份比对 + done/failed 双分支;析构 abort 在飞请求(退出契约) - main.cpp buildWorkbench 形参改 IAsyncProjectRepository& - ApiProjectRepository 删 public IProjectRepository 与 9 个同步方法实现;删不再用的 ok()/errorOf() helper - 删除 src/data/repo/IProjectRepository.hpp(含 RepoResult,已无消费者) - ApiClient 删同步 get/postJson + Impl::await + /ApiResponseParse.hpp include --- src/app/ProjectListDialog.cpp | 114 ++++++++++++++++---------- src/app/ProjectListDialog.hpp | 12 ++- src/app/main.cpp | 2 +- src/data/README.md | 2 +- src/data/api/ApiProjectRepository.cpp | 102 +---------------------- src/data/api/ApiProjectRepository.hpp | 21 +---- src/data/repo/IProjectRepository.hpp | 41 --------- src/net/ApiClient.cpp | 27 ------ src/net/ApiClient.hpp | 6 +- 9 files changed, 89 insertions(+), 238 deletions(-) delete mode 100644 src/data/repo/IProjectRepository.hpp diff --git a/src/app/ProjectListDialog.cpp b/src/app/ProjectListDialog.cpp index 950df35..0324196 100644 --- a/src/app/ProjectListDialog.cpp +++ b/src/app/ProjectListDialog.cpp @@ -15,6 +15,8 @@ #include #include "Theme.hpp" +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" namespace geopro::app { @@ -37,7 +39,7 @@ QColor statusColor(int s) { } } // namespace -ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent) +ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent) : QDialog(parent), repo_(repo) { setWindowTitle(QStringLiteral("全部项目")); resize(980, 560); @@ -125,57 +127,85 @@ ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* pa query(); } +ProjectListDialog::~ProjectListDialog() { + // 退出契约:模态 exec 关窗后本对话框析构 → abort 在飞请求,防回调打到已析构窗口。 + if (typesReq_) typesReq_->abort(); + if (queryReq_) queryReq_->abort(); +} + void ProjectListDialog::fillTypeFilter() { typeCombo_->addItem(QStringLiteral("全部类型"), QString()); - const auto r = repo_.listProjectTypes(); - if (!r.ok) return; - for (const auto& t : r.value) - typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); + // abort-and-replace:最新一次过滤填充为准。 + if (typesReq_) typesReq_->abort(); + auto* r = repo_.listProjectTypesAsync(); + typesReq_ = r; + QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) { + if (r != typesReq_) return; // 身份比对:丢弃迟到/被替换信号 + typesReq_.clear(); + const auto types = qvariant_cast>(v); + for (const auto& t : types) + typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); + }); + QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString&) { + if (r != typesReq_) return; + typesReq_.clear(); + // 失败时仅保留“全部类型”项(与原同步版失败仅 return 一致)。 + }); } void ProjectListDialog::query() { const std::string name = nameEdit_->text().trimmed().toStdString(); const std::string typeId = typeCombo_->currentData().toString().toStdString(); - const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_); - if (!r.ok) { + // abort-and-replace:丢弃上一查询,仅最新结果落表。 + if (queryReq_) queryReq_->abort(); + auto* r = repo_.pageProjectsAsync(name, typeId, pageNo_, pageSize_); + queryReq_ = r; + QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) { + if (r != queryReq_) return; // 身份比对 + queryReq_.clear(); + const auto page = qvariant_cast(v); + total_ = page.total; + const auto& rows = page.rows; + table_->setRowCount(static_cast(rows.size())); + for (int i = 0; i < static_cast(rows.size()); ++i) { + const auto& p = rows[i]; + auto set = [&](int col, const QString& text) { + table_->setItem(i, col, new QTableWidgetItem(text)); + }; + set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1)); + auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name)); + nameItem->setData(Qt::UserRole, QString::fromStdString(p.id)); + nameItem->setForeground(tokenColor("accent/primary")); + table_->setItem(i, 1, nameItem); + set(2, QString::fromStdString(p.code)); + // 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。 + auto* statusItem = new QTableWidgetItem(statusText(p.status)); + statusItem->setForeground(statusColor(p.status)); + if (p.status == 2) { + QFont f = statusItem->font(); + f.setBold(true); + statusItem->setFont(f); + } + table_->setItem(i, 3, statusItem); + set(4, QString::fromStdString(p.typeName)); + set(5, QString::fromStdString(p.ownerCompany)); + set(6, QString::fromStdString(p.responsiblePerson)); + set(7, QString::fromStdString(p.createTime)); + } + const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1; + pageLabel_->setText( + QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages)); + prevBtn_->setEnabled(pageNo_ > 1); + nextBtn_->setEnabled(pageNo_ < pages); + }); + QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString& msg) { + if (r != queryReq_) return; + queryReq_.clear(); table_->setRowCount(0); - pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error))); + pageLabel_->setText(QStringLiteral("加载失败:%1").arg(msg)); prevBtn_->setEnabled(false); nextBtn_->setEnabled(false); - return; - } - total_ = r.value.total; - const auto& rows = r.value.rows; - table_->setRowCount(static_cast(rows.size())); - for (int i = 0; i < static_cast(rows.size()); ++i) { - const auto& p = rows[i]; - auto set = [&](int col, const QString& text) { - table_->setItem(i, col, new QTableWidgetItem(text)); - }; - set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1)); - auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name)); - nameItem->setData(Qt::UserRole, QString::fromStdString(p.id)); - nameItem->setForeground(tokenColor("accent/primary")); - table_->setItem(i, 1, nameItem); - set(2, QString::fromStdString(p.code)); - // 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。 - auto* statusItem = new QTableWidgetItem(statusText(p.status)); - statusItem->setForeground(statusColor(p.status)); - if (p.status == 2) { - QFont f = statusItem->font(); - f.setBold(true); - statusItem->setFont(f); - } - table_->setItem(i, 3, statusItem); - set(4, QString::fromStdString(p.typeName)); - set(5, QString::fromStdString(p.ownerCompany)); - set(6, QString::fromStdString(p.responsiblePerson)); - set(7, QString::fromStdString(p.createTime)); - } - const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1; - pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages)); - prevBtn_->setEnabled(pageNo_ > 1); - nextBtn_->setEnabled(pageNo_ < pages); + }); } } // namespace geopro::app diff --git a/src/app/ProjectListDialog.hpp b/src/app/ProjectListDialog.hpp index c0675a8..eda588f 100644 --- a/src/app/ProjectListDialog.hpp +++ b/src/app/ProjectListDialog.hpp @@ -1,7 +1,8 @@ #pragma once #include +#include -#include "repo/IProjectRepository.hpp" +#include "repo/IAsyncProjectRepository.hpp" class QLineEdit; class QComboBox; @@ -9,13 +10,16 @@ class QTableWidget; class QLabel; class QPushButton; +namespace geopro::data { class NavRequest; } + namespace geopro::app { // 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。 class ProjectListDialog : public QDialog { Q_OBJECT public: - explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr); + explicit ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent = nullptr); + ~ProjectListDialog() override; signals: void projectChosen(const QString& projectId, const QString& projectName); @@ -24,7 +28,9 @@ private: void query(); void fillTypeFilter(); - data::IProjectRepository& repo_; + data::IAsyncProjectRepository& repo_; + QPointer typesReq_; + QPointer queryReq_; QLineEdit* nameEdit_ = nullptr; QComboBox* typeCombo_ = nullptr; QTableWidget* table_ = nullptr; diff --git a/src/app/main.cpp b/src/app/main.cpp index 6e989a7..1dafd25 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -193,7 +193,7 @@ constexpr const char* kWgs84 = "EPSG:4326"; // 在给定 QMainWindow 上构建 M1 工作台。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, - geopro::data::IProjectRepository& projectRepo, + geopro::data::IAsyncProjectRepository& projectRepo, geopro::controller::WorkbenchNavController& nav, geopro::controller::DatasetDetailController& detailCtrl) { diff --git a/src/data/README.md b/src/data/README.md index 4e706df..733dbb1 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -3,7 +3,7 @@ Repository 抽象(**异步契约**:QFuture/回调 + 取消 + 分页),DTO 与领域模型分离。 子目录(设计 §3、§6): -- `repo/` — IProjectRepository, IDatasetRepository +- `repo/` — IAsyncProjectRepository, IDatasetRepository - `local/` — LocalSampleRepository(M1,QtConcurrent 跑解析)+ 各格式解析器 - `api/` — ApiRepository(M1 骨架,签名对齐 pop-api) - `dto/` — 后端 JSON DTO + → model 映射 diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 193863b..c822806 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -15,17 +15,9 @@ namespace geopro::data { namespace { constexpr int kCodeSuccess = 200; -bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; } - -// 异步失败谓词(与同步 ok() 同口径:业务码非 200 或网络错误)。 +// 异步失败谓词:业务码非 200 或网络错误。 bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); } -std::string errorOf(const net::ApiResponse& r, const char* fallback) { - if (!r.msg.isEmpty()) return r.msg.toStdString(); - if (!r.rawError.isEmpty()) return r.rawError.toStdString(); - return fallback; -} - // 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。 QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); @@ -34,97 +26,7 @@ QString enc(const std::string& s) { ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {} -RepoResult> ApiProjectRepository::listWorkspaces() { - const net::ApiResponse r = - api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list")); - if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")}; - return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenantId) { - const QString path = - QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); - const net::ApiResponse r = api_.postJson(path, QJsonObject{}); - if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; - // 切换空间返回新 accessToken:必须重新注入,后续请求才落到新空间。 - const QString token = r.data.value(QStringLiteral("accessToken")).toString(); - if (!token.isEmpty()) api_.setToken(token); - return {true, true, {}}; -} - -RepoResult ApiProjectRepository::pageProjects(const std::string& nameFilter, - const std::string& typeId, int pageNo, - int pageSize) { - QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, - {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), pageSize}}; - if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); - const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body); - if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")}; - return {true, dto::parseProjectPage(r.data), {}}; -} - -RepoResult> ApiProjectRepository::listProjectTypes() { - const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list")); - if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")}; - return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult> ApiProjectRepository::loadStructure(const std::string& projectId) { - // 项目结构(项目根 + GS + TM;不含 DS)。比 projectWorkbench 干净。 - const QString path = - QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")}; - return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult ApiProjectRepository::loadRows(const std::string& projectId, - const std::string& parentId, int parentConfType, - int classifyType, int pageNo) { - const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") - : QStringLiteral("/business/dsObject/data/page"); - const QJsonObject body{ - {QStringLiteral("projectId"), QString::fromStdString(projectId)}, - {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, - {QStringLiteral("structParentConfType"), parentConfType}, - {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, - {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 5}}; - const net::ApiResponse r = api_.postJson(path, body); - if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; - return {true, dto::parseDsPage(r.data), {}}; -} - -RepoResult ApiProjectRepository::loadObjectDetail(const std::string& objectId, - int confType) { - const QString path = - (confType == 1) - ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) - : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; - return {true, dto::parseDynamicForm(r.data), {}}; -} - -RepoResult ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { - const QString path = - QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; - return {true, dto::parseDynamicForm(r.data), {}}; -} - -RepoResult> ApiProjectRepository::loadExceptionsByTm( - const std::string& tmObjectId) { - const QString path = - QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; - return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -// ── 异步实现(薄封装:endpoint/body/parse 与同步版逐一对齐;解析器在 try 内见 ApiNavRequest)── +// ── 异步实现(薄封装:解析器在 try 内见 ApiNavRequest)── NavRequest* ApiProjectRepository::listWorkspacesAsync() { auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list")); diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 1845e00..ed391e8 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -1,5 +1,4 @@ #pragma once -#include "repo/IProjectRepository.hpp" #include "repo/IAsyncProjectRepository.hpp" namespace geopro::net { class ApiClient; } @@ -8,26 +7,12 @@ namespace geopro::data { class NavRequest; -// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。 -// token 由调用方注入 ApiClient。 -class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository { +// 用共享会话 ApiClient 实现导航异步仓储。token 由调用方注入 ApiClient。 +class ApiProjectRepository : public IAsyncProjectRepository { public: explicit ApiProjectRepository(net::ApiClient& api); - // ── 同步(旧,A6 删除) ── - RepoResult> listWorkspaces() override; - RepoResult switchWorkspace(const std::string& tenantId) override; - RepoResult pageProjects(const std::string& nameFilter, const std::string& typeId, - int pageNo, int pageSize) override; - RepoResult> listProjectTypes() override; - RepoResult> loadStructure(const std::string& projectId) override; - RepoResult loadRows(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) override; - RepoResult loadObjectDetail(const std::string& objectId, int confType) override; - RepoResult loadDatasetForm(const std::string& dsObjectId) override; - RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; - - // ── 异步(新) ── 返回 NavRequest*(薄封装,一方法一请求)。 + // ── 异步 ── 返回 NavRequest*(薄封装,一方法一请求)。 NavRequest* listWorkspacesAsync() override; NavRequest* switchWorkspaceAsync(const std::string& tenantId) override; NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId, diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp deleted file mode 100644 index 0c7c0af..0000000 --- a/src/data/repo/IProjectRepository.hpp +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once -#include -#include -#include "repo/RepoTypes.hpp" - -namespace geopro::data { - -// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。 -template -struct RepoResult { - bool ok = false; - T value{}; - std::string error; -}; - -// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。 -class IProjectRepository { -public: - virtual ~IProjectRepository() = default; - virtual RepoResult> listWorkspaces() = 0; - virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; - // 项目分页:nameFilter 名称模糊(可空)、typeId 类型过滤(空=不限)、pageNo 从 1 起。 - virtual RepoResult pageProjects(const std::string& nameFilter, - const std::string& typeId, int pageNo, - int pageSize) = 0; - // 项目类型列表(弹窗类型过滤下拉)。 - virtual RepoResult> listProjectTypes() = 0; - virtual RepoResult> loadStructure(const std::string& projectId) = 0; - // 按结构父节点分页拉数据/文件行:parentConfType 1=GS 2=TM;classifyType 3=数据 1=文件; - // pageNo 从 1 起,pageSize 固定 5。 - virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) = 0; - // 对象详情:confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。 - virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; - // 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。 - virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; - // 单 TM 异常列表(含异常体归属字段)。 - virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; -}; - -} // namespace geopro::data diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp index f9e9f93..8945278 100644 --- a/src/net/ApiClient.cpp +++ b/src/net/ApiClient.cpp @@ -1,9 +1,7 @@ #include "ApiClient.hpp" #include "ApiCall.hpp" -#include "ApiResponseParse.hpp" -#include #include #include #include @@ -34,14 +32,6 @@ struct ApiClient::Impl { } return req; } - - // 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。 - static ApiResponse await(QNetworkReply* reply) { - QEventLoop loop; - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - loop.exec(); - return buildResponse(reply); - } }; ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique(std::move(baseUrl))) {} @@ -50,23 +40,6 @@ ApiClient::~ApiClient() = default; void ApiClient::setToken(const QString& token) { impl_->token = token; } -ApiResponse ApiClient::get(const QString& path) { - QNetworkRequest req = impl_->buildRequest(path); - QNetworkReply* reply = impl_->nam.get(req); - ApiResponse resp = Impl::await(reply); - reply->deleteLater(); - return resp; -} - -ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) { - QNetworkRequest req = impl_->buildRequest(path); - const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); - QNetworkReply* reply = impl_->nam.post(req, payload); - ApiResponse resp = Impl::await(reply); - reply->deleteLater(); - return resp; -} - IApiCall* ApiClient::getAsync(const QString& path) { QNetworkRequest req = impl_->buildRequest(path); QNetworkReply* reply = impl_->nam.get(req); diff --git a/src/net/ApiClient.hpp b/src/net/ApiClient.hpp index b3b65ec..64091cb 100644 --- a/src/net/ApiClient.hpp +++ b/src/net/ApiClient.hpp @@ -21,7 +21,7 @@ struct ApiResponse { QString rawError; }; -// QtNetwork 的同步 HTTP 封装。 +// QtNetwork 的异步 HTTP 封装。 // 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar, // 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID)。 // 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。 @@ -36,10 +36,6 @@ public: // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 void setToken(const QString& token); - // 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。 - ApiResponse get(const QString& path); - ApiResponse postJson(const QString& path, const QJsonObject& body); - // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 IApiCall* getAsync(const QString& path); IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); From 067852e08b91c3a49ea2683c35ad48a62eb3b85d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:38:24 +0800 Subject: [PATCH 68/74] =?UTF-8?q?docs(spec):=20=E5=BC=82=E6=AD=A5=E5=8C=96?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=AE=8C=E6=88=90=20=E2=80=94=20=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E5=80=BA=E6=B8=85=E9=99=A4(ProjectListDialog=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=8C=96,=20=E5=88=A0=E5=90=8C=E6=AD=A5IProjectReposi?= =?UTF-8?q?tory/RepoResult/ApiClient.get|postJson),=20=E5=85=A8App?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E5=B1=82100%=E5=BC=82=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-11-apiclient-async-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md index 944151a..bfc0620 100644 --- a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -12,13 +12,13 @@ - **登录路径 ✅**(同计划 Part B:B1/B2/B4):`AuthService` 异步(`CaptchaLoad`/`LoginLoad` + `ApiChain` 编排 verify→RSA→login2);`LoginWindow` 不冻 + 可取消(析构 abort);`test_auth` live 异步化。 - 测试 89 → 116。每块逐任务 spec+质量双评审 + 整体评审通过。 -**仍未完成(BLOCKED,同根:`ProjectListDialog` 仍同步消费 `IProjectRepository`):** -- 删除 `ApiClient` 同步 `get/postJson`(Part B 的 B3)+ 删除同步 `IProjectRepository`(Part A 的 A6)。解锁前置:先把 `ProjectListDialog` 迁移到 `IAsyncProjectRepository`。 -- 故 `ApiProjectRepository` 暂同时实现同步+异步两接口(过渡技术债)。 +**技术债清除 ✅(2026-06-12):** `ProjectListDialog` 已迁到 `IAsyncProjectRepository`(NavRequest + abort-and-replace + 身份比对 + 析构 abort),随即删除同步 `IProjectRepository`、`RepoResult`、`ApiProjectRepository` 9 个同步方法、`ApiClient` 同步 `get/postJson`+`await`(A6+B3 解锁完成)。**全 App 网络层现已 100% 异步,无 `QEventLoop` 阻塞、无过渡双接口债。** 测试 116/116。 -可选 follow-up(评审建议,非阻断):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;补 `qRegisterMetaType>()`。 +**结论:异步化主题完成。** 数据详情(试点)+ 导航(Part A)+ 登录(Part B)+ 项目列表弹窗全部异步;同步路径彻底移除。 -> 铺开实现计划:`plans/2026-06-11-apiclient-async-rollout.md`(Part A/B 已落地,B3 BLOCKED)。 +可选 follow-up(评审建议,非阻断,纯整洁):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;如未来引入 cross-thread 再补 `qRegisterMetaType>()`。 + +> 铺开实现计划:`plans/2026-06-11-apiclient-async-rollout.md`(Part A/B + 债务清除均已落地)。 ## 1. 背景 From 66869a1e2e01363f26edf28d5486984bdf22d551 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:44:21 +0800 Subject: [PATCH 69/74] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E6=96=87=E6=A1=A3=20=E2=80=94=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=9B=86=E8=AF=A6=E6=83=85=E5=9B=BE=E8=A1=A8=20+=20=E5=85=A8Ap?= =?UTF-8?q?p=E7=BD=91=E7=BB=9C=E5=B1=82=E5=BC=82=E6=AD=A5=E5=8C=96(?= =?UTF-8?q?=E8=AF=A6=E6=83=85/=E5=AF=BC=E8=88=AA/=E7=99=BB=E5=BD=95/?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=97=E8=A1=A8)=E5=AE=8C=E6=88=90,=20?= =?UTF-8?q?=E5=90=AB=E6=9E=B6=E6=9E=84/=E6=96=87=E4=BB=B6=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE/=E4=B8=8B=E4=B8=80=E6=AD=A5/=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HANDOFF-dataset-detail-chart.md | 195 +++++++++++------- 1 file changed, 124 insertions(+), 71 deletions(-) diff --git a/docs/superpowers/HANDOFF-dataset-detail-chart.md b/docs/superpowers/HANDOFF-dataset-detail-chart.md index 9a982df..da71414 100644 --- a/docs/superpowers/HANDOFF-dataset-detail-chart.md +++ b/docs/superpowers/HANDOFF-dataset-detail-chart.md @@ -1,97 +1,150 @@ -# 交接文档:数据集详情图表(dataset-detail-chart) +# 交接文档:数据集详情图表 + 全 App 网络层异步化 -> 给下一个会话:读完本文件 + 文末"立即要做的事"即可无缝接手。日期 2026-06-11。 +> 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。 +> 分支 **`feat/dataset-detail-chart`**,领先 main **68 commits**,工作区干净,**测试 116/116 全绿**。 + +--- + +## 0. 一句话现状 + +geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成两大块: +1. **数据集详情图表**(仅 ERT 反演 `dd_inversion_data`,QwtPlot 落地,用户已验收)。 +2. **全 App 网络层 100% 异步化**(详情/导航/登录/项目列表全异步,同步 `QEventLoop` 阻塞路径已彻底删除,无技术债)。 + +均**未合并入 main**,分支挂起等收尾。 + +--- ## 1. 背景 -geopro 是 **Qt6 / C++ 离线桌面客户端**,目标是**像素级 1:1 复刻** web 系统 -`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)的「数据集详情」视图。 -本轮专做 ERT 反演数据(`ddCode=dd_inversion_data`)的详情视图。 +- geopro = **Qt6 / C++ 离线桌面客户端**,目标**像素级 1:1 复刻** web 系统 `http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)。 +- **硬约束**:禁 QWebEngine/Chromium(离线)。图表用本地 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**。验收 = **视觉等价 + 交互一致**(非字节级 diff)。 +- **标准测试 ds**:`id=1458990939709440`(ddCode=dd_inversion_data)。 +- 站点 baseUrl:`http://tenant.geomative.cn/pop-api`。Auth header:`geomativeauthorization: Geomative `。 -**硬约束**:禁止 QWebEngine/Chromium(离线要求)。图表用本地 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**,不是 web 渲染。验收标准是**视觉等价 + 交互一致**(非字节级像素 diff)。 +--- -**标准测试 ds**:`id=1458990939709440`(ddCode=dd_inversion_data)。原版网格视图参考截图:仓库根 `web-grid-E3b.png`(未入库,可重新用 Playwright 截)。 +## 2. 技术栈与构建/测试 -## 2. 目标 - -数据详情含两个页签,均已完成: -- **原数据**:Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。 -- **网格数据**:填充等值面 + 黑色等值线 + 沿线数值标注 + 不规则白边(NaN 裁剪)+ 底部异常表/描述。 - -## 3. 技术栈与构建 - -- Qt6 Widgets;**Qwt 6.2**(源码在 `external/qwt-src/`,**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,不要用 qmake——本机 VS2026 缺 vswhere,qmake 不可用)。 - - 关键:qwt.cmake 必须 `target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1)`,否则 61 个链接错误;需链接 Qt6 Widgets/Concurrent/PrintSupport/Svg。 -- **VTK 9.6 仅用算法层**(`vtkBandedPolyDataContourFilter` / `vtkStripper` / `vtkSplineFilter` / `vtkDataSetSurfaceFilter`),不做 VTK 渲染。 -- CMake(VS 自带 4.2.3)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 Qt 依赖)。 +- Qt6 Widgets + **Qwt 6.2**(源码 `external/qwt-src/`,**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,**不要用 qmake**——本机 VS2026 缺 vswhere)。 +- **VTK 9.6 仅算法层**(`vtkBandedPolyDataContourFilter`/`vtkStripper`/`vtkSplineFilter`/`vtkDataSetSurfaceFilter`),不做 VTK 渲染。 +- CMake(VS 自带)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 Qt 依赖)。 - **构建**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` - - ⚠️ 若报 `LNK1104 无法打开 geopro_desktop.exe`,是 exe 正在运行:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。 -- **测试**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(当前 **75/75 绿**)。 - - 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ContourBands.*` + - ⚠️ `LNK1104 无法打开 geopro_desktop.exe` = exe 在运行:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。 + - ⚠️ **改头文件后偶发** ninja 增量陈旧导致链接报「符号在 main.cpp.obj 重复定义」:删 `build/release/src/app/CMakeFiles/geopro_desktop.dir/main.cpp.obj` 后重建。 +- **测试**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(**只跑 ctest 不构建——必须先 dev-build**)。当前 **116/116 绿**。 + - 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*` + - ⚠️ 直接跑 exe 时 4 个 `CrsTransform/VoxelRegister/Terrain` 失败是缺 `PROJ_DATA` 环境项;**ctest(dev-test)会注入环境、全绿**——以 dev-test 的 "X/X passed" 为准。 +- **网络/署名**:AuthLiveTest 联网真打站点;提交信息**全局禁用署名**(勿加 Co-Authored-By)。 -## 4. 数据 API(均经 Playwright 实测) +--- -Auth header:`geomativeauthorization: Geomative `。ApiClient 把非 object 的 `data` 包成 `{"value": }`。 -**原版也是分页懒加载**(客户端已对齐): +## 3. 第一块:数据集详情图表(已完成,仅 dd_inversion_data) -| 页 | 接口 | 说明 | -|---|---|---| -| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` | 2 个请求,~0.8s | -| 网格数据 | `dd/ert/inversion/rows/{id}` + `lvl/colorGradation/getDetail`(type2) + `exception/queryException/{id}` | 3 个,rows 服务端网格化**波动 1–4s** | +详情含两页签,均经用户逐项验收: +- **原数据**:Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。 +- **网格数据**:填充等值面(栅格 QImage)+ 黑色等值线(vtkStripper+vtkSplineFilter 样条平滑,**每条线只标一个**数值)+ NaN 白边裁剪 + 底部异常表/描述。 -- `rows`:`x[nx]`、`y[ny]`、`v[ny][nx]`(电阻率,含 NaN=无数据)、`z[ny][nx]`(高程)、vmin/vmax。标准 ds 为 nx=300,ny=100。 -- `colorGradation`:`properties.colorBar=[[值,"rgba()"],…]`(type1 散点连续/type2 网格离散,~17 段)+ `lineConfig{showLines,color,lineType}` + `labelConfig{showLabels,color}`。 -- **配色机制(实测纠正过)**:不是 log;是 **colorBar 值线性归一化到 [0,1] + 非均匀色阶停靠点 + 连续插值(散点)/离散分带(网格)**。 +数据 API(均经 Playwright 实测): +| 页 | 接口 | +|---|---| +| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` | +| 网格 | `dd/ert/inversion/rows/{id}`(服务端网格化,**波动 1–4s**) + `colorGradation/getDetail`(type2) + `exception/queryException/{id}` | -## 5. 架构与关键文件 +> ⚠️ 架构偏离:原 spec 定 QGraphicsView,**实际落地用 QwtPlot**(见 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。 -数据流:`DatasetListPanel` 双击 → `DatasetDetailController.openDataset`(同步阻塞拉原数据 2 请求)→ `chartReady(ChartData)` → `DatasetDetailPanel`(多 ds 的 QTabWidget) → `DatasetDetailPage`(buildTabbedPanel 原数据/网格 两页签) → 两个 View。切到网格页签 → `gridDataNeeded` 冒泡 → `loadGridData`(懒加载 3 请求)→ `gridReady(GridData)` → `GridDataChartView.setGridData`。 +**策略分派已打通**(本次会话):控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`(在 **controller 层** `src/controller/IDatasetChartStrategy.hpp`,含 `hasGridPhase()`),未注册类型优雅降级「暂不支持」。`ErtInversionStrategy`(app 层)已注册。**这是接其余 dd 类型的地基**。 -- `src/controller/DatasetDetailController.{hpp,cpp}` — openDataset / **loadGridData**(懒加载);busy_ 重入守卫 + catch(...) 防死锁。signal: chartReady/gridReady/loadFailed/focusRequested。 -- `src/app/panels/chart/`: - - `RawDataChartView.*` — 散点视图模板(白底浅 palette、QwtPlotGrid 网格线、过原点 QwtPlotMarker 零线、**QwtPlotRescaler 锁定 x:y 真实比尺(aspect=1, ref=xTop)**、**LivePanner**、x 轴在 **xTop**、独立 ColorBarWidget)。析构 delete colorSvc_。 - - `GridDataChartView.*` — 网格视图。x 轴在 **xBottom**。布局:toolbar(固定) + **QScrollArea(setWidgetResizable)** 内含 **QSplitter(竖直)**{ chartArea(plot+colorBar, minH280) | 异常表/描述(minH160) }。→ 页签内滚动 + 可拖分割条。 - - `ContourPlotItem.*`(QwtPlotItem)— **填充走栅格**:QImage(ARGB32, K=4 上采样, 双线性插值 + `colorAtDiscrete` 离散着色 + 任一邻格 NaN→像素透明=白边裁剪),draw 时 blit;**等值线走矢量**:`buildContourBands` 取 lines;标注:`resolveLineLevels` 采样吸附级别,**每条线只标一个**(弧长中点,旋转)。异常叠加(点/线/面)。 - - `ScatterPlotItem.*` — 散点项(xTop/yLeft,7px 方块白描边,连续配色,n=min(x,y) 防越界)。 - - `ColorMapService.*` — colorAtContinuous(线性插值)/colorAtDiscrete(阶梯),from core::ColorScale。 - - `ColorBarWidget.*` — 独立色阶条,**居中约 74% 宽**(非满宽)、等宽色带、白底深字。 - - `LivePanner.*` — canvas 事件过滤器:**左键实时平移**(连续平移两轴+replot) + **滚轮缩放**(以光标为中心,上滚=放大,**消费事件**避免冒泡触发外层滚动条)。取代了 QwtPlotPanner/QwtPlotMagnifier。 -- `src/render/ContourBands.{hpp,cpp}` — `buildContourBands(Grid, ColorScale, ContourOptions{upsample=2,smooth=0.3,makeLines})` → `{bands, lines}`。VTK:网格→上采样→平滑→toCellGrid(剔 NaN 格)→surface→banded→(lines: **vtkStripper 连段 + vtkSplineFilter 样条平滑**, 不再 DP 简化)。 -- `src/data/dto/DatasetChartDto.*`、`src/data/api/ApiDatasetRepository.*` — JSON 解析(v 行数校验 qWarning、markType 钳制)。 -- `src/app/main.cpp` — 装配:detailDock `setWidget(..., ads::CDockWidget::ForceNoScrollArea)`(**禁 ADS 把标题/页签卷入整体滚动条**);gridDataNeeded→loadGridData、gridReady→setGridData 接线。 +--- -## 6. 关键设计决策(及为什么) +## 4. 第二块:全 App 网络层异步化(本次会话完成,无技术债) -- **真实比尺锁定**(用户选定,区别于原版的响应式填充):QwtPlotRescaler aspect=1,剖面呈真实"宽扁"。 -- **填充用栅格而非多边形**:300×100 网格 banded 多边形约 3 万个,逐帧+实时拖动会卡;QImage 一次成图 + blit 流畅。 -- **等值线样条平滑**:banded 边是大量 2 点短线段→ vtkStripper 连接 + vtkSplineFilter 拟合平滑曲线(贴近原版圆滑)。 -- **滚轮事件必须消费**:否则冒泡触发 ADS/外层滚动条。 -- **页签内滚动**:ForceNoScrollArea(dock) + 视图内 QScrollArea(裹 splitter),使标题/页签/工具条固定、仅内容区滚动。 -- **同步阻塞是全 App 共性问题**:ApiClient 用 QEventLoop 死等→每次请求冻 UI。已定**单独立项异步化**(见下)。 +**动机**:原 `ApiClient` 用 `QEventLoop` 死等每个请求 → 全 App 冻 UI(网格 rows 1–4s 最痛)。 -## 7. 当前进度 +**核心安全不变量(spec §5.0,务必遵守)**:「abort 后绝不回灌」靠三件套—— +1. 每层 `aborted_` 入口守卫(`disconnect` 只是尽力而为,挡不住已入队的迟到信号); +2. 控制器**句柄身份比对**(`if (load != current_) return;` 丢弃迟到信号); +3. **一律 `deleteLater`**,禁止同步 delete。 +错误判定口径:业务 `code != 200 || !rawError.isEmpty()`(HTTP 200 也可能 code=500)。 -- 分支 **`feat/dataset-detail-chart`**,领先 main **37 commits**,工作区干净(除未入库的 `web-grid-E3b.png`)。 -- 两个视图 + 交互 + 懒加载 + 布局**全部完成并经用户逐项验收通过**。 -- **cpp-reviewer 审查已做**:HIGH 全修(散点越界 / colorSvc 析构泄漏 / QwtPlot autoDelete 注释 / 控制器 catch(...) 防 busy 死锁)+ 值得的 MEDIUM/LOW(清死代码、填充等比限幅、DTO 校验/枚举钳制、ContourLine.level 默认 NaN)。提交 `78f96db`。75/75 测试绿。 +**net 层原语**(`src/net/`,AUTOMOC 已 ON): +- `IApiCall`/`ApiCall`:单请求句柄(包 QNetworkReply),`finished(ApiResponse)` + `abort()`,自管理 deleteLater。 +- `ApiBatch`:**并发汇聚** N 个 IApiCall,全成功 `succeeded(QList)` / 任一失败 **fail-fast** `failed(i,resp)`+abort 其余。 +- `ApiChain`:**串行依赖链**(上步结果喂下步工厂,工厂可抛转 failed)。**契约:首个 step 工厂不得同步抛/同步 fire**(否则信号在调用方连接前丢失;生产路径都发异步请求,满足)。 +- `ApiResponseParse::buildResponse`:sync/async 共用解析(已无 sync 调用方,仅 ApiCall 用)。 +- `ApiClient`:仅 `getAsync/postJsonAsync`(**同步 `get/postJson`/`await`/QEventLoop 已删除**)。 -## 8. 立即要做的事(接手第一步) +**data 层**: +- 详情:`ChartLoad`/`GridLoad`(抽象基 + `ApiChartLoad`/`ApiGridLoad` 实现,包 ApiBatch + 注入 parse)、`IAsyncDatasetRepository`、`ApiDatasetRepository.loadChartAsync/loadGridAsync`、`DatasetLoads.hpp`(ChartParts/GridParts)。 +- 导航:`NavRequest`(单非模板句柄,`done(QVariant)`/`failed(QString)`;`ApiNavRequest` 包 IApiCall + 解析器)、`NavLoads.hpp`(各类型 `Q_DECLARE_METATYPE`)、`IAsyncProjectRepository`(9 方法 `...Async` 后缀,返回 `NavRequest*`,薄封装;汇聚/链编排放控制器)、`ApiProjectRepository` 仅实现异步接口。 -**收尾本分支**——已问用户"如何收尾",4 选项待其回答:①合并回 main(本地) ②推送建 PR(origin=gitea) ③保持现状 ④丢弃。 -合并/PR 前先清掉未入库的 `web-grid-E3b.png`(保持工作区干净)。**等用户给出选择后执行**(用 superpowers:finishing-a-development-branch)。 +**controller 层**: +- `DatasetDetailController`:abort-and-replace + 句柄身份比对 + `loadStarted(dsId,Phase)` + 析构 abort;走 `ChartStrategyRegistry` 分派。 +- `WorkbenchNavController`:全异步(NavRequest 续延依赖链 + 控制器内并发计数 + abort-and-replace + 身份比对);**删除 `busy_`/`BusyGuard`/`drainPendingCheckedTms`**;`busyChanged(bool)` 语义改为「有在飞句柄」(去抖);`setCheckedTms` 入口去重 + 「最后一次为准」由 abort-and-replace 承接 + `tmExceptionCache_` 缓存命中不发请求。对外信号面**零改动**(main.cpp 接线没动)。 -## 9. 后续计划(本分支之后) +**app 层**: +- `AuthService`(net 层):`fetchCaptchaAsync()→CaptchaLoad`、`loginAsync()→LoginLoad`(内用 ApiChain:verifyCodeCheck→RSA(step2 工厂内)→login2)。`LoginWindow`:不冻 + 可取消(析构 abort),删 `repaint()` hack。 +- `ProjectListDialog`:改用 `IAsyncProjectRepository`(NavRequest + abort-and-replace + 身份比对 + 析构 abort),过滤/分页/选项目行为等价。 +- `LoadingOverlay`:网格懒加载「加载中」遮罩,接 `loadStarted`、就绪/失败隐藏。 +- `main.cpp`:`qRegisterMetaType()`;装配注入 registry/异步 repo(引用绑定,接线信号面不变)。 -1. **工具条编辑类功能**(网格页占位按钮):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格 / 另存为 / 导出。交互较重,建议先 brainstorm 拆解。 -2. **ApiClient 异步化(已单独立项)**:把全局同步阻塞 ApiClient 改异步(QNetworkReply 信号,去 QEventLoop)+ Repository/控制器信号化→全 App 不冻 UI、可并发。架构改动,建议单独 spec+plan。 -3. **更广项目**:数据详情只是一个特性;原型/`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`(客户端 tab) 里还有其他数据类型、三维视图、其他菜单。 +**执行方式**:全程 subagent-driven(每块 implementer + spec 符合度评审 + 代码质量评审 opus + follow-up 加固)。测试 75 → 116(+41,含 abort 回灌防护回归用例)。 -## 10. 相关文档与记忆 +--- -- 设计/计划:`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`、`docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md`(v2 QwtPlot 返工方案,权威)、`docs/superpowers/plans/2026-06-11-dataset-detail-chart.md`。 -- 外部返工方案:`docs/Geopro3.0_二维图表返工技术方案.md`。 +## 5. 关键文件地图 + +- net:`src/net/{IApiCall.hpp,ApiCall.*,ApiBatch.*,ApiChain.*,ApiResponseParse.*,ApiClient.*,AuthService.*,AuthLoads.*}` +- data:`src/data/api/{DatasetLoads.hpp,DatasetLoadHandles.*,ApiDatasetRepository.*,NavRequest.*,NavLoads.hpp,ApiProjectRepository.*,DatasetChartDto.*}`、`src/data/repo/{IAsyncDatasetRepository.hpp,IAsyncProjectRepository.hpp,IDatasetRepository.hpp(本地样例同步,保留),RepoTypes.hpp}` +- controller:`src/controller/{DatasetDetailController.*,WorkbenchNavController.*,IDatasetChartStrategy.hpp}` +- app:`src/app/main.cpp`、`src/app/panels/{DatasetDetailPanel.*,DatasetDetailPage.*,LoadingOverlay.*,chart/*}`、`src/app/login/LoginWindow.*`、`src/app/ProjectListDialog.*`、`src/app/panels/chart/ErtInversionStrategy.hpp` +- 测试:`tests/net/{FakeApiCall.hpp,test_api_batch.cpp,test_api_chain.cpp,test_auth.cpp(live),test_auth_loads.cpp}`、`tests/data/{test_nav_request.cpp,test_dataset_load_handles.cpp}`、`tests/controller/{test_dataset_detail_controller.cpp,test_workbench_nav_controller.cpp}`、`tests/app/test_chart_strategy_registry.cpp` + +--- + +## 6. 尚未完成 / 下一步(按优先级) + +### A. 收尾本分支(最先) +分支领先 main 68 commits、116/116 绿、工作区干净。用户多次选「保持现状」未合并。下一步可问用户:①合并回 main(本地) ②推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ③保持现状。用 `superpowers:finishing-a-development-branch`。 + +### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED) +计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。 +- **Phase 1(策略分派打通):✅ 已完成**(本次会话)。 +- **Phase 0(样本探查):需做**——用 Playwright 登录原版 web,对每类 dd 找有数据对象、抓真实响应存 fixtures + 渲染规格。**这是后续所有类型的前置。** +- **Phase 2(ERT 测量散点)/ Phase 3(TEM 折线,新 LineChartView)**:仅当 Phase 0 确认有样本才解锁。 +- **Phase 4:BLOCKED**——dd_grid/轨迹/测井/GPR,**当前租户无活数据样本**(GPR 对象无数据、无测井数据),按 1:1 复刻铁律不能凭空实现。 +- 矩阵详见计划文件。 + +### C. 工具条编辑类功能(网格页占位按钮,未做,单独立项) +白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图(Esc)全屏。当前是占位按钮。交互较重,建议先 brainstorm 拆解。spec §2.3 列为范围外。 + +### D. 纯整洁 follow-up(非债、非阻断) +- 删 `DatasetDetailController::ChartData.grid/gridScale` 死字段(从未填充,预存)。 +- 若未来引入 cross-thread/QueuedConnection,再补 `qRegisterMetaType>()`。 + +--- + +## 7. 相关文档 + +- **spec**:`docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(异步设计 + §5.0 安全不变量 + §7 错误判定/退出契约 + 顶部「状态更新」=已完成)、`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(详情视图,顶部状态=仅 ERT 反演完成 + QwtPlot 偏离)。 +- **plan**:`docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`(详情试点)、`2026-06-11-apiclient-async-rollout.md`(导航 Part A + 登录 Part B,均落地)、`2026-06-11-dataset-detail-other-dd-types.md`(其余 dd 类型,Phase 1 done)、`2026-06-11-dataset-detail-chart-v2-qwt.md`(QwtPlot 返工,权威)。 - API 文档:`docs/apis`(business_OpenAPI.json)。 -- **记忆(务必遵守)**:`~/.claude/projects/D--Git-lanbingtech-geopro/memory/`: - - `reply-in-chinese` — 全程中文回复。 - - `study-original-via-playwright` — **任何不确定必须用 Playwright 实地看原版,禁止联想猜测**。 - - `no-embellishment-replicate-exactly` — 严格 1:1,不加原版没有的特性(曾因等值线"周期重复标注"被纠正,原版每条线只标一个)。 +- 外部方案:`docs/Geopro3.0_二维图表返工技术方案.md`。 + +--- + +## 8. 记忆(务必遵守,`~/.claude/projects/D--Git-lanbingtech-geopro/memory/`) + +- `reply-in-chinese` — 全程中文回复。 +- `study-original-via-playwright` — **任何不确定必须用 Playwright 实地看原版,禁止臆测**(尤其做新 dd 类型详情图前抓样本/规格)。 +- `no-embellishment-replicate-exactly` — 严格 1:1,不加原版没有的特性(曾因等值线「周期重复标注」被纠正)。 + +--- + +## 9. 工作方式备注(本次会话沿用,效果好) + +- 用户偏好 **subagent-driven** 执行 + 实现后 **code review + spec 符合度** 双评审(opus 评审);「非必要不停,一口气做完」。 +- **真并行构建在本项目不安全**(dev-build 硬编码单一 build 目录 + 多任务共改 main.cpp/CMakeLists)→ 用顺序执行;worktree 隔离不了构建(硬编码路径 + 冷配置开销)。 +- 破坏性接口改形需**原子落地**(同批提交)保持构建绿(详情试点 Task5+6 合并、Part A A4+A5、本次清债 都是此教训)。 +- 评审 follow-up 多为小加固,直接在新代码上补;「不为不可能的场景写错误处理」(CLAUDE.md §2)——评审若建议为构造保证不会发生的情况加防御,可不采纳。 + From 10072eb4b3cf3d16178c748b4945259ad7ba96c3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 19:00:32 +0800 Subject: [PATCH 70/74] =?UTF-8?q?feat(dataset-detail+app):=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9B=86=E6=A0=91/=E6=8C=89=E6=A0=B9=E5=88=86?= =?UTF-8?q?=E9=A1=B5=20+=20=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F=20+=20=E8=AF=A6=E6=83=85=E5=9B=BE=E4=BF=9D=E7=9C=9F?= =?UTF-8?q?=20+=20=E6=A1=8C=E9=9D=A2=E6=97=A5=E5=BF=97=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本分支累积的数据集详情与桌面端健壮性工作(多轮迭代,已逐项实测/用户验收),一次性提交。 数据集列表树化 + 按根分页: - 原版数据列表为 el-table 树(派生数据按 sourceShowParentId 挂源「原始数据」下); DsRow 加 parentId,parseDsRows 解析 sourceShowParentId(回退 parentId), DatasetListPanel 由 QListWidget 改建 QTreeWidget(卡片委托泛化到 QAbstractItemView)。 - 后端 data/page 按扁平 DS 分页 → 改为客户端按「第一层节点(根)」分页: loadRowsAsync 加 pageSize,selectObject 一次取全,新增 emitNextDataRootPage 按根切页(5根/页), loadMoreData 改同步切页;main.cpp 加载更多计数改按根。 暗色主题保真(浅色保持与原版 1:1,仅暗色改 token): - 新增 ChartTheme::applyChartPlotTheme:按 isDarkTheme() 设 QwtPlot 画布/轴字/网格/零线配色,连 ThemeManager::changed 热切换。 - ColorBarWidget、LoadingOverlay 底色/蒙板/文字同步跟随主题。 详情图渲染保真: - colorBar alpha 标度修复(混合 hex+rgba 格式,rgba alpha 为 0–1,Bit255→Unit)。 - 散点 cauto 归一化(ColorMapService.setDataRange 解耦色阶形状与数据归一化)。 - 散点 hover 提示(ScatterHoverTip,X/Y/值 3 位小数,canvas mouseTracking)。 - 详情页签用数据名命名。 桌面端日志 + 崩溃捕获: - Logging:滚动日志 + MiniDump 崩溃捕获 + VEH 抛点符号化;main.cpp GuardedApplication::notify 顶层异常护栏。 - 根 CMakeLists Release 产出 PDB(/Zi /DEBUG);ColorMapService NaN/Inf 守卫。 测试 116→122 全绿(+ParseDsRowsParentIdForTree / DataPaginatesByRootNodeNotFlatCount / 散点/colormap 回归)。 --- CMakeLists.txt | 7 + .../HANDOFF-dataset-detail-chart.md | 79 +++++- ...026-06-11-dataset-detail-other-dd-types.md | 59 ++++- .../2026-06-11-dataset-detail-view-design.md | 53 +++- src/app/CMakeLists.txt | 10 +- src/app/Logging.cpp | 243 ++++++++++++++++++ src/app/Logging.hpp | 18 ++ src/app/main.cpp | 96 +++++-- src/app/panels/DatasetDetailPanel.cpp | 4 +- src/app/panels/DatasetListPanel.cpp | 78 ++++-- src/app/panels/DatasetListPanel.hpp | 12 +- src/app/panels/LoadingOverlay.cpp | 15 +- src/app/panels/chart/ChartTheme.cpp | 45 ++++ src/app/panels/chart/ChartTheme.hpp | 14 + src/app/panels/chart/ColorBarWidget.cpp | 18 +- src/app/panels/chart/ColorMapService.cpp | 16 +- src/app/panels/chart/ColorMapService.hpp | 13 +- src/app/panels/chart/GridDataChartView.cpp | 19 +- src/app/panels/chart/RawDataChartView.cpp | 43 +++- src/app/panels/chart/RawDataChartView.hpp | 2 + src/app/panels/chart/ScatterHoverTip.cpp | 72 ++++++ src/app/panels/chart/ScatterHoverTip.hpp | 43 ++++ src/controller/DatasetDetailController.cpp | 12 +- src/controller/DatasetDetailController.hpp | 4 +- src/controller/WorkbenchNavController.cpp | 101 ++++++-- src/controller/WorkbenchNavController.hpp | 10 +- src/data/api/ApiProjectRepository.cpp | 4 +- src/data/api/ApiProjectRepository.hpp | 3 +- src/data/dto/DatasetChartDto.cpp | 4 +- src/data/dto/NavDto.cpp | 3 + src/data/repo/IAsyncProjectRepository.hpp | 5 +- src/data/repo/RepoTypes.hpp | 3 + src/net/ApiCall.cpp | 8 + tests/CMakeLists.txt | 2 + tests/app/test_colormap_service.cpp | 38 +++ tests/app/test_scatter_hover.cpp | 17 ++ .../test_workbench_nav_controller.cpp | 44 +++- tests/data/test_dataset_chart_dto.cpp | 30 ++- tests/data/test_nav_dto.cpp | 18 ++ 39 files changed, 1126 insertions(+), 139 deletions(-) create mode 100644 src/app/Logging.cpp create mode 100644 src/app/Logging.hpp create mode 100644 src/app/panels/chart/ChartTheme.cpp create mode 100644 src/app/panels/chart/ChartTheme.hpp create mode 100644 src/app/panels/chart/ScatterHoverTip.cpp create mode 100644 src/app/panels/chart/ScatterHoverTip.hpp create mode 100644 tests/app/test_scatter_hover.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 82853d8..7d766ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,13 @@ set(CMAKE_AUTOUIC ON) if(MSVC) add_compile_options(/utf-8 /MP /W4 /permissive-) + # 生成 PDB——即使 Release 优化构建也产出调试符号,使 minidump / 运行期崩溃栈可符号化分析 + # (生产桌面端排障必需)。/Zi 编译期调试信息;/DEBUG 链接产 PDB;/OPT:REF,ICF 抵消 /DEBUG + # 默认关掉的优化,保持二进制优化+精简。仅非 Debug 配置启用。 + add_compile_options($<$>:/Zi>) + add_link_options($<$>:/DEBUG> + $<$>:/OPT:REF> + $<$>:/OPT:ICF>) endif() # ===================================================================== diff --git a/docs/superpowers/HANDOFF-dataset-detail-chart.md b/docs/superpowers/HANDOFF-dataset-detail-chart.md index da71414..f5ab629 100644 --- a/docs/superpowers/HANDOFF-dataset-detail-chart.md +++ b/docs/superpowers/HANDOFF-dataset-detail-chart.md @@ -1,7 +1,8 @@ # 交接文档:数据集详情图表 + 全 App 网络层异步化 > 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。 -> 分支 **`feat/dataset-detail-chart`**,领先 main **68 commits**,工作区干净,**测试 116/116 全绿**。 +> 分支 **`feat/dataset-detail-chart`**,领先 main 68 commits。**测试 122/122 全绿**。 +> ⚠️ **工作区脏**:sessions 0.1–0.5 的代码(散点 hover/日志崩溃捕获/colormap/数据集树/按根分页/暗色主题)**全部未提交**(用户多次选「保持现状」不提交不合并)。`git status` 一大堆 M/??,**别假设干净树**;接手前先 `git status` 看清。新增未跟踪文件:`src/app/Logging.*`、`src/app/panels/chart/{ScatterHoverTip,ChartTheme}.*`、`tests/app/test_scatter_hover.cpp`,以及若干 `*.jpeg/*.yml` 临时抓图/抓取产物(非源码,可忽略/清理)。 --- @@ -13,6 +14,80 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成 均**未合并入 main**,分支挂起等收尾。 +### 0.1 2026-06-12 渲染保真修复(本次会话) + +用户报客户端散点/网格颜色与原版差异大(大量点透明发白、图例只有 6 色 + 白缝)、散点缺 hover。经 Playwright 抓原版真实 API + 读源码定位为**通用根因**: + +- **colorBar alpha 标度 bug**(核心):`lvl/colorGradation/getDetail` 返回的 colorBar 是**混合格式**——hex `#00008B` 与 CSS `rgba(0, 0, 170, 1)`(**alpha 是 0–1 浮点**),共 18 段。`DatasetChartDto.cpp` 用 `AlphaScale::Bit255` 解析 → rgba 的 `a=1` 被当字节 → alpha≈1/255 近透明(12 个 rgba 段全成白缝;6 个 hex 段因 hex 分支强制 255 才可见)。**修复:`Bit255`→`Unit`**(一行)。散点连续插值 `ColorMapService` 的归一化位置 `val/maxVal` 已证**精确等于原版 Plotly colorscale 位置**,无需改。 +- **散点 hover**:新增 `ScatterHoverTip`(canvas 事件过滤器,与 LivePanner 共存),最近点命中显示 `X/Y/值`(各 3 位小数,对齐原版 Plotly hovertemplate `X: %{x:.3f}…`)。 +- 文件:改 `src/data/dto/DatasetChartDto.cpp`;新增 `src/app/panels/chart/ScatterHoverTip.{hpp,cpp}` + 接线 `RawDataChartView.{hpp,cpp}`;测试 `tests/data/test_dataset_chart_dto.cpp`(改用真实混合格式 + 断言 alpha=255 回归)、新增 `tests/app/test_scatter_hover.cpp`。**测试 116→118 全绿**,cpp-reviewer APPROVE。 +- ⚠️ **像素级视觉 1:1 待用户在运行的 app 内登录核对**(原生 Qt 窗口无法用 Playwright 驱动;以下各层已机器验证:抓包/源码/RED-GREEN/插值位置匹配)。 +- 观察(未改,待定):图例 `ColorBarWidget` 画 `stops-1=17` 段,丢弃最后一段最深色;原版疑为 18 段。非本次报障,留待用户决定是否补齐。 + +### 0.2 2026-06-12 第二轮修复(散点 cauto 归一化 + hover mouseTracking + 页签名) + +第一轮 alpha 修复后颜色不再透明,但散点仍与原版不一致(挤在暖色中段、无蓝无紫)。复查 Plotly `_fullData`:散点 **`cmin/cmax` 未设 → `cauto` 自动取 vlist 实际 min/max(本例 45.93–197.79)**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 0–1323 归一化数据 → 压进色阶 0.03–0.15 段。 + +- **修复 A(散点 cauto)**:`ColorMapService.setDataRange(dataMin,dataMax)` 解耦「色阶形状位置(按断点值)」与「数据值归一化(按数据 min/max)」;`RawDataChartView::setData` 按 `d.scatter.v` 有限值 min/max 调用。**数值验证 1:1**:手算修复后客户端对样本值的 RGB 与原版 Plotly 实测逐字节相同(如中位 92.01→[168,0,35]、62.34→[255,119,0])。网格仍用绝对阈值(`colorAtDiscrete`),不变。 +- **修复 B(hover 仍无效)**:根因 `QwtPlotCanvas` 默认不开 mouseTracking → 无按键时收不到 MouseMove。`ScatterHoverTip` 构造里加 `canvas->setMouseTracking(true)`。 +- **修复 C(页签用数据名)**:`ChartData` 加 `dsName`;`openDataset` 加可选第 3 参 `dsName`(默认空,向后兼容测试);`kDsNameRole` 存 dsName,`main.cpp` 双击传入,`DatasetDetailPanel` `addTab` 用 `dsName`(空回退 dsId)+ tooltip。 +- 文件:`ColorMapService.{hpp,cpp}`、`RawDataChartView.cpp`、`ScatterHoverTip.cpp`、`DatasetDetailController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`DatasetDetailPanel.cpp`、`main.cpp`;测试 `test_colormap_service.cpp`(+DataRangeDecouples)。**119/119 全绿**。 +- ⚠️ hover/页签名/视觉仍待用户运行核对(GUI 无法自动驱动)。 + +### 0.3 2026-06-12 桌面端日志 + 崩溃捕获(新基础设施) + +用户反馈:双击 ds 后状态栏报「内部系统错误」然后客户端崩溃;后端错误可接受,但客户端不该崩。复测发现 scatter/color 接口当时其实 200/干净数据 → 无日志根本无从定位。遂加生产级日志 + 崩溃捕获(已实测打通)。 + +- **`src/app/Logging.{hpp,cpp}`**:`initLogging()`(main.cpp 在 setApplicationName 后调用)。 + - `qInstallMessageHandler` 接管全 App `qDebug/qInfo/qWarning/qCritical/qFatal` → 写带时间戳/级别的滚动日志:`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_YYYYMMDD.log`(按天,UTF-8+BOM,启动清理 14 天前旧文件)。 + - **崩溃捕获**:`SetUnhandledExceptionFilter`(SEH,含未捕获 C++ 异常 0xE06D7363)+ `std::set_terminate`;崩溃时 `MiniDumpWriteDump` 写 `crash_*.dmp`(链 `Dbghelp`)+ 记一行 `[FATAL] 崩溃 code=… addr=… dump=…`。`SetErrorMode` 抑制系统弹窗。**已用临时 env 自检实测**:空指针崩溃 → 生成 6MB dmp + 日志记录 code=0xc0000005(自检块已删)。 + - dmp 可 VS/WinDbg 加载看完整调用栈。 +- **埋点**:`ApiCall::onFinished` 记录每个 API 响应(URL/http/code/err,成功 INFO/失败 WARN);`DatasetDetailController` openDataset + 各 loadFailed;App 启动版本/路径。net 层 `qWarning` 自动收录。 +- **顺带堵崩溃根因**(评审 H-2 + 防脏数据):`ColorMapService::colorAtContinuous` 对 NaN/Inf 的 t 回退首断点色(原会 `upper_bound` 返回 end() 后解引用越界崩溃);`RawDataChartView` vlist min/max 用 `isfinite` 跳过 NaN/±Inf。+ 单测 `ColorAtContinuousNaNSafe`。 +- **测试 119→120 全绿**。 +### 0.4 2026-06-12 崩溃定位 + 顶层异常护栏 + +用户提供 dump + log。日志序列:`openDataset(ERT2-WS_result.dat)` → **`dynamicForm` 后端返回 `code=500 sys.internalServerError`**(即"内部系统错误")→ scatter 200 → color 200 → `Qt has caught an exception thrown from an event handler` → 崩溃。 + +**dump 分析(winget 装 Microsoft.WinDbg,`!analyze -v`)**:异常 `e06d7363`(C++ EH),栈 `VCRUNTIME140!CxxThrowException ← msvcp140!std::_Xlength_error ← geopro_desktop+0x9ad0 ← +0xa94ee ← +0x24b71 ← +0x235c0`。即 **`std::length_error`**(STL 容器/字符串用非法 size resize/reserve/构造)。栈帧都在 geopro_desktop(含静态链接的 Qwt),但**该 dump 是旧构建、当前 PDB 已不匹配 → 符号化不出函数名**。静态排查 dynamicForm 解析 / DynamicFormView / selectDataset 失败路径 / 散点解析(已 try/catch) / 渲染 / ColorMapService 均未见明显抛点 → 确切行**待运行期护栏日志点名**。 + +**修复(主,根治"不该崩")**:`main.cpp` 加 `GuardedApplication : QApplication`,override `notify()` try/catch:任何 slot/事件处理器抛出的异常被拦截 + `qCritical` 记录 `[guard] 拦截... what | type | receiver=<对象类名> | event=<事件类型>`,然后吞掉(不终止)。后端故障等异常**不再使整个客户端退出**。 +**附带**:修 `appendCrashLine` 文件共享 bug(崩溃行原写不进日志——g_logFile 独占,第二句柄 open 失败;改为复用已打开句柄);`colorAtContinuous` NaN/Inf 守卫。 + +**护栏实测**:用户复现,护栏拦到 → 日志 `[guard] 拦截未捕获异常: vector too long | type=std::length_error | receiver=QNetworkReplyHttpImpl | event=43(MetaCall)`。即异常在**网络响应 finished 同步链**里(最后一个 chart 请求 color 返回 → `chartReady→openOrUpdate→渲染`)抛出 `std::length_error`("vector too long" = std::vector 用了非法尺寸,典型负数转 size_t)。进程**未崩**(护栏吞掉),但图表没渲染出来。 + +**诊断基础设施(关键补强)**: +1. **Release 构建之前不生成 PDB** → dump/崩溃栈对自家代码全部符号化失败(只有系统 DLL 导出表能解析)。已在根 `CMakeLists.txt` MSVC 块加 `/Zi`(编译) + `/DEBUG /OPT:REF /OPT:ICF`(链接):Release 保持优化的同时产出匹配 PDB。**生产桌面端排障必需。** +2. **VEH 抛点堆栈符号化**(`Logging.cpp`):`AddVectoredExceptionHandler` 在 C++ 异常(0xE06D7363)**抛出瞬间**(栈未展开、PDB 匹配)用 DbgHelp(`SymInitialize`+`SymLoadModuleExW`+`SymFromAddrW`+`SymGetLineFromAddrW64`,**宽字符**——UNICODE 下必须)打印 `模块+RVA 函数名+偏移 (文件:行)`。自测验证:`reserve(-1)` → 正确打印 `geopro::app::initLogging (Logging.cpp:245)`。即使异常被护栏吞掉也留下抛点栈。 + +**下一步定位 length_error 确切行**:用当前构建复现一次 → 日志 `[THROW]` 段直接给出 `geopro::app::<类>::<方法> (文件:行)`。**测试 120/120 全绿。** + +### 0.5 2026-06-12 数据集列表树化 + 按根分页 + 暗色主题保真(本次会话) + +用户三组报障,均已修复、构建链接通过、应用真实流程跑通无崩溃、**测试 120→122 全绿**: + +**(1) 数据集列表应是树(原为平铺)** +- **实地确认(铁律 Playwright)**:原版「数据管理」选 TM 后的数据列表是 **el-table tree-data**(有展开箭头)。脚本直连 API 验证 TM E3(项目 1458977804960256 垃圾掩埋場):10 个 DS → **4 个根**(默认折叠)= 3×源「ERT原始数据」+ 1×「ERT电极坐标」;派生「反演/接地电阻」按 `parentId`(==`sourceShowParentId`)挂源「原始数据」下。根 = parentId 为空或指向不在本批的「源文件节点」。 +- **改**:`DsRow` 加 `parentId`;`NavDto::parseDsRows` 解析 `sourceShowParentId`(回退 `parentId`);`DatasetListPanel::populateDatasetList` 由 `QListWidget` 改建 **`QTreeWidget`**(两遍法按 parentId 嵌套,卡片委托 `applyDatasetCardDelegate` 泛化到 `QAbstractItemView`);`main.cpp` `datasetList` 改 `QTreeWidget`(`setExpandsOnDoubleClick(false)`:双击=开详情、展开靠箭头),点击/双击/反向高亮/加载更多 4 处改 `QTreeWidget` API。文件页签仍平铺 `QListWidget`。默认折叠(对齐原版)。 +- 测试:`test_nav_dto.cpp::ParseDsRowsParentIdForTree`。 + +**(2) 分页应按「第一层节点(根)」算(原按扁平 DS)** +- **根因**:后端 `dsObject/data/page` 按**扁平 DS** 分页(脚本验证:total=10、pageSize=5 返回 5 条扁平行)——子节点的父常落在下一页 → 按页建树出孤儿根、首层数错乱。 +- **改**:`IAsyncProjectRepository::loadRowsAsync` 加 `int pageSize=5` 参数(接口/`ApiProjectRepository`/测试 stub 同步);`WorkbenchNavController::selectObject` 数据页改用大 pageSize(`kFetchAllPageSize=1000`)**一次取全**整棵,缓存 `allDataRows_`;新私有 `emitNextDataRootPage(bool append)` **客户端按根切页**(每页 `kDataRootPageSize=5` 个根 + 各自整棵子树,DFS 收集后按原序输出,`total`=根总数);`loadMoreData` 改同步切下一页(无请求);`dataRootsShown_` 游标,`resetSelectionState` 清空。删因此空置的 `moreDataReq_`。`main.cpp` `addTreeLoadMore` 计数改按顶层根。若 `listCount>` 读 spy 行参)。 + +**(3) 暗色主题图表样式未跟随** +- 原详情图 `QwtPlot`、`ColorBarWidget`、`LoadingOverlay` 全硬编码白底/浅色 → 暗色主题下刺眼白底/白蒙板。**原版 web 无暗色,故暗色为客户端自定**:**浅色分支保持原硬编码值=与原版 1:1 不动,仅暗色改 token**。 +- 新增 **`src/app/panels/chart/ChartTheme.{hpp,cpp}`** `applyChartPlotTheme(QwtPlot*)`:按 `isDarkTheme()` 设画布底色(`bg/panel`)/轴字(`text/secondary`)/网格(`border/default`)/零线(`border/strong`),遍历 itemList 重着色 grid/marker。`Raw/GridDataChartView` 删硬编码白块、ctor 末尾调用 + 连 `ThemeManager::changed` 热切换。 +- `ColorBarWidget::paintEvent` 底色/边框/刻度字按 `isDarkTheme()`(暗色 `bg/panel`/`border/strong`/`text/secondary`;色带格=数据色不变)+ ctor 连 changed→update。 +- `LoadingOverlay` 遮罩纱色由 `rgba(255,255,255,160)` 改按主题(暗色 `bg/app` 深纱)+ label 文字 `text/primary`,连 changed 热切换。 +- token 表见 `src/app/Theme.cpp`(`bg/panel` 白/`#161A20`、`text/secondary`、`border/*`)。 + +**新文件**:`src/app/panels/chart/ChartTheme.{hpp,cpp}`(已加 `src/app/CMakeLists.txt`)。 +**改动文件**:`RepoTypes.hpp`、`NavDto.cpp`、`IAsyncProjectRepository.hpp`、`ApiProjectRepository.{hpp,cpp}`、`WorkbenchNavController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`main.cpp`、`RawDataChartView.cpp`、`GridDataChartView.cpp`、`ColorBarWidget.cpp`、`LoadingOverlay.cpp`、`src/app/CMakeLists.txt`、`tests/{data/test_nav_dto,controller/test_workbench_nav_controller}.cpp`。 +**记忆新增**:`dataset-list-is-tree`(树结构 + 扁平分页坑 + 按根分页解法 + 暗色图表方案)。 +- ⚠️ **待用户运行核对(GUI 无法自动驱动)**:① 暗色下网格详情的「加载中…」蒙板是深纱、色阶条深底浅字;② 数据列表按 5 根分页(根多的 TM 才出「加载更多」)+ 树嵌套正确。机器侧已验证:脚本抓原版结构 + 构建 + 122 测试 + 真实登录流程日志跑通(项目 1458977804960256→E3→data/page→getDetail→dynamicForm,无崩溃)。 + --- ## 1. 背景 @@ -105,7 +180,7 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成 ## 6. 尚未完成 / 下一步(按优先级) ### A. 收尾本分支(最先) -分支领先 main 68 commits、116/116 绿、工作区干净。用户多次选「保持现状」未合并。下一步可问用户:①合并回 main(本地) ②推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ③保持现状。用 `superpowers:finishing-a-development-branch`。 +分支领先 main 68 commits、**122/122 绿**,但**工作区脏**(sessions 0.1–0.5 代码全未提交,见顶部 ⚠️)。用户多次选「保持现状」未提交未合并。**先决**:本次会话两组改动(数据集树/按根分页、暗色主题)**待用户在运行的 app 内目视核对**(见 0.5 末尾清单)——核对通过再谈提交。下一步可问用户:①目视核对本次改动 ②提交这批未提交工作(建议按主题分多个 commit:详情图保真 / 日志崩溃捕获 / 数据集树+分页 / 暗色主题)③合并回 main(本地) ④推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ⑤保持现状。用 `superpowers:finishing-a-development-branch`。 ### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED) 计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。 diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md index e9f332f..9b7552f 100644 --- a/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md @@ -45,23 +45,56 @@ --- -## 1. 各 dd 类型:样本可得性 + 渲染归类 + 可推进/BLOCKED 矩阵 +## 1. 各 dd 类型:实测编目(2026-06-12 全量遍历,已坐实) -> 现实约束(spec §2.4):当前可访问租户**仅 ERT / TEM / GPR 三类**;**GPR 对象无数据、无测井数据样本**。多数 dd 类型无活样本可参照。 -> ddCode 列为推断(源码无常量),**Phase 0 须用 Playwright 抓数据集列表项的真实 `ddCode` 字段核对后回填本表**,禁止写死未经核对的 ddCode。 +> **本节已由 2026-06-12 全量实测替换原"推断矩阵"。** 用脚本直连 API 遍历**两个账号**(威立雅租户 + 赛盈地空"数据多"账号 20 项目 / 108 TM / 752 DS),逐个 ds 核对 `ddCode` + `dsTypeCode` + 真实渲染。Phase 0 探查目标基本达成。 -| 数据类型(菜单/spec) | 推断 ddCode(待 Phase 0 核对) | 取数接口(已核对 OpenAPI 路径/参数) | 渲染归类 / 复用 | 样本可得性 | 状态 | +### 1.0 实测方法(脚本,已验证可用,勿臆测照此复刻) + +枚举 DS 的 API 链(**踩过的坑都在这**): +1. 项目列表:`POST /business/my/profile/project/page` body `{pageNo, pageSize}`(注意 **`pageNo` 不是 `pageNum`**)。 +2. 结构树:`GET /business/projectStruct/queryProjectStruct/{projectId}` → **扁平节点数组**,每节点 `{id, parentId, type, name, confCode, typeId}`。**`type=1`=项目根或 GS 节点,`type=2`=TM**。层级靠 `parentId` 链:项目根(parentId="0") → GS(type=1) → TM(type=2) → DS。**TM 可能直挂项目、也可能挂在 GS 下**(如雷达项目 项目→GS"北京"→TM)。 +3. DS 列表:`POST /business/dsObject/data/page` body **`{projectId, structParentId:, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**。 + - ⚠️ **字段是 `structParentId`(=tmId)+ `structParentConfType:2`,不是 `tmObjectId`**; + - ⚠️ **必须带 `classifyTypeList:[3]`**(=数据,缺了/换值后端直接 `code:500 sys.internalServerError`); + - ⚠️ `pageNo` 不是 `pageNum`。 + - 时序类另走 `POST /business/dsObject/timeSensor/data/page`(同 body)。 +4. DS 对象关键字段:`ddCode`(如 `dd_grid`)、`dsTypeCode`(英文,如 `WhitenedData`)、`name`(中文数据类型名)、`dsName`(文件名)、`id`(dsId)。 +5. 后端对该接口**间歇 500**,脚本需重试(4–8 次)。 + +**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定,URL 详情链接里的 `ddCode` 经常标错。** 实测:「视电阻率数据」「接地电阻」的详情链接都带 `ddCode=dd_inversion_data`,但渲染分别是反演剖面 / 柱状图,完全不同。**客户端策略分派必须用真实数据类型(ds 元数据的 `dsTypeCode`/`ddCode`),绝不能用详情链接的 ddCode。** + +### 1.1 已确认数据类型(10 种 ddCode,均有活样本) + +| ddCode | dsTypeCode | 数据类型(中) | 渲染视图(实测) | 客户端 | 样本 | |---|---|---|---|---|---| -| ERT 反演(已完成基线) | `dd_inversion_data` | `getErtRawDataScatterGraph/{id}`、`inversion/rows/{id}`、色阶 type1/2、`queryException/{id}` | 散点 + 等值面(已实现) | 有 | ✅ 已完成 | -| ERT 测量原始 | 待核对 | `GET dd/ert/measurement/scatter/graph?dsObjectId&vFieldCode`、`GET dd/ert/measurement/rows?dsObjectId` | 散点类 → 复用 `ScatterPlotItem`/`RawDataChartView`;rows 形态待 Phase 0 定 | ERT 租户在,**须 Phase 0 找到有数据对象** | 候选可推进(待 Phase 0 确认有样本) | -| ERT 测量/高密度(gr) | 待核对 | `GET dd/ert/measurement/gr/rows?dsObjectId` | 待 Phase 0 看形态(散点/伪剖面) | 同上 | 候选可推进(待 Phase 0 确认) | -| TEM 时序(设备时序) | 待核对 | `POST dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`)、`GET dd/ert/timeSensor/page` | 时序折线类 → 新建 `LineChartView`(x=时间,y=数值) | **TEM 租户在**,须 Phase 0 找到有数据对象 + 抓响应 | 候选可推进(待 Phase 0 确认有样本) | -| dd_grid(网格) | 待核对 | `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` | 等值面类 → 复用 `ContourPlotItem`/`GridDataChartView` | 当前租户**无样本** | 🚫 BLOCKED:待样本 | -| 轨迹 | 待核对 | `GET dd/ert/trajectory/rows?dsObjectId`、`GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` | 折线/路径类 → 待样本定(可能复用散点连线或新 `LineChartView`) | 当前租户**无样本** | 🚫 BLOCKED:待样本 | -| 测井(well logging) | 待核对(菜单「测井参数表」) | **OpenAPI 未见明确专用 rows 接口** —— Phase 0 须从原版抓真实请求确认接口 | 折线类 → 新建 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线) | **无测井数据样本** | 🚫 BLOCKED:待样本 + 待接口确认 | -| GPR(雷达剖面图像) | 待核对 | `GET dd/gpr/channel/image/{dsObjectId}`、`GET dd/gpr/channel/trace/spectrogram`、`GET dd/gpr/channel/querySegmentation` | 图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴) | **GPR 对象无数据** | 🚫 BLOCKED:待样本 | +| `dd_inversion_data` | ERT inversion data | 电阻率(反演)数据;TEM反演剖面;视电阻率数据 | **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** 2 页签 | ✅ 已做 | 多 | +| `dd_ert_measurement_data` | ERT raw data | ERT原始数据 | **③ 散点伪剖面**(x=斜距/y=伪深度, 视电阻率着色, A/B/M/N 悬浮, 含反演运算/生成视电阻率/色阶工具) + 数据列表 | 可做 | 多 | +| `dd_ert_measurement_gr_data` | earth resistance | ERT接地电阻 | **② 柱状图**(Y=电阻/欧姆, X=电极点) + 列表 | 可做 | 多 | +| `dd_trajectory_data` | Platform trajectory | ERT电极坐标;TEM坐标 | **④ 轨迹**:地图(测线折线) + 列表 + 高程 3 页签 | 可做 | 多 | +| `dd_grid` | WhitenedData | 白化数据 | **⑤ 列表**(序号/x/y 地表点) | 可做 | 有 | +| `dd_gpr_channel_detail` | RADAR_SINGLE_CHANNEL_PROFILE | 雷达单通道剖面数据 | **⑥ 雷达剖面灰度图像(B-scan radargram)** 左 + **单道波形(A-scan wiggle)** 右;工具:对比度滑块/灰度色阶/显示频谱图 | 待需求确认 | 雷达项目 | +| `dd_gpr_channel_image` | RADAR_SINGLE_CHANNEL_IMAGE_LIST | 雷达单通道图片列表 | **⑥ 同上**(B-scan 灰度图像 + A-scan 波形,与 channel_detail 同视图) | 待需求确认 | 雷达项目 | +| `dd_radar_channel_trajectory` | RADAR_SINGLE_CHANNEL_TRAJECTORY | 雷达单通道轨迹坐标 | **⑦ 地图轨迹**:真实地理底图(街道/河流) + GPS 测线路径(黄/绿线) + 起点/终点/轨迹点标记 | 待需求确认 | 雷达项目 | +| `dd_radar_rtk_trajectory` | rtk_trajectory | RTK 轨迹坐标 | **⑦ 地图轨迹 + RTK 坐标点设置面板**(GPS定位/解状态过滤:固定解/浮点解/单点解/差分、最大距离过滤、数据还原) | 待需求确认 | 雷达项目 | +| `dd_radar_preprocess_data` | RADAR_PREPROCESS_DATA | 雷达预处理数据 | **空白**(无标题/内容,仅空 map 容器)——疑数据中间态、无独立可视详情 | 待需求确认 | 雷达项目 | -**矩阵小结:** 唯一确定有数据的是 `dd_inversion_data`(已完成)。其余全部需 Phase 0 实地探查;ERT 测量类与 TEM 是**最可能**找到样本的(租户在),但「是否有具体对象带数据」必须 Phase 0 验证后才解锁实现任务。 +> 渲染视图编号①②③④⑤⑥⑦。**全 10 种 ddCode 渲染均已逐个打开实测(看截图)确认。** 归并后**共 7 种渲染视图**(① 散点+网格[已做] / ② 柱状图 / ③ 散点伪剖面 / ④ 轨迹(地图/列表/高程) / ⑤ 列表 / ⑥ 雷达剖面图像(B-scan+A-scan) / ⑦ 地图轨迹(GIS底图+GPS路径)),外加 `dd_radar_preprocess_data` 无独立详情。 +> +> 注:④(ERT/TEM 坐标 `dd_trajectory_data` 的"地图/列表/高程") 与 ⑦(雷达 `dd_radar_*_trajectory` 的"GIS底图+GPS路径") 都是"轨迹/地图"族但组件形态不同——④是平面测线轨迹+高程剖面页签,⑦是真实地理底图叠 GPS 路径(+RTK 解过滤);实现时可能各自一个 View 或共用底图 View 加配置。 + +**5 个 radar/gpr 样本路径**(同一条 GPR 测线):项目 **雷达0331**(projectId=`1454042286333952`) → GS **北京**(`1454042309754880`) → TM **1229cx1-0_160**(`1454042386726912`, confCode=gpr) → DS: +- `dd_gpr_channel_detail` dsId=`1454042504011776`、`dd_gpr_channel_image`=`1454042523484160`、`dd_radar_channel_trajectory`=`1454042504060928`、`dd_radar_rtk_trajectory`=`1454042387619840`、`dd_radar_preprocess_data`=`1454042387505152`。 + +### 1.2 仍无样本(保持 BLOCKED) + +| 类型 | 接口(OpenAPI) | 状态 | +|---|---|---| +| TEM 时序 / timeSensor | `POST dd/ert/timeSensor/rows`、`dsObject/timeSensor/data/page` | 🚫 两账号均无此 ds 类型样本(TEM 数据实为 dd_inversion_data + dd_trajectory_data)。`LineChartView` 折线视图无依据,不实现。 | +| 测井 well logging | OpenAPI 无明确接口 | 🚫 无样本、无接口。 | +| 电流法 currentmethod | `dd/indicator/currentmethod/rows`、`scatter/graph/{dsObjectId}` | 🚫 项目「填埋场监测」有 CurrentMethod 方法类型 TM,但无带数据 DS 样本。 | + +**小结:** 实测确认 **10 种 ddCode 有样本**(ERT/通用 5 种渲染已定 + radar/gpr 5 种渲染待定本轮确认);timeSensor/测井/电流法 仍无样本,保持 BLOCKED。客户端已做 1 种(`dd_inversion_data`)。 --- diff --git a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md index a4749b3..148748c 100644 --- a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md +++ b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md @@ -8,7 +8,9 @@ **架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。 -**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。 +**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部 + **hover 显 X/Y/值**)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。 + +**2026-06-12 渲染保真修复:** colorBar 是混合 hex+CSS-rgba 格式且 rgba 的 **alpha 为 0–1 浮点**;原 `DatasetChartDto` 用 `AlphaScale::Bit255` 解析致 12/18 段近透明(散点/网格/图例全发白)。修为 `AlphaScale::Unit`(一行根因修复,通用生效)。散点 `ColorMapService` 连续插值的归一化位置已证 1:1 等于原版 Plotly colorscale。新增 `ScatterHoverTip` 复刻原版 hovertemplate。详见 `HANDOFF-dataset-detail-chart.md §0.1`。 **未完成:** - **其余 dd 类型的详情图渲染**(§2.4):`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线)、GPR(`dd/gpr/channel/image`)、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类,GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。 @@ -54,8 +56,53 @@ 网格化参数(GridDialog)、色阶配置(colorEditor)、白化(WhiteningDialog)、滤波/迭代处理、异常框注/自动标注(AutoAnnotationDialog)、另存为(SaveAsDialog)、导出(ExportDialog)、描述富文本编辑、大视图(Esc) 全屏。 ### 2.4 后续 dd 类型(框架内扩展,非本 spec) -`dd_ert_measurement_data` / `dd_ert_measurement_gr_data` / `dd_grid` / `dd_trajectory_data`、测井(`y深度-x数值折线` / `x时间-y数值曲线`,见「测井参数表」)、GPR(`dd/gpr/channel/image`)、TEM 等。 -> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。 +其余 dd 类型详情图见下文 **§2.5(2026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。 + +### 2.5 数据类型全景:设计 taxonomy(Excel)vs 实测运行 ddCode + 渲染映射(2026-06-12) + +> 用脚本直连 API 全量遍历两个账号(威立雅 + 赛盈地空"数据多"账号,20 项目/108 TM/752 DS)+ 逐个打开详情看截图确认渲染。**关键纠正:详情渲染由数据集"真实数据类型"决定,URL 详情链接的 `ddCode` 经常标错,客户端分派须用 ds 元数据的真实类型。** + +#### 2.5.1 实测 10 种 ddCode → 7 种渲染视图(均有活样本、看截图确认) + +| 渲染视图 | ddCode | 数据类型 | 客户端 | +|---|---|---|---| +| **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** | `dd_inversion_data` | ERT/TEM 反演剖面、视电阻率数据 | ✅ 已做 | +| **② 柱状图(Y=电阻欧姆,X=电极点) + 列表** | `dd_ert_measurement_gr_data` | ERT接地电阻 | ❌ | +| **③ 散点伪剖面(斜距/伪深度,视电阻率着色,反演运算工具) + 数据列表** | `dd_ert_measurement_data` | ERT原始数据 | ❌ | +| **④ 轨迹:地图 + 列表 + 高程 3页签** | `dd_trajectory_data` | ERT电极坐标、TEM坐标 | ❌ | +| **⑤ 列表(序号/x/y)** | `dd_grid` | 白化数据 | ❌ | +| **⑥ 雷达剖面灰度图像(B-scan) + 单道波形(A-scan) + 对比度/灰度色阶/频谱** | `dd_gpr_channel_detail`、`dd_gpr_channel_image` | 雷达单通道剖面/图片列表 | ❌ | +| **⑦ 地图轨迹(真实GIS底图 + GPS路径 + 起点/终点)**(RTK 另带坐标点设置/解状态过滤面板) | `dd_radar_channel_trajectory`、`dd_radar_rtk_trajectory` | 雷达单通道轨迹/RTK轨迹 | ❌ | +| (无独立可视详情,疑数据中间态) | `dd_radar_preprocess_data` | 雷达预处理数据 | — | + +**共 7 种渲染视图,客户端已做 1 种(①);待做 6 种:② 柱状图、③ 散点伪剖面、④ 轨迹、⑤ 列表、⑥ 雷达剖面图像、⑦ 地图轨迹。** + +#### 2.5.2 与 `Geopro3.0 菜单.xlsx`「DD类型」设计 taxonomy 的对应 + +Excel「DD类型」页签是**设计期格式蓝图**,用的是另一套命名(`dd_Section`/`dd_Track`/`dd_ERTRawData`/`dd_GPRSection`/`dd_Images`/`dd_TimeVarious`…)。与实际运行 ddCode **命名对不上、语义对得上**: + +| Excel 设计格式 | 描述(Excel) | 对应实测运行 ddCode | 样本 | +|---|---|---|---| +| `dd_Section`("最重要":水平/垂直剖面) | 反演剖面 | `dd_inversion_data`(①)、`dd_gpr_channel_detail`(⑥) | ✅ | +| `dd_ERTRawData` | ERT原始数据 | `dd_ert_measurement_data`(③)、`dd_ert_measurement_gr_data`(②) | ✅ | +| `dd_GPRSection`(设计注:讨论是否并入 dd_Section) | 雷达剖面 | `dd_gpr_channel_detail`/`image`(⑥) | ✅ | +| `dd_Track`(坐标+轨迹合并) | 电极坐标/采集轨迹 | `dd_trajectory_data`(④)、`dd_radar_*_trajectory`(⑦) | ✅ | +| `dd_Images` | 图片类 | `dd_gpr_channel_image`(⑥) | ✅ | +| `dd_Files` | 文件打包(如三维雷达原始包) | `dd_radar_preprocess_data`(疑) | ✅部分 | +| `dd_TimeVarious` | 时间连续变量 | =设计中的 TEM时序/timeSensor 折线 | ❌ 无样本 | +| `dd_Stratigraphy` | 地层编录/**测井** | =设计中的测井折线/深度 | ❌ 无样本 | +| `dd_Segy` | 地震 SEGY(只展示) | — | ❌ 无地震数据 | +| `dd_Sampling` | 采样/化学(XRF/VOCs) | — | ❌ | +| `dd_LinearVarious` / `dd_Structual3D` / `dd_Property3D` / `dd_Video` | 线性变量/三维结构/三维属性/视频 | — | ❌ | +| `dd_KML`/`dd_GeoJson`/`dd_shp`/`dd_dem`/`dd_bim`/`dd_czml`… | 背景资料**图层**(导入/预览/坐标对齐) | — | 非"数据详情图",是地图图层 | + +**结论:** Excel 是"应有哪些数据格式"的设计蓝图;实测 10 种是"现在真有数据、详情页真能出图"的子集(电法 ERT + 地质雷达 GPR 两大类的落地)。两者语义吻合;Excel 里 时序(dd_TimeVarious)/测井(dd_Stratigraphy)/地震(dd_Segy)/采样/三维模型/视频 等当前**无活样本,BLOCKED**(与 plan §1.2 一致);KML/shp/dem/bim 等是地图图层,不属本 spec 的详情图范畴。 + +#### 2.5.3 枚举 DS 的 API(实测可用,踩坑记录) + +`POST my/profile/project/page`{pageNo,pageSize} → `GET projectStruct/queryProjectStruct/{pid}`(扁平节点:type=1=项目/GS,type=2=TM,parentId 链表层级;TM 可挂项目或 GS 下)→ `POST dsObject/data/page` body **`{projectId, structParentId:, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**(字段是 `structParentId` 不是 `tmObjectId`;必须带 `classifyTypeList:[3]` 否则后端 `code:500`;`pageNo` 不是 `pageNum`;时序类另走 `dsObject/timeSensor/data/page`;后端间歇 500 需重试)。DS 字段:`ddCode`+`dsTypeCode`(英文)+`name`(中文类型名)+`dsName`(文件)。 + +> 现实约束:当前可访问租户实测有样本的方法类为 ERT/TEM/GPR;测井/地震/采样/三维/时序无活样本。无样本类详情页**无可参照**,须待数据样本到位后再精确 1:1 复刻。 --- diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index f98119e..1ef6c5a 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,18 +29,21 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp panels/chart/ScatterPlotItem.cpp panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp + panels/chart/ScatterHoverTip.cpp panels/AnomalyTablePanel.cpp panels/LoadingOverlay.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp CentralScene.cpp ProjectListDialog.cpp - SettingsDialog.cpp) + SettingsDialog.cpp + Logging.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) # QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。 @@ -68,6 +71,11 @@ endif() vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES}) +# 崩溃 minidump:DbgHelp(Windows 自带)提供 MiniDumpWriteDump。 +if(WIN32) + target_link_libraries(geopro_desktop PRIVATE Dbghelp) +endif() + if(WIN32) add_custom_command(TARGET geopro_desktop POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different diff --git a/src/app/Logging.cpp b/src/app/Logging.cpp new file mode 100644 index 0000000..4b2e2e5 --- /dev/null +++ b/src/app/Logging.cpp @@ -0,0 +1,243 @@ +#include "Logging.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef Q_OS_WIN +// clang-format off +#include +#include // MiniDumpWriteDump(链接 Dbghelp) +// clang-format on +#endif + +namespace geopro::app { +namespace { + +QFile g_logFile; +QMutex g_mutex; +QString g_logDir; +constexpr int kRetentionDays = 14; // 旧日志/dump 保留天数 + +const char* levelStr(QtMsgType t) { + switch (t) { + case QtDebugMsg: return "DEBUG"; + case QtInfoMsg: return "INFO"; + case QtWarningMsg: return "WARN"; + case QtCriticalMsg: return "ERROR"; + case QtFatalMsg: return "FATAL"; + } + return "INFO"; +} + +// 线程安全写一行(落盘 + 同步到 stderr 便于开发期观察)。 +void writeLine(const QString& line) { + const QByteArray utf8 = line.toUtf8(); + QMutexLocker lock(&g_mutex); + if (g_logFile.isOpen()) { + g_logFile.write(utf8); + g_logFile.write("\n", 1); + g_logFile.flush(); + } + std::fwrite(utf8.constData(), 1, static_cast(utf8.size()), stderr); + std::fputc('\n', stderr); +} + +void messageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) { + const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")); + QString line = QStringLiteral("%1 [%2] %3").arg(ts, QString::fromLatin1(levelStr(type)), msg); + if (ctx.file && type >= QtWarningMsg) // 警告及以上带源码定位,便于排查 + line += QStringLiteral(" (%1:%2)").arg(QString::fromUtf8(ctx.file)).arg(ctx.line); + writeLine(line); + if (type == QtFatalMsg) { + std::abort(); // qFatal 语义:记录后终止(触发崩溃捕获 → dump) + } +} + +void pruneOldFiles(const QString& dir) { + const QDateTime cutoff = QDateTime::currentDateTime().addDays(-kRetentionDays); + const QFileInfoList files = + QDir(dir).entryInfoList({QStringLiteral("geopro_*.log"), QStringLiteral("crash_*.dmp")}, + QDir::Files); + for (const QFileInfo& fi : files) + if (fi.lastModified() < cutoff) QFile::remove(fi.absoluteFilePath()); +} + +#ifdef Q_OS_WIN +// 崩溃时(写完 dump 后)追加一行摘要。直接写已打开的 g_logFile(进程将终止,不抢 g_mutex — +// 否则崩溃发生在持锁线程时会死锁;另开第二句柄又会因独占共享冲突失败)。best-effort。 +void appendCrashLine(const QString& line) { + const QByteArray utf8 = line.toUtf8(); + if (g_logFile.isOpen()) { + g_logFile.write(utf8); + g_logFile.write("\n", 1); + g_logFile.flush(); + } + std::fwrite(utf8.constData(), 1, static_cast(utf8.size()), stderr); + std::fputc('\n', stderr); +} + +LONG WINAPI crashFilter(EXCEPTION_POINTERS* info) { + const DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0; + const void* addr = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionAddress : nullptr; + const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz")); + const QString dumpPath = g_logDir + QStringLiteral("/crash_") + ts + QStringLiteral(".dmp"); + + bool dumped = false; + HANDLE hFile = CreateFileW(reinterpret_cast(dumpPath.utf16()), GENERIC_WRITE, 0, + nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION mei{}; + mei.ThreadId = GetCurrentThreadId(); + mei.ExceptionPointers = info; + mei.ClientPointers = FALSE; + const MINIDUMP_TYPE flags = static_cast( + MiniDumpWithDataSegs | MiniDumpWithThreadInfo | MiniDumpWithHandleData); + dumped = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hFile, flags, + info ? &mei : nullptr, nullptr, nullptr); + CloseHandle(hFile); + } + appendCrashLine(QStringLiteral("%1 [FATAL] 崩溃 code=0x%2 addr=0x%3 dump=%4") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))) + .arg(static_cast(code), 0, 16) + .arg(reinterpret_cast(addr), 0, 16) + .arg(dumped ? dumpPath : QStringLiteral("写入失败"))); + return EXCEPTION_EXECUTE_HANDLER; // 记录后终止进程 +} +#endif // Q_OS_WIN + +#ifdef Q_OS_WIN +// 向量化异常处理器:在 C++ 异常(0xE06D7363)**抛出瞬间**(栈未展开、进程健康、符号匹配) +// 捕获并符号化调用栈写日志。即使异常随后被 try/catch(如顶层护栏)吞掉,也已留下抛点堆栈。 +constexpr DWORD kCppExceptionCode = 0xE06D7363; +thread_local bool g_inVeh = false; // 防符号化过程自身再抛异常导致的重入 + +LONG WINAPI throwStackVeh(EXCEPTION_POINTERS* info) { + if (!info || !info->ExceptionRecord) return EXCEPTION_CONTINUE_SEARCH; + if (info->ExceptionRecord->ExceptionCode != kCppExceptionCode) return EXCEPTION_CONTINUE_SEARCH; + if (g_inVeh) return EXCEPTION_CONTINUE_SEARCH; + g_inVeh = true; + + void* frames[32]; + const USHORT n = CaptureStackBackTrace(1, 32, frames, nullptr); + const HANDLE proc = GetCurrentProcess(); + SymRefreshModuleList(proc); // 确保已加载模块(含本 exe 的 PDB)在符号表中 + alignas(SYMBOL_INFOW) char symBuf[sizeof(SYMBOL_INFOW) + 1024] = {}; + auto* sym = reinterpret_cast(symBuf); + sym->SizeOfStruct = sizeof(SYMBOL_INFOW); + sym->MaxNameLen = 1024 / sizeof(wchar_t) - 1; + appendCrashLine(QStringLiteral("%1 [THROW] C++ 异常抛出,调用栈:") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")))); + for (USHORT i = 0; i < n; ++i) { + const DWORD64 addr = reinterpret_cast(frames[i]); + // 模块名 + RVA:总是可得;即使符号未解析,也能离线用匹配 PDB 还原。 + QString modRva; + HMODULE mod = nullptr; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(addr), &mod) && + mod) { + wchar_t mpath[MAX_PATH] = {}; + GetModuleFileNameW(mod, mpath, MAX_PATH); + QString base = QString::fromWCharArray(mpath); + base = base.mid(base.lastIndexOf('\\') + 1); + modRva = QStringLiteral("%1+0x%2").arg(base).arg(addr - reinterpret_cast(mod), 0, 16); + } else { + modRva = QStringLiteral("0x%1").arg(addr, 0, 16); + } + // 符号名(宽字符:UNICODE 下 SymFromAddrW + WCHAR Name)。 + QString fn; + DWORD64 symDisp = 0; + if (SymFromAddrW(proc, addr, &symDisp, sym)) + fn = QStringLiteral(" %1+0x%2").arg(QString::fromWCharArray(sym->Name)).arg(symDisp, 0, 16); + // 文件:行。 + QString loc; + DWORD lineDisp = 0; + IMAGEHLP_LINEW64 line = {}; + line.SizeOfStruct = sizeof(IMAGEHLP_LINEW64); + if (SymGetLineFromAddrW64(proc, addr, &lineDisp, &line)) + loc = QStringLiteral(" (%1:%2)").arg(QString::fromWCharArray(line.FileName)).arg(line.LineNumber); + appendCrashLine(QStringLiteral(" #%1 %2%3%4").arg(i).arg(modRva).arg(fn).arg(loc)); + } + g_inVeh = false; + return EXCEPTION_CONTINUE_SEARCH; // 不处理,交回正常流程(顶层护栏 try/catch 仍会接住) +} +#endif // Q_OS_WIN + +void installCrashHandlers() { +#ifdef Q_OS_WIN + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); // 抑制系统崩溃弹窗 + SetUnhandledExceptionFilter(crashFilter); + SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS); + // 显式把 exe 目录作为符号搜索路径(geopro_desktop.pdb 与 exe 同目录、匹配);立即(非 deferred)加载。 + const QByteArray searchPath = QCoreApplication::applicationDirPath().toLocal8Bit(); + if (!SymInitialize(GetCurrentProcess(), searchPath.constData(), TRUE)) + std::fprintf(stderr, "[Logging] SymInitialize 失败 err=%lu\n", GetLastError()); + // 显式加载本 exe 的符号(invade 偶尔不加载主模块的私有符号 → 内部函数无名)。 + wchar_t selfPath[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, selfPath, MAX_PATH); + const DWORD64 selfBase = + SymLoadModuleExW(GetCurrentProcess(), nullptr, selfPath, nullptr, + reinterpret_cast(GetModuleHandleW(nullptr)), 0, nullptr, 0); + if (selfBase == 0 && GetLastError() != ERROR_SUCCESS) + std::fprintf(stderr, "[Logging] SymLoadModuleExW 失败 err=%lu\n", GetLastError()); + AddVectoredExceptionHandler(1, throwStackVeh); // first=1:先于 SEH 链,抛出瞬间捕获 +#endif + // 未捕获 C++ 异常(跨事件循环逃逸)→ 记录后终止。SEH 过滤器通常已先写 dump。 + std::set_terminate([] { + QString what = QStringLiteral("(无异常信息)"); + if (std::exception_ptr e = std::current_exception()) { + try { + std::rethrow_exception(e); + } catch (const std::exception& ex) { + what = QString::fromUtf8(ex.what()); + } catch (...) { + what = QStringLiteral("(非 std::exception)"); + } + } + writeLine(QStringLiteral("%1 [FATAL] std::terminate 未捕获异常: %2") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), what)); + std::abort(); + }); +} + +} // namespace + +void initLogging() { + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + g_logDir = base + QStringLiteral("/logs"); + QDir().mkpath(g_logDir); + pruneOldFiles(g_logDir); + + const QString fname = QStringLiteral("geopro_%1.log") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd"))); + g_logFile.setFileName(g_logDir + QStringLiteral("/") + fname); + const bool opened = g_logFile.open(QIODevice::Append | QIODevice::Text); + if (!opened) { // 打不开(权限等)则仅写 stderr(writeLine 已容错) + std::fprintf(stderr, "[Logging] 无法打开日志文件: %s\n", qUtf8Printable(g_logFile.fileName())); + } else if (g_logFile.size() == 0) { + g_logFile.write("\xEF\xBB\xBF", 3); // 新建文件写 UTF-8 BOM,便于中文 Windows 记事本正确识别编码 + } + + qInstallMessageHandler(messageHandler); + installCrashHandlers(); + + qInfo("=== Geopro 启动 v%s | 日志目录 %s ===", + qUtf8Printable(QCoreApplication::applicationVersion().isEmpty() + ? QStringLiteral("dev") + : QCoreApplication::applicationVersion()), + qUtf8Printable(g_logDir)); +} + +QString logDirectory() { return g_logDir; } + +} // namespace geopro::app diff --git a/src/app/Logging.hpp b/src/app/Logging.hpp new file mode 100644 index 0000000..f9b45a9 --- /dev/null +++ b/src/app/Logging.hpp @@ -0,0 +1,18 @@ +#pragma once +#include + +namespace geopro::app { + +// 初始化全局日志与崩溃捕获。需在 QApplication 构造、setOrganizationName/setApplicationName +// 之后调用一次(依赖 AppLocalDataLocation 定位日志目录)。 +// - 安装 qInstallMessageHandler:全 App 的 qDebug/qInfo/qWarning/qCritical/qFatal 写入 +// 带时间戳/级别的滚动日志文件(%LOCALAPPDATA%///logs/geopro_YYYYMMDD.log)。 +// - 安装崩溃捕获:未处理 SEH 异常(段错误等)+ std::terminate(未捕获 C++ 异常)→ +// 记录异常码/地址 + flush 日志,并在 Windows 上用 DbgHelp 写 crash_*.dmp(可 VS/WinDbg 事后分析)。 +// - 启动时清理 14 天前的旧日志/dump。 +void initLogging(); + +// 当前日志目录绝对路径(供 UI「打开日志目录」等使用,可选)。 +QString logDirectory(); + +} // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index 1dafd25..d22883c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -56,6 +57,7 @@ #include #include #include +#include #include #include @@ -73,6 +75,7 @@ #include "AuthService.hpp" #include "Credential.hpp" #include "Glyphs.hpp" +#include "Logging.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" #include "SettingsDialog.hpp" @@ -415,7 +418,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 auto* datasetTabs = new QTabWidget(); - auto* datasetList = new QListWidget(); + // 数据页签:树形列表(原版 el-table 树——派生数据挂源数据下,按 DsRow.parentId 嵌套)。 + auto* datasetList = new QTreeWidget(); + datasetList->setHeaderHidden(true); + datasetList->setColumnCount(1); + datasetList->setRootIsDecorated(true); // 显展开/折叠箭头 + datasetList->setIndentation(geopro::app::scaledPx(14)); + datasetList->setExpandsOnDoubleClick(false); // 双击=打开详情,不切展开(展开靠箭头) + datasetList->setSelectionBehavior(QAbstractItemView::SelectRows); geopro::app::applyDatasetCardDelegate(datasetList); datasetTabs->addTab(datasetList, QStringLiteral("数据")); auto* fileList = new QListWidget(); @@ -483,24 +493,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── - QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [&nav, &detailCtrl](QListWidgetItem* item) { - if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { + QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList, + [&nav, &detailCtrl](QTreeWidgetItem* item, int) { + if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } - const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); if (dsId.isEmpty()) return; nav.selectDataset(dsId); // 属性表单(现状) detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 }); // ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)── - QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList, - [&detailCtrl](QListWidgetItem* item) { - const QString dsId = item->data(geopro::app::kDsIdRole).toString(); - const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString(); - if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); + QObject::connect(datasetList, &QTreeWidget::itemDoubleClicked, datasetList, + [&detailCtrl](QTreeWidgetItem* item, int) { + const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); + const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); + const QString dsName = item->data(0, geopro::app::kDsNameRole).toString(); + if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName); }); // ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── @@ -541,10 +552,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 详情面板切 Tab → 反向高亮数据集列表对应行 ── QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, datasetList, [datasetList](const QString& dsId) { - for (int i = 0; i < datasetList->count(); ++i) - if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == - dsId) - datasetList->setCurrentRow(i); + for (QTreeWidgetItemIterator it(datasetList); *it; ++it) + if ((*it)->data(0, geopro::app::kDsIdRole).toString() == dsId) { + datasetList->setCurrentItem(*it); + break; + } }); // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 @@ -635,6 +647,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } return loaded; }; + // 数据树的「加载更多」:末尾顶层项;已加载数=树中非"加载更多"项总数(含各层子节点)。 + auto removeTreeLoadMore = [](QTreeWidget* tw) { + const int n = tw->topLevelItemCount(); + if (n > 0 && tw->topLevelItem(n - 1)->data(0, geopro::app::kDsLoadMoreRole).toBool()) + delete tw->takeTopLevelItem(n - 1); + }; + // total = 根节点总数(控制器按根分页);loaded 也按「第一层节点(根)」计 → 加载更多/页签数一致。 + auto addTreeLoadMore = [](QTreeWidget* tw, int total) { + int loaded = 0; + for (int i = 0; i < tw->topLevelItemCount(); ++i) + if (!tw->topLevelItem(i)->data(0, geopro::app::kDsLoadMoreRole).toBool()) ++loaded; + if (loaded < total) { + auto* m = new QTreeWidgetItem(tw); + m->setText(0, QStringLiteral("加载更多(%1/%2)").arg(loaded).arg(total)); + m->setData(0, geopro::app::kDsLoadMoreRole, true); + m->setTextAlignment(0, Qt::AlignCenter); + } + return loaded; + }; QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, &geopro::controller::WorkbenchNavController::switchWorkspace); QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, @@ -711,12 +742,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re datasetTabs->setTabText(1, QStringLiteral("文件")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, - [removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs]( + [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs]( const QString&, const std::vector& rows, int total, bool append) { - removeLoadMore(datasetList); + removeTreeLoadMore(datasetList); geopro::app::populateDatasetList(datasetList, rows, append); - const int loaded = addLoadMore(datasetList, total); + const int loaded = addTreeLoadMore(datasetList, total); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); datasetTabs->setTabText( 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) @@ -789,6 +820,32 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } // namespace +namespace { +// 顶层异常护栏:任何 slot / 事件处理器抛出的 C++ 异常都会经过 QApplication::notify。 +// 默认 Qt 不允许异常穿透事件循环 → terminate(崩溃)。这里拦截 + 记录(异常信息 + 接收者 +// 对象类名 + 事件类型,足以定位崩点),并吞掉以**保证后端故障等异常不致整个客户端退出**。 +// 注:吞异常后该次事件处理中断,可能留下局部不一致;但"不崩 + 有日志"优先于直接退出。 +class GuardedApplication : public QApplication { +public: + using QApplication::QApplication; + bool notify(QObject* receiver, QEvent* e) override { + try { + return QApplication::notify(receiver, e); + } catch (const std::exception& ex) { + qCritical("[guard] 拦截未捕获异常: %s | type=%s | receiver=%s | event=%d", + ex.what(), typeid(ex).name(), + receiver ? receiver->metaObject()->className() : "null", + e ? static_cast(e->type()) : 0); + } catch (...) { + qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d", + receiver ? receiver->metaObject()->className() : "null", + e ? static_cast(e->type()) : 0); + } + return false; + } +}; +} // namespace + int main(int argc, char* argv[]) { // 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。 @@ -798,7 +855,7 @@ int main(int argc, char* argv[]) // QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。 QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); - QApplication app(argc, argv); + GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃 // 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递 // (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。 @@ -808,6 +865,9 @@ int main(int argc, char* argv[]) QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); + // 日志 + 崩溃捕获:尽早安装(依赖上面的 Org/App 名定位日志目录)。生产桌面端问题可回溯。 + geopro::app::initLogging(); + // 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。 // 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。 geopro::app::applyPersistedThemeMode(); diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 765a002..205b843 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -22,7 +22,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon auto* p = pageFor(d.dsId); if (!p) { p = new DatasetDetailPage(this); - addTab(p, d.dsId); // 标题后续可换 ds 名 + const QString title = d.dsName.isEmpty() ? d.dsId : d.dsName; // 页签标题用数据名(空则回退 id) + const int idx = addTab(p, title); + setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名 // 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); } diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 8faf540..d2e2ab8 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -1,6 +1,8 @@ #include "panels/DatasetListPanel.hpp" +#include #include +#include #include #include #include @@ -8,6 +10,9 @@ #include #include #include +#include +#include +#include #include "Theme.hpp" @@ -104,20 +109,53 @@ public: }; } // namespace -void populateDatasetList(QListWidget* list, const std::vector& rows, bool append) { - if (!list) return; - if (!append) list->clear(); - for (const auto& d : rows) { - QString text = QString::fromStdString(d.dsName); - QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 - if (!d.typeName.empty()) - sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 - if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); - auto* item = new QListWidgetItem(text, list); - item->setData(kDsIdRole, QString::fromStdString(d.id)); - item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); - item->setData(kDsDdCodeRole, QString::fromStdString(d.ddCode)); +namespace { +// 建一条数据集树项(不挂载):列0 文本 = dsName +「创建时间 · 类型名」,data 存各角色。 +QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { + QString text = QString::fromStdString(d.dsName); + QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 + if (!d.typeName.empty()) + sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 + if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); + auto* item = new QTreeWidgetItem(); + item->setText(0, text); + item->setData(0, kDsIdRole, QString::fromStdString(d.id)); + item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode)); + item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode)); + item->setData(0, kDsNameRole, QString::fromStdString(d.dsName)); + return item; +} +} // namespace + +void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append) { + if (!tree) return; + if (!append) tree->clear(); + + // id→已在树中的项:!append 时为空;append(分页)时含已加载行,使新行能挂到既有父下。 + QHash byId; + for (QTreeWidgetItemIterator it(tree); *it; ++it) { + const QString id = (*it)->data(0, kDsIdRole).toString(); + if (!id.isEmpty()) byId.insert(id, *it); } + + // 第一遍:本批全部建项(不挂载)并登记 id,使同批内的父子也能互相找到。 + std::vector batch; + batch.reserve(rows.size()); + for (const auto& d : rows) { + auto* item = makeDatasetItem(d); + byId.insert(QString::fromStdString(d.id), item); + batch.push_back(item); + } + // 第二遍:按 parentId 挂载。父在集合内→作其子;否则(父是源文件节点/不在本批)→作树根。 + for (std::size_t i = 0; i < rows.size(); ++i) { + const QString pid = QString::fromStdString(rows[i].parentId); + QTreeWidgetItem* parent = pid.isEmpty() ? nullptr : byId.value(pid, nullptr); + if (parent && parent != batch[i]) + parent->addChild(batch[i]); + else + tree->addTopLevelItem(batch[i]); + } + // 默认折叠(对齐原版:仅显源数据根行,派生数据收在展开箭头内)。 } void populateFileList(QListWidget* list, const std::vector& rows, bool append) { @@ -141,13 +179,13 @@ void populateFileList(QListWidget* list, const std::vector& } } -void applyDatasetCardDelegate(QListWidget* list) { - if (!list) return; - list->setItemDelegate(new DatasetCardDelegate(list)); - list->setMouseTracking(true); // 让委托收到 hover 状态 - list->setSpacing(0); // 卡间距由委托内边距控制 - QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, list, - [list]() { list->viewport()->update(); }); +void applyDatasetCardDelegate(QAbstractItemView* view) { + if (!view) return; + view->setItemDelegate(new DatasetCardDelegate(view)); + view->setMouseTracking(true); // 让委托收到 hover 状态 + if (auto* list = qobject_cast(view)) list->setSpacing(0); // 卡间距由委托内边距控制 + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, view, + [view]() { view->viewport()->update(); }); } } // namespace geopro::app diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index d6b7f9f..a06d937 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -4,6 +4,8 @@ #include "repo/RepoTypes.hpp" class QListWidget; +class QTreeWidget; +class QAbstractItemView; namespace geopro::app { @@ -13,13 +15,17 @@ constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用) constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用) +constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用) -// 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。 -void populateDatasetList(QListWidget* list, const std::vector& rows, bool append); +// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 +// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。 +// append=true 时把新行挂到已加载的父节点下(分页)。 +void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append); // 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。 void populateFileList(QListWidget* list, const std::vector& rows, bool append); // 给数据/文件列表套用卡片委托(标题+元信息双行、悬停/选中圆角高亮+左强调竖条,规范§6.2)。 -void applyDatasetCardDelegate(QListWidget* list); +// 接受 QListWidget(文件)或 QTreeWidget(数据树)——故形参为其共同基类 QAbstractItemView。 +void applyDatasetCardDelegate(QAbstractItemView* view); } // namespace geopro::app diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp index 00bc80f..095c1f9 100644 --- a/src/app/panels/LoadingOverlay.cpp +++ b/src/app/panels/LoadingOverlay.cpp @@ -1,19 +1,32 @@ #include "panels/LoadingOverlay.hpp" +#include #include #include #include +#include "Theme.hpp" + namespace geopro::app { LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作) setAttribute(Qt::WA_StyledBackground, true); - setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); label_->setText(QStringLiteral("加载中…")); label_->setAlignment(Qt::AlignCenter); auto* lay = new QVBoxLayout(this); lay->addWidget(label_); if (parent) parent->installEventFilter(this); + + // 半透明遮罩跟随主题:浅色白纱深字(原版),暗色深纱浅字,避免暗色下刺眼白蒙板。 + const auto applyTheme = [this]() { + const QColor veil = isDarkTheme() ? tokenColor("bg/app") : QColor(255, 255, 255); + setStyleSheet(QStringLiteral("background: rgba(%1,%2,%3,160);") + .arg(veil.red()).arg(veil.green()).arg(veil.blue())); + label_->setStyleSheet(QStringLiteral("background: transparent; color: %1;") + .arg(tokenColor("text/primary").name())); + }; + applyTheme(); + connect(&ThemeManager::instance(), &ThemeManager::changed, this, applyTheme); hide(); } diff --git a/src/app/panels/chart/ChartTheme.cpp b/src/app/panels/chart/ChartTheme.cpp new file mode 100644 index 0000000..6abb138 --- /dev/null +++ b/src/app/panels/chart/ChartTheme.cpp @@ -0,0 +1,45 @@ +#include "panels/chart/ChartTheme.hpp" + +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +void applyChartPlotTheme(QwtPlot* plot) { + if (!plot) return; + const bool dark = isDarkTheme(); + // 浅色分支=原有硬编码值(保持与原版 1:1);暗色分支=主题 token。 + const QColor bg = dark ? tokenColor("bg/panel") : QColor(Qt::white); + const QColor axisText = dark ? tokenColor("text/secondary"): QColor(90, 90, 90); + const QColor gridMajor = dark ? tokenColor("border/default"): QColor(225, 225, 225); + const QColor gridMinor = dark ? tokenColor("bg/hover") : QColor(240, 240, 240); + const QColor zeroPen = dark ? tokenColor("border/strong") : QColor(180, 180, 180); + + plot->setCanvasBackground(QBrush(bg)); + plot->setAutoFillBackground(true); + QPalette pal = plot->palette(); + pal.setColor(QPalette::Window, bg); + pal.setColor(QPalette::WindowText, axisText); + pal.setColor(QPalette::Text, axisText); + plot->setPalette(pal); + + // 网格线 / 零线(line marker)按主题重着色——不动其它 item(散点/网格填充自带配色)。 + const QwtPlotItemList items = plot->itemList(); + for (QwtPlotItem* it : items) { + if (auto* g = dynamic_cast(it)) { + g->setMajorPen(gridMajor, 1.0, Qt::SolidLine); + g->setMinorPen(gridMinor, 1.0, Qt::DotLine); + } else if (auto* m = dynamic_cast(it)) { + if (m->lineStyle() != QwtPlotMarker::NoLine) m->setLinePen(zeroPen, 1.0); + } + } + plot->replot(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ChartTheme.hpp b/src/app/panels/chart/ChartTheme.hpp new file mode 100644 index 0000000..81fb6e9 --- /dev/null +++ b/src/app/panels/chart/ChartTheme.hpp @@ -0,0 +1,14 @@ +#pragma once + +class QwtPlot; + +namespace geopro::app { + +// 按当前主题给 QwtPlot 套底色/轴文字/网格/零线配色,并 replot。 +// 浅色:与原版 web 图表一致(白底 + 深灰轴字)——保持 1:1,不动。 +// 暗色:改深色画布(bg/panel) + 浅色轴字,避免暗色主题下刺眼白底。 +// 网格线 / 零线标记按主题重新着色(遍历 plot 的 item 列表,无需各视图持有指针)。 +// 在视图构造末尾调用一次;并连 ThemeManager::changed 再调用以热切换。 +void applyChartPlotTheme(QwtPlot* plot); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index 4e34289..a637760 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -2,6 +2,8 @@ #include #include +#include "Theme.hpp" + namespace geopro::app { static constexpr int kBarHeight = 18; // 色带高度(px) @@ -11,6 +13,9 @@ static constexpr int kFontSize = 9; // 刻度字号 ColorBarWidget::ColorBarWidget(QWidget* parent) : QWidget(parent) { setFixedHeight(36); + // 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。 + connect(&ThemeManager::instance(), &ThemeManager::changed, this, + qOverload<>(&QWidget::update)); } void ColorBarWidget::setColorScale(const core::ColorScale& scale) { @@ -23,7 +28,12 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { p.setRenderHint(QPainter::Antialiasing, false); const int W = width(); const int H = height(); - p.fillRect(0, 0, W, H, Qt::white); // 白底,对齐原版 + // 浅色=白底深字(原版 1:1);暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。 + const bool dark = isDarkTheme(); + const QColor bgColor = dark ? tokenColor("bg/panel") : QColor(Qt::white); + const QColor borderColor = dark ? tokenColor("border/strong") : QColor(120, 120, 120); + const QColor textColor = dark ? tokenColor("text/secondary"): QColor(60, 60, 60); + p.fillRect(0, 0, W, H, bgColor); auto stops = scale_.stops(); if (stops.size() < 2) return; @@ -47,14 +57,14 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a)); } // 外边框 - p.setPen(QPen(QColor(120, 120, 120), 1)); + p.setPen(QPen(borderColor, 1)); p.drawRect(barLeft, barY, barW - 1, barH - 1); - // 边界值标签(深色文字,白底可见),在各分格边界下方。 + // 边界值标签(随主题:浅色深字 / 暗色浅字),在各分格边界下方。 QFont font = p.font(); font.setPixelSize(kFontSize); p.setFont(font); - p.setPen(QColor(60, 60, 60)); + p.setPen(textColor); QFontMetrics fm(font); const int tickY = barY + barH + 1; for (int i = 0; i <= nSeg; ++i) { diff --git a/src/app/panels/chart/ColorMapService.cpp b/src/app/panels/chart/ColorMapService.cpp index 475ddf9..e316468 100644 --- a/src/app/panels/chart/ColorMapService.cpp +++ b/src/app/panels/chart/ColorMapService.cpp @@ -19,10 +19,14 @@ ColorMapService::ColorMapService(const core::ColorScale& scale) if (raw.empty()) { minVal_ = 0.0; maxVal_ = 1.0; + dataMin_ = minVal_; + dataMax_ = maxVal_; return; } minVal_ = raw.front().first; maxVal_ = raw.back().first; + dataMin_ = minVal_; // 默认数据范围 = 断点范围;setDataRange 可改为数据 min/max(cauto) + dataMax_ = maxVal_; double range = maxVal_ - minVal_; normStops_.reserve(raw.size()); for (const auto& [val, color] : raw) { @@ -31,10 +35,15 @@ ColorMapService::ColorMapService(const core::ColorScale& scale) } } +void ColorMapService::setDataRange(double dataMin, double dataMax) { + dataMin_ = dataMin; + dataMax_ = dataMax; +} + double ColorMapService::normalized(double v) const { - double range = maxVal_ - minVal_; + double range = dataMax_ - dataMin_; if (range <= 0.0) return 0.0; - return clamp01((v - minVal_) / range); + return clamp01((v - dataMin_) / range); } core::Rgba ColorMapService::colorAtContinuous(double v) const { @@ -42,6 +51,9 @@ core::Rgba ColorMapService::colorAtContinuous(double v) const { if (normStops_.size() == 1) return normStops_.front().color; double t = normalized(v); + // 非有限值(NaN/Inf,可能来自降级后端的脏数据或退化数据范围):回退首断点色, + // 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。 + if (!std::isfinite(t)) return normStops_.front().color; // 找到 t 落在哪两个 normStop 之间 if (t <= normStops_.front().pos) return normStops_.front().color; diff --git a/src/app/panels/chart/ColorMapService.hpp b/src/app/panels/chart/ColorMapService.hpp index a824794..f686851 100644 --- a/src/app/panels/chart/ColorMapService.hpp +++ b/src/app/panels/chart/ColorMapService.hpp @@ -11,7 +11,12 @@ class ColorMapService { public: explicit ColorMapService(const core::ColorScale& scale); - // 将数据值归一化到 [0,1](min=首断点值, max=末断点值),超范围 clamp。 + // 设置数据值归一化范围(散点用,对齐原版 Plotly cauto=数据 min/max)。 + // 与色阶形状(断点位置,按断点值范围)解耦:色阶位置不变,只改输入值→[0,1] 的映射。 + // 默认(不调用)数据范围 = 断点值范围(首/末断点)。 + void setDataRange(double dataMin, double dataMax); + + // 将数据值归一化到 [0,1](按数据范围,默认=断点值范围),超范围 clamp。 double normalized(double v) const; // 连续插值取色(散点用):按断点位置线性插值 RGB。 @@ -31,8 +36,10 @@ private: core::Rgba color; }; std::vector normStops_; - double minVal_; - double maxVal_; + double minVal_; // 断点值范围下界(色阶形状用) + double maxVal_; // 断点值范围上界 + double dataMin_; // 数据归一化下界(默认=minVal_,setDataRange 可改) + double dataMax_; // 数据归一化上界(默认=maxVal_) }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index c41eaa4..f3c16c2 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -15,8 +15,10 @@ #include #include "PanelHeader.hpp" +#include "Theme.hpp" #include "panels/AnomalyTablePanel.hpp" #include "panels/DescriptionPanel.hpp" +#include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorMapService.hpp" #include "panels/chart/ContourPlotItem.hpp" @@ -103,16 +105,8 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { plot_->enableAxis(QwtPlot::xTop, false); plot_->enableAxis(QwtPlot::yLeft, true); - // 白底浅色(对齐原版 web 图表)。 - plot_->setCanvasBackground(QBrush(Qt::white)); - plot_->setAutoFillBackground(true); - { - QPalette pal = plot_->palette(); - pal.setColor(QPalette::Window, Qt::white); - pal.setColor(QPalette::WindowText, QColor(90, 90, 90)); - pal.setColor(QPalette::Text, QColor(90, 90, 90)); - plot_->setPalette(pal); - } + // 底色/轴字由 applyChartPlotTheme 按主题设置(ctor 末尾 + 主题热切换): + // 浅色=原版白底深灰字(1:1),暗色=深色画布避免刺眼白底。 // 交互:LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。 new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); @@ -171,6 +165,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { showLabels_ = on; if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } }); + + // 主题配色:当前主题套一次 + 监听切换热更新。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, + [this]() { applyChartPlotTheme(plot_); }); } GridDataChartView::~GridDataChartView() { diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 5840f61..3748a86 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,5 +1,7 @@ #include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterPlotItem.hpp" #include @@ -14,7 +16,12 @@ #include #include +#include +#include +#include + #include "panels/chart/LivePanner.hpp" +#include "Theme.hpp" namespace geopro::app { @@ -61,18 +68,10 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { plot_->enableAxis(QwtPlot::xBottom, false); plot_->enableAxis(QwtPlot::yLeft, true); - // 白底浅色(对齐原版 web 图表,与 App 暗色主题独立):画布白、轴文字深灰。 - plot_->setCanvasBackground(QBrush(Qt::white)); - plot_->setAutoFillBackground(true); - { - QPalette pal = plot_->palette(); - pal.setColor(QPalette::Window, Qt::white); - pal.setColor(QPalette::WindowText, QColor(90, 90, 90)); - pal.setColor(QPalette::Text, QColor(90, 90, 90)); - plot_->setPalette(pal); - } + // 底色/轴字/网格/零线配色由 applyChartPlotTheme 按主题统一设置(见 ctor 末尾 + 主题热切换)。 + // 浅色与原版 web 一致(白底深灰字);暗色改深色画布避免刺眼白底。 - // 横纵网格线(对齐原版浅灰网格)。 + // 横纵网格线(浅灰,暗色下由 applyChartPlotTheme 重着色)。 auto* grid = new QwtPlotGrid(); grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine); @@ -98,6 +97,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { // 交互:LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。 new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); + // 散点 hover 提示(X/Y/值)。后装(晚于 LivePanner)→ 事件链中先收到 MouseMove, + // 无按键时弹提示且不消费;有按键(拖动)跳过,交给 LivePanner。数据地址稳定,装配期绑定一次。 + hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); + hoverTip_->setField(&data_.scatter); + // 允许随停靠面板自由收缩(不强制最小宽度)。 plot_->setMinimumSize(0, 0); @@ -113,6 +117,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { colorBar_ = new ColorBarWidget(this); colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); lay->addWidget(colorBar_); + + // 主题配色:当前主题套一次 + 监听切换热更新(暗色给深底,浅色保持白底=原版)。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, + [this]() { applyChartPlotTheme(plot_); }); } RawDataChartView::~RawDataChartView() { @@ -127,6 +136,7 @@ QWidget* RawDataChartView::plotArea() const { void RawDataChartView::setData( const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; + if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) if (d.scatterScale.empty()) return; @@ -134,6 +144,17 @@ void RawDataChartView::setData( delete colorSvc_; colorSvc_ = new ColorMapService(d.scatterScale); + // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max): + // 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : d.scatter.v) { + if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf(脏数据),否则数据范围被污染→全图 NaN 取色 + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); + // 卸载旧散点项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。 // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 if (scatterItem_) { diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 0344849..befb465 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -11,6 +11,7 @@ namespace geopro::app { class ColorBarWidget; class ScatterPlotItem; +class ScatterHoverTip; // 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 class RawDataChartView : public QWidget { @@ -34,6 +35,7 @@ private: // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 ScatterPlotItem* scatterItem_ = nullptr; + ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有) }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHoverTip.cpp b/src/app/panels/chart/ScatterHoverTip.cpp new file mode 100644 index 0000000..f38280b --- /dev/null +++ b/src/app/panels/chart/ScatterHoverTip.cpp @@ -0,0 +1,72 @@ +#include "panels/chart/ScatterHoverTip.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace geopro::app { + +ScatterHoverTip::ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) { + // 默认 widget 仅在按键按下时才收到 MouseMove;hover(无按键)需开启鼠标跟踪。 + plot_->canvas()->setMouseTracking(true); + plot_->canvas()->installEventFilter(this); + } +} + +bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + if (ev->type() == QEvent::Leave) { + QToolTip::hideText(); + return false; + } + if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev); + + auto* me = static_cast(ev); + // 拖动平移中(有按键)不弹提示——交给 LivePanner;无数据则跳过。 + if (me->buttons() != Qt::NoButton || !field_) return false; + + const auto& xs = field_->x; + const auto& ys = field_->y; + const auto& vs = field_->v; + const std::size_t n = std::min(xs.size(), ys.size()); + if (n == 0) { + QToolTip::hideText(); + return false; + } + + // 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap)。 + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + const QPointF mp = me->position(); + double bestD2 = std::numeric_limits::max(); + std::size_t bestI = 0; + for (std::size_t i = 0; i < n; ++i) { + const double dx = xMap.transform(xs[i]) - mp.x(); + const double dy = yMap.transform(ys[i]) - mp.y(); + const double d2 = dx * dx + dy * dy; + if (d2 < bestD2) { + bestD2 = d2; + bestI = i; + } + } + + if (bestD2 <= kHitRadiusPx * kHitRadiusPx) { + const double v = (bestI < vs.size()) ? vs[bestI] : 0.0; + QToolTip::showText(me->globalPosition().toPoint(), + scatterHoverText(xs[bestI], ys[bestI], v), plot_->canvas()); + } else { + QToolTip::hideText(); + } + return false; // 不消费,保留其它过滤器(LivePanner)链路 +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHoverTip.hpp b/src/app/panels/chart/ScatterHoverTip.hpp new file mode 100644 index 0000000..7c83409 --- /dev/null +++ b/src/app/panels/chart/ScatterHoverTip.hpp @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include "model/Field.hpp" // core::ScatterField + +class QwtPlot; + +namespace geopro::app { + +// 格式化散点 hover 提示文本,对齐原版 Plotly hovertemplate: +// X: {x:.3f}
    Y: {y:.3f}
    值: {v:.3f} +// inline 纯函数:无 qwt 依赖,可独立单测。 +inline QString scatterHoverText(double x, double y, double v) { + return QStringLiteral("X: %1
    Y: %2
    值: %3") + .arg(x, 0, 'f', 3) + .arg(y, 0, 'f', 3) + .arg(v, 0, 'f', 3); +} + +// 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点, +// 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。 +// 不消费事件,与 LivePanner(左键平移/滚轮缩放)共存。 +class ScatterHoverTip : public QObject { + Q_OBJECT +public: + ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + + // 数据由 RawDataChartView 持有(其成员 data_.scatter,地址稳定);本类只读不拥有。 + void setField(const core::ScatterField* field) { field_ = field; } + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + QwtPlot* plot_; + int xAxis_; + int yAxis_; + const core::ScatterField* field_ = nullptr; + + static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸 +}; + +} // namespace geopro::app diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index c753e86..c21d7a6 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,4 +1,5 @@ #include "DatasetDetailController.hpp" +#include #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" namespace geopro::controller { @@ -12,8 +13,12 @@ DatasetDetailController::~DatasetDetailController() { if (gridLoad_) gridLoad_->abort(); } -void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { +void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode, + const QString& dsName) { + qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId), + qUtf8Printable(ddCode), qUtf8Printable(dsName)); if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级 + qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } @@ -22,12 +27,13 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd chartLoad_ = load; emit loadStarted(dsId, LoadPhase::Chart); QObject::connect(load, &data::ChartLoad::done, this, - [this, load, dsId, ddCode](const data::ChartParts& parts) { + [this, load, dsId, ddCode, dsName](const data::ChartParts& parts) { if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 chartLoad_.clear(); ChartData d; d.dsId = dsId; d.ddCode = ddCode; + d.dsName = dsName; d.scatter = parts.scatter; d.scatterScale = parts.scatterScale; emit chartReady(d); @@ -36,6 +42,7 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd [this, load, dsId](const QString& msg) { if (load != chartLoad_) return; chartLoad_.clear(); + qWarning("[detail] 原数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); emit loadFailed(dsId, msg); }); } @@ -62,6 +69,7 @@ void DatasetDetailController::loadGridData(const QString& dsId, const QString& d [this, load, dsId](const QString& msg) { if (load != gridLoad_) return; gridLoad_.clear(); + qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); emit loadFailed(dsId, msg); }); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index d103e1d..5487bf0 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -18,7 +18,7 @@ public: Q_ENUM(LoadPhase) struct ChartData { - QString dsId, ddCode; + QString dsId, ddCode, dsName; // dsName:页签标题用(空则回退 dsId) geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; geopro::core::Grid grid{1, 1}; // Grid 无默认构造;以占位值初始化,openDataset 会覆盖 @@ -37,7 +37,7 @@ public: ChartStrategyRegistry& registry, QObject* parent = nullptr); ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this public slots: - void openDataset(const QString& dsId, const QString& ddCode); + void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString()); void focusDataset(const QString& dsId); void loadGridData(const QString& dsId, const QString& ddCode); signals: diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 8843727..9993240 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,5 +1,10 @@ #include "WorkbenchNavController.hpp" +#include +#include +#include +#include + #include "api/NavLoads.hpp" #include "api/NavRequest.hpp" #include "dto/NavDto.hpp" @@ -7,7 +12,14 @@ namespace geopro::controller { +namespace { +// 数据页树形:一次取全的大 pageSize(远超单 TM 实际 DS 数;超出会日志告警)+ 每页根节点数。 +constexpr int kFetchAllPageSize = 1000; +constexpr int kDataRootPageSize = 5; +} // namespace + using data::DsPage; +using data::DsRow; using data::DynamicForm; using data::ExceptionRow; using data::NavRequest; @@ -23,7 +35,7 @@ WorkbenchNavController::~WorkbenchNavController() { abortAll(); } bool WorkbenchNavController::anyInflight() const { if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || - moreDataReq_ || moreFilesReq_ || datasetReq_) + moreFilesReq_ || datasetReq_) return true; for (const auto& h : checkedInflight_) if (h) return true; @@ -44,7 +56,6 @@ void WorkbenchNavController::abortAll() { if (selDataReq_) selDataReq_->abort(); if (selFileReq_) selFileReq_->abort(); if (selDetailReq_) selDetailReq_->abort(); - if (moreDataReq_) moreDataReq_->abort(); if (moreFilesReq_) moreFilesReq_->abort(); if (datasetReq_) datasetReq_->abort(); for (const auto& h : checkedInflight_) @@ -56,6 +67,9 @@ void WorkbenchNavController::resetSelectionState() { tmExceptionCache_.clear(); currentParentId_.clear(); currentParentConfType_ = 0; + allDataRows_.clear(); + dataRootsShown_ = 0; + dataTotal_ = 0; } // ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ── @@ -204,8 +218,11 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType) const std::string pid = currentProjectId_; dataPageNo_ = 1; filePageNo_ = 1; + allDataRows_.clear(); + dataRootsShown_ = 0; - NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, dataPageNo_); + // 数据页:一次取全(大 pageSize),再按根客户端分页——保证树完整(子节点不会跨服务端分页丢失父)。 + NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, 1, kFetchAllPageSize); selDataReq_ = dReq; NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_); selFileReq_ = fReq; @@ -217,8 +234,12 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType) if (dReq != selDataReq_) return; selDataReq_.clear(); const auto page = qvariant_cast(v); - dataTotal_ = page.total; - emit datasetsLoaded(objectId, page.rows, page.total, false); + if (static_cast(page.rows.size()) < page.total) + qWarning() << "[nav] data/page 未取全:listCount=" << page.rows.size() + << " total=" << page.total << " → 树可能不完整(pageSize 不足)"; + allDataRows_ = page.rows; // 全量缓存;按根分页由 emitNextDataRootPage 切 + dataRootsShown_ = 0; + emitNextDataRootPage(false); // 首页(append=false) emitBusyIfChanged(); }); QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) { @@ -255,30 +276,56 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType) }); } -// ── loadMoreData / loadMoreFiles:单请求,append=true ── +// ── 数据页树形分页:从 allDataRows_ 按根切下一页(同步,无请求)── +// 根 = parentId 为空或不在本 TM 全量集合内(其父是源文件节点,不在 data/page 返回里)。 +// 每页取 kDataRootPageSize 个根 + 各自整棵子树;行序保持后端原序(便于稳定显示)。 +void WorkbenchNavController::emitNextDataRootPage(bool append) { + const QString parent = QString::fromStdString(currentParentId_); + // 本 TM 全部行 id 集合(判定谁是根)。 + std::unordered_set ids; + ids.reserve(allDataRows_.size()); + for (const auto& r : allDataRows_) ids.insert(r.id); + // 根索引(按原序)+ parentId→子索引表。 + std::vector rootIdx; + std::unordered_map> kids; + for (std::size_t i = 0; i < allDataRows_.size(); ++i) { + const std::string& p = allDataRows_[i].parentId; + if (p.empty() || ids.find(p) == ids.end()) + rootIdx.push_back(i); + else + kids[p].push_back(i); + } + const int rootCount = static_cast(rootIdx.size()); + dataTotal_ = rootCount; + + // 取本页根 [shown, end) 的整棵子树(DFS 收集索引),再按原序输出保稳定。 + const int end = std::min(dataRootsShown_ + kDataRootPageSize, rootCount); + std::unordered_set picked; + for (int k = dataRootsShown_; k < end; ++k) { + std::vector stack{rootIdx[k]}; + while (!stack.empty()) { + const std::size_t cur = stack.back(); + stack.pop_back(); + if (!picked.insert(cur).second) continue; + auto it = kids.find(allDataRows_[cur].id); + if (it != kids.end()) + for (std::size_t c : it->second) stack.push_back(c); + } + } + std::vector out; + out.reserve(picked.size()); + for (std::size_t i = 0; i < allDataRows_.size(); ++i) + if (picked.count(i)) out.push_back(allDataRows_[i]); + + dataRootsShown_ = end; + emit datasetsLoaded(parent, out, rootCount, append); +} + +// ── loadMoreData:数据页树形——同步切下一页根(无请求)。loadMoreFiles:文件页服务端分页 ── void WorkbenchNavController::loadMoreData() { if (currentParentId_.empty()) return; - if (moreDataReq_) moreDataReq_->abort(); - NavRequest* req = - repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); - moreDataReq_ = req; - const QString parent = QString::fromStdString(currentParentId_); - emitBusyIfChanged(); - QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) { - if (req != moreDataReq_) return; - moreDataReq_.clear(); - const auto page = qvariant_cast(v); - dataTotal_ = page.total; - emit datasetsLoaded(parent, page.rows, page.total, true); - emitBusyIfChanged(); - }); - QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { - if (req != moreDataReq_) return; - moreDataReq_.clear(); - --dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页 - emit loadFailed(QStringLiteral("datasets"), msg); - emitBusyIfChanged(); - }); + if (dataRootsShown_ >= dataTotal_) return; // 无更多根 + emitNextDataRootPage(true); } void WorkbenchNavController::loadMoreFiles() { diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index d2a9551..6869e03 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -61,6 +61,9 @@ private: void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged bool anyInflight() const; // OR 所有在飞 QPointer / 集合 void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树 + // 数据页树形分页:从 allDataRows_(一次取全的整棵)按「第一层节点(根)」切下一页, + // 每页 kDataRootPageSize 个根 + 各自整棵子树;total=根总数。append=false 首页、true 加载更多。 + void emitNextDataRootPage(bool append); data::IAsyncProjectRepository& repo_; bool lastBusy_ = false; @@ -71,8 +74,7 @@ private: QPointer selDataReq_; // selectObject:data 行 QPointer selFileReq_; // selectObject:file 行 QPointer selDetailReq_; // selectObject:对象详情 - QPointer moreDataReq_; - QPointer moreFilesReq_; + QPointer moreFilesReq_; // loadMoreFiles(数据页改客户端按根分页,无在飞句柄) QPointer datasetReq_; std::vector> checkedInflight_; // setCheckedTms:未命中缓存的并发批 @@ -84,8 +86,10 @@ private: std::map> tmExceptionCache_; int dataPageNo_ = 0; int filePageNo_ = 0; - int dataTotal_ = 0; + int dataTotal_ = 0; // 数据页:根节点总数(树形分页单位) int fileTotal_ = 0; + std::vector allDataRows_; // 当前 TM 一次取全的所有数据行(树形按根客户端分页用) + int dataRootsShown_ = 0; // 已 emit 的根节点数(loadMoreData 续切) }; } // namespace geopro::controller diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index c822806..273a977 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -78,7 +78,7 @@ NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectI NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, const std::string& parentId, int parentConfType, - int classifyType, int pageNo) { + int classifyType, int pageNo, int pageSize) { const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") : QStringLiteral("/business/dsObject/data/page"); const QJsonObject body{ @@ -87,7 +87,7 @@ NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, {QStringLiteral("structParentConfType"), parentConfType}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 5}}; + {QStringLiteral("pageSize"), pageSize}}; auto* call = api_.postJsonAsync(path, body); return new ApiNavRequest(call, [](const net::ApiResponse& r) { return QVariant::fromValue(dto::parseDsPage(r.data)); diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index ed391e8..28f10c9 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -20,7 +20,8 @@ public: NavRequest* listProjectTypesAsync() override; NavRequest* loadStructureAsync(const std::string& projectId) override; NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) override; + int parentConfType, int classifyType, int pageNo, + int pageSize = 5) override; NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override; NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override; NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override; diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 5cdf49c..5bc6ead 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -49,7 +49,9 @@ ColorScale parseColorBar(const QJsonObject& data) { if (pair.size() < 2) continue; const double val = num(pair.at(0)); const std::string rgba = pair.at(1).toString().toStdString(); - cs.addStop(val, parseColor(rgba, AlphaScale::Bit255)); + // API colorBar 颜色为混合格式:hex(#RRGGBB) 与 CSS rgba(r,g,b,a),其中 a 是 0–1 浮点 + // (实测 "rgba(0, 0, 170, 1)")。须用 Unit 标度(a*255),否则 a=1 被当字节 → alpha≈1 近透明。 + cs.addStop(val, parseColor(rgba, AlphaScale::Unit)); } return cs; } diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 2f91e4a..6f38f88 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -123,6 +123,9 @@ std::vector parseDsRows(const QJsonArray& arr) { d.typeName = str(o, "name"); // 注意:name 字段=ds类型名 d.ddCode = str(o, "ddCode"); d.createTime = str(o, "createTime"); + // 数据集树父节点:sourceShowParentId 是“显示树”父(=派生数据挂源数据下),回退 parentId。 + d.parentId = str(o, "sourceShowParentId"); + if (d.parentId.empty()) d.parentId = str(o, "parentId"); const QJsonObject f = o.value(QStringLiteral("file")).toObject(); d.fileName = str(f, "name"); d.fileUrl = str(f, "url"); diff --git a/src/data/repo/IAsyncProjectRepository.hpp b/src/data/repo/IAsyncProjectRepository.hpp index 9f9ed26..d7d354a 100644 --- a/src/data/repo/IAsyncProjectRepository.hpp +++ b/src/data/repo/IAsyncProjectRepository.hpp @@ -18,8 +18,11 @@ public: const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage virtual NavRequest* listProjectTypesAsync() = 0; // std::vector virtual NavRequest* loadStructureAsync(const std::string& projectId) = 0; // std::vector + // pageSize 默认 5(文件页/数据页服务端分页);数据页树形需一次取全→控制器传大 pageSize 取整棵子树, + // 再按“第一层节点(根)”客户端分页(详见 WorkbenchNavController::emitNextDataRootPage)。 virtual NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) = 0; // DsPage + int parentConfType, int classifyType, int pageNo, + int pageSize = 5) = 0; // DsPage virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index f1ecb2a..47261f5 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -5,8 +5,11 @@ namespace geopro::data { struct DsNode { std::string id, name, ddType; }; // data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode;文件行另含 file*。 +// parentId = 数据集树的父节点 id(取 sourceShowParentId,回退 parentId);空或不在本批=树根。 +// 原版数据列表是树:源「原始数据」为根,派生「反演/接地电阻」挂其下。 struct DsRow { std::string id, dsName, typeName, ddCode, createTime; + std::string parentId; std::string fileName, fileUrl; long long fileSize = 0; }; diff --git a/src/net/ApiCall.cpp b/src/net/ApiCall.cpp index a23d9ed..cbbe880 100644 --- a/src/net/ApiCall.cpp +++ b/src/net/ApiCall.cpp @@ -1,6 +1,7 @@ #include "ApiCall.hpp" #include +#include #include "ApiResponseParse.hpp" namespace geopro::net { @@ -15,7 +16,14 @@ void ApiCall::onFinished() { QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空 if (!reply) return; ApiResponse resp = buildResponse(reply); + const QString url = reply->url().toString(); // 先快照 URL(reply 即将 deleteLater) reply->deleteLater(); + // 错误判定口径同 spec §7:code != 200 || !rawError.isEmpty()。 + if (resp.rawError.isEmpty() && resp.code == 200) + qInfo("[net] %s http=%d code=%d", qUtf8Printable(url), resp.httpStatus, resp.code); + else + qWarning("[net] %s http=%d code=%d err=%s", qUtf8Printable(url), resp.httpStatus, resp.code, + qUtf8Printable(resp.rawError.isEmpty() ? resp.msg : resp.rawError)); emit finished(resp); deleteLater(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 087c5bc..25f26f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -104,6 +104,8 @@ target_sources(geopro_tests PRIVATE app/test_colormap_service.cpp ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ColorMapService.cpp ) +# 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。 +target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) diff --git a/tests/app/test_colormap_service.cpp b/tests/app/test_colormap_service.cpp index fb41754..c5a7e3d 100644 --- a/tests/app/test_colormap_service.cpp +++ b/tests/app/test_colormap_service.cpp @@ -1,4 +1,5 @@ #include +#include #include "panels/chart/ColorMapService.hpp" using namespace geopro; @@ -36,6 +37,43 @@ TEST(ColorMapService, ColorAtContinuousAtExtremes) { EXPECT_EQ(cMax.b, 220); } +// 复刻原版 Plotly:散点颜色按**数据 min/max(cauto)**归一化,而色阶形状(断点位置)仍按 +// colorBar 断点值范围。数据范围窄于色阶范围时,应把整段光谱铺满数据范围(而非压进一小段)。 +TEST(ColorMapService, DataRangeDecouplesFromColorscaleShape) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); // blue pos 0.0 + cs.addStop(500.0, core::Rgba{0, 255, 0, 255}); // green pos 0.5 + cs.addStop(1000.0, core::Rgba{255, 0, 0, 255}); // red pos 1.0 + app::ColorMapService svc(cs); + + // 默认数据范围 = 断点范围 [0,1000]:值 250 → 归一化 0.25 → 蓝绿之间(非纯绿)。 + EXPECT_NEAR(svc.normalized(250.0), 0.25, 1e-9); + + // 收窄数据范围到 [0,500](模拟 cauto 取数据 min/max): + svc.setDataRange(0.0, 500.0); + EXPECT_NEAR(svc.normalized(250.0), 0.5, 1e-9); + // 值 250 → 归一化 0.5 → 落在色阶位置 0.5 = 纯绿。 + auto mid = svc.colorAtContinuous(250.0); + EXPECT_EQ(mid.r, 0); EXPECT_EQ(mid.g, 255); EXPECT_EQ(mid.b, 0); + // 数据范围两端铺到色阶两端:0→蓝、500→红。 + auto lo = svc.colorAtContinuous(0.0); + EXPECT_EQ(lo.b, 255); EXPECT_EQ(lo.r, 0); + auto hi = svc.colorAtContinuous(500.0); + EXPECT_EQ(hi.r, 255); EXPECT_EQ(hi.b, 0); +} + +// 脏数据健壮性:NaN 值取色不得崩溃(曾因 upper_bound 用 NaN 比较返回 end() 后解引用越界)。 +TEST(ColorMapService, ColorAtContinuousNaNSafe) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{11, 22, 33, 255}); + cs.addStop(100.0, core::Rgba{200, 210, 220, 255}); + app::ColorMapService svc(cs); + auto c = svc.colorAtContinuous(std::nan("")); // 不崩 → 回退首断点色 + EXPECT_EQ(c.r, 11); + EXPECT_EQ(c.g, 22); + EXPECT_EQ(c.b, 33); +} + TEST(ColorMapService, ScaleRefReturnsOriginal) { core::ColorScale cs; cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); diff --git a/tests/app/test_scatter_hover.cpp b/tests/app/test_scatter_hover.cpp new file mode 100644 index 0000000..e640929 --- /dev/null +++ b/tests/app/test_scatter_hover.cpp @@ -0,0 +1,17 @@ +#include +#include "panels/chart/ScatterHoverTip.hpp" + +using geopro::app::scatterHoverText; + +// 对齐原版 Plotly hovertemplate: +// X: %{x:.3f}
    Y: %{y:.3f}
    值: %{marker.color:.3f} +TEST(ScatterHoverTip, FormatsXYValueWith3Decimals) { + const QString t = scatterHoverText(12.3456, 24.0, 60.77); + EXPECT_EQ(t, QStringLiteral("X: 12.346
    Y: 24.000
    值: 60.770")); +} + +TEST(ScatterHoverTip, RoundsAndPadsToFixed3) { + // 负值、整数、需补零各覆盖一次 + EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0), + QStringLiteral("X: -1.000
    Y: 0.500
    值: 1.000")); +} diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp index 6a60b2e..a74ba2f 100644 --- a/tests/controller/test_workbench_nav_controller.cpp +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -42,7 +42,7 @@ struct StubAsyncRepo : data::IAsyncProjectRepository { return lastStructure = new StubNavRequest; } data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType, - int) override { + int, int) override { auto* r = new StubNavRequest; if (classifyType == 1) lastFile = r; @@ -208,6 +208,48 @@ TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) { EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets")); } +namespace { +// 构造一棵树:6 个根(parentId="src" 不在集合内) + r1 两个子(c1a/c1b)。扁平 8 行。 +QVariant treePageVar() { + auto mk = [](const std::string& id, const std::string& parent) { + data::DsRow d; d.id = id; d.dsName = id; d.ddCode = "dd"; d.parentId = parent; return d; + }; + std::vector rows; + for (int i = 1; i <= 6; ++i) rows.push_back(mk("r" + std::to_string(i), "src")); + rows.push_back(mk("c1a", "r1")); + rows.push_back(mk("c1b", "r1")); + return QVariant::fromValue(data::DsPage{rows, static_cast(rows.size())}); +} +} // namespace + +// 数据页树形分页:按「第一层节点(根)」分页(每页 5 根),total=根总数,子树随根整棵带出; +// loadMoreData 同步续切下一页根。 +TEST(WorkbenchNavController, DataPaginatesByRootNodeNotFlatCount) { + qRegisterMetaType>(); + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded); + c.selectObject("tm1", 2); + repo.lastData->fireDone(treePageVar()); // 一次取全 8 行 + + ASSERT_EQ(dsSpy.count(), 1); + const auto rows0 = qvariant_cast>(dsSpy.at(0).at(1)); + EXPECT_EQ(rows0.size(), 7u); // 首页 5 根(r1..r5) + r1 两子 = 7 行 + EXPECT_EQ(dsSpy.at(0).at(2).toInt(), 6); // total = 根总数 6(非扁平 8) + EXPECT_FALSE(dsSpy.at(0).at(3).toBool()); // append=false + + c.loadMoreData(); // 同步切下一页(无新请求) + ASSERT_EQ(dsSpy.count(), 2); + const auto rows1 = qvariant_cast>(dsSpy.at(1).at(1)); + EXPECT_EQ(rows1.size(), 1u); // 第二页第 6 个根 r6 + EXPECT_EQ(rows1[0].id, "r6"); + EXPECT_EQ(dsSpy.at(1).at(2).toInt(), 6); + EXPECT_TRUE(dsSpy.at(1).at(3).toBool()); // append=true + + c.loadMoreData(); // 已无更多根 → 不再 emit + EXPECT_EQ(dsSpy.count(), 2); +} + // 失败路径:start 首级失败 → loadFailed + busy 复位。 TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { StubAsyncRepo repo; diff --git a/tests/data/test_dataset_chart_dto.cpp b/tests/data/test_dataset_chart_dto.cpp index 876da41..0c36ac1 100644 --- a/tests/data/test_dataset_chart_dto.cpp +++ b/tests/data/test_dataset_chart_dto.cpp @@ -17,15 +17,31 @@ TEST(DatasetChartDto, ParsesInversionGrid) { EXPECT_DOUBLE_EQ(g.vmax, 6.0); } TEST(DatasetChartDto, ParsesColorBar) { - // Use "json" delimiter to avoid raw-string termination by ")" inside rgba() - const char* colorBarJson = "{\"properties\":{\"colorBar\":[[\"10\",\"rgba(0,0,255,255)\"],[\"20\",\"rgba(255,0,0,255)\"]]}}"; + // Real API colorBar format: MIXED hex (#RRGGBB) and CSS rgba() with alpha in 0–1 scale + // (verified live: lvl/colorGradation/getDetail returns e.g. ["1.51","rgba(0, 0, 170, 1)"]). + // Every stop must render OPAQUE (alpha=255); the rgba a=1 must map to 255, not 1. + const char* colorBarJson = + "{\"properties\":{\"colorBar\":[" + "[\"0.00\",\"#00008B\"]," + "[\"1.51\",\"rgba(0, 0, 170, 1)\"]," + "[\"60.77\",\"#FFFF00\"]," + "[\"138.20\",\"rgba(255, 128, 0, 1)\"]]}}"; auto d = obj(colorBarJson); auto cs = parseColorBar(d); - auto stops = cs.stopValues(); - ASSERT_EQ(stops.size(), 2u); - EXPECT_DOUBLE_EQ(stops[0], 10.0); - auto c = cs.colorAt(12.0); // [10,20) -> blue - EXPECT_GT(c.b, c.r); + auto stops = cs.stops(); + ASSERT_EQ(stops.size(), 4u); + EXPECT_DOUBLE_EQ(stops[0].first, 0.0); + EXPECT_DOUBLE_EQ(stops[1].first, 1.51); + // hex stop → exact rgb, opaque + EXPECT_EQ(stops[0].second.r, 0); EXPECT_EQ(stops[0].second.g, 0); + EXPECT_EQ(stops[0].second.b, 0x8B); EXPECT_EQ(stops[0].second.a, 255); + // rgba(a=1) stop → exact rgb, OPAQUE (regression: was alpha=1 under Bit255 → near-transparent) + EXPECT_EQ(stops[1].second.r, 0); EXPECT_EQ(stops[1].second.g, 0); + EXPECT_EQ(stops[1].second.b, 170); EXPECT_EQ(stops[1].second.a, 255); + EXPECT_EQ(stops[3].second.r, 255); EXPECT_EQ(stops[3].second.g, 128); + EXPECT_EQ(stops[3].second.b, 0); EXPECT_EQ(stops[3].second.a, 255); + // every stop opaque + for (const auto& s : stops) EXPECT_EQ(s.second.a, 255) << "value=" << s.first; } TEST(DatasetChartDto, ParsesAnomalyPolyline) { auto arr = QJsonDocument::fromJson( diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index eb4a42f..b42f9cf 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -176,6 +176,24 @@ TEST(NavDto, ParseDsRowsDataAndFile) { EXPECT_EQ(page.rows[0].dsName, "a"); } +// 数据集树父节点:sourceShowParentId 优先(=显示树父),缺失回退 parentId,皆无则空(=树根)。 +TEST(NavDto, ParseDsRowsParentIdForTree) { + const auto d = dto::parseDsRows(arrOf(R"([ + {"id":"raw","dsName":"E3原始","name":"ERT原始数据","ddCode":"dd_ert_measurement_data", + "parentId":"srcFile","sourceShowParentId":"srcFile"}, + {"id":"inv","dsName":"E3反演","name":"电阻率数据","ddCode":"dd_inversion_data", + "parentId":"raw","sourceShowParentId":"raw"}, + {"id":"fallback","dsName":"仅parentId","name":"t","ddCode":"dd", + "parentId":"raw"}, + {"id":"root","dsName":"无父","name":"t","ddCode":"dd"} + ])")); + ASSERT_EQ(d.size(), 4u); + EXPECT_EQ(d[0].parentId, "srcFile"); // 原始数据→源文件节点(不在本批→树根) + EXPECT_EQ(d[1].parentId, "raw"); // 派生反演→挂原始数据下 + EXPECT_EQ(d[2].parentId, "raw"); // 无 sourceShowParentId → 回退 parentId + EXPECT_TRUE(d[3].parentId.empty()); // 二者皆无 → 空(树根) +} + TEST(NavDto, ParseProjectItemFullFields) { const auto v = dto::parseProjectList(arrOf(R"([ {"id":"p1","projectName":"演示","projectCode":"001","status":2, From a00aeb9a56dcbe535c3a1fcfb52be2aec2a7b426 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sat, 13 Jun 2026 10:51:24 +0800 Subject: [PATCH 71/74] =?UTF-8?q?feat(dataset-detail):=20=E6=8C=89?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=B8=B2=E6=9F=93=E5=BC=95=E6=93=8E=20+=20in?= =?UTF-8?q?version=20=E8=BF=81=E7=A7=BB=20+=20dd=5Fert=5Fmeasurement=5Fdat?= =?UTF-8?q?a=20=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将数据集详情从硬编码 ERT 反演重构为通用「ddCode→页签→视图kind」渲染引擎, 把已验收的 dd_inversion_data 迁入新引擎(渲染零变化),并在引擎上实现 dd_ert_measurement_data(ERT原始数据)详情。 引擎: - 策略声明页签:IDatasetChartStrategy::tabs()→vector 取代 hasGridPhase();控制器通用化(datasetOpened/loadTab/ tabReady),按页签槽位保留 abort-and-replace + 句柄身份比对 + deleteLater 三件套 - 单一异步句柄 DetailLoad/ApiDetailLoad(QVariant 载荷),仓储 loadAsync(loaderKey, dsId) 扁平分派;删 ChartLoad/GridLoad/loadChartAsync/loadGridAsync/DatasetLoads.hpp - 视图 IDetailView{widget,setPayload(QVariant)} + DetailViewFactory 按 ViewKind 实例化; 载荷 Scatter/Contour/TablePayload(core/model/detail/DetailPayloads.hpp) - Page 按 TabSpec 建页签 + 通用 lazy;inversion 散点/网格迁入,渲染代码未改 dd_ert_measurement_data(③散点伪剖面 + 列表): - 散点复用 RawDataChartView/ScatterPlotItem:x=斜距/y=伪深度(负向)/色=视电阻率, 连续 cauto(含负离群值),colorBar 取自 colorGradation businessCode=R0&type=3(混合 hex+rgba/AlphaScale::Unit);电极灰菱形(#BEBEBE实心+白描边 size16);A/B/M/N 与电极 浮动 hover;数据点尺寸对齐原版 12px;色阶底部横向(与反演一致) - 通用 DataTableView(gridHeaderDisplay/filedList+rowList,均衡等宽列+居中+隐藏/显示 开关列);MeasurementDto 解析(散点位置数组 + vmap 摊平) - 工具条视觉 1:1(信息/框选/显示/隐藏/数据过滤/x·y·v·计算方法下拉/色阶配置/生成视电阻 率/反演运算/另存为);显示隐藏+x/y 下拉轻交互,其余占位 测试 75→138 全绿。Phase0 真实响应夹具 tests/fixtures/dd/*。 --- .../plans/2026-06-12-detail-render-engine.md | 114 +++ src/app/CMakeLists.txt | 2 + src/app/main.cpp | 44 +- src/app/panels/DatasetDetailPage.cpp | 81 +- src/app/panels/DatasetDetailPage.hpp | 48 +- src/app/panels/DatasetDetailPanel.cpp | 29 +- src/app/panels/DatasetDetailPanel.hpp | 22 +- src/app/panels/chart/ColorBarWidget.cpp | 74 +- src/app/panels/chart/ColorBarWidget.hpp | 14 +- src/app/panels/chart/DataTableView.cpp | 151 ++++ src/app/panels/chart/DataTableView.hpp | 60 ++ src/app/panels/chart/DetailViewFactory.cpp | 28 + src/app/panels/chart/DetailViewFactory.hpp | 16 + src/app/panels/chart/ErtInversionStrategy.hpp | 12 +- src/app/panels/chart/GridDataChartView.cpp | 11 +- src/app/panels/chart/GridDataChartView.hpp | 14 +- src/app/panels/chart/IDetailView.hpp | 17 + src/app/panels/chart/MeasurementStrategy.hpp | 18 + src/app/panels/chart/RawDataChartView.cpp | 323 ++++++- src/app/panels/chart/RawDataChartView.hpp | 34 +- src/app/panels/chart/ScatterHoverTip.cpp | 50 +- src/app/panels/chart/ScatterHoverTip.hpp | 26 + src/app/panels/chart/ScatterPlotItem.cpp | 30 +- src/app/panels/chart/ScatterPlotItem.hpp | 14 +- src/controller/DatasetDetailController.cpp | 97 +- src/controller/DatasetDetailController.hpp | 49 +- src/controller/DatasetDetailTab.hpp | 24 + src/controller/IDatasetChartStrategy.hpp | 8 +- src/core/model/Field.hpp | 6 + src/core/model/detail/DetailPayloads.hpp | 74 ++ src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetRepository.cpp | 129 ++- src/data/api/ApiDatasetRepository.hpp | 7 +- src/data/api/DatasetLoadHandles.cpp | 47 +- src/data/api/DatasetLoadHandles.hpp | 42 +- src/data/api/DatasetLoads.hpp | 21 - src/data/dto/MeasurementDto.cpp | 162 ++++ src/data/dto/MeasurementDto.hpp | 20 + src/data/repo/IAsyncDatasetRepository.hpp | 7 +- tests/CMakeLists.txt | 5 +- tests/app/test_chart_strategy_registry.cpp | 36 +- tests/app/test_scatter_hover.cpp | 15 + .../test_dataset_detail_controller.cpp | 175 ++-- tests/data/test_async_repo_dispatch.cpp | 79 ++ tests/data/test_dataset_load_handles.cpp | 50 +- tests/data/test_measurement_dto.cpp | 254 ++++++ .../fixtures/dd/ert-measurement-colorbar.json | 39 + tests/fixtures/dd/ert-measurement-rows.json | 575 ++++++++++++ .../fixtures/dd/ert-measurement-scatter.json | 830 ++++++++++++++++++ 49 files changed, 3520 insertions(+), 464 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-12-detail-render-engine.md create mode 100644 src/app/panels/chart/DataTableView.cpp create mode 100644 src/app/panels/chart/DataTableView.hpp create mode 100644 src/app/panels/chart/DetailViewFactory.cpp create mode 100644 src/app/panels/chart/DetailViewFactory.hpp create mode 100644 src/app/panels/chart/IDetailView.hpp create mode 100644 src/app/panels/chart/MeasurementStrategy.hpp create mode 100644 src/controller/DatasetDetailTab.hpp create mode 100644 src/core/model/detail/DetailPayloads.hpp delete mode 100644 src/data/api/DatasetLoads.hpp create mode 100644 src/data/dto/MeasurementDto.cpp create mode 100644 src/data/dto/MeasurementDto.hpp create mode 100644 tests/data/test_async_repo_dispatch.cpp create mode 100644 tests/data/test_measurement_dto.cpp create mode 100644 tests/fixtures/dd/ert-measurement-colorbar.json create mode 100644 tests/fixtures/dd/ert-measurement-rows.json create mode 100644 tests/fixtures/dd/ert-measurement-scatter.json diff --git a/docs/superpowers/plans/2026-06-12-detail-render-engine.md b/docs/superpowers/plans/2026-06-12-detail-render-engine.md new file mode 100644 index 0000000..5cceb30 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-detail-render-engine.md @@ -0,0 +1,114 @@ +# 数据详情「按类型渲染」通用引擎 + 迁移 inversion + measurement_data 实现计划 + +> 2026-06-12。承接 `2026-06-11-dataset-detail-other-dd-types.md`(Phase 1 策略分派已 done)。 +> **决策(用户已拍板=选项2)**:建通用渲染引擎,并把已验收的 `dd_inversion_data` 一起迁到新引擎、删旧专用代码,终态只有一套。 +> **铁律**:不确定必用 Playwright 实看原版;严格 1:1;无活样本类型 BLOCKED。 +> **工作方式**:subagent-driven + TDD(真实夹具) + 破坏性接口改形原子落地保持构建绿。 + +## 0. 背景与动机 + +详情视图有多种 dd 类型,每类页签集 + 图形 kind 不同。现状把"两页签(chartReady/gridReady) + 两个专用 Load 句柄 + 写死 inversion 字段"耦合进三层。要支持 measurement_data / gr_data(柱状) / trajectory(轨迹) / grid(白化) 等且可持续扩展,需围绕「页签描述符」统一。 + +## 1. Phase 0 实测成果(2026-06-12,真实响应,已抓夹具) + +API base:`http://tenant.geomative.cn/pop-api/business`。Auth header:`geomativeauthorization: Geomative `。 +同一 TM(project=1438889436225536 / structParentId=1438889842614272)集齐 4 类样本: + +| ddCode | dsId 样本 | 端点 | 数据形状 | 页签 → render kind | +|---|---|---|---|---| +| `dd_ert_measurement_data` 原始 | 1453611522236416 | `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(图) + `GET dd/ert/measurement/rows?dsObjectId=`(列表) | 图:`{electrodeList[40], scatterGraphConf{x[2],v[6],y[3],vcalculationMethod[3]}, scatterGraphData{hasCoordinate,hasElevation,min[9],max[9],rows[576]位置数组}}`;列表:`{filedList[12], rowList[576]键控对象,含 vmap{R,V,I,R0_RD,SP,R0}}` | 散点伪剖面=Scatter + 列表=Table | +| `dd_ert_measurement_gr_data` 接地电阻 | 1453611522285569 | `GET dd/ert/measurement/gr/rows?dsObjectId=` | `array[40]` of `{electrodeId,testDate,testTime,p1Rg,p1RgStatus,p2Rg,p2RgStatus}` | 柱状图=Bar(X=electrodeId,Y=电阻Ω,系列P1) + 列表=Table | +| `dd_trajectory_data` 坐标 | 1438893961060352 | `GET dd/ert/trajectory/rows?dsObjectId=`(+`/line?…&frontCrsCode` 400 需参数) | `{rowList[40]{electrodeNo,projectX,projectY,longitude,latitude,elevation,lineNo,channelNo,…}, gridHeaderDisplay[14]}` | 地图=PolylineMap(平面折线,非GIS底图) + 列表=Table + 高程=LineProfile | +| `dd_grid` 白化 | 1438944148742207 | `GET dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize=` | `{rowList[N]{x,y,id}, gridHeaderDisplay[2], total}` **分页** | 列表=Table(仅,分页) | + +夹具:`tests/fixtures/dd/ert-measurement-scatter.json`、`ert-measurement-rows.json`(真实响应,rows 裁剪至 20,保 conf/min/max/electrodeList 全量)。 + +**散点位置数组列序(rows[i],与键控 rows 比对得)**:`[0]平距 [1]斜距 [2]layerNo(null) [3]伪深度(负,向下) [4]选中v原值 [5]log10(值) [6]导数态 [7]0 [8]id(str) [9]高程伪深度 [10]a [11]b [12]m [13]n [14]K [15]rowNo [16]选中v值(=色)`。默认 vFieldCode 空 → v=视电阻率 R0;x=平距;y=伪深度。色阶 cauto(同反演原数据,按 v 有限值 min/max 归一化)。 + +**渲染 kind 全集**:Scatter / FilledContour / Bar / LineProfile / PolylineMap / Table /(未来 Image,GPR BLOCKED)。 +**通用 Table**:`{gridHeaderDisplay 或 filedList(列定义) + rowList(对象)}` 形状被 measurement列表/grid白化/trajectory列表共用 → 一个 `DataTableView` + 一个 `parseTable` 通吃(分页与列格式化为 parse-time/decorator,见 §4)。 + +## 2. 目标架构 + +依赖箭头:`controller(ViewKind/TabSpec/Controller) ← app/strategies, app/views ; data(DetailLoad/Repo) 产出 QVariant 载荷`。 + +### 2.1 载荷(payload)——`src/model/detail/DetailPayloads.hpp`(纯数据,无 Qt-widget 依赖,Q_DECLARE_METATYPE) +- `ScatterPayload { core::ScatterField scatter; core::ColorScale scale; }`(反演原数据 + measurement 散点共用;≈现 `ChartParts`) +- `ContourPayload { core::Grid grid; core::ColorScale scale; std::vector anomalies; }`(≈现 `GridParts`) +- `TablePayload { std::vector columns; std::vector> rows; int total = 0; }`;`TableColumn { QString code, title; int width; int sort; }` +- (Bar/LineProfile/Polyline 载荷待对应类型阶段再加,YAGNI) + +### 2.2 页签描述符 + 策略——`src/controller/DatasetDetailTab.hpp` + `IDatasetChartStrategy.hpp` +```cpp +enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table /*,Image*/ }; +struct TabSpec { QString title; ViewKind kind; QString loaderKey; bool lazy=false; bool paginated=false; }; +struct IDatasetChartStrategy { virtual std::string ddCode() const=0; virtual std::vector tabs() const=0; }; // tabs() 取代 hasGridPhase() +``` +`ChartStrategyRegistry` 不变。`ErtInversionStrategy::tabs()` = `{{"原数据",Scatter,"inversion.scatter",false},{"网格数据",FilledContour,"inversion.grid",true}}`。 + +### 2.3 通用异步句柄——`src/data/api/DatasetLoadHandles.*` +合并 `ChartLoad`/`GridLoad` → 单 `DetailLoad{ virtual void abort(); signals: done(QVariant); failed(QString); }` + `ApiDetailLoad`(包 ApiBatch + Parser=`std::function&)>`)。**aborted_ 守卫 + 双 try/catch→failed + deleteLater 三件套从 ApiChartLoad 原样照搬**。删 `ChartParts`/`GridParts`(被 payload 取代)或保留作中间态(由实现者定,优先删以免双份)。 + +### 2.4 仓储——`IAsyncDatasetRepository::loadAsync(loaderKey,dsId)` + `ApiDatasetRepository` 分派表 +唯一端点+解析器集中处。`if (key=="inversion.scatter") return makeInversionScatter(dsId); …`;未知 key 抛。每 make 建 ApiBatch + 注入返回 `QVariant::fromValue(payload)` 的解析器。删 `loadChartAsync/loadGridAsync`。 +> measurement scatter 的 `dsObjectId` 是 **query 参数**(`?dsObjectId=&vFieldCode=`,默认 vFieldCode 空),≠反演 path 参数;用现有 `enc()`。 + +### 2.5 控制器——`DatasetDetailController` 变通用(无 per-ddCode if) +```cpp +slots: openDataset(dsId,ddCode,dsName), loadTab(dsId,ddCode,tabIndex), focusDataset(dsId); +signals: datasetOpened(dsId,dsName, std::vector), tabLoadStarted(dsId,tabIndex), + tabReady(dsId,tabIndex,QVariant), loadFailed(dsId,msg), focusRequested(dsId); +``` +`openDataset`:查 registry,无→`loadFailed("暂不支持…")`(降级不变);`emit datasetOpened(...,strategy->tabs())`;对每个非 lazy 页签 `loadTab`。 +`loadTab`:**唯一保存 §5.0 安全不变量处**,按 tab 槽位:`inflight_`=`QMap>`;abort 旧槽 → 新 load → `tabLoadStarted` → done 回调里 `if (load!=inflight_.value(i)) return;`(身份比对)→ `tabReady`。析构 abort 全部。删 `chartReady/gridReady/ChartData/GridData/loadGridData`。 + +### 2.6 视图 + 工厂 + 壳 +- `src/app/panels/chart/IDetailView.hpp`:`{ QWidget* widget(); void setPayload(const QVariant&); }`。 +- `RawDataChartView`/`GridDataChartView` 实现 IDetailView:`setPayload` 解包 `ScatterPayload`/`ContourPayload` → 调原 `setData(...)`(**渲染代码零改**)。坏/空 variant → 可见"渲染数据格式错误"不崩。 +- `DetailViewFactory.{hpp,cpp}`:`makeDetailView(ViewKind,parent)` switch → 对应视图。 +- `DatasetDetailPage`:`build(tabs)` 按 TabSpec 建页签(`buildTabbedPanel` + 工厂);lazy 页签首次激活发 `tabNeeded(dsId,ddCode,i)`(泛化现 gridDataNeeded);`setTabPayload(i,QVariant)` → `views_[i]->setPayload`。删写死 `rawView_/gridView_/kGridTabIndex`。 +- `DatasetDetailPanel`:`onDatasetOpened(dsId,dsName,tabs)` 建 page→build;按 dsId 转发 tabReady/tabNeeded。dsId 去重壳逻辑不动。 +- `main.cpp`:接线改 datasetOpened/tabReady/tabNeeded/tabLoadStarted;registry 注册不变(后续加 MeasurementStrategy)。**与控制器改形同提交(原子)。** + +## 3. 实施序列(任务) + +### E1a 引擎地基(加性,构建保持绿) [task #1] +新增 payloads / TabSpec/ViewKind / DetailLoad+ApiDetailLoad(与旧句柄并存) / `loadAsync` 分派(inversion.scatter/grid 产 payload),与旧 `loadChartAsync/loadGridAsync` 并存。TDD:DTO→payload、句柄桩(`tests/data/test_dataset_load_handles.cpp` 仿现有桩)、loadAsync 分派。**构建+测试全绿,旧路径不动。** + +### E1b 破坏性改形 + inversion 迁移(原子) [task #2] +控制器/策略接口/视图 IDetailView/工厂/Page/Panel/main.cpp 全部切到 tab 引擎;删旧 `chartReady/gridReady/ChartData/GridData/ChartLoad/GridLoad/loadChartAsync/loadGridAsync/hasGridPhase`。改全部相关测试(controller/strategy_registry/handles)。**一个提交内落地,构建+测试绿 + 跑 app 双击标准 ds `1458990939709440` 目视回归两页签(散点+网格等值面+异常)正常。** + +### E2 measurement_data(③散点伪剖面 + 列表) [task #3] + +**Phase 0 色阶补查(2026-06-12,已坐实,夹具齐)**:measurement 散点**有 colorBar**,来自 `POST lvl/colorGradation/getDetail` body **`{dsObjectId, businessCode:"", type:3}`**(注意:businessCode=v字段码、type=3,≠反演的 type1/2;用 businessCode='' 会返回 null)。响应是**与反演同构的混合格式 colorBar**(hex + rgba alpha 0–1,18 个对数间距 stops `0.00→#00008B … 208.40→#FF0000 … 1323.20→rgba(48,0,48,1)`,`lvlSchemeType:normal`/equalAreaLayerCount/labelConfig/lineConfig)——**现有 `DatasetChartDto` colorBar 解析器(AlphaScale::Unit)直接可用**。截图核对:散点按 v 值**绝对值离散映射**到 colorBar(同反演**网格** `colorAtDiscrete`,**非**反演原数据的连续 cauto),右侧 ColorBarWidget 显示离散级。夹具:`tests/fixtures/dd/ert-measurement-{scatter,rows,colorbar}.json`;原版截图 `meas-scatter-original.jpeg`。 + +**⚠️ 着色模型修正(2026-06-12 读原版 Plotly `_fullData` 实测,推翻"离散"判断)**:原版是 **Plotly scattergl**,散点**连续 cauto 着色**(非离散!):`marker.color`=原始 v 值(col4,**含负值**,本例范围 -1066.59~1232.44);`cauto:true` → `cmin/cmax`=数据 v 的真实 min/max(**含负离群值**);`colorscale`=连续 18 段,**position=colorBar值/maxColorBar值(1323.20)**;上色=`norm(v)=(v-cmin)/(cmax-cmin)` 查 colorscale 连续插值。验证 v=50.18→norm 0.4857→暗紫(stop 0.358 与 0.540 之间)。**这与 inversion 原数据完全同路(交接 §0.2:cauto 数据范围 + colorscale 位置 val/maxVal),measurement 必须用 `discreteColor=false` 走同一条已验收路径**,而非我 E2 误用的 colorAtDiscrete。负离群值撑大数据范围 → R0=25~250 全挤色阶中段暗紫,故原版几乎全暗紫(仅极端值现橙/蓝)。colorBar 图例(竖排右侧离散色块标 0.00~1323.20)独立于 cauto,显示 colorBar 自身 value→color。hover=浮动黑底框 X/Y/Value/a/b/m/n(另有底部 footer A=B=M=N=DataRow=Pseu_Resis=)。 + +**散点渲染规格(截图确认)**:x=**斜距 slopeDistance**(toolbar 默认,col1;≈平距,几乎不可辨,存疑标用户核对)、y=**伪深度 pseudoDepth**(col3,负向下)、色=**视电阻率 R0**(col16,离散 colorBar)、x 轴在**顶部**(同反演原数据)、y=0 处画 **40 个灰色菱形=电极位置**(electrodeList 的 x)、hover 显示 `A= B= M= N= DataRow= Pseu_Resis=`(col10-13=A/B/M/N + 值)。工具条(散点图/数据列表 切换、显示/隐藏、数据过滤、x/y/v/计算方法下拉、色阶配置、生成视电阻率、反演运算、另存为、导出)=**范围外**,MVP 只渲染默认静态视图。 +- 端点:scatter `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(空=默认R0)+ colorBar `POST lvl/colorGradation/getDetail{dsObjectId,businessCode:"R0",type:3}`;列表 `GET dd/ert/measurement/rows?dsObjectId=`。loaderKey `ert_measurement.scatter` 用 ApiBatch 组 scatter+colorBar 两请求(类比反演 loadChartAsync 的 scatter+type1)。 + +- `src/data/dto/MeasurementDto.{hpp,cpp}`:`parseMeasurementScatter(scatterJson, colorBarJson)→ScatterPayload`(位置数组 rows:x=col1 斜距/y=col3 伪深度/色值=col16;colorBar 复用现有解析器→离散 ColorScale;电极 electrodeList→灰菱形,可放 ScatterPayload 扩展或单列);`parseTable(QJsonObject)→TablePayload`(通用:列来自 `gridHeaderDisplay` 或 `filedList`,值预格式化为 QString;measurement 的 `vmap` 由解析器摊平为列——按 filedList 列码取 vmap[code])。 +- `src/app/panels/chart/DataTableView.{hpp,cpp}`:IDetailView + QTableView + QAbstractTableModel(由 TablePayload 构),列宽/标题/排序来自 TableColumn。 +- `src/app/strategies/MeasurementStrategy.hpp`:ddCode=`dd_ert_measurement_data`,tabs=`{{"散点伪剖面",Scatter,"ert_measurement.scatter",false},{"列表",Table,"ert_measurement.rows",true}}`。 +- `ApiDatasetRepository`:加 `ert_measurement.scatter`(scatter/graph,query 参数 + 默认 vFieldCode 空)、`ert_measurement.rows`(measurement/rows)loaderKey。 +- `DetailViewFactory`:加 `Table` case。`main.cpp`:注册 MeasurementStrategy 一行。 +- TDD 用 `tests/fixtures/dd` 夹具:`tests/data/test_measurement_dto.cpp`(散点位置解码/y 负号/列提取/vmap 摊平) + 控制器 2 页签谱 + loadAsync 分派。 +- 构建+测试绿 + 跑 app 双击「ERT原始数据」对照原版散点伪剖面 + 列表目视核对。 + +## 4. 风险与裁断(architect 评估) + +1. **不要过度泛化控制器信号面**:`tabReady(dsId,i,QVariant)` 单信号已吸收全部近期形状(类型擦除)。激进泛化"句柄/载荷"(合一 DetailLoad/QVariant,是纯去重),但控制器信号保持扁平最小。分页的 `pageNo` 等真做 dd_grid 再加一个默认参,加性无重构。 +2. **QVariant 类型擦除以编译期安全换扩展性**:错的 loaderKey→ViewKind→payload 三元组是运行期 cast miss 而非编译错。缓解:downcast 仅在每视图 `setPayload` 一处;坏 variant 出可见错误不崩(配合现 GuardedApplication::notify);每策略一条 round-trip 测试。接受此权衡(否则 N×3 个 XxxLoad/XxxReady/XxxData 爆炸)。 +3. **不要提前耦合 BLOCKED 的 GPR/radar**:引擎结构上可容纳(加 ViewKind::Image/GisMap + 新视图+解析器+loaderKey,控制器/句柄零改),故不被锁死;但现在不加任何 enum 值/桩(1:1 + 无样本)。trajectory 的 PolylineMap 是金丝雀,证明非图非表视图也能套进引擎。 + +**通用 Table 的边界**:list 形状(measurement/trajectory/grid)共用对;但 ① 分页(dd_grid 独有)= `PaginatedTableView` 装饰器或 spec `paginated` 标志 + page 发 `loadTab(...,pageNo)`,**基类 DataTableView 保持哑**;② 列格式化/值语义(measurement 的 vmap 摊平、gr 的 status 标志着色)= **parse-time** 处理(TablePayload 存预格式化 QString),需要彩色状态格时再加 `TableColumn.kind` 提示(YAGNI)。bar 不是表复用——gr_data 主页签是 Bar,列表页签才复用 parseTable。 + +## 5. 安全不变量(spec §5.0,务必保留) + +"abort 后绝不回灌"三件套:① 每层 `aborted_` 入口守卫;② 控制器句柄身份比对(`if (load!=inflight_.value(i)) return;`);③ 一律 `deleteLater`。错误判定:业务 `code!=200 || !rawError.isEmpty()`(HTTP 200 也可能 code=500)。迁移后这三件套从"按 Chart/Grid 阶段"改"按页签槽位",逐字保留,不得重造削弱。 + +## 6. 构建/测试命令 + +- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`(LNK1104 先 `Get-Process geopro_desktop|Stop-Process -Force`)。 +- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 build)。基线 **122/122 绿**。 +- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=.*`。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 1ef6c5a..4f09f65 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,8 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/DataTableView.cpp + panels/chart/DetailViewFactory.cpp panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index d22883c..2b09d7f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -85,6 +85,7 @@ #include "WorkbenchNavController.hpp" #include "DatasetDetailController.hpp" #include "panels/chart/ErtInversionStrategy.hpp" +#include "panels/chart/MeasurementStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -514,37 +515,23 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName); }); - // ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::chartReady, detailPanel, - [detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) { - detailPanel->openOrUpdate(d); - }); + // ── 控制器信号 → 详情面板(tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ── + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::datasetOpened, + detailPanel, &geopro::app::DatasetDetailPanel::onDatasetOpened); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabReady, + detailPanel, &geopro::app::DatasetDetailPanel::onTabReady); + QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabLoadStarted, + detailPanel, &geopro::app::DatasetDetailPanel::onTabLoadStarted); QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested, - detailPanel, [detailPanel](const QString& dsId) { - detailPanel->focusDataset(dsId); - }); - // ── 网格数据懒加载:网格页首次激活 → 拉 rows+色阶type2+异常 → 回填对应页 ── - QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::gridDataNeeded, &detailCtrl, - &geopro::controller::DatasetDetailController::loadGridData); - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::gridReady, detailPanel, - [detailPanel](const geopro::controller::DatasetDetailController::GridData& d) { - detailPanel->setGridData(d); - }); - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel, - [detailPanel](const QString& dsId, - geopro::controller::DatasetDetailController::LoadPhase phase) { - if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) - detailPanel->setGridLoading(dsId, true); - }); - // context 用 detailPanel(与 loadStarted 一致):detailPanel 析构即自动断连,避免野指针。 - // window 比 detailPanel 活得久,lambda 捕 &window 取状态栏安全。loadFailed 无 phase: - // Chart 失败时 gridOverlay 本已隐藏,setGridLoading(false) 幂等无害;Grid 失败时正确清遮罩。 + detailPanel, &geopro::app::DatasetDetailPanel::focusDataset); + // ── 页签懒加载:lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ── + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl, + &geopro::controller::DatasetDetailController::loadTab); + // context 用 detailPanel:析构即自动断连,避免野指针。window 比 detailPanel 活得久, + // 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。 QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel, [&window, detailPanel](const QString& dsId, const QString& msg) { - detailPanel->setGridLoading(dsId, false); + detailPanel->onLoadFailed(dsId, msg); window.statusBar()->showMessage( QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); }); @@ -924,6 +911,7 @@ int main(int argc, char* argv[]) // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); + chartRegistry.add(std::make_unique()); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 7b5f47f..aacae48 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -6,63 +6,78 @@ #include "Glyphs.hpp" #include "PanelHeader.hpp" #include "panels/LoadingOverlay.hpp" -#include "panels/chart/GridDataChartView.hpp" -#include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/DetailViewFactory.hpp" +#include "panels/chart/IDetailView.hpp" namespace geopro::app { -namespace { -constexpr int kGridTabIndex = 1; // 「网格数据」页签在 tabs 中的索引 -} - DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); +} - rawView_ = new RawDataChartView(this); - gridView_ = new GridDataChartView(this); - gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_,随其尺寸覆盖网格图区 +void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& tabs) { + Q_ASSERT(views_.empty()); // build() 仅一次:views_ 为已 release 的裸指针,二次构建会泄漏旧视图 + if (!views_.empty()) return; + dsId_ = dsId; + ddCode_ = ddCode; + dsName_ = dsName; + tabs_ = tabs; + views_.assign(tabs.size(), nullptr); + loaded_.assign(tabs.size(), false); + requested_.assign(tabs.size(), false); + overlays_.clear(); - const QVector tabs = { - {Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, - {Glyph::Dataset, QStringLiteral("网格数据"), gridView_, false}, - }; + // 按 TabSpec 经工厂造视图,并组装为带表头页签的面板。 + QVector panelTabs; + for (size_t i = 0; i < tabs.size(); ++i) { + const auto& spec = tabs[i]; + auto view = makeDetailView(spec.kind, this); // 抛出由调用栈兜底(GuardedApplication) + IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 + views_[i] = raw; + // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 + if (spec.lazy) overlays_[static_cast(i)] = new LoadingOverlay(raw->widget()); + panelTabs.append({Glyph::Detail, spec.title, raw->widget(), false}); + } const QVector actions = { {Glyph::Download, QStringLiteral("导出")}, }; - auto tabbedPanel = buildTabbedPanel(tabs, actions); - lay->addWidget(tabbedPanel.container); + auto tabbedPanel = buildTabbedPanel(panelTabs, actions); + layout()->addWidget(tabbedPanel.container); - // 「网格数据」页签首次激活 → 懒加载网格数据(rows 服务端网格化慢,故不随开页同步拉)。 + // lazy 页签首次激活 → 发 tabNeeded 请求懒加载(数据慢,故不随开页同步拉)。 if (tabbedPanel.tabGroup) { connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) { - if (idx != kGridTabIndex || gridRequested_ || dsId_.isEmpty()) return; - gridRequested_ = true; - emit gridDataNeeded(dsId_, ddCode_); + if (idx < 0 || idx >= static_cast(tabs_.size())) return; + if (!tabs_[static_cast(idx)].lazy) return; + if (requested_[static_cast(idx)] || loaded_[static_cast(idx)]) return; + if (dsId_.isEmpty()) return; + requested_[static_cast(idx)] = true; + emit tabNeeded(dsId_, ddCode_, idx); }); } } -void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - dsId_ = d.dsId; - ddCode_ = d.ddCode; - gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 - rawView_->setData(d); - gridView_->setData(d); - setGridLoading(false); // 重开/换 ds:重置遮罩状态,避免上次的「加载中」残留 +void DatasetDetailPage::setTabPayload(int tabIndex, const QVariant& payload) { + if (tabIndex < 0 || tabIndex >= static_cast(views_.size())) return; + if (auto* v = views_[static_cast(tabIndex)]) v->setPayload(payload); + loaded_[static_cast(tabIndex)] = true; + requested_[static_cast(tabIndex)] = true; // 已加载,切回不再重复请求 + setTabLoading(tabIndex, false); // 数据到达,隐藏遮罩 } -void DatasetDetailPage::setGridData( - const geopro::controller::DatasetDetailController::GridData& d) { - gridRequested_ = true; // 已加载,切回网格页不再重复请求 - gridView_->setGridData(d.grid, d.gridScale, d.anomalies); - setGridLoading(false); // 数据到达,隐藏遮罩 +void DatasetDetailPage::setTabLoading(int tabIndex, bool on) { + auto it = overlays_.find(tabIndex); + if (it == overlays_.end()) return; // 非 lazy 页签无遮罩 + if (on) it.value()->showOver(); else it.value()->hide(); } -void DatasetDetailPage::setGridLoading(bool on) { - if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); +void DatasetDetailPage::clearAllLoadingOverlays() { + for (auto* overlay : overlays_) + if (overlay) overlay->hide(); } } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index b408d06..323b90d 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -1,35 +1,51 @@ #pragma once +#include +#include +#include #include -#include "DatasetDetailController.hpp" +#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec namespace geopro::app { -class RawDataChartView; -class GridDataChartView; +class IDetailView; class LoadingOverlay; -// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 -// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。 +// 单个数据集详情页:按策略 tabs() 动态建页签 + 右侧「导出」操作。 +// 每页签由工厂造的 IDetailView 承载;lazy 页签首次激活时发 tabNeeded 请求懒加载。 class DatasetDetailPage : public QWidget { Q_OBJECT public: explicit DatasetDetailPage(QWidget* parent = nullptr); - void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 - void setGridData(const geopro::controller::DatasetDetailController::GridData& d); - // 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。 - void setGridLoading(bool on); + + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 + void build(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& tabs); + + // 页签载荷到达 → 下发给对应视图并标记已加载、隐藏遮罩。 + void setTabPayload(int tabIndex, const QVariant& payload); + // 页签加载进行中 → 对 lazy 页签显示遮罩(非 lazy 页签无遮罩,幂等忽略)。 + void setTabLoading(int tabIndex, bool on); + // 清掉本页全部加载遮罩(失败兜底用,不假设页签数;幂等)。 + void clearAllLoadingOverlays(); + QString dsId() const { return dsId_; } + int tabCount() const { return static_cast(tabs_.size()); } + signals: - // 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 - void gridDataNeeded(const QString& dsId, const QString& ddCode); + // lazy 页签首次激活且未加载 → 请求懒加载。 + void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); + private: QString dsId_; QString ddCode_; - bool gridRequested_ = false; // 已请求过(避免重复发信号) - RawDataChartView* rawView_; - GridDataChartView* gridView_; - LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩 + QString dsName_; + std::vector tabs_; + // 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管, + // 生命周期由 Qt 父子树清理(不在此 delete);build() 仅调用一次(见其断言)。 + std::vector views_; + std::vector loaded_; // 各页签是否已加载(避免重复请求) + std::vector requested_; // lazy 页签是否已请求过 + QMap overlays_; // lazy 页签的加载遮罩(覆盖该视图) }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 205b843..4567cb6 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -18,26 +18,35 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { return nullptr; } -void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { - auto* p = pageFor(d.dsId); +void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode, + const QString& dsName, + const std::vector& tabs) { + auto* p = pageFor(dsId); if (!p) { p = new DatasetDetailPage(this); - const QString title = d.dsName.isEmpty() ? d.dsId : d.dsName; // 页签标题用数据名(空则回退 id) + p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 + const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id) const int idx = addTab(p, title); setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名 - // 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 - connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); + // 页内 lazy 页签首次激活 → 冒泡为面板信号(外部接控制器 loadTab)。 + connect(p, &DatasetDetailPage::tabNeeded, this, &DatasetDetailPanel::tabNeeded); } - p->setData(d); setCurrentWidget(p); } -void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { - if (auto* p = pageFor(d.dsId)) p->setGridData(d); +void DatasetDetailPanel::onTabReady(const QString& dsId, int tabIndex, const QVariant& payload) { + if (auto* p = pageFor(dsId)) p->setTabPayload(tabIndex, payload); } -void DatasetDetailPanel::setGridLoading(const QString& dsId, bool on) { - if (auto* p = pageFor(dsId)) p->setGridLoading(on); + +void DatasetDetailPanel::onTabLoadStarted(const QString& dsId, int tabIndex) { + if (auto* p = pageFor(dsId)) p->setTabLoading(tabIndex, true); } + +void DatasetDetailPanel::onLoadFailed(const QString& dsId, const QString&) { + // 失败:清掉该页全部「加载中」遮罩(不假设页签数;幂等:非 lazy/已隐藏皆无害)。 + if (auto* p = pageFor(dsId)) p->clearAllLoadingOverlays(); +} + void DatasetDetailPanel::focusDataset(const QString& dsId) { if (auto* p = pageFor(dsId)) setCurrentWidget(p); } diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 344c882..5fcf7e8 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -1,21 +1,29 @@ #pragma once +#include #include -#include "DatasetDetailController.hpp" +#include +#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec namespace geopro::app { class DatasetDetailPage; -// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。 +// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。tab 引擎版。 class DatasetDetailPanel : public QTabWidget { Q_OBJECT public: explicit DatasetDetailPanel(QWidget* parent = nullptr); - void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 - void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 - void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页 + + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 + void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& tabs); + void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload); + void onTabLoadStarted(const QString& dsId, int tabIndex); + void onLoadFailed(const QString& dsId, const QString& message); void focusDataset(const QString& dsId); // 单击聚焦已开页 + signals: - void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 - void gridDataNeeded(const QString& dsId, const QString& ddCode); // 网格页首次激活 → 请求懒加载 + void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 + void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); // lazy 页首激活 → 懒加载 + private: DatasetDetailPage* pageFor(const QString& dsId) const; }; diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index a637760..030fb04 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -9,10 +9,16 @@ namespace geopro::app { static constexpr int kBarHeight = 18; // 色带高度(px) static constexpr int kTickHeight = 4; // 刻度短线高度(px) static constexpr int kFontSize = 9; // 刻度字号 +static constexpr int kVBarWidth = 16; // 竖条色带宽度(px) +static constexpr int kVLabelGap = 4; // 竖条值标签与色带间距(px) -ColorBarWidget::ColorBarWidget(QWidget* parent) - : QWidget(parent) { - setFixedHeight(36); +ColorBarWidget::ColorBarWidget(QWidget* parent, Orientation orient) + : QWidget(parent), orient_(orient) { + if (orient_ == Orientation::Vertical) { + setFixedWidth(64); // 竖条 + 左侧值标签 + } else { + setFixedHeight(36); + } // 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。 connect(&ThemeManager::instance(), &ThemeManager::changed, this, qOverload<>(&QWidget::update)); @@ -26,6 +32,14 @@ void ColorBarWidget::setColorScale(const core::ColorScale& scale) { void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); + if (orient_ == Orientation::Vertical) { + paintVertical(p); + } else { + paintHorizontal(p); + } +} + +void ColorBarWidget::paintHorizontal(QPainter& p) { const int W = width(); const int H = height(); // 浅色=白底深字(原版 1:1);暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。 @@ -79,4 +93,58 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { } } +void ColorBarWidget::paintVertical(QPainter& p) { + const int W = width(); + const int H = height(); + const bool dark = isDarkTheme(); + const QColor bgColor = dark ? tokenColor("bg/panel") : QColor(Qt::white); + const QColor borderColor = dark ? tokenColor("border/strong") : QColor(120, 120, 120); + const QColor textColor = dark ? tokenColor("text/secondary"): QColor(60, 60, 60); + p.fillRect(0, 0, W, H, bgColor); + + auto stops = scale_.stops(); + if (stops.size() < 2) return; + + QFont font = p.font(); + font.setPixelSize(kFontSize); + p.setFont(font); + QFontMetrics fm(font); + + // 竖条靠右;左侧留出值标签宽度。上下各留半行高,避免顶/底标签出界。 + const int halfLine = fm.height() / 2 + 1; + const int barRight = W - 2; + const int barLeft = barRight - kVBarWidth; + const int barTop = halfLine; + const int barBottom = H - halfLine; + const int barH = barBottom - barTop; + if (barH < 10 || barLeft < 0) return; + + // 等高色带:每相邻断点一格,最大值在顶、最小值在底(与原版 1323→0 自上而下一致)。 + const int nSeg = static_cast(stops.size()) - 1; + // segY(i): 第 i 个边界的 y(i=0 为最小值在底,i=nSeg 为最大值在顶)。 + auto segY = [&](int i) { + return barBottom - static_cast(static_cast(i) / nSeg * barH); + }; + for (int i = 0; i < nSeg; ++i) { + int yB = segY(i), yT = segY(i + 1); + if (yT >= yB) yT = yB - 1; + const auto& c = stops[i].second; // 段 [stop_i, stop_{i+1}) 取 stop_i 色(与离散一致) + p.fillRect(barLeft, yT, kVBarWidth, yB - yT, QColor(c.r, c.g, c.b, c.a)); + } + // 外边框 + p.setPen(QPen(borderColor, 1)); + p.drawRect(barLeft, barTop, kVBarWidth - 1, barH - 1); + + // 边界值标签(色带左侧,垂直居中于各边界)。 + p.setPen(textColor); + for (int i = 0; i <= nSeg; ++i) { + int y = segY(i); + const QString label = QString::number(stops[i].first, 'f', 2); + int tw = fm.horizontalAdvance(label); + int tx = barLeft - kVLabelGap - tw; + if (tx < 0) tx = 0; + p.drawText(tx, y + fm.ascent() / 2 - 1, label); + } +} + } // namespace geopro::app diff --git a/src/app/panels/chart/ColorBarWidget.hpp b/src/app/panels/chart/ColorBarWidget.hpp index b58d77f..b90c857 100644 --- a/src/app/panels/chart/ColorBarWidget.hpp +++ b/src/app/panels/chart/ColorBarWidget.hpp @@ -4,13 +4,17 @@ namespace geopro::app { -// 独立色阶条 Widget,水平排布:上方彩色色带 + 下方刻度值。 -// 作为 QwtPlot 的兄弟 widget 布局在图表下方,不进入 Qwt 坐标系, +// 独立色阶条 Widget,作为 QwtPlot 的兄弟 widget,不进入 Qwt 坐标系, // 因此不随图表缩放/平移移动,也不与轴标注重叠。 +// Horizontal(默认,反演原数据):底部横条,等宽色带 + 下方边界刻度值。 +// Vertical(measurement):右侧竖条,等高色带,最大值在顶、最小值在底, +// 边界值标在色带左侧(对齐原版 Plotly 离散图例)。 class ColorBarWidget : public QWidget { Q_OBJECT public: - explicit ColorBarWidget(QWidget* parent = nullptr); + enum class Orientation { Horizontal, Vertical }; + + explicit ColorBarWidget(QWidget* parent = nullptr, Orientation orient = Orientation::Horizontal); void setColorScale(const core::ColorScale& scale); @@ -18,7 +22,11 @@ protected: void paintEvent(QPaintEvent* event) override; private: + void paintHorizontal(QPainter& p); + void paintVertical(QPainter& p); + core::ColorScale scale_; + Orientation orient_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.cpp b/src/app/panels/chart/DataTableView.cpp new file mode 100644 index 0000000..c6a9bcf --- /dev/null +++ b/src/app/panels/chart/DataTableView.cpp @@ -0,0 +1,151 @@ +#include "panels/chart/DataTableView.hpp" + +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +// 药丸开关配色(原版蓝色 ON)。 +const QColor kSwitchOn(64, 158, 255); // 蓝色(ON/可见) +const QColor kSwitchOff(190, 190, 190); // 灰色(OFF/隐藏) +constexpr int kSwitchW = 36; // 轨道宽 +constexpr int kSwitchH = 18; // 轨道高(=直径) +constexpr int kKnobMargin = 2; // 旋钮与轨道边距 +constexpr int kToggleColW = 80; // Toggle 列固定窄宽(开关 36 + 两侧留白) +} // namespace + +TablePayloadModel::TablePayloadModel(QObject* parent) : QAbstractTableModel(parent) {} + +void TablePayloadModel::setPayload(const geopro::core::TablePayload& payload) { + beginResetModel(); + payload_ = payload; + endResetModel(); +} + +int TablePayloadModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(payload_.rows.size()); +} + +int TablePayloadModel::columnCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(payload_.columns.size()); +} + +QVariant TablePayloadModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) return {}; + const size_t r = static_cast(index.row()); + const size_t c = static_cast(index.column()); + if (r >= payload_.rows.size() || c >= payload_.rows[r].size()) return {}; + if (role == Qt::TextAlignmentRole) + return static_cast(Qt::AlignCenter); // 单元内容水平+垂直居中 + if (role == Qt::DisplayRole) return payload_.rows[r][c]; + return {}; +} + +QVariant TablePayloadModel::headerData(int section, Qt::Orientation orientation, int role) const { + if (role != Qt::DisplayRole) return {}; + if (orientation == Qt::Horizontal) { + if (section < 0 || section >= static_cast(payload_.columns.size())) return {}; + return payload_.columns[static_cast(section)].title; + } + return section + 1; // 行号 +} + +geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const { + if (column < 0 || column >= static_cast(payload_.columns.size())) + return geopro::core::TableColumnKind::Text; + return payload_.columns[static_cast(column)].kind; +} + +ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent) + : QStyledItemDelegate(parent), model_(model) {} + +void ToggleSwitchDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + // 画背景(选中态/交替行),但不画文本。 + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + opt.text.clear(); + if (const QWidget* w = opt.widget) + w->style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter, w); + + const bool on = index.data(Qt::DisplayRole).toString() == QLatin1String("1"); + + // 居中药丸开关。 + const QRect cell = option.rect; + QRectF track(cell.center().x() - kSwitchW / 2.0, cell.center().y() - kSwitchH / 2.0, + kSwitchW, kSwitchH); + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + painter->setBrush(on ? kSwitchOn : kSwitchOff); + painter->drawRoundedRect(track, kSwitchH / 2.0, kSwitchH / 2.0); + // 白色旋钮(ON 靠右,OFF 靠左)。 + const double d = kSwitchH - 2.0 * kKnobMargin; + const double knobX = on ? track.right() - kKnobMargin - d : track.left() + kKnobMargin; + painter->setBrush(Qt::white); + painter->drawEllipse(QRectF(knobX, track.top() + kKnobMargin, d, d)); + painter->restore(); +} + +QSize ToggleSwitchDelegate::sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const { + if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) + return QStyledItemDelegate::sizeHint(option, index); + // 贴合开关的窄宽(+两侧内边距),高度沿用默认行高,避免 ResizeToContents 撑宽该列。 + return QSize(kToggleColW, kSwitchH + 6); +} + +DataTableView::DataTableView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + model_ = new TablePayloadModel(this); + table_ = new QTableView(this); + table_->setModel(model_); + table_->setAlternatingRowColors(true); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + // 列宽策略在 setPayload 按列种类设置(Toggle 列固定窄宽;文本列均分 Stretch)。 + // 不强制拉伸末列——末列通常是窄的 Toggle 列,拉伸会把它撑得过宽。 + table_->horizontalHeader()->setStretchLastSection(false); + table_->horizontalHeader()->setDefaultAlignment(Qt::AlignCenter); // 表头文本居中 + table_->verticalHeader()->setDefaultSectionSize(24); + table_->verticalHeader()->setVisible(false); // 原版无行号列 + + // Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。 + table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_)); + + lay->addWidget(table_); +} + +void DataTableView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) return; // 坏/空 → 空态 + const auto t = payload.value(); + model_->setPayload(t); + + // 列宽策略:文本列等宽均分,Toggle 列窄。 + // Toggle 列 → Fixed 固定窄宽(贴合开关 + 留白),开关居中。 + // 其余文本列 → Stretch(均分可用宽度,整体均衡,无单一巨列、无右侧空白)。 + auto* header = table_->horizontalHeader(); + for (size_t i = 0; i < t.columns.size(); ++i) { + const int col = static_cast(i); + if (t.columns[i].kind == geopro::core::TableColumnKind::Toggle) { + header->setSectionResizeMode(col, QHeaderView::Fixed); + table_->setColumnWidth(col, kToggleColW); + } else { + header->setSectionResizeMode(col, QHeaderView::Stretch); + } + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.hpp b/src/app/panels/chart/DataTableView.hpp new file mode 100644 index 0000000..72cf498 --- /dev/null +++ b/src/app/panels/chart/DataTableView.hpp @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include "model/detail/DetailPayloads.hpp" +#include "panels/chart/IDetailView.hpp" + +class QTableView; + +namespace geopro::app { + +// TablePayload 驱动的只读表模型(列标题来自 TableColumn,单元为预格式化 QString)。 +class TablePayloadModel : public QAbstractTableModel { + Q_OBJECT +public: + explicit TablePayloadModel(QObject* parent = nullptr); + void setPayload(const geopro::core::TablePayload& payload); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + // 列渲染种类(供委托判断是否画开关)。越界返回 Text。 + geopro::core::TableColumnKind columnKind(int column) const; + +private: + geopro::core::TablePayload payload_; +}; + +// Toggle 列委托:把单元值("1"=ON/"0"=OFF)画成蓝色药丸开关(仅展示状态,暂不联动散点)。 +// Text 列回退到默认绘制。 +class ToggleSwitchDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + explicit ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent = nullptr); + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + // Toggle 列返回贴合开关的窄尺寸(供 ResizeToContents 不至于撑宽);其余回退默认。 + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + +private: + const TablePayloadModel* model_; // 不拥有 +}; + +// 通用数据列表视图:IDetailView + QTableView。measurement/grid/trajectory 列表共用。 +class DataTableView : public QWidget, public IDetailView { + Q_OBJECT +public: + explicit DataTableView(QWidget* parent = nullptr); + + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; // 坏/空 variant → 保持空态不崩 + +private: + QTableView* table_; + TablePayloadModel* model_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp new file mode 100644 index 0000000..e5114af --- /dev/null +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -0,0 +1,28 @@ +#include "panels/chart/DetailViewFactory.hpp" + +#include + +#include "panels/chart/DataTableView.hpp" +#include "panels/chart/GridDataChartView.hpp" +#include "panels/chart/RawDataChartView.hpp" + +namespace geopro::app { + +std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent) { + 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::Table: + return std::unique_ptr(new DataTableView(parent)); + case controller::ViewKind::Bar: + case controller::ViewKind::LineProfile: + case controller::ViewKind::PolylineMap: + // 后续阶段补:Bar(gr_data)/LineProfile,PolylineMap(trajectory)。 + throw std::runtime_error("makeDetailView: ViewKind not yet implemented"); + } + throw std::runtime_error("makeDetailView: unknown ViewKind"); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp new file mode 100644 index 0000000..98464b0 --- /dev/null +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -0,0 +1,16 @@ +#pragma once +#include +#include "DatasetDetailTab.hpp" // geopro::controller::ViewKind + +class QWidget; + +namespace geopro::app { + +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); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ErtInversionStrategy.hpp b/src/app/panels/chart/ErtInversionStrategy.hpp index 1fde3ae..62a5ffa 100644 --- a/src/app/panels/chart/ErtInversionStrategy.hpp +++ b/src/app/panels/chart/ErtInversionStrategy.hpp @@ -1,9 +1,17 @@ #pragma once +#include #include "IDatasetChartStrategy.hpp" // geopro::controller(geopro_controller PUBLIC include) namespace geopro::app { -// ERT 反演策略:散点(chart) + 网格等值面(grid) 两阶段。 +// ERT 反演策略:散点(原数据,同步) + 网格等值面(网格数据,懒加载) 两页签。 struct ErtInversionStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } // 反演有网格数据阶段 + std::vector tabs() const override { + return { + {QStringLiteral("原数据"), controller::ViewKind::Scatter, + QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("网格数据"), controller::ViewKind::FilledContour, + QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false}, + }; + } }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index f3c16c2..d268977 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -177,10 +177,13 @@ GridDataChartView::~GridDataChartView() { delete colorSvc_; } -void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - data_ = d; - // 开页:仅把 anomalies 喂给底部异常列表;图表区待网格数据懒加载后填充。 - anomalyTable_->setAnomalies(d.anomalies, {}, {}); +void GridDataChartView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) { + // 坏/空 variant:保持空态(不渲染、不崩)。 + return; + } + const auto p = payload.value(); + setGridData(p.grid, p.scale, p.anomalies); } void GridDataChartView::setGridData(const geopro::core::Grid& grid, diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index cf0acf7..ab8c6d4 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -3,10 +3,11 @@ #include -#include "DatasetDetailController.hpp" #include "model/Anomaly.hpp" #include "model/ColorScale.hpp" #include "model/Field.hpp" +#include "model/detail/DetailPayloads.hpp" +#include "panels/chart/IDetailView.hpp" class QSlider; class QLabel; @@ -24,24 +25,23 @@ class ContourPlotItem; // 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部) // + 独立色阶条 + 底部双页签(异常列表/描述)。 // 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。 -class GridDataChartView : public QWidget { +class GridDataChartView : public QWidget, public IDetailView { Q_OBJECT public: explicit GridDataChartView(QWidget* parent = nullptr); ~GridDataChartView() override; - // 开页时调用:仅喂底部异常列表(网格数据随网格页激活懒加载)。 - void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem,挂到 QwtPlot,更新色阶条/异常表。 void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale, const std::vector& anoms); + // IDetailView:解包 ContourPayload(grid + 色阶 + 异常)→ setGridData。坏/空 variant 保持空态。 + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem - geopro::controller::DatasetDetailController::ChartData data_; - QwtPlot* plot_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr; ColorBarWidget* colorBar_ = nullptr; diff --git a/src/app/panels/chart/IDetailView.hpp b/src/app/panels/chart/IDetailView.hpp new file mode 100644 index 0000000..35782e8 --- /dev/null +++ b/src/app/panels/chart/IDetailView.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +class QWidget; + +namespace geopro::app { + +// 详情页签视图统一接口:壳/工厂只依赖它,渲染细节由具体视图自行解包载荷。 +// widget() 返回承载的 QWidget(多为 this);setPayload 解包 QVariant 载荷并渲染。 +class IDetailView { +public: + virtual ~IDetailView() = default; + virtual QWidget* widget() = 0; + virtual void setPayload(const QVariant& payload) = 0; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/MeasurementStrategy.hpp b/src/app/panels/chart/MeasurementStrategy.hpp new file mode 100644 index 0000000..0904fdb --- /dev/null +++ b/src/app/panels/chart/MeasurementStrategy.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#include "IDatasetChartStrategy.hpp" // geopro::controller +namespace geopro::app { + +// ERT 原始数据(measurement)策略:散点伪剖面(同步)+ 数据列表(懒加载)两页签。 +struct MeasurementStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_ert_measurement_data"; } + std::vector tabs() const override { + return { + {QStringLiteral("散点图"), controller::ViewKind::Scatter, + QStringLiteral("ert_measurement.scatter"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("数据列表"), controller::ViewKind::Table, + QStringLiteral("ert_measurement.rows"), /*lazy*/ true, /*paginated*/ false}, + }; + } +}; +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 3748a86..7268f10 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -5,9 +5,17 @@ #include "panels/chart/ScatterPlotItem.hpp" #include +#include #include +#include #include +#include +#include +#include +#include +#include #include +#include #include #include @@ -29,9 +37,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); + rootLay_ = lay; - // ---- 工具条 ---- + // ---- 工具条(默认 = 反演原数据;measurement 到来时 buildMeasurementToolbar 替换)---- auto* toolbar = new QWidget(this); + toolbar_ = toolbar; auto* tbLay = new QHBoxLayout(toolbar); tbLay->setContentsMargins(4, 4, 4, 4); tbLay->setSpacing(4); @@ -111,9 +121,21 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { rescaler_->setAspectRatio(1.0); rescaler_->setEnabled(true); - lay->addWidget(plot_, 1); + // ---- 图表行:plot(stretch)+ 右侧竖向色阶条(measurement 用,默认隐藏)---- + auto* plotRow = new QWidget(this); + auto* plotRowLay = new QHBoxLayout(plotRow); + plotRowLay->setContentsMargins(0, 0, 0, 0); + plotRowLay->setSpacing(0); + plotRowLay->addWidget(plot_, 1); - // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- + colorBarV_ = new ColorBarWidget(plotRow, ColorBarWidget::Orientation::Vertical); + colorBarV_->setObjectName(QStringLiteral("rawColorScaleBarV")); + colorBarV_->setVisible(false); // 默认(反演原数据)用底部横条 + plotRowLay->addWidget(colorBarV_); + + lay->addWidget(plotRow, 1); + + // ---- 底部独立色阶条(固定高 36px,反演原数据用)---- colorBar_ = new ColorBarWidget(this); colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); lay->addWidget(colorBar_); @@ -133,27 +155,280 @@ QWidget* RawDataChartView::plotArea() const { return plot_; } -void RawDataChartView::setData( - const geopro::controller::DatasetDetailController::ChartData& d) { - data_ = d; +namespace { + +// 把一组 FieldOption 填进下拉,并按 defaultCode(或 defaultName 回退)选中默认项。 +void fillCombo(QComboBox* combo, const std::vector& opts, + const QString& defaultCode, const QString& defaultName) { + for (const auto& o : opts) { + // 用户可见名为 name;userData 存 fieldCode(重绘/识别用)。 + combo->addItem(o.name, o.code); + } + // 默认选中:优先匹配 fieldCode,否则匹配 name(method 的 code 全为 null,用 name)。 + int idx = defaultCode.isEmpty() ? -1 : combo->findData(defaultCode); + if (idx < 0 && !defaultName.isEmpty()) idx = combo->findText(defaultName); + if (idx >= 0) combo->setCurrentIndex(idx); +} + +// ── 工具条占位图标(线性风格,2× 超采样 → HiDPI 清晰)──────────────────── +// 原版用 Arco line icons(viewBox 0 0 48 48,stroke-width 4,stroke=currentColor)。 +// 这里以 QPainter 画细线风格,逻辑边长 logical px(按钮 setIconSize 用同值), +// 像素 2× 渲染并 setDevicePixelRatio(2) 保证缩放下不糊。 +constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐) +constexpr qreal kToolIconScale = 2.0; // 超采样倍率(HiDPI 清晰) + +QPixmap makeToolIconCanvas(QPainter& p) { + // 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。 + const int dim = qRound(kToolIconPx * kToolIconScale); + QPixmap pm(dim, dim); + pm.fill(Qt::transparent); + p.begin(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + p.scale(kToolIconScale, kToolIconScale); // 之后用逻辑 px 坐标 + return pm; +} + +// 信息图标:细描边圆 + 内部 "i"(圆点 + 竖线),品牌蓝(两主题一致,对应原版小蓝圈-i)。 +QIcon makeInfoIcon() { + const QColor accent = tokenColor("accent/primary"); + QPainter p; + QPixmap pm = makeToolIconCanvas(p); + const qreal s = kToolIconPx; + QPen pen(accent, 1.4); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + // 外圈(留 1.5px 边距,避免描边被裁)。 + const qreal m = 1.5; + p.drawEllipse(QRectF(m, m, s - 2 * m, s - 2 * m)); + // "i":上点 + 下竖线(居中)。 + const qreal cx = s / 2.0; + p.setBrush(accent); + p.setPen(Qt::NoPen); + p.drawEllipse(QPointF(cx, s * 0.34), 0.9, 0.9); // 点 + QPen stem(accent, 1.4); + stem.setCapStyle(Qt::RoundCap); + p.setPen(stem); + p.drawLine(QPointF(cx, s * 0.46), QPointF(cx, s * 0.70)); // 竖 + p.end(); + pm.setDevicePixelRatio(kToolIconScale); + return QIcon(pm); +} + +// 框选图标:虚线方框(选区)+ 右下角小箭头光标。描边随主题(次要文本色,两主题可见)。 +QIcon makeMarqueeIcon() { + const QColor stroke = tokenColor("text/secondary"); + QPainter p; + QPixmap pm = makeToolIconCanvas(p); + const qreal s = kToolIconPx; + // 虚线选区框(左上偏移,给右下角箭头让位)。 + QPen dash(stroke, 1.3); + dash.setCapStyle(Qt::FlatCap); + dash.setJoinStyle(Qt::MiterJoin); + QVector pattern{2.0, 1.6}; + dash.setDashPattern(pattern); + p.setPen(dash); + p.setBrush(Qt::NoBrush); + p.drawRect(QRectF(2.0, 2.0, s - 6.0, s - 6.0)); + // 右下角小箭头光标(实线填充,指向右下)。 + QPainterPath cur; + const qreal ax = s - 5.0, ay = s - 5.0; + cur.moveTo(ax, ay); + cur.lineTo(ax + 4.6, ay + 1.8); + cur.lineTo(ax + 1.9, ay + 2.6); + cur.lineTo(ax + 1.1, ay + 4.9); + cur.closeSubpath(); + p.setPen(Qt::NoPen); + p.setBrush(stroke); + p.drawPath(cur); + p.end(); + pm.setDevicePixelRatio(kToolIconScale); + return QIcon(pm); +} + +// 把占位 QToolButton 配成图标按钮:清文字、设图标 + 固定尺寸(与工具条其它按钮一致高度)。 +void styleToolIconButton(QToolButton* btn, const QIcon& icon) { + btn->setText(QString()); + btn->setIcon(icon); + btn->setIconSize(QSize(kToolIconPx, kToolIconPx)); + btn->setAutoRaise(true); + btn->setFixedSize(QSize(28, 28)); // 与下拉/按钮行高协调;不再过小或锯齿 + btn->setCursor(Qt::PointingHandCursor); +} + +} // namespace + +void RawDataChartView::showNotImplemented(QWidget* anchor) { + // 轻提示:占位按钮/下拉点击 → 暂未实现(不阻塞,不弹窗)。 + const QPoint pos = anchor ? anchor->mapToGlobal(QPoint(0, anchor->height())) + : QCursor::pos(); + QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor); +} + +void RawDataChartView::replotForAxis() { + // 本地换 x/y(无网络):按下拉 fieldCode 从备选列取数据,重设 scatter.x/.y 并重绘。 + if (!xCombo_ || !yCombo_ || !scatterItem_) return; + const QString xCode = xCombo_->currentData().toString(); + const QString yCode = yCombo_->currentData().toString(); + + if (xCode == QStringLiteral("horizontalDistance") && !data_.altXHorizontal.empty()) + data_.scatter.x = data_.altXHorizontal; + else if (!data_.altXSlope.empty()) + data_.scatter.x = data_.altXSlope; // 默认/斜距 + + if (yCode == QStringLiteral("elevationPseudoDepth") && !data_.altYElevationPseudo.empty()) + data_.scatter.y = data_.altYElevationPseudo; + else if (yCode == QStringLiteral("pseudoDepth") && !data_.altYPseudo.empty()) + data_.scatter.y = data_.altYPseudo; + // 层数(Layer No):数据为 null → 不改轴(保持当前),选项可选但 no-op。 + + scatterItem_->setData(data_.scatter, colorSvc_); + if (hoverTip_) hoverTip_->setField(&data_.scatter); + QRectF bbox = scatterItem_->boundingRect(); + if (!bbox.isEmpty()) { + plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right()); + plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); + } + plot_->updateAxes(); + if (rescaler_) rescaler_->rescale(); + plot_->replot(); +} + +void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolbarConf& conf) { + auto* toolbar = new QWidget(this); + auto* tbLay = new QHBoxLayout(toolbar); + tbLay->setContentsMargins(4, 4, 4, 4); + tbLay->setSpacing(4); + + // [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标(HiDPI 清晰,随主题)。 + auto* btnInfo = new QToolButton(toolbar); + btnInfo->setToolTip(QStringLiteral("信息")); + styleToolIconButton(btnInfo, makeInfoIcon()); + connect(btnInfo, &QToolButton::clicked, this, [this, btnInfo]() { showNotImplemented(btnInfo); }); + auto* btnMarquee = new QToolButton(toolbar); + btnMarquee->setToolTip(QStringLiteral("框选")); + styleToolIconButton(btnMarquee, makeMarqueeIcon()); + connect(btnMarquee, &QToolButton::clicked, this, [this, btnMarquee]() { showNotImplemented(btnMarquee); }); + // 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。 + connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo, + [btnInfo]() { btnInfo->setIcon(makeInfoIcon()); }); + connect(&ThemeManager::instance(), &ThemeManager::changed, btnMarquee, + [btnMarquee]() { btnMarquee->setIcon(makeMarqueeIcon()); }); + + // 显示 / 隐藏:功能性——切换全部数据方块可见性(电极保留)。 + auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar); + auto* btnHide = new QPushButton(QStringLiteral("隐藏"), toolbar); + connect(btnShow, &QPushButton::clicked, this, [this]() { + if (scatterItem_) { scatterItem_->setScatterVisible(true); plot_->replot(); } + }); + connect(btnHide, &QPushButton::clicked, this, [this]() { + if (scatterItem_) { scatterItem_->setScatterVisible(false); plot_->replot(); } + }); + + // 数据过滤:占位(暂未实现)。 + auto* btnFilter = new QPushButton(QStringLiteral("数据过滤"), toolbar); + connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { showNotImplemented(btnFilter); }); + + // x / y 下拉:功能性(本地换列重绘);v / method 下拉:视觉占位(选不同 v/method 提示暂未实现)。 + xCombo_ = new QComboBox(toolbar); + fillCombo(xCombo_, conf.x, conf.defaultX, QString()); + yCombo_ = new QComboBox(toolbar); + fillCombo(yCombo_, conf.y, conf.defaultY, QString()); + auto* vCombo = new QComboBox(toolbar); + fillCombo(vCombo, conf.v, conf.defaultV, QString()); + auto* methodCombo = new QComboBox(toolbar); + fillCombo(methodCombo, conf.method, QString(), conf.defaultMethod); + + connect(xCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { replotForAxis(); }); + connect(yCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { replotForAxis(); }); + // v / method:换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。 + connect(vCombo, QOverload::of(&QComboBox::activated), this, + [this, vCombo](int) { showNotImplemented(vCombo); }); + connect(methodCombo, QOverload::of(&QComboBox::activated), this, + [this, methodCombo](int) { showNotImplemented(methodCombo); }); + + // 色阶配置:占位(暂未实现)。 + auto* btnColorScale = new QPushButton(QStringLiteral("色阶配置"), toolbar); + connect(btnColorScale, &QPushButton::clicked, this, [this, btnColorScale]() { showNotImplemented(btnColorScale); }); + + // 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为 —— 占位(暂未实现)。 + auto* btnGen = new QPushButton(QStringLiteral("生成视电阻率数据"), toolbar); + auto* btnInvert = new QPushButton(QStringLiteral("反演运算"), toolbar); + auto* btnSaveAs = new QPushButton(QStringLiteral("另存为"), toolbar); + for (auto* b : {btnGen, btnInvert, btnSaveAs}) { + b->setObjectName(QStringLiteral("primaryBtn")); // 蓝色主按钮(下方 QSS) + connect(b, &QPushButton::clicked, this, [this, b]() { showNotImplemented(b); }); + } + + tbLay->addWidget(btnInfo); + tbLay->addWidget(btnMarquee); + tbLay->addWidget(btnShow); + tbLay->addWidget(btnHide); + tbLay->addWidget(btnFilter); + tbLay->addWidget(xCombo_); + tbLay->addWidget(yCombo_); + tbLay->addWidget(vCombo); + tbLay->addWidget(methodCombo); + tbLay->addWidget(btnColorScale); + tbLay->addStretch(); // 把主操作推到右侧 + tbLay->addWidget(btnGen); + tbLay->addWidget(btnInvert); + tbLay->addWidget(btnSaveAs); + + // 蓝色主按钮样式(随主题;普通按钮/下拉走全局 QSS 已支持明暗)。 + applyTokenizedStyleSheet( + toolbar, + QStringLiteral( + "QPushButton#primaryBtn { background: {{accent/primary}}; color: {{text/on-primary}};" + " border: 1px solid {{accent/primary}}; border-radius: 6px; padding: 6px 14px; }" + "QPushButton#primaryBtn:hover { background: {{accent/primary-hover}}; border-color: {{accent/primary-hover}}; }" + "QPushButton#primaryBtn:pressed { background: {{accent/primary-pressed}}; }")); + + // 替换 ctor 建的 inversion 工具条(同一顶层布局首位)。 + rootLay_->replaceWidget(toolbar_, toolbar); + toolbar_->deleteLater(); + toolbar_ = toolbar; + chartTypeCombo_ = nullptr; // 已随旧工具条移除 + measurementToolbar_ = true; +} + +void RawDataChartView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) { + // 坏/空 variant:保持空态(不渲染、不崩)。E2+ 可在此显式提示「渲染数据格式错误」。 + return; + } + setData(payload.value()); +} + +void RawDataChartView::setData(const geopro::core::ScatterPayload& p) { + data_ = p; if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) - if (d.scatterScale.empty()) return; + // measurement 载荷(toolbar 非空):首次到来时建并替换工具条(视觉 1:1)。反演留空 → 不动。 + if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar); + + if (p.scale.empty()) return; // 重建 ColorMapService(旧的 scatterItem 已被 QwtPlot detach/delete) delete colorSvc_; - colorSvc_ = new ColorMapService(d.scatterScale); + colorSvc_ = new ColorMapService(p.scale); - // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max): - // 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。 - double vMin = std::numeric_limits::max(); - double vMax = std::numeric_limits::lowest(); - for (double v : d.scatter.v) { - if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf(脏数据),否则数据范围被污染→全图 NaN 取色 - if (v < vMin) vMin = v; - if (v > vMax) vMax = v; + // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max),按 vlist 有限值 + // 的 min/max 设数据范围,使整段色阶铺满数据实际范围。measurement 与反演原数据同此路径: + // v 含负异常值(如 -1066),故 cmin<0,中段视电阻率归一化到色阶中部(深品红/紫)。 + { + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : p.scatter.v) { + if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf(脏数据),否则数据范围被污染→全图 NaN 取色 + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); } - if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); // 卸载旧散点项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。 // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 @@ -165,7 +440,7 @@ void RawDataChartView::setData( // 新建散点项并挂到 plot scatterItem_ = new ScatterPlotItem(); - scatterItem_->setData(d.scatter, colorSvc_); + scatterItem_->setData(p.scatter, colorSvc_); scatterItem_->attach(plot_); // 按数据包围盒设置轴范围 @@ -178,8 +453,16 @@ void RawDataChartView::setData( if (rescaler_) rescaler_->rescale(); // 应用真实比尺 plot_->replot(); - // 更新色阶条 - colorBar_->setColorScale(d.scatterScale); + // 更新色阶条:measurement 用右侧竖条,反演原数据用底部横条(二选一显示)。 + if (p.verticalLegend) { + colorBarV_->setColorScale(p.scale); + colorBarV_->setVisible(true); + colorBar_->setVisible(false); + } else { + colorBar_->setColorScale(p.scale); + colorBar_->setVisible(true); + colorBarV_->setVisible(false); + } } } // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index befb465..38b2c51 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,9 +1,11 @@ #pragma once #include -#include "DatasetDetailController.hpp" +#include "model/detail/DetailPayloads.hpp" #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/IDetailView.hpp" class QComboBox; +class QVBoxLayout; class QwtPlot; class QwtPlotRescaler; @@ -14,23 +16,43 @@ class ScatterPlotItem; class ScatterHoverTip; // 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 -class RawDataChartView : public QWidget { +class RawDataChartView : public QWidget, public IDetailView { Q_OBJECT public: explicit RawDataChartView(QWidget* parent = nullptr); ~RawDataChartView() override; - void setData(const geopro::controller::DatasetDetailController::ChartData& d); + // 渲染散点载荷(解包后调内部渲染;渲染代码不变)。 + void setData(const geopro::core::ScatterPayload& p); + + // IDetailView:壳/工厂统一入口。坏/空 variant → 保持空态不崩。 + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; // 供外部访问(已不再是占位,保留兼容接口返回 plot_) QWidget* plotArea() const; private: - geopro::controller::DatasetDetailController::ChartData data_; + // 工具条按载荷类型二选一:反演原数据 = ctor 默认建的 inversion 工具条;measurement = + // 首个非空 ScatterToolbarConf 到来时建一次并替换(视觉 1:1)。建好后缓存,后续 setData 复用。 + void buildMeasurementToolbar(const geopro::core::ScatterToolbarConf& conf); + // 按当前 x/y 下拉选择,从备选列重绘散点(本地,无网络)。code 为下拉项 fieldCode。 + void replotForAxis(); + // “暂未实现”轻提示(占位按钮/下拉点击)。 + void showNotImplemented(QWidget* anchor); + + geopro::core::ScatterPayload data_; QwtPlot* plot_; QwtPlotRescaler* rescaler_ = nullptr; // 锁定 x:y 真实比尺 - ColorBarWidget* colorBar_; - QComboBox* chartTypeCombo_; + ColorBarWidget* colorBar_; // 底部横条(反演原数据) + ColorBarWidget* colorBarV_; // 右侧竖条(measurement) + QComboBox* chartTypeCombo_ = nullptr; + + QVBoxLayout* rootLay_ = nullptr; // 顶层竖向布局(用于替换工具条) + QWidget* toolbar_ = nullptr; // 当前工具条(inversion 或 measurement) + bool measurementToolbar_ = false; // 已建 measurement 工具条 + QComboBox* xCombo_ = nullptr; // measurement x 下拉 + QComboBox* yCombo_ = nullptr; // measurement y 下拉 // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 diff --git a/src/app/panels/chart/ScatterHoverTip.cpp b/src/app/panels/chart/ScatterHoverTip.cpp index f38280b..1150190 100644 --- a/src/app/panels/chart/ScatterHoverTip.cpp +++ b/src/app/panels/chart/ScatterHoverTip.cpp @@ -38,15 +38,44 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { const auto& ys = field_->y; const auto& vs = field_->v; const std::size_t n = std::min(xs.size(), ys.size()); - if (n == 0) { - QToolTip::hideText(); - return false; - } // 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap)。 const QwtScaleMap xMap = plot_->canvasMap(xAxis_); const QwtScaleMap yMap = plot_->canvasMap(yAxis_); const QPointF mp = me->position(); + + // 电极 hover(y=0 行,与数据点 hover 分开):电极 marker 略大于数据方块, + // 命中半径稍大。命中则显示 x/y/num 浮动框(measurement 专有,反演 electrodeX 空→跳过)。 + { + const auto& ex = field_->electrodeX; + const auto& eno = field_->electrodeNo; + const double ey0 = yMap.transform(0.0); + double eBestD2 = std::numeric_limits::max(); + std::size_t eBestI = 0; + for (std::size_t i = 0; i < ex.size(); ++i) { + const double dx = xMap.transform(ex[i]) - mp.x(); + const double dy = ey0 - mp.y(); + const double d2 = dx * dx + dy * dy; + if (d2 < eBestD2) { + eBestD2 = d2; + eBestI = i; + } + } + if (eBestD2 <= kElectrodeHitRadiusPx * kElectrodeHitRadiusPx) { + const int num = (eBestI < eno.size()) + ? static_cast(eno[eBestI]) + : static_cast(eBestI) + 1; + QToolTip::showText(me->globalPosition().toPoint(), + electrodeHoverText(ex[eBestI], num), plot_->canvas()); + return false; + } + } + + if (n == 0) { + QToolTip::hideText(); + return false; + } + double bestD2 = std::numeric_limits::max(); std::size_t bestI = 0; for (std::size_t i = 0; i < n; ++i) { @@ -61,8 +90,17 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { if (bestD2 <= kHitRadiusPx * kHitRadiusPx) { const double v = (bestI < vs.size()) ? vs[bestI] : 0.0; - QToolTip::showText(me->globalPosition().toPoint(), - scatterHoverText(xs[bestI], ys[bestI], v), plot_->canvas()); + // 有 A/B/M/N 元数据(measurement)→ 浮动框含 X/Y/Value/a/b/m/n;否则回退 X/Y/值(反演原数据)。 + const auto& a = field_->a; + QString text; + if (bestI < a.size() && bestI < field_->b.size() && bestI < field_->m.size() && + bestI < field_->n.size()) { + text = measurementHoverText(xs[bestI], ys[bestI], v, a[bestI], field_->b[bestI], + field_->m[bestI], field_->n[bestI]); + } else { + text = scatterHoverText(xs[bestI], ys[bestI], v); + } + QToolTip::showText(me->globalPosition().toPoint(), text, plot_->canvas()); } else { QToolTip::hideText(); } diff --git a/src/app/panels/chart/ScatterHoverTip.hpp b/src/app/panels/chart/ScatterHoverTip.hpp index 7c83409..ac1916e 100644 --- a/src/app/panels/chart/ScatterHoverTip.hpp +++ b/src/app/panels/chart/ScatterHoverTip.hpp @@ -17,6 +17,31 @@ inline QString scatterHoverText(double x, double y, double v) { .arg(v, 0, 'f', 3); } +// measurement 散点 hover 浮动框:对齐原版 Plotly 浮动框多行 +// X / Y / Value(视电阻率 col16) / a / b / m / n。 +inline QString measurementHoverText(double x, double y, double v, + double a, double b, double m, double n) { + return QStringLiteral( + "X: %1
    Y: %2
    Value: %3" + "
    a: %4
    b: %5
    m: %6
    n: %7") + .arg(x, 0, 'f', 3) + .arg(y, 0, 'f', 3) + .arg(v, 0, 'f', 3) + .arg(a, 0, 'g', 6) + .arg(b, 0, 'g', 6) + .arg(m, 0, 'g', 6) + .arg(n, 0, 'g', 6); +} + +// 电极 hover 浮动框:对齐原版 Plotly 电极 trace 浮动框(与数据点 hover 分开), +// x: {slopeDistance}
    y: 0
    num: {electrodeNo} +// x 用 6 位有效数字(对齐原版,如 5.123837),num 为电极编号(1-based 整数)。 +inline QString electrodeHoverText(double x, int num) { + return QStringLiteral("x: %1
    y: 0
    num: %2") + .arg(x, 0, 'g', 7) + .arg(num); +} + // 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点, // 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。 // 不消费事件,与 LivePanner(左键平移/滚轮缩放)共存。 @@ -38,6 +63,7 @@ private: const core::ScatterField* field_ = nullptr; static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸 + static constexpr double kElectrodeHitRadiusPx = 7.0; // 电极菱形略大,命中半径稍大 }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp index bfd8ed1..85a051a 100644 --- a/src/app/panels/chart/ScatterPlotItem.cpp +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -1,8 +1,10 @@ #include "panels/chart/ScatterPlotItem.hpp" #include +#include #include #include #include +#include #include namespace geopro::app { @@ -30,6 +32,9 @@ void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* double xMax = *std::max_element(field_.x.begin(), field_.x.end()); double yMin = *std::min_element(field_.y.begin(), field_.y.end()); double yMax = *std::max_element(field_.y.begin(), field_.y.end()); + // 电极 marker 在 y=0、x=electrodeX 处:纳入包围盒,避免电极被截。 + for (double ex : field_.electrodeX) { xMin = std::min(xMin, ex); xMax = std::max(xMax, ex); } + if (!field_.electrodeX.empty()) { yMin = std::min(yMin, 0.0); yMax = std::max(yMax, 0.0); } // 加少量 margin,避免边界点被截 double xM = (xMax - xMin) * 0.03 + 0.1; double yM = (yMax - yMin) * 0.03 + 0.1; @@ -56,12 +61,35 @@ void ScatterPlotItem::draw(QPainter* painter, painter->save(); painter->setRenderHint(QPainter::Antialiasing, false); - painter->setPen(QPen(Qt::white, kPenWidth)); + // 电极位置:y=0 处灰色菱形(先画,置于散点下层)。 + // 原版 Plotly 电极 marker:实心 #BEBEBE(190,190,190) 填充 + 白色 2px 描边, + // 菱形比数据方块略大(size 16 vs 12)。 + if (!field_.electrodeX.empty()) { + const double y0 = yMap.transform(0.0); + painter->setPen(QPen(Qt::white, kElectrodePenWidth)); + painter->setBrush(QColor(190, 190, 190)); + const double h = kElectrodeHalfSide; + for (double ex : field_.electrodeX) { + const double px = xMap.transform(ex); + QPolygonF dia; + dia << QPointF(px, y0 - h) << QPointF(px + h, y0) + << QPointF(px, y0 + h) << QPointF(px - h, y0); + painter->drawPolygon(dia); + } + } + + // 隐藏数据方块:电极已绘,直接收尾(保留电极菱形)。 + if (!scatterVisible_) { painter->restore(); return; } + + painter->setPen(QPen(Qt::white, kPenWidth)); for (std::size_t i = 0; i < n; ++i) { double px = xMap.transform(xs[i]); double py = yMap.transform(ys[i]); double val = (i < vs.size()) ? vs[i] : 0.0; + // 非有限值(NaN/±Inf,可能来自降级后端的脏数据):跳过该点,不绘制。 + // 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。 + if (!std::isfinite(val)) continue; auto c = colorSvc_->colorAtContinuous(val); painter->setBrush(QColor(c.r, c.g, c.b, c.a)); painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, diff --git a/src/app/panels/chart/ScatterPlotItem.hpp b/src/app/panels/chart/ScatterPlotItem.hpp index 34342a9..3b39b32 100644 --- a/src/app/panels/chart/ScatterPlotItem.hpp +++ b/src/app/panels/chart/ScatterPlotItem.hpp @@ -11,13 +11,17 @@ namespace geopro::app { // QwtPlotItem:把 ScatterField 数据渲染为彩色方块散点。 // 每个点用固定像素边长方块绘制(不随缩放变大),白色描边, -// 颜色由 ColorMapService 连续插值决定(与原版 Plotly 一致)。 +// 颜色由 ColorMapService 连续插值决定(cauto,与原版 Plotly 一致;measurement/反演同路径)。 class ScatterPlotItem : public QwtPlotItem { public: ScatterPlotItem(); + // 按 ColorMapService 连续插值上色(cauto);数据范围由调用方在 svc 上 setDataRange。 void setData(const core::ScatterField& field, ColorMapService* svc); + // 显示/隐藏数据方块(measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。 + void setScatterVisible(bool on) { scatterVisible_ = on; } + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } QRectF boundingRect() const override; @@ -31,9 +35,15 @@ private: core::ScatterField field_; ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 QRectF bounding_; + bool scatterVisible_ = true; // false:仅画电极,隐藏数据方块 - static constexpr double kHalfSide = 3.5; // 方块半边长(像素) + // 数据方块:原版 Plotly marker.size 12px(绝对像素直径)→ 半边长 6px,真 1:1。 + static constexpr double kHalfSide = 6.0; // 方块半边长(像素,全宽 12px) static constexpr double kPenWidth = 1.0; // 白色描边宽度(像素) + // 电极菱形:原版 Plotly marker.size 16px(绝对像素直径)→ 半对角 8px,真 1:1。 + // 相对数据方块 ≈1.33×(16 vs 12),实心 #BEBEBE 填充 + 白色 2px 描边。 + static constexpr double kElectrodeHalfSide = 8.0; // 半对角(像素,全宽 16px) + static constexpr double kElectrodePenWidth = 2.0; // 电极白色描边宽度(像素) }; } // namespace geopro::app diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index c21d7a6..a496f17 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,4 +1,5 @@ #include "DatasetDetailController.hpp" +#include #include #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" @@ -6,70 +7,68 @@ namespace geopro::controller { DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, ChartStrategyRegistry& registry, QObject* parent) - : QObject(parent), repo_(repo), registry_(registry) {} + : QObject(parent), repo_(repo), registry_(registry) { + // QSignalSpy / 队列连接对自定义类型需注册(页签集随 datasetOpened 传递)。 + qRegisterMetaType>("std::vector"); +} DatasetDetailController::~DatasetDetailController() { - if (chartLoad_) chartLoad_->abort(); // 退出契约:abort 在飞句柄,不依赖外部析构顺序兜底 - if (gridLoad_) gridLoad_->abort(); + // 退出契约:abort 全部在飞句柄,不依赖外部析构顺序兜底。 + for (auto& load : inflight_) + if (load) load->abort(); } void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode, const QString& dsName) { qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId), qUtf8Printable(ddCode), qUtf8Printable(dsName)); - if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级 + auto* s = registry_.find(ddCode.toStdString()); + if (!s) { // 未注册策略 → 优雅降级 qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } - if (chartLoad_) chartLoad_->abort(); // abort-and-replace - data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); - chartLoad_ = load; - emit loadStarted(dsId, LoadPhase::Chart); - QObject::connect(load, &data::ChartLoad::done, this, - [this, load, dsId, ddCode, dsName](const data::ChartParts& parts) { - if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 - chartLoad_.clear(); - ChartData d; - d.dsId = dsId; - d.ddCode = ddCode; - d.dsName = dsName; - d.scatter = parts.scatter; - d.scatterScale = parts.scatterScale; - emit chartReady(d); - }); - QObject::connect(load, &data::ChartLoad::failed, this, - [this, load, dsId](const QString& msg) { - if (load != chartLoad_) return; - chartLoad_.clear(); - qWarning("[detail] 原数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); - emit loadFailed(dsId, msg); - }); + const std::vector tabs = s->tabs(); + emit datasetOpened(dsId, ddCode, dsName, tabs); + for (int i = 0; i < static_cast(tabs.size()); ++i) + if (!tabs[static_cast(i)].lazy) loadTab(dsId, ddCode, i); } -void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { - auto* strategy = registry_.find(ddCode.toStdString()); - if (!strategy || !strategy->hasGridPhase()) return; // 仅有网格阶段的类型加载网格数据 - if (gridLoad_) gridLoad_->abort(); // abort-and-replace - data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); - gridLoad_ = load; - emit loadStarted(dsId, LoadPhase::Grid); - QObject::connect(load, &data::GridLoad::done, this, - [this, load, dsId](const data::GridParts& parts) { - if (load != gridLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 - gridLoad_.clear(); - GridData d; - d.dsId = dsId; - d.grid = parts.grid; - d.gridScale = parts.gridScale; - d.anomalies = parts.anomalies; - emit gridReady(d); +void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode, int tabIndex) { + auto* s = registry_.find(ddCode.toStdString()); + if (!s) return; // 策略消失(不应发生):静默不加载 + const std::vector tabs = s->tabs(); + if (tabIndex < 0 || tabIndex >= static_cast(tabs.size())) return; + const controller::TabSpec& spec = tabs[static_cast(tabIndex)]; + + // loadAsync 对未知 loaderKey 抛 std::runtime_error;若逃逸槽函数会被 GuardedApplication + // 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed,且不留半注册的在飞句柄。 + data::DetailLoad* load = nullptr; + try { + load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString()); + } catch (const std::exception& e) { + qWarning("[detail] loadAsync 失败 id=%s tab=%d key=%s: %s", qUtf8Printable(dsId), tabIndex, + qUtf8Printable(spec.loaderKey), e.what()); + emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); + return; + } + + if (auto& prev = inflight_[tabIndex]) prev->abort(); // abort-and-replace 该槽位 + inflight_[tabIndex] = load; + emit tabLoadStarted(dsId, tabIndex); + + QObject::connect(load, &data::DetailLoad::done, this, + [this, load, dsId, tabIndex](const QVariant& payload) { + if (load != inflight_.value(tabIndex)) return; // §5.0 句柄身份比对:丢弃迟到信号 + inflight_.remove(tabIndex); + emit tabReady(dsId, tabIndex, payload); }); - QObject::connect(load, &data::GridLoad::failed, this, - [this, load, dsId](const QString& msg) { - if (load != gridLoad_) return; - gridLoad_.clear(); - qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); + QObject::connect(load, &data::DetailLoad::failed, this, + [this, load, dsId, tabIndex](const QString& msg) { + if (load != inflight_.value(tabIndex)) return; + inflight_.remove(tabIndex); + qWarning("[detail] 页签加载失败 id=%s tab=%d: %s", qUtf8Printable(dsId), tabIndex, + qUtf8Printable(msg)); emit loadFailed(dsId, msg); }); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 5487bf0..20b5260 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -1,55 +1,40 @@ #pragma once #include +#include +#include #include #include #include -#include "model/Field.hpp" -#include "model/ColorScale.hpp" -#include "model/Anomaly.hpp" +#include +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" -namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } +namespace geopro::data { class IAsyncDatasetRepository; class DetailLoad; } namespace geopro::controller { -// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 +// 数据详情通用 tab 引擎编排:双击/页签激活 → 按 loaderKey 异步拉载荷(QVariant) → 发信号给详情面板。 +// 无 per-ddCode 分支:页签集由策略 tabs() 描述,载荷经 QVariant 类型擦除。被动视图。 class DatasetDetailController : public QObject { Q_OBJECT public: - enum class LoadPhase { Chart, Grid }; - Q_ENUM(LoadPhase) - - struct ChartData { - QString dsId, ddCode, dsName; // dsName:页签标题用(空则回退 dsId) - geopro::core::ScatterField scatter; - geopro::core::ColorScale scatterScale; - geopro::core::Grid grid{1, 1}; // Grid 无默认构造;以占位值初始化,openDataset 会覆盖 - geopro::core::ColorScale gridScale; - std::vector anomalies; - }; - // 网格数据(inversion/rows + 色阶 type2 + 异常):随「网格数据」页签首次激活按需懒加载。 - struct GridData { - QString dsId; - geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位值初始化,loadGridData 会覆盖 - geopro::core::ColorScale gridScale; - std::vector anomalies; - }; - DatasetDetailController(data::IAsyncDatasetRepository& repo, ChartStrategyRegistry& registry, QObject* parent = nullptr); - ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this + ~DatasetDetailController() override; // 退出契约(spec §7):abort 全部在飞句柄,避免迟到信号打到已析构 this public slots: + // 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。 void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString()); + // 加载某页签(lazy 页签首次激活时由壳触发;非 lazy 由 openDataset 自动触发)。 + void loadTab(const QString& dsId, const QString& ddCode, int tabIndex); void focusDataset(const QString& dsId); - void loadGridData(const QString& dsId, const QString& ddCode); signals: - void loadStarted(const QString& dsId, LoadPhase phase); - void chartReady(const ChartData& data); - void gridReady(const GridData& data); - void focusRequested(const QString& dsId); + void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& tabs); + void tabLoadStarted(const QString& dsId, int tabIndex); + void tabReady(const QString& dsId, int tabIndex, const QVariant& payload); void loadFailed(const QString& dsId, const QString& message); + void focusRequested(const QString& dsId); private: data::IAsyncDatasetRepository& repo_; ChartStrategyRegistry& registry_; - QPointer chartLoad_; - QPointer gridLoad_; + QMap> inflight_; // 按页签槽位的在飞句柄(§5.0 身份比对) }; } // namespace geopro::controller diff --git a/src/controller/DatasetDetailTab.hpp b/src/controller/DatasetDetailTab.hpp new file mode 100644 index 0000000..d4b1676 --- /dev/null +++ b/src/controller/DatasetDetailTab.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +namespace geopro::controller { + +// 详情页签的渲染 kind 全集(Image/GISMap 待 GPR/radar 有活样本再加,YAGNI)。 +enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table }; + +// 页签描述符:策略声明每个 dd 类型的页签集(标题/kind/加载键/惰性/分页)。 +struct TabSpec { + QString title; + ViewKind kind; + QString loaderKey; + bool lazy = false; + bool paginated = false; +}; + +} // namespace geopro::controller + +// QSignalSpy 对 datasetOpened(..., std::vector) 需注册的元类型。 +Q_DECLARE_METATYPE(geopro::controller::TabSpec) +Q_DECLARE_METATYPE(std::vector) diff --git a/src/controller/IDatasetChartStrategy.hpp b/src/controller/IDatasetChartStrategy.hpp index 29c1452..4c7aa3c 100644 --- a/src/controller/IDatasetChartStrategy.hpp +++ b/src/controller/IDatasetChartStrategy.hpp @@ -2,15 +2,17 @@ #include #include #include +#include +#include "DatasetDetailTab.hpp" namespace geopro::controller { // dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 struct IDatasetChartStrategy { virtual ~IDatasetChartStrategy() = default; virtual std::string ddCode() const = 0; - // 该类型是否有「网格数据」加载阶段(ERT 反演=true;纯散点/折线/图像类=false)。 - // 控制器据此决定是否允许 loadGridData,替代硬编码 ddCode 判断。 - virtual bool hasGridPhase() const = 0; + // 该类型的页签集(标题/render kind/加载键/惰性/分页)。控制器据此建页签 + 驱动加载, + // 取代硬编码的 hasGridPhase()/ddCode 判断。 + virtual std::vector tabs() const = 0; }; class ChartStrategyRegistry { diff --git a/src/core/model/Field.hpp b/src/core/model/Field.hpp index 6e1d65f..95d7ca0 100644 --- a/src/core/model/Field.hpp +++ b/src/core/model/Field.hpp @@ -49,6 +49,12 @@ private: struct ScatterField { std::vector x, y, z, v; // 对应样本 xlist/ylist/(hlist)/vlist std::vector projX, projY; // GIS 平面坐标 + // 可选元数据(measurement 散点用,反演留空)。a/b/m/n 与各点一一对应(hover 显示 + // A/B/M/N),electrodeX 为电极沿测线位置(y=0 处灰菱形 marker),electrodeNo 为对应 + // 电极编号(1-based,hover 显示 num,与 electrodeX 一一对应)。 + std::vector a, b, m, n; + std::vector electrodeX; + std::vector electrodeNo; }; } // namespace geopro::core diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp new file mode 100644 index 0000000..616c81b --- /dev/null +++ b/src/core/model/detail/DetailPayloads.hpp @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include +#include "model/Field.hpp" +#include "model/ColorScale.hpp" +#include "model/Anomaly.hpp" + +// 详情渲染载荷(纯数据,跨 QVariant 类型擦除传递)。无 Qt-widget 依赖。 +// 命名空间 geopro::core(与同目录 Field.hpp/ColorScale.hpp 一致)。 +namespace geopro::core { + +// 下拉项:字段码 + 显示名(measurement 工具条 x/y/v/method 下拉驱动;code 可空如 method)。 +struct FieldOption { + QString code; + QString name; +}; + +// measurement 散点工具条配置(来自服务端 scatterGraphConf;反演留空 → 视图渲染反演工具条)。 +// x/y/v/method 为各下拉的可选项(含 fieldCode+name);defaultX/Y/V/Method 为默认选中项的 +// fieldCode(method 的 fieldCode 全为 null,故 defaultMethod 用 name)。empty() 判定走 x 是否为空。 +struct ScatterToolbarConf { + std::vector x, y, v, method; + QString defaultX, defaultY, defaultV, defaultMethod; + bool empty() const { return x.empty(); } +}; + +// 散点载荷:反演原数据 / measurement 散点共用(≈ data::ChartParts)。 +// 两者上色一致——按数据 min/max 连续插值(Plotly cauto,含负异常值,RawDataChartView 据 v +// 有限值 min/max 设 setDataRange)。verticalLegend=true(measurement)时色阶图例画在右侧竖条 +// (离散带,1323→0 自上而下,对齐原版);false(默认,反演原数据)时画在底部横条。 +// toolbar 非空(measurement)时视图渲染 measurement 工具条;altX*/altY* 为 x/y 下拉本地重绘 +// 用的备选列(平距/斜距、伪深度/伪深度+高程),与 scatter.v/.a... 同序、一一对应,避免再发请求。 +struct ScatterPayload { + geopro::core::ScatterField scatter; + geopro::core::ColorScale scale; + bool verticalLegend = false; + ScatterToolbarConf toolbar; + std::vector altXHorizontal, altXSlope; // x 下拉:平距 / 斜距 + std::vector altYPseudo, altYElevationPseudo; // y 下拉:伪深度 / 伪深度+高程 +}; + +// 等值面载荷:grid(rows) + 色阶 + 异常(≈ data::GridParts)。 +// Grid 无默认构造,给占位初始化以满足 QVariant 对默认可构造的要求。 +struct ContourPayload { + geopro::core::Grid grid{1, 1}; + geopro::core::ColorScale scale; + std::vector anomalies; +}; + +// 列渲染种类:Text=预格式化文本(默认);Toggle=每行开关(蓝色药丸开关,ON=可见)。 +enum class TableColumnKind { Text, Toggle }; + +// 通用表格列定义。 +struct TableColumn { + QString code; + QString title; + int width = 0; + int sort = 0; + TableColumnKind kind = TableColumnKind::Text; +}; + +// 通用表格载荷:列定义 + 预格式化的行(每格 QString)+ 总数(分页用)。 +struct TablePayload { + std::vector columns; + std::vector> rows; + int total = 0; +}; + +} // namespace geopro::core + +Q_DECLARE_METATYPE(geopro::core::ScatterPayload) +Q_DECLARE_METATYPE(geopro::core::ContourPayload) +Q_DECLARE_METATYPE(geopro::core::TablePayload) diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 86f09c5..45594ec 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(geopro_data STATIC repo/LocalSampleRepository.cpp dto/NavDto.cpp dto/DatasetChartDto.cpp + dto/MeasurementDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index ad9ee39..ba7e71d 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,11 +1,15 @@ #include "api/ApiDatasetRepository.hpp" +#include #include #include #include +#include #include "ApiClient.hpp" #include "ApiBatch.hpp" #include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" +#include "dto/MeasurementDto.hpp" +#include "model/detail/DetailPayloads.hpp" namespace geopro::data { namespace { @@ -14,6 +18,19 @@ QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); } +// 解析中间态(仅本 .cpp 内部使用,故置匿名命名空间,避免对外双份表示)。 +// 原数据加载结果:scatter + 散点色阶(type1)。 +struct ChartParts { + geopro::core::ScatterField scatter; + geopro::core::ColorScale scatterScale; +}; +// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 +struct GridParts { + geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 + geopro::core::ColorScale gridScale; + std::vector anomalies; +}; + // 失败判定(原 must() 口径):业务码 != 200 或传输错误。 bool isFailure(const geopro::net::ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); @@ -23,39 +40,99 @@ QJsonObject colorBody(const std::string& dsId, int type) { return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; } +// ── 共享批次构造:唯一端点定义处,old/new 路径复用,避免双份解析逻辑。 ── +// 反演原数据:index 0 = scatter(GET),1 = 散点色阶 type1(POST)。 +net::ApiBatch* inversionScatterBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), + api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), + }; + return new net::ApiBatch(calls, &isFailure); +} + +ChartParts parseScatterParts(const QList& r) { + ChartParts p; + p.scatter = dto::parseScatterGraph(r[0].data); + p.scatterScale = dto::parseColorBar(r[1].data); + return p; +} + +// 反演网格:index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET)。 +net::ApiBatch* inversionGridBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), + api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), + api.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), + }; + return new net::ApiBatch(calls, &isFailure); +} + +GridParts parseGridParts(const QList& r) { + GridParts p; + p.grid = dto::parseInversionGrid(r[0].data); + p.gridScale = dto::parseColorBar(r[1].data); + p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); + return p; +} + +// measurement 散点:index 0 = scatter/graph(GET,query 参数),1 = 色阶 type3(POST,businessCode=R0)。 +net::ApiBatch* measurementScatterBatch(net::ApiClient& api, const std::string& dsId) { + const QString did = enc(dsId); + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/measurement/scatter/graph?dsObjectId=%1&vFieldCode=").arg(did)), + api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), + QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, + {"businessCode", "R0"}, + {"type", 3}}), + }; + return new net::ApiBatch(calls, &isFailure); +} + +// measurement 列表:单请求 measurement/rows(GET,query 参数)。 +net::ApiBatch* measurementRowsBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/measurement/rows?dsObjectId=%1").arg(enc(dsId))), + }; + return new net::ApiBatch(calls, &isFailure); +} + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} -ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { - // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) - QList calls{ - api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), - api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), - }; - auto* batch = new net::ApiBatch(calls, &isFailure); - return new ApiChartLoad(batch, [](const QList& r) { - ChartParts p; - p.scatter = dto::parseScatterGraph(r[0].data); - p.scatterScale = dto::parseColorBar(r[1].data); - return p; +DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId) { + if (loaderKey == "inversion.scatter") return makeInversionScatter(dsId); + if (loaderKey == "inversion.grid") return makeInversionGrid(dsId); + if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId); + if (loaderKey == "ert_measurement.rows") return makeMeasurementRows(dsId); + throw std::runtime_error("unknown loaderKey: " + loaderKey); +} + +DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) { + // 复用同一批次 + 解析器,再映射为 ScatterPayload(不复制 JSON 解析逻辑)。 + return new ApiDetailLoad(inversionScatterBatch(api_, dsId), [](const QList& r) { + ChartParts p = parseScatterParts(r); + return QVariant::fromValue(core::ScatterPayload{p.scatter, p.scatterScale}); }); } -GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { - // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) - QList calls{ - api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), - api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), - api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), - }; - auto* batch = new net::ApiBatch(calls, &isFailure); - return new ApiGridLoad(batch, [](const QList& r) { - GridParts p; - p.grid = dto::parseInversionGrid(r[0].data); - p.gridScale = dto::parseColorBar(r[1].data); - p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); - return p; +DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) { + return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList& r) { + GridParts p = parseGridParts(r); + return QVariant::fromValue(core::ContourPayload{p.grid, p.gridScale, p.anomalies}); + }); +} + +DetailLoad* ApiDatasetRepository::makeMeasurementScatter(const std::string& dsId) { + // index 0 = scatter/graph, 1 = colorBar(type3) → 离散上色的 ScatterPayload。 + return new ApiDetailLoad(measurementScatterBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseMeasurementScatter(r[0].data, r[1].data)); + }); +} + +DetailLoad* ApiDatasetRepository::makeMeasurementRows(const std::string& dsId) { + return new ApiDetailLoad(measurementRowsBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseMeasurementTable(r[0].data)); }); } diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index c341fca..cb76a91 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -7,9 +7,12 @@ namespace geopro::data { class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); - ChartLoad* loadChartAsync(const std::string& dsId) override; - GridLoad* loadGridAsync(const std::string& dsId) override; + DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) override; private: + DetailLoad* makeInversionScatter(const std::string& dsId); + DetailLoad* makeInversionGrid(const std::string& dsId); + DetailLoad* makeMeasurementScatter(const std::string& dsId); + DetailLoad* makeMeasurementRows(const std::string& dsId); net::ApiClient& api_; }; } // namespace geopro::data diff --git a/src/data/api/DatasetLoadHandles.cpp b/src/data/api/DatasetLoadHandles.cpp index ba23173..f5c136d 100644 --- a/src/data/api/DatasetLoadHandles.cpp +++ b/src/data/api/DatasetLoadHandles.cpp @@ -9,14 +9,14 @@ QString reasonOf(const geopro::net::ApiResponse& r) { } } // namespace -ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) - : ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { +ApiDetailLoad::ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) + : DetailLoad(parent), batch_(batch), parse_(std::move(parse)) { QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, [this](const QList& resps) { if (aborted_) return; // §5.0 - ChartParts parts; + QVariant payload; try { - parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 + payload = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); deleteLater(); @@ -26,7 +26,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* deleteLater(); return; } - emit done(parts); + emit done(payload); deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, @@ -37,42 +37,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* }); } -void ApiChartLoad::abort() { - if (aborted_) return; - aborted_ = true; - if (batch_) batch_->abort(); - deleteLater(); -} - -ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) - : GridLoad(parent), batch_(batch), parse_(std::move(parse)) { - QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, - [this](const QList& resps) { - if (aborted_) return; - GridParts parts; - try { - parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 - } catch (const std::exception& e) { - emit failed(QString::fromUtf8(e.what())); - deleteLater(); - return; - } catch (...) { - emit failed(QStringLiteral("解析失败:未知异常")); - deleteLater(); - return; - } - emit done(parts); - deleteLater(); - }); - QObject::connect(batch, &geopro::net::ApiBatch::failed, this, - [this](int, const geopro::net::ApiResponse& r) { - if (aborted_) return; - emit failed(reasonOf(r)); - deleteLater(); - }); -} - -void ApiGridLoad::abort() { +void ApiDetailLoad::abort() { if (aborted_) return; aborted_ = true; if (batch_) batch_->abort(); diff --git a/src/data/api/DatasetLoadHandles.hpp b/src/data/api/DatasetLoadHandles.hpp index cc65328..7e4d867 100644 --- a/src/data/api/DatasetLoadHandles.hpp +++ b/src/data/api/DatasetLoadHandles.hpp @@ -4,52 +4,30 @@ #include #include #include +#include #include "ApiBatch.hpp" -#include "DatasetLoads.hpp" namespace geopro::data { -// ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ── -class ChartLoad : public QObject { +// ── 通用详情句柄(tab 引擎):载荷经 QVariant 类型擦除,单一 done(QVariant)。 ── +// 可测试缝(类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它。 +class DetailLoad : public QObject { Q_OBJECT public: using QObject::QObject; - ~ChartLoad() override = default; + ~DetailLoad() override = default; virtual void abort() = 0; signals: - void done(const geopro::data::ChartParts& parts); + void done(const QVariant& payload); void failed(const QString& message); }; -class GridLoad : public QObject { +// Api 实现:包 ApiBatch + 注入解析器(返回 QVariant 载荷)。逻辑与 ApiChartLoad 等价。 +class ApiDetailLoad : public DetailLoad { Q_OBJECT public: - using QObject::QObject; - ~GridLoad() override = default; - virtual void abort() = 0; -signals: - void done(const geopro::data::GridParts& parts); - void failed(const QString& message); -}; - -// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ── -class ApiChartLoad : public ChartLoad { - Q_OBJECT -public: - using Parser = std::function&)>; - ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); - void abort() override; -private: - QPointer batch_; - Parser parse_; - bool aborted_ = false; -}; - -class ApiGridLoad : public GridLoad { - Q_OBJECT -public: - using Parser = std::function&)>; - ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + using Parser = std::function&)>; + ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); void abort() override; private: QPointer batch_; diff --git a/src/data/api/DatasetLoads.hpp b/src/data/api/DatasetLoads.hpp deleted file mode 100644 index 1e303ae..0000000 --- a/src/data/api/DatasetLoads.hpp +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include -#include "model/Field.hpp" -#include "model/ColorScale.hpp" -#include "model/Anomaly.hpp" - -namespace geopro::data { - -// 原数据加载结果:scatter + 散点色阶(type1)。 -struct ChartParts { - geopro::core::ScatterField scatter; - geopro::core::ColorScale scatterScale; -}; -// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 -struct GridParts { - geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 - geopro::core::ColorScale gridScale; - std::vector anomalies; -}; - -} // namespace geopro::data diff --git a/src/data/dto/MeasurementDto.cpp b/src/data/dto/MeasurementDto.cpp new file mode 100644 index 0000000..d206c02 --- /dev/null +++ b/src/data/dto/MeasurementDto.cpp @@ -0,0 +1,162 @@ +#include "dto/MeasurementDto.hpp" + +#include +#include +#include "dto/DatasetChartDto.hpp" // parseColorBar + +namespace geopro::data::dto { +using namespace geopro::core; + +namespace { + +// 位置数组列序(rows[i] 为 17 列定位数组)。 +constexpr int kColHorizontalDistance = 0; // x 备选:平距 +constexpr int kColSlopeDistance = 1; // x:斜距(默认) +constexpr int kColPseudoDepth = 3; // y:伪深度(负,向下,默认) +constexpr int kColElevationPseudoDepth = 9; // y 备选:伪深度+高程 +constexpr int kColA = 10; +constexpr int kColB = 11; +constexpr int kColM = 12; +constexpr int kColN = 13; +constexpr int kColValue = 16; // 色值:选中 v(默认视电阻率 R0) + +double numAt(const QJsonArray& arr, int i) { + if (i < 0 || i >= arr.size()) return 0.0; + const QJsonValue v = arr.at(i); + if (v.isDouble()) return v.toDouble(); + if (v.isString()) { bool ok = false; double d = v.toString().toDouble(&ok); return ok ? d : 0.0; } + return 0.0; +} + +// 把 JSON 值预格式化为单元格 QString(数字按原值,null/缺省→空串)。 +QString cellText(const QJsonValue& v) { + if (v.isDouble()) { + const double d = v.toDouble(); + // 整数值不带小数点(与列表观感一致);非整数保留有效位。 + if (d == static_cast(static_cast(d))) return QString::number(static_cast(d)); + return QString::number(d, 'g', 10); + } + if (v.isString()) return v.toString(); + if (v.isBool()) return v.toBool() ? QStringLiteral("true") : QStringLiteral("false"); + return QString(); // null/undefined +} + +// scatterGraphConf 的某一组下拉项 → FieldOption 列表(保留服务端顺序)。 +std::vector parseOptions(const QJsonArray& arr) { + std::vector out; + out.reserve(arr.size()); + for (const auto& e : arr) { + const QJsonObject o = e.toObject(); + out.push_back({o.value(QStringLiteral("fieldCode")).toString(), + o.value(QStringLiteral("name")).toString()}); + } + return out; +} + +} // namespace + +ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, const QJsonObject& colorBarData) { + ScatterPayload p; + // 上色与反演原数据同路径:连续插值(cauto)。色值 col16 原样保留(含负异常值), + // RawDataChartView 据 v 有限值 min/max 设 setDataRange → cmin<0,与原版 Plotly 一致。 + // 图例与反演统一为底部横条(verticalLegend 保持默认 false)。 + + const QJsonArray rows = scatterData.value(QStringLiteral("scatterGraphData")) + .toObject() + .value(QStringLiteral("rows")) + .toArray(); + auto& s = p.scatter; + for (const auto& e : rows) { + const QJsonArray row = e.toArray(); + s.x.push_back(numAt(row, kColSlopeDistance)); + s.y.push_back(numAt(row, kColPseudoDepth)); + s.v.push_back(numAt(row, kColValue)); + s.a.push_back(numAt(row, kColA)); + s.b.push_back(numAt(row, kColB)); + s.m.push_back(numAt(row, kColM)); + s.n.push_back(numAt(row, kColN)); + // x/y 下拉本地重绘备选列(与上面 push 同序、一一对应 → 视图换 x/y 无需再请求)。 + p.altXHorizontal.push_back(numAt(row, kColHorizontalDistance)); + p.altXSlope.push_back(numAt(row, kColSlopeDistance)); + p.altYPseudo.push_back(numAt(row, kColPseudoDepth)); + p.altYElevationPseudo.push_back(numAt(row, kColElevationPseudoDepth)); + } + + // 工具条下拉配置(scatterGraphConf):驱动 measurement 工具条 x/y/v/method 下拉与默认选中。 + // x/y/method 默认取首项(斜距 / 伪深度 / 线性);v 默认取“视电阻率”(R0),回退末-… 改为首项。 + const QJsonObject conf = scatterData.value(QStringLiteral("scatterGraphConf")).toObject(); + auto& tb = p.toolbar; + tb.x = parseOptions(conf.value(QStringLiteral("x")).toArray()); + tb.y = parseOptions(conf.value(QStringLiteral("y")).toArray()); + tb.v = parseOptions(conf.value(QStringLiteral("v")).toArray()); + tb.method = parseOptions(conf.value(QStringLiteral("vcalculationMethod")).toArray()); + // 默认选中:x=斜距(slopeDistance)、y=伪深度(pseudoDepth)、v=视电阻率(R0)、method=线性。 + tb.defaultX = QStringLiteral("slopeDistance"); + tb.defaultY = QStringLiteral("pseudoDepth"); + tb.defaultV = QStringLiteral("R0"); + if (!tb.method.empty()) tb.defaultMethod = tb.method.front().name; // 线性 + + // 电极沿测线位置(y=0 处灰菱形):取 slopeDistance 与 x(斜距)同量纲; + // electrodeNo 为电极编号(1-based),与 electrodeX 一一对应,供 hover 显示 num。 + for (const auto& e : scatterData.value(QStringLiteral("electrodeList")).toArray()) { + const QJsonObject eo = e.toObject(); + s.electrodeX.push_back(eo.value(QStringLiteral("slopeDistance")).toDouble()); + s.electrodeNo.push_back(eo.value(QStringLiteral("electrodeNo")).toDouble()); + } + + p.scale = parseColorBar(colorBarData); // 复用既有混合格式解析器(AlphaScale::Unit) + return p; +} + +TablePayload parseMeasurementTable(const QJsonObject& data) { + TablePayload t; + + // 列定义:优先 filedList,其次 gridHeaderDisplay(grid/trajectory 列表用同形状)。 + QJsonArray cols = data.value(QStringLiteral("filedList")).toArray(); + if (cols.isEmpty()) cols = data.value(QStringLiteral("gridHeaderDisplay")).toArray(); + for (const auto& e : cols) { + const QJsonObject c = e.toObject(); + TableColumn col; + col.code = c.value(QStringLiteral("fieldCode")).toString(); + col.title = c.value(QStringLiteral("name")).toString(); + if (col.title.isEmpty()) col.title = col.code; + t.columns.push_back(col); + } + + // 末尾追加“隐藏/显示”开关列(原版每行右侧蓝色药丸开关,全部 ON=可见)。 + // 仅 measurement 列表有此列(filedList 驱动时);grid/trajectory 走 gridHeaderDisplay 不追加。 + const bool hasToggleCol = !data.value(QStringLiteral("filedList")).toArray().isEmpty(); + if (hasToggleCol) { + TableColumn toggle; + toggle.code = QStringLiteral("displayStatus"); + toggle.title = QStringLiteral("隐藏/显示"); + toggle.kind = TableColumnKind::Toggle; + t.columns.push_back(toggle); + } + + // 行:每格按列码先查 vmap,再查行对象顶层。 + // Toggle 列:displayStatus==0 ⇒ ON/可见("1"),否则 OFF("0")。 + const QJsonArray rowList = data.value(QStringLiteral("rowList")).toArray(); + for (const auto& e : rowList) { + const QJsonObject obj = e.toObject(); + const QJsonObject vmap = obj.value(QStringLiteral("vmap")).toObject(); + std::vector cells; + cells.reserve(t.columns.size()); + for (const auto& col : t.columns) { + if (col.kind == TableColumnKind::Toggle) { + const int status = obj.value(QStringLiteral("displayStatus")).toInt(0); + cells.push_back(status == 0 ? QStringLiteral("1") : QStringLiteral("0")); + continue; + } + const QJsonValue v = vmap.contains(col.code) ? vmap.value(col.code) : obj.value(col.code); + cells.push_back(cellText(v)); + } + t.rows.push_back(std::move(cells)); + } + + // 总数(分页用):measurement 用 __rowListTotal,回退到本批行数。 + t.total = data.value(QStringLiteral("__rowListTotal")).toInt(static_cast(t.rows.size())); + return t; +} + +} // namespace geopro::data::dto diff --git a/src/data/dto/MeasurementDto.hpp b/src/data/dto/MeasurementDto.hpp new file mode 100644 index 0000000..3f9f468 --- /dev/null +++ b/src/data/dto/MeasurementDto.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include "model/detail/DetailPayloads.hpp" + +namespace geopro::data::dto { + +// dd_ert_measurement_data 散点伪剖面: +// scatterData = scatter/graph 的 data{ electrodeList[], scatterGraphData{rows[位置数组]} } +// colorBarData = colorGradation/getDetail{type:3} 的 data{ properties.colorBar } +// 位置数组列序:[1]斜距(x) [3]伪深度(y,负向下) [10]a [11]b [12]m [13]n [16]视电阻率(色值)。 +// 上色与反演原数据同路径:连续插值(cauto),色值含负异常值不过滤;色阶复用 +// DatasetChartDto::parseColorBar。图例画在右侧竖条(verticalLegend=true)。 +geopro::core::ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, + const QJsonObject& colorBarData); + +// 通用列表:data{ filedList[列定义] | gridHeaderDisplay, rowList[键控对象,含 vmap] } → TablePayload。 +// 列码命中 vmap 时从 vmap 取值,否则从行对象顶层取;值预格式化为 QString。可复用于 grid/trajectory 列表。 +geopro::core::TablePayload parseMeasurementTable(const QJsonObject& data); + +} // namespace geopro::data::dto diff --git a/src/data/repo/IAsyncDatasetRepository.hpp b/src/data/repo/IAsyncDatasetRepository.hpp index 5103b03..1bf0629 100644 --- a/src/data/repo/IAsyncDatasetRepository.hpp +++ b/src/data/repo/IAsyncDatasetRepository.hpp @@ -3,15 +3,14 @@ namespace geopro::data { -class ChartLoad; -class GridLoad; +class DetailLoad; // 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。 class IAsyncDatasetRepository { public: virtual ~IAsyncDatasetRepository() = default; - virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) - virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 + // 通用页签加载(tab 引擎):按 loaderKey 分派,载荷经 QVariant 类型擦除。 + virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) = 0; }; } // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 25f26f3..b5e9669 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,7 +38,10 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) +# 通用仓储分派离线单测(loadAsync 分派 + QVariant payload round-trip)。 +target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) # NavRequest 离线单测(QVariant payload: done/failed/abort 闸门)。 target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) @@ -107,7 +110,7 @@ target_sources(geopro_tests PRIVATE # 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。 target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) -# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 +# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index d54c7ba..0de0772 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -1,10 +1,19 @@ #include +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) +#include "panels/chart/MeasurementStrategy.hpp" using namespace geopro::controller; namespace { struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } + std::vector tabs() const override { + return { + {QStringLiteral("原数据"), ViewKind::Scatter, + QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("网格数据"), ViewKind::FilledContour, + QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false}, + }; + } }; } TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { @@ -15,10 +24,31 @@ TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { EXPECT_FALSE(reg.supports("dd_unknown")); EXPECT_EQ(reg.find("dd_unknown"), nullptr); } -TEST(ChartStrategyRegistry, ReportsHasGridPhase) { +TEST(ChartStrategyRegistry, ExposesTabSpecsFromStrategy) { ChartStrategyRegistry reg; reg.add(std::make_unique()); auto* s = reg.find("dd_inversion_data"); ASSERT_NE(s, nullptr); - EXPECT_TRUE(s->hasGridPhase()); + const auto tabs = s->tabs(); + ASSERT_EQ(tabs.size(), 2u); + EXPECT_FALSE(tabs[0].lazy); // 原数据非 lazy + EXPECT_TRUE(tabs[1].lazy); // 网格数据 lazy + EXPECT_EQ(tabs[1].kind, ViewKind::FilledContour); +} + +TEST(MeasurementStrategy, DrivesTwoTabsScatterAndTable) { + geopro::app::MeasurementStrategy s; + EXPECT_EQ(s.ddCode(), "dd_ert_measurement_data"); + const auto tabs = s.tabs(); + ASSERT_EQ(tabs.size(), 2u); + // 散点图:Scatter,非 lazy。 + EXPECT_EQ(tabs[0].title.toStdString(), std::string("散点图")); + EXPECT_EQ(tabs[0].kind, ViewKind::Scatter); + EXPECT_FALSE(tabs[0].lazy); + EXPECT_EQ(tabs[0].loaderKey.toStdString(), "ert_measurement.scatter"); + // 数据列表:Table,lazy。 + EXPECT_EQ(tabs[1].title.toStdString(), std::string("数据列表")); + EXPECT_EQ(tabs[1].kind, ViewKind::Table); + EXPECT_TRUE(tabs[1].lazy); + EXPECT_EQ(tabs[1].loaderKey.toStdString(), "ert_measurement.rows"); } diff --git a/tests/app/test_scatter_hover.cpp b/tests/app/test_scatter_hover.cpp index e640929..bff1fa9 100644 --- a/tests/app/test_scatter_hover.cpp +++ b/tests/app/test_scatter_hover.cpp @@ -1,6 +1,8 @@ #include #include "panels/chart/ScatterHoverTip.hpp" +using geopro::app::electrodeHoverText; +using geopro::app::measurementHoverText; using geopro::app::scatterHoverText; // 对齐原版 Plotly hovertemplate: @@ -15,3 +17,16 @@ TEST(ScatterHoverTip, RoundsAndPadsToFixed3) { EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0), QStringLiteral("X: -1.000
    Y: 0.500
    值: 1.000")); } + +// measurement 浮动框:X/Y/Value/a/b/m/n 多行(对齐原版 Plotly 浮动框)。 +TEST(ScatterHoverTip, MeasurementFloatingTipShowsXYValueABMN) { + const QString t = measurementHoverText(2.283, -1.2, 242.952988, 1, 4, 2, 3); + EXPECT_EQ(t, QStringLiteral("X: 2.283
    Y: -1.200
    Value: 242.953" + "
    a: 1
    b: 4
    m: 2
    n: 3")); +} + +// 电极浮动框:x(7 位有效数字)/ y: 0 / num(电极编号),对齐原版 Plotly 电极 trace。 +TEST(ScatterHoverTip, ElectrodeFloatingTipShowsXYNum) { + const QString t = electrodeHoverText(5.123837209632222, 4); + EXPECT_EQ(t, QStringLiteral("x: 5.123837
    y: 0
    num: 4")); +} diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index e996ddc..be8c8a4 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -1,21 +1,33 @@ #include #include +#include #include "DatasetDetailController.hpp" +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" using namespace geopro; namespace { -// 反演策略桩:散点 + 网格两阶段。 +// 反演策略桩:散点(非 lazy) + 网格(lazy) 两页签。 struct InversionStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } + std::vector tabs() const override { + return { + {QStringLiteral("原数据"), controller::ViewKind::Scatter, + QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("网格数据"), controller::ViewKind::FilledContour, + QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false}, + }; + } }; -// 无网格阶段策略桩:仅散点(如纯散点类型)。 -struct NoGridStrategy : controller::IDatasetChartStrategy { +// 单页签策略桩(仅一个非 lazy 页签)。 +struct SingleTabStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_scatter_only"; } - bool hasGridPhase() const override { return false; } + std::vector tabs() const override { + return {{QStringLiteral("散点"), controller::ViewKind::Scatter, + QStringLiteral("scatter.only"), /*lazy*/ false, /*paginated*/ false}}; + } }; // 注册了反演策略的注册表(多数用例复用)。 controller::ChartStrategyRegistry makeInversionRegistry() { @@ -24,39 +36,56 @@ controller::ChartStrategyRegistry makeInversionRegistry() { return reg; } -// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 -struct StubChartLoad : data::ChartLoad { +// 桩详情句柄:不声明 Q_OBJECT —— 发射继承自 data::DetailLoad 的信号、override abort。 +struct StubDetailLoad : data::DetailLoad { bool aborted = false; void abort() override { aborted = true; } - void fireDone() { emit done(data::ChartParts{}); } - void fireFailed() { emit failed(QStringLiteral("x")); } -}; -struct StubGridLoad : data::GridLoad { - bool aborted = false; - void abort() override { aborted = true; } - void fireDone() { emit done(data::GridParts{}); } + void fireDone() { emit done(QVariant{}); } void fireFailed() { emit failed(QStringLiteral("x")); } }; +// 桩仓储:每个 loaderKey 都造一个新句柄,记录最近一个用于 fire。 struct StubAsyncRepo : data::IAsyncDatasetRepository { - StubChartLoad* lastChart = nullptr; - StubGridLoad* lastGrid = nullptr; - data::ChartLoad* loadChartAsync(const std::string&) override { - lastChart = new StubChartLoad; return lastChart; - } - data::GridLoad* loadGridAsync(const std::string&) override { - lastGrid = new StubGridLoad; return lastGrid; + StubDetailLoad* last = nullptr; + data::DetailLoad* loadAsync(const std::string&, const std::string&) override { + last = new StubDetailLoad; + return last; } }; -} +} // namespace -TEST(DatasetDetailController, EmitsChartReadyOnDone) { +TEST(DatasetDetailController, OpenEmitsDatasetOpenedWithTabsAndDdCode) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + QSignalSpy spy(&c, &controller::DatasetDetailController::datasetOpened); + c.openDataset("ds1", "dd_inversion_data", "名称"); + ASSERT_EQ(spy.count(), 1); + const auto args = spy.takeFirst(); + EXPECT_EQ(args.at(0).toString(), QStringLiteral("ds1")); + EXPECT_EQ(args.at(1).toString(), QStringLiteral("dd_inversion_data")); // ddCode 透传 + EXPECT_EQ(args.at(2).toString(), QStringLiteral("名称")); +} + +TEST(DatasetDetailController, OpenLoadsNonLazyTabsOnly) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy started(&c, &controller::DatasetDetailController::tabLoadStarted); c.openDataset("ds1", "dd_inversion_data"); - repo.lastChart->fireDone(); - EXPECT_EQ(spy.count(), 1); + // 反演:tab0 非 lazy 自动加载,tab1 lazy 不加载。 + ASSERT_EQ(started.count(), 1); + EXPECT_EQ(started.takeFirst().at(1).toInt(), 0); +} + +TEST(DatasetDetailController, EmitsTabReadyOnDone) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy spy(&c, &controller::DatasetDetailController::tabReady); + c.openDataset("ds1", "dd_inversion_data"); + repo.last->fireDone(); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(1).toInt(), 0); // tabIndex 0 } TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { @@ -65,7 +94,7 @@ TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); - repo.lastChart->fireFailed(); + repo.last->fireFailed(); EXPECT_EQ(spy.count(), 1); } @@ -76,7 +105,7 @@ TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_other"); EXPECT_EQ(spy.count(), 1); - EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 + EXPECT_EQ(repo.last, nullptr); // 未发起加载 } // 空注册表 → 任意 ddCode 都不支持 → loadFailed,不发起加载。 @@ -87,76 +116,62 @@ TEST(DatasetDetailController, EmptyRegistryFailsAnyType) { QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); EXPECT_EQ(spy.count(), 1); - EXPECT_EQ(repo.lastChart, nullptr); + EXPECT_EQ(repo.last, nullptr); } -// 无网格阶段的策略 → loadGridData 不发起网格加载。 -TEST(DatasetDetailController, NoGridPhaseStrategySkipsGridLoad) { - StubAsyncRepo repo; - controller::ChartStrategyRegistry reg; - reg.add(std::make_unique()); - controller::DatasetDetailController c(repo, reg); - c.loadGridData("ds1", "dd_scatter_only"); - EXPECT_EQ(repo.lastGrid, nullptr); // hasGridPhase()==false → 未发起 -} - -TEST(DatasetDetailController, AbortsPreviousOnReopen) { +// 越界 tabIndex → loadTab 静默不加载。 +TEST(DatasetDetailController, LoadTabOutOfRangeDoesNothing) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - c.openDataset("dsA", "dd_inversion_data"); - StubChartLoad* a = repo.lastChart; - c.openDataset("dsB", "dd_inversion_data"); // 替换 - EXPECT_TRUE(a->aborted); // 旧句柄被 abort + c.loadTab("ds1", "dd_inversion_data", 99); + EXPECT_EQ(repo.last, nullptr); +} + +// lazy 页签经 loadTab 触发后加载。 +TEST(DatasetDetailController, LoadTabLazyTabStartsLoad) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy ready(&c, &controller::DatasetDetailController::tabReady); + c.loadTab("ds1", "dd_inversion_data", 1); // lazy 网格页 + ASSERT_NE(repo.last, nullptr); + repo.last->fireDone(); + ASSERT_EQ(ready.count(), 1); + EXPECT_EQ(ready.takeFirst().at(1).toInt(), 1); +} + +TEST(DatasetDetailController, AbortsPreviousOnSameSlotReload) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + c.loadTab("dsA", "dd_inversion_data", 0); + StubDetailLoad* a = repo.last; + c.loadTab("dsB", "dd_inversion_data", 0); // 同槽位替换 + EXPECT_TRUE(a->aborted); // 旧句柄被 abort } TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); - c.openDataset("dsA", "dd_inversion_data"); - StubChartLoad* a = repo.lastChart; - c.openDataset("dsB", "dd_inversion_data"); - StubChartLoad* b = repo.lastChart; + QSignalSpy spy(&c, &controller::DatasetDetailController::tabReady); + c.loadTab("dsA", "dd_inversion_data", 0); + StubDetailLoad* a = repo.last; + c.loadTab("dsB", "dd_inversion_data", 0); // 同槽位替换 + StubDetailLoad* b = repo.last; a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 EXPECT_EQ(spy.count(), 0); b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); } -TEST(DatasetDetailController, EmitsGridReadyOnDone) { +TEST(DatasetDetailController, FocusEmitsFocusRequested) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); - c.loadGridData("ds1", "dd_inversion_data"); - repo.lastGrid->fireDone(); - EXPECT_EQ(spy.count(), 1); -} - -TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { - StubAsyncRepo repo; - auto reg = makeInversionRegistry(); - controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); - c.loadGridData("ds1", "dd_inversion_data"); - repo.lastGrid->fireFailed(); - EXPECT_EQ(spy.count(), 1); -} - -TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) { - StubAsyncRepo repo; - auto reg = makeInversionRegistry(); - controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); - c.loadGridData("dsA", "dd_inversion_data"); - StubGridLoad* a = repo.lastGrid; - c.loadGridData("dsB", "dd_inversion_data"); // 替换 → 旧句柄被 abort - StubGridLoad* b = repo.lastGrid; - EXPECT_TRUE(a->aborted); - a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 - EXPECT_EQ(spy.count(), 0); - b->fireDone(); // 当前句柄 → 正常 - EXPECT_EQ(spy.count(), 1); + QSignalSpy spy(&c, &controller::DatasetDetailController::focusRequested); + c.focusDataset("ds1"); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(0).toString(), QStringLiteral("ds1")); } diff --git a/tests/data/test_async_repo_dispatch.cpp b/tests/data/test_async_repo_dispatch.cpp new file mode 100644 index 0000000..2da68e6 --- /dev/null +++ b/tests/data/test_async_repo_dispatch.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include "ApiClient.hpp" +#include "api/ApiDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" +#include "model/detail/DetailPayloads.hpp" + +using geopro::data::ApiDatasetRepository; +using geopro::data::DetailLoad; +using geopro::core::ContourPayload; +using geopro::core::ScatterPayload; + +namespace { +// ApiClient 构造 + getAsync/postJsonAsync 创建网络句柄需 QCoreApplication(QNAM 事件循环)。 +QCoreApplication* ensureApp() { + if (!QCoreApplication::instance()) { + static int argc = 0; + static char** argv = nullptr; + new QCoreApplication(argc, argv); + } + return QCoreApplication::instance(); +} +} // namespace + +// 已知 loaderKey 返回非空句柄;返回后立即 abort 取消在飞请求(不依赖网络可达)。 +TEST(AsyncRepoDispatch, KnownKeysReturnNonNullHandle) { + ensureApp(); + geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); + ApiDatasetRepository repo(api); + + DetailLoad* scatter = repo.loadAsync("inversion.scatter", "ds1"); + ASSERT_NE(scatter, nullptr); + scatter->abort(); + + DetailLoad* grid = repo.loadAsync("inversion.grid", "ds1"); + ASSERT_NE(grid, nullptr); + grid->abort(); + + DetailLoad* measScatter = repo.loadAsync("ert_measurement.scatter", "ds1"); + ASSERT_NE(measScatter, nullptr); + measScatter->abort(); + + DetailLoad* measRows = repo.loadAsync("ert_measurement.rows", "ds1"); + ASSERT_NE(measRows, nullptr); + measRows->abort(); +} + +// 未知 loaderKey 抛 std::runtime_error。 +TEST(AsyncRepoDispatch, UnknownKeyThrows) { + ensureApp(); + geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); + ApiDatasetRepository repo(api); + EXPECT_THROW(repo.loadAsync("bogus", "ds1"), std::runtime_error); +} + +// payload 经 QVariant 类型擦除往返还原(不触网)。 +TEST(AsyncRepoDispatch, ScatterPayloadRoundTrips) { + ScatterPayload in; + in.scatter.x = {1.0, 2.0, 3.0}; + in.scatter.v = {10.0, 20.0, 30.0}; + QVariant v = QVariant::fromValue(in); + ASSERT_TRUE(v.canConvert()); + ScatterPayload out = v.value(); + ASSERT_EQ(out.scatter.x.size(), 3u); + EXPECT_DOUBLE_EQ(out.scatter.v[2], 30.0); +} + +TEST(AsyncRepoDispatch, ContourPayloadRoundTrips) { + ContourPayload in; // Grid{1,1} 占位 + 空异常 + in.grid.vmin = 1.5; + in.grid.vmax = 9.5; + QVariant v = QVariant::fromValue(in); + ASSERT_TRUE(v.canConvert()); + ContourPayload out = v.value(); + EXPECT_DOUBLE_EQ(out.grid.vmin, 1.5); + EXPECT_DOUBLE_EQ(out.grid.vmax, 9.5); +} diff --git a/tests/data/test_dataset_load_handles.cpp b/tests/data/test_dataset_load_handles.cpp index d0590c7..686df30 100644 --- a/tests/data/test_dataset_load_handles.cpp +++ b/tests/data/test_dataset_load_handles.cpp @@ -1,13 +1,16 @@ #include #include #include +#include #include "api/DatasetLoadHandles.hpp" +#include "model/detail/DetailPayloads.hpp" #include "net/FakeApiCall.hpp" using namespace geopro::data; using geopro::net::ApiBatch; using geopro::net::ApiResponse; using geopro::net::test::FakeApiCall; +using geopro::core::ScatterPayload; namespace { ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } @@ -15,51 +18,54 @@ ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QSt auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; } -TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { +// ── 通用详情句柄 ApiDetailLoad(tab 引擎):done(QVariant) / failed / abort 闸门 ── + +TEST(DatasetLoadHandles, DetailLoadEmitsDoneWithPayloadOnSuccess) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); - bool parsed = false; - auto* load = new ApiChartLoad(batch, [&](const QList& resps) { - parsed = (resps.size() == 2); - return ChartParts{}; + auto* load = new ApiDetailLoad(batch, [](const QList& resps) { + ScatterPayload p; + p.scatter.v = std::vector(static_cast(resps.size()), 0.0); // 携带可校验状态 + return QVariant::fromValue(p); }); - QSignalSpy doneSpy(load, &ChartLoad::done); + QSignalSpy doneSpy(load, &DetailLoad::done); a->fire(ok()); b->fire(ok()); - EXPECT_EQ(doneSpy.count(), 1); - EXPECT_TRUE(parsed); + ASSERT_EQ(doneSpy.count(), 1); + QVariant payload = doneSpy.takeFirst().at(0).value(); + ASSERT_TRUE(payload.canConvert()); + EXPECT_EQ(payload.value().scatter.v.size(), 2u); // round-trip 还原字段 } -TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { +TEST(DatasetLoadHandles, DetailLoadEmitsFailedOnBatchFailure) { auto* a = new FakeApiCall; - auto* b = new FakeApiCall; - auto* batch = new ApiBatch({a, b}, isFailure); - auto* load = new ApiChartLoad(batch, [](const QList&) { return ChartParts{}; }); - QSignalSpy failSpy(load, &ChartLoad::failed); + auto* batch = new ApiBatch({a}, isFailure); + auto* load = new ApiDetailLoad(batch, [](const QList&) { return QVariant{}; }); + QSignalSpy failSpy(load, &DetailLoad::failed); a->fire(bad()); EXPECT_EQ(failSpy.count(), 1); } -TEST(DatasetLoadHandles, ChartLoadEmitsFailedWhenParseThrows) { +TEST(DatasetLoadHandles, DetailLoadEmitsFailedWhenParseThrows) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); - auto* load = new ApiChartLoad(batch, [](const QList&) -> ChartParts { + auto* load = new ApiDetailLoad(batch, [](const QList&) -> QVariant { throw std::runtime_error("parse boom"); }); - QSignalSpy doneSpy(load, &ChartLoad::done); - QSignalSpy failSpy(load, &ChartLoad::failed); - a->fire(ok()); // batch 成功 → parse 抛异常 → failed(emit done 已移出 try) + QSignalSpy doneSpy(load, &DetailLoad::done); + QSignalSpy failSpy(load, &DetailLoad::failed); + a->fire(ok()); // batch 成功 → parse 抛 → failed(done 已移出 try) EXPECT_EQ(doneSpy.count(), 0); EXPECT_EQ(failSpy.count(), 1); } -TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { +TEST(DatasetLoadHandles, DetailLoadAbortSuppressesLateDone) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); - auto* load = new ApiGridLoad(batch, [](const QList&) { return GridParts{}; }); - QSignalSpy doneSpy(load, &GridLoad::done); + auto* load = new ApiDetailLoad(batch, [](const QList&) { return QVariant{}; }); + QSignalSpy doneSpy(load, &DetailLoad::done); load->abort(); a->fire(ok()); // 迟到 - EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 + EXPECT_EQ(doneSpy.count(), 0); // 双闸门 } diff --git a/tests/data/test_measurement_dto.cpp b/tests/data/test_measurement_dto.cpp new file mode 100644 index 0000000..25f3b0d --- /dev/null +++ b/tests/data/test_measurement_dto.cpp @@ -0,0 +1,254 @@ +#include +#include +#include +#include +#include "dto/MeasurementDto.hpp" +using namespace geopro::data::dto; + +static QJsonObject obj(const char* json) { + return QJsonDocument::fromJson(json).object(); +} + +// scatter/graph 的 data:electrodeList + scatterGraphData.rows(17 列定位数组,取自真实夹具前 3 行)。 +static const char* kScatterData = R"({ + "electrodeList": [ + {"electrodeNo":1,"horizontalDistance":0,"slopeDistance":0,"pseudoDepth":0}, + {"electrodeNo":2,"horizontalDistance":1.08,"slopeDistance":1.2459494894830485,"pseudoDepth":0}, + {"electrodeNo":3,"horizontalDistance":3.15,"slopeDistance":3.3194843662393243,"pseudoDepth":0} + ], + "scatterGraphData": { + "hasCoordinate": true, + "rows": [ + [2.115013167852418,2.2827169278611863,null,-1.2,242.952988,2.385,4.116,0,"1453611521843200",24.288,1,4,2,3,12.5664,1,242.952988], + [5.124875148343908,5.300065674086864,null,-2.4,-1066.592407,1.873,13.377,0,"1453611521843201",23.12,1,7,3,5,25.1327,2,-1066.592407], + [7.994418521491841,8.175900836015376,null,-3.6,100.239388,2.001,9.976,0,"1453611521843202",21.987,1,10,4,7,37.6991,3,100.239388] + ], + "__rowsTotal": 576 + } +})"; + +// colorGradation/getDetail{type:3} 的 data:与反演同构混合格式 colorBar(hex + rgba alpha 0–1)。 +static const char* kColorBarData = R"json({ + "properties": { + "lvlSchemeType": "normal", + "colorBar": [ + ["0.00", "#00008B"], + ["1.51", "rgba(0, 0, 170, 1)"], + ["208.40", "#FF0000"], + ["1323.20", "rgba(48, 0, 48, 1)"] + ] + } +})json"; + +// measurement/rows 的 data:filedList(列定义)+ rowList(含 vmap 摊平),取自真实夹具前 2 行。 +static const char* kRowsData = R"({ + "filedList": [ + {"fieldCode":"a","name":"A"}, + {"fieldCode":"b","name":"B"}, + {"fieldCode":"k","name":"K"}, + {"fieldCode":"R","name":"电阻"}, + {"fieldCode":"R0","name":"视电阻率"} + ], + "rowList": [ + {"id":"1453611521843200","rowNo":1,"displayStatus":0,"a":1,"b":4,"k":12.5664, + "vmap":{"R":19.333542,"V":10223.44531,"I":528.793213,"R0_RD":0,"SP":127.640175,"R0":242.952988}}, + {"id":"1453611521843201","rowNo":2,"displayStatus":0,"a":1,"b":7,"k":25.1327, + "vmap":{"R":2.974368,"V":1822.526123,"I":612.743958,"R0_RD":0,"SP":92.380089,"R0":74.753899}} + ], + "__rowListTotal": 576 +})"; + +TEST(MeasurementDto, ParsesScatterPositionalRows) { + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + const auto& s = p.scatter; + + // 3 个散点;连续上色(cauto,与反演原数据同路径,已无 discreteColor 字段) + // + 图例与反演统一为底部横条。 + EXPECT_FALSE(p.verticalLegend); // measurement 图例改用底部横条(与反演一致) + ASSERT_EQ(s.x.size(), 3u); + ASSERT_EQ(s.y.size(), 3u); + ASSERT_EQ(s.v.size(), 3u); + + // x = col1 斜距;y = col3 伪深度(负);色值 = col16。 + EXPECT_DOUBLE_EQ(s.x[0], 2.2827169278611863); + EXPECT_DOUBLE_EQ(s.y[0], -1.2); + EXPECT_LT(s.y[0], 0.0); // 伪深度向下为负 + EXPECT_DOUBLE_EQ(s.v[0], 242.952988); + EXPECT_DOUBLE_EQ(s.x[2], 8.175900836015376); + EXPECT_DOUBLE_EQ(s.y[2], -3.6); + + // cauto 关键事实:负异常值(col16)原样保留——视图 setDataRange 据此得 cmin<0, + // 使中段视电阻率(≈25–250)归一化到色阶中部(深品红/紫),与原版 Plotly 一致。 + EXPECT_DOUBLE_EQ(s.v[1], -1066.592407); + EXPECT_LT(s.v[1], 0.0); // 负异常值未被过滤 + const double vMin = std::min({s.v[0], s.v[1], s.v[2]}); + EXPECT_DOUBLE_EQ(vMin, -1066.592407); // 数据范围下界为负 → cmin<0 + + // A/B/M/N = col10-13。 + ASSERT_EQ(s.a.size(), 3u); + EXPECT_DOUBLE_EQ(s.a[0], 1.0); + EXPECT_DOUBLE_EQ(s.b[0], 4.0); + EXPECT_DOUBLE_EQ(s.m[0], 2.0); + EXPECT_DOUBLE_EQ(s.n[0], 3.0); + + // 电极 X = electrodeList.slopeDistance。 + ASSERT_EQ(s.electrodeX.size(), 3u); + EXPECT_DOUBLE_EQ(s.electrodeX[0], 0.0); + EXPECT_DOUBLE_EQ(s.electrodeX[1], 1.2459494894830485); + + // 电极编号 = electrodeList.electrodeNo(1-based),与 electrodeX 一一对应(供 hover num)。 + ASSERT_EQ(s.electrodeNo.size(), 3u); + EXPECT_DOUBLE_EQ(s.electrodeNo[0], 1.0); + EXPECT_DOUBLE_EQ(s.electrodeNo[1], 2.0); + EXPECT_DOUBLE_EQ(s.electrodeNo[2], 3.0); +} + +// scatter/graph 含 scatterGraphConf 时:工具条下拉项 + 默认选中 + x/y 本地重绘备选列。 +static const char* kScatterDataWithConf = R"({ + "electrodeList": [ + {"electrodeNo":1,"horizontalDistance":0,"slopeDistance":0,"pseudoDepth":0} + ], + "scatterGraphConf": { + "x": [ + {"fieldCode":"horizontalDistance","name":"平距"}, + {"fieldCode":"slopeDistance","name":"斜距"} + ], + "y": [ + {"fieldCode":"Layer No","name":"层数"}, + {"fieldCode":"pseudoDepth","name":"伪深度"}, + {"fieldCode":"elevationPseudoDepth","name":"伪深度+高程"} + ], + "v": [ + {"fieldCode":"I","name":"电流"}, + {"fieldCode":"R0","name":"视电阻率"}, + {"fieldCode":"SP","name":"自然电位"} + ], + "vcalculationMethod": [ + {"fieldCode":null,"name":"线性"}, + {"fieldCode":null,"name":"对数"}, + {"fieldCode":null,"name":"导数"} + ] + }, + "scatterGraphData": { + "rows": [ + [2.115013167852418,2.2827169278611863,null,-1.2,242.952988,2.385,4.116,0,"id0",24.288,1,4,2,3,12.5664,1,242.952988] + ] + } +})"; + +TEST(MeasurementDto, ParsesScatterToolbarConfAndDefaults) { + auto p = parseMeasurementScatter(obj(kScatterDataWithConf), obj(kColorBarData)); + const auto& tb = p.toolbar; + + EXPECT_FALSE(tb.empty()); + ASSERT_EQ(tb.x.size(), 2u); + ASSERT_EQ(tb.y.size(), 3u); + ASSERT_EQ(tb.v.size(), 3u); + ASSERT_EQ(tb.method.size(), 3u); + + // 选项名/码(顺序保留)。 + EXPECT_EQ(tb.x[0].code.toStdString(), "horizontalDistance"); + EXPECT_EQ(tb.x[0].name.toStdString(), std::string("平距")); + EXPECT_EQ(tb.x[1].code.toStdString(), "slopeDistance"); + EXPECT_EQ(tb.method[0].name.toStdString(), std::string("线性")); + EXPECT_TRUE(tb.method[0].code.isEmpty()); // method 的 fieldCode 为 null + + // 默认选中:斜距 / 伪深度 / 视电阻率 / 线性。 + EXPECT_EQ(tb.defaultX.toStdString(), "slopeDistance"); + EXPECT_EQ(tb.defaultY.toStdString(), "pseudoDepth"); + EXPECT_EQ(tb.defaultV.toStdString(), "R0"); + EXPECT_EQ(tb.defaultMethod.toStdString(), std::string("线性")); +} + +TEST(MeasurementDto, CarriesAltColumnsForLocalReplot) { + auto p = parseMeasurementScatter(obj(kScatterDataWithConf), obj(kColorBarData)); + + // x 备选:平距(col0) / 斜距(col1);y 备选:伪深度(col3) / 伪深度+高程(col9)。 + ASSERT_EQ(p.altXHorizontal.size(), 1u); + ASSERT_EQ(p.altXSlope.size(), 1u); + ASSERT_EQ(p.altYPseudo.size(), 1u); + ASSERT_EQ(p.altYElevationPseudo.size(), 1u); + EXPECT_DOUBLE_EQ(p.altXHorizontal[0], 2.115013167852418); + EXPECT_DOUBLE_EQ(p.altXSlope[0], 2.2827169278611863); + EXPECT_DOUBLE_EQ(p.altYPseudo[0], -1.2); + EXPECT_DOUBLE_EQ(p.altYElevationPseudo[0], 24.288); +} + +TEST(MeasurementDto, ToolbarEmptyWhenNoConf) { + // 无 scatterGraphConf(反演原数据形状):工具条空 → 视图渲染反演工具条。 + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + EXPECT_TRUE(p.toolbar.empty()); +} + +TEST(MeasurementDto, ParsesScatterColorBarOpaque) { + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + auto stops = p.scale.stops(); + ASSERT_EQ(stops.size(), 4u); + // 首/末值。 + EXPECT_DOUBLE_EQ(stops.front().first, 0.0); + EXPECT_DOUBLE_EQ(stops.back().first, 1323.20); + // 首色 #00008B(深蓝),末色 rgba(48,0,48,1)(alpha=1 → 255,不透明)。 + EXPECT_EQ(stops.front().second.r, 0); + EXPECT_EQ(stops.front().second.g, 0); + EXPECT_EQ(stops.front().second.b, 0x8B); + EXPECT_EQ(stops.front().second.a, 255); + EXPECT_EQ(stops.back().second.r, 48); + EXPECT_EQ(stops.back().second.g, 0); + EXPECT_EQ(stops.back().second.b, 48); + EXPECT_EQ(stops.back().second.a, 255); // 回归:alpha=1 须映射为 255 不透明 +} + +TEST(MeasurementDto, ParsesTableColumnsAndVmapFlattened) { + auto t = parseMeasurementTable(obj(kRowsData)); + + // 列来自 filedList(5 列)+ 末尾追加“隐藏/显示”开关列 = 6 列。 + ASSERT_EQ(t.columns.size(), 6u); + EXPECT_EQ(t.columns[0].code.toStdString(), "a"); + EXPECT_EQ(t.columns[0].title.toStdString(), "A"); + EXPECT_EQ(t.columns[4].code.toStdString(), "R0"); + EXPECT_EQ(t.columns[4].title.toStdString(), std::string("视电阻率")); + + // 2 行;total 来自 __rowListTotal。 + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.total, 576); + + // 顶层列(a/b/k)从行对象取;vmap 列(R/R0)从 vmap 摊平。 + ASSERT_EQ(t.rows[0].size(), 6u); + EXPECT_EQ(t.rows[0][0].toStdString(), "1"); // a(整数无小数点) + EXPECT_EQ(t.rows[0][1].toStdString(), "4"); // b + EXPECT_EQ(t.rows[0][3].toStdString(), "19.333542"); // R from vmap + EXPECT_EQ(t.rows[0][4].toStdString(), "242.952988"); // R0 from vmap + EXPECT_EQ(t.rows[1][4].toStdString(), "74.753899"); +} + +TEST(MeasurementDto, AppendsHideShowToggleColumn) { + auto t = parseMeasurementTable(obj(kRowsData)); + + // 末列为“隐藏/显示”,kind=Toggle,code=displayStatus。 + ASSERT_EQ(t.columns.size(), 6u); + const auto& toggle = t.columns.back(); + EXPECT_EQ(toggle.title.toStdString(), std::string("隐藏/显示")); + EXPECT_EQ(toggle.code.toStdString(), "displayStatus"); + EXPECT_EQ(toggle.kind, geopro::core::TableColumnKind::Toggle); + + // displayStatus==0 ⇒ ON/可见("1"),全部 ON。 + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.rows[0].back().toStdString(), "1"); + EXPECT_EQ(t.rows[1].back().toStdString(), "1"); +} + +TEST(MeasurementDto, ToggleOffWhenDisplayStatusNonZero) { + // displayStatus!=0 ⇒ OFF("0")。 + auto t = parseMeasurementTable(obj(R"({ + "filedList": [{"fieldCode":"a","name":"A"}], + "rowList": [ + {"id":"x","a":1,"displayStatus":0}, + {"id":"y","a":2,"displayStatus":1} + ] + })")); + ASSERT_EQ(t.columns.size(), 2u); + EXPECT_EQ(t.columns.back().kind, geopro::core::TableColumnKind::Toggle); + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.rows[0].back().toStdString(), "1"); // displayStatus 0 → ON + EXPECT_EQ(t.rows[1].back().toStdString(), "0"); // displayStatus 1 → OFF +} diff --git a/tests/fixtures/dd/ert-measurement-colorbar.json b/tests/fixtures/dd/ert-measurement-colorbar.json new file mode 100644 index 0000000..7b1b490 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-colorbar.json @@ -0,0 +1,39 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "id": null, + "projectId": "1438889436225536", + "templateId": "1439864962138112", + "dsObjectId": null, + "tmObjectId": null, + "graphicArea": 0, + "properties": { + "lvlSchemeType": "normal", + "colorBar": [ + ["0.00", "#00008B"], + ["1.51", "rgba(0, 0, 170, 1)"], + ["2.27", "rgba(0, 0, 211, 1)"], + ["3.43", "#0000FF"], + ["5.17", "rgba(0, 128, 255, 1)"], + ["7.79", "#00FFFF"], + ["11.75", "rgba(0, 192, 128, 1)"], + ["17.72", "#00FF00"], + ["26.73", "rgba(0, 128, 0, 1)"], + ["40.30", "rgba(128, 192, 0, 1)"], + ["60.77", "#FFFF00"], + ["91.64", "rgba(191, 128, 0, 1)"], + ["138.20", "rgba(255, 128, 0, 1)"], + ["208.40", "#FF0000"], + ["314.20", "rgba(211, 0, 0, 1)"], + ["473.80", "rgba(132, 0, 64, 1)"], + ["714.50", "rgba(96, 0, 96, 1)"], + ["1323.20", "rgba(48, 0, 48, 1)"] + ], + "equalAreaLayerCount": 10, + "labelConfig": { "showLabels": true, "color": "#000000" }, + "logLinesCount": 8, + "lineConfig": { "showLines": true, "color": "#000000", "lineType": "dashed" } + } + } +} diff --git a/tests/fixtures/dd/ert-measurement-rows.json b/tests/fixtures/dd/ert-measurement-rows.json new file mode 100644 index 0000000..98eb0d9 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-rows.json @@ -0,0 +1,575 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "filedList": [ + { + "fieldCode": "a", + "name": "A", + "sheetHeaderNameList": [ + "A" + ] + }, + { + "fieldCode": "b", + "name": "B", + "sheetHeaderNameList": [ + "B" + ] + }, + { + "fieldCode": "m", + "name": "M", + "sheetHeaderNameList": [ + "M" + ] + }, + { + "fieldCode": "n", + "name": "N", + "sheetHeaderNameList": [ + "N" + ] + }, + { + "fieldCode": "k", + "name": "K", + "sheetHeaderNameList": [ + "K" + ] + }, + { + "fieldCode": "stacking", + "name": "Stacking", + "sheetHeaderNameList": [ + "Stacking" + ] + }, + { + "fieldCode": "I", + "name": "电流", + "sheetHeaderNameList": [ + "I(mA)" + ] + }, + { + "fieldCode": "V", + "name": "电压", + "sheetHeaderNameList": [ + "V(mV)" + ] + }, + { + "fieldCode": "R", + "name": "电阻", + "sheetHeaderNameList": [ + "R(Ohm)" + ] + }, + { + "fieldCode": "R0", + "name": "视电阻率", + "sheetHeaderNameList": [ + "R0" + ] + }, + { + "fieldCode": "SP", + "name": "自然电位", + "sheetHeaderNameList": [ + "SP" + ] + }, + { + "fieldCode": "R0_RD", + "name": "电阻误差", + "sheetHeaderNameList": [ + "R0_RD" + ] + } + ], + "rowList": [ + { + "id": "1453611521843200", + "rowNo": 1, + "displayStatus": 0, + "horizontalDistance": 2.115013167852418, + "slopeDistance": 2.2827169278611863, + "layerNo": null, + "pseudoDepth": -1.2, + "elevationPseudoDepth": 24.288, + "n": 3, + "a": 1, + "b": 4, + "m": 2, + "k": 12.5664, + "vmap": { + "R": 19.333542, + "V": 10223.44531, + "I": 528.793213, + "R0_RD": 0, + "SP": 127.640175, + "R0": 242.952988 + }, + "stacking": 1 + }, + { + "id": "1453611521843201", + "rowNo": 2, + "displayStatus": 0, + "horizontalDistance": 5.124875148343908, + "slopeDistance": 5.300065674086864, + "layerNo": null, + "pseudoDepth": -2.4, + "elevationPseudoDepth": 23.12, + "n": 5, + "a": 1, + "b": 7, + "m": 3, + "k": 25.1327, + "vmap": { + "R": 2.974368, + "V": 1822.526123, + "I": 612.743958, + "R0_RD": 0, + "SP": 92.380089, + "R0": 74.753899 + }, + "stacking": 1 + }, + { + "id": "1453611521843202", + "rowNo": 3, + "displayStatus": 0, + "horizontalDistance": 7.994418521491841, + "slopeDistance": 8.175900836015376, + "layerNo": null, + "pseudoDepth": -3.6, + "elevationPseudoDepth": 21.987, + "n": 7, + "a": 1, + "b": 10, + "m": 4, + "k": 37.6991, + "vmap": { + "R": 2.658933, + "V": 886.042603, + "I": 333.232361, + "R0_RD": 0, + "SP": -186.630417, + "R0": 100.239388 + }, + "stacking": 1 + }, + { + "id": "1453611521843203", + "rowNo": 4, + "displayStatus": 0, + "horizontalDistance": 8.00601333013422, + "slopeDistance": 8.186794593291953, + "layerNo": null, + "pseudoDepth": -3.6, + "elevationPseudoDepth": 22.020999999999997, + "n": 6, + "a": 1, + "b": 10, + "m": 5, + "k": 125.663696, + "vmap": { + "R": 0.561675, + "V": 187.168427, + "I": 333.232361, + "R0_RD": 0, + "SP": -129.25145, + "R0": 70.582214 + }, + "stacking": 1 + }, + { + "id": "1453611521843204", + "rowNo": 5, + "displayStatus": 0, + "horizontalDistance": 11.066663821597649, + "slopeDistance": 11.272344909478555, + "layerNo": null, + "pseudoDepth": -4.8, + "elevationPseudoDepth": 20.9685, + "n": 9, + "a": 1, + "b": 13, + "m": 5, + "k": 50.265499, + "vmap": { + "R": 1.496101, + "V": 528.129211, + "I": 353.003784, + "R0_RD": 0, + "SP": -22.242311, + "R0": 75.202255 + }, + "stacking": 1 + }, + { + "id": "1453611521843205", + "rowNo": 6, + "displayStatus": 0, + "horizontalDistance": 11.00295159513125, + "slopeDistance": 11.184890199081117, + "layerNo": null, + "pseudoDepth": -4.8, + "elevationPseudoDepth": 20.758499999999998, + "n": 8, + "a": 1, + "b": 13, + "m": 6, + "k": 109.955704, + "vmap": { + "R": 0.941678, + "V": 332.415863, + "I": 353.003784, + "R0_RD": 0, + "SP": 288.221405, + "R0": 103.542862 + }, + "stacking": 1 + }, + { + "id": "1453611521859584", + "rowNo": 7, + "displayStatus": 0, + "horizontalDistance": 13.92101964767117, + "slopeDistance": 14.166540305149894, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.490499999999997, + "n": 11, + "a": 1, + "b": 16, + "m": 6, + "k": 62.831902, + "vmap": { + "R": 1.357018, + "V": 693.766174, + "I": 511.243073, + "R0_RD": 0, + "SP": 289.488678, + "R0": 85.264038 + }, + "stacking": 1 + }, + { + "id": "1453611521859585", + "rowNo": 8, + "displayStatus": 0, + "horizontalDistance": 14.000862630669717, + "slopeDistance": 14.247085672002468, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.456, + "n": 10, + "a": 1, + "b": 16, + "m": 7, + "k": 113.097298, + "vmap": { + "R": 0.557539, + "V": 285.037994, + "I": 511.243073, + "R0_RD": 0, + "SP": -35.634811, + "R0": 63.056168 + }, + "stacking": 1 + }, + { + "id": "1453611521859586", + "rowNo": 9, + "displayStatus": 0, + "horizontalDistance": 14.063602086594681, + "slopeDistance": 14.27044051526772, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.706, + "n": 9, + "a": 1, + "b": 16, + "m": 8, + "k": 351.858398, + "vmap": { + "R": 0.125825, + "V": 64.3274, + "I": 511.243073, + "R0_RD": 0, + "SP": -187.302017, + "R0": 44.272751 + }, + "stacking": 1 + }, + { + "id": "1453611521859587", + "rowNo": 10, + "displayStatus": 0, + "horizontalDistance": 16.987234838304097, + "slopeDistance": 17.233999253766825, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.296499999999998, + "n": 13, + "a": 1, + "b": 19, + "m": 7, + "k": 75.398201, + "vmap": { + "R": 0.889478, + "V": 318.904541, + "I": 358.53006, + "R0_RD": 0, + "SP": -63.969704, + "R0": 67.065033 + }, + "stacking": 1 + }, + { + "id": "1453611521859588", + "rowNo": 11, + "displayStatus": 0, + "horizontalDistance": 17.03278556632032, + "slopeDistance": 17.279602362635487, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.24, + "n": 12, + "a": 1, + "b": 19, + "m": 8, + "k": 120.951302, + "vmap": { + "R": 0.585498, + "V": 209.918564, + "I": 358.53006, + "R0_RD": 0, + "SP": -268.122803, + "R0": 70.816719 + }, + "stacking": 1 + }, + { + "id": "1453611521859589", + "rowNo": 12, + "displayStatus": 0, + "horizontalDistance": 16.9816701391346, + "slopeDistance": 17.2520906213365, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.438, + "n": 11, + "a": 1, + "b": 19, + "m": 9, + "k": 251.327393, + "vmap": { + "R": 0.166266, + "V": 59.611279, + "I": 358.53006, + "R0_RD": 0, + "SP": 187.193558, + "R0": 41.787144 + }, + "stacking": 1 + }, + { + "id": "1453611521859590", + "rowNo": 13, + "displayStatus": 0, + "horizontalDistance": 20.01061875986487, + "slopeDistance": 20.259070486101244, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.037, + "n": 15, + "a": 1, + "b": 22, + "m": 8, + "k": 87.9646, + "vmap": { + "R": 0.778783, + "V": 222.046936, + "I": 285.120575, + "R0_RD": 0, + "SP": -188.105652, + "R0": 68.505302 + }, + "stacking": 1 + }, + { + "id": "1453611521859591", + "rowNo": 14, + "displayStatus": 0, + "horizontalDistance": 19.924955341057455, + "slopeDistance": 20.196078557097973, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.301000000000002, + "n": 14, + "a": 1, + "b": 22, + "m": 9, + "k": 130.690308, + "vmap": { + "R": 0.586209, + "V": 167.140335, + "I": 285.120575, + "R0_RD": 0, + "SP": -30.588928, + "R0": 76.61187 + }, + "stacking": 1 + }, + { + "id": "1453611521859592", + "rowNo": 15, + "displayStatus": 0, + "horizontalDistance": 19.94235941950773, + "slopeDistance": 20.25312046337076, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.003500000000003, + "n": 13, + "a": 1, + "b": 22, + "m": 10, + "k": 226.194595, + "vmap": { + "R": 0.258264, + "V": 73.636406, + "I": 285.120575, + "R0_RD": 0, + "SP": -38.283382, + "R0": 58.417942 + }, + "stacking": 1 + }, + { + "id": "1453611521859593", + "rowNo": 16, + "displayStatus": 0, + "horizontalDistance": 19.95085361886024, + "slopeDistance": 20.261252468704264, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 16.972, + "n": 12, + "a": 1, + "b": 22, + "m": 11, + "k": 691.150391, + "vmap": { + "R": 0.219839, + "V": 62.776394, + "I": 285.55603, + "R0_RD": 0, + "SP": -264.453094, + "R0": 151.941925 + }, + "stacking": 1 + }, + { + "id": "1453611521859594", + "rowNo": 17, + "displayStatus": 0, + "horizontalDistance": 22.95679663366693, + "slopeDistance": 23.231100221926333, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 16.131, + "n": 17, + "a": 1, + "b": 25, + "m": 9, + "k": 100.530998, + "vmap": { + "R": 0.865843, + "V": 235.531586, + "I": 272.025665, + "R0_RD": 0, + "SP": 252.63089, + "R0": 87.04409 + }, + "stacking": 1 + }, + { + "id": "1453611521859595", + "rowNo": 18, + "displayStatus": 0, + "horizontalDistance": 22.822828222784292, + "slopeDistance": 23.136758664349642, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.8315, + "n": 16, + "a": 1, + "b": 25, + "m": 10, + "k": 141.371704, + "vmap": { + "R": 0.636254, + "V": 173.077347, + "I": 272.025665, + "R0_RD": 0, + "SP": 17.840714, + "R0": 89.94828 + }, + "stacking": 1 + }, + { + "id": "1453611521867776", + "rowNo": 19, + "displayStatus": 0, + "horizontalDistance": 22.928686812404784, + "slopeDistance": 23.24072059217002, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.769, + "n": 15, + "a": 1, + "b": 25, + "m": 11, + "k": 219.911407, + "vmap": { + "R": 0.112888, + "V": 30.708496, + "I": 272.025665, + "R0_RD": 0, + "SP": -189.315582, + "R0": 24.825411 + }, + "stacking": 1 + }, + { + "id": "1453611521867777", + "rowNo": 20, + "displayStatus": 0, + "horizontalDistance": 22.8941388207831, + "slopeDistance": 23.205240404465737, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.835000000000003, + "n": 14, + "a": 1, + "b": 25, + "m": 12, + "k": 449.247803, + "vmap": { + "R": 0.086995, + "V": 23.698902, + "I": 272.416779, + "R0_RD": 0, + "SP": 45.706585, + "R0": 39.082321 + }, + "stacking": 1 + } + ], + "__rowListTotal": 576 + } +} \ No newline at end of file diff --git a/tests/fixtures/dd/ert-measurement-scatter.json b/tests/fixtures/dd/ert-measurement-scatter.json new file mode 100644 index 0000000..91fedd6 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-scatter.json @@ -0,0 +1,830 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "electrodeList": [ + { + "electrodeNo": 1, + "horizontalDistance": 0, + "slopeDistance": 0, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.172 + }, + { + "electrodeNo": 2, + "horizontalDistance": 1.0801616223246715, + "slopeDistance": 1.2459494894830485, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.551 + }, + { + "electrodeNo": 3, + "horizontalDistance": 3.1498647133801647, + "slopeDistance": 3.3194843662393243, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.425 + }, + { + "electrodeNo": 4, + "horizontalDistance": 4.943098993517602, + "slopeDistance": 5.123837209632222, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.625 + }, + { + "electrodeNo": 5, + "horizontalDistance": 7.0998855833076515, + "slopeDistance": 7.280646981934403, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.615 + }, + { + "electrodeNo": 6, + "horizontalDistance": 8.912141076960786, + "slopeDistance": 9.092942204649502, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.627 + }, + { + "electrodeNo": 7, + "horizontalDistance": 11.045738049466081, + "slopeDistance": 11.227964462398532, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.549 + }, + { + "electrodeNo": 8, + "horizontalDistance": 13.093762113301716, + "slopeDistance": 13.276838193512734, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.49 + }, + { + "electrodeNo": 9, + "horizontalDistance": 15.033442059887646, + "slopeDistance": 15.264042837022707, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.922 + }, + { + "electrodeNo": 10, + "horizontalDistance": 16.955987211873353, + "slopeDistance": 17.266206881606404, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.363 + }, + { + "electrodeNo": 11, + "horizontalDistance": 18.929898218381553, + "slopeDistance": 19.24013840565029, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.354 + }, + { + "electrodeNo": 12, + "horizontalDistance": 20.971809019338927, + "slopeDistance": 21.282366531758235, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.39 + }, + { + "electrodeNo": 13, + "horizontalDistance": 22.92873162714211, + "slopeDistance": 23.240034045135115, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.444 + }, + { + "electrodeNo": 14, + "horizontalDistance": 24.816468622227266, + "slopeDistance": 25.12811427717324, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.48 + }, + { + "electrodeNo": 15, + "horizontalDistance": 26.92747540642802, + "slopeDistance": 27.241302778689754, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.384 + }, + { + "electrodeNo": 16, + "horizontalDistance": 28.68966923369523, + "slopeDistance": 29.007310447092884, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.5 + }, + { + "electrodeNo": 17, + "horizontalDistance": 30.880151207446215, + "slopeDistance": 31.19815760682996, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.54 + }, + { + "electrodeNo": 18, + "horizontalDistance": 32.7659271758147, + "slopeDistance": 33.084258347213705, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.505 + }, + { + "electrodeNo": 19, + "horizontalDistance": 34.71171810948364, + "slopeDistance": 35.03134421012479, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.576 + }, + { + "electrodeNo": 20, + "horizontalDistance": 36.644544469129066, + "slopeDistance": 36.96419152342716, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.567 + }, + { + "electrodeNo": 21, + "horizontalDistance": 38.31639860471849, + "slopeDistance": 38.640204724654254, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.685 + }, + { + "electrodeNo": 22, + "horizontalDistance": 40.713354415497676, + "slopeDistance": 41.050580869331284, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.431 + }, + { + "electrodeNo": 23, + "horizontalDistance": 42.28359724152586, + "slopeDistance": 42.626616253671685, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.296 + }, + { + "electrodeNo": 24, + "horizontalDistance": 44.00336585439301, + "slopeDistance": 44.351366984010134, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.427 + }, + { + "electrodeNo": 25, + "horizontalDistance": 45.95916673864363, + "slopeDistance": 46.30721797496919, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.413 + }, + { + "electrodeNo": 26, + "horizontalDistance": 48.03509295173176, + "slopeDistance": 48.385051135303875, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.502 + }, + { + "electrodeNo": 27, + "horizontalDistance": 50.00439250362521, + "slopeDistance": 50.35438140854088, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.513 + }, + { + "electrodeNo": 28, + "horizontalDistance": 52.15587463692657, + "slopeDistance": 52.50823292847325, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.614 + }, + { + "electrodeNo": 29, + "horizontalDistance": 54.31927360220433, + "slopeDistance": 54.671698685762905, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.597 + }, + { + "electrodeNo": 30, + "horizontalDistance": 56.070653463392375, + "slopeDistance": 56.42330235616756, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.625 + }, + { + "electrodeNo": 31, + "horizontalDistance": 58.3971778197452, + "slopeDistance": 58.820309778079924, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.202 + }, + { + "electrodeNo": 32, + "horizontalDistance": 60.267675140394914, + "slopeDistance": 60.69681190346048, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.052 + }, + { + "electrodeNo": 33, + "horizontalDistance": 62.3517973628095, + "slopeDistance": 62.78119537057067, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.085 + }, + { + "electrodeNo": 34, + "horizontalDistance": 64.26099256115273, + "slopeDistance": 64.69219388061114, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.002 + }, + { + "electrodeNo": 35, + "horizontalDistance": 66.23694132533387, + "slopeDistance": 66.66845259815523, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.967 + }, + { + "electrodeNo": 36, + "horizontalDistance": 68.50618870757245, + "slopeDistance": 68.9401279010532, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.072 + }, + { + "electrodeNo": 37, + "horizontalDistance": 70.12425130226687, + "slopeDistance": 70.55912498577858, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.127 + }, + { + "electrodeNo": 38, + "horizontalDistance": 72.45685720369549, + "slopeDistance": 72.90445786010146, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.371 + }, + { + "electrodeNo": 39, + "horizontalDistance": 74.330372956236, + "slopeDistance": 74.85313876651416, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.907 + }, + { + "electrodeNo": 40, + "horizontalDistance": 75.76722057945078, + "slopeDistance": 76.30859577114809, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 27.139 + } + ], + "scatterGraphConf": { + "canFilter": null, + "x": [ + { + "fieldCode": "horizontalDistance", + "name": "平距", + "sheetHeaderNameList": null + }, + { + "fieldCode": "slopeDistance", + "name": "斜距", + "sheetHeaderNameList": null + } + ], + "v": [ + { + "fieldCode": "I", + "name": "电流", + "sheetHeaderNameList": [ + "I(mA)" + ] + }, + { + "fieldCode": "V", + "name": "电压", + "sheetHeaderNameList": [ + "V(mV)" + ] + }, + { + "fieldCode": "R", + "name": "电阻", + "sheetHeaderNameList": [ + "R(Ohm)" + ] + }, + { + "fieldCode": "R0", + "name": "视电阻率", + "sheetHeaderNameList": [ + "R0" + ] + }, + { + "fieldCode": "SP", + "name": "自然电位", + "sheetHeaderNameList": [ + "SP" + ] + }, + { + "fieldCode": "R0_RD", + "name": "电阻误差", + "sheetHeaderNameList": [ + "R0_RD" + ] + } + ], + "y": [ + { + "fieldCode": "Layer No", + "name": "层数", + "sheetHeaderNameList": null + }, + { + "fieldCode": "pseudoDepth", + "name": "伪深度", + "sheetHeaderNameList": null + }, + { + "fieldCode": "elevationPseudoDepth", + "name": "伪深度+高程", + "sheetHeaderNameList": null + } + ], + "vcalculationMethod": [ + { + "fieldCode": null, + "name": "线性", + "sheetHeaderNameList": null + }, + { + "fieldCode": null, + "name": "对数", + "sheetHeaderNameList": null + }, + { + "fieldCode": null, + "name": "导数", + "sheetHeaderNameList": null + } + ] + }, + "scatterGraphData": { + "hasCoordinate": true, + "hasElevation": true, + "min": [ + 2.115013167852418, + 2.2827169278611863, + 0, + -15.6, + -1066.592407, + -1.1950568103363985, + -7538.067239559778, + 0, + "" + ], + "max": [ + 73.39361507996574, + 73.87879831330781, + 0, + -1.2, + 1232.438965, + "NaN", + 15669.56031213764, + 0, + "" + ], + "rows": [ + [ + 2.115013167852418, + 2.2827169278611863, + null, + -1.2, + 242.952988, + 2.385522244678844, + 4.116022643854044, + 0, + "1453611521843200", + 24.288, + 1, + 4, + 2, + 3, + 12.5664, + 1, + 242.952988 + ], + [ + 5.124875148343908, + 5.300065674086864, + null, + -2.4, + 74.753899, + 1.8736338494382652, + 13.377228657999497, + 0, + "1453611521843201", + 23.12, + 1, + 7, + 3, + 5, + 25.1327, + 2, + 74.753899 + ], + [ + 7.994418521491841, + 8.175900836015376, + null, + -3.6, + 100.239388, + 2.001038406459415, + 9.976118369757005, + 0, + "1453611521843202", + 21.987, + 1, + 10, + 4, + 7, + 37.6991, + 3, + 100.239388 + ], + [ + 8.00601333013422, + 8.186794593291953, + null, + -3.6, + 70.582214, + 1.8486952770460858, + 14.16787521003521, + 0, + "1453611521843203", + 22.020999999999997, + 1, + 10, + 5, + 6, + 125.663696, + 4, + 70.582214 + ], + [ + 11.066663821597649, + 11.272344909478555, + null, + -4.8, + 75.202255, + 1.8762308634556513, + 13.297473593045847, + 0, + "1453611521843204", + 20.9685, + 1, + 13, + 5, + 9, + 50.265499, + 5, + 75.202255 + ], + [ + 11.00295159513125, + 11.184890199081117, + null, + -4.8, + 103.542862, + 2.015120165027033, + 9.65783619154742, + 0, + "1453611521843205", + 20.758499999999998, + 1, + 13, + 6, + 8, + 109.955704, + 6, + 103.542862 + ], + [ + 13.92101964767117, + 14.166540305149894, + null, + -6, + 85.264038, + 1.9307658964508176, + 11.728273999877885, + 0, + "1453611521859584", + 19.490499999999997, + 1, + 16, + 6, + 11, + 62.831902, + 7, + 85.264038 + ], + [ + 14.000862630669717, + 14.247085672002468, + null, + -6, + 63.056168, + 1.7997275746100803, + 15.858876803297024, + 0, + "1453611521859585", + 19.456, + 1, + 16, + 7, + 10, + 113.097298, + 8, + 63.056168 + ], + [ + 14.063602086594681, + 14.27044051526772, + null, + -6, + 44.272751, + 1.6461365088096525, + 22.587256888554318, + 0, + "1453611521859586", + 19.706, + 1, + 16, + 8, + 9, + 351.858398, + 9, + 44.272751 + ], + [ + 16.987234838304097, + 17.233999253766825, + null, + -7.2, + 67.065033, + 1.8264961426225779, + 14.91089999165437, + 0, + "1453611521859587", + 18.296499999999998, + 1, + 19, + 7, + 13, + 75.398201, + 10, + 67.065033 + ], + [ + 17.03278556632032, + 17.279602362635487, + null, + -7.2, + 70.816719, + 1.85013580164825, + 14.120959204562979, + 0, + "1453611521859588", + 18.24, + 1, + 19, + 8, + 12, + 120.951302, + 11, + 70.816719 + ], + [ + 16.9816701391346, + 17.2520906213365, + null, + -7.2, + 41.787144, + 1.6210426897024701, + 23.930805129922256, + 0, + "1453611521859589", + 18.438, + 1, + 19, + 9, + 11, + 251.327393, + 12, + 41.787144 + ], + [ + 20.01061875986487, + 20.259070486101244, + null, + -8.4, + 68.505302, + 1.8357241852184725, + 14.597410285119246, + 0, + "1453611521859590", + 17.037, + 1, + 22, + 8, + 15, + 87.9646, + 13, + 68.505302 + ], + [ + 19.924955341057455, + 20.196078557097973, + null, + -8.4, + 76.61187, + 1.8842960630545889, + 13.05280761323278, + 0, + "1453611521859591", + 17.301000000000002, + 1, + 22, + 9, + 14, + 130.690308, + 14, + 76.61187 + ], + [ + 19.94235941950773, + 20.25312046337076, + null, + -8.4, + 58.417942, + 1.7665462531889349, + 17.118028567319268, + 0, + "1453611521859592", + 17.003500000000003, + 1, + 22, + 10, + 13, + 226.194595, + 15, + 58.417942 + ], + [ + 19.95085361886024, + 20.261252468704264, + null, + -8.4, + 151.941925, + 2.1816776243164018, + 6.581461963180999, + 0, + "1453611521859593", + 16.972, + 1, + 22, + 11, + 12, + 691.150391, + 16, + 151.941925 + ], + [ + 22.95679663366693, + 23.231100221926333, + null, + -9.6, + 87.04409, + 1.9397392893244523, + 11.488430748141546, + 0, + "1453611521859594", + 16.131, + 1, + 25, + 9, + 17, + 100.530998, + 17, + 87.04409 + ], + [ + 22.822828222784292, + 23.136758664349642, + null, + -9.6, + 89.94828, + 1.9539928631384818, + 11.117499967759251, + 0, + "1453611521859595", + 15.8315, + 1, + 25, + 10, + 16, + 141.371704, + 18, + 89.94828 + ], + [ + 22.928686812404784, + 23.24072059217002, + null, + -9.6, + 24.825411, + 1.3948964472401078, + 40.28130692378064, + 0, + "1453611521867776", + 15.769, + 1, + 25, + 11, + 15, + 219.911407, + 19, + 24.825411 + ], + [ + 22.8941388207831, + 23.205240404465737, + null, + -9.6, + 39.082321, + 1.5919803474612875, + 25.587016697396244, + 0, + "1453611521867777", + 15.835000000000003, + 1, + 25, + 12, + 14, + 449.247803, + 20, + 39.082321 + ] + ], + "__rowsTotal": 576 + } + } +} \ No newline at end of file From bc5613f0d22a3041b3184b9b2582798669ef7882 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sat, 13 Jun 2026 11:46:47 +0800 Subject: [PATCH 72/74] =?UTF-8?q?feat(dataset-detail):=20dd=5Fert=5Fmeasur?= =?UTF-8?q?ement=5Fgr=5Fdata=20=E6=8E=A5=E5=9C=B0=E7=94=B5=E9=98=BB?= =?UTF-8?q?=E6=9F=B1=E7=8A=B6=E5=9B=BE=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在按类型渲染引擎上新增 Bar 视图 kind。 - 柱状图(BarChartView:QwtPlotBarChart 单系列 / QwtPlotMultiBarChart 分组): X=电极点 #1..#40(electrodeId),Y=电阻(欧姆)=p1Rg,单系列 P1 实心 #5470c6 (任一行 p2Rg 非空时加分组系列 P2);Y 轴标题「电阻(单位:欧姆)」横排左上、 X 轴标题「电极点」、图例 P1;主题跟随;析构 clearSeries 防悬垂 - 列表:复用通用 DataTableView,固定 7 列(ID/日期/时间/P1 Rg(Ω)/P1状态/ P2 Rg(Ω)/P2状态) - BarPayload(DetailPayloads.hpp 加性);GrMeasurementDto(parseGrBar/parseGrTable, 响应为 JSON 数组,p1/p2 isDouble 守卫);GrMeasurementStrategy(柱状图/Bar+列表/Table); ApiDatasetRepository loaderKey gr.bar/gr.rows(同一端点不同解析); DetailViewFactory Bar case;main 注册 测试 138→143 全绿。夹具 tests/fixtures/dd/ert-gr-rows.json。 --- src/app/CMakeLists.txt | 1 + src/app/main.cpp | 2 + src/app/panels/chart/BarChartView.cpp | 215 ++++++++++++++++++ src/app/panels/chart/BarChartView.hpp | 40 ++++ src/app/panels/chart/DetailViewFactory.cpp | 4 +- .../panels/chart/GrMeasurementStrategy.hpp | 19 ++ src/core/model/detail/DetailPayloads.hpp | 17 ++ src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetRepository.cpp | 30 +++ src/data/api/ApiDatasetRepository.hpp | 2 + src/data/dto/GrMeasurementDto.cpp | 87 +++++++ src/data/dto/GrMeasurementDto.hpp | 19 ++ tests/CMakeLists.txt | 1 + tests/app/test_chart_strategy_registry.cpp | 18 ++ tests/data/test_async_repo_dispatch.cpp | 8 + tests/data/test_gr_dto.cpp | 123 ++++++++++ tests/fixtures/dd/ert-gr-rows.json | 27 +++ 17 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 src/app/panels/chart/BarChartView.cpp create mode 100644 src/app/panels/chart/BarChartView.hpp create mode 100644 src/app/panels/chart/GrMeasurementStrategy.hpp create mode 100644 src/data/dto/GrMeasurementDto.cpp create mode 100644 src/data/dto/GrMeasurementDto.hpp create mode 100644 tests/data/test_gr_dto.cpp create mode 100644 tests/fixtures/dd/ert-gr-rows.json diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 4f09f65..89f92ab 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(geopro_desktop WIN32 panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp panels/chart/DataTableView.cpp + panels/chart/BarChartView.cpp panels/chart/DetailViewFactory.cpp panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index 2b09d7f..646b4f8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -86,6 +86,7 @@ #include "DatasetDetailController.hpp" #include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp" +#include "panels/chart/GrMeasurementStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -912,6 +913,7 @@ int main(int argc, char* argv[]) geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); + chartRegistry.add(std::make_unique()); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 diff --git a/src/app/panels/chart/BarChartView.cpp b/src/app/panels/chart/BarChartView.cpp new file mode 100644 index 0000000..2e872a3 --- /dev/null +++ b/src/app/panels/chart/BarChartView.cpp @@ -0,0 +1,215 @@ +#include "panels/chart/BarChartView.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Theme.hpp" +#include "panels/chart/ChartTheme.hpp" + +namespace geopro::app { + +namespace { + +// 类目轴刻度:把整数刻度位(0,1,2,…)映射为类目标签 "#1","#2",…(来自 categories)。 +// 非整数/越界刻度返回空标签(避免次刻度污染)。 +class CategoryScaleDraw : public QwtScaleDraw { +public: + explicit CategoryScaleDraw(std::vector labels) : labels_(std::move(labels)) { + enableComponent(QwtScaleDraw::Backbone, true); + enableComponent(QwtScaleDraw::Ticks, true); + } + QwtText label(double v) const override { + const double r = std::round(v); + if (std::abs(v - r) > 1e-6) return QwtText(); // 仅整数刻度出标签 + const int i = static_cast(r); + if (i < 0 || i >= static_cast(labels_.size())) return QwtText(); + return labels_[static_cast(i)]; + } + +private: + std::vector labels_; +}; + +QColor barColor(const QString& hex) { + QColor c(hex); + return c.isValid() ? c : QColor(0x54, 0x70, 0xc6); // 回退 ECharts 蓝 +} + +// 造一个 Box 样式柱符号(实心填充、无边框)。 +QwtColumnSymbol* makeBarSymbol(const QColor& fill) { + auto* sym = new QwtColumnSymbol(QwtColumnSymbol::Box); + sym->setLineWidth(0); + sym->setFrameStyle(QwtColumnSymbol::NoFrame); + QPalette pal(fill); + pal.setColor(QPalette::Window, fill); + pal.setColor(QPalette::Dark, fill); + sym->setPalette(pal); + return sym; +} + +} // namespace + +BarChartView::BarChartView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + // 左上水平 y 轴标题(ECharts 风格,置于图例行左侧)。 + yTitle_ = new QLabel(this); + auto* titleRow = new QWidget(this); + auto* titleLay = new QHBoxLayout(titleRow); + titleLay->setContentsMargins(48, 6, 8, 0); // 左缩进对齐 y 轴上方 + titleLay->setSpacing(0); + titleLay->addWidget(yTitle_); + titleLay->addStretch(); + lay->addWidget(titleRow); + + plot_ = new QwtPlot(this); + plot_->setObjectName(QStringLiteral("grBarPlotArea")); + plot_->enableAxis(QwtPlot::xBottom, true); + plot_->enableAxis(QwtPlot::yLeft, true); + + // x 轴标题「电极点」底部居中(setData 设文本)。 + plot_->setAxisTitle(QwtPlot::xBottom, QwtText()); + + // 顶部图例(P1/P2)。 + plot_->insertLegend(new QwtLegend(), QwtPlot::TopLegend); + + // 仅横向(y)网格,弱化(与原版 ECharts 一致:仅水平刻度线)。 + auto* grid = new QwtPlotGrid(); + grid->enableX(false); + grid->enableY(true); + grid->enableXMin(false); + grid->enableYMin(false); + grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); + grid->attach(plot_); + + plot_->setMinimumSize(0, 0); + lay->addWidget(plot_, 1); + + // 主题:底色/轴字/网格按当前主题套一次 + 热切换。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, + [this]() { + applyChartPlotTheme(plot_); + // y 轴标题文字色随主题。 + QPalette pal = yTitle_->palette(); + pal.setColor(QPalette::WindowText, + isDarkTheme() ? tokenColor("text/secondary") + : QColor(90, 90, 90)); + yTitle_->setPalette(pal); + }); +} + +BarChartView::~BarChartView() { + // 卸载并删除已挂的柱状项,先于 QwtPlot autoDelete 触发(与 RawDataChartView/ + // GridDataChartView 析构对称,避免悬垂/双删)。 + clearSeries(); +} + +void BarChartView::clearSeries() { + for (QwtPlotItem* it : barItems_) { + it->detach(); + delete it; + } + barItems_.clear(); +} + +void BarChartView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) return; // 坏/空 → 空态 + setData(payload.value()); +} + +void BarChartView::setData(const geopro::core::BarPayload& p) { + data_ = p; + clearSeries(); + + const int n = static_cast(p.categories.size()); + + // y 轴标题(左上水平 QLabel)。 + yTitle_->setText(p.yTitle); + QPalette tpal = yTitle_->palette(); + tpal.setColor(QPalette::WindowText, + isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90)); + yTitle_->setPalette(tpal); + + // x 轴标题「电极点」+ 类目刻度 "#1".."#40"。 + plot_->setAxisTitle(QwtPlot::xBottom, p.xTitle); + plot_->setAxisScaleDraw(QwtPlot::xBottom, new CategoryScaleDraw(p.categories)); + plot_->setAxisScale(QwtPlot::xBottom, -0.5, n - 0.5, 1.0); // 每类目一刻度 + plot_->setAxisMaxMinor(QwtPlot::xBottom, 0); + + // y 范围 0..max(自动从数据取上界,留 5% 余量)。 + double yMax = 0.0; + for (const auto& s : p.series) + for (double v : s.values) yMax = std::max(yMax, v); + plot_->setAxisScale(QwtPlot::yLeft, 0.0, yMax > 0 ? yMax * 1.05 : 1.0); + + if (p.series.empty()) { + plot_->replot(); + return; + } + + if (p.series.size() == 1) { + // 单系列 → QwtPlotBarChart。 + const auto& s = p.series.front(); + auto* chart = new QwtPlotBarChart(s.name); + QVector samples; + samples.reserve(n); + for (int i = 0; i < n && i < static_cast(s.values.size()); ++i) + samples.append(QPointF(i, s.values[static_cast(i)])); + chart->setSamples(samples); + chart->setSymbol(makeBarSymbol(barColor(s.color))); + chart->setLegendMode(QwtPlotBarChart::LegendChartTitle); + chart->setLayoutPolicy(QwtPlotBarChart::AutoAdjustSamples); + chart->setSpacing(10); // 柱间留白(默认柱状观感) + chart->setMargin(4); + chart->attach(plot_); + barItems_.push_back(chart); + } else { + // 多系列(P1+P2)→ QwtPlotMultiBarChart(分组柱)。 + auto* chart = new QwtPlotMultiBarChart(); + QList titles; + for (size_t k = 0; k < p.series.size(); ++k) { + chart->setSymbol(static_cast(k), makeBarSymbol(barColor(p.series[k].color))); + titles.append(QwtText(p.series[k].name)); + } + chart->setBarTitles(titles); + chart->setStyle(QwtPlotMultiBarChart::Grouped); + chart->setLayoutPolicy(QwtPlotMultiBarChart::AutoAdjustSamples); + chart->setSpacing(10); + chart->setMargin(4); + + QVector samples; + samples.reserve(n); + for (int i = 0; i < n; ++i) { + QVector set; + set.reserve(static_cast(p.series.size())); + for (const auto& s : p.series) + set.append(i < static_cast(s.values.size()) ? s.values[static_cast(i)] + : 0.0); + samples.append(QwtSetSample(i, set)); + } + chart->setSamples(samples); + chart->attach(plot_); + barItems_.push_back(chart); + } + + plot_->replot(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/BarChartView.hpp b/src/app/panels/chart/BarChartView.hpp new file mode 100644 index 0000000..c889eee --- /dev/null +++ b/src/app/panels/chart/BarChartView.hpp @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +#include "model/detail/DetailPayloads.hpp" +#include "panels/chart/IDetailView.hpp" + +class QLabel; +class QwtPlot; +class QwtPlotItem; + +namespace geopro::app { + +// 柱状图视图(dd_ert_measurement_gr_data 接地电阻 柱状图页签): +// QwtPlot + QwtPlotBarChart(单系列 P1)或 QwtPlotMultiBarChart(分组 P1+P2)。 +// x 轴类目标签 "#1".."#40"(自定义 QwtScaleDraw);x 轴标题「电极点」(底部居中); +// y 轴标题「电阻(单位:欧姆)」用左上水平 QLabel(ECharts 风格,对齐原版); +// 顶部图例(P1/P2);柱填充 #5470c6(P1,数据色,两主题一致)。 +// 背景/轴字/网格随主题(ChartTheme / ThemeManager)。 +class BarChartView : public QWidget, public IDetailView { + Q_OBJECT +public: + explicit BarChartView(QWidget* parent = nullptr); + ~BarChartView() override; + + void setData(const geopro::core::BarPayload& p); + + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; // 坏/空 variant → 空态不崩 + +private: + void clearSeries(); // 卸载并删除已挂的柱状项(避免 QwtPlot autoDelete 双删) + + geopro::core::BarPayload data_; + QwtPlot* plot_; + QLabel* yTitle_; // 左上水平 y 轴标题(ECharts 风格) + std::vector barItems_; // 当前挂载的柱状项(已 attach;卸载时 detach+delete) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index e5114af..7908b64 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -2,6 +2,7 @@ #include +#include "panels/chart/BarChartView.hpp" #include "panels/chart/DataTableView.hpp" #include "panels/chart/GridDataChartView.hpp" #include "panels/chart/RawDataChartView.hpp" @@ -17,9 +18,10 @@ std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* case controller::ViewKind::Table: return std::unique_ptr(new DataTableView(parent)); case controller::ViewKind::Bar: + return std::unique_ptr(new BarChartView(parent)); case controller::ViewKind::LineProfile: case controller::ViewKind::PolylineMap: - // 后续阶段补:Bar(gr_data)/LineProfile,PolylineMap(trajectory)。 + // 后续阶段补:LineProfile,PolylineMap(trajectory)。 throw std::runtime_error("makeDetailView: ViewKind not yet implemented"); } throw std::runtime_error("makeDetailView: unknown ViewKind"); diff --git a/src/app/panels/chart/GrMeasurementStrategy.hpp b/src/app/panels/chart/GrMeasurementStrategy.hpp new file mode 100644 index 0000000..0d21849 --- /dev/null +++ b/src/app/panels/chart/GrMeasurementStrategy.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include "IDatasetChartStrategy.hpp" // geopro::controller +namespace geopro::app { + +// ERT 接地电阻(measurement gr_data)策略:柱状图(同步)+ 列表(懒加载)两页签。 +// 两页签同一端点 measurement/gr/rows,loaderKey 不同(gr.bar 产 BarPayload / gr.rows 产 TablePayload)。 +struct GrMeasurementStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_ert_measurement_gr_data"; } + std::vector tabs() const override { + return { + {QStringLiteral("柱状图"), controller::ViewKind::Bar, + QStringLiteral("gr.bar"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("列表"), controller::ViewKind::Table, + QStringLiteral("gr.rows"), /*lazy*/ true, /*paginated*/ false}, + }; + } +}; +} // namespace geopro::app diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index 616c81b..d769005 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -67,8 +67,25 @@ struct TablePayload { int total = 0; }; +// 柱状图系列:名称(图例/legend)+ 各类目的 y 值 + 填充色(hex,如 #5470c6;数据色,两主题一致)。 +struct BarSeries { + QString name; + std::vector values; + QString color; +}; + +// 柱状图载荷(dd_ert_measurement_gr_data 接地电阻):类目(x 轴标签,如 "#1".."#40")+ +// 一个或多个系列(P1/P2,每系列一组按类目对齐的 y 值)+ 轴标题。 +struct BarPayload { + std::vector categories; + std::vector series; + QString xTitle; + QString yTitle; +}; + } // namespace geopro::core Q_DECLARE_METATYPE(geopro::core::ScatterPayload) Q_DECLARE_METATYPE(geopro::core::ContourPayload) Q_DECLARE_METATYPE(geopro::core::TablePayload) +Q_DECLARE_METATYPE(geopro::core::BarPayload) diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 45594ec..0dcde7c 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(geopro_data STATIC dto/NavDto.cpp dto/DatasetChartDto.cpp dto/MeasurementDto.cpp + dto/GrMeasurementDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index ba7e71d..b6114c0 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,5 +1,6 @@ #include "api/ApiDatasetRepository.hpp" #include +#include #include #include #include @@ -8,6 +9,7 @@ #include "ApiBatch.hpp" #include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" +#include "dto/GrMeasurementDto.hpp" #include "dto/MeasurementDto.hpp" #include "model/detail/DetailPayloads.hpp" @@ -96,6 +98,20 @@ net::ApiBatch* measurementRowsBatch(net::ApiClient& api, const std::string& dsId return new net::ApiBatch(calls, &isFailure); } +// 接地电阻(gr):柱状图与列表同一端点 measurement/gr/rows(GET,query 参数)。 +// 响应 data 是 JSON 数组 → ApiResponseParse 包成 {value:[...]},取 r[0].data.value("value").toArray()。 +net::ApiBatch* grRowsBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/measurement/gr/rows?dsObjectId=%1").arg(enc(dsId))), + }; + return new net::ApiBatch(calls, &isFailure); +} + +// gr 响应数组(包在 data.value 里)。 +QJsonArray grDataArray(const QList& r) { + return r[0].data.value(QStringLiteral("value")).toArray(); +} + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} @@ -105,6 +121,8 @@ DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const if (loaderKey == "inversion.grid") return makeInversionGrid(dsId); if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId); if (loaderKey == "ert_measurement.rows") return makeMeasurementRows(dsId); + if (loaderKey == "gr.bar") return makeGrBar(dsId); + if (loaderKey == "gr.rows") return makeGrRows(dsId); throw std::runtime_error("unknown loaderKey: " + loaderKey); } @@ -136,4 +154,16 @@ DetailLoad* ApiDatasetRepository::makeMeasurementRows(const std::string& dsId) { }); } +DetailLoad* ApiDatasetRepository::makeGrBar(const std::string& dsId) { + return new ApiDetailLoad(grRowsBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseGrBar(grDataArray(r))); + }); +} + +DetailLoad* ApiDatasetRepository::makeGrRows(const std::string& dsId) { + return new ApiDetailLoad(grRowsBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseGrTable(grDataArray(r))); + }); +} + } // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index cb76a91..7dd3897 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -13,6 +13,8 @@ private: DetailLoad* makeInversionGrid(const std::string& dsId); DetailLoad* makeMeasurementScatter(const std::string& dsId); DetailLoad* makeMeasurementRows(const std::string& dsId); + DetailLoad* makeGrBar(const std::string& dsId); + DetailLoad* makeGrRows(const std::string& dsId); net::ApiClient& api_; }; } // namespace geopro::data diff --git a/src/data/dto/GrMeasurementDto.cpp b/src/data/dto/GrMeasurementDto.cpp new file mode 100644 index 0000000..962b591 --- /dev/null +++ b/src/data/dto/GrMeasurementDto.cpp @@ -0,0 +1,87 @@ +#include "dto/GrMeasurementDto.hpp" + +#include +#include + +namespace geopro::data::dto { +using namespace geopro::core; + +namespace { + +// ECharts 默认蓝(柱填充,数据色——浅/暗主题一致)。 +const QString kP1Color = QStringLiteral("#5470c6"); +const QString kP2Color = QStringLiteral("#91cc75"); // ECharts 默认绿(备用 P2 系列) + +// 把 JSON 数值/字符串预格式化为单元格 QString(整数不带小数点;null/缺省 → 空串)。 +QString cellText(const QJsonValue& v) { + if (v.isDouble()) { + const double d = v.toDouble(); + if (d == static_cast(static_cast(d))) + return QString::number(static_cast(d)); + return QString::number(d, 'g', 10); + } + if (v.isString()) return v.toString(); + return QString(); // null/undefined +} + +} // namespace + +BarPayload parseGrBar(const QJsonArray& rows) { + BarPayload p; + p.xTitle = QStringLiteral("电极点"); + p.yTitle = QStringLiteral("电阻(单位:欧姆)"); + + BarSeries p1{QStringLiteral("P1"), {}, kP1Color}; + BarSeries p2{QStringLiteral("P2"), {}, kP2Color}; + bool hasP2 = false; // 任一行 p2Rg 非空 → 建 P2 系列(分组柱)。 + + for (const auto& e : rows) { + const QJsonObject o = e.toObject(); + p.categories.push_back( + QStringLiteral("#") + QString::number(o.value(QStringLiteral("electrodeId")).toInt())); + const QJsonValue p1v = o.value(QStringLiteral("p1Rg")); + p1.values.push_back(p1v.isDouble() ? p1v.toDouble() : 0.0); // null/缺省 → 0(与 P2 对称防脏数据) + + const QJsonValue p2v = o.value(QStringLiteral("p2Rg")); + if (p2v.isDouble()) hasP2 = true; + p2.values.push_back(p2v.isDouble() ? p2v.toDouble() : 0.0); + } + + p.series.push_back(std::move(p1)); // P1 恒有 + if (hasP2) p.series.push_back(std::move(p2)); + return p; +} + +TablePayload parseGrTable(const QJsonArray& rows) { + TablePayload t; + + // 7 固定列(硬编码:gr 响应无列定义)。code = JSON 字段名,title = 原版列标题。 + const struct { const char* code; const char* title; } kCols[] = { + {"electrodeId", "ID"}, + {"testDate", "日期"}, + {"testTime", "时间"}, + {"p1Rg", "P1 Rg(Ω)"}, + {"p1RgStatus", "P1状态"}, + {"p2Rg", "P2 Rg(Ω)"}, + {"p2RgStatus", "P2状态"}, + }; + for (const auto& c : kCols) { + TableColumn col; + col.code = QString::fromUtf8(c.code); + col.title = QString::fromUtf8(c.title); + t.columns.push_back(col); + } + + for (const auto& e : rows) { + const QJsonObject o = e.toObject(); + std::vector cells; + cells.reserve(t.columns.size()); + for (const auto& col : t.columns) cells.push_back(cellText(o.value(col.code))); + t.rows.push_back(std::move(cells)); + } + + t.total = static_cast(t.rows.size()); + return t; +} + +} // namespace geopro::data::dto diff --git a/src/data/dto/GrMeasurementDto.hpp b/src/data/dto/GrMeasurementDto.hpp new file mode 100644 index 0000000..61d3483 --- /dev/null +++ b/src/data/dto/GrMeasurementDto.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include "model/detail/DetailPayloads.hpp" + +namespace geopro::data::dto { + +// dd_ert_measurement_gr_data 接地电阻:两页签同一端点 measurement/gr/rows,data 是 JSON 数组 +// (每元素 {electrodeId,testDate,testTime,p1Rg,p1RgStatus,p2Rg,p2RgStatus},无 gridHeaderDisplay)。 +// +// 柱状图:类目 = "#"+electrodeId("#1".."#40");系列 P1 = p1Rg(恒有,色 #5470c6); +// 系列 P2 = p2Rg,仅当任一行 p2Rg 非空才建(本样本全 null → 只有 P1)。 +// x 轴标题「电极点」,y 轴标题「电阻(单位:欧姆)」。 +geopro::core::BarPayload parseGrBar(const QJsonArray& rows); + +// 列表:7 固定列(gr 无 gridHeaderDisplay,故硬编码列标题/列码): +// ID | 日期 | 时间 | P1 Rg(Ω) | P1状态 | P2 Rg(Ω) | P2状态。 +geopro::core::TablePayload parseGrTable(const QJsonArray& rows); + +} // namespace geopro::data::dto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b5e9669..891a555 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -39,6 +39,7 @@ target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_gr_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) # 通用仓储分派离线单测(loadAsync 分派 + QVariant payload round-trip)。 target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index 0de0772..cebeef6 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -2,6 +2,7 @@ #include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) #include "panels/chart/MeasurementStrategy.hpp" +#include "panels/chart/GrMeasurementStrategy.hpp" using namespace geopro::controller; namespace { struct Fake : IDatasetChartStrategy { @@ -52,3 +53,20 @@ TEST(MeasurementStrategy, DrivesTwoTabsScatterAndTable) { EXPECT_TRUE(tabs[1].lazy); EXPECT_EQ(tabs[1].loaderKey.toStdString(), "ert_measurement.rows"); } + +TEST(GrMeasurementStrategy, DrivesBarAndTableTabs) { + geopro::app::GrMeasurementStrategy s; + EXPECT_EQ(s.ddCode(), "dd_ert_measurement_gr_data"); + const auto tabs = s.tabs(); + ASSERT_EQ(tabs.size(), 2u); + // 柱状图:Bar,非 lazy。 + EXPECT_EQ(tabs[0].title.toStdString(), std::string("柱状图")); + EXPECT_EQ(tabs[0].kind, ViewKind::Bar); + EXPECT_FALSE(tabs[0].lazy); + EXPECT_EQ(tabs[0].loaderKey.toStdString(), "gr.bar"); + // 列表:Table,lazy。 + EXPECT_EQ(tabs[1].title.toStdString(), std::string("列表")); + EXPECT_EQ(tabs[1].kind, ViewKind::Table); + EXPECT_TRUE(tabs[1].lazy); + EXPECT_EQ(tabs[1].loaderKey.toStdString(), "gr.rows"); +} diff --git a/tests/data/test_async_repo_dispatch.cpp b/tests/data/test_async_repo_dispatch.cpp index 2da68e6..e9ab843 100644 --- a/tests/data/test_async_repo_dispatch.cpp +++ b/tests/data/test_async_repo_dispatch.cpp @@ -45,6 +45,14 @@ TEST(AsyncRepoDispatch, KnownKeysReturnNonNullHandle) { DetailLoad* measRows = repo.loadAsync("ert_measurement.rows", "ds1"); ASSERT_NE(measRows, nullptr); measRows->abort(); + + DetailLoad* grBar = repo.loadAsync("gr.bar", "ds1"); + ASSERT_NE(grBar, nullptr); + grBar->abort(); + + DetailLoad* grRows = repo.loadAsync("gr.rows", "ds1"); + ASSERT_NE(grRows, nullptr); + grRows->abort(); } // 未知 loaderKey 抛 std::runtime_error。 diff --git a/tests/data/test_gr_dto.cpp b/tests/data/test_gr_dto.cpp new file mode 100644 index 0000000..190099f --- /dev/null +++ b/tests/data/test_gr_dto.cpp @@ -0,0 +1,123 @@ +#include + +#include +#include + +#include "dto/GrMeasurementDto.hpp" + +using namespace geopro::data::dto; +using geopro::core::TableColumnKind; + +namespace { + +// 取自真实夹具 tests/fixtures/dd/ert-gr-rows.json 的 data 数组(裁剪至 20 行,逐字一致)。 +// 与 test_measurement_dto.cpp 同法:内联夹具,避免引入 fixture 路径编译定义。 +const char* kGrRows = R"([ + { "electrodeId": 1, "testDate": "5/26/12", "testTime": "18:25:15", "p1Rg": 1385, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 2, "testDate": "5/26/12", "testTime": "18:25:16", "p1Rg": 3444, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 3, "testDate": "5/26/12", "testTime": "18:25:17", "p1Rg": 737, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 4, "testDate": "5/26/12", "testTime": "18:25:18", "p1Rg": 813, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 5, "testDate": "5/26/12", "testTime": "18:25:19", "p1Rg": 577, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 6, "testDate": "5/26/12", "testTime": "18:25:20", "p1Rg": 652, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 7, "testDate": "5/26/12", "testTime": "18:25:20", "p1Rg": 696, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 8, "testDate": "5/26/12", "testTime": "18:25:21", "p1Rg": 523, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 9, "testDate": "5/26/12", "testTime": "18:25:22", "p1Rg": 587, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 10, "testDate": "5/26/12", "testTime": "18:25:23", "p1Rg": 968, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 11, "testDate": "5/26/12", "testTime": "18:25:24", "p1Rg": 1701, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 12, "testDate": "5/26/12", "testTime": "18:25:25", "p1Rg": 1951, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 13, "testDate": "5/26/12", "testTime": "18:25:26", "p1Rg": 1405, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 14, "testDate": "5/26/12", "testTime": "18:25:27", "p1Rg": 1166, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 15, "testDate": "5/26/12", "testTime": "18:25:28", "p1Rg": 1222, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 16, "testDate": "5/26/12", "testTime": "18:25:29", "p1Rg": 1043, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 17, "testDate": "5/26/12", "testTime": "18:25:30", "p1Rg": 1272, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 18, "testDate": "5/26/12", "testTime": "18:25:30", "p1Rg": 2150, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 19, "testDate": "5/26/12", "testTime": "18:25:31", "p1Rg": 1806, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 20, "testDate": "5/26/12", "testTime": "18:25:32", "p1Rg": 971, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" } +])"; + +QJsonArray grRows() { return QJsonDocument::fromJson(kGrRows).array(); } + +} // namespace + +TEST(GrDto, ParsesBarSingleP1Series) { + const QJsonArray rows = grRows(); + ASSERT_EQ(rows.size(), 20); + + auto bar = parseGrBar(rows); + + // 类目 "#1".."#20"。 + ASSERT_EQ(bar.categories.size(), 20u); + EXPECT_EQ(bar.categories.front().toStdString(), "#1"); + EXPECT_EQ(bar.categories.back().toStdString(), "#20"); + + // 轴标题。 + EXPECT_EQ(bar.xTitle.toStdString(), std::string("电极点")); + EXPECT_EQ(bar.yTitle.toStdString(), std::string("电阻(单位:欧姆)")); + + // 单系列 P1(p2Rg 全 null → 无 P2),色 #5470c6。 + ASSERT_EQ(bar.series.size(), 1u); + const auto& s = bar.series.front(); + EXPECT_EQ(s.name.toStdString(), "P1"); + EXPECT_EQ(s.color.toStdString(), "#5470c6"); + ASSERT_EQ(s.values.size(), 20u); + EXPECT_DOUBLE_EQ(s.values.front(), 1385.0); // 首行 p1Rg + EXPECT_DOUBLE_EQ(s.values[1], 3444.0); + EXPECT_DOUBLE_EQ(s.values.back(), 971.0); // 第 20 行 p1Rg +} + +TEST(GrDto, AddsP2SeriesWhenAnyNonNull) { + // 合成:含一行 p2Rg 非空 → 应建 P2 系列(分组柱)。 + const char* json = R"([ + {"electrodeId":1,"p1Rg":100,"p1RgStatus":"正常","p2Rg":null,"p2RgStatus":""}, + {"electrodeId":2,"p1Rg":200,"p1RgStatus":"正常","p2Rg":55,"p2RgStatus":"正常"} + ])"; + const QJsonArray rows = QJsonDocument::fromJson(json).array(); + + auto bar = parseGrBar(rows); + ASSERT_EQ(bar.series.size(), 2u); + EXPECT_EQ(bar.series[0].name.toStdString(), "P1"); + EXPECT_EQ(bar.series[1].name.toStdString(), "P2"); + ASSERT_EQ(bar.series[1].values.size(), 2u); + EXPECT_DOUBLE_EQ(bar.series[1].values[1], 55.0); +} + +TEST(GrDto, ParsesEmptyArrayToValidEmptyPayloads) { + // 空/畸形响应:空 QJsonArray → 有效但空的载荷,不崩。 + const QJsonArray empty; + + auto bar = parseGrBar(empty); + EXPECT_EQ(bar.categories.size(), 0u); + EXPECT_EQ(bar.series.size(), 1u); // P1 恒有(系列存在但无值) + EXPECT_EQ(bar.series.front().values.size(), 0u); + EXPECT_EQ(bar.xTitle.toStdString(), std::string("电极点")); + + auto t = parseGrTable(empty); + EXPECT_EQ(t.columns.size(), 7u); // 7 列硬编码,恒有 + EXPECT_EQ(t.rows.size(), 0u); + EXPECT_EQ(t.total, 0); +} + +TEST(GrDto, ParsesTableSevenFixedColumns) { + const QJsonArray rows = grRows(); + auto t = parseGrTable(rows); + + // 7 固定列,无 Toggle 列(gr 不复用 measurement 的开关列)。 + ASSERT_EQ(t.columns.size(), 7u); + EXPECT_EQ(t.columns[0].title.toStdString(), "ID"); + EXPECT_EQ(t.columns[1].title.toStdString(), std::string("日期")); + EXPECT_EQ(t.columns[2].title.toStdString(), std::string("时间")); + EXPECT_EQ(t.columns[3].title.toStdString(), "P1 Rg(Ω)"); + EXPECT_EQ(t.columns[4].title.toStdString(), std::string("P1状态")); + EXPECT_EQ(t.columns[5].title.toStdString(), "P2 Rg(Ω)"); + EXPECT_EQ(t.columns[6].title.toStdString(), std::string("P2状态")); + for (const auto& c : t.columns) EXPECT_EQ(c.kind, TableColumnKind::Text); + + // 20 行;首行 ID=1, p1Rg=1385, P1状态=正常;p2Rg null → 空串。 + ASSERT_EQ(t.rows.size(), 20u); + ASSERT_EQ(t.rows[0].size(), 7u); + EXPECT_EQ(t.rows[0][0].toStdString(), "1"); + EXPECT_EQ(t.rows[0][3].toStdString(), "1385"); + EXPECT_EQ(t.rows[0][4].toStdString(), std::string("正常")); + EXPECT_TRUE(t.rows[0][5].isEmpty()); // p2Rg null + EXPECT_EQ(t.total, 20); +} diff --git a/tests/fixtures/dd/ert-gr-rows.json b/tests/fixtures/dd/ert-gr-rows.json new file mode 100644 index 0000000..cff1c41 --- /dev/null +++ b/tests/fixtures/dd/ert-gr-rows.json @@ -0,0 +1,27 @@ +{ + "code": 200, + "msg": "成功", + "__total": 40, + "data": [ + { "electrodeId": 1, "testDate": "5/26/12", "testTime": "18:25:15", "p1Rg": 1385, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 2, "testDate": "5/26/12", "testTime": "18:25:16", "p1Rg": 3444, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 3, "testDate": "5/26/12", "testTime": "18:25:17", "p1Rg": 737, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 4, "testDate": "5/26/12", "testTime": "18:25:18", "p1Rg": 813, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 5, "testDate": "5/26/12", "testTime": "18:25:19", "p1Rg": 577, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 6, "testDate": "5/26/12", "testTime": "18:25:20", "p1Rg": 652, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 7, "testDate": "5/26/12", "testTime": "18:25:20", "p1Rg": 696, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 8, "testDate": "5/26/12", "testTime": "18:25:21", "p1Rg": 523, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 9, "testDate": "5/26/12", "testTime": "18:25:22", "p1Rg": 587, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 10, "testDate": "5/26/12", "testTime": "18:25:23", "p1Rg": 968, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 11, "testDate": "5/26/12", "testTime": "18:25:24", "p1Rg": 1701, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 12, "testDate": "5/26/12", "testTime": "18:25:25", "p1Rg": 1951, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 13, "testDate": "5/26/12", "testTime": "18:25:26", "p1Rg": 1405, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 14, "testDate": "5/26/12", "testTime": "18:25:27", "p1Rg": 1166, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 15, "testDate": "5/26/12", "testTime": "18:25:28", "p1Rg": 1222, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 16, "testDate": "5/26/12", "testTime": "18:25:29", "p1Rg": 1043, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 17, "testDate": "5/26/12", "testTime": "18:25:30", "p1Rg": 1272, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 18, "testDate": "5/26/12", "testTime": "18:25:30", "p1Rg": 2150, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 19, "testDate": "5/26/12", "testTime": "18:25:31", "p1Rg": 1806, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" }, + { "electrodeId": 20, "testDate": "5/26/12", "testTime": "18:25:32", "p1Rg": 971, "p1RgStatus": "正常", "p2Rg": null, "p2RgStatus": "" } + ] +} From b033dc2a2c4a086d7ce7c3752395e3e7f9cbbe78 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sat, 13 Jun 2026 16:36:28 +0800 Subject: [PATCH 73/74] =?UTF-8?q?feat(dataset-detail):=20dd=5Ftrajectory?= =?UTF-8?q?=5Fdata=20=E8=BD=A8=E8=BF=B9=E8=AF=A6=E6=83=85(=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE+=E5=88=97=E8=A1=A8+=E9=AB=98=E7=A8=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在按类型渲染引擎上落地轨迹三页签,新增 LineProfile + WebMap 两个视图 kind。 - 地图(TrajectoryMapView:QWebEngineView + 本地 MapLibre GL JS 4.7.1): WebGL 引擎,天地图栅格 WMTS 底图(街道 vec_w+cva_w / 卫星 img_w+cia_w,token, t0-t7 子域,逐层 maxzoom);over-zoom 上采样平滑;街道/卫星图层切换 + 回到当前 位置定位按钮;实心橙点(#ff8c00 白环)+ 连线 + 起点标;经纬度取自 trajectory/line?frontCrsCode=EPSG:4326(服务端转 WGS84);runJavaScript 喂点(无 WebChannel) - 高程(LineChartView:QwtPlot + QwtPlotCurve):X=电极号 #1..#40,Y=高程, 数据空间预算样条平滑(非 Fitted,避免像素重拟合漂移);hover 用 QwtWidgetOverlay 画虚线参考线+实心点(叠加层不 replot 主图→曲线零漂移)+ 自定义浮动框(#编号/●高程(m) 值) - 列表:复用通用 DataTableView + 通用 gridHeaderDisplay→TablePayload 解析(14 列) - LinePayload/MapPayload(DetailPayloads.hpp 加性);TrajectoryDto;TrajectoryStrategy (地图/WebMap+列表/Table+高程/LineProfile);ApiDatasetRepository loaderKey traj.map/traj.rows/traj.elev;DetailViewFactory LineProfile/WebMap;main 注册 - 构建:geopro_desktop 链接 Qt6::WebEngineWidgets/WebEngineQuick;main 加 QtWebEngineQuick::initialize()+AA_ShareOpenGLContexts;CMake 部署 WebEngine 运行时 测试 143→148 全绿。夹具 tests/fixtures/dd/ert-trajectory-{rows,line}.json。 --- src/app/CMakeLists.txt | 23 ++ src/app/main.cpp | 10 + src/app/panels/chart/DetailViewFactory.cpp | 8 +- src/app/panels/chart/LineChartView.cpp | 380 ++++++++++++++++++++ src/app/panels/chart/LineChartView.hpp | 78 ++++ src/app/panels/chart/TrajectoryMapView.cpp | 58 +++ src/app/panels/chart/TrajectoryMapView.hpp | 35 ++ src/app/panels/chart/TrajectoryStrategy.hpp | 25 ++ src/app/resources/map/map.qrc | 8 + src/app/resources/map/maplibre-gl.css | 1 + src/app/resources/map/maplibre-gl.js | 59 +++ src/app/resources/map/trajectory_map.html | 363 +++++++++++++++++++ src/controller/DatasetDetailTab.hpp | 3 +- src/core/model/detail/DetailPayloads.hpp | 28 ++ src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetRepository.cpp | 41 +++ src/data/api/ApiDatasetRepository.hpp | 3 + src/data/dto/TrajectoryDto.cpp | 111 ++++++ src/data/dto/TrajectoryDto.hpp | 29 ++ tests/CMakeLists.txt | 1 + tests/app/test_chart_strategy_registry.cpp | 23 ++ tests/data/test_async_repo_dispatch.cpp | 12 + tests/data/test_trajectory_dto.cpp | 179 +++++++++ tests/fixtures/dd/ert-trajectory-line.json | 29 ++ tests/fixtures/dd/ert-trajectory-rows.json | 46 +++ 25 files changed, 1552 insertions(+), 2 deletions(-) create mode 100644 src/app/panels/chart/LineChartView.cpp create mode 100644 src/app/panels/chart/LineChartView.hpp create mode 100644 src/app/panels/chart/TrajectoryMapView.cpp create mode 100644 src/app/panels/chart/TrajectoryMapView.hpp create mode 100644 src/app/panels/chart/TrajectoryStrategy.hpp create mode 100644 src/app/resources/map/map.qrc create mode 100644 src/app/resources/map/maplibre-gl.css create mode 100644 src/app/resources/map/maplibre-gl.js create mode 100644 src/app/resources/map/trajectory_map.html create mode 100644 src/data/dto/TrajectoryDto.cpp create mode 100644 src/data/dto/TrajectoryDto.hpp create mode 100644 tests/data/test_trajectory_dto.cpp create mode 100644 tests/fixtures/dd/ert-trajectory-line.json create mode 100644 tests/fixtures/dd/ert-trajectory-rows.json diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 89f92ab..12c4a11 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,10 @@ find_package(VTK REQUIRED COMPONENTS ) find_package(nlohmann_json CONFIG REQUIRED) find_package(Qt6 REQUIRED COMPONENTS Svg) +# trajectory 地图(QWebEngineView + Leaflet + 天地图)实现于 TrajectoryMapView.{hpp,cpp} + +# resources/map/。WebEngine 硬依赖模块 Qt6WebChannel / Qt6Positioning 已补装,恢复 find_package/link/ +# deploy 三处 + main.cpp 初始化,DetailViewFactory 的 WebMap 分支切回 TrajectoryMapView。 +find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebEngineQuick) add_executable(geopro_desktop WIN32 main.cpp @@ -31,7 +35,10 @@ add_executable(geopro_desktop WIN32 panels/chart/GridDataChartView.cpp panels/chart/DataTableView.cpp panels/chart/BarChartView.cpp + panels/chart/LineChartView.cpp + panels/chart/TrajectoryMapView.cpp panels/chart/DetailViewFactory.cpp + resources/map/map.qrc panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp @@ -54,6 +61,7 @@ target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtk target_link_libraries(geopro_desktop PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg + Qt6::WebEngineWidgets Qt6::WebEngineQuick ${VTK_LIBRARIES} ads::qt6advanceddocking qt6keychain @@ -94,4 +102,19 @@ if(WIN32) "$/../plugins/${_pl}" "$/${_pl}") endforeach() + + # ── Qt WebEngine 运行期部署(地图页签需)────────────────────────────────── + # WebEngine 子进程 QtWebEngineProcess.exe(Chromium 渲染进程宿主)须与 exe 同目录或在 PATH; + # 其依赖 resources/*.pak + icudtl.dat + v8 快照,以及 qtwebengine_locales/*.pak。dev-build 不跑 + # windeployqt,故显式拷贝。WebEngine DLL(Qt6WebEngineCore/Widgets/Quick、WebChannel、Positioning、 + # Qml、Quick、QuickWidgets 等)由上方 TARGET_RUNTIME_DLLS 步随链接依赖自动拷贝。 + set(_qt_bin "$") + add_custom_command(TARGET geopro_desktop POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_qt_bin}/QtWebEngineProcess.exe" "$" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_qt_bin}/../resources" "$/resources" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_qt_bin}/../translations/qtwebengine_locales" + "$/qtwebengine_locales") endif() diff --git a/src/app/main.cpp b/src/app/main.cpp index 646b4f8..41cdbab 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -61,6 +61,8 @@ #include #include +#include + #include #include #include @@ -87,6 +89,7 @@ #include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" +#include "panels/chart/TrajectoryStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -836,6 +839,12 @@ public: int main(int argc, char* argv[]) { + // Qt WebEngine(地图页签的 QWebEngineView):必须在 QApplication 构造前初始化, + // 且需启用跨上下文共享 OpenGL(QtWebEngine 与 QVTK 同进程共用 GL context,避免黑屏/崩溃)。 + // AA_ShareOpenGLContexts 须在 QApplication 之前设置;QtWebEngineQuick::initialize() 同样须前置。 + QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + QtWebEngineQuick::initialize(); + // 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。 // 必须在 QApplication 构造前设置。 QApplication::setHighDpiScaleFactorRoundingPolicy( @@ -914,6 +923,7 @@ int main(int argc, char* argv[]) chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); + chartRegistry.add(std::make_unique()); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index 7908b64..5185893 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -5,7 +5,9 @@ #include "panels/chart/BarChartView.hpp" #include "panels/chart/DataTableView.hpp" #include "panels/chart/GridDataChartView.hpp" +#include "panels/chart/LineChartView.hpp" #include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/TrajectoryMapView.hpp" namespace geopro::app { @@ -20,8 +22,12 @@ std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* case controller::ViewKind::Bar: return std::unique_ptr(new BarChartView(parent)); case controller::ViewKind::LineProfile: + return std::unique_ptr(new LineChartView(parent)); + case controller::ViewKind::WebMap: + // trajectory 地图:QWebEngineView + Leaflet + 天地图瓦片,电极经纬点画橙色空心圈并取景。 + return std::unique_ptr(new TrajectoryMapView(parent)); case controller::ViewKind::PolylineMap: - // 后续阶段补:LineProfile,PolylineMap(trajectory)。 + // 后续阶段补:PolylineMap。 throw std::runtime_error("makeDetailView: ViewKind not yet implemented"); } throw std::runtime_error("makeDetailView: unknown ViewKind"); diff --git a/src/app/panels/chart/LineChartView.cpp b/src/app/panels/chart/LineChartView.cpp new file mode 100644 index 0000000..2d8e868 --- /dev/null +++ b/src/app/panels/chart/LineChartView.cpp @@ -0,0 +1,380 @@ +#include "panels/chart/LineChartView.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 "Theme.hpp" +#include "panels/chart/ChartTheme.hpp" + +namespace geopro::app { + +namespace { + +// 类目轴刻度:把整数刻度位(0,1,2,…)映射为类目标签 "#1","#2",…(来自 categories)。 +// 非整数/越界刻度返回空标签(避免次刻度污染)。与 BarChartView::CategoryScaleDraw 同款。 +class CategoryScaleDraw : public QwtScaleDraw { +public: + explicit CategoryScaleDraw(std::vector labels) : labels_(std::move(labels)) { + enableComponent(QwtScaleDraw::Backbone, true); + enableComponent(QwtScaleDraw::Ticks, true); + } + QwtText label(double v) const override { + const double r = std::round(v); + if (std::abs(v - r) > 1e-6) return QwtText(); // 仅整数刻度出标签 + const int i = static_cast(r); + if (i < 0 || i >= static_cast(labels_.size())) return QwtText(); + return labels_[static_cast(i)]; + } + +private: + std::vector labels_; +}; + +QColor lineColor(const QString& hex) { + QColor c(hex); + return c.isValid() ? c : QColor(0x54, 0x70, 0xc6); // 回退 ECharts 蓝 +} + +} // namespace + +LineChartView::LineChartView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + // 左上水平 y 轴标题(ECharts 风格,对齐 BarChartView)。 + yTitle_ = new QLabel(this); + auto* titleRow = new QWidget(this); + auto* titleLay = new QHBoxLayout(titleRow); + titleLay->setContentsMargins(48, 6, 8, 0); // 左缩进对齐 y 轴上方 + titleLay->setSpacing(0); + titleLay->addWidget(yTitle_); + titleLay->addStretch(); + lay->addWidget(titleRow); + + plot_ = new QwtPlot(this); + plot_->setObjectName(QStringLiteral("trajLinePlotArea")); + plot_->enableAxis(QwtPlot::xBottom, true); + plot_->enableAxis(QwtPlot::yLeft, true); + + // x 轴标题「电极号」底部居中(setData 设文本)。 + plot_->setAxisTitle(QwtPlot::xBottom, QwtText()); + + // 仅横向(y)网格,弱化(与原版 ECharts 一致:仅水平刻度线)。 + auto* grid = new QwtPlotGrid(); + grid->enableX(false); + grid->enableY(true); + grid->enableXMin(false); + grid->enableYMin(false); + grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); + grid->attach(plot_); + + plot_->setMinimumSize(0, 0); + lay->addWidget(plot_, 1); + + // 鼠标 hover 追踪:虚线参考线 + 实心点 + 浮动框(data_ 地址稳定,setData 只改其内容)。 + hover_ = new LineHoverTip(plot_, &data_, this); + + // 主题:底色/轴字/网格按当前主题套一次 + 热切换。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, [this]() { + applyChartPlotTheme(plot_); + QPalette pal = yTitle_->palette(); + pal.setColor(QPalette::WindowText, + isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90)); + yTitle_->setPalette(pal); + }); +} + +LineChartView::~LineChartView() { + // 卸载并删除已挂折线,先于 QwtPlot autoDelete 触发(与 BarChartView 析构对称,避免双删)。 + clearCurve(); +} + +void LineChartView::clearCurve() { + if (curve_) { + curve_->detach(); + delete curve_; + curve_ = nullptr; + } +} + +void LineChartView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) return; // 坏/空 → 空态 + setData(payload.value()); +} + +void LineChartView::setData(const geopro::core::LinePayload& p) { + data_ = p; + clearCurve(); + + const int n = static_cast(p.categories.size()); + + // y 轴标题(左上水平 QLabel)。 + yTitle_->setText(p.yTitle); + QPalette tpal = yTitle_->palette(); + tpal.setColor(QPalette::WindowText, + isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90)); + yTitle_->setPalette(tpal); + + // 锁定两轴为固定刻度(关闭自动缩放),使 hover 时挂/卸标记的 replot 不会重新拟合 → + // 曲线不再随鼠标进入而上下漂移(FIX 3)。 + plot_->setAxisAutoScale(QwtPlot::xBottom, false); + plot_->setAxisAutoScale(QwtPlot::yLeft, false); + + // x 轴标题「电极号」+ 类目刻度 "#1".."#40"。 + plot_->setAxisTitle(QwtPlot::xBottom, p.xTitle); + plot_->setAxisScaleDraw(QwtPlot::xBottom, new CategoryScaleDraw(p.categories)); + plot_->setAxisScale(QwtPlot::xBottom, 0.0, n > 0 ? n - 1 : 0.0, 1.0); // 每类目一刻度 + plot_->setAxisMaxMinor(QwtPlot::xBottom, 0); + + // y 范围固定 0..max*1.1(从数据取上界,留 10% 余量;对齐原版 0–30 观感)。 + // 固定刻度后 hover 标记不再触发 y 轴重算。 + double yMax = 0.0; + for (double v : p.y) yMax = std::max(yMax, v); + plot_->setAxisScale(QwtPlot::yLeft, 0.0, yMax > 0 ? yMax * 1.1 : 1.0); + + if (p.y.empty()) { + plot_->replot(); + return; + } + + // 单条折线:电极号(0..n-1) → 高程。 + // + // 平滑必须在【数据坐标】里一次性算好,绝不能用 QwtPlotCurve::Fitted。原因(本 BUG 根因): + // QwtPlotCurve 的 Fitted 拟合器在【绘制时】对【已映射到画布像素】的点跑样条 + // (见 qwt_plot_curve.cpp 注释 “The curve fitter operates on the translated points + // ( = widget coordinates)”)。于是拟合结果取决于当时画布的像素几何;当首次 hover 触发 + // replot 时画布几何与首帧哪怕有一丁点差异,样条就在不同像素网格上重采样 → 曲线竖直方向 + // 细微漂移。锁轴并不能消除这一点(漂移源是像素级重拟合,不是自动缩放)。 + // 解法(QWT 官方建议:把拟合结果缓存进 series):在数据坐标里把样条展开成稠密折线, + // 作为静态 samples 交给普通 Lines 曲线。此后每次 replot 都映射同一条固定折线 → 首帧与 + // hover 帧像素完全一致,零漂移。Cardinal + ParameterUniform 与原 QwtSplineCurveFitter + // 内部一致,平滑观感不变。 + curve_ = new QwtPlotCurve(p.seriesName); + QVector nodes; + nodes.reserve(n); + for (int i = 0; i < n && i < static_cast(p.y.size()); ++i) + nodes.append(QPointF(i, p.y[static_cast(i)])); + + QVector samples = nodes; + if (p.smooth && nodes.size() > 2) { + QwtSplineLocal spline(QwtSplineLocal::Cardinal); + spline.setParametrization(QwtSplineParametrization::ParameterUniform); + // 展平容差取数据范围的极小比例 → 折线足够稠密、肉眼平滑;数据坐标空间,缩放无关。 + const double xSpan = nodes.isEmpty() ? 1.0 : (nodes.last().x() - nodes.first().x()); + const double tolerance = std::max(std::abs(xSpan), 1.0) / 2000.0; + const QPolygonF smooth = spline.polygon(QPolygonF(nodes), tolerance); + if (smooth.size() > 1) samples = QVector(smooth.begin(), smooth.end()); + } + curve_->setSamples(samples); + curve_->setStyle(QwtPlotCurve::Lines); + curve_->setPen(QPen(lineColor(p.color), 2)); + curve_->setRenderHint(QwtPlotItem::RenderAntialiased, true); + curve_->attach(plot_); + + plot_->replot(); +} + +// ── LineHoverOverlay / LineHoverTip:折线 hover 虚线参考线 + 实心点 + 浮动框 ────── + +namespace { + +// 从类目标签 "#N" 解析电极号;无前缀/非数字 → 回退 1-based 索引。 +int electrodeNoFromCategory(const std::vector& cats, int i) { + if (i >= 0 && i < static_cast(cats.size())) { + QString s = cats[static_cast(i)]; + if (s.startsWith(QLatin1Char('#'))) s = s.mid(1); + bool ok = false; + const int no = s.toInt(&ok); + if (ok) return no; + } + return i + 1; +} + +} // namespace + +// hover 叠层:画在 plot canvas 之上的 QwtWidgetOverlay。激活时用 plot 的 canvasMap +// 把数据坐标 (idx, yVal) 变换为像素,画一条垂直虚线参考线(满画布高)+ 曲线上实心点。 +// updateOverlay() 只重绘本叠层、【不】触碰 plot/曲线 → 曲线零漂移。颜色每帧按主题重算(廉价)。 +class LineHoverOverlay : public QwtWidgetOverlay { +public: + explicit LineHoverOverlay(QwtPlot* plot) + : QwtWidgetOverlay(plot->canvas()), plot_(plot) { + // 不用 mask(默认 MaskHint 在未重写 maskHint 时会把可见区域裁空 → 叠层不显示)。 + setMaskMode(QwtWidgetOverlay::NoMask); + } + + // 设置 hover 状态并重绘叠层(仅叠层,不 replot plot)。 + void setHover(bool active, int idx, double yVal, const QColor& dotColor) { + active_ = active; + idx_ = idx; + yVal_ = yVal; + dotColor_ = dotColor; + updateOverlay(); + } + + void clearHover() { + if (!active_) return; + active_ = false; + updateOverlay(); + } + +protected: + void drawOverlay(QPainter* painter) const override { + if (!active_ || !plot_) return; + + const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom); + const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft); + const double xPix = xMap.transform(idx_); + const double yPix = yMap.transform(yVal_); + + // 垂直虚线参考线:满画布高(主题感知颜色)。 + const QColor lineCol = + isDarkTheme() ? tokenColor("border/strong") : QColor(150, 170, 210); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(QPen(lineCol, 1.0, Qt::DashLine)); + painter->drawLine(QPointF(xPix, 0.0), QPointF(xPix, static_cast(height()))); + + // 曲线上实心圆点(系列色填充 + 同色描边,直径 ~8px)。 + QColor dot = dotColor_.isValid() ? dotColor_ : QColor(0x54, 0x70, 0xc6); + painter->setPen(QPen(dot, 1.0)); + painter->setBrush(QBrush(dot)); + painter->drawEllipse(QPointF(xPix, yPix), 4.0, 4.0); + } + +private: + QwtPlot* plot_; + bool active_ = false; + int idx_ = 0; + double yVal_ = 0.0; + QColor dotColor_; +}; + +LineHoverTip::LineHoverTip(QwtPlot* plot, const geopro::core::LinePayload* data, QObject* parent) + : QObject(parent), plot_(plot), data_(data) { + if (plot_ && plot_->canvas()) { + // hover(无按键)需开启鼠标跟踪,否则默认仅按键按下时才收到 MouseMove。 + plot_->canvas()->setMouseTracking(true); + plot_->canvas()->installEventFilter(this); + // 叠层 parent 为 canvas → 随 canvas 尺寸/位置自动跟随(QwtWidgetOverlay 处理 resize)。 + overlay_ = new LineHoverOverlay(plot_); + overlay_->show(); + } +} + +LineHoverTip::~LineHoverTip() { + // tip_ 是无父顶层 widget,须显式删除。overlay_ parent 为 canvas,随其析构(无需手删)。 + delete tip_; + tip_ = nullptr; +} + +void LineHoverTip::hideHover() { + // 叠层置非激活并重绘(仅叠层,不 replot plot);隐藏浮动框。 + if (overlay_) overlay_->clearHover(); + if (tip_) tip_->hide(); +} + +void LineHoverTip::showTip(const QString& html, const QPoint& globalPos) { + // 自定义浮动框:Qt::ToolTip 顶层 QLabel(富文本)。每次 MouseMove 必更新文本与位置, + // 不经 QToolTip 去重逻辑 → 提示稳定显示(FIX 4)。主题感知配色(白框/暗框)。 + if (!tip_) { + tip_ = new QLabel(nullptr, Qt::ToolTip | Qt::FramelessWindowHint); + tip_->setObjectName(QStringLiteral("lineHoverTip")); + tip_->setTextFormat(Qt::RichText); + tip_->setMargin(8); + tip_->setAttribute(Qt::WA_ShowWithoutActivating, true); + } + const bool dark = isDarkTheme(); + const QColor bg = dark ? tokenColor("bg/panel") : QColor(0xFF, 0xFF, 0xFF); + const QColor border = dark ? tokenColor("border/strong") : QColor(0xE3, 0xE6, 0xEB); + const QColor fg = dark ? tokenColor("text/primary") : QColor(0x27, 0x2C, 0x35); + tip_->setStyleSheet(QStringLiteral( + "QLabel#lineHoverTip{background:%1;color:%2;border:1px solid %3;" + "border-radius:4px;}") + .arg(bg.name(), fg.name(), border.name())); + tip_->setText(html); + tip_->adjustSize(); + + // 在光标右下偏移摆放;越右/下边界则翻向左/上,避免被屏幕裁切。 + QPoint pos = globalPos + QPoint(14, 16); + if (QScreen* scr = QGuiApplication::screenAt(globalPos)) { + const QRect g = scr->availableGeometry(); + if (pos.x() + tip_->width() > g.right()) pos.setX(globalPos.x() - tip_->width() - 14); + if (pos.y() + tip_->height() > g.bottom()) pos.setY(globalPos.y() - tip_->height() - 16); + } + tip_->move(pos); + if (!tip_->isVisible()) tip_->show(); +} + +bool LineHoverTip::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + if (ev->type() == QEvent::Leave) { + hideHover(); + return false; + } + if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev); + + auto* me = static_cast(ev); + // 拖动中(有按键)或无数据 → 不弹提示。 + if (me->buttons() != Qt::NoButton || !data_ || data_->y.empty()) { + hideHover(); + return false; + } + + const auto& ys = data_->y; + const int n = static_cast(ys.size()); + + // 按 x 吸附到最近电极索引(类目位于整数 x = 0..n-1)。 + const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom); + const double xVal = xMap.invTransform(me->position().x()); + int idx = static_cast(std::lround(xVal)); + idx = std::clamp(idx, 0, n - 1); + + const double yVal = ys[static_cast(idx)]; + const int electrodeNo = electrodeNoFromCategory(data_->categories, idx); + + // 实心点系列色(虚线参考线颜色在 overlay 内按主题重算)。 + QColor dotCol(data_->color); + if (!dotCol.isValid()) dotCol = QColor(0x54, 0x70, 0xc6); + + // 关键:把虚线 + 实心点交给叠层重绘(仅叠层重绘,【绝不】replot plot → 曲线零漂移)。 + if (overlay_) overlay_->setHover(true, idx, yVal, dotCol); + + // 浮动框:#<电极号> 表头 + 系列点·标签·值(高程保留 3 位小数)。系列点用系列色圆点。 + const QString tip = QStringLiteral("#%1
    高程(m)" + "  %3") + .arg(electrodeNo) + .arg(dotCol.name()) + .arg(yVal, 0, 'f', 3); + showTip(tip, me->globalPosition().toPoint()); + + return false; // 不消费,保留其它过滤器链路 +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/LineChartView.hpp b/src/app/panels/chart/LineChartView.hpp new file mode 100644 index 0000000..7fa1009 --- /dev/null +++ b/src/app/panels/chart/LineChartView.hpp @@ -0,0 +1,78 @@ +#pragma once +#include +#include + +#include "model/detail/DetailPayloads.hpp" +#include "panels/chart/IDetailView.hpp" + +class QEvent; +class QLabel; +class QPoint; +class QwtPlot; +class QwtPlotCurve; + +namespace geopro::app { + +class LineHoverTip; +class LineHoverOverlay; + +// 折线图视图(dd_trajectory_data 高程页签): +// QwtPlot + QwtPlotCurve(单条平滑折线,Fitted + QwtSplineCurveFitter)。 +// x 轴类目标签 "#1".."#40"(自定义 QwtScaleDraw,复用 BarChartView 同款思路); +// x 轴标题「电极号」(底部居中);y 轴标题「高程」用左上水平 QLabel(ECharts 风格); +// 线色 #5470c6(ECharts 默认蓝,数据色,两主题一致);无图例(单系列)。 +// 背景/轴字/网格随主题(ChartTheme / ThemeManager)。 +class LineChartView : public QWidget, public IDetailView { + Q_OBJECT +public: + explicit LineChartView(QWidget* parent = nullptr); + ~LineChartView() override; + + void setData(const geopro::core::LinePayload& p); + + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; // 坏/空 variant → 空态不崩 + +private: + void clearCurve(); // 卸载并删除已挂折线(避免 QwtPlot autoDelete 双删) + + geopro::core::LinePayload data_; + QwtPlot* plot_; + QLabel* yTitle_; // 左上水平 y 轴标题(ECharts 风格) + QwtPlotCurve* curve_ = nullptr; // 当前挂载的折线(已 attach;卸载时 detach+delete) + LineHoverTip* hover_ = nullptr; // 鼠标 hover 追踪器(虚线参考线 + 实心点 + 浮动框) +}; + +// 折线 hover 追踪器(对齐原版 ECharts 高程折线 hover):监听画布鼠标移动(无按键时), +// 按 x 吸附到最近电极索引,绘制:垂直虚线参考线 + 曲线上该点实心圆点 + +// 自定义浮动框(#<电极号> / ● 高程(m): <值,3 位>)。 +// +// 关键(DEFINITIVE FIX):虚线参考线 + 实心点用 QwtWidgetOverlay 画在 canvas 之上, +// hover 路径【绝不】调用 plot_->replot()。此前用 QwtPlotMarker + 每次 MouseMove replot, +// 那次 replot 重跑 plot 布局/updateAxes,使曲线随 hover 标记的挂/卸而上下漂移(锁轴 + 数据 +// 坐标样条都无法阻止——漂移源是 replot/marker-attach 本身)。改为 overlay 后曲线在 setData +// 里只渲染一次、hover 全程不再 replot → 曲线不可能移动。 +// 浮动框仍用独立的 Qt::ToolTip 顶层 QLabel(富文本):QToolTip 会去重抑制相同/相近位置的重复 +// showText(FIX 4),自定义 widget 每次 MouseMove 必更新位置/内容。 +// 鼠标离开/越界时隐藏。不消费事件(镜像 ScatterHoverTip)。 +class LineHoverTip : public QObject { + Q_OBJECT +public: + LineHoverTip(QwtPlot* plot, const geopro::core::LinePayload* data, QObject* parent = nullptr); + ~LineHoverTip() override; + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + void hideHover(); // overlay 置非激活 + 隐藏浮动框(无 replot) + // 在 globalPos 旁显示富文本浮动框(按需创建、主题感知样式;每次必更新)。 + void showTip(const QString& html, const QPoint& globalPos); + + QwtPlot* plot_; + const geopro::core::LinePayload* data_; // 由 LineChartView 持有(data_ 成员,地址稳定);只读不拥有 + LineHoverOverlay* overlay_ = nullptr; // 画布之上的 hover 叠层(虚线 + 实心点;不触发 plot replot) + QLabel* tip_ = nullptr; // 自定义浮动框(Qt::ToolTip 顶层 QLabel,富文本;替代 QToolTip) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/TrajectoryMapView.cpp b/src/app/panels/chart/TrajectoryMapView.cpp new file mode 100644 index 0000000..8c09deb --- /dev/null +++ b/src/app/panels/chart/TrajectoryMapView.cpp @@ -0,0 +1,58 @@ +#include "panels/chart/TrajectoryMapView.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { + +// MapPayload → 紧凑 JSON 数组串 "[{\"no\":1,\"lon\":..,\"lat\":..}, ...]"(供页面 setPoints)。 +QString pointsToJson(const geopro::core::MapPayload& m) { + QJsonArray arr; + for (const auto& p : m.points) { + QJsonObject o; + o.insert(QStringLiteral("no"), p.electrodeNo); + o.insert(QStringLiteral("lon"), p.lon); + o.insert(QStringLiteral("lat"), p.lat); + arr.append(o); + } + return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +} // namespace + +TrajectoryMapView::TrajectoryMapView(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + view_ = new QWebEngineView(this); + lay->addWidget(view_, 1); + + // 页面加载完成 → 标记就绪并注入待推送的点(载荷可能先到)。 + QObject::connect(view_, &QWebEngineView::loadFinished, this, [this](bool ok) { + pageLoaded_ = ok; + if (ok) injectPoints(); + }); + + view_->load(QUrl(QStringLiteral("qrc:/map/trajectory_map.html"))); +} + +void TrajectoryMapView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) return; // 坏/空 → 忽略 + pendingJson_ = pointsToJson(payload.value()); + if (pageLoaded_) injectPoints(); // 页面已就绪则立即注入,否则待 loadFinished +} + +void TrajectoryMapView::injectPoints() { + if (!pageLoaded_ || pendingJson_.isEmpty()) return; + // 无 QWebChannel:单向经 runJavaScript 调页面侧 setPoints(arr)。 + view_->page()->runJavaScript(QStringLiteral("setPoints(%1)").arg(pendingJson_)); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/TrajectoryMapView.hpp b/src/app/panels/chart/TrajectoryMapView.hpp new file mode 100644 index 0000000..6fa6d2c --- /dev/null +++ b/src/app/panels/chart/TrajectoryMapView.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include + +#include "model/detail/DetailPayloads.hpp" +#include "panels/chart/IDetailView.hpp" + +class QWebEngineView; + +namespace geopro::app { + +// 轨迹「地图」页签视图(dd_trajectory_data 地图页签): +// QWebEngineView 加载本地 MapLibre GL 地图页(qrc:/map/trajectory_map.html),WebGL 渲染, +// 底图用天地图 WMTS 栅格瓦片 + 注记瓦片,含街道/卫星/地形三套底图切换(仅瓦片联网, +// MapLibre 库本地内置于 qrc)。 +// setPayload 解包 MapPayload(电极经纬点),构造 JSON 数组后经 page()->runJavaScript() +// 调页面侧 setPoints(arr) 画实心橙点 + 连线 + 起点标记 + 取景;无 QWebChannel(未安装),单向推数据。 +// 载荷可能早于页面 loadFinished 到达 → 暂存,待 loadFinished 后再注入(幂等)。 +class TrajectoryMapView : public QWidget, public IDetailView { + Q_OBJECT +public: + explicit TrajectoryMapView(QWidget* parent = nullptr); + + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; // 坏/空 variant → 忽略 + +private: + void injectPoints(); // 把 pendingJson_ 推入页面(仅页面就绪时) + + QWebEngineView* view_ = nullptr; + QString pendingJson_; // 最近一次载荷的 JS 数组串("[]" 表示无点) + bool pageLoaded_ = false; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/TrajectoryStrategy.hpp b/src/app/panels/chart/TrajectoryStrategy.hpp new file mode 100644 index 0000000..8b63b9a --- /dev/null +++ b/src/app/panels/chart/TrajectoryStrategy.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include "IDatasetChartStrategy.hpp" // geopro::controller +namespace geopro::app { + +// ERT 电极坐标轨迹(dd_trajectory_data)策略:地图 / 列表 / 高程 三页签(页签序对齐原版)。 +// 地图(默认/首位)= WebMap(QWebEngineView + Leaflet + 天地图瓦片;traj.map 产 MapPayload, +// 端点 trajectory/line,electrodeCoordinate → 经纬点)。 +// 列表 = Table(懒加载,gridHeaderDisplay+rowList → TablePayload)。 +// 高程 = LineProfile(平滑折线,electrodeNo→elevation)。 +// 列表与高程同端点 trajectory/rows,loaderKey 不同(traj.rows 产 TablePayload / traj.elev 产 LinePayload)。 +struct TrajectoryStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_trajectory_data"; } + std::vector tabs() const override { + return { + {QStringLiteral("地图"), controller::ViewKind::WebMap, + QStringLiteral("traj.map"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("列表"), controller::ViewKind::Table, + QStringLiteral("traj.rows"), /*lazy*/ true, /*paginated*/ false}, + {QStringLiteral("高程"), controller::ViewKind::LineProfile, + QStringLiteral("traj.elev"), /*lazy*/ false, /*paginated*/ false}, + }; + } +}; +} // namespace geopro::app diff --git a/src/app/resources/map/map.qrc b/src/app/resources/map/map.qrc new file mode 100644 index 0000000..a89ab2d --- /dev/null +++ b/src/app/resources/map/map.qrc @@ -0,0 +1,8 @@ + + + + trajectory_map.html + maplibre-gl.js + maplibre-gl.css + + diff --git a/src/app/resources/map/maplibre-gl.css b/src/app/resources/map/maplibre-gl.css new file mode 100644 index 0000000..6ceb5de --- /dev/null +++ b/src/app/resources/map/maplibre-gl.css @@ -0,0 +1 @@ +.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999} \ No newline at end of file diff --git a/src/app/resources/map/maplibre-gl.js b/src/app/resources/map/maplibre-gl.js new file mode 100644 index 0000000..2caee97 --- /dev/null +++ b/src/app/resources/map/maplibre-gl.js @@ -0,0 +1,59 @@ +/** + * MapLibre GL JS + * @license 3-Clause BSD. Full text of license: https://github.com/maplibre/maplibre-gl-js/blob/v4.7.1/LICENSE.txt + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : +typeof define === 'function' && define.amd ? define(factory) : +(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.maplibregl = factory()); +})(this, (function () { 'use strict'; + +/* eslint-disable */ + +var maplibregl = {}; +var modules = {}; +function define(moduleName, _dependencies, moduleFactory) { + modules[moduleName] = moduleFactory; + + // to get the list of modules see generated dist/maplibre-gl-dev.js file (look for `define(` calls) + if (moduleName !== 'index') { + return; + } + + // we assume that when an index module is initializing then other modules are loaded already + var workerBundleString = 'var sharedModule = {}; (' + modules.shared + ')(sharedModule); (' + modules.worker + ')(sharedModule);' + + var sharedModule = {}; + // the order of arguments of a module factory depends on rollup (it decides who is whose dependency) + // to check the correct order, see dist/maplibre-gl-dev.js file (look for `define(` calls) + // we assume that for our 3 chunks it will generate 3 modules and their order is predefined like the following + modules.shared(sharedModule); + modules.index(maplibregl, sharedModule); + + if (typeof window !== 'undefined') { + maplibregl.setWorkerUrl(window.URL.createObjectURL(new Blob([workerBundleString], { type: 'text/javascript' }))); + } + + return maplibregl; +}; + + + +define("shared",["exports"],(function(t){"use strict";function e(t,e,r,n){return new(r||(r=Promise))((function(i,s){function a(t){try{l(n.next(t));}catch(t){s(t);}}function o(t){try{l(n.throw(t));}catch(t){s(t);}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof r?e:new r((function(t){t(e);}))).then(a,o);}l((n=n.apply(t,e||[])).next());}))}function r(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}"function"==typeof SuppressedError&&SuppressedError;var n=i;function i(t,e){this.x=t,this.y=e;}i.prototype={clone:function(){return new i(this.x,this.y)},add:function(t){return this.clone()._add(t)},sub:function(t){return this.clone()._sub(t)},multByPoint:function(t){return this.clone()._multByPoint(t)},divByPoint:function(t){return this.clone()._divByPoint(t)},mult:function(t){return this.clone()._mult(t)},div:function(t){return this.clone()._div(t)},rotate:function(t){return this.clone()._rotate(t)},rotateAround:function(t,e){return this.clone()._rotateAround(t,e)},matMult:function(t){return this.clone()._matMult(t)},unit:function(){return this.clone()._unit()},perp:function(){return this.clone()._perp()},round:function(){return this.clone()._round()},mag:function(){return Math.sqrt(this.x*this.x+this.y*this.y)},equals:function(t){return this.x===t.x&&this.y===t.y},dist:function(t){return Math.sqrt(this.distSqr(t))},distSqr:function(t){var e=t.x-this.x,r=t.y-this.y;return e*e+r*r},angle:function(){return Math.atan2(this.y,this.x)},angleTo:function(t){return Math.atan2(this.y-t.y,this.x-t.x)},angleWith:function(t){return this.angleWithSep(t.x,t.y)},angleWithSep:function(t,e){return Math.atan2(this.x*e-this.y*t,this.x*t+this.y*e)},_matMult:function(t){var e=t[2]*this.x+t[3]*this.y;return this.x=t[0]*this.x+t[1]*this.y,this.y=e,this},_add:function(t){return this.x+=t.x,this.y+=t.y,this},_sub:function(t){return this.x-=t.x,this.y-=t.y,this},_mult:function(t){return this.x*=t,this.y*=t,this},_div:function(t){return this.x/=t,this.y/=t,this},_multByPoint:function(t){return this.x*=t.x,this.y*=t.y,this},_divByPoint:function(t){return this.x/=t.x,this.y/=t.y,this},_unit:function(){return this._div(this.mag()),this},_perp:function(){var t=this.y;return this.y=this.x,this.x=-t,this},_rotate:function(t){var e=Math.cos(t),r=Math.sin(t),n=r*this.x+e*this.y;return this.x=e*this.x-r*this.y,this.y=n,this},_rotateAround:function(t,e){var r=Math.cos(t),n=Math.sin(t),i=e.y+n*(this.x-e.x)+r*(this.y-e.y);return this.x=e.x+r*(this.x-e.x)-n*(this.y-e.y),this.y=i,this},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}},i.convert=function(t){return t instanceof i?t:Array.isArray(t)?new i(t[0],t[1]):t};var s=r(n),a=o;function o(t,e,r,n){this.cx=3*t,this.bx=3*(r-t)-this.cx,this.ax=1-this.cx-this.bx,this.cy=3*e,this.by=3*(n-e)-this.cy,this.ay=1-this.cy-this.by,this.p1x=t,this.p1y=e,this.p2x=r,this.p2y=n;}o.prototype={sampleCurveX:function(t){return ((this.ax*t+this.bx)*t+this.cx)*t},sampleCurveY:function(t){return ((this.ay*t+this.by)*t+this.cy)*t},sampleCurveDerivativeX:function(t){return (3*this.ax*t+2*this.bx)*t+this.cx},solveCurveX:function(t,e){if(void 0===e&&(e=1e-6),t<0)return 0;if(t>1)return 1;for(var r=t,n=0;n<8;n++){var i=this.sampleCurveX(r)-t;if(Math.abs(i)i?a=r:o=r,r=.5*(o-a)+a;return r},solve:function(t,e){return this.sampleCurveY(this.solveCurveX(t,e))}};var l=r(a);let u,c;function h(){return null==u&&(u="undefined"!=typeof OffscreenCanvas&&new OffscreenCanvas(1,1).getContext("2d")&&"function"==typeof createImageBitmap),u}function p(){if(null==c&&(c=!1,h())){const t=5,e=new OffscreenCanvas(t,t).getContext("2d",{willReadFrequently:!0});if(e){for(let r=0;ri.solve(t)}const d=f(.25,.1,.25,1);function y(t,e,r){return Math.min(r,Math.max(e,t))}function m(t,e,r){const n=r-e,i=((t-e)%n+n)%n+e;return i===e?r:i}function g(t,...e){for(const r of e)for(const e in r)t[e]=r[e];return t}let x=1;function v(t,e,r){const n={};for(const r in t)n[r]=e.call(this,t[r],r,t);return n}function b(t,e,r){const n={};for(const r in t)e.call(this,t[r],r,t)&&(n[r]=t[r]);return n}function w(t){return Array.isArray(t)?t.map(w):"object"==typeof t&&t?v(t,w):t}const _={};function A(t){_[t]||("undefined"!=typeof console&&console.warn(t),_[t]=!0);}function S(t,e,r){return (r.y-t.y)*(e.x-t.x)>(e.y-t.y)*(r.x-t.x)}function k(t){return "undefined"!=typeof WorkerGlobalScope&&void 0!==t&&t instanceof WorkerGlobalScope}let M=null;function I(t){return "undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap}const z="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII=";function P(t,r,n,i,s){return e(this,void 0,void 0,(function*(){if("undefined"==typeof VideoFrame)throw new Error("VideoFrame not supported");const e=new VideoFrame(t,{timestamp:0});try{const a=null==e?void 0:e.format;if(!a||!a.startsWith("BGR")&&!a.startsWith("RGB"))throw new Error(`Unrecognized format ${a}`);const o=a.startsWith("BGR"),l=new Uint8ClampedArray(i*s*4);if(yield e.copyTo(l,function(t,e,r,n,i){const s=4*Math.max(-e,0),a=(Math.max(0,r)-r)*n*4+s,o=4*n,l=Math.max(0,e),u=Math.max(0,r);return {rect:{x:l,y:u,width:Math.min(t.width,e+n)-l,height:Math.min(t.height,r+i)-u},layout:[{offset:a,stride:o}]}}(t,r,n,i,s)),o)for(let t=0;tk(self)?self.worker&&self.worker.referrer:("blob:"===window.location.protocol?window.parent:window).location.href,O=function(t,r){if(/:\/\//.test(t.url)&&!/^https?:|^file:/.test(t.url)){const e=T(t.url);if(e)return e(t,r);if(k(self)&&self.worker&&self.worker.actor)return self.worker.actor.sendAsync({type:"GR",data:t,targetMapId:$},r)}if(!(/^file:/.test(n=t.url)||/^file:/.test(D())&&!/^\w+:/.test(n))){if(fetch&&Request&&AbortController&&Object.prototype.hasOwnProperty.call(Request.prototype,"signal"))return function(t,r){return e(this,void 0,void 0,(function*(){const e=new Request(t.url,{method:t.method||"GET",body:t.body,credentials:t.credentials,headers:t.headers,cache:t.cache,referrer:D(),signal:r.signal});"json"!==t.type||e.headers.has("Accept")||e.headers.set("Accept","application/json");const n=yield fetch(e);if(!n.ok){const e=yield n.blob();throw new L(n.status,n.statusText,t.url,e)}let i;i="arrayBuffer"===t.type||"image"===t.type?n.arrayBuffer():"json"===t.type?n.json():n.text();const s=yield i;if(r.signal.aborted)throw E();return {data:s,cacheControl:n.headers.get("Cache-Control"),expires:n.headers.get("Expires")}}))}(t,r);if(k(self)&&self.worker&&self.worker.actor)return self.worker.actor.sendAsync({type:"GR",data:t,mustQueue:!0,targetMapId:$},r)}var n;return function(t,e){return new Promise(((r,n)=>{var i;const s=new XMLHttpRequest;s.open(t.method||"GET",t.url,!0),"arrayBuffer"!==t.type&&"image"!==t.type||(s.responseType="arraybuffer");for(const e in t.headers)s.setRequestHeader(e,t.headers[e]);"json"===t.type&&(s.responseType="text",(null===(i=t.headers)||void 0===i?void 0:i.Accept)||s.setRequestHeader("Accept","application/json")),s.withCredentials="include"===t.credentials,s.onerror=()=>{n(new Error(s.statusText));},s.onload=()=>{if(!e.signal.aborted)if((s.status>=200&&s.status<300||0===s.status)&&null!==s.response){let e=s.response;if("json"===t.type)try{e=JSON.parse(s.response);}catch(t){return void n(t)}r({data:e,cacheControl:s.getResponseHeader("Cache-Control"),expires:s.getResponseHeader("Expires")});}else {const e=new Blob([s.response],{type:s.getResponseHeader("Content-Type")});n(new L(s.status,s.statusText,t.url,e));}},e.signal.addEventListener("abort",(()=>{s.abort(),n(E());})),s.send(t.body);}))}(t,r)};function j(t){if(!t||t.indexOf("://")<=0||0===t.indexOf("data:image/")||0===t.indexOf("blob:"))return !0;const e=new URL(t),r=window.location;return e.protocol===r.protocol&&e.host===r.host}function R(t,e,r){r[t]&&-1!==r[t].indexOf(e)||(r[t]=r[t]||[],r[t].push(e));}function U(t,e,r){if(r&&r[t]){const n=r[t].indexOf(e);-1!==n&&r[t].splice(n,1);}}class q{constructor(t,e={}){g(this,e),this.type=t;}}class N extends q{constructor(t,e={}){super("error",g({error:t},e));}}class Z{on(t,e){return this._listeners=this._listeners||{},R(t,e,this._listeners),this}off(t,e){return U(t,e,this._listeners),U(t,e,this._oneTimeListeners),this}once(t,e){return e?(this._oneTimeListeners=this._oneTimeListeners||{},R(t,e,this._oneTimeListeners),this):new Promise((e=>this.once(t,e)))}fire(t,e){"string"==typeof t&&(t=new q(t,e||{}));const r=t.type;if(this.listens(r)){t.target=this;const e=this._listeners&&this._listeners[r]?this._listeners[r].slice():[];for(const r of e)r.call(this,t);const n=this._oneTimeListeners&&this._oneTimeListeners[r]?this._oneTimeListeners[r].slice():[];for(const e of n)U(r,e,this._oneTimeListeners),e.call(this,t);const i=this._eventedParent;i&&(g(t,"function"==typeof this._eventedParentData?this._eventedParentData():this._eventedParentData),i.fire(t));}else t instanceof N&&console.error(t.error);return this}listens(t){return this._listeners&&this._listeners[t]&&this._listeners[t].length>0||this._oneTimeListeners&&this._oneTimeListeners[t]&&this._oneTimeListeners[t].length>0||this._eventedParent&&this._eventedParent.listens(t)}setEventedParent(t,e){return this._eventedParent=t,this._eventedParentData=e,this}}var G={$version:8,$root:{version:{required:!0,type:"enum",values:[8]},name:{type:"string"},metadata:{type:"*"},center:{type:"array",value:"number"},zoom:{type:"number"},bearing:{type:"number",default:0,period:360,units:"degrees"},pitch:{type:"number",default:0,units:"degrees"},light:{type:"light"},sky:{type:"sky"},projection:{type:"projection"},terrain:{type:"terrain"},sources:{required:!0,type:"sources"},sprite:{type:"sprite"},glyphs:{type:"string"},transition:{type:"transition"},layers:{required:!0,type:"array",value:"layer"}},sources:{"*":{type:"source"}},source:["source_vector","source_raster","source_raster_dem","source_geojson","source_video","source_image"],source_vector:{type:{required:!0,type:"enum",values:{vector:{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},scheme:{type:"enum",values:{xyz:{},tms:{}},default:"xyz"},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},attribution:{type:"string"},promoteId:{type:"promoteId"},volatile:{type:"boolean",default:!1},"*":{type:"*"}},source_raster:{type:{required:!0,type:"enum",values:{raster:{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},tileSize:{type:"number",default:512,units:"pixels"},scheme:{type:"enum",values:{xyz:{},tms:{}},default:"xyz"},attribution:{type:"string"},volatile:{type:"boolean",default:!1},"*":{type:"*"}},source_raster_dem:{type:{required:!0,type:"enum",values:{"raster-dem":{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},tileSize:{type:"number",default:512,units:"pixels"},attribution:{type:"string"},encoding:{type:"enum",values:{terrarium:{},mapbox:{},custom:{}},default:"mapbox"},redFactor:{type:"number",default:1},blueFactor:{type:"number",default:1},greenFactor:{type:"number",default:1},baseShift:{type:"number",default:0},volatile:{type:"boolean",default:!1},"*":{type:"*"}},source_geojson:{type:{required:!0,type:"enum",values:{geojson:{}}},data:{required:!0,type:"*"},maxzoom:{type:"number",default:18},attribution:{type:"string"},buffer:{type:"number",default:128,maximum:512,minimum:0},filter:{type:"*"},tolerance:{type:"number",default:.375},cluster:{type:"boolean",default:!1},clusterRadius:{type:"number",default:50,minimum:0},clusterMaxZoom:{type:"number"},clusterMinPoints:{type:"number"},clusterProperties:{type:"*"},lineMetrics:{type:"boolean",default:!1},generateId:{type:"boolean",default:!1},promoteId:{type:"promoteId"}},source_video:{type:{required:!0,type:"enum",values:{video:{}}},urls:{required:!0,type:"array",value:"string"},coordinates:{required:!0,type:"array",length:4,value:{type:"array",length:2,value:"number"}}},source_image:{type:{required:!0,type:"enum",values:{image:{}}},url:{required:!0,type:"string"},coordinates:{required:!0,type:"array",length:4,value:{type:"array",length:2,value:"number"}}},layer:{id:{type:"string",required:!0},type:{type:"enum",values:{fill:{},line:{},symbol:{},circle:{},heatmap:{},"fill-extrusion":{},raster:{},hillshade:{},background:{}},required:!0},metadata:{type:"*"},source:{type:"string"},"source-layer":{type:"string"},minzoom:{type:"number",minimum:0,maximum:24},maxzoom:{type:"number",minimum:0,maximum:24},filter:{type:"filter"},layout:{type:"layout"},paint:{type:"paint"}},layout:["layout_fill","layout_line","layout_circle","layout_heatmap","layout_fill-extrusion","layout_symbol","layout_raster","layout_hillshade","layout_background"],layout_background:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_fill:{"fill-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_circle:{"circle-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_heatmap:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},"layout_fill-extrusion":{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_line:{"line-cap":{type:"enum",values:{butt:{},round:{},square:{}},default:"butt",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"line-join":{type:"enum",values:{bevel:{},round:{},miter:{}},default:"miter",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"line-miter-limit":{type:"number",default:2,requires:[{"line-join":"miter"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"line-round-limit":{type:"number",default:1.05,requires:[{"line-join":"round"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"line-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_symbol:{"symbol-placement":{type:"enum",values:{point:{},line:{},"line-center":{}},default:"point",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"symbol-spacing":{type:"number",default:250,minimum:1,units:"pixels",requires:[{"symbol-placement":"line"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"symbol-avoid-edges":{type:"boolean",default:!1,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"symbol-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"symbol-z-order":{type:"enum",values:{auto:{},"viewport-y":{},source:{}},default:"auto",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-allow-overlap":{type:"boolean",default:!1,requires:["icon-image",{"!":"icon-overlap"}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-overlap":{type:"enum",values:{never:{},always:{},cooperative:{}},requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-ignore-placement":{type:"boolean",default:!1,requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-optional":{type:"boolean",default:!1,requires:["icon-image","text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-rotation-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-size":{type:"number",default:1,minimum:0,units:"factor of the original icon size",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-text-fit":{type:"enum",values:{none:{},width:{},height:{},both:{}},default:"none",requires:["icon-image","text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-text-fit-padding":{type:"array",value:"number",length:4,default:[0,0,0,0],units:"pixels",requires:["icon-image","text-field",{"icon-text-fit":["both","width","height"]}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"icon-image":{type:"resolvedImage",tokens:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-rotate":{type:"number",default:0,period:360,units:"degrees",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-padding":{type:"padding",default:[2],units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-keep-upright":{type:"boolean",default:!1,requires:["icon-image",{"icon-rotation-alignment":"map"},{"symbol-placement":["line","line-center"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-offset":{type:"array",value:"number",length:2,default:[0,0],requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-anchor":{type:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},default:"center",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-pitch-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-pitch-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-rotation-alignment":{type:"enum",values:{map:{},viewport:{},"viewport-glyph":{},auto:{}},default:"auto",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-field":{type:"formatted",default:"",tokens:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-font":{type:"array",value:"string",default:["Open Sans Regular","Arial Unicode MS Regular"],requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-size":{type:"number",default:16,minimum:0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-max-width":{type:"number",default:10,minimum:0,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-line-height":{type:"number",default:1.2,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-letter-spacing":{type:"number",default:0,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-justify":{type:"enum",values:{auto:{},left:{},center:{},right:{}},default:"center",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-radial-offset":{type:"number",units:"ems",default:0,requires:["text-field"],"property-type":"data-driven",expression:{interpolated:!0,parameters:["zoom","feature"]}},"text-variable-anchor":{type:"array",value:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-variable-anchor-offset":{type:"variableAnchorOffsetCollection",requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-anchor":{type:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},default:"center",requires:["text-field",{"!":"text-variable-anchor"}],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-max-angle":{type:"number",default:45,units:"degrees",requires:["text-field",{"symbol-placement":["line","line-center"]}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-writing-mode":{type:"array",value:"enum",values:{horizontal:{},vertical:{}},requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-rotate":{type:"number",default:0,period:360,units:"degrees",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-padding":{type:"number",default:2,minimum:0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-keep-upright":{type:"boolean",default:!0,requires:["text-field",{"text-rotation-alignment":"map"},{"symbol-placement":["line","line-center"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-transform":{type:"enum",values:{none:{},uppercase:{},lowercase:{}},default:"none",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-offset":{type:"array",value:"number",units:"ems",length:2,default:[0,0],requires:["text-field",{"!":"text-radial-offset"}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-allow-overlap":{type:"boolean",default:!1,requires:["text-field",{"!":"text-overlap"}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-overlap":{type:"enum",values:{never:{},always:{},cooperative:{}},requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-ignore-placement":{type:"boolean",default:!1,requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-optional":{type:"boolean",default:!1,requires:["text-field","icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_raster:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},layout_hillshade:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible","property-type":"constant"}},filter:{type:"array",value:"*"},filter_operator:{type:"enum",values:{"==":{},"!=":{},">":{},">=":{},"<":{},"<=":{},in:{},"!in":{},all:{},any:{},none:{},has:{},"!has":{}}},geometry_type:{type:"enum",values:{Point:{},LineString:{},Polygon:{}}},function:{expression:{type:"expression"},stops:{type:"array",value:"function_stop"},base:{type:"number",default:1,minimum:0},property:{type:"string",default:"$zoom"},type:{type:"enum",values:{identity:{},exponential:{},interval:{},categorical:{}},default:"exponential"},colorSpace:{type:"enum",values:{rgb:{},lab:{},hcl:{}},default:"rgb"},default:{type:"*",required:!1}},function_stop:{type:"array",minimum:0,maximum:24,value:["number","color"],length:2},expression:{type:"array",value:"*",minimum:1},light:{anchor:{type:"enum",default:"viewport",values:{map:{},viewport:{}},"property-type":"data-constant",transition:!1,expression:{interpolated:!1,parameters:["zoom"]}},position:{type:"array",default:[1.15,210,30],length:3,value:"number","property-type":"data-constant",transition:!0,expression:{interpolated:!0,parameters:["zoom"]}},color:{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},intensity:{type:"number","property-type":"data-constant",default:.5,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0}},sky:{"sky-color":{type:"color","property-type":"data-constant",default:"#88C6FC",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"horizon-color":{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"fog-color":{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"fog-ground-blend":{type:"number","property-type":"data-constant",default:.5,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"horizon-fog-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"sky-horizon-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"atmosphere-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0}},terrain:{source:{type:"string",required:!0},exaggeration:{type:"number",minimum:0,default:1}},projection:{type:{type:"enum",default:"mercator",values:{mercator:{},globe:{}}}},paint:["paint_fill","paint_line","paint_circle","paint_heatmap","paint_fill-extrusion","paint_symbol","paint_raster","paint_hillshade","paint_background"],paint_fill:{"fill-antialias":{type:"boolean",default:!0,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"fill-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-outline-color":{type:"color",transition:!0,requires:[{"!":"fill-pattern"},{"fill-antialias":!0}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["fill-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"}},"paint_fill-extrusion":{"fill-extrusion-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"fill-extrusion-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["fill-extrusion-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"},"fill-extrusion-height":{type:"number",default:0,minimum:0,units:"meters",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-base":{type:"number",default:0,minimum:0,units:"meters",transition:!0,requires:["fill-extrusion-height"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-vertical-gradient":{type:"boolean",default:!0,transition:!1,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},paint_line:{"line-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"line-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"line-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["line-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"line-width":{type:"number",default:1,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-gap-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-offset":{type:"number",default:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-dasharray":{type:"array",value:"number",minimum:0,transition:!0,units:"line widths",requires:[{"!":"line-pattern"}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"cross-faded"},"line-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"},"line-gradient":{type:"color",transition:!1,requires:[{"!":"line-dasharray"},{"!":"line-pattern"},{source:"geojson",has:{lineMetrics:!0}}],expression:{interpolated:!0,parameters:["line-progress"]},"property-type":"color-ramp"}},paint_circle:{"circle-radius":{type:"number",default:5,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-blur":{type:"number",default:0,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"circle-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["circle-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-pitch-scale":{type:"enum",values:{map:{},viewport:{}},default:"map",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-pitch-alignment":{type:"enum",values:{map:{},viewport:{}},default:"viewport",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-stroke-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-stroke-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-stroke-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"}},paint_heatmap:{"heatmap-radius":{type:"number",default:30,minimum:1,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"heatmap-weight":{type:"number",default:1,minimum:0,transition:!1,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"heatmap-intensity":{type:"number",default:1,minimum:0,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"heatmap-color":{type:"color",default:["interpolate",["linear"],["heatmap-density"],0,"rgba(0, 0, 255, 0)",.1,"royalblue",.3,"cyan",.5,"lime",.7,"yellow",1,"red"],transition:!1,expression:{interpolated:!0,parameters:["heatmap-density"]},"property-type":"color-ramp"},"heatmap-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},paint_symbol:{"icon-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-color":{type:"color",default:"#000000",transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-color":{type:"color",default:"rgba(0, 0, 0, 0)",transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"icon-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["icon-image","icon-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-color":{type:"color",default:"#000000",transition:!0,overridable:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-color":{type:"color",default:"rgba(0, 0, 0, 0)",transition:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["text-field","text-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},paint_raster:{"raster-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-hue-rotate":{type:"number",default:0,period:360,transition:!0,units:"degrees",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-brightness-min":{type:"number",default:0,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-brightness-max":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-saturation":{type:"number",default:0,minimum:-1,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-contrast":{type:"number",default:0,minimum:-1,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-resampling":{type:"enum",values:{linear:{},nearest:{}},default:"linear",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"raster-fade-duration":{type:"number",default:300,minimum:0,transition:!1,units:"milliseconds",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},paint_hillshade:{"hillshade-illumination-direction":{type:"number",default:335,minimum:0,maximum:359,transition:!1,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-illumination-anchor":{type:"enum",values:{map:{},viewport:{}},default:"viewport",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-exaggeration":{type:"number",default:.5,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-shadow-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-highlight-color":{type:"color",default:"#FFFFFF",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-accent-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},paint_background:{"background-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"background-pattern"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"background-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"cross-faded"},"background-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},transition:{duration:{type:"number",default:300,minimum:0,units:"milliseconds"},delay:{type:"number",default:0,minimum:0,units:"milliseconds"}},"property-type":{"data-driven":{type:"property-type"},"cross-faded":{type:"property-type"},"cross-faded-data-driven":{type:"property-type"},"color-ramp":{type:"property-type"},"data-constant":{type:"property-type"},constant:{type:"property-type"}},promoteId:{"*":{type:"string"}}};const K=["type","source","source-layer","minzoom","maxzoom","filter","layout"];function X(t,e){const r={};for(const e in t)"ref"!==e&&(r[e]=t[e]);return K.forEach((t=>{t in e&&(r[t]=e[t]);})),r}function H(t,e){if(Array.isArray(t)){if(!Array.isArray(e)||t.length!==e.length)return !1;for(let r=0;r`:"value"===t.itemType.kind?"array":`array<${e}>`}return t.kind}const _t=[lt,ut,ct,ht,pt,mt,ft,bt(dt),gt,xt,vt];function At(t,e){if("error"===e.kind)return null;if("array"===t.kind){if("array"===e.kind&&(0===e.N&&"value"===e.itemType.kind||!At(t.itemType,e.itemType))&&("number"!=typeof t.N||t.N===e.N))return null}else {if(t.kind===e.kind)return null;if("value"===t.kind)for(const t of _t)if(!At(t,e))return null}return `Expected ${wt(t)} but found ${wt(e)} instead.`}function St(t,e){return e.some((e=>e.kind===t.kind))}function kt(t,e){return e.some((e=>"null"===e?null===t:"array"===e?Array.isArray(t):"object"===e?t&&!Array.isArray(t)&&"object"==typeof t:e===typeof t))}function Mt(t,e){return "array"===t.kind&&"array"===e.kind?t.itemType.kind===e.itemType.kind&&"number"==typeof t.N:t.kind===e.kind}const It=.96422,zt=.82521,Pt=4/29,Ct=6/29,Bt=3*Ct*Ct,Vt=Ct*Ct*Ct,Et=Math.PI/180,Ft=180/Math.PI;function Tt(t){return (t%=360)<0&&(t+=360),t}function $t([t,e,r,n]){let i,s;const a=Dt((.2225045*(t=Lt(t))+.7168786*(e=Lt(e))+.0606169*(r=Lt(r)))/1);t===e&&e===r?i=s=a:(i=Dt((.4360747*t+.3850649*e+.1430804*r)/It),s=Dt((.0139322*t+.0971045*e+.7141733*r)/zt));const o=116*a-16;return [o<0?0:o,500*(i-a),200*(a-s),n]}function Lt(t){return t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function Dt(t){return t>Vt?Math.pow(t,1/3):t/Bt+Pt}function Ot([t,e,r,n]){let i=(t+16)/116,s=isNaN(e)?i:i+e/500,a=isNaN(r)?i:i-r/200;return i=1*Rt(i),s=It*Rt(s),a=zt*Rt(a),[jt(3.1338561*s-1.6168667*i-.4906146*a),jt(-.9787684*s+1.9161415*i+.033454*a),jt(.0719453*s-.2289914*i+1.4052427*a),n]}function jt(t){return (t=t<=.00304?12.92*t:1.055*Math.pow(t,1/2.4)-.055)<0?0:t>1?1:t}function Rt(t){return t>Ct?t*t*t:Bt*(t-Pt)}function Ut(t){return parseInt(t.padEnd(2,t),16)/255}function qt(t,e){return Nt(e?t/100:t,0,1)}function Nt(t,e,r){return Math.min(Math.max(e,t),r)}function Zt(t){return !t.some(Number.isNaN)}const Gt={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]};class Kt{constructor(t,e,r,n=1,i=!0){this.r=t,this.g=e,this.b=r,this.a=n,i||(this.r*=n,this.g*=n,this.b*=n,n||this.overwriteGetter("rgb",[t,e,r,n]));}static parse(t){if(t instanceof Kt)return t;if("string"!=typeof t)return;const e=function(t){if("transparent"===(t=t.toLowerCase().trim()))return [0,0,0,0];const e=Gt[t];if(e){const[t,r,n]=e;return [t/255,r/255,n/255,1]}if(t.startsWith("#")&&/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(t)){const e=t.length<6?1:2;let r=1;return [Ut(t.slice(r,r+=e)),Ut(t.slice(r,r+=e)),Ut(t.slice(r,r+=e)),Ut(t.slice(r,r+e)||"ff")]}if(t.startsWith("rgb")){const e=t.match(/^rgba?\(\s*([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/);if(e){const[t,r,n,i,s,a,o,l,u,c,h,p]=e,f=[i||" ",o||" ",c].join("");if(" "===f||" /"===f||",,"===f||",,,"===f){const t=[n,a,u].join(""),e="%%%"===t?100:""===t?255:0;if(e){const t=[Nt(+r/e,0,1),Nt(+s/e,0,1),Nt(+l/e,0,1),h?qt(+h,p):1];if(Zt(t))return t}}return}}const r=t.match(/^hsla?\(\s*([\de.+-]+)(?:deg)?(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/);if(r){const[t,e,n,i,s,a,o,l,u]=r,c=[n||" ",s||" ",o].join("");if(" "===c||" /"===c||",,"===c||",,,"===c){const t=[+e,Nt(+i,0,100),Nt(+a,0,100),l?qt(+l,u):1];if(Zt(t))return function([t,e,r,n]){function i(n){const i=(n+t/30)%12,s=e*Math.min(r,1-r);return r-s*Math.max(-1,Math.min(i-3,9-i,1))}return t=Tt(t),e/=100,r/=100,[i(0),i(8),i(4),n]}(t)}}}(t);return e?new Kt(...e,!1):void 0}get rgb(){const{r:t,g:e,b:r,a:n}=this,i=n||1/0;return this.overwriteGetter("rgb",[t/i,e/i,r/i,n])}get hcl(){return this.overwriteGetter("hcl",function(t){const[e,r,n,i]=$t(t),s=Math.sqrt(r*r+n*n);return [Math.round(1e4*s)?Tt(Math.atan2(n,r)*Ft):NaN,s,e,i]}(this.rgb))}get lab(){return this.overwriteGetter("lab",$t(this.rgb))}overwriteGetter(t,e){return Object.defineProperty(this,t,{value:e}),e}toString(){const[t,e,r,n]=this.rgb;return `rgba(${[t,e,r].map((t=>Math.round(255*t))).join(",")},${n})`}}Kt.black=new Kt(0,0,0,1),Kt.white=new Kt(1,1,1,1),Kt.transparent=new Kt(0,0,0,0),Kt.red=new Kt(1,0,0,1);class Xt{constructor(t,e,r){this.sensitivity=t?e?"variant":"case":e?"accent":"base",this.locale=r,this.collator=new Intl.Collator(this.locale?this.locale:[],{sensitivity:this.sensitivity,usage:"search"});}compare(t,e){return this.collator.compare(t,e)}resolvedLocale(){return new Intl.Collator(this.locale?this.locale:[]).resolvedOptions().locale}}class Ht{constructor(t,e,r,n,i){this.text=t,this.image=e,this.scale=r,this.fontStack=n,this.textColor=i;}}class Yt{constructor(t){this.sections=t;}static fromString(t){return new Yt([new Ht(t,null,null,null,null)])}isEmpty(){return 0===this.sections.length||!this.sections.some((t=>0!==t.text.length||t.image&&0!==t.image.name.length))}static factory(t){return t instanceof Yt?t:Yt.fromString(t)}toString(){return 0===this.sections.length?"":this.sections.map((t=>t.text)).join("")}}class Jt{constructor(t){this.values=t.slice();}static parse(t){if(t instanceof Jt)return t;if("number"==typeof t)return new Jt([t,t,t,t]);if(Array.isArray(t)&&!(t.length<1||t.length>4)){for(const e of t)if("number"!=typeof e)return;switch(t.length){case 1:t=[t[0],t[0],t[0],t[0]];break;case 2:t=[t[0],t[1],t[0],t[1]];break;case 3:t=[t[0],t[1],t[2],t[1]];}return new Jt(t)}}toString(){return JSON.stringify(this.values)}}const Wt=new Set(["center","left","right","top","bottom","top-left","top-right","bottom-left","bottom-right"]);class Qt{constructor(t){this.values=t.slice();}static parse(t){if(t instanceof Qt)return t;if(Array.isArray(t)&&!(t.length<1)&&t.length%2==0){for(let e=0;e=0&&t<=255&&"number"==typeof e&&e>=0&&e<=255&&"number"==typeof r&&r>=0&&r<=255?void 0===n||"number"==typeof n&&n>=0&&n<=1?null:`Invalid rgba value [${[t,e,r,n].join(", ")}]: 'a' must be between 0 and 1.`:`Invalid rgba value [${("number"==typeof n?[t,e,r,n]:[t,e,r]).join(", ")}]: 'r', 'g', and 'b' must be between 0 and 255.`}function re(t){if(null===t||"string"==typeof t||"boolean"==typeof t||"number"==typeof t||t instanceof Kt||t instanceof Xt||t instanceof Yt||t instanceof Jt||t instanceof Qt||t instanceof te)return !0;if(Array.isArray(t)){for(const e of t)if(!re(e))return !1;return !0}if("object"==typeof t){for(const e in t)if(!re(t[e]))return !1;return !0}return !1}function ne(t){if(null===t)return lt;if("string"==typeof t)return ct;if("boolean"==typeof t)return ht;if("number"==typeof t)return ut;if(t instanceof Kt)return pt;if(t instanceof Xt)return yt;if(t instanceof Yt)return mt;if(t instanceof Jt)return gt;if(t instanceof Qt)return vt;if(t instanceof te)return xt;if(Array.isArray(t)){const e=t.length;let r;for(const e of t){const t=ne(e);if(r){if(r===t)continue;r=dt;break}r=t;}return bt(r||dt,e)}return ft}function ie(t){const e=typeof t;return null===t?"":"string"===e||"number"===e||"boolean"===e?String(t):t instanceof Kt||t instanceof Yt||t instanceof Jt||t instanceof Qt||t instanceof te?t.toString():JSON.stringify(t)}class se{constructor(t,e){this.type=t,this.value=e;}static parse(t,e){if(2!==t.length)return e.error(`'literal' expression requires exactly one argument, but found ${t.length-1} instead.`);if(!re(t[1]))return e.error("invalid value");const r=t[1];let n=ne(r);const i=e.expectedType;return "array"!==n.kind||0!==n.N||!i||"array"!==i.kind||"number"==typeof i.N&&0!==i.N||(n=i),new se(n,r)}evaluate(){return this.value}eachChild(){}outputDefined(){return !0}}class ae{constructor(t){this.name="ExpressionEvaluationError",this.message=t;}toJSON(){return this.message}}const oe={string:ct,number:ut,boolean:ht,object:ft};class le{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");let r,n=1;const i=t[0];if("array"===i){let i,s;if(t.length>2){const r=t[1];if("string"!=typeof r||!(r in oe)||"object"===r)return e.error('The item type argument of "array" must be one of string, number, boolean',1);i=oe[r],n++;}else i=dt;if(t.length>3){if(null!==t[2]&&("number"!=typeof t[2]||t[2]<0||t[2]!==Math.floor(t[2])))return e.error('The length argument to "array" must be a positive integer literal',2);s=t[2],n++;}r=bt(i,s);}else {if(!oe[i])throw new Error(`Types doesn't contain name = ${i}`);r=oe[i];}const s=[];for(;nt.outputDefined()))}}const ue={"to-boolean":ht,"to-color":pt,"to-number":ut,"to-string":ct};class ce{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");const r=t[0];if(!ue[r])throw new Error(`Can't parse ${r} as it is not part of the known types`);if(("to-boolean"===r||"to-string"===r)&&2!==t.length)return e.error("Expected one argument.");const n=ue[r],i=[];for(let r=1;r4?`Invalid rbga value ${JSON.stringify(e)}: expected an array containing either three or four numeric values.`:ee(e[0],e[1],e[2],e[3]),!r))return new Kt(e[0]/255,e[1]/255,e[2]/255,e[3])}throw new ae(r||`Could not parse color from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case"padding":{let e;for(const r of this.args){e=r.evaluate(t);const n=Jt.parse(e);if(n)return n}throw new ae(`Could not parse padding from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case"variableAnchorOffsetCollection":{let e;for(const r of this.args){e=r.evaluate(t);const n=Qt.parse(e);if(n)return n}throw new ae(`Could not parse variableAnchorOffsetCollection from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case"number":{let e=null;for(const r of this.args){if(e=r.evaluate(t),null===e)return 0;const n=Number(e);if(!isNaN(n))return n}throw new ae(`Could not convert ${JSON.stringify(e)} to number.`)}case"formatted":return Yt.fromString(ie(this.args[0].evaluate(t)));case"resolvedImage":return te.fromString(ie(this.args[0].evaluate(t)));default:return ie(this.args[0].evaluate(t))}}eachChild(t){this.args.forEach(t);}outputDefined(){return this.args.every((t=>t.outputDefined()))}}const he=["Unknown","Point","LineString","Polygon"];class pe{constructor(){this.globals=null,this.feature=null,this.featureState=null,this.formattedSection=null,this._parseColorCache={},this.availableImages=null,this.canonical=null;}id(){return this.feature&&"id"in this.feature?this.feature.id:null}geometryType(){return this.feature?"number"==typeof this.feature.type?he[this.feature.type]:this.feature.type:null}geometry(){return this.feature&&"geometry"in this.feature?this.feature.geometry:null}canonicalID(){return this.canonical}properties(){return this.feature&&this.feature.properties||{}}parseColor(t){let e=this._parseColorCache[t];return e||(e=this._parseColorCache[t]=Kt.parse(t)),e}}class fe{constructor(t,e,r=[],n,i=new ot,s=[]){this.registry=t,this.path=r,this.key=r.map((t=>`[${t}]`)).join(""),this.scope=i,this.errors=s,this.expectedType=n,this._isConstant=e;}parse(t,e,r,n,i={}){return e?this.concat(e,r,n)._parse(t,i):this._parse(t,i)}_parse(t,e){function r(t,e,r){return "assert"===r?new le(e,[t]):"coerce"===r?new ce(e,[t]):t}if(null!==t&&"string"!=typeof t&&"boolean"!=typeof t&&"number"!=typeof t||(t=["literal",t]),Array.isArray(t)){if(0===t.length)return this.error('Expected an array with at least one element. If you wanted a literal array, use ["literal", []].');const n=t[0];if("string"!=typeof n)return this.error(`Expression name must be a string, but found ${typeof n} instead. If you wanted a literal array, use ["literal", [...]].`,0),null;const i=this.registry[n];if(i){let n=i.parse(t,this);if(!n)return null;if(this.expectedType){const t=this.expectedType,i=n.type;if("string"!==t.kind&&"number"!==t.kind&&"boolean"!==t.kind&&"object"!==t.kind&&"array"!==t.kind||"value"!==i.kind)if("color"!==t.kind&&"formatted"!==t.kind&&"resolvedImage"!==t.kind||"value"!==i.kind&&"string"!==i.kind)if("padding"!==t.kind||"value"!==i.kind&&"number"!==i.kind&&"array"!==i.kind)if("variableAnchorOffsetCollection"!==t.kind||"value"!==i.kind&&"array"!==i.kind){if(this.checkSubtype(t,i))return null}else n=r(n,t,e.typeAnnotation||"coerce");else n=r(n,t,e.typeAnnotation||"coerce");else n=r(n,t,e.typeAnnotation||"coerce");else n=r(n,t,e.typeAnnotation||"assert");}if(!(n instanceof se)&&"resolvedImage"!==n.type.kind&&this._isConstant(n)){const t=new pe;try{n=new se(n.type,n.evaluate(t));}catch(t){return this.error(t.message),null}}return n}return this.error(`Unknown expression "${n}". If you wanted a literal array, use ["literal", [...]].`,0)}return this.error(void 0===t?"'undefined' value invalid. Use null instead.":"object"==typeof t?'Bare objects invalid. Use ["literal", {...}] instead.':`Expected an array, but found ${typeof t} instead.`)}concat(t,e,r){const n="number"==typeof t?this.path.concat(t):this.path,i=r?this.scope.concat(r):this.scope;return new fe(this.registry,this._isConstant,n,e||null,i,this.errors)}error(t,...e){const r=`${this.key}${e.map((t=>`[${t}]`)).join("")}`;this.errors.push(new at(r,t));}checkSubtype(t,e){const r=At(t,e);return r&&this.error(r),r}}class de{constructor(t,e){this.type=e.type,this.bindings=[].concat(t),this.result=e;}evaluate(t){return this.result.evaluate(t)}eachChild(t){for(const e of this.bindings)t(e[1]);t(this.result);}static parse(t,e){if(t.length<4)return e.error(`Expected at least 3 arguments, but found ${t.length-1} instead.`);const r=[];for(let n=1;n=r.length)throw new ae(`Array index out of bounds: ${e} > ${r.length-1}.`);if(e!==Math.floor(e))throw new ae(`Array index must be an integer, but found ${e} instead.`);return r[e]}eachChild(t){t(this.index),t(this.input);}outputDefined(){return !1}}class ge{constructor(t,e){this.type=ht,this.needle=t,this.haystack=e;}static parse(t,e){if(3!==t.length)return e.error(`Expected 2 arguments, but found ${t.length-1} instead.`);const r=e.parse(t[1],1,dt),n=e.parse(t[2],2,dt);return r&&n?St(r.type,[ht,ct,ut,lt,dt])?new ge(r,n):e.error(`Expected first argument to be of type boolean, string, number or null, but found ${wt(r.type)} instead`):null}evaluate(t){const e=this.needle.evaluate(t),r=this.haystack.evaluate(t);if(!r)return !1;if(!kt(e,["boolean","string","number","null"]))throw new ae(`Expected first argument to be of type boolean, string, number or null, but found ${wt(ne(e))} instead.`);if(!kt(r,["string","array"]))throw new ae(`Expected second argument to be of type array or string, but found ${wt(ne(r))} instead.`);return r.indexOf(e)>=0}eachChild(t){t(this.needle),t(this.haystack);}outputDefined(){return !0}}class xe{constructor(t,e,r){this.type=ut,this.needle=t,this.haystack=e,this.fromIndex=r;}static parse(t,e){if(t.length<=2||t.length>=5)return e.error(`Expected 3 or 4 arguments, but found ${t.length-1} instead.`);const r=e.parse(t[1],1,dt),n=e.parse(t[2],2,dt);if(!r||!n)return null;if(!St(r.type,[ht,ct,ut,lt,dt]))return e.error(`Expected first argument to be of type boolean, string, number or null, but found ${wt(r.type)} instead`);if(4===t.length){const i=e.parse(t[3],3,ut);return i?new xe(r,n,i):null}return new xe(r,n)}evaluate(t){const e=this.needle.evaluate(t),r=this.haystack.evaluate(t);if(!kt(e,["boolean","string","number","null"]))throw new ae(`Expected first argument to be of type boolean, string, number or null, but found ${wt(ne(e))} instead.`);let n;if(this.fromIndex&&(n=this.fromIndex.evaluate(t)),kt(r,["string"])){const t=r.indexOf(e,n);return -1===t?-1:[...r.slice(0,t)].length}if(kt(r,["array"]))return r.indexOf(e,n);throw new ae(`Expected second argument to be of type array or string, but found ${wt(ne(r))} instead.`)}eachChild(t){t(this.needle),t(this.haystack),this.fromIndex&&t(this.fromIndex);}outputDefined(){return !1}}class ve{constructor(t,e,r,n,i,s){this.inputType=t,this.type=e,this.input=r,this.cases=n,this.outputs=i,this.otherwise=s;}static parse(t,e){if(t.length<5)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if(t.length%2!=1)return e.error("Expected an even number of arguments.");let r,n;e.expectedType&&"value"!==e.expectedType.kind&&(n=e.expectedType);const i={},s=[];for(let a=2;aNumber.MAX_SAFE_INTEGER)return u.error(`Branch labels must be integers no larger than ${Number.MAX_SAFE_INTEGER}.`);if("number"==typeof t&&Math.floor(t)!==t)return u.error("Numeric branch labels must be integer values.");if(r){if(u.checkSubtype(r,ne(t)))return null}else r=ne(t);if(void 0!==i[String(t)])return u.error("Branch labels must be unique.");i[String(t)]=s.length;}const c=e.parse(l,a,n);if(!c)return null;n=n||c.type,s.push(c);}const a=e.parse(t[1],1,dt);if(!a)return null;const o=e.parse(t[t.length-1],t.length-1,n);return o?"value"!==a.type.kind&&e.concat(1).checkSubtype(r,a.type)?null:new ve(r,n,a,i,s,o):null}evaluate(t){const e=this.input.evaluate(t);return (ne(e)===this.inputType&&this.outputs[this.cases[e]]||this.otherwise).evaluate(t)}eachChild(t){t(this.input),this.outputs.forEach(t),t(this.otherwise);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))&&this.otherwise.outputDefined()}}class be{constructor(t,e,r){this.type=t,this.branches=e,this.otherwise=r;}static parse(t,e){if(t.length<4)return e.error(`Expected at least 3 arguments, but found only ${t.length-1}.`);if(t.length%2!=0)return e.error("Expected an odd number of arguments.");let r;e.expectedType&&"value"!==e.expectedType.kind&&(r=e.expectedType);const n=[];for(let i=1;ie.outputDefined()))&&this.otherwise.outputDefined()}}class we{constructor(t,e,r,n){this.type=t,this.input=e,this.beginIndex=r,this.endIndex=n;}static parse(t,e){if(t.length<=2||t.length>=5)return e.error(`Expected 3 or 4 arguments, but found ${t.length-1} instead.`);const r=e.parse(t[1],1,dt),n=e.parse(t[2],2,ut);if(!r||!n)return null;if(!St(r.type,[bt(dt),ct,dt]))return e.error(`Expected first argument to be of type array or string, but found ${wt(r.type)} instead`);if(4===t.length){const i=e.parse(t[3],3,ut);return i?new we(r.type,r,n,i):null}return new we(r.type,r,n)}evaluate(t){const e=this.input.evaluate(t),r=this.beginIndex.evaluate(t);let n;if(this.endIndex&&(n=this.endIndex.evaluate(t)),kt(e,["string"]))return [...e].slice(r,n).join("");if(kt(e,["array"]))return e.slice(r,n);throw new ae(`Expected first argument to be of type array or string, but found ${wt(ne(e))} instead.`)}eachChild(t){t(this.input),t(this.beginIndex),this.endIndex&&t(this.endIndex);}outputDefined(){return !1}}function _e(t,e){const r=t.length-1;let n,i,s=0,a=r,o=0;for(;s<=a;)if(o=Math.floor((s+a)/2),n=t[o],i=t[o+1],n<=e){if(o===r||ee))throw new ae("Input is not a number.");a=o-1;}return 0}class Ae{constructor(t,e,r){this.type=t,this.input=e,this.labels=[],this.outputs=[];for(const[t,e]of r)this.labels.push(t),this.outputs.push(e);}static parse(t,e){if(t.length-1<4)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if((t.length-1)%2!=0)return e.error("Expected an even number of arguments.");const r=e.parse(t[1],1,ut);if(!r)return null;const n=[];let i=null;e.expectedType&&"value"!==e.expectedType.kind&&(i=e.expectedType);for(let r=1;r=s)return e.error('Input/output pairs for "step" expressions must be arranged with input values in strictly ascending order.',o);const u=e.parse(a,l,i);if(!u)return null;i=i||u.type,n.push([s,u]);}return new Ae(i,r,n)}evaluate(t){const e=this.labels,r=this.outputs;if(1===e.length)return r[0].evaluate(t);const n=this.input.evaluate(t);if(n<=e[0])return r[0].evaluate(t);const i=e.length;return n>=e[i-1]?r[i-1].evaluate(t):r[_e(e,n)].evaluate(t)}eachChild(t){t(this.input);for(const e of this.outputs)t(e);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))}}function Se(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var ke=Me;function Me(t,e,r,n){this.cx=3*t,this.bx=3*(r-t)-this.cx,this.ax=1-this.cx-this.bx,this.cy=3*e,this.by=3*(n-e)-this.cy,this.ay=1-this.cy-this.by,this.p1x=t,this.p1y=e,this.p2x=r,this.p2y=n;}Me.prototype={sampleCurveX:function(t){return ((this.ax*t+this.bx)*t+this.cx)*t},sampleCurveY:function(t){return ((this.ay*t+this.by)*t+this.cy)*t},sampleCurveDerivativeX:function(t){return (3*this.ax*t+2*this.bx)*t+this.cx},solveCurveX:function(t,e){if(void 0===e&&(e=1e-6),t<0)return 0;if(t>1)return 1;for(var r=t,n=0;n<8;n++){var i=this.sampleCurveX(r)-t;if(Math.abs(i)i?a=r:o=r,r=.5*(o-a)+a;return r},solve:function(t,e){return this.sampleCurveY(this.solveCurveX(t,e))}};var Ie=Se(ke);function ze(t,e,r){return t+r*(e-t)}function Pe(t,e,r){return t.map(((t,n)=>ze(t,e[n],r)))}const Ce={number:ze,color:function(t,e,r,n="rgb"){switch(n){case"rgb":{const[n,i,s,a]=Pe(t.rgb,e.rgb,r);return new Kt(n,i,s,a,!1)}case"hcl":{const[n,i,s,a]=t.hcl,[o,l,u,c]=e.hcl;let h,p;if(isNaN(n)||isNaN(o))isNaN(n)?isNaN(o)?h=NaN:(h=o,1!==s&&0!==s||(p=l)):(h=n,1!==u&&0!==u||(p=i));else {let t=o-n;o>n&&t>180?t-=360:o180&&(t+=360),h=n+r*t;}const[f,d,y,m]=function([t,e,r,n]){return t=isNaN(t)?0:t*Et,Ot([r,Math.cos(t)*e,Math.sin(t)*e,n])}([h,null!=p?p:ze(i,l,r),ze(s,u,r),ze(a,c,r)]);return new Kt(f,d,y,m,!1)}case"lab":{const[n,i,s,a]=Ot(Pe(t.lab,e.lab,r));return new Kt(n,i,s,a,!1)}}},array:Pe,padding:function(t,e,r){return new Jt(Pe(t.values,e.values,r))},variableAnchorOffsetCollection:function(t,e,r){const n=t.values,i=e.values;if(n.length!==i.length)throw new ae(`Cannot interpolate values of different length. from: ${t.toString()}, to: ${e.toString()}`);const s=[];for(let t=0;t"number"!=typeof t||t<0||t>1)))return e.error("Cubic bezier interpolation requires four numeric arguments with values between 0 and 1.",1);n={name:"cubic-bezier",controlPoints:t};}}if(t.length-1<4)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if((t.length-1)%2!=0)return e.error("Expected an even number of arguments.");if(i=e.parse(i,2,ut),!i)return null;const a=[];let o=null;"interpolate-hcl"===r||"interpolate-lab"===r?o=pt:e.expectedType&&"value"!==e.expectedType.kind&&(o=e.expectedType);for(let t=0;t=r)return e.error('Input/output pairs for "interpolate" expressions must be arranged with input values in strictly ascending order.',i);const u=e.parse(n,l,o);if(!u)return null;o=o||u.type,a.push([r,u]);}return Mt(o,ut)||Mt(o,pt)||Mt(o,gt)||Mt(o,vt)||Mt(o,bt(ut))?new Be(o,r,n,i,a):e.error(`Type ${wt(o)} is not interpolatable.`)}evaluate(t){const e=this.labels,r=this.outputs;if(1===e.length)return r[0].evaluate(t);const n=this.input.evaluate(t);if(n<=e[0])return r[0].evaluate(t);const i=e.length;if(n>=e[i-1])return r[i-1].evaluate(t);const s=_e(e,n),a=Be.interpolationFactor(this.interpolation,n,e[s],e[s+1]),o=r[s].evaluate(t),l=r[s+1].evaluate(t);switch(this.operator){case"interpolate":return Ce[this.type.kind](o,l,a);case"interpolate-hcl":return Ce.color(o,l,a,"hcl");case"interpolate-lab":return Ce.color(o,l,a,"lab")}}eachChild(t){t(this.input);for(const e of this.outputs)t(e);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))}}function Ve(t,e,r,n){const i=n-r,s=t-r;return 0===i?0:1===e?s/i:(Math.pow(e,s)-1)/(Math.pow(e,i)-1)}class Ee{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expectected at least one argument.");let r=null;const n=e.expectedType;n&&"value"!==n.kind&&(r=n);const i=[];for(const n of t.slice(1)){const t=e.parse(n,1+i.length,r,void 0,{typeAnnotation:"omit"});if(!t)return null;r=r||t.type,i.push(t);}if(!r)throw new Error("No output type");const s=n&&i.some((t=>At(n,t.type)));return new Ee(s?dt:r,i)}evaluate(t){let e,r=null,n=0;for(const i of this.args)if(n++,r=i.evaluate(t),r&&r instanceof te&&!r.available&&(e||(e=r.name),r=null,n===this.args.length&&(r=e)),null!==r)break;return r}eachChild(t){this.args.forEach(t);}outputDefined(){return this.args.every((t=>t.outputDefined()))}}function Fe(t,e){return "=="===t||"!="===t?"boolean"===e.kind||"string"===e.kind||"number"===e.kind||"null"===e.kind||"value"===e.kind:"string"===e.kind||"number"===e.kind||"value"===e.kind}function Te(t,e,r,n){return 0===n.compare(e,r)}function $e(t,e,r){const n="=="!==t&&"!="!==t;return class i{constructor(t,e,r){this.type=ht,this.lhs=t,this.rhs=e,this.collator=r,this.hasUntypedArgument="value"===t.type.kind||"value"===e.type.kind;}static parse(t,e){if(3!==t.length&&4!==t.length)return e.error("Expected two or three arguments.");const r=t[0];let s=e.parse(t[1],1,dt);if(!s)return null;if(!Fe(r,s.type))return e.concat(1).error(`"${r}" comparisons are not supported for type '${wt(s.type)}'.`);let a=e.parse(t[2],2,dt);if(!a)return null;if(!Fe(r,a.type))return e.concat(2).error(`"${r}" comparisons are not supported for type '${wt(a.type)}'.`);if(s.type.kind!==a.type.kind&&"value"!==s.type.kind&&"value"!==a.type.kind)return e.error(`Cannot compare types '${wt(s.type)}' and '${wt(a.type)}'.`);n&&("value"===s.type.kind&&"value"!==a.type.kind?s=new le(a.type,[s]):"value"!==s.type.kind&&"value"===a.type.kind&&(a=new le(s.type,[a])));let o=null;if(4===t.length){if("string"!==s.type.kind&&"string"!==a.type.kind&&"value"!==s.type.kind&&"value"!==a.type.kind)return e.error("Cannot use collator to compare non-string types.");if(o=e.parse(t[3],3,yt),!o)return null}return new i(s,a,o)}evaluate(i){const s=this.lhs.evaluate(i),a=this.rhs.evaluate(i);if(n&&this.hasUntypedArgument){const e=ne(s),r=ne(a);if(e.kind!==r.kind||"string"!==e.kind&&"number"!==e.kind)throw new ae(`Expected arguments for "${t}" to be (string, string) or (number, number), but found (${e.kind}, ${r.kind}) instead.`)}if(this.collator&&!n&&this.hasUntypedArgument){const t=ne(s),r=ne(a);if("string"!==t.kind||"string"!==r.kind)return e(i,s,a)}return this.collator?r(i,s,a,this.collator.evaluate(i)):e(i,s,a)}eachChild(t){t(this.lhs),t(this.rhs),this.collator&&t(this.collator);}outputDefined(){return !0}}}const Le=$e("==",(function(t,e,r){return e===r}),Te),De=$e("!=",(function(t,e,r){return e!==r}),(function(t,e,r,n){return !Te(0,e,r,n)})),Oe=$e("<",(function(t,e,r){return e",(function(t,e,r){return e>r}),(function(t,e,r,n){return n.compare(e,r)>0})),Re=$e("<=",(function(t,e,r){return e<=r}),(function(t,e,r,n){return n.compare(e,r)<=0})),Ue=$e(">=",(function(t,e,r){return e>=r}),(function(t,e,r,n){return n.compare(e,r)>=0}));class qe{constructor(t,e,r){this.type=yt,this.locale=r,this.caseSensitive=t,this.diacriticSensitive=e;}static parse(t,e){if(2!==t.length)return e.error("Expected one argument.");const r=t[1];if("object"!=typeof r||Array.isArray(r))return e.error("Collator options argument must be an object.");const n=e.parse(void 0!==r["case-sensitive"]&&r["case-sensitive"],1,ht);if(!n)return null;const i=e.parse(void 0!==r["diacritic-sensitive"]&&r["diacritic-sensitive"],1,ht);if(!i)return null;let s=null;return r.locale&&(s=e.parse(r.locale,1,ct),!s)?null:new qe(n,i,s)}evaluate(t){return new Xt(this.caseSensitive.evaluate(t),this.diacriticSensitive.evaluate(t),this.locale?this.locale.evaluate(t):null)}eachChild(t){t(this.caseSensitive),t(this.diacriticSensitive),this.locale&&t(this.locale);}outputDefined(){return !1}}class Ne{constructor(t,e,r,n,i){this.type=ct,this.number=t,this.locale=e,this.currency=r,this.minFractionDigits=n,this.maxFractionDigits=i;}static parse(t,e){if(3!==t.length)return e.error("Expected two arguments.");const r=e.parse(t[1],1,ut);if(!r)return null;const n=t[2];if("object"!=typeof n||Array.isArray(n))return e.error("NumberFormat options argument must be an object.");let i=null;if(n.locale&&(i=e.parse(n.locale,1,ct),!i))return null;let s=null;if(n.currency&&(s=e.parse(n.currency,1,ct),!s))return null;let a=null;if(n["min-fraction-digits"]&&(a=e.parse(n["min-fraction-digits"],1,ut),!a))return null;let o=null;return n["max-fraction-digits"]&&(o=e.parse(n["max-fraction-digits"],1,ut),!o)?null:new Ne(r,i,s,a,o)}evaluate(t){return new Intl.NumberFormat(this.locale?this.locale.evaluate(t):[],{style:this.currency?"currency":"decimal",currency:this.currency?this.currency.evaluate(t):void 0,minimumFractionDigits:this.minFractionDigits?this.minFractionDigits.evaluate(t):void 0,maximumFractionDigits:this.maxFractionDigits?this.maxFractionDigits.evaluate(t):void 0}).format(this.number.evaluate(t))}eachChild(t){t(this.number),this.locale&&t(this.locale),this.currency&&t(this.currency),this.minFractionDigits&&t(this.minFractionDigits),this.maxFractionDigits&&t(this.maxFractionDigits);}outputDefined(){return !1}}class Ze{constructor(t){this.type=mt,this.sections=t;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");const r=t[1];if(!Array.isArray(r)&&"object"==typeof r)return e.error("First argument must be an image or text section.");const n=[];let i=!1;for(let r=1;r<=t.length-1;++r){const s=t[r];if(i&&"object"==typeof s&&!Array.isArray(s)){i=!1;let t=null;if(s["font-scale"]&&(t=e.parse(s["font-scale"],1,ut),!t))return null;let r=null;if(s["text-font"]&&(r=e.parse(s["text-font"],1,bt(ct)),!r))return null;let a=null;if(s["text-color"]&&(a=e.parse(s["text-color"],1,pt),!a))return null;const o=n[n.length-1];o.scale=t,o.font=r,o.textColor=a;}else {const s=e.parse(t[r],1,dt);if(!s)return null;const a=s.type.kind;if("string"!==a&&"value"!==a&&"null"!==a&&"resolvedImage"!==a)return e.error("Formatted text type must be 'string', 'value', 'image' or 'null'.");i=!0,n.push({content:s,scale:null,font:null,textColor:null});}}return new Ze(n)}evaluate(t){return new Yt(this.sections.map((e=>{const r=e.content.evaluate(t);return ne(r)===xt?new Ht("",r,null,null,null):new Ht(ie(r),null,e.scale?e.scale.evaluate(t):null,e.font?e.font.evaluate(t).join(","):null,e.textColor?e.textColor.evaluate(t):null)})))}eachChild(t){for(const e of this.sections)t(e.content),e.scale&&t(e.scale),e.font&&t(e.font),e.textColor&&t(e.textColor);}outputDefined(){return !1}}class Ge{constructor(t){this.type=xt,this.input=t;}static parse(t,e){if(2!==t.length)return e.error("Expected two arguments.");const r=e.parse(t[1],1,ct);return r?new Ge(r):e.error("No image name provided.")}evaluate(t){const e=this.input.evaluate(t),r=te.fromString(e);return r&&t.availableImages&&(r.available=t.availableImages.indexOf(e)>-1),r}eachChild(t){t(this.input);}outputDefined(){return !1}}class Ke{constructor(t){this.type=ut,this.input=t;}static parse(t,e){if(2!==t.length)return e.error(`Expected 1 argument, but found ${t.length-1} instead.`);const r=e.parse(t[1],1);return r?"array"!==r.type.kind&&"string"!==r.type.kind&&"value"!==r.type.kind?e.error(`Expected argument of type string or array, but found ${wt(r.type)} instead.`):new Ke(r):null}evaluate(t){const e=this.input.evaluate(t);if("string"==typeof e)return [...e].length;if(Array.isArray(e))return e.length;throw new ae(`Expected value to be of type string or array, but found ${wt(ne(e))} instead.`)}eachChild(t){t(this.input);}outputDefined(){return !1}}const Xe=8192;function He(t,e){const r=(180+t[0])/360,n=(180-180/Math.PI*Math.log(Math.tan(Math.PI/4+t[1]*Math.PI/360)))/360,i=Math.pow(2,e.z);return [Math.round(r*i*Xe),Math.round(n*i*Xe)]}function Ye(t,e){const r=Math.pow(2,e.z);return [(i=(t[0]/Xe+e.x)/r,360*i-180),(n=(t[1]/Xe+e.y)/r,360/Math.PI*Math.atan(Math.exp((180-360*n)*Math.PI/180))-90)];var n,i;}function Je(t,e){t[0]=Math.min(t[0],e[0]),t[1]=Math.min(t[1],e[1]),t[2]=Math.max(t[2],e[0]),t[3]=Math.max(t[3],e[1]);}function We(t,e){return !(t[0]<=e[0]||t[2]>=e[2]||t[1]<=e[1]||t[3]>=e[3])}function Qe(t,e,r){const n=t[0]-e[0],i=t[1]-e[1],s=t[0]-r[0],a=t[1]-r[1];return n*a-s*i==0&&n*s<=0&&i*a<=0}function tr(t,e,r,n){return 0!=(i=[n[0]-r[0],n[1]-r[1]])[0]*(s=[e[0]-t[0],e[1]-t[1]])[1]-i[1]*s[0]&&!(!ar(t,e,r,n)||!ar(r,n,t,e));var i,s;}function er(t,e,r){for(const n of r)for(let r=0;r(i=t)[1]!=(a=o[e+1])[1]>i[1]&&i[0]<(a[0]-s[0])*(i[1]-s[1])/(a[1]-s[1])+s[0]&&(n=!n);}var i,s,a;return n}function nr(t,e){for(const r of e)if(rr(t,r))return !0;return !1}function ir(t,e){for(const r of t)if(!rr(r,e))return !1;for(let r=0;r0&&o<0||a<0&&o>0}function or(t,e,r){const n=[];for(let i=0;ir[2]){const e=.5*n;let i=t[0]-r[0]>e?-n:r[0]-t[0]>e?n:0;0===i&&(i=t[0]-r[2]>e?-n:r[2]-t[0]>e?n:0),t[0]+=i;}Je(e,t);}function cr(t,e,r,n){const i=Math.pow(2,n.z)*Xe,s=[n.x*Xe,n.y*Xe],a=[];for(const n of t)for(const t of n){const n=[t.x+s[0],t.y+s[1]];ur(n,e,r,i),a.push(n);}return a}function hr(t,e,r,n){const i=Math.pow(2,n.z)*Xe,s=[n.x*Xe,n.y*Xe],a=[];for(const r of t){const t=[];for(const n of r){const r=[n.x+s[0],n.y+s[1]];Je(e,r),t.push(r);}a.push(t);}if(e[2]-e[0]<=i/2){(o=e)[0]=o[1]=1/0,o[2]=o[3]=-1/0;for(const t of a)for(const n of t)ur(n,e,r,i);}var o;return a}class pr{constructor(t,e){this.type=ht,this.geojson=t,this.geometries=e;}static parse(t,e){if(2!==t.length)return e.error(`'within' expression requires exactly one argument, but found ${t.length-1} instead.`);if(re(t[1])){const e=t[1];if("FeatureCollection"===e.type){const t=[];for(const r of e.features){const{type:e,coordinates:n}=r.geometry;"Polygon"===e&&t.push(n),"MultiPolygon"===e&&t.push(...n);}if(t.length)return new pr(e,{type:"MultiPolygon",coordinates:t})}else if("Feature"===e.type){const t=e.geometry.type;if("Polygon"===t||"MultiPolygon"===t)return new pr(e,e.geometry)}else if("Polygon"===e.type||"MultiPolygon"===e.type)return new pr(e,e)}return e.error("'within' expression requires valid geojson object that contains polygon geometry type.")}evaluate(t){if(null!=t.geometry()&&null!=t.canonicalID()){if("Point"===t.geometryType())return function(t,e){const r=[1/0,1/0,-1/0,-1/0],n=[1/0,1/0,-1/0,-1/0],i=t.canonicalID();if("Polygon"===e.type){const s=or(e.coordinates,n,i),a=cr(t.geometry(),r,n,i);if(!We(r,n))return !1;for(const t of a)if(!rr(t,s))return !1}if("MultiPolygon"===e.type){const s=lr(e.coordinates,n,i),a=cr(t.geometry(),r,n,i);if(!We(r,n))return !1;for(const t of a)if(!nr(t,s))return !1}return !0}(t,this.geometries);if("LineString"===t.geometryType())return function(t,e){const r=[1/0,1/0,-1/0,-1/0],n=[1/0,1/0,-1/0,-1/0],i=t.canonicalID();if("Polygon"===e.type){const s=or(e.coordinates,n,i),a=hr(t.geometry(),r,n,i);if(!We(r,n))return !1;for(const t of a)if(!ir(t,s))return !1}if("MultiPolygon"===e.type){const s=lr(e.coordinates,n,i),a=hr(t.geometry(),r,n,i);if(!We(r,n))return !1;for(const t of a)if(!sr(t,s))return !1}return !0}(t,this.geometries)}return !1}eachChild(){}outputDefined(){return !0}}let fr=class{constructor(t=[],e=((t,e)=>te?1:0)){if(this.data=t,this.length=this.data.length,this.compare=e,this.length>0)for(let t=(this.length>>1)-1;t>=0;t--)this._down(t);}push(t){this.data.push(t),this._up(this.length++);}pop(){if(0===this.length)return;const t=this.data[0],e=this.data.pop();return --this.length>0&&(this.data[0]=e,this._down(0)),t}peek(){return this.data[0]}_up(t){const{data:e,compare:r}=this,n=e[t];for(;t>0;){const i=t-1>>1,s=e[i];if(r(n,s)>=0)break;e[t]=s,t=i;}e[t]=n;}_down(t){const{data:e,compare:r}=this,n=this.length>>1,i=e[t];for(;t=0)break;e[t]=e[n],t=n;}e[t]=i;}};function dr(t,e,r,n,i){yr(t,e,r,n||t.length-1,i||gr);}function yr(t,e,r,n,i){for(;n>r;){if(n-r>600){var s=n-r+1,a=e-r+1,o=Math.log(s),l=.5*Math.exp(2*o/3),u=.5*Math.sqrt(o*l*(s-l)/s)*(a-s/2<0?-1:1);yr(t,e,Math.max(r,Math.floor(e-a*l/s+u)),Math.min(n,Math.floor(e+(s-a)*l/s+u)),i);}var c=t[e],h=r,p=n;for(mr(t,r,e),i(t[n],c)>0&&mr(t,r,n);h0;)p--;}0===i(t[r],c)?mr(t,r,p):mr(t,++p,n),p<=e&&(r=p+1),e<=p&&(n=p-1);}}function mr(t,e,r){var n=t[e];t[e]=t[r],t[r]=n;}function gr(t,e){return te?1:0}function xr(t,e){if(t.length<=1)return [t];const r=[];let n,i;for(const e of t){const t=br(e);0!==t&&(e.area=Math.abs(t),void 0===i&&(i=t<0),i===t<0?(n&&r.push(n),n=[e]):n.push(e));}if(n&&r.push(n),e>1)for(let t=0;t1?(l=t[o+1][0],u=t[o+1][1]):p>0&&(l+=c/this.kx*p,u+=h/this.ky*p)),c=this.wrap(e[0]-l)*this.kx,h=(e[1]-u)*this.ky;const f=c*c+h*h;f180;)t-=360;return t}}function kr(t,e){return e[0]-t[0]}function Mr(t){return t[1]-t[0]+1}function Ir(t,e){return t[1]>=t[0]&&t[1]t[1])return [null,null];const r=Mr(t);if(e){if(2===r)return [t,null];const e=Math.floor(r/2);return [[t[0],t[0]+e],[t[0]+e,t[1]]]}if(1===r)return [t,null];const n=Math.floor(r/2)-1;return [[t[0],t[0]+n],[t[0]+n+1,t[1]]]}function Pr(t,e){if(!Ir(e,t.length))return [1/0,1/0,-1/0,-1/0];const r=[1/0,1/0,-1/0,-1/0];for(let n=e[0];n<=e[1];++n)Je(r,t[n]);return r}function Cr(t){const e=[1/0,1/0,-1/0,-1/0];for(const r of t)for(const t of r)Je(e,t);return e}function Br(t){return t[0]!==-1/0&&t[1]!==-1/0&&t[2]!==1/0&&t[3]!==1/0}function Vr(t,e,r){if(!Br(t)||!Br(e))return NaN;let n=0,i=0;return t[2]e[2]&&(n=t[0]-e[2]),t[1]>e[3]&&(i=t[1]-e[3]),t[3]=n)return n;if(We(i,s)){if(Or(t,e))return 0}else if(Or(e,t))return 0;let a=1/0;for(const n of t)for(let t=0,i=n.length,s=i-1;t0;){const i=a.pop();if(i[0]>=s)continue;const l=i[1],u=e?50:100;if(Mr(l)<=u){if(!Ir(l,t.length))return NaN;if(e){const e=Dr(t,l,r,n);if(isNaN(e)||0===e)return e;s=Math.min(s,e);}else for(let e=l[0];e<=l[1];++e){const i=Lr(t[e],r,n);if(s=Math.min(s,i),0===s)return 0}}else {const r=zr(l,e);Rr(a,s,n,t,o,r[0]),Rr(a,s,n,t,o,r[1]);}}return s}function Nr(t,e,r,n,i,s=1/0){let a=Math.min(s,i.distance(t[0],r[0]));if(0===a)return a;const o=new fr([[0,[0,t.length-1],[0,r.length-1]]],kr);for(;o.length>0;){const s=o.pop();if(s[0]>=a)continue;const l=s[1],u=s[2],c=e?50:100,h=n?50:100;if(Mr(l)<=c&&Mr(u)<=h){if(!Ir(l,t.length)&&Ir(u,r.length))return NaN;let s;if(e&&n)s=Tr(t,l,r,u,i),a=Math.min(a,s);else if(e&&!n){const e=t.slice(l[0],l[1]+1);for(let t=u[0];t<=u[1];++t)if(s=Er(r[t],e,i),a=Math.min(a,s),0===a)return a}else if(!e&&n){const e=r.slice(u[0],u[1]+1);for(let r=l[0];r<=l[1];++r)if(s=Er(t[r],e,i),a=Math.min(a,s),0===a)return a}else s=$r(t,l,r,u,i),a=Math.min(a,s);}else {const s=zr(l,e),c=zr(u,n);Ur(o,a,i,t,r,s[0],c[0]),Ur(o,a,i,t,r,s[0],c[1]),Ur(o,a,i,t,r,s[1],c[0]),Ur(o,a,i,t,r,s[1],c[1]);}}return a}function Zr(t){return "MultiPolygon"===t.type?t.coordinates.map((t=>({type:"Polygon",coordinates:t}))):"MultiLineString"===t.type?t.coordinates.map((t=>({type:"LineString",coordinates:t}))):"MultiPoint"===t.type?t.coordinates.map((t=>({type:"Point",coordinates:t}))):[t]}class Gr{constructor(t,e){this.type=ut,this.geojson=t,this.geometries=e;}static parse(t,e){if(2!==t.length)return e.error(`'distance' expression requires exactly one argument, but found ${t.length-1} instead.`);if(re(t[1])){const e=t[1];if("FeatureCollection"===e.type)return new Gr(e,e.features.map((t=>Zr(t.geometry))).flat());if("Feature"===e.type)return new Gr(e,Zr(e.geometry));if("type"in e&&"coordinates"in e)return new Gr(e,Zr(e))}return e.error("'distance' expression requires valid geojson object that contains polygon geometry type.")}evaluate(t){if(null!=t.geometry()&&null!=t.canonicalID()){if("Point"===t.geometryType())return function(t,e){const r=t.geometry(),n=r.flat().map((e=>Ye([e.x,e.y],t.canonical)));if(0===r.length)return NaN;const i=new Sr(n[0][1]);let s=1/0;for(const t of e){switch(t.type){case"Point":s=Math.min(s,Nr(n,!1,[t.coordinates],!1,i,s));break;case"LineString":s=Math.min(s,Nr(n,!1,t.coordinates,!0,i,s));break;case"Polygon":s=Math.min(s,qr(n,!1,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries);if("LineString"===t.geometryType())return function(t,e){const r=t.geometry(),n=r.flat().map((e=>Ye([e.x,e.y],t.canonical)));if(0===r.length)return NaN;const i=new Sr(n[0][1]);let s=1/0;for(const t of e){switch(t.type){case"Point":s=Math.min(s,Nr(n,!0,[t.coordinates],!1,i,s));break;case"LineString":s=Math.min(s,Nr(n,!0,t.coordinates,!0,i,s));break;case"Polygon":s=Math.min(s,qr(n,!0,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries);if("Polygon"===t.geometryType())return function(t,e){const r=t.geometry();if(0===r.length||0===r[0].length)return NaN;const n=xr(r,0).map((e=>e.map((e=>e.map((e=>Ye([e.x,e.y],t.canonical))))))),i=new Sr(n[0][0][0][1]);let s=1/0;for(const t of e)for(const e of n){switch(t.type){case"Point":s=Math.min(s,qr([t.coordinates],!1,e,i,s));break;case"LineString":s=Math.min(s,qr(t.coordinates,!0,e,i,s));break;case"Polygon":s=Math.min(s,jr(e,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries)}return NaN}eachChild(){}outputDefined(){return !0}}const Kr={"==":Le,"!=":De,">":je,"<":Oe,">=":Ue,"<=":Re,array:le,at:me,boolean:le,case:be,coalesce:Ee,collator:qe,format:Ze,image:Ge,in:ge,"index-of":xe,interpolate:Be,"interpolate-hcl":Be,"interpolate-lab":Be,length:Ke,let:de,literal:se,match:ve,number:le,"number-format":Ne,object:le,slice:we,step:Ae,string:le,"to-boolean":ce,"to-color":ce,"to-number":ce,"to-string":ce,var:ye,within:pr,distance:Gr};class Xr{constructor(t,e,r,n){this.name=t,this.type=e,this._evaluate=r,this.args=n;}evaluate(t){return this._evaluate(t,this.args)}eachChild(t){this.args.forEach(t);}outputDefined(){return !1}static parse(t,e){const r=t[0],n=Xr.definitions[r];if(!n)return e.error(`Unknown expression "${r}". If you wanted a literal array, use ["literal", [...]].`,0);const i=Array.isArray(n)?n[0]:n.type,s=Array.isArray(n)?[[n[1],n[2]]]:n.overloads,a=s.filter((([e])=>!Array.isArray(e)||e.length===t.length-1));let o=null;for(const[n,s]of a){o=new fe(e.registry,Qr,e.path,null,e.scope);const a=[];let l=!1;for(let e=1;e{return e=t,Array.isArray(e)?`(${e.map(wt).join(", ")})`:`(${wt(e.type)}...)`;var e;})).join(" | "),n=[];for(let r=1;r{r=e?r&&Qr(t):r&&t instanceof se;})),!!r&&tn(t)&&rn(t,["zoom","heatmap-density","line-progress","accumulated","is-supported-script"])}function tn(t){if(t instanceof Xr){if("get"===t.name&&1===t.args.length)return !1;if("feature-state"===t.name)return !1;if("has"===t.name&&1===t.args.length)return !1;if("properties"===t.name||"geometry-type"===t.name||"id"===t.name)return !1;if(/^filter-/.test(t.name))return !1}if(t instanceof pr)return !1;if(t instanceof Gr)return !1;let e=!0;return t.eachChild((t=>{e&&!tn(t)&&(e=!1);})),e}function en(t){if(t instanceof Xr&&"feature-state"===t.name)return !1;let e=!0;return t.eachChild((t=>{e&&!en(t)&&(e=!1);})),e}function rn(t,e){if(t instanceof Xr&&e.indexOf(t.name)>=0)return !1;let r=!0;return t.eachChild((t=>{r&&!rn(t,e)&&(r=!1);})),r}function nn(t){return {result:"success",value:t}}function sn(t){return {result:"error",value:t}}function an(t){return "data-driven"===t["property-type"]||"cross-faded-data-driven"===t["property-type"]}function on(t){return !!t.expression&&t.expression.parameters.indexOf("zoom")>-1}function ln(t){return !!t.expression&&t.expression.interpolated}function un(t){return t instanceof Number?"number":t instanceof String?"string":t instanceof Boolean?"boolean":Array.isArray(t)?"array":null===t?"null":typeof t}function cn(t){return "object"==typeof t&&null!==t&&!Array.isArray(t)}function hn(t){return t}function pn(t,e){const r="color"===e.type,n=t.stops&&"object"==typeof t.stops[0][0],i=n||!(n||void 0!==t.property),s=t.type||(ln(e)?"exponential":"interval");if(r||"padding"===e.type){const n=r?Kt.parse:Jt.parse;(t=st({},t)).stops&&(t.stops=t.stops.map((t=>[t[0],n(t[1])]))),t.default=n(t.default?t.default:e.default);}if(t.colorSpace&&"rgb"!==(a=t.colorSpace)&&"hcl"!==a&&"lab"!==a)throw new Error(`Unknown color space: "${t.colorSpace}"`);var a;let o,l,u;if("exponential"===s)o=mn;else if("interval"===s)o=yn;else if("categorical"===s){o=dn,l=Object.create(null);for(const e of t.stops)l[e[0]]=e[1];u=typeof t.stops[0][0];}else {if("identity"!==s)throw new Error(`Unknown function type "${s}"`);o=gn;}if(n){const r={},n=[];for(let e=0;et[0])),evaluate:({zoom:r},n)=>mn({stops:i,base:t.base},e,r).evaluate(r,n)}}if(i){const r="exponential"===s?{name:"exponential",base:void 0!==t.base?t.base:1}:null;return {kind:"camera",interpolationType:r,interpolationFactor:Be.interpolationFactor.bind(void 0,r),zoomStops:t.stops.map((t=>t[0])),evaluate:({zoom:r})=>o(t,e,r,l,u)}}return {kind:"source",evaluate(r,n){const i=n&&n.properties?n.properties[t.property]:void 0;return void 0===i?fn(t.default,e.default):o(t,e,i,l,u)}}}function fn(t,e,r){return void 0!==t?t:void 0!==e?e:void 0!==r?r:void 0}function dn(t,e,r,n,i){return fn(typeof r===i?n[r]:void 0,t.default,e.default)}function yn(t,e,r){if("number"!==un(r))return fn(t.default,e.default);const n=t.stops.length;if(1===n)return t.stops[0][1];if(r<=t.stops[0][0])return t.stops[0][1];if(r>=t.stops[n-1][0])return t.stops[n-1][1];const i=_e(t.stops.map((t=>t[0])),r);return t.stops[i][1]}function mn(t,e,r){const n=void 0!==t.base?t.base:1;if("number"!==un(r))return fn(t.default,e.default);const i=t.stops.length;if(1===i)return t.stops[0][1];if(r<=t.stops[0][0])return t.stops[0][1];if(r>=t.stops[i-1][0])return t.stops[i-1][1];const s=_e(t.stops.map((t=>t[0])),r),a=function(t,e,r,n){const i=n-r,s=t-r;return 0===i?0:1===e?s/i:(Math.pow(e,s)-1)/(Math.pow(e,i)-1)}(r,n,t.stops[s][0],t.stops[s+1][0]),o=t.stops[s][1],l=t.stops[s+1][1],u=Ce[e.type]||hn;return "function"==typeof o.evaluate?{evaluate(...e){const r=o.evaluate.apply(void 0,e),n=l.evaluate.apply(void 0,e);if(void 0!==r&&void 0!==n)return u(r,n,a,t.colorSpace)}}:u(o,l,a,t.colorSpace)}function gn(t,e,r){switch(e.type){case"color":r=Kt.parse(r);break;case"formatted":r=Yt.fromString(r.toString());break;case"resolvedImage":r=te.fromString(r.toString());break;case"padding":r=Jt.parse(r);break;default:un(r)===e.type||"enum"===e.type&&e.values[r]||(r=void 0);}return fn(r,t.default,e.default)}Xr.register(Kr,{error:[{kind:"error"},[ct],(t,[e])=>{throw new ae(e.evaluate(t))}],typeof:[ct,[dt],(t,[e])=>wt(ne(e.evaluate(t)))],"to-rgba":[bt(ut,4),[pt],(t,[e])=>{const[r,n,i,s]=e.evaluate(t).rgb;return [255*r,255*n,255*i,s]}],rgb:[pt,[ut,ut,ut],Hr],rgba:[pt,[ut,ut,ut,ut],Hr],has:{type:ht,overloads:[[[ct],(t,[e])=>Yr(e.evaluate(t),t.properties())],[[ct,ft],(t,[e,r])=>Yr(e.evaluate(t),r.evaluate(t))]]},get:{type:dt,overloads:[[[ct],(t,[e])=>Jr(e.evaluate(t),t.properties())],[[ct,ft],(t,[e,r])=>Jr(e.evaluate(t),r.evaluate(t))]]},"feature-state":[dt,[ct],(t,[e])=>Jr(e.evaluate(t),t.featureState||{})],properties:[ft,[],t=>t.properties()],"geometry-type":[ct,[],t=>t.geometryType()],id:[dt,[],t=>t.id()],zoom:[ut,[],t=>t.globals.zoom],"heatmap-density":[ut,[],t=>t.globals.heatmapDensity||0],"line-progress":[ut,[],t=>t.globals.lineProgress||0],accumulated:[dt,[],t=>void 0===t.globals.accumulated?null:t.globals.accumulated],"+":[ut,Wr(ut),(t,e)=>{let r=0;for(const n of e)r+=n.evaluate(t);return r}],"*":[ut,Wr(ut),(t,e)=>{let r=1;for(const n of e)r*=n.evaluate(t);return r}],"-":{type:ut,overloads:[[[ut,ut],(t,[e,r])=>e.evaluate(t)-r.evaluate(t)],[[ut],(t,[e])=>-e.evaluate(t)]]},"/":[ut,[ut,ut],(t,[e,r])=>e.evaluate(t)/r.evaluate(t)],"%":[ut,[ut,ut],(t,[e,r])=>e.evaluate(t)%r.evaluate(t)],ln2:[ut,[],()=>Math.LN2],pi:[ut,[],()=>Math.PI],e:[ut,[],()=>Math.E],"^":[ut,[ut,ut],(t,[e,r])=>Math.pow(e.evaluate(t),r.evaluate(t))],sqrt:[ut,[ut],(t,[e])=>Math.sqrt(e.evaluate(t))],log10:[ut,[ut],(t,[e])=>Math.log(e.evaluate(t))/Math.LN10],ln:[ut,[ut],(t,[e])=>Math.log(e.evaluate(t))],log2:[ut,[ut],(t,[e])=>Math.log(e.evaluate(t))/Math.LN2],sin:[ut,[ut],(t,[e])=>Math.sin(e.evaluate(t))],cos:[ut,[ut],(t,[e])=>Math.cos(e.evaluate(t))],tan:[ut,[ut],(t,[e])=>Math.tan(e.evaluate(t))],asin:[ut,[ut],(t,[e])=>Math.asin(e.evaluate(t))],acos:[ut,[ut],(t,[e])=>Math.acos(e.evaluate(t))],atan:[ut,[ut],(t,[e])=>Math.atan(e.evaluate(t))],min:[ut,Wr(ut),(t,e)=>Math.min(...e.map((e=>e.evaluate(t))))],max:[ut,Wr(ut),(t,e)=>Math.max(...e.map((e=>e.evaluate(t))))],abs:[ut,[ut],(t,[e])=>Math.abs(e.evaluate(t))],round:[ut,[ut],(t,[e])=>{const r=e.evaluate(t);return r<0?-Math.round(-r):Math.round(r)}],floor:[ut,[ut],(t,[e])=>Math.floor(e.evaluate(t))],ceil:[ut,[ut],(t,[e])=>Math.ceil(e.evaluate(t))],"filter-==":[ht,[ct,dt],(t,[e,r])=>t.properties()[e.value]===r.value],"filter-id-==":[ht,[dt],(t,[e])=>t.id()===e.value],"filter-type-==":[ht,[ct],(t,[e])=>t.geometryType()===e.value],"filter-<":[ht,[ct,dt],(t,[e,r])=>{const n=t.properties()[e.value],i=r.value;return typeof n==typeof i&&n{const r=t.id(),n=e.value;return typeof r==typeof n&&r":[ht,[ct,dt],(t,[e,r])=>{const n=t.properties()[e.value],i=r.value;return typeof n==typeof i&&n>i}],"filter-id->":[ht,[dt],(t,[e])=>{const r=t.id(),n=e.value;return typeof r==typeof n&&r>n}],"filter-<=":[ht,[ct,dt],(t,[e,r])=>{const n=t.properties()[e.value],i=r.value;return typeof n==typeof i&&n<=i}],"filter-id-<=":[ht,[dt],(t,[e])=>{const r=t.id(),n=e.value;return typeof r==typeof n&&r<=n}],"filter->=":[ht,[ct,dt],(t,[e,r])=>{const n=t.properties()[e.value],i=r.value;return typeof n==typeof i&&n>=i}],"filter-id->=":[ht,[dt],(t,[e])=>{const r=t.id(),n=e.value;return typeof r==typeof n&&r>=n}],"filter-has":[ht,[dt],(t,[e])=>e.value in t.properties()],"filter-has-id":[ht,[],t=>null!==t.id()&&void 0!==t.id()],"filter-type-in":[ht,[bt(ct)],(t,[e])=>e.value.indexOf(t.geometryType())>=0],"filter-id-in":[ht,[bt(dt)],(t,[e])=>e.value.indexOf(t.id())>=0],"filter-in-small":[ht,[ct,bt(dt)],(t,[e,r])=>r.value.indexOf(t.properties()[e.value])>=0],"filter-in-large":[ht,[ct,bt(dt)],(t,[e,r])=>function(t,e,r,n){for(;r<=n;){const i=r+n>>1;if(e[i]===t)return !0;e[i]>t?n=i-1:r=i+1;}return !1}(t.properties()[e.value],r.value,0,r.value.length-1)],all:{type:ht,overloads:[[[ht,ht],(t,[e,r])=>e.evaluate(t)&&r.evaluate(t)],[Wr(ht),(t,e)=>{for(const r of e)if(!r.evaluate(t))return !1;return !0}]]},any:{type:ht,overloads:[[[ht,ht],(t,[e,r])=>e.evaluate(t)||r.evaluate(t)],[Wr(ht),(t,e)=>{for(const r of e)if(r.evaluate(t))return !0;return !1}]]},"!":[ht,[ht],(t,[e])=>!e.evaluate(t)],"is-supported-script":[ht,[ct],(t,[e])=>{const r=t.globals&&t.globals.isSupportedScript;return !r||r(e.evaluate(t))}],upcase:[ct,[ct],(t,[e])=>e.evaluate(t).toUpperCase()],downcase:[ct,[ct],(t,[e])=>e.evaluate(t).toLowerCase()],concat:[ct,Wr(dt),(t,e)=>e.map((e=>ie(e.evaluate(t)))).join("")],"resolved-locale":[ct,[yt],(t,[e])=>e.evaluate(t).resolvedLocale()]});class xn{constructor(t,e){var r;this.expression=t,this._warningHistory={},this._evaluator=new pe,this._defaultValue=e?"color"===(r=e).type&&cn(r.default)?new Kt(0,0,0,0):"color"===r.type?Kt.parse(r.default)||null:"padding"===r.type?Jt.parse(r.default)||null:"variableAnchorOffsetCollection"===r.type?Qt.parse(r.default)||null:void 0===r.default?null:r.default:null,this._enumValues=e&&"enum"===e.type?e.values:null;}evaluateWithoutErrorHandling(t,e,r,n,i,s){return this._evaluator.globals=t,this._evaluator.feature=e,this._evaluator.featureState=r,this._evaluator.canonical=n,this._evaluator.availableImages=i||null,this._evaluator.formattedSection=s,this.expression.evaluate(this._evaluator)}evaluate(t,e,r,n,i,s){this._evaluator.globals=t,this._evaluator.feature=e||null,this._evaluator.featureState=r||null,this._evaluator.canonical=n,this._evaluator.availableImages=i||null,this._evaluator.formattedSection=s||null;try{const t=this.expression.evaluate(this._evaluator);if(null==t||"number"==typeof t&&t!=t)return this._defaultValue;if(this._enumValues&&!(t in this._enumValues))throw new ae(`Expected value to be one of ${Object.keys(this._enumValues).map((t=>JSON.stringify(t))).join(", ")}, but found ${JSON.stringify(t)} instead.`);return t}catch(t){return this._warningHistory[t.message]||(this._warningHistory[t.message]=!0,"undefined"!=typeof console&&console.warn(t.message)),this._defaultValue}}}function vn(t){return Array.isArray(t)&&t.length>0&&"string"==typeof t[0]&&t[0]in Kr}function bn(t,e){const r=new fe(Kr,Qr,[],e?function(t){const e={color:pt,string:ct,number:ut,enum:ct,boolean:ht,formatted:mt,padding:gt,resolvedImage:xt,variableAnchorOffsetCollection:vt};return "array"===t.type?bt(e[t.value]||dt,t.length):e[t.type]}(e):void 0),n=r.parse(t,void 0,void 0,void 0,e&&"string"===e.type?{typeAnnotation:"coerce"}:void 0);return n?nn(new xn(n,e)):sn(r.errors)}class wn{constructor(t,e){this.kind=t,this._styleExpression=e,this.isStateDependent="constant"!==t&&!en(e.expression);}evaluateWithoutErrorHandling(t,e,r,n,i,s){return this._styleExpression.evaluateWithoutErrorHandling(t,e,r,n,i,s)}evaluate(t,e,r,n,i,s){return this._styleExpression.evaluate(t,e,r,n,i,s)}}class _n{constructor(t,e,r,n){this.kind=t,this.zoomStops=r,this._styleExpression=e,this.isStateDependent="camera"!==t&&!en(e.expression),this.interpolationType=n;}evaluateWithoutErrorHandling(t,e,r,n,i,s){return this._styleExpression.evaluateWithoutErrorHandling(t,e,r,n,i,s)}evaluate(t,e,r,n,i,s){return this._styleExpression.evaluate(t,e,r,n,i,s)}interpolationFactor(t,e,r){return this.interpolationType?Be.interpolationFactor(this.interpolationType,t,e,r):0}}function An(t,e){const r=bn(t,e);if("error"===r.result)return r;const n=r.value.expression,i=tn(n);if(!i&&!an(e))return sn([new at("","data expressions not supported")]);const s=rn(n,["zoom"]);if(!s&&!on(e))return sn([new at("","zoom expressions not supported")]);const a=kn(n);return a||s?a instanceof at?sn([a]):a instanceof Be&&!ln(e)?sn([new at("",'"interpolate" expressions cannot be used with this property')]):nn(a?new _n(i?"camera":"composite",r.value,a.labels,a instanceof Be?a.interpolation:void 0):new wn(i?"constant":"source",r.value)):sn([new at("",'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.')])}class Sn{constructor(t,e){this._parameters=t,this._specification=e,st(this,pn(this._parameters,this._specification));}static deserialize(t){return new Sn(t._parameters,t._specification)}static serialize(t){return {_parameters:t._parameters,_specification:t._specification}}}function kn(t){let e=null;if(t instanceof de)e=kn(t.result);else if(t instanceof Ee){for(const r of t.args)if(e=kn(r),e)break}else (t instanceof Ae||t instanceof Be)&&t.input instanceof Xr&&"zoom"===t.input.name&&(e=t);return e instanceof at||t.eachChild((t=>{const r=kn(t);r instanceof at?e=r:!e&&r?e=new at("",'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'):e&&r&&e!==r&&(e=new at("",'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'));})),e}function Mn(t){if(!0===t||!1===t)return !0;if(!Array.isArray(t)||0===t.length)return !1;switch(t[0]){case"has":return t.length>=2&&"$id"!==t[1]&&"$type"!==t[1];case"in":return t.length>=3&&("string"!=typeof t[1]||Array.isArray(t[2]));case"!in":case"!has":case"none":return !1;case"==":case"!=":case">":case">=":case"<":case"<=":return 3!==t.length||Array.isArray(t[1])||Array.isArray(t[2]);case"any":case"all":for(const e of t.slice(1))if(!Mn(e)&&"boolean"!=typeof e)return !1;return !0;default:return !0}}const In={type:"boolean",default:!1,transition:!1,"property-type":"data-driven",expression:{interpolated:!1,parameters:["zoom","feature"]}};function zn(t){if(null==t)return {filter:()=>!0,needGeometry:!1};Mn(t)||(t=Bn(t));const e=bn(t,In);if("error"===e.result)throw new Error(e.value.map((t=>`${t.key}: ${t.message}`)).join(", "));return {filter:(t,r,n)=>e.value.evaluate(t,r,{},n),needGeometry:Cn(t)}}function Pn(t,e){return te?1:0}function Cn(t){if(!Array.isArray(t))return !1;if("within"===t[0]||"distance"===t[0])return !0;for(let e=1;e"===e||"<="===e||">="===e?Vn(t[1],t[2],e):"any"===e?(r=t.slice(1),["any"].concat(r.map(Bn))):"all"===e?["all"].concat(t.slice(1).map(Bn)):"none"===e?["all"].concat(t.slice(1).map(Bn).map(Tn)):"in"===e?En(t[1],t.slice(2)):"!in"===e?Tn(En(t[1],t.slice(2))):"has"===e?Fn(t[1]):"!has"!==e||Tn(Fn(t[1]));var r;}function Vn(t,e,r){switch(t){case"$type":return [`filter-type-${r}`,e];case"$id":return [`filter-id-${r}`,e];default:return [`filter-${r}`,t,e]}}function En(t,e){if(0===e.length)return !1;switch(t){case"$type":return ["filter-type-in",["literal",e]];case"$id":return ["filter-id-in",["literal",e]];default:return e.length>200&&!e.some((t=>typeof t!=typeof e[0]))?["filter-in-large",t,["literal",e.sort(Pn)]]:["filter-in-small",t,["literal",e]]}}function Fn(t){switch(t){case"$type":return !0;case"$id":return ["filter-has-id"];default:return ["filter-has",t]}}function Tn(t){return ["!",t]}function $n(t){const e=typeof t;if("number"===e||"boolean"===e||"string"===e||null==t)return JSON.stringify(t);if(Array.isArray(t)){let e="[";for(const r of t)e+=`${$n(r)},`;return `${e}]`}const r=Object.keys(t).sort();let n="{";for(let e=0;en.maximum?[new it(e,r,`${r} is greater than the maximum value ${n.maximum}`)]:[]}function Nn(t){const e=t.valueSpec,r=On(t.value.type);let n,i,s,a={};const o="categorical"!==r&&void 0===t.value.property,l=!o,u="array"===un(t.value.stops)&&"array"===un(t.value.stops[0])&&"object"===un(t.value.stops[0][0]),c=Rn({key:t.key,value:t.value,valueSpec:t.styleSpec.function,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,objectElementValidators:{stops:function(t){if("identity"===r)return [new it(t.key,t.value,'identity function may not have a "stops" property')];let e=[];const n=t.value;return e=e.concat(Un({key:t.key,value:n,valueSpec:t.valueSpec,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,arrayElementValidator:h})),"array"===un(n)&&0===n.length&&e.push(new it(t.key,n,"array must have at least one stop")),e},default:function(t){return t.validateSpec({key:t.key,value:t.value,valueSpec:e,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec})}}});return "identity"===r&&o&&c.push(new it(t.key,t.value,'missing required property "property"')),"identity"===r||t.value.stops||c.push(new it(t.key,t.value,'missing required property "stops"')),"exponential"===r&&t.valueSpec.expression&&!ln(t.valueSpec)&&c.push(new it(t.key,t.value,"exponential functions not supported")),t.styleSpec.$version>=8&&(l&&!an(t.valueSpec)?c.push(new it(t.key,t.value,"property functions not supported")):o&&!on(t.valueSpec)&&c.push(new it(t.key,t.value,"zoom functions not supported"))),"categorical"!==r&&!u||void 0!==t.value.property||c.push(new it(t.key,t.value,'"property" property is required')),c;function h(t){let r=[];const n=t.value,o=t.key;if("array"!==un(n))return [new it(o,n,`array expected, ${un(n)} found`)];if(2!==n.length)return [new it(o,n,`array length 2 expected, length ${n.length} found`)];if(u){if("object"!==un(n[0]))return [new it(o,n,`object expected, ${un(n[0])} found`)];if(void 0===n[0].zoom)return [new it(o,n,"object stop key must have zoom")];if(void 0===n[0].value)return [new it(o,n,"object stop key must have value")];if(s&&s>On(n[0].zoom))return [new it(o,n[0].zoom,"stop zoom values must appear in ascending order")];On(n[0].zoom)!==s&&(s=On(n[0].zoom),i=void 0,a={}),r=r.concat(Rn({key:`${o}[0]`,value:n[0],valueSpec:{zoom:{}},validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,objectElementValidators:{zoom:qn,value:p}}));}else r=r.concat(p({key:`${o}[0]`,value:n[0],valueSpec:{},validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec},n));return vn(jn(n[1]))?r.concat([new it(`${o}[1]`,n[1],"expressions are not allowed in function stops.")]):r.concat(t.validateSpec({key:`${o}[1]`,value:n[1],valueSpec:e,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec}))}function p(t,s){const o=un(t.value),l=On(t.value),u=null!==t.value?t.value:s;if(n){if(o!==n)return [new it(t.key,u,`${o} stop domain type must match previous stop domain type ${n}`)]}else n=o;if("number"!==o&&"string"!==o&&"boolean"!==o)return [new it(t.key,u,"stop domain value must be a number, string, or boolean")];if("number"!==o&&"categorical"!==r){let n=`number expected, ${o} found`;return an(e)&&void 0===r&&(n+='\nIf you intended to use a categorical function, specify `"type": "categorical"`.'),[new it(t.key,u,n)]}return "categorical"!==r||"number"!==o||isFinite(l)&&Math.floor(l)===l?"categorical"!==r&&"number"===o&&void 0!==i&&lnew it(`${t.key}${e.key}`,t.value,e.message)));const r=e.value.expression||e.value._styleExpression.expression;if("property"===t.expressionContext&&"text-font"===t.propertyKey&&!r.outputDefined())return [new it(t.key,t.value,`Invalid data expression for "${t.propertyKey}". Output values must be contained as literals within the expression.`)];if("property"===t.expressionContext&&"layout"===t.propertyType&&!en(r))return [new it(t.key,t.value,'"feature-state" data expressions are not supported with layout properties.')];if("filter"===t.expressionContext&&!en(r))return [new it(t.key,t.value,'"feature-state" data expressions are not supported with filters.')];if(t.expressionContext&&0===t.expressionContext.indexOf("cluster")){if(!rn(r,["zoom","feature-state"]))return [new it(t.key,t.value,'"zoom" and "feature-state" expressions are not supported with cluster properties.')];if("cluster-initial"===t.expressionContext&&!tn(r))return [new it(t.key,t.value,"Feature data expressions are not supported with initial expression part of cluster properties.")]}return []}function Gn(t){const e=t.key,r=t.value,n=t.valueSpec,i=[];return Array.isArray(n.values)?-1===n.values.indexOf(On(r))&&i.push(new it(e,r,`expected one of [${n.values.join(", ")}], ${JSON.stringify(r)} found`)):-1===Object.keys(n.values).indexOf(On(r))&&i.push(new it(e,r,`expected one of [${Object.keys(n.values).join(", ")}], ${JSON.stringify(r)} found`)),i}function Kn(t){return Mn(jn(t.value))?Zn(st({},t,{expressionContext:"filter",valueSpec:{value:"boolean"}})):Xn(t)}function Xn(t){const e=t.value,r=t.key;if("array"!==un(e))return [new it(r,e,`array expected, ${un(e)} found`)];const n=t.styleSpec;let i,s=[];if(e.length<1)return [new it(r,e,"filter array must have at least 1 element")];switch(s=s.concat(Gn({key:`${r}[0]`,value:e[0],valueSpec:n.filter_operator,style:t.style,styleSpec:t.styleSpec})),On(e[0])){case"<":case"<=":case">":case">=":e.length>=2&&"$type"===On(e[1])&&s.push(new it(r,e,`"$type" cannot be use with operator "${e[0]}"`));case"==":case"!=":3!==e.length&&s.push(new it(r,e,`filter array for operator "${e[0]}" must have 3 elements`));case"in":case"!in":e.length>=2&&(i=un(e[1]),"string"!==i&&s.push(new it(`${r}[1]`,e[1],`string expected, ${i} found`)));for(let a=2;a{t in r&&e.push(new it(n,r[t],`"${t}" is prohibited for ref layers`));})),i.layers.forEach((e=>{On(e.id)===o&&(t=e);})),t?t.ref?e.push(new it(n,r.ref,"ref cannot reference another ref layer")):a=On(t.type):e.push(new it(n,r.ref,`ref layer "${o}" not found`));}else if("background"!==a)if(r.source){const t=i.sources&&i.sources[r.source],s=t&&On(t.type);t?"vector"===s&&"raster"===a?e.push(new it(n,r.source,`layer "${r.id}" requires a raster source`)):"raster-dem"!==s&&"hillshade"===a?e.push(new it(n,r.source,`layer "${r.id}" requires a raster-dem source`)):"raster"===s&&"raster"!==a?e.push(new it(n,r.source,`layer "${r.id}" requires a vector source`)):"vector"!==s||r["source-layer"]?"raster-dem"===s&&"hillshade"!==a?e.push(new it(n,r.source,"raster-dem source can only be used with layer type 'hillshade'.")):"line"!==a||!r.paint||!r.paint["line-gradient"]||"geojson"===s&&t.lineMetrics||e.push(new it(n,r,`layer "${r.id}" specifies a line-gradient, which requires a GeoJSON source with \`lineMetrics\` enabled.`)):e.push(new it(n,r,`layer "${r.id}" must specify a "source-layer"`)):e.push(new it(n,r.source,`source "${r.source}" not found`));}else e.push(new it(n,r,'missing required property "source"'));return e=e.concat(Rn({key:n,value:r,valueSpec:s.layer,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":()=>[],type:()=>t.validateSpec({key:`${n}.type`,value:r.type,valueSpec:s.layer.type,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,object:r,objectKey:"type"}),filter:Kn,layout:t=>Rn({layer:r,key:t.key,value:t.value,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":t=>Jn(st({layerType:a},t))}}),paint:t=>Rn({layer:r,key:t.key,value:t.value,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":t=>Yn(st({layerType:a},t))}})}})),e}function Qn(t){const e=t.value,r=t.key,n=un(e);return "string"!==n?[new it(r,e,`string expected, ${n} found`)]:[]}const ti={promoteId:function({key:t,value:e}){if("string"===un(e))return Qn({key:t,value:e});{const r=[];for(const n in e)r.push(...Qn({key:`${t}.${n}`,value:e[n]}));return r}}};function ei(t){const e=t.value,r=t.key,n=t.styleSpec,i=t.style,s=t.validateSpec;if(!e.type)return [new it(r,e,'"type" is required')];const a=On(e.type);let o;switch(a){case"vector":case"raster":return o=Rn({key:r,value:e,valueSpec:n[`source_${a.replace("-","_")}`],style:t.style,styleSpec:n,objectElementValidators:ti,validateSpec:s}),o;case"raster-dem":return o=function(t){var e;const r=null!==(e=t.sourceName)&&void 0!==e?e:"",n=t.value,i=t.styleSpec,s=i.source_raster_dem,a=t.style;let o=[];const l=un(n);if(void 0===n)return o;if("object"!==l)return o.push(new it("source_raster_dem",n,`object expected, ${l} found`)),o;const u="custom"===On(n.encoding),c=["redFactor","greenFactor","blueFactor","baseShift"],h=t.value.encoding?`"${t.value.encoding}"`:"Default";for(const e in n)!u&&c.includes(e)?o.push(new it(e,n[e],`In "${r}": "${e}" is only valid when "encoding" is set to "custom". ${h} encoding found`)):s[e]?o=o.concat(t.validateSpec({key:e,value:n[e],valueSpec:s[e],validateSpec:t.validateSpec,style:a,styleSpec:i})):o.push(new it(e,n[e],`unknown property "${e}"`));return o}({sourceName:r,value:e,style:t.style,styleSpec:n,validateSpec:s}),o;case"geojson":if(o=Rn({key:r,value:e,valueSpec:n.source_geojson,style:i,styleSpec:n,validateSpec:s,objectElementValidators:ti}),e.cluster)for(const t in e.clusterProperties){const[n,i]=e.clusterProperties[t],a="string"==typeof n?[n,["accumulated"],["get",t]]:n;o.push(...Zn({key:`${r}.${t}.map`,value:i,validateSpec:s,expressionContext:"cluster-map"})),o.push(...Zn({key:`${r}.${t}.reduce`,value:a,validateSpec:s,expressionContext:"cluster-reduce"}));}return o;case"video":return Rn({key:r,value:e,valueSpec:n.source_video,style:i,validateSpec:s,styleSpec:n});case"image":return Rn({key:r,value:e,valueSpec:n.source_image,style:i,validateSpec:s,styleSpec:n});case"canvas":return [new it(r,null,"Please use runtime APIs to add canvas sources, rather than including them in stylesheets.","source.canvas")];default:return Gn({key:`${r}.type`,value:e.type,valueSpec:{values:["vector","raster","raster-dem","geojson","video","image"]},style:i,validateSpec:s,styleSpec:n})}}function ri(t){const e=t.value,r=t.styleSpec,n=r.light,i=t.style;let s=[];const a=un(e);if(void 0===e)return s;if("object"!==a)return s=s.concat([new it("light",e,`object expected, ${a} found`)]),s;for(const a in e){const o=a.match(/^(.*)-transition$/);s=s.concat(o&&n[o[1]]&&n[o[1]].transition?t.validateSpec({key:a,value:e[a],valueSpec:r.transition,validateSpec:t.validateSpec,style:i,styleSpec:r}):n[a]?t.validateSpec({key:a,value:e[a],valueSpec:n[a],validateSpec:t.validateSpec,style:i,styleSpec:r}):[new it(a,e[a],`unknown property "${a}"`)]);}return s}function ni(t){const e=t.value,r=t.styleSpec,n=r.sky,i=t.style,s=un(e);if(void 0===e)return [];if("object"!==s)return [new it("sky",e,`object expected, ${s} found`)];let a=[];for(const s in e)a=a.concat(n[s]?t.validateSpec({key:s,value:e[s],valueSpec:n[s],style:i,styleSpec:r}):[new it(s,e[s],`unknown property "${s}"`)]);return a}function ii(t){const e=t.value,r=t.styleSpec,n=r.terrain,i=t.style;let s=[];const a=un(e);if(void 0===e)return s;if("object"!==a)return s=s.concat([new it("terrain",e,`object expected, ${a} found`)]),s;for(const a in e)s=s.concat(n[a]?t.validateSpec({key:a,value:e[a],valueSpec:n[a],validateSpec:t.validateSpec,style:i,styleSpec:r}):[new it(a,e[a],`unknown property "${a}"`)]);return s}function si(t){let e=[];const r=t.value,n=t.key;if(Array.isArray(r)){const i=[],s=[];for(const a in r)r[a].id&&i.includes(r[a].id)&&e.push(new it(n,r,`all the sprites' ids must be unique, but ${r[a].id} is duplicated`)),i.push(r[a].id),r[a].url&&s.includes(r[a].url)&&e.push(new it(n,r,`all the sprites' URLs must be unique, but ${r[a].url} is duplicated`)),s.push(r[a].url),e=e.concat(Rn({key:`${n}[${a}]`,value:r[a],valueSpec:{id:{type:"string",required:!0},url:{type:"string",required:!0}},validateSpec:t.validateSpec}));return e}return Qn({key:n,value:r})}const ai={"*":()=>[],array:Un,boolean:function(t){const e=t.value,r=t.key,n=un(e);return "boolean"!==n?[new it(r,e,`boolean expected, ${n} found`)]:[]},number:qn,color:function(t){const e=t.key,r=t.value,n=un(r);return "string"!==n?[new it(e,r,`color expected, ${n} found`)]:Kt.parse(String(r))?[]:[new it(e,r,`color expected, "${r}" found`)]},constants:Dn,enum:Gn,filter:Kn,function:Nn,layer:Wn,object:Rn,source:ei,light:ri,sky:ni,terrain:ii,projection:function(t){const e=t.value,r=t.styleSpec,n=r.projection,i=t.style,s=un(e);if(void 0===e)return [];if("object"!==s)return [new it("projection",e,`object expected, ${s} found`)];let a=[];for(const s in e)a=a.concat(n[s]?t.validateSpec({key:s,value:e[s],valueSpec:n[s],style:i,styleSpec:r}):[new it(s,e[s],`unknown property "${s}"`)]);return a},string:Qn,formatted:function(t){return 0===Qn(t).length?[]:Zn(t)},resolvedImage:function(t){return 0===Qn(t).length?[]:Zn(t)},padding:function(t){const e=t.key,r=t.value;if("array"===un(r)){if(r.length<1||r.length>4)return [new it(e,r,`padding requires 1 to 4 values; ${r.length} values found`)];const n={type:"number"};let i=[];for(let s=0;s[]}})),t.constants&&(r=r.concat(Dn({key:"constants",value:t.constants,style:t,styleSpec:e,validateSpec:oi}))),hi(r)}function ci(t){return function(e){return t({...e,validateSpec:oi})}}function hi(t){return [].concat(t).sort(((t,e)=>t.line-e.line))}function pi(t){return function(...e){return hi(t.apply(this,e))}}ui.source=pi(ci(ei)),ui.sprite=pi(ci(si)),ui.glyphs=pi(ci(li)),ui.light=pi(ci(ri)),ui.sky=pi(ci(ni)),ui.terrain=pi(ci(ii)),ui.layer=pi(ci(Wn)),ui.filter=pi(ci(Kn)),ui.paintProperty=pi(ci(Yn)),ui.layoutProperty=pi(ci(Jn));const fi=ui,di=fi.light,yi=fi.sky,mi=fi.paintProperty,gi=fi.layoutProperty;function xi(t,e){let r=!1;if(e&&e.length)for(const n of e)t.fire(new N(new Error(n.message))),r=!0;return r}class vi{constructor(t,e,r){const n=this.cells=[];if(t instanceof ArrayBuffer){this.arrayBuffer=t;const i=new Int32Array(this.arrayBuffer);t=i[0],this.d=(e=i[1])+2*(r=i[2]);for(let t=0;t=u[l+0]&&n>=u[l+1])?(a[h]=!0,s.push(i[h])):a[h]=!1;}}}}_forEachCell(t,e,r,n,i,s,a,o){const l=this._convertToCellCoord(t),u=this._convertToCellCoord(e),c=this._convertToCellCoord(r),h=this._convertToCellCoord(n);for(let p=l;p<=c;p++)for(let l=u;l<=h;l++){const u=this.d*l+p;if((!o||o(this._convertFromCellCoord(p),this._convertFromCellCoord(l),this._convertFromCellCoord(p+1),this._convertFromCellCoord(l+1)))&&i.call(this,t,e,r,n,u,s,a,o))return}}_convertFromCellCoord(t){return (t-this.padding)/this.scale}_convertToCellCoord(t){return Math.max(0,Math.min(this.d-1,Math.floor(t*this.scale)+this.padding))}toArrayBuffer(){if(this.arrayBuffer)return this.arrayBuffer;const t=this.cells,e=3+this.cells.length+1+1;let r=0;for(let t=0;t=0)continue;const s=t[n];i[n]=bi[r].shallow.indexOf(n)>=0?s:ki(s,e);}t instanceof Error&&(i.message=t.message);}if(i.$name)throw new Error("$name property is reserved for worker serialization logic.");return "Object"!==r&&(i.$name=r),i}function Mi(t){if(Si(t))return t;if(Array.isArray(t))return t.map(Mi);if("object"!=typeof t)throw new Error("can't deserialize object of type "+typeof t);const e=Ai(t)||"Object";if(!bi[e])throw new Error(`can't deserialize unregistered class ${e}`);const{klass:r}=bi[e];if(!r)throw new Error(`can't deserialize unregistered class ${e}`);if(r.deserialize)return r.deserialize(t);const n=Object.create(r.prototype);for(const r of Object.keys(t)){if("$name"===r)continue;const i=t[r];n[r]=bi[e].shallow.indexOf(r)>=0?i:Mi(i);}return n}class Ii{constructor(){this.first=!0;}update(t,e){const r=Math.floor(t);return this.first?(this.first=!1,this.lastIntegerZoom=r,this.lastIntegerZoomTime=0,this.lastZoom=t,this.lastFloorZoom=r,!0):(this.lastFloorZoom>r?(this.lastIntegerZoom=r+1,this.lastIntegerZoomTime=e):this.lastFloorZoomt>=128&&t<=255,"Hangul Jamo":t=>t>=4352&&t<=4607,Khmer:t=>t>=6016&&t<=6143,"General Punctuation":t=>t>=8192&&t<=8303,"Letterlike Symbols":t=>t>=8448&&t<=8527,"Number Forms":t=>t>=8528&&t<=8591,"Miscellaneous Technical":t=>t>=8960&&t<=9215,"Control Pictures":t=>t>=9216&&t<=9279,"Optical Character Recognition":t=>t>=9280&&t<=9311,"Enclosed Alphanumerics":t=>t>=9312&&t<=9471,"Geometric Shapes":t=>t>=9632&&t<=9727,"Miscellaneous Symbols":t=>t>=9728&&t<=9983,"Miscellaneous Symbols and Arrows":t=>t>=11008&&t<=11263,"Ideographic Description Characters":t=>t>=12272&&t<=12287,"CJK Symbols and Punctuation":t=>t>=12288&&t<=12351,Katakana:t=>t>=12448&&t<=12543,Kanbun:t=>t>=12688&&t<=12703,"CJK Strokes":t=>t>=12736&&t<=12783,"Enclosed CJK Letters and Months":t=>t>=12800&&t<=13055,"CJK Compatibility":t=>t>=13056&&t<=13311,"Yijing Hexagram Symbols":t=>t>=19904&&t<=19967,"Private Use Area":t=>t>=57344&&t<=63743,"Vertical Forms":t=>t>=65040&&t<=65055,"CJK Compatibility Forms":t=>t>=65072&&t<=65103,"Small Form Variants":t=>t>=65104&&t<=65135,"Halfwidth and Fullwidth Forms":t=>t>=65280&&t<=65519};function Pi(t){for(const e of t)if(Ti(e.charCodeAt(0)))return !0;return !1}function Ci(t){for(const e of t)if(!Ei(e.charCodeAt(0)))return !1;return !0}function Bi(t){const e=t.map((t=>{try{return new RegExp(`\\p{sc=${t}}`,"u").source}catch(t){return null}})).filter((t=>t));return new RegExp(e.join("|"),"u")}const Vi=Bi(["Arab","Dupl","Mong","Ougr","Syrc"]);function Ei(t){return !Vi.test(String.fromCodePoint(t))}const Fi=Bi(["Bopo","Hani","Hira","Kana","Kits","Nshu","Tang","Yiii"]);function Ti(t){return !(746!==t&&747!==t&&(t<4352||!(zi["CJK Compatibility Forms"](t)&&!(t>=65097&&t<=65103)||zi["CJK Compatibility"](t)||zi["CJK Strokes"](t)||!(!zi["CJK Symbols and Punctuation"](t)||t>=12296&&t<=12305||t>=12308&&t<=12319||12336===t)||zi["Enclosed CJK Letters and Months"](t)||zi["Ideographic Description Characters"](t)||zi.Kanbun(t)||zi.Katakana(t)&&12540!==t||!(!zi["Halfwidth and Fullwidth Forms"](t)||65288===t||65289===t||65293===t||t>=65306&&t<=65310||65339===t||65341===t||65343===t||t>=65371&&t<=65503||65507===t||t>=65512&&t<=65519)||!(!zi["Small Form Variants"](t)||t>=65112&&t<=65118||t>=65123&&t<=65126)||zi["Vertical Forms"](t)||zi["Yijing Hexagram Symbols"](t)||/\p{sc=Cans}/u.test(String.fromCodePoint(t))||/\p{sc=Hang}/u.test(String.fromCodePoint(t))||Fi.test(String.fromCodePoint(t)))))}function $i(t){return !(Ti(t)||function(t){return !!(zi["Latin-1 Supplement"](t)&&(167===t||169===t||174===t||177===t||188===t||189===t||190===t||215===t||247===t)||zi["General Punctuation"](t)&&(8214===t||8224===t||8225===t||8240===t||8241===t||8251===t||8252===t||8258===t||8263===t||8264===t||8265===t||8273===t)||zi["Letterlike Symbols"](t)||zi["Number Forms"](t)||zi["Miscellaneous Technical"](t)&&(t>=8960&&t<=8967||t>=8972&&t<=8991||t>=8996&&t<=9e3||9003===t||t>=9085&&t<=9114||t>=9150&&t<=9165||9167===t||t>=9169&&t<=9179||t>=9186&&t<=9215)||zi["Control Pictures"](t)&&9251!==t||zi["Optical Character Recognition"](t)||zi["Enclosed Alphanumerics"](t)||zi["Geometric Shapes"](t)||zi["Miscellaneous Symbols"](t)&&!(t>=9754&&t<=9759)||zi["Miscellaneous Symbols and Arrows"](t)&&(t>=11026&&t<=11055||t>=11088&&t<=11097||t>=11192&&t<=11243)||zi["CJK Symbols and Punctuation"](t)||zi.Katakana(t)||zi["Private Use Area"](t)||zi["CJK Compatibility Forms"](t)||zi["Small Form Variants"](t)||zi["Halfwidth and Fullwidth Forms"](t)||8734===t||8756===t||8757===t||t>=9984&&t<=10087||t>=10102&&t<=10131||65532===t||65533===t)}(t))}const Li=Bi(["Adlm","Arab","Armi","Avst","Chrs","Cprt","Egyp","Elym","Gara","Hatr","Hebr","Hung","Khar","Lydi","Mand","Mani","Mend","Merc","Mero","Narb","Nbat","Nkoo","Orkh","Palm","Phli","Phlp","Phnx","Prti","Rohg","Samr","Sarb","Sogo","Syrc","Thaa","Todr","Yezi"]);function Di(t){return Li.test(String.fromCodePoint(t))}function Oi(t,e){return !(!e&&Di(t)||t>=2304&&t<=3583||t>=3840&&t<=4255||zi.Khmer(t))}function ji(t){for(const e of t)if(Di(e.charCodeAt(0)))return !0;return !1}const Ri=new class{constructor(){this.applyArabicShaping=null,this.processBidirectionalText=null,this.processStyledBidirectionalText=null,this.pluginStatus="unavailable",this.pluginURL=null;}setState(t){this.pluginStatus=t.pluginStatus,this.pluginURL=t.pluginURL;}getState(){return {pluginStatus:this.pluginStatus,pluginURL:this.pluginURL}}setMethods(t){this.applyArabicShaping=t.applyArabicShaping,this.processBidirectionalText=t.processBidirectionalText,this.processStyledBidirectionalText=t.processStyledBidirectionalText;}isParsed(){return null!=this.applyArabicShaping&&null!=this.processBidirectionalText&&null!=this.processStyledBidirectionalText}getPluginURL(){return this.pluginURL}getRTLTextPluginStatus(){return this.pluginStatus}};class Ui{constructor(t,e){this.zoom=t,e?(this.now=e.now,this.fadeDuration=e.fadeDuration,this.zoomHistory=e.zoomHistory,this.transition=e.transition):(this.now=0,this.fadeDuration=0,this.zoomHistory=new Ii,this.transition={});}isSupportedScript(t){return function(t,e){for(const r of t)if(!Oi(r.charCodeAt(0),e))return !1;return !0}(t,"loaded"===Ri.getRTLTextPluginStatus())}crossFadingFactor(){return 0===this.fadeDuration?1:Math.min((this.now-this.zoomHistory.lastIntegerZoomTime)/this.fadeDuration,1)}getCrossfadeParameters(){const t=this.zoom,e=t-Math.floor(t),r=this.crossFadingFactor();return t>this.zoomHistory.lastIntegerZoom?{fromScale:2,toScale:1,t:e+(1-e)*r}:{fromScale:.5,toScale:1,t:1-(1-r)*e}}}class qi{constructor(t,e){this.property=t,this.value=e,this.expression=function(t,e){if(cn(t))return new Sn(t,e);if(vn(t)){const r=An(t,e);if("error"===r.result)throw new Error(r.value.map((t=>`${t.key}: ${t.message}`)).join(", "));return r.value}{let r=t;return "color"===e.type&&"string"==typeof t?r=Kt.parse(t):"padding"!==e.type||"number"!=typeof t&&!Array.isArray(t)?"variableAnchorOffsetCollection"===e.type&&Array.isArray(t)&&(r=Qt.parse(t)):r=Jt.parse(t),{kind:"constant",evaluate:()=>r}}}(void 0===e?t.specification.default:e,t.specification);}isDataDriven(){return "source"===this.expression.kind||"composite"===this.expression.kind}possiblyEvaluate(t,e,r){return this.property.possiblyEvaluate(this,t,e,r)}}class Ni{constructor(t){this.property=t,this.value=new qi(t,void 0);}transitioned(t,e){return new Gi(this.property,this.value,e,g({},t.transition,this.transition),t.now)}untransitioned(){return new Gi(this.property,this.value,null,{},0)}}class Zi{constructor(t){this._properties=t,this._values=Object.create(t.defaultTransitionablePropertyValues);}getValue(t){return w(this._values[t].value.value)}setValue(t,e){Object.prototype.hasOwnProperty.call(this._values,t)||(this._values[t]=new Ni(this._values[t].property)),this._values[t].value=new qi(this._values[t].property,null===e?void 0:w(e));}getTransition(t){return w(this._values[t].transition)}setTransition(t,e){Object.prototype.hasOwnProperty.call(this._values,t)||(this._values[t]=new Ni(this._values[t].property)),this._values[t].transition=w(e)||void 0;}serialize(){const t={};for(const e of Object.keys(this._values)){const r=this.getValue(e);void 0!==r&&(t[e]=r);const n=this.getTransition(e);void 0!==n&&(t[`${e}-transition`]=n);}return t}transitioned(t,e){const r=new Ki(this._properties);for(const n of Object.keys(this._values))r._values[n]=this._values[n].transitioned(t,e._values[n]);return r}untransitioned(){const t=new Ki(this._properties);for(const e of Object.keys(this._values))t._values[e]=this._values[e].untransitioned();return t}}class Gi{constructor(t,e,r,n,i){this.property=t,this.value=e,this.begin=i+n.delay||0,this.end=this.begin+n.duration||0,t.specification.transition&&(n.delay||n.duration)&&(this.prior=r);}possiblyEvaluate(t,e,r){const n=t.now||0,i=this.value.possiblyEvaluate(t,e,r),s=this.prior;if(s){if(n>this.end)return this.prior=null,i;if(this.value.isDataDriven())return this.prior=null,i;if(n=1)return 1;const e=t*t,r=e*t;return 4*(t<.5?r:3*(t-e)+r-.75)}(a))}}return i}}class Ki{constructor(t){this._properties=t,this._values=Object.create(t.defaultTransitioningPropertyValues);}possiblyEvaluate(t,e,r){const n=new Yi(this._properties);for(const i of Object.keys(this._values))n._values[i]=this._values[i].possiblyEvaluate(t,e,r);return n}hasTransition(){for(const t of Object.keys(this._values))if(this._values[t].prior)return !0;return !1}}class Xi{constructor(t){this._properties=t,this._values=Object.create(t.defaultPropertyValues);}hasValue(t){return void 0!==this._values[t].value}getValue(t){return w(this._values[t].value)}setValue(t,e){this._values[t]=new qi(this._values[t].property,null===e?void 0:w(e));}serialize(){const t={};for(const e of Object.keys(this._values)){const r=this.getValue(e);void 0!==r&&(t[e]=r);}return t}possiblyEvaluate(t,e,r){const n=new Yi(this._properties);for(const i of Object.keys(this._values))n._values[i]=this._values[i].possiblyEvaluate(t,e,r);return n}}class Hi{constructor(t,e,r){this.property=t,this.value=e,this.parameters=r;}isConstant(){return "constant"===this.value.kind}constantOr(t){return "constant"===this.value.kind?this.value.value:t}evaluate(t,e,r,n){return this.property.evaluate(this.value,this.parameters,t,e,r,n)}}class Yi{constructor(t){this._properties=t,this._values=Object.create(t.defaultPossiblyEvaluatedValues);}get(t){return this._values[t]}}class Ji{constructor(t){this.specification=t;}possiblyEvaluate(t,e){if(t.isDataDriven())throw new Error("Value should not be data driven");return t.expression.evaluate(e)}interpolate(t,e,r){const n=Ce[this.specification.type];return n?n(t,e,r):t}}class Wi{constructor(t,e){this.specification=t,this.overrides=e;}possiblyEvaluate(t,e,r,n){return new Hi(this,"constant"===t.expression.kind||"camera"===t.expression.kind?{kind:"constant",value:t.expression.evaluate(e,null,{},r,n)}:t.expression,e)}interpolate(t,e,r){if("constant"!==t.value.kind||"constant"!==e.value.kind)return t;if(void 0===t.value.value||void 0===e.value.value)return new Hi(this,{kind:"constant",value:void 0},t.parameters);const n=Ce[this.specification.type];if(n){const i=n(t.value.value,e.value.value,r);return new Hi(this,{kind:"constant",value:i},t.parameters)}return t}evaluate(t,e,r,n,i,s){return "constant"===t.kind?t.value:t.evaluate(e,r,n,i,s)}}class Qi extends Wi{possiblyEvaluate(t,e,r,n){if(void 0===t.value)return new Hi(this,{kind:"constant",value:void 0},e);if("constant"===t.expression.kind){const i=t.expression.evaluate(e,null,{},r,n),s="resolvedImage"===t.property.specification.type&&"string"!=typeof i?i.name:i,a=this._calculate(s,s,s,e);return new Hi(this,{kind:"constant",value:a},e)}if("camera"===t.expression.kind){const r=this._calculate(t.expression.evaluate({zoom:e.zoom-1}),t.expression.evaluate({zoom:e.zoom}),t.expression.evaluate({zoom:e.zoom+1}),e);return new Hi(this,{kind:"constant",value:r},e)}return new Hi(this,t.expression,e)}evaluate(t,e,r,n,i,s){if("source"===t.kind){const a=t.evaluate(e,r,n,i,s);return this._calculate(a,a,a,e)}return "composite"===t.kind?this._calculate(t.evaluate({zoom:Math.floor(e.zoom)-1},r,n),t.evaluate({zoom:Math.floor(e.zoom)},r,n),t.evaluate({zoom:Math.floor(e.zoom)+1},r,n),e):t.value}_calculate(t,e,r,n){return n.zoom>n.zoomHistory.lastIntegerZoom?{from:t,to:e}:{from:r,to:e}}interpolate(t){return t}}class ts{constructor(t){this.specification=t;}possiblyEvaluate(t,e,r,n){if(void 0!==t.value){if("constant"===t.expression.kind){const i=t.expression.evaluate(e,null,{},r,n);return this._calculate(i,i,i,e)}return this._calculate(t.expression.evaluate(new Ui(Math.floor(e.zoom-1),e)),t.expression.evaluate(new Ui(Math.floor(e.zoom),e)),t.expression.evaluate(new Ui(Math.floor(e.zoom+1),e)),e)}}_calculate(t,e,r,n){return n.zoom>n.zoomHistory.lastIntegerZoom?{from:t,to:e}:{from:r,to:e}}interpolate(t){return t}}class es{constructor(t){this.specification=t;}possiblyEvaluate(t,e,r,n){return !!t.expression.evaluate(e,null,{},r,n)}interpolate(){return !1}}class rs{constructor(t){this.properties=t,this.defaultPropertyValues={},this.defaultTransitionablePropertyValues={},this.defaultTransitioningPropertyValues={},this.defaultPossiblyEvaluatedValues={},this.overridableProperties=[];for(const e in t){const r=t[e];r.specification.overridable&&this.overridableProperties.push(e);const n=this.defaultPropertyValues[e]=new qi(r,void 0),i=this.defaultTransitionablePropertyValues[e]=new Ni(r);this.defaultTransitioningPropertyValues[e]=i.untransitioned(),this.defaultPossiblyEvaluatedValues[e]=n.possiblyEvaluate({});}}}wi("DataDrivenProperty",Wi),wi("DataConstantProperty",Ji),wi("CrossFadedDataDrivenProperty",Qi),wi("CrossFadedProperty",ts),wi("ColorRampProperty",es);const ns="-transition";class is extends Z{constructor(t,e){if(super(),this.id=t.id,this.type=t.type,this._featureFilter={filter:()=>!0,needGeometry:!1},"custom"!==t.type&&(this.metadata=t.metadata,this.minzoom=t.minzoom,this.maxzoom=t.maxzoom,"background"!==t.type&&(this.source=t.source,this.sourceLayer=t["source-layer"],this.filter=t.filter),e.layout&&(this._unevaluatedLayout=new Xi(e.layout)),e.paint)){this._transitionablePaint=new Zi(e.paint);for(const e in t.paint)this.setPaintProperty(e,t.paint[e],{validate:!1});for(const e in t.layout)this.setLayoutProperty(e,t.layout[e],{validate:!1});this._transitioningPaint=this._transitionablePaint.untransitioned(),this.paint=new Yi(e.paint);}}getCrossfadeParameters(){return this._crossfadeParameters}getLayoutProperty(t){return "visibility"===t?this.visibility:this._unevaluatedLayout.getValue(t)}setLayoutProperty(t,e,r={}){null!=e&&this._validate(gi,`layers.${this.id}.layout.${t}`,t,e,r)||("visibility"!==t?this._unevaluatedLayout.setValue(t,e):this.visibility=e);}getPaintProperty(t){return t.endsWith(ns)?this._transitionablePaint.getTransition(t.slice(0,-11)):this._transitionablePaint.getValue(t)}setPaintProperty(t,e,r={}){if(null!=e&&this._validate(mi,`layers.${this.id}.paint.${t}`,t,e,r))return !1;if(t.endsWith(ns))return this._transitionablePaint.setTransition(t.slice(0,-11),e||void 0),!1;{const r=this._transitionablePaint._values[t],n="cross-faded-data-driven"===r.property.specification["property-type"],i=r.value.isDataDriven(),s=r.value;this._transitionablePaint.setValue(t,e),this._handleSpecialPaintPropertyUpdate(t);const a=this._transitionablePaint._values[t].value;return a.isDataDriven()||i||n||this._handleOverridablePaintPropertyUpdate(t,s,a)}}_handleSpecialPaintPropertyUpdate(t){}_handleOverridablePaintPropertyUpdate(t,e,r){return !1}isHidden(t){return !!(this.minzoom&&t=this.maxzoom)||"none"===this.visibility}updateTransitions(t){this._transitioningPaint=this._transitionablePaint.transitioned(t,this._transitioningPaint);}hasTransition(){return this._transitioningPaint.hasTransition()}recalculate(t,e){t.getCrossfadeParameters&&(this._crossfadeParameters=t.getCrossfadeParameters()),this._unevaluatedLayout&&(this.layout=this._unevaluatedLayout.possiblyEvaluate(t,void 0,e)),this.paint=this._transitioningPaint.possiblyEvaluate(t,void 0,e);}serialize(){const t={id:this.id,type:this.type,source:this.source,"source-layer":this.sourceLayer,metadata:this.metadata,minzoom:this.minzoom,maxzoom:this.maxzoom,filter:this.filter,layout:this._unevaluatedLayout&&this._unevaluatedLayout.serialize(),paint:this._transitionablePaint&&this._transitionablePaint.serialize()};return this.visibility&&(t.layout=t.layout||{},t.layout.visibility=this.visibility),b(t,((t,e)=>!(void 0===t||"layout"===e&&!Object.keys(t).length||"paint"===e&&!Object.keys(t).length)))}_validate(t,e,r,n,i={}){return (!i||!1!==i.validate)&&xi(this,t.call(fi,{key:e,layerType:this.type,objectKey:r,value:n,styleSpec:G,style:{glyphs:!0,sprite:!0}}))}is3D(){return !1}isTileClipped(){return !1}hasOffscreenPass(){return !1}resize(){}isStateDependent(){for(const t in this.paint._values){const e=this.paint.get(t);if(e instanceof Hi&&an(e.property.specification)&&("source"===e.value.kind||"composite"===e.value.kind)&&e.value.isStateDependent)return !0}return !1}}const ss={Int8:Int8Array,Uint8:Uint8Array,Int16:Int16Array,Uint16:Uint16Array,Int32:Int32Array,Uint32:Uint32Array,Float32:Float32Array};class as{constructor(t,e){this._structArray=t,this._pos1=e*this.size,this._pos2=this._pos1/2,this._pos4=this._pos1/4,this._pos8=this._pos1/8;}}class os{constructor(){this.isTransferred=!1,this.capacity=-1,this.resize(0);}static serialize(t,e){return t._trim(),e&&(t.isTransferred=!0,e.push(t.arrayBuffer)),{length:t.length,arrayBuffer:t.arrayBuffer}}static deserialize(t){const e=Object.create(this.prototype);return e.arrayBuffer=t.arrayBuffer,e.length=t.length,e.capacity=t.arrayBuffer.byteLength/e.bytesPerElement,e._refreshViews(),e}_trim(){this.length!==this.capacity&&(this.capacity=this.length,this.arrayBuffer=this.arrayBuffer.slice(0,this.length*this.bytesPerElement),this._refreshViews());}clear(){this.length=0;}resize(t){this.reserve(t),this.length=t;}reserve(t){if(t>this.capacity){this.capacity=Math.max(t,Math.floor(5*this.capacity),128),this.arrayBuffer=new ArrayBuffer(this.capacity*this.bytesPerElement);const e=this.uint8;this._refreshViews(),e&&this.uint8.set(e);}}_refreshViews(){throw new Error("_refreshViews() must be implemented by each concrete StructArray layout")}}function ls(t,e=1){let r=0,n=0;return {members:t.map((t=>{const i=ss[t.type].BYTES_PER_ELEMENT,s=r=us(r,Math.max(e,i)),a=t.components||1;return n=Math.max(n,i),r+=i*a,{name:t.name,type:t.type,components:a,offset:s}})),size:us(r,Math.max(n,e)),alignment:e}}function us(t,e){return Math.ceil(t/e)*e}class cs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e){const r=this.length;return this.resize(r+1),this.emplace(r,t,e)}emplace(t,e,r){const n=2*t;return this.int16[n+0]=e,this.int16[n+1]=r,t}}cs.prototype.bytesPerElement=4,wi("StructArrayLayout2i4",cs);class hs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r){const n=this.length;return this.resize(n+1),this.emplace(n,t,e,r)}emplace(t,e,r,n){const i=3*t;return this.int16[i+0]=e,this.int16[i+1]=r,this.int16[i+2]=n,t}}hs.prototype.bytesPerElement=6,wi("StructArrayLayout3i6",hs);class ps extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n){const i=this.length;return this.resize(i+1),this.emplace(i,t,e,r,n)}emplace(t,e,r,n,i){const s=4*t;return this.int16[s+0]=e,this.int16[s+1]=r,this.int16[s+2]=n,this.int16[s+3]=i,t}}ps.prototype.bytesPerElement=8,wi("StructArrayLayout4i8",ps);class fs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s){const a=this.length;return this.resize(a+1),this.emplace(a,t,e,r,n,i,s)}emplace(t,e,r,n,i,s,a){const o=6*t;return this.int16[o+0]=e,this.int16[o+1]=r,this.int16[o+2]=n,this.int16[o+3]=i,this.int16[o+4]=s,this.int16[o+5]=a,t}}fs.prototype.bytesPerElement=12,wi("StructArrayLayout2i4i12",fs);class ds extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s){const a=this.length;return this.resize(a+1),this.emplace(a,t,e,r,n,i,s)}emplace(t,e,r,n,i,s,a){const o=4*t,l=8*t;return this.int16[o+0]=e,this.int16[o+1]=r,this.uint8[l+4]=n,this.uint8[l+5]=i,this.uint8[l+6]=s,this.uint8[l+7]=a,t}}ds.prototype.bytesPerElement=8,wi("StructArrayLayout2i4ub8",ds);class ys extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e){const r=this.length;return this.resize(r+1),this.emplace(r,t,e)}emplace(t,e,r){const n=2*t;return this.float32[n+0]=e,this.float32[n+1]=r,t}}ys.prototype.bytesPerElement=8,wi("StructArrayLayout2f8",ys);class ms extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s,a,o,l,u){const c=this.length;return this.resize(c+1),this.emplace(c,t,e,r,n,i,s,a,o,l,u)}emplace(t,e,r,n,i,s,a,o,l,u,c){const h=10*t;return this.uint16[h+0]=e,this.uint16[h+1]=r,this.uint16[h+2]=n,this.uint16[h+3]=i,this.uint16[h+4]=s,this.uint16[h+5]=a,this.uint16[h+6]=o,this.uint16[h+7]=l,this.uint16[h+8]=u,this.uint16[h+9]=c,t}}ms.prototype.bytesPerElement=20,wi("StructArrayLayout10ui20",ms);class gs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s,a,o,l,u,c,h){const p=this.length;return this.resize(p+1),this.emplace(p,t,e,r,n,i,s,a,o,l,u,c,h)}emplace(t,e,r,n,i,s,a,o,l,u,c,h,p){const f=12*t;return this.int16[f+0]=e,this.int16[f+1]=r,this.int16[f+2]=n,this.int16[f+3]=i,this.uint16[f+4]=s,this.uint16[f+5]=a,this.uint16[f+6]=o,this.uint16[f+7]=l,this.int16[f+8]=u,this.int16[f+9]=c,this.int16[f+10]=h,this.int16[f+11]=p,t}}gs.prototype.bytesPerElement=24,wi("StructArrayLayout4i4ui4i24",gs);class xs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,r){const n=this.length;return this.resize(n+1),this.emplace(n,t,e,r)}emplace(t,e,r,n){const i=3*t;return this.float32[i+0]=e,this.float32[i+1]=r,this.float32[i+2]=n,t}}xs.prototype.bytesPerElement=12,wi("StructArrayLayout3f12",xs);class vs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.uint32[1*t+0]=e,t}}vs.prototype.bytesPerElement=4,wi("StructArrayLayout1ul4",vs);class bs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s,a,o,l){const u=this.length;return this.resize(u+1),this.emplace(u,t,e,r,n,i,s,a,o,l)}emplace(t,e,r,n,i,s,a,o,l,u){const c=10*t,h=5*t;return this.int16[c+0]=e,this.int16[c+1]=r,this.int16[c+2]=n,this.int16[c+3]=i,this.int16[c+4]=s,this.int16[c+5]=a,this.uint32[h+3]=o,this.uint16[c+8]=l,this.uint16[c+9]=u,t}}bs.prototype.bytesPerElement=20,wi("StructArrayLayout6i1ul2ui20",bs);class ws extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s){const a=this.length;return this.resize(a+1),this.emplace(a,t,e,r,n,i,s)}emplace(t,e,r,n,i,s,a){const o=6*t;return this.int16[o+0]=e,this.int16[o+1]=r,this.int16[o+2]=n,this.int16[o+3]=i,this.int16[o+4]=s,this.int16[o+5]=a,t}}ws.prototype.bytesPerElement=12,wi("StructArrayLayout2i2i2i12",ws);class _s extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i){const s=this.length;return this.resize(s+1),this.emplace(s,t,e,r,n,i)}emplace(t,e,r,n,i,s){const a=4*t,o=8*t;return this.float32[a+0]=e,this.float32[a+1]=r,this.float32[a+2]=n,this.int16[o+6]=i,this.int16[o+7]=s,t}}_s.prototype.bytesPerElement=16,wi("StructArrayLayout2f1f2i16",_s);class As extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s){const a=this.length;return this.resize(a+1),this.emplace(a,t,e,r,n,i,s)}emplace(t,e,r,n,i,s,a){const o=16*t,l=4*t,u=8*t;return this.uint8[o+0]=e,this.uint8[o+1]=r,this.float32[l+1]=n,this.float32[l+2]=i,this.int16[u+6]=s,this.int16[u+7]=a,t}}As.prototype.bytesPerElement=16,wi("StructArrayLayout2ub2f2i16",As);class Ss extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,r){const n=this.length;return this.resize(n+1),this.emplace(n,t,e,r)}emplace(t,e,r,n){const i=3*t;return this.uint16[i+0]=e,this.uint16[i+1]=r,this.uint16[i+2]=n,t}}Ss.prototype.bytesPerElement=6,wi("StructArrayLayout3ui6",Ss);class ks extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m){const g=this.length;return this.resize(g+1),this.emplace(g,t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m)}emplace(t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m,g){const x=24*t,v=12*t,b=48*t;return this.int16[x+0]=e,this.int16[x+1]=r,this.uint16[x+2]=n,this.uint16[x+3]=i,this.uint32[v+2]=s,this.uint32[v+3]=a,this.uint32[v+4]=o,this.uint16[x+10]=l,this.uint16[x+11]=u,this.uint16[x+12]=c,this.float32[v+7]=h,this.float32[v+8]=p,this.uint8[b+36]=f,this.uint8[b+37]=d,this.uint8[b+38]=y,this.uint32[v+10]=m,this.int16[x+22]=g,t}}ks.prototype.bytesPerElement=48,wi("StructArrayLayout2i2ui3ul3ui2f3ub1ul1i48",ks);class Ms extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,A,S,k,M,I){const z=this.length;return this.resize(z+1),this.emplace(z,t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,A,S,k,M,I)}emplace(t,e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,A,S,k,M,I,z){const P=32*t,C=16*t;return this.int16[P+0]=e,this.int16[P+1]=r,this.int16[P+2]=n,this.int16[P+3]=i,this.int16[P+4]=s,this.int16[P+5]=a,this.int16[P+6]=o,this.int16[P+7]=l,this.uint16[P+8]=u,this.uint16[P+9]=c,this.uint16[P+10]=h,this.uint16[P+11]=p,this.uint16[P+12]=f,this.uint16[P+13]=d,this.uint16[P+14]=y,this.uint16[P+15]=m,this.uint16[P+16]=g,this.uint16[P+17]=x,this.uint16[P+18]=v,this.uint16[P+19]=b,this.uint16[P+20]=w,this.uint16[P+21]=_,this.uint16[P+22]=A,this.uint32[C+12]=S,this.float32[C+13]=k,this.float32[C+14]=M,this.uint16[P+30]=I,this.uint16[P+31]=z,t}}Ms.prototype.bytesPerElement=64,wi("StructArrayLayout8i15ui1ul2f2ui64",Ms);class Is extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.float32[1*t+0]=e,t}}Is.prototype.bytesPerElement=4,wi("StructArrayLayout1f4",Is);class zs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,r){const n=this.length;return this.resize(n+1),this.emplace(n,t,e,r)}emplace(t,e,r,n){const i=3*t;return this.uint16[6*t+0]=e,this.float32[i+1]=r,this.float32[i+2]=n,t}}zs.prototype.bytesPerElement=12,wi("StructArrayLayout1ui2f12",zs);class Ps extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,r){const n=this.length;return this.resize(n+1),this.emplace(n,t,e,r)}emplace(t,e,r,n){const i=4*t;return this.uint32[2*t+0]=e,this.uint16[i+2]=r,this.uint16[i+3]=n,t}}Ps.prototype.bytesPerElement=8,wi("StructArrayLayout1ul2ui8",Ps);class Cs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e){const r=this.length;return this.resize(r+1),this.emplace(r,t,e)}emplace(t,e,r){const n=2*t;return this.uint16[n+0]=e,this.uint16[n+1]=r,t}}Cs.prototype.bytesPerElement=4,wi("StructArrayLayout2ui4",Cs);class Bs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.uint16[1*t+0]=e,t}}Bs.prototype.bytesPerElement=2,wi("StructArrayLayout1ui2",Bs);class Vs extends os{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,r,n){const i=this.length;return this.resize(i+1),this.emplace(i,t,e,r,n)}emplace(t,e,r,n,i){const s=4*t;return this.float32[s+0]=e,this.float32[s+1]=r,this.float32[s+2]=n,this.float32[s+3]=i,t}}Vs.prototype.bytesPerElement=16,wi("StructArrayLayout4f16",Vs);class Es extends as{get anchorPointX(){return this._structArray.int16[this._pos2+0]}get anchorPointY(){return this._structArray.int16[this._pos2+1]}get x1(){return this._structArray.int16[this._pos2+2]}get y1(){return this._structArray.int16[this._pos2+3]}get x2(){return this._structArray.int16[this._pos2+4]}get y2(){return this._structArray.int16[this._pos2+5]}get featureIndex(){return this._structArray.uint32[this._pos4+3]}get sourceLayerIndex(){return this._structArray.uint16[this._pos2+8]}get bucketIndex(){return this._structArray.uint16[this._pos2+9]}get anchorPoint(){return new s(this.anchorPointX,this.anchorPointY)}}Es.prototype.size=20;class Fs extends bs{get(t){return new Es(this,t)}}wi("CollisionBoxArray",Fs);class Ts extends as{get anchorX(){return this._structArray.int16[this._pos2+0]}get anchorY(){return this._structArray.int16[this._pos2+1]}get glyphStartIndex(){return this._structArray.uint16[this._pos2+2]}get numGlyphs(){return this._structArray.uint16[this._pos2+3]}get vertexStartIndex(){return this._structArray.uint32[this._pos4+2]}get lineStartIndex(){return this._structArray.uint32[this._pos4+3]}get lineLength(){return this._structArray.uint32[this._pos4+4]}get segment(){return this._structArray.uint16[this._pos2+10]}get lowerSize(){return this._structArray.uint16[this._pos2+11]}get upperSize(){return this._structArray.uint16[this._pos2+12]}get lineOffsetX(){return this._structArray.float32[this._pos4+7]}get lineOffsetY(){return this._structArray.float32[this._pos4+8]}get writingMode(){return this._structArray.uint8[this._pos1+36]}get placedOrientation(){return this._structArray.uint8[this._pos1+37]}set placedOrientation(t){this._structArray.uint8[this._pos1+37]=t;}get hidden(){return this._structArray.uint8[this._pos1+38]}set hidden(t){this._structArray.uint8[this._pos1+38]=t;}get crossTileID(){return this._structArray.uint32[this._pos4+10]}set crossTileID(t){this._structArray.uint32[this._pos4+10]=t;}get associatedIconIndex(){return this._structArray.int16[this._pos2+22]}}Ts.prototype.size=48;class $s extends ks{get(t){return new Ts(this,t)}}wi("PlacedSymbolArray",$s);class Ls extends as{get anchorX(){return this._structArray.int16[this._pos2+0]}get anchorY(){return this._structArray.int16[this._pos2+1]}get rightJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+2]}get centerJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+3]}get leftJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+4]}get verticalPlacedTextSymbolIndex(){return this._structArray.int16[this._pos2+5]}get placedIconSymbolIndex(){return this._structArray.int16[this._pos2+6]}get verticalPlacedIconSymbolIndex(){return this._structArray.int16[this._pos2+7]}get key(){return this._structArray.uint16[this._pos2+8]}get textBoxStartIndex(){return this._structArray.uint16[this._pos2+9]}get textBoxEndIndex(){return this._structArray.uint16[this._pos2+10]}get verticalTextBoxStartIndex(){return this._structArray.uint16[this._pos2+11]}get verticalTextBoxEndIndex(){return this._structArray.uint16[this._pos2+12]}get iconBoxStartIndex(){return this._structArray.uint16[this._pos2+13]}get iconBoxEndIndex(){return this._structArray.uint16[this._pos2+14]}get verticalIconBoxStartIndex(){return this._structArray.uint16[this._pos2+15]}get verticalIconBoxEndIndex(){return this._structArray.uint16[this._pos2+16]}get featureIndex(){return this._structArray.uint16[this._pos2+17]}get numHorizontalGlyphVertices(){return this._structArray.uint16[this._pos2+18]}get numVerticalGlyphVertices(){return this._structArray.uint16[this._pos2+19]}get numIconVertices(){return this._structArray.uint16[this._pos2+20]}get numVerticalIconVertices(){return this._structArray.uint16[this._pos2+21]}get useRuntimeCollisionCircles(){return this._structArray.uint16[this._pos2+22]}get crossTileID(){return this._structArray.uint32[this._pos4+12]}set crossTileID(t){this._structArray.uint32[this._pos4+12]=t;}get textBoxScale(){return this._structArray.float32[this._pos4+13]}get collisionCircleDiameter(){return this._structArray.float32[this._pos4+14]}get textAnchorOffsetStartIndex(){return this._structArray.uint16[this._pos2+30]}get textAnchorOffsetEndIndex(){return this._structArray.uint16[this._pos2+31]}}Ls.prototype.size=64;class Ds extends Ms{get(t){return new Ls(this,t)}}wi("SymbolInstanceArray",Ds);class Os extends Is{getoffsetX(t){return this.float32[1*t+0]}}wi("GlyphOffsetArray",Os);class js extends hs{getx(t){return this.int16[3*t+0]}gety(t){return this.int16[3*t+1]}gettileUnitDistanceFromAnchor(t){return this.int16[3*t+2]}}wi("SymbolLineVertexArray",js);class Rs extends as{get textAnchor(){return this._structArray.uint16[this._pos2+0]}get textOffset0(){return this._structArray.float32[this._pos4+1]}get textOffset1(){return this._structArray.float32[this._pos4+2]}}Rs.prototype.size=12;class Us extends zs{get(t){return new Rs(this,t)}}wi("TextAnchorOffsetArray",Us);class qs extends as{get featureIndex(){return this._structArray.uint32[this._pos4+0]}get sourceLayerIndex(){return this._structArray.uint16[this._pos2+2]}get bucketIndex(){return this._structArray.uint16[this._pos2+3]}}qs.prototype.size=8;class Ns extends Ps{get(t){return new qs(this,t)}}wi("FeatureIndexArray",Ns);class Zs extends cs{}class Gs extends cs{}class Ks extends cs{}class Xs extends fs{}class Hs extends ds{}class Ys extends ys{}class Js extends ms{}class Ws extends gs{}class Qs extends xs{}class ta extends vs{}class ea extends ws{}class ra extends As{}class na extends Ss{}class ia extends Cs{}const sa=ls([{name:"a_pos",components:2,type:"Int16"}],4),{members:aa}=sa;class oa{constructor(t=[]){this.segments=t;}prepareSegment(t,e,r,n){let i=this.segments[this.segments.length-1];return t>oa.MAX_VERTEX_ARRAY_LENGTH&&A(`Max vertices per segment is ${oa.MAX_VERTEX_ARRAY_LENGTH}: bucket requested ${t}`),(!i||i.vertexLength+t>oa.MAX_VERTEX_ARRAY_LENGTH||i.sortKey!==n)&&(i={vertexOffset:e.length,primitiveOffset:r.length,vertexLength:0,primitiveLength:0},void 0!==n&&(i.sortKey=n),this.segments.push(i)),i}get(){return this.segments}destroy(){for(const t of this.segments)for(const e in t.vaos)t.vaos[e].destroy();}static simpleSegment(t,e,r,n){return new oa([{vertexOffset:t,primitiveOffset:e,vertexLength:r,primitiveLength:n,vaos:{},sortKey:0}])}}function la(t,e){return 256*(t=y(Math.floor(t),0,255))+y(Math.floor(e),0,255)}oa.MAX_VERTEX_ARRAY_LENGTH=Math.pow(2,16)-1,wi("SegmentVector",oa);const ua=ls([{name:"a_pattern_from",components:4,type:"Uint16"},{name:"a_pattern_to",components:4,type:"Uint16"},{name:"a_pixel_ratio_from",components:1,type:"Uint16"},{name:"a_pixel_ratio_to",components:1,type:"Uint16"}]);var ca={exports:{}},ha={exports:{}};ha.exports=function(t,e){var r,n,i,s,a,o,l,u;for(n=t.length-(r=3&t.length),i=e,a=3432918353,o=461845907,u=0;u>>16)*a&65535)<<16)&4294967295)<<15|l>>>17))*o+(((l>>>16)*o&65535)<<16)&4294967295)<<13|i>>>19))+((5*(i>>>16)&65535)<<16)&4294967295))+((58964+(s>>>16)&65535)<<16);switch(l=0,r){case 3:l^=(255&t.charCodeAt(u+2))<<16;case 2:l^=(255&t.charCodeAt(u+1))<<8;case 1:i^=l=(65535&(l=(l=(65535&(l^=255&t.charCodeAt(u)))*a+(((l>>>16)*a&65535)<<16)&4294967295)<<15|l>>>17))*o+(((l>>>16)*o&65535)<<16)&4294967295;}return i^=t.length,i=2246822507*(65535&(i^=i>>>16))+((2246822507*(i>>>16)&65535)<<16)&4294967295,i=3266489909*(65535&(i^=i>>>13))+((3266489909*(i>>>16)&65535)<<16)&4294967295,(i^=i>>>16)>>>0};var pa=ha.exports,fa={exports:{}};fa.exports=function(t,e){for(var r,n=t.length,i=e^n,s=0;n>=4;)r=1540483477*(65535&(r=255&t.charCodeAt(s)|(255&t.charCodeAt(++s))<<8|(255&t.charCodeAt(++s))<<16|(255&t.charCodeAt(++s))<<24))+((1540483477*(r>>>16)&65535)<<16),i=1540483477*(65535&i)+((1540483477*(i>>>16)&65535)<<16)^(r=1540483477*(65535&(r^=r>>>24))+((1540483477*(r>>>16)&65535)<<16)),n-=4,++s;switch(n){case 3:i^=(255&t.charCodeAt(s+2))<<16;case 2:i^=(255&t.charCodeAt(s+1))<<8;case 1:i=1540483477*(65535&(i^=255&t.charCodeAt(s)))+((1540483477*(i>>>16)&65535)<<16);}return i=1540483477*(65535&(i^=i>>>13))+((1540483477*(i>>>16)&65535)<<16),(i^=i>>>15)>>>0};var da=pa,ya=fa.exports;ca.exports=da,ca.exports.murmur3=da,ca.exports.murmur2=ya;var ma=r(ca.exports);class ga{constructor(){this.ids=[],this.positions=[],this.indexed=!1;}add(t,e,r,n){this.ids.push(xa(t)),this.positions.push(e,r,n);}getPositions(t){if(!this.indexed)throw new Error("Trying to get index, but feature positions are not indexed");const e=xa(t);let r=0,n=this.ids.length-1;for(;r>1;this.ids[t]>=e?n=t:r=t+1;}const i=[];for(;this.ids[r]===e;)i.push({index:this.positions[3*r],start:this.positions[3*r+1],end:this.positions[3*r+2]}),r++;return i}static serialize(t,e){const r=new Float64Array(t.ids),n=new Uint32Array(t.positions);return va(r,n,0,r.length-1),e&&e.push(r.buffer,n.buffer),{ids:r,positions:n}}static deserialize(t){const e=new ga;return e.ids=t.ids,e.positions=t.positions,e.indexed=!0,e}}function xa(t){const e=+t;return !isNaN(e)&&e<=Number.MAX_SAFE_INTEGER?e:ma(String(t))}function va(t,e,r,n){for(;r>1];let s=r-1,a=n+1;for(;;){do{s++;}while(t[s]i);if(s>=a)break;ba(t,s,a),ba(e,3*s,3*a),ba(e,3*s+1,3*a+1),ba(e,3*s+2,3*a+2);}a-r`u_${t}`)),this.type=r;}setUniform(t,e,r){t.set(r.constantOr(this.value));}getBinding(t,e,r){return "color"===this.type?new Sa(t,e):new _a(t,e)}}class za{constructor(t,e){this.uniformNames=e.map((t=>`u_${t}`)),this.patternFrom=null,this.patternTo=null,this.pixelRatioFrom=1,this.pixelRatioTo=1;}setConstantPatternPositions(t,e){this.pixelRatioFrom=e.pixelRatio,this.pixelRatioTo=t.pixelRatio,this.patternFrom=e.tlbr,this.patternTo=t.tlbr;}setUniform(t,e,r,n){const i="u_pattern_to"===n?this.patternTo:"u_pattern_from"===n?this.patternFrom:"u_pixel_ratio_to"===n?this.pixelRatioTo:"u_pixel_ratio_from"===n?this.pixelRatioFrom:null;i&&t.set(i);}getBinding(t,e,r){return "u_pattern"===r.substr(0,9)?new Aa(t,e):new _a(t,e)}}class Pa{constructor(t,e,r,n){this.expression=t,this.type=r,this.maxValue=0,this.paintVertexAttributes=e.map((t=>({name:`a_${t}`,type:"Float32",components:"color"===r?2:1,offset:0}))),this.paintVertexArray=new n;}populatePaintArray(t,e,r,n,i){const s=this.paintVertexArray.length,a=this.expression.evaluate(new Ui(0),e,{},n,[],i);this.paintVertexArray.resize(t),this._setPaintValue(s,t,a);}updatePaintArray(t,e,r,n){const i=this.expression.evaluate({zoom:0},r,n);this._setPaintValue(t,e,i);}_setPaintValue(t,e,r){if("color"===this.type){const n=Ma(r);for(let r=t;r`u_${t}_t`)),this.type=r,this.useIntegerZoom=n,this.zoom=i,this.maxValue=0,this.paintVertexAttributes=e.map((t=>({name:`a_${t}`,type:"Float32",components:"color"===r?4:2,offset:0}))),this.paintVertexArray=new s;}populatePaintArray(t,e,r,n,i){const s=this.expression.evaluate(new Ui(this.zoom),e,{},n,[],i),a=this.expression.evaluate(new Ui(this.zoom+1),e,{},n,[],i),o=this.paintVertexArray.length;this.paintVertexArray.resize(t),this._setPaintValue(o,t,s,a);}updatePaintArray(t,e,r,n){const i=this.expression.evaluate({zoom:this.zoom},r,n),s=this.expression.evaluate({zoom:this.zoom+1},r,n);this._setPaintValue(t,e,i,s);}_setPaintValue(t,e,r,n){if("color"===this.type){const i=Ma(r),s=Ma(n);for(let r=t;r`#define HAS_UNIFORM_${t}`)));}return t}getBinderAttributes(){const t=[];for(const e in this.binders){const r=this.binders[e];if(r instanceof Pa||r instanceof Ca)for(let e=0;e!0)){this.programConfigurations={};for(const n of t)this.programConfigurations[n.id]=new Va(n,e,r);this.needsUpload=!1,this._featureMap=new ga,this._bufferOffset=0;}populatePaintArrays(t,e,r,n,i,s){for(const r in this.programConfigurations)this.programConfigurations[r].populatePaintArrays(t,e,n,i,s);void 0!==e.id&&this._featureMap.add(e.id,r,this._bufferOffset,t),this._bufferOffset=t,this.needsUpload=!0;}updatePaintArrays(t,e,r,n){for(const i of r)this.needsUpload=this.programConfigurations[i.id].updatePaintArrays(t,this._featureMap,e,i,n)||this.needsUpload;}get(t){return this.programConfigurations[t]}upload(t){if(this.needsUpload){for(const e in this.programConfigurations)this.programConfigurations[e].upload(t);this.needsUpload=!1;}}destroy(){for(const t in this.programConfigurations)this.programConfigurations[t].destroy();}}function Fa(t,e){return {"text-opacity":["opacity"],"icon-opacity":["opacity"],"text-color":["fill_color"],"icon-color":["fill_color"],"text-halo-color":["halo_color"],"icon-halo-color":["halo_color"],"text-halo-blur":["halo_blur"],"icon-halo-blur":["halo_blur"],"text-halo-width":["halo_width"],"icon-halo-width":["halo_width"],"line-gap-width":["gapwidth"],"line-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"],"fill-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"],"fill-extrusion-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"]}[t]||[t.replace(`${e}-`,"").replace(/-/g,"_")]}function Ta(t,e,r){const n={color:{source:ys,composite:Vs},number:{source:Is,composite:ys}},i=function(t){return {"line-pattern":{source:Js,composite:Js},"fill-pattern":{source:Js,composite:Js},"fill-extrusion-pattern":{source:Js,composite:Js}}[t]}(t);return i&&i[r]||n[e][r]}wi("ConstantBinder",Ia),wi("CrossFadedConstantBinder",za),wi("SourceExpressionBinder",Pa),wi("CrossFadedCompositeBinder",Ba),wi("CompositeExpressionBinder",Ca),wi("ProgramConfiguration",Va,{omit:["_buffers"]}),wi("ProgramConfigurationSet",Ea);const $a=8192,La=Math.pow(2,14)-1,Da=-La-1;function Oa(t){const e=$a/t.extent,r=t.loadGeometry();for(let t=0;tr.x+1||sr.y+1)&&A("Geometry exceeds allowed extent, reduce your vector tile buffer size");}}return r}function ja(t,e){return {type:t.type,id:t.id,properties:t.properties,geometry:e?Oa(t):[]}}function Ra(t,e,r,n,i){t.emplaceBack(2*e+(n+1)/2,2*r+(i+1)/2);}class Ua{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasPattern=!1,this.layoutVertexArray=new Gs,this.indexArray=new na,this.segments=new oa,this.programConfigurations=new Ea(t.layers,t.zoom),this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,r){const n=this.layers[0],i=[];let s=null,a=!1;"circle"===n.type&&(s=n.layout.get("circle-sort-key"),a=!s.isConstant());for(const{feature:e,id:n,index:o,sourceLayerIndex:l}of t){const t=this.layers[0]._featureFilter.needGeometry,u=ja(e,t);if(!this.layers[0]._featureFilter.filter(new Ui(this.zoom),u,r))continue;const c=a?s.evaluate(u,{},r):void 0,h={id:n,properties:e.properties,type:e.type,sourceLayerIndex:l,index:o,geometry:t?u.geometry:Oa(e),patterns:{},sortKey:c};i.push(h);}a&&i.sort(((t,e)=>t.sortKey-e.sortKey));for(const n of i){const{geometry:i,index:s,sourceLayerIndex:a}=n,o=t[s].feature;this.addFeature(n,i,s,r),e.featureIndex.insert(o,i,s,a,this.index);}}update(t,e,r){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,r);}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,aa),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy());}addFeature(t,e,r,n){for(const r of e)for(const e of r){const r=e.x,n=e.y;if(r<0||r>=$a||n<0||n>=$a)continue;const i=this.segments.prepareSegment(4,this.layoutVertexArray,this.indexArray,t.sortKey),s=i.vertexLength;Ra(this.layoutVertexArray,r,n,-1,-1),Ra(this.layoutVertexArray,r,n,1,-1),Ra(this.layoutVertexArray,r,n,1,1),Ra(this.layoutVertexArray,r,n,-1,1),this.indexArray.emplaceBack(s,s+1,s+2),this.indexArray.emplaceBack(s,s+3,s+2),i.vertexLength+=4,i.primitiveLength+=2;}this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length,t,r,{},n);}}function qa(t,e){for(let r=0;r1){if(Ka(t,e))return !0;for(let n=0;n1?r:r.sub(e)._mult(i)._add(e))}function Ja(t,e){let r,n,i,s=!1;for(let a=0;ae.y!=i.y>e.y&&e.x<(i.x-n.x)*(e.y-n.y)/(i.y-n.y)+n.x&&(s=!s);}return s}function Wa(t,e){let r=!1;for(let n=0,i=t.length-1;ne.y!=a.y>e.y&&e.x<(a.x-s.x)*(e.y-s.y)/(a.y-s.y)+s.x&&(r=!r);}return r}function Qa(t,e,r){const n=r[0],i=r[2];if(t.xi.x&&e.x>i.x||t.yi.y&&e.y>i.y)return !1;const s=S(t,e,r[0]);return s!==S(t,e,r[1])||s!==S(t,e,r[2])||s!==S(t,e,r[3])}function to(t,e,r){const n=e.paint.get(t).value;return "constant"===n.kind?n.value:r.programConfigurations.get(e.id).getMaxValue(t)}function eo(t){return Math.sqrt(t[0]*t[0]+t[1]*t[1])}function ro(t,e,r,n,i){if(!e[0]&&!e[1])return t;const a=s.convert(e)._mult(i);"viewport"===r&&a._rotate(-n);const o=[];for(let e=0;eyo(t,e)))}(l,o),p=c?u*a:u;for(const t of n)for(const e of t){const t=c?e:yo(e,o);let r=p;const n=po([],[e.x,e.y,0,1],o);if("viewport"===this.paint.get("circle-pitch-scale")&&"map"===this.paint.get("circle-pitch-alignment")?r*=n[3]/s.cameraToCenterDistance:"map"===this.paint.get("circle-pitch-scale")&&"viewport"===this.paint.get("circle-pitch-alignment")&&(r*=s.cameraToCenterDistance/n[3]),Na(h,t,r))return !0}return !1}}function yo(t,e){const r=po([],[t.x,t.y,0,1],e);return new s(r[0]/r[3],r[1]/r[3])}class mo extends Ua{}let go;wi("HeatmapBucket",mo,{omit:["layers"]});var xo={get paint(){return go=go||new rs({"heatmap-radius":new Wi(G.paint_heatmap["heatmap-radius"]),"heatmap-weight":new Wi(G.paint_heatmap["heatmap-weight"]),"heatmap-intensity":new Ji(G.paint_heatmap["heatmap-intensity"]),"heatmap-color":new es(G.paint_heatmap["heatmap-color"]),"heatmap-opacity":new Ji(G.paint_heatmap["heatmap-opacity"])})}};function vo(t,{width:e,height:r},n,i){if(i){if(i instanceof Uint8ClampedArray)i=new Uint8Array(i.buffer);else if(i.length!==e*r*n)throw new RangeError(`mismatched image size. expected: ${i.length} but got: ${e*r*n}`)}else i=new Uint8Array(e*r*n);return t.width=e,t.height=r,t.data=i,t}function bo(t,{width:e,height:r},n){if(e===t.width&&r===t.height)return;const i=vo({},{width:e,height:r},n);wo(t,i,{x:0,y:0},{x:0,y:0},{width:Math.min(t.width,e),height:Math.min(t.height,r)},n),t.width=e,t.height=r,t.data=i.data;}function wo(t,e,r,n,i,s){if(0===i.width||0===i.height)return e;if(i.width>t.width||i.height>t.height||r.x>t.width-i.width||r.y>t.height-i.height)throw new RangeError("out of range source coordinates for image copy");if(i.width>e.width||i.height>e.height||n.x>e.width-i.width||n.y>e.height-i.height)throw new RangeError("out of range destination coordinates for image copy");const a=t.data,o=e.data;if(a===o)throw new Error("srcData equals dstData, so image is already copied");for(let l=0;l{e[t.evaluationKey]=s;const a=t.expression.evaluate(e);i.data[r+n+0]=Math.floor(255*a.r/a.a),i.data[r+n+1]=Math.floor(255*a.g/a.a),i.data[r+n+2]=Math.floor(255*a.b/a.a),i.data[r+n+3]=Math.floor(255*a.a);};if(t.clips)for(let e=0,i=0;e80*r){o=1/0,l=1/0;let e=-1/0,n=-1/0;for(let s=r;se&&(e=r),i>n&&(n=i);}u=Math.max(e-o,n-l),u=0!==u?32767/u:0;}return To(s,a,r,o,l,u,0),a}function Eo(t,e,r,n,i){let s;if(i===function(t,e,r,n){let i=0;for(let s=e,a=r-n;s0)for(let i=e;i=e;i-=n)s=tl(i/n|0,t[i],t[i+1],s);return s&&Xo(s,s.next)&&(el(s),s=s.next),s}function Fo(t,e){if(!t)return t;e||(e=t);let r,n=t;do{if(r=!1,n.steiner||!Xo(n,n.next)&&0!==Ko(n.prev,n,n.next))n=n.next;else {if(el(n),n=e=n.prev,n===n.next)break;r=!0;}}while(r||n!==e);return e}function To(t,e,r,n,i,s,a){if(!t)return;!a&&s&&function(t,e,r,n){let i=t;do{0===i.z&&(i.z=qo(i.x,i.y,e,r,n)),i.prevZ=i.prev,i.nextZ=i.next,i=i.next;}while(i!==t);i.prevZ.nextZ=null,i.prevZ=null,function(t){let e,r=1;do{let n,i=t;t=null;let s=null;for(e=0;i;){e++;let a=i,o=0;for(let t=0;t0||l>0&&a;)0!==o&&(0===l||!a||i.z<=a.z)?(n=i,i=i.nextZ,o--):(n=a,a=a.nextZ,l--),s?s.nextZ=n:t=n,n.prevZ=s,s=n;i=a;}s.nextZ=null,r*=2;}while(e>1)}(i);}(t,n,i,s);let o=t;for(;t.prev!==t.next;){const l=t.prev,u=t.next;if(s?Lo(t,n,i,s):$o(t))e.push(l.i,t.i,u.i),el(t),t=u.next,o=u.next;else if((t=u)===o){a?1===a?To(t=Do(Fo(t),e),e,r,n,i,s,2):2===a&&Oo(t,e,r,n,i,s):To(Fo(t),e,r,n,i,s,1);break}}}function $o(t){const e=t.prev,r=t,n=t.next;if(Ko(e,r,n)>=0)return !1;const i=e.x,s=r.x,a=n.x,o=e.y,l=r.y,u=n.y,c=is?i>a?i:a:s>a?s:a,f=o>l?o>u?o:u:l>u?l:u;let d=n.next;for(;d!==e;){if(d.x>=c&&d.x<=p&&d.y>=h&&d.y<=f&&Zo(i,o,s,l,a,u,d.x,d.y)&&Ko(d.prev,d,d.next)>=0)return !1;d=d.next;}return !0}function Lo(t,e,r,n){const i=t.prev,s=t,a=t.next;if(Ko(i,s,a)>=0)return !1;const o=i.x,l=s.x,u=a.x,c=i.y,h=s.y,p=a.y,f=ol?o>u?o:u:l>u?l:u,m=c>h?c>p?c:p:h>p?h:p,g=qo(f,d,e,r,n),x=qo(y,m,e,r,n);let v=t.prevZ,b=t.nextZ;for(;v&&v.z>=g&&b&&b.z<=x;){if(v.x>=f&&v.x<=y&&v.y>=d&&v.y<=m&&v!==i&&v!==a&&Zo(o,c,l,h,u,p,v.x,v.y)&&Ko(v.prev,v,v.next)>=0)return !1;if(v=v.prevZ,b.x>=f&&b.x<=y&&b.y>=d&&b.y<=m&&b!==i&&b!==a&&Zo(o,c,l,h,u,p,b.x,b.y)&&Ko(b.prev,b,b.next)>=0)return !1;b=b.nextZ;}for(;v&&v.z>=g;){if(v.x>=f&&v.x<=y&&v.y>=d&&v.y<=m&&v!==i&&v!==a&&Zo(o,c,l,h,u,p,v.x,v.y)&&Ko(v.prev,v,v.next)>=0)return !1;v=v.prevZ;}for(;b&&b.z<=x;){if(b.x>=f&&b.x<=y&&b.y>=d&&b.y<=m&&b!==i&&b!==a&&Zo(o,c,l,h,u,p,b.x,b.y)&&Ko(b.prev,b,b.next)>=0)return !1;b=b.nextZ;}return !0}function Do(t,e){let r=t;do{const n=r.prev,i=r.next.next;!Xo(n,i)&&Ho(n,r,r.next,i)&&Wo(n,i)&&Wo(i,n)&&(e.push(n.i,r.i,i.i),el(r),el(r.next),r=t=i),r=r.next;}while(r!==t);return Fo(r)}function Oo(t,e,r,n,i,s){let a=t;do{let t=a.next.next;for(;t!==a.prev;){if(a.i!==t.i&&Go(a,t)){let o=Qo(a,t);return a=Fo(a,a.next),o=Fo(o,o.next),To(a,e,r,n,i,s,0),void To(o,e,r,n,i,s,0)}t=t.next;}a=a.next;}while(a!==t)}function jo(t,e){return t.x-e.x}function Ro(t,e){const r=function(t,e){let r=e;const n=t.x,i=t.y;let s,a=-1/0;do{if(i<=r.y&&i>=r.next.y&&r.next.y!==r.y){const t=r.x+(i-r.y)*(r.next.x-r.x)/(r.next.y-r.y);if(t<=n&&t>a&&(a=t,s=r.x=r.x&&r.x>=l&&n!==r.x&&Zo(is.x||r.x===s.x&&Uo(s,r)))&&(s=r,c=e);}r=r.next;}while(r!==o);return s}(t,e);if(!r)return e;const n=Qo(r,t);return Fo(n,n.next),Fo(r,r.next)}function Uo(t,e){return Ko(t.prev,t,e.prev)<0&&Ko(e.next,t,t.next)<0}function qo(t,e,r,n,i){return (t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=(t-r)*i|0)|t<<8))|t<<4))|t<<2))|t<<1))|(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=(e-n)*i|0)|e<<8))|e<<4))|e<<2))|e<<1))<<1}function No(t){let e=t,r=t;do{(e.x=(t-a)*(s-o)&&(t-a)*(n-o)>=(r-a)*(e-o)&&(r-a)*(s-o)>=(i-a)*(n-o)}function Go(t,e){return t.next.i!==e.i&&t.prev.i!==e.i&&!function(t,e){let r=t;do{if(r.i!==t.i&&r.next.i!==t.i&&r.i!==e.i&&r.next.i!==e.i&&Ho(r,r.next,t,e))return !0;r=r.next;}while(r!==t);return !1}(t,e)&&(Wo(t,e)&&Wo(e,t)&&function(t,e){let r=t,n=!1;const i=(t.x+e.x)/2,s=(t.y+e.y)/2;do{r.y>s!=r.next.y>s&&r.next.y!==r.y&&i<(r.next.x-r.x)*(s-r.y)/(r.next.y-r.y)+r.x&&(n=!n),r=r.next;}while(r!==t);return n}(t,e)&&(Ko(t.prev,t,e.prev)||Ko(t,e.prev,e))||Xo(t,e)&&Ko(t.prev,t,t.next)>0&&Ko(e.prev,e,e.next)>0)}function Ko(t,e,r){return (e.y-t.y)*(r.x-e.x)-(e.x-t.x)*(r.y-e.y)}function Xo(t,e){return t.x===e.x&&t.y===e.y}function Ho(t,e,r,n){const i=Jo(Ko(t,e,r)),s=Jo(Ko(t,e,n)),a=Jo(Ko(r,n,t)),o=Jo(Ko(r,n,e));return i!==s&&a!==o||!(0!==i||!Yo(t,r,e))||!(0!==s||!Yo(t,n,e))||!(0!==a||!Yo(r,t,n))||!(0!==o||!Yo(r,e,n))}function Yo(t,e,r){return e.x<=Math.max(t.x,r.x)&&e.x>=Math.min(t.x,r.x)&&e.y<=Math.max(t.y,r.y)&&e.y>=Math.min(t.y,r.y)}function Jo(t){return t>0?1:t<0?-1:0}function Wo(t,e){return Ko(t.prev,t,t.next)<0?Ko(t,e,t.next)>=0&&Ko(t,t.prev,e)>=0:Ko(t,e,t.prev)<0||Ko(t,t.next,e)<0}function Qo(t,e){const r=rl(t.i,t.x,t.y),n=rl(e.i,e.x,e.y),i=t.next,s=e.prev;return t.next=e,e.prev=t,r.next=i,i.prev=r,n.next=r,r.prev=n,s.next=n,n.prev=s,n}function tl(t,e,r,n){const i=rl(t,e,r);return n?(i.next=n.next,i.prev=n,n.next.prev=i,n.next=i):(i.prev=i,i.next=i),i}function el(t){t.next.prev=t.prev,t.prev.next=t.next,t.prevZ&&(t.prevZ.nextZ=t.nextZ),t.nextZ&&(t.nextZ.prevZ=t.prevZ);}function rl(t,e,r){return {i:t,x:e,y:r,prev:null,next:null,z:0,prevZ:null,nextZ:null,steiner:!1}}function nl(t,e,r){const n=r.patternDependencies;let i=!1;for(const r of e){const e=r.paint.get(`${t}-pattern`);e.isConstant()||(i=!0);const s=e.constantOr(null);s&&(i=!0,n[s.to]=!0,n[s.from]=!0);}return i}function il(t,e,r,n,i){const s=i.patternDependencies;for(const a of e){const e=a.paint.get(`${t}-pattern`).value;if("constant"!==e.kind){let t=e.evaluate({zoom:n-1},r,{},i.availableImages),o=e.evaluate({zoom:n},r,{},i.availableImages),l=e.evaluate({zoom:n+1},r,{},i.availableImages);t=t&&t.name?t.name:t,o=o&&o.name?o.name:o,l=l&&l.name?l.name:l,s[t]=!0,s[o]=!0,s[l]=!0,r.patterns[a.id]={min:t,mid:o,max:l};}}return r}class sl{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasPattern=!1,this.patternFeatures=[],this.layoutVertexArray=new Ks,this.indexArray=new na,this.indexArray2=new ia,this.programConfigurations=new Ea(t.layers,t.zoom),this.segments=new oa,this.segments2=new oa,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,r){this.hasPattern=nl("fill",this.layers,e);const n=this.layers[0].layout.get("fill-sort-key"),i=!n.isConstant(),s=[];for(const{feature:a,id:o,index:l,sourceLayerIndex:u}of t){const t=this.layers[0]._featureFilter.needGeometry,c=ja(a,t);if(!this.layers[0]._featureFilter.filter(new Ui(this.zoom),c,r))continue;const h=i?n.evaluate(c,{},r,e.availableImages):void 0,p={id:o,properties:a.properties,type:a.type,sourceLayerIndex:u,index:l,geometry:t?c.geometry:Oa(a),patterns:{},sortKey:h};s.push(p);}i&&s.sort(((t,e)=>t.sortKey-e.sortKey));for(const n of s){const{geometry:i,index:s,sourceLayerIndex:a}=n;if(this.hasPattern){const t=il("fill",this.layers,n,this.zoom,e);this.patternFeatures.push(t);}else this.addFeature(n,i,s,r,{});e.featureIndex.insert(t[s].feature,i,s,a,this.index);}}update(t,e,r){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,r);}addFeatures(t,e,r){for(const t of this.patternFeatures)this.addFeature(t,t.geometry,t.index,e,r);}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,Bo),this.indexBuffer=t.createIndexBuffer(this.indexArray),this.indexBuffer2=t.createIndexBuffer(this.indexArray2)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.indexBuffer2.destroy(),this.programConfigurations.destroy(),this.segments.destroy(),this.segments2.destroy());}addFeature(t,e,r,n,i){for(const t of xr(e,500)){let e=0;for(const r of t)e+=r.length;const r=this.segments.prepareSegment(e,this.layoutVertexArray,this.indexArray),n=r.vertexLength,i=[],s=[];for(const e of t){if(0===e.length)continue;e!==t[0]&&s.push(i.length/2);const r=this.segments2.prepareSegment(e.length,this.layoutVertexArray,this.indexArray2),n=r.vertexLength;this.layoutVertexArray.emplaceBack(e[0].x,e[0].y),this.indexArray2.emplaceBack(n+e.length-1,n),i.push(e[0].x),i.push(e[0].y);for(let t=1;t>3;}if(i--,1===n||2===n)s+=t.readSVarint(),a+=t.readSVarint(),1===n&&(e&&o.push(e),e=[]),e.push(new dl(s,a));else {if(7!==n)throw new Error("unknown command "+n);e&&e.push(e[0].clone());}}return e&&o.push(e),o},ml.prototype.bbox=function(){var t=this._pbf;t.pos=this._geometry;for(var e=t.readVarint()+t.pos,r=1,n=0,i=0,s=0,a=1/0,o=-1/0,l=1/0,u=-1/0;t.pos>3;}if(n--,1===r||2===r)(i+=t.readSVarint())o&&(o=i),(s+=t.readSVarint())u&&(u=s);else if(7!==r)throw new Error("unknown command "+r)}return [a,l,o,u]},ml.prototype.toGeoJSON=function(t,e,r){var n,i,s=this.extent*Math.pow(2,r),a=this.extent*t,o=this.extent*e,l=this.loadGeometry(),u=ml.types[this.type];function c(t){for(var e=0;e>3;e=1===n?t.readString():2===n?t.readFloat():3===n?t.readDouble():4===n?t.readVarint64():5===n?t.readVarint():6===n?t.readSVarint():7===n?t.readBoolean():null;}return e}(r));}wl.prototype.feature=function(t){if(t<0||t>=this._features.length)throw new Error("feature index out of bounds");this._pbf.pos=this._features[t];var e=this._pbf.readVarint()+this._pbf.pos;return new vl(this._pbf,e,this.extent,this._keys,this._values)};var Al=bl;function Sl(t,e,r){if(3===t){var n=new Al(r,r.readVarint()+r.pos);n.length&&(e[n.name]=n);}}fl.VectorTile=function(t,e){this.layers=t.readFields(Sl,{},e);},fl.VectorTileFeature=yl,fl.VectorTileLayer=bl;const kl=fl.VectorTileFeature.types,Ml=Math.pow(2,13);function Il(t,e,r,n,i,s,a,o){t.emplaceBack(e,r,2*Math.floor(n*Ml)+a,i*Ml*2,s*Ml*2,Math.round(o));}class zl{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasPattern=!1,this.layoutVertexArray=new Xs,this.centroidVertexArray=new Zs,this.indexArray=new na,this.programConfigurations=new Ea(t.layers,t.zoom),this.segments=new oa,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,r){this.features=[],this.hasPattern=nl("fill-extrusion",this.layers,e);for(const{feature:n,id:i,index:s,sourceLayerIndex:a}of t){const t=this.layers[0]._featureFilter.needGeometry,o=ja(n,t);if(!this.layers[0]._featureFilter.filter(new Ui(this.zoom),o,r))continue;const l={id:i,sourceLayerIndex:a,index:s,geometry:t?o.geometry:Oa(n),properties:n.properties,type:n.type,patterns:{}};this.hasPattern?this.features.push(il("fill-extrusion",this.layers,l,this.zoom,e)):this.addFeature(l,l.geometry,s,r,{}),e.featureIndex.insert(n,l.geometry,s,a,this.index,!0);}}addFeatures(t,e,r){for(const t of this.features){const{geometry:n}=t;this.addFeature(t,n,t.index,e,r);}}update(t,e,r){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,r);}isEmpty(){return 0===this.layoutVertexArray.length&&0===this.centroidVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,pl),this.centroidVertexBuffer=t.createVertexBuffer(this.centroidVertexArray,hl.members,!0),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy(),this.centroidVertexBuffer.destroy());}addFeature(t,e,r,n,i){for(const r of xr(e,500)){const e={x:0,y:0,vertexCount:0};let n=0;for(const t of r)n+=t.length;let i=this.segments.prepareSegment(4,this.layoutVertexArray,this.indexArray);for(const t of r){if(0===t.length)continue;if(Cl(t))continue;let r=0;for(let n=0;n=1){const a=t[n-1];if(!Pl(s,a)){i.vertexLength+4>oa.MAX_VERTEX_ARRAY_LENGTH&&(i=this.segments.prepareSegment(4,this.layoutVertexArray,this.indexArray));const t=s.sub(a)._perp()._unit(),n=a.dist(s);r+n>32768&&(r=0),Il(this.layoutVertexArray,s.x,s.y,t.x,t.y,0,0,r),Il(this.layoutVertexArray,s.x,s.y,t.x,t.y,0,1,r),e.x+=2*s.x,e.y+=2*s.y,e.vertexCount+=2,r+=n,Il(this.layoutVertexArray,a.x,a.y,t.x,t.y,0,0,r),Il(this.layoutVertexArray,a.x,a.y,t.x,t.y,0,1,r),e.x+=2*a.x,e.y+=2*a.y,e.vertexCount+=2;const o=i.vertexLength;this.indexArray.emplaceBack(o,o+2,o+1),this.indexArray.emplaceBack(o+1,o+2,o+3),i.vertexLength+=4,i.primitiveLength+=2;}}}}if(i.vertexLength+n>oa.MAX_VERTEX_ARRAY_LENGTH&&(i=this.segments.prepareSegment(n,this.layoutVertexArray,this.indexArray)),"Polygon"!==kl[t.type])continue;const s=[],a=[],o=i.vertexLength;for(const t of r)if(0!==t.length){t!==r[0]&&a.push(s.length/2);for(let r=0;r$a)||t.y===e.y&&(t.y<0||t.y>$a)}function Cl(t){return t.every((t=>t.x<0))||t.every((t=>t.x>$a))||t.every((t=>t.y<0))||t.every((t=>t.y>$a))}let Bl;wi("FillExtrusionBucket",zl,{omit:["layers","features"]});var Vl={get paint(){return Bl=Bl||new rs({"fill-extrusion-opacity":new Ji(G["paint_fill-extrusion"]["fill-extrusion-opacity"]),"fill-extrusion-color":new Wi(G["paint_fill-extrusion"]["fill-extrusion-color"]),"fill-extrusion-translate":new Ji(G["paint_fill-extrusion"]["fill-extrusion-translate"]),"fill-extrusion-translate-anchor":new Ji(G["paint_fill-extrusion"]["fill-extrusion-translate-anchor"]),"fill-extrusion-pattern":new Qi(G["paint_fill-extrusion"]["fill-extrusion-pattern"]),"fill-extrusion-height":new Wi(G["paint_fill-extrusion"]["fill-extrusion-height"]),"fill-extrusion-base":new Wi(G["paint_fill-extrusion"]["fill-extrusion-base"]),"fill-extrusion-vertical-gradient":new Ji(G["paint_fill-extrusion"]["fill-extrusion-vertical-gradient"])})}};class El extends is{constructor(t){super(t,Vl);}createBucket(t){return new zl(t)}queryRadius(){return eo(this.paint.get("fill-extrusion-translate"))}is3D(){return !0}queryIntersectsFeature(t,e,r,n,i,a,o,l){const u=ro(t,this.paint.get("fill-extrusion-translate"),this.paint.get("fill-extrusion-translate-anchor"),a.angle,o),c=this.paint.get("fill-extrusion-height").evaluate(e,r),h=this.paint.get("fill-extrusion-base").evaluate(e,r),p=function(t,e,r,n){const i=[];for(const r of t){const t=[r.x,r.y,0,1];po(t,t,e),i.push(new s(t[0]/t[3],t[1]/t[3]));}return i}(u,l),f=function(t,e,r,n){const i=[],a=[],o=n[8]*e,l=n[9]*e,u=n[10]*e,c=n[11]*e,h=n[8]*r,p=n[9]*r,f=n[10]*r,d=n[11]*r;for(const e of t){const t=[],r=[];for(const i of e){const e=i.x,a=i.y,y=n[0]*e+n[4]*a+n[12],m=n[1]*e+n[5]*a+n[13],g=n[2]*e+n[6]*a+n[14],x=n[3]*e+n[7]*a+n[15],v=g+u,b=x+c,w=y+h,_=m+p,A=g+f,S=x+d,k=new s((y+o)/b,(m+l)/b);k.z=v/b,t.push(k);const M=new s(w/S,_/S);M.z=A/S,r.push(M);}i.push(t),a.push(r);}return [i,a]}(n,h,c,l);return function(t,e,r){let n=1/0;Za(r,e)&&(n=Tl(r,e[0]));for(let i=0;it.id)),this.index=t.index,this.hasPattern=!1,this.patternFeatures=[],this.lineClipsArray=[],this.gradients={},this.layers.forEach((t=>{this.gradients[t.id]={};})),this.layoutVertexArray=new Hs,this.layoutVertexArray2=new Ys,this.indexArray=new na,this.programConfigurations=new Ea(t.layers,t.zoom),this.segments=new oa,this.maxLineLength=0,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,r){this.hasPattern=nl("line",this.layers,e);const n=this.layers[0].layout.get("line-sort-key"),i=!n.isConstant(),s=[];for(const{feature:e,id:a,index:o,sourceLayerIndex:l}of t){const t=this.layers[0]._featureFilter.needGeometry,u=ja(e,t);if(!this.layers[0]._featureFilter.filter(new Ui(this.zoom),u,r))continue;const c=i?n.evaluate(u,{},r):void 0,h={id:a,properties:e.properties,type:e.type,sourceLayerIndex:l,index:o,geometry:t?u.geometry:Oa(e),patterns:{},sortKey:c};s.push(h);}i&&s.sort(((t,e)=>t.sortKey-e.sortKey));for(const n of s){const{geometry:i,index:s,sourceLayerIndex:a}=n;if(this.hasPattern){const t=il("line",this.layers,n,this.zoom,e);this.patternFeatures.push(t);}else this.addFeature(n,i,s,r,{});e.featureIndex.insert(t[s].feature,i,s,a,this.index);}}update(t,e,r){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,r);}addFeatures(t,e,r){for(const t of this.patternFeatures)this.addFeature(t,t.geometry,t.index,e,r);}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(0!==this.layoutVertexArray2.length&&(this.layoutVertexBuffer2=t.createVertexBuffer(this.layoutVertexArray2,Ol)),this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,Ll),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy());}lineFeatureClips(t){if(t.properties&&Object.prototype.hasOwnProperty.call(t.properties,"mapbox_clip_start")&&Object.prototype.hasOwnProperty.call(t.properties,"mapbox_clip_end"))return {start:+t.properties.mapbox_clip_start,end:+t.properties.mapbox_clip_end}}addFeature(t,e,r,n,i){const s=this.layers[0].layout,a=s.get("line-join").evaluate(t,{}),o=s.get("line-cap"),l=s.get("line-miter-limit"),u=s.get("line-round-limit");this.lineClips=this.lineFeatureClips(t);for(const r of e)this.addLine(r,t,a,o,l,u);this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length,t,r,i,n);}addLine(t,e,r,n,i,s){if(this.distance=0,this.scaledDistance=0,this.totalDistance=0,this.lineClips){this.lineClipsArray.push(this.lineClips);for(let e=0;e=2&&t[o-1].equals(t[o-2]);)o--;let l=0;for(;l0;if(w&&e>l){const t=h.dist(p);if(t>2*u){const e=h.sub(h.sub(p)._mult(u/t)._round());this.updateDistance(p,e),this.addCurrentVertex(e,d,0,0,c),p=e;}}const A=p&&f;let S=A?r:a?"butt":n;if(A&&"round"===S&&(vi&&(S="bevel"),"bevel"===S&&(v>2&&(S="flipbevel"),v100)m=y.mult(-1);else {const t=v*d.add(y).mag()/d.sub(y).mag();m._perp()._mult(t*(_?-1:1));}this.addCurrentVertex(h,m,0,0,c),this.addCurrentVertex(h,m.mult(-1),0,0,c);}else if("bevel"===S||"fakeround"===S){const t=-Math.sqrt(v*v-1),e=_?t:0,r=_?0:t;if(p&&this.addCurrentVertex(h,d,e,r,c),"fakeround"===S){const t=Math.round(180*b/Math.PI/20);for(let e=1;e2*u){const e=h.add(f.sub(h)._mult(u/t)._round());this.updateDistance(h,e),this.addCurrentVertex(e,y,0,0,c),h=e;}}}}addCurrentVertex(t,e,r,n,i,s=!1){const a=e.y*n-e.x,o=-e.y-e.x*n;this.addHalfVertex(t,e.x+e.y*r,e.y-e.x*r,s,!1,r,i),this.addHalfVertex(t,a,o,s,!0,-n,i),this.distance>Ul/2&&0===this.totalDistance&&(this.distance=0,this.updateScaledDistance(),this.addCurrentVertex(t,e,r,n,i,s));}addHalfVertex({x:t,y:e},r,n,i,s,a,o){const l=.5*(this.lineClips?this.scaledDistance*(Ul-1):this.scaledDistance);this.layoutVertexArray.emplaceBack((t<<1)+(i?1:0),(e<<1)+(s?1:0),Math.round(63*r)+128,Math.round(63*n)+128,1+(0===a?0:a<0?-1:1)|(63&l)<<2,l>>6),this.lineClips&&this.layoutVertexArray2.emplaceBack((this.scaledDistance-this.lineClips.start)/(this.lineClips.end-this.lineClips.start),this.lineClipsArray.length);const u=o.vertexLength++;this.e1>=0&&this.e2>=0&&(this.indexArray.emplaceBack(this.e1,this.e2,u),o.primitiveLength++),s?this.e2=u:this.e1=u;}updateScaledDistance(){this.scaledDistance=this.lineClips?this.lineClips.start+(this.lineClips.end-this.lineClips.start)*this.distance/this.totalDistance:this.distance;}updateDistance(t,e){this.distance+=t.dist(e),this.updateScaledDistance();}}let Nl,Zl;wi("LineBucket",ql,{omit:["layers","patternFeatures"]});var Gl={get paint(){return Zl=Zl||new rs({"line-opacity":new Wi(G.paint_line["line-opacity"]),"line-color":new Wi(G.paint_line["line-color"]),"line-translate":new Ji(G.paint_line["line-translate"]),"line-translate-anchor":new Ji(G.paint_line["line-translate-anchor"]),"line-width":new Wi(G.paint_line["line-width"]),"line-gap-width":new Wi(G.paint_line["line-gap-width"]),"line-offset":new Wi(G.paint_line["line-offset"]),"line-blur":new Wi(G.paint_line["line-blur"]),"line-dasharray":new ts(G.paint_line["line-dasharray"]),"line-pattern":new Qi(G.paint_line["line-pattern"]),"line-gradient":new es(G.paint_line["line-gradient"])})},get layout(){return Nl=Nl||new rs({"line-cap":new Ji(G.layout_line["line-cap"]),"line-join":new Wi(G.layout_line["line-join"]),"line-miter-limit":new Ji(G.layout_line["line-miter-limit"]),"line-round-limit":new Ji(G.layout_line["line-round-limit"]),"line-sort-key":new Wi(G.layout_line["line-sort-key"])})}};class Kl extends Wi{possiblyEvaluate(t,e){return e=new Ui(Math.floor(e.zoom),{now:e.now,fadeDuration:e.fadeDuration,zoomHistory:e.zoomHistory,transition:e.transition}),super.possiblyEvaluate(t,e)}evaluate(t,e,r,n){return e=g({},e,{zoom:Math.floor(e.zoom)}),super.evaluate(t,e,r,n)}}let Xl;class Hl extends is{constructor(t){super(t,Gl),this.gradientVersion=0,Xl||(Xl=new Kl(Gl.paint.properties["line-width"].specification),Xl.useIntegerZoom=!0);}_handleSpecialPaintPropertyUpdate(t){if("line-gradient"===t){const t=this.gradientExpression();this.stepInterpolant=!!function(t){return void 0!==t._styleExpression}(t)&&t._styleExpression.expression instanceof Ae,this.gradientVersion=(this.gradientVersion+1)%Number.MAX_SAFE_INTEGER;}}gradientExpression(){return this._transitionablePaint._values["line-gradient"].value.expression}recalculate(t,e){super.recalculate(t,e),this.paint._values["line-floorwidth"]=Xl.possiblyEvaluate(this._transitioningPaint._values["line-width"].value,t);}createBucket(t){return new ql(t)}queryRadius(t){const e=t,r=Yl(to("line-width",this,e),to("line-gap-width",this,e)),n=to("line-offset",this,e);return r/2+Math.abs(n)+eo(this.paint.get("line-translate"))}queryIntersectsFeature(t,e,r,n,i,a,o){const l=ro(t,this.paint.get("line-translate"),this.paint.get("line-translate-anchor"),a.angle,o),u=o/2*Yl(this.paint.get("line-width").evaluate(e,r),this.paint.get("line-gap-width").evaluate(e,r)),c=this.paint.get("line-offset").evaluate(e,r);return c&&(n=function(t,e){const r=[];for(let n=0;n=3)for(let e=0;e0?e+2*t:t}const Jl=ls([{name:"a_pos_offset",components:4,type:"Int16"},{name:"a_data",components:4,type:"Uint16"},{name:"a_pixeloffset",components:4,type:"Int16"}],4),Wl=ls([{name:"a_projected_pos",components:3,type:"Float32"}],4);ls([{name:"a_fade_opacity",components:1,type:"Uint32"}],4);const Ql=ls([{name:"a_placed",components:2,type:"Uint8"},{name:"a_shift",components:2,type:"Float32"},{name:"a_box_real",components:2,type:"Int16"}]);ls([{type:"Int16",name:"anchorPointX"},{type:"Int16",name:"anchorPointY"},{type:"Int16",name:"x1"},{type:"Int16",name:"y1"},{type:"Int16",name:"x2"},{type:"Int16",name:"y2"},{type:"Uint32",name:"featureIndex"},{type:"Uint16",name:"sourceLayerIndex"},{type:"Uint16",name:"bucketIndex"}]);const tu=ls([{name:"a_pos",components:2,type:"Int16"},{name:"a_anchor_pos",components:2,type:"Int16"},{name:"a_extrude",components:2,type:"Int16"}],4),eu=ls([{name:"a_pos",components:2,type:"Float32"},{name:"a_radius",components:1,type:"Float32"},{name:"a_flags",components:2,type:"Int16"}],4);function ru(t,e,r){return t.sections.forEach((t=>{t.text=function(t,e,r){const n=e.layout.get("text-transform").evaluate(r,{});return "uppercase"===n?t=t.toLocaleUpperCase():"lowercase"===n&&(t=t.toLocaleLowerCase()),Ri.applyArabicShaping&&(t=Ri.applyArabicShaping(t)),t}(t.text,e,r);})),t}ls([{name:"triangle",components:3,type:"Uint16"}]),ls([{type:"Int16",name:"anchorX"},{type:"Int16",name:"anchorY"},{type:"Uint16",name:"glyphStartIndex"},{type:"Uint16",name:"numGlyphs"},{type:"Uint32",name:"vertexStartIndex"},{type:"Uint32",name:"lineStartIndex"},{type:"Uint32",name:"lineLength"},{type:"Uint16",name:"segment"},{type:"Uint16",name:"lowerSize"},{type:"Uint16",name:"upperSize"},{type:"Float32",name:"lineOffsetX"},{type:"Float32",name:"lineOffsetY"},{type:"Uint8",name:"writingMode"},{type:"Uint8",name:"placedOrientation"},{type:"Uint8",name:"hidden"},{type:"Uint32",name:"crossTileID"},{type:"Int16",name:"associatedIconIndex"}]),ls([{type:"Int16",name:"anchorX"},{type:"Int16",name:"anchorY"},{type:"Int16",name:"rightJustifiedTextSymbolIndex"},{type:"Int16",name:"centerJustifiedTextSymbolIndex"},{type:"Int16",name:"leftJustifiedTextSymbolIndex"},{type:"Int16",name:"verticalPlacedTextSymbolIndex"},{type:"Int16",name:"placedIconSymbolIndex"},{type:"Int16",name:"verticalPlacedIconSymbolIndex"},{type:"Uint16",name:"key"},{type:"Uint16",name:"textBoxStartIndex"},{type:"Uint16",name:"textBoxEndIndex"},{type:"Uint16",name:"verticalTextBoxStartIndex"},{type:"Uint16",name:"verticalTextBoxEndIndex"},{type:"Uint16",name:"iconBoxStartIndex"},{type:"Uint16",name:"iconBoxEndIndex"},{type:"Uint16",name:"verticalIconBoxStartIndex"},{type:"Uint16",name:"verticalIconBoxEndIndex"},{type:"Uint16",name:"featureIndex"},{type:"Uint16",name:"numHorizontalGlyphVertices"},{type:"Uint16",name:"numVerticalGlyphVertices"},{type:"Uint16",name:"numIconVertices"},{type:"Uint16",name:"numVerticalIconVertices"},{type:"Uint16",name:"useRuntimeCollisionCircles"},{type:"Uint32",name:"crossTileID"},{type:"Float32",name:"textBoxScale"},{type:"Float32",name:"collisionCircleDiameter"},{type:"Uint16",name:"textAnchorOffsetStartIndex"},{type:"Uint16",name:"textAnchorOffsetEndIndex"}]),ls([{type:"Float32",name:"offsetX"}]),ls([{type:"Int16",name:"x"},{type:"Int16",name:"y"},{type:"Int16",name:"tileUnitDistanceFromAnchor"}]),ls([{type:"Uint16",name:"textAnchor"},{type:"Float32",components:2,name:"textOffset"}]);const nu={"!":"︕","#":"#",$:"$","%":"%","&":"&","(":"︵",")":"︶","*":"*","+":"+",",":"︐","-":"︲",".":"・","/":"/",":":"︓",";":"︔","<":"︿","=":"=",">":"﹀","?":"︖","@":"@","[":"﹇","\\":"\","]":"﹈","^":"^",_:"︳","`":"`","{":"︷","|":"―","}":"︸","~":"~","¢":"¢","£":"£","¥":"¥","¦":"¦","¬":"¬","¯":" ̄","–":"︲","—":"︱","‘":"﹃","’":"﹄","“":"﹁","”":"﹂","…":"︙","‧":"・","₩":"₩","、":"︑","。":"︒","〈":"︿","〉":"﹀","《":"︽","》":"︾","「":"﹁","」":"﹂","『":"﹃","』":"﹄","【":"︻","】":"︼","〔":"︹","〕":"︺","〖":"︗","〗":"︘","!":"︕","(":"︵",")":"︶",",":"︐","-":"︲",".":"・",":":"︓",";":"︔","<":"︿",">":"﹀","?":"︖","[":"﹇","]":"﹈","_":"︳","{":"︷","|":"―","}":"︸","⦅":"︵","⦆":"︶","。":"︒","「":"﹁","」":"﹂"};var iu=24,su=lu,au=function(t,e,r,n,i){var s,a,o=8*i-n-1,l=(1<>1,c=-7,h=r?i-1:0,p=r?-1:1,f=t[e+h];for(h+=p,s=f&(1<<-c)-1,f>>=-c,c+=o;c>0;s=256*s+t[e+h],h+=p,c-=8);for(a=s&(1<<-c)-1,s>>=-c,c+=n;c>0;a=256*a+t[e+h],h+=p,c-=8);if(0===s)s=1-u;else {if(s===l)return a?NaN:1/0*(f?-1:1);a+=Math.pow(2,n),s-=u;}return (f?-1:1)*a*Math.pow(2,s-n)},ou=function(t,e,r,n,i,s){var a,o,l,u=8*s-i-1,c=(1<>1,p=23===i?Math.pow(2,-24)-Math.pow(2,-77):0,f=n?0:s-1,d=n?1:-1,y=e<0||0===e&&1/e<0?1:0;for(e=Math.abs(e),isNaN(e)||e===1/0?(o=isNaN(e)?1:0,a=c):(a=Math.floor(Math.log(e)/Math.LN2),e*(l=Math.pow(2,-a))<1&&(a--,l*=2),(e+=a+h>=1?p/l:p*Math.pow(2,1-h))*l>=2&&(a++,l/=2),a+h>=c?(o=0,a=c):a+h>=1?(o=(e*l-1)*Math.pow(2,i),a+=h):(o=e*Math.pow(2,h-1)*Math.pow(2,i),a=0));i>=8;t[r+f]=255&o,f+=d,o/=256,i-=8);for(a=a<0;t[r+f]=255&a,f+=d,a/=256,u-=8);t[r+f-d]|=128*y;};function lu(t){this.buf=ArrayBuffer.isView&&ArrayBuffer.isView(t)?t:new Uint8Array(t||0),this.pos=0,this.type=0,this.length=this.buf.length;}lu.Varint=0,lu.Fixed64=1,lu.Bytes=2,lu.Fixed32=5;var uu=4294967296,cu=1/uu,hu="undefined"==typeof TextDecoder?null:new TextDecoder("utf-8");function pu(t){return t.type===lu.Bytes?t.readVarint()+t.pos:t.pos+1}function fu(t,e,r){return r?4294967296*e+(t>>>0):4294967296*(e>>>0)+(t>>>0)}function du(t,e,r){var n=e<=16383?1:e<=2097151?2:e<=268435455?3:Math.floor(Math.log(e)/(7*Math.LN2));r.realloc(n);for(var i=r.pos-1;i>=t;i--)r.buf[i+n]=r.buf[i];}function yu(t,e){for(var r=0;r>>8,t[r+2]=e>>>16,t[r+3]=e>>>24;}function Mu(t,e){return (t[e]|t[e+1]<<8|t[e+2]<<16)+(t[e+3]<<24)}lu.prototype={destroy:function(){this.buf=null;},readFields:function(t,e,r){for(r=r||this.length;this.pos>3,s=this.pos;this.type=7&n,t(i,e,this),this.pos===s&&this.skip(n);}return e},readMessage:function(t,e){return this.readFields(t,e,this.readVarint()+this.pos)},readFixed32:function(){var t=Su(this.buf,this.pos);return this.pos+=4,t},readSFixed32:function(){var t=Mu(this.buf,this.pos);return this.pos+=4,t},readFixed64:function(){var t=Su(this.buf,this.pos)+Su(this.buf,this.pos+4)*uu;return this.pos+=8,t},readSFixed64:function(){var t=Su(this.buf,this.pos)+Mu(this.buf,this.pos+4)*uu;return this.pos+=8,t},readFloat:function(){var t=au(this.buf,this.pos,!0,23,4);return this.pos+=4,t},readDouble:function(){var t=au(this.buf,this.pos,!0,52,8);return this.pos+=8,t},readVarint:function(t){var e,r,n=this.buf;return e=127&(r=n[this.pos++]),r<128?e:(e|=(127&(r=n[this.pos++]))<<7,r<128?e:(e|=(127&(r=n[this.pos++]))<<14,r<128?e:(e|=(127&(r=n[this.pos++]))<<21,r<128?e:function(t,e,r){var n,i,s=r.buf;if(n=(112&(i=s[r.pos++]))>>4,i<128)return fu(t,n,e);if(n|=(127&(i=s[r.pos++]))<<3,i<128)return fu(t,n,e);if(n|=(127&(i=s[r.pos++]))<<10,i<128)return fu(t,n,e);if(n|=(127&(i=s[r.pos++]))<<17,i<128)return fu(t,n,e);if(n|=(127&(i=s[r.pos++]))<<24,i<128)return fu(t,n,e);if(n|=(1&(i=s[r.pos++]))<<31,i<128)return fu(t,n,e);throw new Error("Expected varint not more than 10 bytes")}(e|=(15&(r=n[this.pos]))<<28,t,this))))},readVarint64:function(){return this.readVarint(!0)},readSVarint:function(){var t=this.readVarint();return t%2==1?(t+1)/-2:t/2},readBoolean:function(){return Boolean(this.readVarint())},readString:function(){var t=this.readVarint()+this.pos,e=this.pos;return this.pos=t,t-e>=12&&hu?function(t,e,r){return hu.decode(t.subarray(e,r))}(this.buf,e,t):function(t,e,r){for(var n="",i=e;i239?4:l>223?3:l>191?2:1;if(i+c>r)break;1===c?l<128&&(u=l):2===c?128==(192&(s=t[i+1]))&&(u=(31&l)<<6|63&s)<=127&&(u=null):3===c?(a=t[i+2],128==(192&(s=t[i+1]))&&128==(192&a)&&((u=(15&l)<<12|(63&s)<<6|63&a)<=2047||u>=55296&&u<=57343)&&(u=null)):4===c&&(a=t[i+2],o=t[i+3],128==(192&(s=t[i+1]))&&128==(192&a)&&128==(192&o)&&((u=(15&l)<<18|(63&s)<<12|(63&a)<<6|63&o)<=65535||u>=1114112)&&(u=null)),null===u?(u=65533,c=1):u>65535&&(u-=65536,n+=String.fromCharCode(u>>>10&1023|55296),u=56320|1023&u),n+=String.fromCharCode(u),i+=c;}return n}(this.buf,e,t)},readBytes:function(){var t=this.readVarint()+this.pos,e=this.buf.subarray(this.pos,t);return this.pos=t,e},readPackedVarint:function(t,e){if(this.type!==lu.Bytes)return t.push(this.readVarint(e));var r=pu(this);for(t=t||[];this.pos127;);else if(e===lu.Bytes)this.pos=this.readVarint()+this.pos;else if(e===lu.Fixed32)this.pos+=4;else {if(e!==lu.Fixed64)throw new Error("Unimplemented type: "+e);this.pos+=8;}},writeTag:function(t,e){this.writeVarint(t<<3|e);},realloc:function(t){for(var e=this.length||16;e268435455||t<0?function(t,e){var r,n;if(t>=0?(r=t%4294967296|0,n=t/4294967296|0):(n=~(-t/4294967296),4294967295^(r=~(-t%4294967296))?r=r+1|0:(r=0,n=n+1|0)),t>=0x10000000000000000||t<-0x10000000000000000)throw new Error("Given varint doesn't fit into 10 bytes");e.realloc(10),function(t,e,r){r.buf[r.pos++]=127&t|128,t>>>=7,r.buf[r.pos++]=127&t|128,t>>>=7,r.buf[r.pos++]=127&t|128,t>>>=7,r.buf[r.pos++]=127&t|128,r.buf[r.pos]=127&(t>>>=7);}(r,0,e),function(t,e){var r=(7&t)<<4;e.buf[e.pos++]|=r|((t>>>=3)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t)))));}(n,e);}(t,this):(this.realloc(4),this.buf[this.pos++]=127&t|(t>127?128:0),t<=127||(this.buf[this.pos++]=127&(t>>>=7)|(t>127?128:0),t<=127||(this.buf[this.pos++]=127&(t>>>=7)|(t>127?128:0),t<=127||(this.buf[this.pos++]=t>>>7&127))));},writeSVarint:function(t){this.writeVarint(t<0?2*-t-1:2*t);},writeBoolean:function(t){this.writeVarint(Boolean(t));},writeString:function(t){t=String(t),this.realloc(4*t.length),this.pos++;var e=this.pos;this.pos=function(t,e,r){for(var n,i,s=0;s55295&&n<57344){if(!i){n>56319||s+1===e.length?(t[r++]=239,t[r++]=191,t[r++]=189):i=n;continue}if(n<56320){t[r++]=239,t[r++]=191,t[r++]=189,i=n;continue}n=i-55296<<10|n-56320|65536,i=null;}else i&&(t[r++]=239,t[r++]=191,t[r++]=189,i=null);n<128?t[r++]=n:(n<2048?t[r++]=n>>6|192:(n<65536?t[r++]=n>>12|224:(t[r++]=n>>18|240,t[r++]=n>>12&63|128),t[r++]=n>>6&63|128),t[r++]=63&n|128);}return r}(this.buf,t,this.pos);var r=this.pos-e;r>=128&&du(e,r,this),this.pos=e-1,this.writeVarint(r),this.pos+=r;},writeFloat:function(t){this.realloc(4),ou(this.buf,t,this.pos,!0,23,4),this.pos+=4;},writeDouble:function(t){this.realloc(8),ou(this.buf,t,this.pos,!0,52,8),this.pos+=8;},writeBytes:function(t){var e=t.length;this.writeVarint(e),this.realloc(e);for(var r=0;r=128&&du(r,n,this),this.pos=r-1,this.writeVarint(n),this.pos+=n;},writeMessage:function(t,e,r){this.writeTag(t,lu.Bytes),this.writeRawMessage(e,r);},writePackedVarint:function(t,e){e.length&&this.writeMessage(t,yu,e);},writePackedSVarint:function(t,e){e.length&&this.writeMessage(t,mu,e);},writePackedBoolean:function(t,e){e.length&&this.writeMessage(t,vu,e);},writePackedFloat:function(t,e){e.length&&this.writeMessage(t,gu,e);},writePackedDouble:function(t,e){e.length&&this.writeMessage(t,xu,e);},writePackedFixed32:function(t,e){e.length&&this.writeMessage(t,bu,e);},writePackedSFixed32:function(t,e){e.length&&this.writeMessage(t,wu,e);},writePackedFixed64:function(t,e){e.length&&this.writeMessage(t,_u,e);},writePackedSFixed64:function(t,e){e.length&&this.writeMessage(t,Au,e);},writeBytesField:function(t,e){this.writeTag(t,lu.Bytes),this.writeBytes(e);},writeFixed32Field:function(t,e){this.writeTag(t,lu.Fixed32),this.writeFixed32(e);},writeSFixed32Field:function(t,e){this.writeTag(t,lu.Fixed32),this.writeSFixed32(e);},writeFixed64Field:function(t,e){this.writeTag(t,lu.Fixed64),this.writeFixed64(e);},writeSFixed64Field:function(t,e){this.writeTag(t,lu.Fixed64),this.writeSFixed64(e);},writeVarintField:function(t,e){this.writeTag(t,lu.Varint),this.writeVarint(e);},writeSVarintField:function(t,e){this.writeTag(t,lu.Varint),this.writeSVarint(e);},writeStringField:function(t,e){this.writeTag(t,lu.Bytes),this.writeString(e);},writeFloatField:function(t,e){this.writeTag(t,lu.Fixed32),this.writeFloat(e);},writeDoubleField:function(t,e){this.writeTag(t,lu.Fixed64),this.writeDouble(e);},writeBooleanField:function(t,e){this.writeVarintField(t,Boolean(e));}};var Iu=r(su);const zu=3;function Pu(t,e,r){1===t&&r.readMessage(Cu,e);}function Cu(t,e,r){if(3===t){const{id:t,bitmap:n,width:i,height:s,left:a,top:o,advance:l}=r.readMessage(Bu,{});e.push({id:t,bitmap:new _o({width:i+2*zu,height:s+2*zu},n),metrics:{width:i,height:s,left:a,top:o,advance:l}});}}function Bu(t,e,r){1===t?e.id=r.readVarint():2===t?e.bitmap=r.readBytes():3===t?e.width=r.readVarint():4===t?e.height=r.readVarint():5===t?e.left=r.readSVarint():6===t?e.top=r.readSVarint():7===t&&(e.advance=r.readVarint());}const Vu=zu;function Eu(t){let e=0,r=0;for(const n of t)e+=n.w*n.h,r=Math.max(r,n.w);t.sort(((t,e)=>e.h-t.h));const n=[{x:0,y:0,w:Math.max(Math.ceil(Math.sqrt(e/.95)),r),h:1/0}];let i=0,s=0;for(const e of t)for(let t=n.length-1;t>=0;t--){const r=n[t];if(!(e.w>r.w||e.h>r.h)){if(e.x=r.x,e.y=r.y,s=Math.max(s,e.y+e.h),i=Math.max(i,e.x+e.w),e.w===r.w&&e.h===r.h){const e=n.pop();t=0&&r>=t&&Uu[this.text.charCodeAt(r)];r--)e--;this.text=this.text.substring(t,e),this.sectionIndex=this.sectionIndex.slice(t,e);}substring(t,e){const r=new ju;return r.text=this.text.substring(t,e),r.sectionIndex=this.sectionIndex.slice(t,e),r.sections=this.sections,r}toString(){return this.text}getMaxScale(){return this.sectionIndex.reduce(((t,e)=>Math.max(t,this.sections[e].scale)),0)}addTextSection(t,e){this.text+=t.text,this.sections.push(Ou.forText(t.scale,t.fontStack||e));const r=this.sections.length-1;for(let e=0;e=63743?null:++this.imageSectionID:(this.imageSectionID=57344,this.imageSectionID)}}function Ru(e,r,n,i,s,a,o,l,u,c,h,p,f,d,y){const m=ju.fromFeature(e,s);let g;p===t.ah.vertical&&m.verticalizePunctuation();const{processBidirectionalText:x,processStyledBidirectionalText:v}=Ri;if(x&&1===m.sections.length){g=[];const t=x(m.toString(),Yu(m,c,a,r,i,d));for(const e of t){const t=new ju;t.text=e,t.sections=m.sections;for(let r=0;r0&&n>_&&(_=n);}else {const t=n[y.fontStack],e=t&&t[g];if(e&&e.rect)A=e.rect,b=e.metrics;else {const t=r[y.fontStack],e=t&&t[g];if(!e)continue;b=e.metrics;}x=(s-y.scale)*iu;}M?(e.verticalizable=!0,w.push({glyph:g,imageName:S,x:f,y:d+x,vertical:M,scale:y.scale,fontStack:y.fontStack,sectionIndex:m,metrics:b,rect:A}),f+=k*y.scale+c):(w.push({glyph:g,imageName:S,x:f,y:d+x,vertical:M,scale:y.scale,fontStack:y.fontStack,sectionIndex:m,metrics:b,rect:A}),f+=b.advance*y.scale+c);}0!==w.length&&(y=Math.max(f-c,y),Wu(w,0,w.length-1,g,_)),f=0;const A=a*s+_;b.lineOffset=Math.max(_,l),d+=A,m=Math.max(A,m),++x;}var v;const b=d-Du,{horizontalAlign:w,verticalAlign:_}=Ju(o);((function(t,e,r,n,i,s,a,o,l){const u=(e-r)*i;let c=0;c=s!==a?-o*n-Du:(-n*l+.5)*a;for(const e of t)for(const t of e.positionedGlyphs)t.x+=u,t.y+=c;}))(e.positionedLines,g,w,_,y,m,a,b,s.length),e.top+=-_*b,e.bottom=e.top+b,e.left+=-w*y,e.right=e.left+y;}(w,r,n,i,g,o,l,u,p,c,f,y),!function(t){for(const e of t)if(0!==e.positionedGlyphs.length)return !1;return !0}(b)&&w}const Uu={9:!0,10:!0,11:!0,12:!0,13:!0,32:!0},qu={10:!0,32:!0,38:!0,41:!0,43:!0,45:!0,47:!0,173:!0,183:!0,8203:!0,8208:!0,8211:!0,8231:!0},Nu={40:!0};function Zu(t,e,r,n,i,s){if(e.imageName){const t=n[e.imageName];return t?t.displaySize[0]*e.scale*iu/s+i:0}{const n=r[e.fontStack],s=n&&n[t];return s?s.metrics.advance*e.scale+i:0}}function Gu(t,e,r,n){const i=Math.pow(t-e,2);return n?t=0;let u=0;for(let r=0;ru){const t=Math.ceil(s/u);i*=t/a,a=t;}return {x1:n,y1:i,x2:n+s,y2:i+a}}function ec(t,e,r,n,i,s){const a=t.image;let o;if(a.content){const t=a.content,e=a.pixelRatio||1;o=[t[0]/e,t[1]/e,a.displaySize[0]-t[2]/e,a.displaySize[1]-t[3]/e];}const l=e.left*s,u=e.right*s;let c,h,p,f;"width"===r||"both"===r?(f=i[0]+l-n[3],h=i[0]+u+n[1]):(f=i[0]+(l+u-a.displaySize[0])/2,h=f+a.displaySize[0]);const d=e.top*s,y=e.bottom*s;return "height"===r||"both"===r?(c=i[1]+d-n[0],p=i[1]+y+n[2]):(c=i[1]+(d+y-a.displaySize[1])/2,p=c+a.displaySize[1]),{image:a,top:c,right:h,bottom:p,left:f,collisionPadding:o}}const rc=255,nc=128,ic=rc*nc;function sc(t,e){const{expression:r}=e;if("constant"===r.kind)return {kind:"constant",layoutSize:r.evaluate(new Ui(t+1))};if("source"===r.kind)return {kind:"source"};{const{zoomStops:e,interpolationType:n}=r;let i=0;for(;it.id)),this.index=e.index,this.pixelRatio=e.pixelRatio,this.sourceLayerIndex=e.sourceLayerIndex,this.hasPattern=!1,this.hasRTLText=!1,this.sortKeyRanges=[],this.collisionCircleArray=[],this.placementInvProjMatrix=lo([]),this.placementViewportMatrix=lo([]);const r=this.layers[0]._unevaluatedLayout._values;this.textSizeData=sc(this.zoom,r["text-size"]),this.iconSizeData=sc(this.zoom,r["icon-size"]);const n=this.layers[0].layout,i=n.get("symbol-sort-key"),s=n.get("symbol-z-order");this.canOverlap="never"!==ac(n,"text-overlap","text-allow-overlap")||"never"!==ac(n,"icon-overlap","icon-allow-overlap")||n.get("text-ignore-placement")||n.get("icon-ignore-placement"),this.sortFeaturesByKey="viewport-y"!==s&&!i.isConstant(),this.sortFeaturesByY=("viewport-y"===s||"auto"===s&&!this.sortFeaturesByKey)&&this.canOverlap,"point"===n.get("symbol-placement")&&(this.writingModes=n.get("text-writing-mode").map((e=>t.ah[e]))),this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id)),this.sourceID=e.sourceID;}createArrays(){this.text=new pc(new Ea(this.layers,this.zoom,(t=>/^text/.test(t)))),this.icon=new pc(new Ea(this.layers,this.zoom,(t=>/^icon/.test(t)))),this.glyphOffsetArray=new Os,this.lineVertexArray=new js,this.symbolInstances=new Ds,this.textAnchorOffsets=new Us;}calculateGlyphDependencies(t,e,r,n,i){for(let s=0;s0)&&("constant"!==a.value.kind||a.value.value.length>0),c="constant"!==l.value.kind||!!l.value.value||Object.keys(l.parameters).length>0,h=s.get("symbol-sort-key");if(this.features=[],!u&&!c)return;const p=r.iconDependencies,f=r.glyphDependencies,d=r.availableImages,y=new Ui(this.zoom);for(const{feature:r,id:o,index:l,sourceLayerIndex:m}of e){const e=i._featureFilter.needGeometry,g=ja(r,e);if(!i._featureFilter.filter(y,g,n))continue;let x,v;if(e||(g.geometry=Oa(r)),u){const t=i.getValueAndResolveTokens("text-field",g,n,d),e=Yt.factory(t),r=this.hasRTLText=this.hasRTLText||hc(e);(!r||"unavailable"===Ri.getRTLTextPluginStatus()||r&&Ri.isParsed())&&(x=ru(e,i,g));}if(c){const t=i.getValueAndResolveTokens("icon-image",g,n,d);v=t instanceof te?t:te.fromString(t);}if(!x&&!v)continue;const b=this.sortFeaturesByKey?h.evaluate(g,{},n):void 0;if(this.features.push({id:o,text:x,icon:v,index:l,sourceLayerIndex:m,geometry:g.geometry,properties:r.properties,type:oc[r.type],sortKey:b}),v&&(p[v.name]=!0),x){const e=a.evaluate(g,{},n).join(","),r="viewport"!==s.get("text-rotation-alignment")&&"point"!==s.get("symbol-placement");this.allowVerticalPlacement=this.writingModes&&this.writingModes.indexOf(t.ah.vertical)>=0;for(const t of x.sections)if(t.image)p[t.image.name]=!0;else {const n=Pi(x.toString()),i=t.fontStack||e,s=f[i]=f[i]||{};this.calculateGlyphDependencies(t.text,s,r,this.allowVerticalPlacement,n);}}}"line"===s.get("symbol-placement")&&(this.features=function(t){const e={},r={},n=[];let i=0;function s(e){n.push(t[e]),i++;}function a(t,e,i){const s=r[t];return delete r[t],r[e]=s,n[s].geometry[0].pop(),n[s].geometry[0]=n[s].geometry[0].concat(i[0]),s}function o(t,r,i){const s=e[r];return delete e[r],e[t]=s,n[s].geometry[0].shift(),n[s].geometry[0]=i[0].concat(n[s].geometry[0]),s}function l(t,e,r){const n=r?e[0][e[0].length-1]:e[0][0];return `${t}:${n.x}:${n.y}`}for(let u=0;ut.geometry))}(this.features)),this.sortFeaturesByKey&&this.features.sort(((t,e)=>t.sortKey-e.sortKey));}update(t,e,r){this.stateDependentLayers.length&&(this.text.programConfigurations.updatePaintArrays(t,e,this.layers,r),this.icon.programConfigurations.updatePaintArrays(t,e,this.layers,r));}isEmpty(){return 0===this.symbolInstances.length&&!this.hasRTLText}uploadPending(){return !this.uploaded||this.text.programConfigurations.needsUpload||this.icon.programConfigurations.needsUpload}upload(t){!this.uploaded&&this.hasDebugData()&&(this.textCollisionBox.upload(t),this.iconCollisionBox.upload(t)),this.text.upload(t,this.sortFeaturesByY,!this.uploaded,this.text.programConfigurations.needsUpload),this.icon.upload(t,this.sortFeaturesByY,!this.uploaded,this.icon.programConfigurations.needsUpload),this.uploaded=!0;}destroyDebugData(){this.textCollisionBox.destroy(),this.iconCollisionBox.destroy();}destroy(){this.text.destroy(),this.icon.destroy(),this.hasDebugData()&&this.destroyDebugData();}addToLineVertexArray(t,e){const r=this.lineVertexArray.length;if(void 0!==t.segment){let r=t.dist(e[t.segment+1]),n=t.dist(e[t.segment]);const i={};for(let n=t.segment+1;n=0;r--)i[r]={x:e[r].x,y:e[r].y,tileUnitDistanceFromAnchor:n},r>0&&(n+=e[r-1].dist(e[r]));for(let t=0;t0}hasIconData(){return this.icon.segments.get().length>0}hasDebugData(){return this.textCollisionBox&&this.iconCollisionBox}hasTextCollisionBoxData(){return this.hasDebugData()&&this.textCollisionBox.segments.get().length>0}hasIconCollisionBoxData(){return this.hasDebugData()&&this.iconCollisionBox.segments.get().length>0}addIndicesForPlacedSymbol(t,e){const r=t.placedSymbolArray.get(e),n=r.vertexStartIndex+4*r.numGlyphs;for(let e=r.vertexStartIndex;en[t]-n[e]||i[e]-i[t])),s}addToSortKeyRanges(t,e){const r=this.sortKeyRanges[this.sortKeyRanges.length-1];r&&r.sortKey===e?r.symbolInstanceEnd=t+1:this.sortKeyRanges.push({sortKey:e,symbolInstanceStart:t,symbolInstanceEnd:t+1});}sortFeatures(t){if(this.sortFeaturesByY&&this.sortedAngle!==t&&!(this.text.segments.get().length>1||this.icon.segments.get().length>1)){this.symbolInstanceIndexes=this.getSortedSymbolIndexes(t),this.sortedAngle=t,this.text.indexArray.clear(),this.icon.indexArray.clear(),this.featureSortOrder=[];for(const t of this.symbolInstanceIndexes){const e=this.symbolInstances.get(t);this.featureSortOrder.push(e.featureIndex),[e.rightJustifiedTextSymbolIndex,e.centerJustifiedTextSymbolIndex,e.leftJustifiedTextSymbolIndex].forEach(((t,e,r)=>{t>=0&&r.indexOf(t)===e&&this.addIndicesForPlacedSymbol(this.text,t);})),e.verticalPlacedTextSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.text,e.verticalPlacedTextSymbolIndex),e.placedIconSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.icon,e.placedIconSymbolIndex),e.verticalPlacedIconSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.icon,e.verticalPlacedIconSymbolIndex);}this.text.indexBuffer&&this.text.indexBuffer.updateData(this.text.indexArray),this.icon.indexBuffer&&this.icon.indexBuffer.updateData(this.icon.indexArray);}}}let yc,mc;wi("SymbolBucket",dc,{omit:["layers","collisionBoxArray","features","compareText"]}),dc.MAX_GLYPHS=65535,dc.addDynamicAttributes=cc;var gc={get paint(){return mc=mc||new rs({"icon-opacity":new Wi(G.paint_symbol["icon-opacity"]),"icon-color":new Wi(G.paint_symbol["icon-color"]),"icon-halo-color":new Wi(G.paint_symbol["icon-halo-color"]),"icon-halo-width":new Wi(G.paint_symbol["icon-halo-width"]),"icon-halo-blur":new Wi(G.paint_symbol["icon-halo-blur"]),"icon-translate":new Ji(G.paint_symbol["icon-translate"]),"icon-translate-anchor":new Ji(G.paint_symbol["icon-translate-anchor"]),"text-opacity":new Wi(G.paint_symbol["text-opacity"]),"text-color":new Wi(G.paint_symbol["text-color"],{runtimeType:pt,getOverride:t=>t.textColor,hasOverride:t=>!!t.textColor}),"text-halo-color":new Wi(G.paint_symbol["text-halo-color"]),"text-halo-width":new Wi(G.paint_symbol["text-halo-width"]),"text-halo-blur":new Wi(G.paint_symbol["text-halo-blur"]),"text-translate":new Ji(G.paint_symbol["text-translate"]),"text-translate-anchor":new Ji(G.paint_symbol["text-translate-anchor"])})},get layout(){return yc=yc||new rs({"symbol-placement":new Ji(G.layout_symbol["symbol-placement"]),"symbol-spacing":new Ji(G.layout_symbol["symbol-spacing"]),"symbol-avoid-edges":new Ji(G.layout_symbol["symbol-avoid-edges"]),"symbol-sort-key":new Wi(G.layout_symbol["symbol-sort-key"]),"symbol-z-order":new Ji(G.layout_symbol["symbol-z-order"]),"icon-allow-overlap":new Ji(G.layout_symbol["icon-allow-overlap"]),"icon-overlap":new Ji(G.layout_symbol["icon-overlap"]),"icon-ignore-placement":new Ji(G.layout_symbol["icon-ignore-placement"]),"icon-optional":new Ji(G.layout_symbol["icon-optional"]),"icon-rotation-alignment":new Ji(G.layout_symbol["icon-rotation-alignment"]),"icon-size":new Wi(G.layout_symbol["icon-size"]),"icon-text-fit":new Ji(G.layout_symbol["icon-text-fit"]),"icon-text-fit-padding":new Ji(G.layout_symbol["icon-text-fit-padding"]),"icon-image":new Wi(G.layout_symbol["icon-image"]),"icon-rotate":new Wi(G.layout_symbol["icon-rotate"]),"icon-padding":new Wi(G.layout_symbol["icon-padding"]),"icon-keep-upright":new Ji(G.layout_symbol["icon-keep-upright"]),"icon-offset":new Wi(G.layout_symbol["icon-offset"]),"icon-anchor":new Wi(G.layout_symbol["icon-anchor"]),"icon-pitch-alignment":new Ji(G.layout_symbol["icon-pitch-alignment"]),"text-pitch-alignment":new Ji(G.layout_symbol["text-pitch-alignment"]),"text-rotation-alignment":new Ji(G.layout_symbol["text-rotation-alignment"]),"text-field":new Wi(G.layout_symbol["text-field"]),"text-font":new Wi(G.layout_symbol["text-font"]),"text-size":new Wi(G.layout_symbol["text-size"]),"text-max-width":new Wi(G.layout_symbol["text-max-width"]),"text-line-height":new Ji(G.layout_symbol["text-line-height"]),"text-letter-spacing":new Wi(G.layout_symbol["text-letter-spacing"]),"text-justify":new Wi(G.layout_symbol["text-justify"]),"text-radial-offset":new Wi(G.layout_symbol["text-radial-offset"]),"text-variable-anchor":new Ji(G.layout_symbol["text-variable-anchor"]),"text-variable-anchor-offset":new Wi(G.layout_symbol["text-variable-anchor-offset"]),"text-anchor":new Wi(G.layout_symbol["text-anchor"]),"text-max-angle":new Ji(G.layout_symbol["text-max-angle"]),"text-writing-mode":new Ji(G.layout_symbol["text-writing-mode"]),"text-rotate":new Wi(G.layout_symbol["text-rotate"]),"text-padding":new Ji(G.layout_symbol["text-padding"]),"text-keep-upright":new Ji(G.layout_symbol["text-keep-upright"]),"text-transform":new Wi(G.layout_symbol["text-transform"]),"text-offset":new Wi(G.layout_symbol["text-offset"]),"text-allow-overlap":new Ji(G.layout_symbol["text-allow-overlap"]),"text-overlap":new Ji(G.layout_symbol["text-overlap"]),"text-ignore-placement":new Ji(G.layout_symbol["text-ignore-placement"]),"text-optional":new Ji(G.layout_symbol["text-optional"])})}};class xc{constructor(t){if(void 0===t.property.overrides)throw new Error("overrides must be provided to instantiate FormatSectionOverride class");this.type=t.property.overrides?t.property.overrides.runtimeType:lt,this.defaultValue=t;}evaluate(t){if(t.formattedSection){const e=this.defaultValue.property.overrides;if(e&&e.hasOverride(t.formattedSection))return e.getOverride(t.formattedSection)}return t.feature&&t.featureState?this.defaultValue.evaluate(t.feature,t.featureState):this.defaultValue.property.specification.default}eachChild(t){this.defaultValue.isConstant()||t(this.defaultValue.value._styleExpression.expression);}outputDefined(){return !1}serialize(){return null}}wi("FormatSectionOverride",xc,{omit:["defaultValue"]});class vc extends is{constructor(t){super(t,gc);}recalculate(t,e){if(super.recalculate(t,e),"auto"===this.layout.get("icon-rotation-alignment")&&(this.layout._values["icon-rotation-alignment"]="point"!==this.layout.get("symbol-placement")?"map":"viewport"),"auto"===this.layout.get("text-rotation-alignment")&&(this.layout._values["text-rotation-alignment"]="point"!==this.layout.get("symbol-placement")?"map":"viewport"),"auto"===this.layout.get("text-pitch-alignment")&&(this.layout._values["text-pitch-alignment"]="map"===this.layout.get("text-rotation-alignment")?"map":"viewport"),"auto"===this.layout.get("icon-pitch-alignment")&&(this.layout._values["icon-pitch-alignment"]=this.layout.get("icon-rotation-alignment")),"point"===this.layout.get("symbol-placement")){const t=this.layout.get("text-writing-mode");if(t){const e=[];for(const r of t)e.indexOf(r)<0&&e.push(r);this.layout._values["text-writing-mode"]=e;}else this.layout._values["text-writing-mode"]=["horizontal"];}this._setPaintOverrides();}getValueAndResolveTokens(t,e,r,n){const i=this.layout.get(t).evaluate(e,{},r,n),s=this._unevaluatedLayout._values[t];return s.isDataDriven()||vn(s.value)||!i?i:function(t,e){return e.replace(/{([^{}]+)}/g,((e,r)=>t&&r in t?String(t[r]):""))}(e.properties,i)}createBucket(t){return new dc(t)}queryRadius(){return 0}queryIntersectsFeature(){throw new Error("Should take a different path in FeatureIndex")}_setPaintOverrides(){for(const t of gc.paint.overridableProperties){if(!vc.hasPaintOverride(this.layout,t))continue;const e=this.paint.get(t),r=new xc(e),n=new xn(r,e.property.specification);let i=null;i="constant"===e.value.kind||"source"===e.value.kind?new wn("source",n):new _n("composite",n,e.value.zoomStops),this.paint._values[t]=new Hi(e.property,i,e.parameters);}}_handleOverridablePaintPropertyUpdate(t,e,r){return !(!this.layout||e.isDataDriven()||r.isDataDriven())&&vc.hasPaintOverride(this.layout,t)}static hasPaintOverride(t,e){const r=t.get("text-field"),n=gc.paint.properties[e];let i=!1;const s=t=>{for(const e of t)if(n.overrides&&n.overrides.hasOverride(e))return void(i=!0)};if("constant"===r.value.kind&&r.value.value instanceof Yt)s(r.value.value.sections);else if("source"===r.value.kind){const t=e=>{i||(e instanceof se&&ne(e.value)===mt?s(e.value.sections):e instanceof Ze?s(e.sections):e.eachChild(t));},e=r.value;e._styleExpression&&t(e._styleExpression.expression);}return i}}let bc;var wc={get paint(){return bc=bc||new rs({"background-color":new Ji(G.paint_background["background-color"]),"background-pattern":new ts(G.paint_background["background-pattern"]),"background-opacity":new Ji(G.paint_background["background-opacity"])})}};class _c extends is{constructor(t){super(t,wc);}}let Ac;var Sc={get paint(){return Ac=Ac||new rs({"raster-opacity":new Ji(G.paint_raster["raster-opacity"]),"raster-hue-rotate":new Ji(G.paint_raster["raster-hue-rotate"]),"raster-brightness-min":new Ji(G.paint_raster["raster-brightness-min"]),"raster-brightness-max":new Ji(G.paint_raster["raster-brightness-max"]),"raster-saturation":new Ji(G.paint_raster["raster-saturation"]),"raster-contrast":new Ji(G.paint_raster["raster-contrast"]),"raster-resampling":new Ji(G.paint_raster["raster-resampling"]),"raster-fade-duration":new Ji(G.paint_raster["raster-fade-duration"])})}};class kc extends is{constructor(t){super(t,Sc);}}class Mc extends is{constructor(t){super(t,{}),this.onAdd=t=>{this.implementation.onAdd&&this.implementation.onAdd(t,t.painter.context.gl);},this.onRemove=t=>{this.implementation.onRemove&&this.implementation.onRemove(t,t.painter.context.gl);},this.implementation=t;}is3D(){return "3d"===this.implementation.renderingMode}hasOffscreenPass(){return void 0!==this.implementation.prerender}recalculate(){}updateTransitions(){}hasTransition(){return !1}serialize(){throw new Error("Custom layers cannot be serialized")}}class Ic{constructor(t){this._methodToThrottle=t,this._triggered=!1,"undefined"!=typeof MessageChannel&&(this._channel=new MessageChannel,this._channel.port2.onmessage=()=>{this._triggered=!1,this._methodToThrottle();});}trigger(){this._triggered||(this._triggered=!0,this._channel?this._channel.port1.postMessage(!0):setTimeout((()=>{this._triggered=!1,this._methodToThrottle();}),0));}remove(){delete this._channel,this._methodToThrottle=()=>{};}}const zc=6371008.8;class Pc{constructor(t,e){if(isNaN(t)||isNaN(e))throw new Error(`Invalid LngLat object: (${t}, ${e})`);if(this.lng=+t,this.lat=+e,this.lat>90||this.lat<-90)throw new Error("Invalid LngLat latitude value: must be between -90 and 90")}wrap(){return new Pc(m(this.lng,-180,180),this.lat)}toArray(){return [this.lng,this.lat]}toString(){return `LngLat(${this.lng}, ${this.lat})`}distanceTo(t){const e=Math.PI/180,r=this.lat*e,n=t.lat*e,i=Math.sin(r)*Math.sin(n)+Math.cos(r)*Math.cos(n)*Math.cos((t.lng-this.lng)*e);return zc*Math.acos(Math.min(i,1))}static convert(t){if(t instanceof Pc)return t;if(Array.isArray(t)&&(2===t.length||3===t.length))return new Pc(Number(t[0]),Number(t[1]));if(!Array.isArray(t)&&"object"==typeof t&&null!==t)return new Pc(Number("lng"in t?t.lng:t.lon),Number(t.lat));throw new Error("`LngLatLike` argument must be specified as a LngLat instance, an object {lng: , lat: }, an object {lon: , lat: }, or an array of [, ]")}}const Cc=2*Math.PI*zc;function Bc(t){return Cc*Math.cos(t*Math.PI/180)}function Vc(t){return (180+t)/360}function Ec(t){return (180-180/Math.PI*Math.log(Math.tan(Math.PI/4+t*Math.PI/360)))/360}function Fc(t,e){return t/Bc(e)}function Tc(t){return 360/Math.PI*Math.atan(Math.exp((180-360*t)*Math.PI/180))-90}class $c{constructor(t,e,r=0){this.x=+t,this.y=+e,this.z=+r;}static fromLngLat(t,e=0){const r=Pc.convert(t);return new $c(Vc(r.lng),Ec(r.lat),Fc(e,r.lat))}toLngLat(){return new Pc(360*this.x-180,Tc(this.y))}toAltitude(){return this.z*Bc(Tc(this.y))}meterInMercatorCoordinateUnits(){return 1/Cc*(t=Tc(this.y),1/Math.cos(t*Math.PI/180));var t;}}function Lc(t,e,r){var n=2*Math.PI*6378137/256/Math.pow(2,r);return [t*n-2*Math.PI*6378137/2,e*n-2*Math.PI*6378137/2]}class Dc{constructor(t,e,r){if(!function(t,e,r){return !(t<0||t>25||r<0||r>=Math.pow(2,t)||e<0||e>=Math.pow(2,t))}(t,e,r))throw new Error(`x=${e}, y=${r}, z=${t} outside of bounds. 0<=x<${Math.pow(2,t)}, 0<=y<${Math.pow(2,t)} 0<=z<=25 `);this.z=t,this.x=e,this.y=r,this.key=Rc(0,t,t,e,r);}equals(t){return this.z===t.z&&this.x===t.x&&this.y===t.y}url(t,e,r){const n=(s=this.y,a=this.z,o=Lc(256*(i=this.x),256*(s=Math.pow(2,a)-s-1),a),l=Lc(256*(i+1),256*(s+1),a),o[0]+","+o[1]+","+l[0]+","+l[1]);var i,s,a,o,l;const u=function(t,e,r){let n,i="";for(let s=t;s>0;s--)n=1<1?"@2x":"").replace(/{quadkey}/g,u).replace(/{bbox-epsg-3857}/g,n)}isChildOf(t){const e=this.z-t.z;return e>0&&t.x===this.x>>e&&t.y===this.y>>e}getTilePoint(t){const e=Math.pow(2,this.z);return new s((t.x*e-this.x)*$a,(t.y*e-this.y)*$a)}toString(){return `${this.z}/${this.x}/${this.y}`}}class Oc{constructor(t,e){this.wrap=t,this.canonical=e,this.key=Rc(t,e.z,e.z,e.x,e.y);}}class jc{constructor(t,e,r,n,i){if(t= z; overscaledZ = ${t}; z = ${r}`);this.overscaledZ=t,this.wrap=e,this.canonical=new Dc(r,+n,+i),this.key=Rc(e,t,r,n,i);}clone(){return new jc(this.overscaledZ,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y)}equals(t){return this.overscaledZ===t.overscaledZ&&this.wrap===t.wrap&&this.canonical.equals(t.canonical)}scaledTo(t){if(t>this.overscaledZ)throw new Error(`targetZ > this.overscaledZ; targetZ = ${t}; overscaledZ = ${this.overscaledZ}`);const e=this.canonical.z-t;return t>this.canonical.z?new jc(t,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y):new jc(t,this.wrap,t,this.canonical.x>>e,this.canonical.y>>e)}calculateScaledKey(t,e){if(t>this.overscaledZ)throw new Error(`targetZ > this.overscaledZ; targetZ = ${t}; overscaledZ = ${this.overscaledZ}`);const r=this.canonical.z-t;return t>this.canonical.z?Rc(this.wrap*+e,t,this.canonical.z,this.canonical.x,this.canonical.y):Rc(this.wrap*+e,t,t,this.canonical.x>>r,this.canonical.y>>r)}isChildOf(t){if(t.wrap!==this.wrap)return !1;const e=this.canonical.z-t.canonical.z;return 0===t.overscaledZ||t.overscaledZ>e&&t.canonical.y===this.canonical.y>>e}children(t){if(this.overscaledZ>=t)return [new jc(this.overscaledZ+1,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y)];const e=this.canonical.z+1,r=2*this.canonical.x,n=2*this.canonical.y;return [new jc(e,this.wrap,e,r,n),new jc(e,this.wrap,e,r+1,n),new jc(e,this.wrap,e,r,n+1),new jc(e,this.wrap,e,r+1,n+1)]}isLessThan(t){return this.wrapt.wrap)&&(this.overscaledZt.overscaledZ)&&(this.canonical.xt.canonical.x)&&this.canonical.ythis.max&&(this.max=r),r=this.dim+1||e<-1||e>=this.dim+1)throw new RangeError("out of range source coordinates for DEM data");return (e+1)*this.stride+(t+1)}unpack(t,e,r){return t*this.redFactor+e*this.greenFactor+r*this.blueFactor-this.baseShift}getPixels(){return new Ao({width:this.stride,height:this.stride},new Uint8Array(this.data.buffer))}backfillBorder(t,e,r){if(this.dim!==t.dim)throw new Error("dem dimension mismatch");let n=e*this.dim,i=e*this.dim+this.dim,s=r*this.dim,a=r*this.dim+this.dim;switch(e){case-1:n=i-1;break;case 1:i=n+1;}switch(r){case-1:s=a-1;break;case 1:a=s+1;}const o=-e*this.dim,l=-r*this.dim;for(let e=s;e=this._numberToString.length)throw new Error(`Out of bounds. Index requested n=${t} can't be >= this._numberToString.length ${this._numberToString.length}`);return this._numberToString[t]}}class Nc{constructor(t,e,r,n,i){this.type="Feature",this._vectorTileFeature=t,t._z=e,t._x=r,t._y=n,this.properties=t.properties,this.id=i;}get geometry(){return void 0===this._geometry&&(this._geometry=this._vectorTileFeature.toGeoJSON(this._vectorTileFeature._x,this._vectorTileFeature._y,this._vectorTileFeature._z).geometry),this._geometry}set geometry(t){this._geometry=t;}toJSON(){const t={geometry:this.geometry};for(const e in this)"_geometry"!==e&&"_vectorTileFeature"!==e&&(t[e]=this[e]);return t}}class Zc{constructor(t,e){this.tileID=t,this.x=t.canonical.x,this.y=t.canonical.y,this.z=t.canonical.z,this.grid=new vi($a,16,0),this.grid3D=new vi($a,16,0),this.featureIndexArray=new Ns,this.promoteId=e;}insert(t,e,r,n,i,s){const a=this.featureIndexArray.length;this.featureIndexArray.emplaceBack(r,n,i);const o=s?this.grid3D:this.grid;for(let t=0;t=0&&n[3]>=0&&o.insert(a,n[0],n[1],n[2],n[3]);}}loadVTLayers(){return this.vtLayers||(this.vtLayers=new fl.VectorTile(new Iu(this.rawTileData)).layers,this.sourceLayerCoder=new qc(this.vtLayers?Object.keys(this.vtLayers).sort():["_geojsonTileLayer"])),this.vtLayers}query(t,e,r,n){this.loadVTLayers();const i=t.params||{},a=$a/t.tileSize/t.scale,o=zn(i.filter),l=t.queryGeometry,u=t.queryPadding*a,c=Kc(l),h=this.grid.query(c.minX-u,c.minY-u,c.maxX+u,c.maxY+u),p=Kc(t.cameraQueryGeometry),f=this.grid3D.query(p.minX-u,p.minY-u,p.maxX+u,p.maxY+u,((e,r,n,i)=>function(t,e,r,n,i){for(const s of t)if(e<=s.x&&r<=s.y&&n>=s.x&&i>=s.y)return !0;const a=[new s(e,r),new s(e,i),new s(n,i),new s(n,r)];if(t.length>2)for(const e of a)if(Wa(t,e))return !0;for(let e=0;e(p||(p=Oa(e)),r.queryIntersectsFeature(l,e,n,p,this.z,t.transform,a,t.pixelPosMatrix))));}return d}loadMatchingFeature(t,e,r,n,i,s,a,o,l,u,c){const h=this.bucketLayerIDs[e];if(s&&!function(t,e){for(let r=0;r=0)return !0;return !1}(s,h))return;const p=this.sourceLayerCoder.decode(r),f=this.vtLayers[p].feature(n);if(i.needGeometry){const t=ja(f,!0);if(!i.filter(new Ui(this.tileID.overscaledZ),t,this.tileID.canonical))return}else if(!i.filter(new Ui(this.tileID.overscaledZ),f))return;const d=this.getId(f,p);for(let e=0;e{const a=e instanceof Yi?e.get(s):null;return a&&a.evaluate?a.evaluate(r,n,i):a}))}function Kc(t){let e=1/0,r=1/0,n=-1/0,i=-1/0;for(const s of t)e=Math.min(e,s.x),r=Math.min(r,s.y),n=Math.max(n,s.x),i=Math.max(i,s.y);return {minX:e,minY:r,maxX:n,maxY:i}}function Xc(t,e){return e-t}function Hc(t,e,r,n,i){const a=[];for(let o=0;o=n&&c.x>=n||(o.x>=n?o=new s(n,o.y+(n-o.x)/(c.x-o.x)*(c.y-o.y))._round():c.x>=n&&(c=new s(n,o.y+(n-o.x)/(c.x-o.x)*(c.y-o.y))._round()),o.y>=i&&c.y>=i||(o.y>=i?o=new s(o.x+(i-o.y)/(c.y-o.y)*(c.x-o.x),i)._round():c.y>=i&&(c=new s(o.x+(i-o.y)/(c.y-o.y)*(c.x-o.x),i)._round()),u&&o.equals(u[u.length-1])||(u=[o],a.push(u)),u.push(c)))));}}return a}wi("FeatureIndex",Zc,{omit:["rawTileData","sourceLayerCoder"]});class Yc extends s{constructor(t,e,r,n){super(t,e),this.angle=r,void 0!==n&&(this.segment=n);}clone(){return new Yc(this.x,this.y,this.angle,this.segment)}}function Jc(t,e,r,n,i){if(void 0===e.segment||0===r)return !0;let s=e,a=e.segment+1,o=0;for(;o>-r/2;){if(a--,a<0)return !1;o-=t[a].dist(s),s=t[a];}o+=t[a].dist(t[a+1]),a++;const l=[];let u=0;for(;on;)u-=l.shift().angleDelta;if(u>i)return !1;a++,o+=e.dist(r);}return !0}function Wc(t){let e=0;for(let r=0;ru){const c=(u-l)/s,h=Ce.number(n.x,i.x,c),p=Ce.number(n.y,i.y,c),f=new Yc(h,p,i.angleTo(n),r);return f._round(),!a||Jc(t,f,o,a,e)?f:void 0}l+=s;}}function rh(t,e,r,n,i,s,a,o,l){const u=Qc(n,s,a),c=th(n,i),h=c*a,p=0===t[0].x||t[0].x===l||0===t[0].y||t[0].y===l;return e-h=0&&g=0&&x=0&&p+u<=c){const r=new Yc(g,x,y,e);r._round(),n&&!Jc(t,r,s,n,i)||f.push(r);}}h+=d;}return o||f.length||a||(f=nh(t,h/2,r,n,i,s,a,!0,l)),f}wi("Anchor",Yc);const ih=Fu;function sh(t,e,r,n){const i=[],a=t.image,o=a.pixelRatio,l=a.paddedRect.w-2*ih,u=a.paddedRect.h-2*ih;let c={x1:t.left,y1:t.top,x2:t.right,y2:t.bottom};const h=a.stretchX||[[0,l]],p=a.stretchY||[[0,u]],f=(t,e)=>t+e[1]-e[0],d=h.reduce(f,0),y=p.reduce(f,0),m=l-d,g=u-y;let x=0,v=d,b=0,w=y,_=0,A=m,S=0,k=g;if(a.content&&n){const e=a.content,r=e[2]-e[0],n=e[3]-e[1];(a.textFitWidth||a.textFitHeight)&&(c=tc(t)),x=ah(h,0,e[0]),b=ah(p,0,e[1]),v=ah(h,e[0],e[2]),w=ah(p,e[1],e[3]),_=e[0]-x,S=e[1]-b,A=r-v,k=n-w;}const M=c.x1,I=c.y1,z=c.x2-M,P=c.y2-I,C=(t,n,i,l)=>{const u=lh(t.stretch-x,v,z,M),c=uh(t.fixed-_,A,t.stretch,d),h=lh(n.stretch-b,w,P,I),p=uh(n.fixed-S,k,n.stretch,y),f=lh(i.stretch-x,v,z,M),m=uh(i.fixed-_,A,i.stretch,d),g=lh(l.stretch-b,w,P,I),C=uh(l.fixed-S,k,l.stretch,y),B=new s(u,h),V=new s(f,h),E=new s(f,g),F=new s(u,g),T=new s(c/o,p/o),$=new s(m/o,C/o),L=e*Math.PI/180;if(L){const t=Math.sin(L),e=Math.cos(L),r=[e,-t,t,e];B._matMult(r),V._matMult(r),F._matMult(r),E._matMult(r);}const D=t.stretch+t.fixed,O=n.stretch+n.fixed;return {tl:B,tr:V,bl:F,br:E,tex:{x:a.paddedRect.x+ih+D,y:a.paddedRect.y+ih+O,w:i.stretch+i.fixed-D,h:l.stretch+l.fixed-O},writingMode:void 0,glyphOffset:[0,0],sectionIndex:0,pixelOffsetTL:T,pixelOffsetBR:$,minFontScaleX:A/o/z,minFontScaleY:k/o/P,isSDF:r}};if(n&&(a.stretchX||a.stretchY)){const t=oh(h,m,d),e=oh(p,g,y);for(let r=0;r0&&(n=Math.max(10,n),this.circleDiameter=n);}else {const u=(null===(h=a.image)||void 0===h?void 0:h.content)&&(a.image.textFitWidth||a.image.textFitHeight)?tc(a):{x1:a.left,y1:a.top,x2:a.right,y2:a.bottom};u.y1=u.y1*o-l[0],u.y2=u.y2*o+l[2],u.x1=u.x1*o-l[3],u.x2=u.x2*o+l[1];const p=a.collisionPadding;if(p&&(u.x1-=p[0]*o,u.y1-=p[1]*o,u.x2+=p[2]*o,u.y2+=p[3]*o),c){const t=new s(u.x1,u.y1),e=new s(u.x2,u.y1),r=new s(u.x1,u.y2),n=new s(u.x2,u.y2),i=c*Math.PI/180;t._rotate(i),e._rotate(i),r._rotate(i),n._rotate(i),u.x1=Math.min(t.x,e.x,r.x,n.x),u.x2=Math.max(t.x,e.x,r.x,n.x),u.y1=Math.min(t.y,e.y,r.y,n.y),u.y2=Math.max(t.y,e.y,r.y,n.y);}t.emplaceBack(e.x,e.y,u.x1,u.y1,u.x2,u.y2,r,n,i);}this.boxEndIndex=t.length;}}class hh{constructor(t=[],e=((t,e)=>te?1:0)){if(this.data=t,this.length=this.data.length,this.compare=e,this.length>0)for(let t=(this.length>>1)-1;t>=0;t--)this._down(t);}push(t){this.data.push(t),this._up(this.length++);}pop(){if(0===this.length)return;const t=this.data[0],e=this.data.pop();return --this.length>0&&(this.data[0]=e,this._down(0)),t}peek(){return this.data[0]}_up(t){const{data:e,compare:r}=this,n=e[t];for(;t>0;){const i=t-1>>1,s=e[i];if(r(n,s)>=0)break;e[t]=s,t=i;}e[t]=n;}_down(t){const{data:e,compare:r}=this,n=this.length>>1,i=e[t];for(;t=0)break;e[t]=e[n],t=n;}e[t]=i;}}function ph(t,e=1,r=!1){let n=1/0,i=1/0,a=-1/0,o=-1/0;const l=t[0];for(let t=0;ta)&&(a=e.x),(!t||e.y>o)&&(o=e.y);}const u=Math.min(a-n,o-i);let c=u/2;const h=new hh([],fh);if(0===u)return new s(n,i);for(let e=n;ep.d||!p.d)&&(p=n,r&&console.log("found best %d after %d probes",Math.round(1e4*n.d)/1e4,f)),n.max-p.d<=e||(c=n.h/2,h.push(new dh(n.p.x-c,n.p.y-c,c,t)),h.push(new dh(n.p.x+c,n.p.y-c,c,t)),h.push(new dh(n.p.x-c,n.p.y+c,c,t)),h.push(new dh(n.p.x+c,n.p.y+c,c,t)),f+=4);}return r&&(console.log(`num probes: ${f}`),console.log(`best distance: ${p.d}`)),p.p}function fh(t,e){return e.max-t.max}function dh(t,e,r,n){this.p=new s(t,e),this.h=r,this.d=function(t,e){let r=!1,n=1/0;for(let i=0;it.y!=o.y>t.y&&t.x<(o.x-i.x)*(t.y-i.y)/(o.y-i.y)+i.x&&(r=!r),n=Math.min(n,Ya(t,i,o));}}return (r?1:-1)*Math.sqrt(n)}(this.p,n),this.max=this.d+this.h*Math.SQRT2;}var yh;t.aq=void 0,(yh=t.aq||(t.aq={}))[yh.center=1]="center",yh[yh.left=2]="left",yh[yh.right=3]="right",yh[yh.top=4]="top",yh[yh.bottom=5]="bottom",yh[yh["top-left"]=6]="top-left",yh[yh["top-right"]=7]="top-right",yh[yh["bottom-left"]=8]="bottom-left",yh[yh["bottom-right"]=9]="bottom-right";const mh=7,gh=Number.POSITIVE_INFINITY;function xh(t,e){return e[1]!==gh?function(t,e,r){let n=0,i=0;switch(e=Math.abs(e),r=Math.abs(r),t){case"top-right":case"top-left":case"top":i=r-mh;break;case"bottom-right":case"bottom-left":case"bottom":i=-r+mh;}switch(t){case"top-right":case"bottom-right":case"right":n=-e;break;case"top-left":case"bottom-left":case"left":n=e;}return [n,i]}(t,e[0],e[1]):function(t,e){let r=0,n=0;e<0&&(e=0);const i=e/Math.SQRT2;switch(t){case"top-right":case"top-left":n=i-mh;break;case"bottom-right":case"bottom-left":n=-i+mh;break;case"bottom":n=-e+mh;break;case"top":n=e-mh;}switch(t){case"top-right":case"bottom-right":r=-i;break;case"top-left":case"bottom-left":r=i;break;case"left":r=e;break;case"right":r=-e;}return [r,n]}(t,e[0])}function vh(t,e,r){var n;const i=t.layout,s=null===(n=i.get("text-variable-anchor-offset"))||void 0===n?void 0:n.evaluate(e,{},r);if(s){const t=s.values,e=[];for(let r=0;rt*iu));n.startsWith("top")?i[1]-=mh:n.startsWith("bottom")&&(i[1]+=mh),e[r+1]=i;}return new Qt(e)}const a=i.get("text-variable-anchor");if(a){let n;n=void 0!==t._unevaluatedLayout.getValue("text-radial-offset")?[i.get("text-radial-offset").evaluate(e,{},r)*iu,gh]:i.get("text-offset").evaluate(e,{},r).map((t=>t*iu));const s=[];for(const t of a)s.push(t,xh(t,n));return new Qt(s)}return null}function bh(t){switch(t){case"right":case"top-right":case"bottom-right":return "right";case"left":case"top-left":case"bottom-left":return "left"}return "center"}function wh(e,r,n,i,s,a,o,l,u,c,h){let p=a.textMaxSize.evaluate(r,{});void 0===p&&(p=o);const f=e.layers[0].layout,d=f.get("icon-offset").evaluate(r,{},h),y=Ah(n.horizontal),m=o/24,g=e.tilePixelRatio*m,x=e.tilePixelRatio*p/24,v=e.tilePixelRatio*l,b=e.tilePixelRatio*f.get("symbol-spacing"),w=f.get("text-padding")*e.tilePixelRatio,_=function(t,e,r,n=1){const i=t.get("icon-padding").evaluate(e,{},r),s=i&&i.values;return [s[0]*n,s[1]*n,s[2]*n,s[3]*n]}(f,r,h,e.tilePixelRatio),S=f.get("text-max-angle")/180*Math.PI,k="viewport"!==f.get("text-rotation-alignment")&&"point"!==f.get("symbol-placement"),M="map"===f.get("icon-rotation-alignment")&&"point"!==f.get("symbol-placement"),I=f.get("symbol-placement"),z=b/2,P=f.get("icon-text-fit");let C;i&&"none"!==P&&(e.allowVerticalPlacement&&n.vertical&&(C=ec(i,n.vertical,P,f.get("icon-text-fit-padding"),d,m)),y&&(i=ec(i,y,P,f.get("icon-text-fit-padding"),d,m)));const B=(l,p)=>{p.x<0||p.x>=$a||p.y<0||p.y>=$a||function(e,r,n,i,s,a,o,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,S,k,M){const I=e.addToLineVertexArray(r,n);let z,P,C,B,V=0,E=0,F=0,T=0,$=-1,L=-1;const D={};let O=ma("");if(e.allowVerticalPlacement&&i.vertical){const t=l.layout.get("text-rotate").evaluate(w,{},k)+90;C=new ch(u,r,c,h,p,i.vertical,f,d,y,t),o&&(B=new ch(u,r,c,h,p,o,g,x,y,t));}if(s){const n=l.layout.get("icon-rotate").evaluate(w,{}),i="none"!==l.layout.get("icon-text-fit"),a=sh(s,n,S,i),f=o?sh(o,n,S,i):void 0;P=new ch(u,r,c,h,p,s,g,x,!1,n),V=4*a.length;const d=e.iconSizeData;let y=null;"source"===d.kind?(y=[nc*l.layout.get("icon-size").evaluate(w,{})],y[0]>ic&&A(`${e.layerIds[0]}: Value for "icon-size" is >= ${rc}. Reduce your "icon-size".`)):"composite"===d.kind&&(y=[nc*_.compositeIconSizes[0].evaluate(w,{},k),nc*_.compositeIconSizes[1].evaluate(w,{},k)],(y[0]>ic||y[1]>ic)&&A(`${e.layerIds[0]}: Value for "icon-size" is >= ${rc}. Reduce your "icon-size".`)),e.addSymbols(e.icon,a,y,b,v,w,t.ah.none,r,I.lineStartIndex,I.lineLength,-1,k),$=e.icon.placedSymbolArray.length-1,f&&(E=4*f.length,e.addSymbols(e.icon,f,y,b,v,w,t.ah.vertical,r,I.lineStartIndex,I.lineLength,-1,k),L=e.icon.placedSymbolArray.length-1);}const j=Object.keys(i.horizontal);for(const n of j){const s=i.horizontal[n];if(!z){O=ma(s.text);const t=l.layout.get("text-rotate").evaluate(w,{},k);z=new ch(u,r,c,h,p,s,f,d,y,t);}const o=1===s.positionedLines.length;if(F+=_h(e,r,s,a,l,y,w,m,I,i.vertical?t.ah.horizontal:t.ah.horizontalOnly,o?j:[n],D,$,_,k),o)break}i.vertical&&(T+=_h(e,r,i.vertical,a,l,y,w,m,I,t.ah.vertical,["vertical"],D,L,_,k));const R=z?z.boxStartIndex:e.collisionBoxArray.length,U=z?z.boxEndIndex:e.collisionBoxArray.length,q=C?C.boxStartIndex:e.collisionBoxArray.length,N=C?C.boxEndIndex:e.collisionBoxArray.length,Z=P?P.boxStartIndex:e.collisionBoxArray.length,G=P?P.boxEndIndex:e.collisionBoxArray.length,K=B?B.boxStartIndex:e.collisionBoxArray.length,X=B?B.boxEndIndex:e.collisionBoxArray.length;let H=-1;const Y=(t,e)=>t&&t.circleDiameter?Math.max(t.circleDiameter,e):e;H=Y(z,H),H=Y(C,H),H=Y(P,H),H=Y(B,H);const J=H>-1?1:0;J&&(H*=M/iu),e.glyphOffsetArray.length>=dc.MAX_GLYPHS&&A("Too many glyphs being rendered in a tile. See https://github.com/mapbox/mapbox-gl-js/issues/2907"),void 0!==w.sortKey&&e.addToSortKeyRanges(e.symbolInstances.length,w.sortKey);const W=vh(l,w,k),[Q,tt]=function(e,r){const n=e.length,i=null==r?void 0:r.values;if((null==i?void 0:i.length)>0)for(let r=0;r=0?D.right:-1,D.center>=0?D.center:-1,D.left>=0?D.left:-1,D.vertical||-1,$,L,O,R,U,q,N,Z,G,K,X,c,F,T,V,E,J,0,f,H,Q,tt);}(e,p,l,n,i,s,C,e.layers[0],e.collisionBoxArray,r.index,r.sourceLayerIndex,e.index,g,[w,w,w,w],k,u,v,_,M,d,r,a,c,h,o);};if("line"===I)for(const t of Hc(r.geometry,0,0,$a,$a)){const r=rh(t,b,S,n.vertical||y,i,24,x,e.overscaling,$a);for(const n of r)y&&Sh(e,y.text,z,n)||B(t,n);}else if("line-center"===I){for(const t of r.geometry)if(t.length>1){const e=eh(t,S,n.vertical||y,i,24,x);e&&B(t,e);}}else if("Polygon"===r.type)for(const t of xr(r.geometry,0)){const e=ph(t,16);B(t[0],new Yc(e.x,e.y,0));}else if("LineString"===r.type)for(const t of r.geometry)B(t,new Yc(t[0].x,t[0].y,0));else if("Point"===r.type)for(const t of r.geometry)for(const e of t)B([e],new Yc(e.x,e.y,0));}function _h(t,e,r,n,i,a,o,l,u,c,h,p,f,d,y){const m=function(t,e,r,n,i,a,o,l){const u=n.layout.get("text-rotate").evaluate(a,{})*Math.PI/180,c=[];for(const t of e.positionedLines)for(const n of t.positionedGlyphs){if(!n.rect)continue;const a=n.rect||{};let h=Vu+1,p=!0,f=1,d=0;const y=(i||l)&&n.vertical,m=n.metrics.advance*n.scale/2;if(l&&e.verticalizable&&(d=t.lineOffset/2-(n.imageName?-(iu-n.metrics.width*n.scale)/2:(n.scale-1)*iu)),n.imageName){const t=o[n.imageName];p=t.sdf,f=t.pixelRatio,h=Fu/f;}const g=i?[n.x+m,n.y]:[0,0];let x=i?[0,0]:[n.x+m+r[0],n.y+r[1]-d],v=[0,0];y&&(v=x,x=[0,0]);const b=n.metrics.isDoubleResolution?2:1,w=(n.metrics.left-h)*n.scale-m+x[0],_=(-n.metrics.top-h)*n.scale+x[1],A=w+a.w/b*n.scale/f,S=_+a.h/b*n.scale/f,k=new s(w,_),M=new s(A,_),I=new s(w,S),z=new s(A,S);if(y){const t=new s(-m,m-Du),e=-Math.PI/2,r=iu/2-m,i=new s(5-Du-r,-(n.imageName?r:0)),a=new s(...v);k._rotateAround(e,t)._add(i)._add(a),M._rotateAround(e,t)._add(i)._add(a),I._rotateAround(e,t)._add(i)._add(a),z._rotateAround(e,t)._add(i)._add(a);}if(u){const t=Math.sin(u),e=Math.cos(u),r=[e,-t,t,e];k._matMult(r),M._matMult(r),I._matMult(r),z._matMult(r);}const P=new s(0,0),C=new s(0,0);c.push({tl:k,tr:M,bl:I,br:z,tex:a,writingMode:e.writingMode,glyphOffset:g,sectionIndex:n.sectionIndex,isSDF:p,pixelOffsetTL:P,pixelOffsetBR:C,minFontScaleX:0,minFontScaleY:0});}return c}(0,r,l,i,a,o,n,t.allowVerticalPlacement),g=t.textSizeData;let x=null;"source"===g.kind?(x=[nc*i.layout.get("text-size").evaluate(o,{})],x[0]>ic&&A(`${t.layerIds[0]}: Value for "text-size" is >= ${rc}. Reduce your "text-size".`)):"composite"===g.kind&&(x=[nc*d.compositeTextSizes[0].evaluate(o,{},y),nc*d.compositeTextSizes[1].evaluate(o,{},y)],(x[0]>ic||x[1]>ic)&&A(`${t.layerIds[0]}: Value for "text-size" is >= ${rc}. Reduce your "text-size".`)),t.addSymbols(t.text,m,x,l,a,o,c,e,u.lineStartIndex,u.lineLength,f,y);for(const e of h)p[e]=t.text.placedSymbolArray.length-1;return 4*m.length}function Ah(t){for(const e in t)return t[e];return null}function Sh(t,e,r,n){const i=t.compareText;if(e in i){const t=i[e];for(let e=t.length-1;e>=0;e--)if(n.dist(t[e])>4;if(1!==n)throw new Error(`Got v${n} data when expected v1.`);const i=kh[15&r];if(!i)throw new Error("Unrecognized array type.");const[s]=new Uint16Array(t,2,1),[a]=new Uint32Array(t,4,1);return new Mh(a,s,i,t)}constructor(t,e=64,r=Float64Array,n){if(isNaN(t)||t<0)throw new Error(`Unpexpected numItems value: ${t}.`);this.numItems=+t,this.nodeSize=Math.min(Math.max(+e,2),65535),this.ArrayType=r,this.IndexArrayType=t<65536?Uint16Array:Uint32Array;const i=kh.indexOf(this.ArrayType),s=2*t*this.ArrayType.BYTES_PER_ELEMENT,a=t*this.IndexArrayType.BYTES_PER_ELEMENT,o=(8-a%8)%8;if(i<0)throw new Error(`Unexpected typed array class: ${r}.`);n&&n instanceof ArrayBuffer?(this.data=n,this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+a+o,2*t),this._pos=2*t,this._finished=!0):(this.data=new ArrayBuffer(8+s+a+o),this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+a+o,2*t),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,16+i]),new Uint16Array(this.data,2,1)[0]=e,new Uint32Array(this.data,4,1)[0]=t);}add(t,e){const r=this._pos>>1;return this.ids[r]=r,this.coords[this._pos++]=t,this.coords[this._pos++]=e,r}finish(){const t=this._pos>>1;if(t!==this.numItems)throw new Error(`Added ${t} items when expected ${this.numItems}.`);return Ih(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(t,e,r,n){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:i,coords:s,nodeSize:a}=this,o=[0,i.length-1,0],l=[];for(;o.length;){const u=o.pop()||0,c=o.pop()||0,h=o.pop()||0;if(c-h<=a){for(let a=h;a<=c;a++){const o=s[2*a],u=s[2*a+1];o>=t&&o<=r&&u>=e&&u<=n&&l.push(i[a]);}continue}const p=h+c>>1,f=s[2*p],d=s[2*p+1];f>=t&&f<=r&&d>=e&&d<=n&&l.push(i[p]),(0===u?t<=f:e<=d)&&(o.push(h),o.push(p-1),o.push(1-u)),(0===u?r>=f:n>=d)&&(o.push(p+1),o.push(c),o.push(1-u));}return l}within(t,e,r){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:n,coords:i,nodeSize:s}=this,a=[0,n.length-1,0],o=[],l=r*r;for(;a.length;){const u=a.pop()||0,c=a.pop()||0,h=a.pop()||0;if(c-h<=s){for(let r=h;r<=c;r++)Bh(i[2*r],i[2*r+1],t,e)<=l&&o.push(n[r]);continue}const p=h+c>>1,f=i[2*p],d=i[2*p+1];Bh(f,d,t,e)<=l&&o.push(n[p]),(0===u?t-r<=f:e-r<=d)&&(a.push(h),a.push(p-1),a.push(1-u)),(0===u?t+r>=f:e+r>=d)&&(a.push(p+1),a.push(c),a.push(1-u));}return o}}function Ih(t,e,r,n,i,s){if(i-n<=r)return;const a=n+i>>1;zh(t,e,a,n,i,s),Ih(t,e,r,n,a-1,1-s),Ih(t,e,r,a+1,i,1-s);}function zh(t,e,r,n,i,s){for(;i>n;){if(i-n>600){const a=i-n+1,o=r-n+1,l=Math.log(a),u=.5*Math.exp(2*l/3),c=.5*Math.sqrt(l*u*(a-u)/a)*(o-a/2<0?-1:1);zh(t,e,r,Math.max(n,Math.floor(r-o*u/a+c)),Math.min(i,Math.floor(r+(a-o)*u/a+c)),s);}const a=e[2*r+s];let o=n,l=i;for(Ph(t,e,n,r),e[2*i+s]>a&&Ph(t,e,n,i);oa;)l--;}e[2*n+s]===a?Ph(t,e,n,l):(l++,Ph(t,e,l,i)),l<=r&&(n=l+1),r<=l&&(i=l-1);}}function Ph(t,e,r,n){Ch(t,r,n),Ch(e,2*r,2*n),Ch(e,2*r+1,2*n+1);}function Ch(t,e,r){const n=t[e];t[e]=t[r],t[r]=n;}function Bh(t,e,r,n){const i=t-r,s=e-n;return i*i+s*s}var Vh;t.bg=void 0,(Vh=t.bg||(t.bg={})).create="create",Vh.load="load",Vh.fullLoad="fullLoad";let Eh=null,Fh=[];const Th=1e3/60,$h="loadTime",Lh="fullLoadTime",Dh={mark(t){performance.mark(t);},frame(t){const e=t;null!=Eh&&Fh.push(e-Eh),Eh=e;},clearMetrics(){Eh=null,Fh=[],performance.clearMeasures($h),performance.clearMeasures(Lh);for(const e in t.bg)performance.clearMarks(t.bg[e]);},getPerformanceMetrics(){performance.measure($h,t.bg.create,t.bg.load),performance.measure(Lh,t.bg.create,t.bg.fullLoad);const e=performance.getEntriesByName($h)[0].duration,r=performance.getEntriesByName(Lh)[0].duration,n=Fh.length,i=1/(Fh.reduce(((t,e)=>t+e),0)/n/1e3),s=Fh.filter((t=>t>Th)).reduce(((t,e)=>t+(e-Th)/Th),0);return {loadTime:e,fullLoadTime:r,fps:i,percentDroppedFrames:s/(n+s)*100,totalFrames:n}}};t.$=class extends ps{},t.A=oo,t.B=yi,t.C=function(t){if(null==M){const e=t.navigator?t.navigator.userAgent:null;M=!!t.safari||!(!e||!(/\b(iPad|iPhone|iPod)\b/.test(e)||e.match("Safari")&&!e.match("Chrome")));}return M},t.D=Ji,t.E=Z,t.F=class{constructor(t,e){this.target=t,this.mapId=e,this.resolveRejects={},this.tasks={},this.taskQueue=[],this.abortControllers={},this.messageHandlers={},this.invoker=new Ic((()=>this.process())),this.subscription=function(t,e,r,n){return t.addEventListener(e,r,!1),{unsubscribe:()=>{t.removeEventListener(e,r,!1);}}}(this.target,"message",(t=>this.receive(t))),this.globalScope=k(self)?t:window;}registerMessageHandler(t,e){this.messageHandlers[t]=e;}sendAsync(t,e){return new Promise(((r,n)=>{const i=Math.round(1e18*Math.random()).toString(36).substring(0,10);this.resolveRejects[i]={resolve:r,reject:n},e&&e.signal.addEventListener("abort",(()=>{delete this.resolveRejects[i];const e={id:i,type:"",origin:location.origin,targetMapId:t.targetMapId,sourceMapId:this.mapId};this.target.postMessage(e);}),{once:!0});const s=[],a=Object.assign(Object.assign({},t),{id:i,sourceMapId:this.mapId,origin:location.origin,data:ki(t.data,s)});this.target.postMessage(a,{transfer:s});}))}receive(t){const e=t.data,r=e.id;if(!("file://"!==e.origin&&"file://"!==location.origin&&"resource://android"!==e.origin&&"resource://android"!==location.origin&&e.origin!==location.origin||e.targetMapId&&this.mapId!==e.targetMapId)){if(""===e.type){delete this.tasks[r];const t=this.abortControllers[r];return delete this.abortControllers[r],void(t&&t.abort())}if(k(self)||e.mustQueue)return this.tasks[r]=e,this.taskQueue.push(r),void this.invoker.trigger();this.processTask(r,e);}}process(){if(0===this.taskQueue.length)return;const t=this.taskQueue.shift(),e=this.tasks[t];delete this.tasks[t],this.taskQueue.length>0&&this.invoker.trigger(),e&&this.processTask(t,e);}processTask(t,r){return e(this,void 0,void 0,(function*(){if(""===r.type){const e=this.resolveRejects[t];if(delete this.resolveRejects[t],!e)return;return void(r.error?e.reject(Mi(r.error)):e.resolve(Mi(r.data)))}if(!this.messageHandlers[r.type])return void this.completeTask(t,new Error(`Could not find a registered handler for ${r.type}, map ID: ${this.mapId}, available handlers: ${Object.keys(this.messageHandlers).join(", ")}`));const e=Mi(r.data),n=new AbortController;this.abortControllers[t]=n;try{const i=yield this.messageHandlers[r.type](r.sourceMapId,e,n);this.completeTask(t,null,i);}catch(e){this.completeTask(t,e);}}))}completeTask(t,e,r){const n=[];delete this.abortControllers[t];const i={id:t,type:"",sourceMapId:this.mapId,origin:location.origin,error:e?ki(e):null,data:ki(r,n)};this.target.postMessage(i,{transfer:n});}remove(){this.invoker.remove(),this.subscription.unsubscribe();}},t.G=$,t.H=function(){var t=new oo(16);return oo!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=0,t[12]=0,t[13]=0,t[14]=0),t[0]=1,t[5]=1,t[10]=1,t[15]=1,t},t.I=Tu,t.J=function(t,e,r){var n,i,s,a,o,l,u,c,h,p,f,d,y=r[0],m=r[1],g=r[2];return e===t?(t[12]=e[0]*y+e[4]*m+e[8]*g+e[12],t[13]=e[1]*y+e[5]*m+e[9]*g+e[13],t[14]=e[2]*y+e[6]*m+e[10]*g+e[14],t[15]=e[3]*y+e[7]*m+e[11]*g+e[15]):(i=e[1],s=e[2],a=e[3],o=e[4],l=e[5],u=e[6],c=e[7],h=e[8],p=e[9],f=e[10],d=e[11],t[0]=n=e[0],t[1]=i,t[2]=s,t[3]=a,t[4]=o,t[5]=l,t[6]=u,t[7]=c,t[8]=h,t[9]=p,t[10]=f,t[11]=d,t[12]=n*y+o*m+h*g+e[12],t[13]=i*y+l*m+p*g+e[13],t[14]=s*y+u*m+f*g+e[14],t[15]=a*y+c*m+d*g+e[15]),t},t.K=function(t,e,r){var n=r[0],i=r[1],s=r[2];return t[0]=e[0]*n,t[1]=e[1]*n,t[2]=e[2]*n,t[3]=e[3]*n,t[4]=e[4]*i,t[5]=e[5]*i,t[6]=e[6]*i,t[7]=e[7]*i,t[8]=e[8]*s,t[9]=e[9]*s,t[10]=e[10]*s,t[11]=e[11]*s,t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15],t},t.L=uo,t.M=function(t,e){const r={};for(let n=0;n{const e=window.document.createElement("video");return e.muted=!0,new Promise((r=>{e.onloadstart=()=>{r(e);};for(const r of t){const t=window.document.createElement("source");j(r)||(e.crossOrigin="Anonymous"),t.src=r,e.appendChild(t);}}))},t.a4=function(){return x++},t.a5=Fs,t.a6=dc,t.a7=zn,t.a8=ja,t.a9=Nc,t.aA=function(t){if("custom"===t.type)return new Mc(t);switch(t.type){case"background":return new _c(t);case"circle":return new fo(t);case"fill":return new ul(t);case"fill-extrusion":return new El(t);case"heatmap":return new Mo(t);case"hillshade":return new Po(t);case"line":return new Hl(t);case"raster":return new kc(t);case"symbol":return new vc(t)}},t.aB=w,t.aC=function(t,e){if(!t)return [{command:"setStyle",args:[e]}];let r=[];try{if(!H(t.version,e.version))return [{command:"setStyle",args:[e]}];H(t.center,e.center)||r.push({command:"setCenter",args:[e.center]}),H(t.zoom,e.zoom)||r.push({command:"setZoom",args:[e.zoom]}),H(t.bearing,e.bearing)||r.push({command:"setBearing",args:[e.bearing]}),H(t.pitch,e.pitch)||r.push({command:"setPitch",args:[e.pitch]}),H(t.sprite,e.sprite)||r.push({command:"setSprite",args:[e.sprite]}),H(t.glyphs,e.glyphs)||r.push({command:"setGlyphs",args:[e.glyphs]}),H(t.transition,e.transition)||r.push({command:"setTransition",args:[e.transition]}),H(t.light,e.light)||r.push({command:"setLight",args:[e.light]}),H(t.terrain,e.terrain)||r.push({command:"setTerrain",args:[e.terrain]}),H(t.sky,e.sky)||r.push({command:"setSky",args:[e.sky]}),H(t.projection,e.projection)||r.push({command:"setProjection",args:[e.projection]});const n={},i=[];!function(t,e,r,n){let i;for(i in e=e||{},t=t||{})Object.prototype.hasOwnProperty.call(t,i)&&(Object.prototype.hasOwnProperty.call(e,i)||W(i,r,n));for(i in e)Object.prototype.hasOwnProperty.call(e,i)&&(Object.prototype.hasOwnProperty.call(t,i)?H(t[i],e[i])||("geojson"===t[i].type&&"geojson"===e[i].type&&tt(t,e,i)?Y(r,{command:"setGeoJSONSourceData",args:[i,e[i].data]}):Q(i,e,r,n)):J(i,e,r));}(t.sources,e.sources,i,n);const s=[];t.layers&&t.layers.forEach((t=>{"source"in t&&n[t.source]?r.push({command:"removeLayer",args:[t.id]}):s.push(t);})),r=r.concat(i),function(t,e,r){e=e||[];const n=(t=t||[]).map(rt),i=e.map(rt),s=t.reduce(nt,{}),a=e.reduce(nt,{}),o=n.slice(),l=Object.create(null);let u,c,h,p,f;for(let t=0,e=0;t@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/g,((t,r,n,i)=>{const s=n||i;return e[r]=!s||s.toLowerCase(),""})),e["max-age"]){const t=parseInt(e["max-age"],10);isNaN(t)?delete e["max-age"]:e["max-age"]=t;}return e},t.ab=function(t,e){const r=[];for(const n in t)n in e||r.push(n);return r},t.ac=y,t.ad=function(t,e,r){var n=Math.sin(r),i=Math.cos(r),s=e[0],a=e[1],o=e[2],l=e[3],u=e[4],c=e[5],h=e[6],p=e[7];return e!==t&&(t[8]=e[8],t[9]=e[9],t[10]=e[10],t[11]=e[11],t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15]),t[0]=s*i+u*n,t[1]=a*i+c*n,t[2]=o*i+h*n,t[3]=l*i+p*n,t[4]=u*i-s*n,t[5]=c*i-a*n,t[6]=h*i-o*n,t[7]=p*i-l*n,t},t.ae=function(t){var e=new oo(16);return e[0]=t[0],e[1]=t[1],e[2]=t[2],e[3]=t[3],e[4]=t[4],e[5]=t[5],e[6]=t[6],e[7]=t[7],e[8]=t[8],e[9]=t[9],e[10]=t[10],e[11]=t[11],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15],e},t.af=po,t.ag=function(t,e){let r=0,n=0;if("constant"===t.kind)n=t.layoutSize;else if("source"!==t.kind){const{interpolationType:i,minZoom:s,maxZoom:a}=t,o=i?y(Be.interpolationFactor(i,e,s,a),0,1):0;"camera"===t.kind?n=Ce.number(t.minSize,t.maxSize,o):r=o;}return {uSizeT:r,uSize:n}},t.ai=function(t,{uSize:e,uSizeT:r},{lowerSize:n,upperSize:i}){return "source"===t.kind?n/nc:"composite"===t.kind?Ce.number(n/nc,i/nc,r):e},t.aj=cc,t.ak=function(t,e,r,n){const i=e.y-t.y,a=e.x-t.x,o=n.y-r.y,l=n.x-r.x,u=o*a-l*i;if(0===u)return null;const c=(l*(t.y-r.y)-o*(t.x-r.x))/u;return new s(t.x+c*a,t.y+c*i)},t.al=Hc,t.am=qa,t.an=lo,t.ao=function(t){let e=1/0,r=1/0,n=-1/0,i=-1/0;for(const s of t)e=Math.min(e,s.x),r=Math.min(r,s.y),n=Math.max(n,s.x),i=Math.max(i,s.y);return [e,r,n,i]},t.ap=iu,t.ar=ac,t.as=function(t,e){var r=e[0],n=e[1],i=e[2],s=e[3],a=e[4],o=e[5],l=e[6],u=e[7],c=e[8],h=e[9],p=e[10],f=e[11],d=e[12],y=e[13],m=e[14],g=e[15],x=r*o-n*a,v=r*l-i*a,b=r*u-s*a,w=n*l-i*o,_=n*u-s*o,A=i*u-s*l,S=c*y-h*d,k=c*m-p*d,M=c*g-f*d,I=h*m-p*y,z=h*g-f*y,P=p*g-f*m,C=x*P-v*z+b*I+w*M-_*k+A*S;return C?(t[0]=(o*P-l*z+u*I)*(C=1/C),t[1]=(i*z-n*P-s*I)*C,t[2]=(y*A-m*_+g*w)*C,t[3]=(p*_-h*A-f*w)*C,t[4]=(l*M-a*P-u*k)*C,t[5]=(r*P-i*M+s*k)*C,t[6]=(m*b-d*A-g*v)*C,t[7]=(c*A-p*b+f*v)*C,t[8]=(a*z-o*M+u*S)*C,t[9]=(n*M-r*z-s*S)*C,t[10]=(d*_-y*b+g*x)*C,t[11]=(h*b-c*_-f*x)*C,t[12]=(o*k-a*I-l*S)*C,t[13]=(r*I-n*k+i*S)*C,t[14]=(y*v-d*w-m*x)*C,t[15]=(c*w-h*v+p*x)*C,t):null},t.at=bh,t.au=Ju,t.av=Mh,t.aw=function(){const t={},e=G.$version;for(const r in G.$root){const n=G.$root[r];if(n.required){let i=null;i="version"===r?e:"array"===n.type?[]:{},null!=i&&(t[r]=i);}}return t},t.ax=Ii,t.ay=D,t.az=function(t){t=t.slice();const e=Object.create(null);for(let r=0;r25||n<0||n>=1||r<0||r>=1)},t.bc=function(t,e){return t[0]=e[0],t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e[1],t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=e[2],t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},t.bd=class extends hs{},t.be=zc,t.bf=Dh,t.bh=L,t.bi=function(t,e){F.REGISTERED_PROTOCOLS[t]=e;},t.bj=function(t){delete F.REGISTERED_PROTOCOLS[t];},t.bk=function(t,e){const r={};for(let n=0;nt*iu));}let v=o?"center":n.get("text-justify").evaluate(i,{},e.canonical);const b="point"===n.get("symbol-placement")?n.get("text-max-width").evaluate(i,{},e.canonical)*iu:1/0,w=()=>{e.bucket.allowVerticalPlacement&&Pi(s)&&(d.vertical=Ru(y,e.glyphMap,e.glyphPositions,e.imagePositions,c,b,a,m,"left",f,g,t.ah.vertical,!0,p,h));};if(!o&&x){const r=new Set;if("auto"===v)for(let t=0;te(void 0,void 0,void 0,(function*(){if(0===t.byteLength)return createImageBitmap(new ImageData(1,1));const e=new Blob([new Uint8Array(t)],{type:"image/png"});try{return createImageBitmap(e)}catch(t){throw new Error(`Could not load image because of ${t.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`)}})),t.e=g,t.f=t=>new Promise(((e,r)=>{const n=new Image;n.onload=()=>{e(n),URL.revokeObjectURL(n.src),n.onload=null,window.requestAnimationFrame((()=>{n.src=z;}));},n.onerror=()=>r(new Error("Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported."));const i=new Blob([new Uint8Array(t)],{type:"image/png"});n.src=t.byteLength?URL.createObjectURL(i):z;})),t.g=T,t.h=(t,e)=>O(g(t,{type:"json"}),e),t.i=k,t.j=N,t.k=q,t.l=(t,e)=>O(g(t,{type:"arrayBuffer"}),e),t.m=O,t.n=function(t){return new Iu(t).readFields(Pu,[])},t.o=_o,t.p=Eu,t.q=rs,t.r=di,t.s=j,t.t=xi,t.u=fi,t.v=G,t.w=A,t.x=function([t,e,r]){return e+=90,e*=Math.PI/180,r*=Math.PI/180,{x:t*Math.cos(e)*Math.sin(r),y:t*Math.sin(e)*Math.sin(r),z:t*Math.cos(r)}},t.y=Ce,t.z=Ui;})); + +define("worker",["./shared"],(function(e){"use strict";class t{constructor(e){this.keyCache={},e&&this.replace(e);}replace(e){this._layerConfigs={},this._layers={},this.update(e,[]);}update(t,o){for(const o of t){this._layerConfigs[o.id]=o;const t=this._layers[o.id]=e.aA(o);t._featureFilter=e.a7(t.filter),this.keyCache[o.id]&&delete this.keyCache[o.id];}for(const e of o)delete this.keyCache[e],delete this._layerConfigs[e],delete this._layers[e];this.familiesBySource={};const i=e.bk(Object.values(this._layerConfigs),this.keyCache);for(const e of i){const t=e.map((e=>this._layers[e.id])),o=t[0];if("none"===o.visibility)continue;const i=o.source||"";let s=this.familiesBySource[i];s||(s=this.familiesBySource[i]={});const r=o.sourceLayer||"_geojsonTileLayer";let n=s[r];n||(n=s[r]=[]),n.push(t);}}}class o{constructor(t){const o={},i=[];for(const e in t){const s=t[e],r=o[e]={};for(const e in s){const t=s[+e];if(!t||0===t.bitmap.width||0===t.bitmap.height)continue;const o={x:0,y:0,w:t.bitmap.width+2,h:t.bitmap.height+2};i.push(o),r[e]={rect:o,metrics:t.metrics};}}const{w:s,h:r}=e.p(i),n=new e.o({width:s||1,height:r||1});for(const i in t){const s=t[i];for(const t in s){const r=s[+t];if(!r||0===r.bitmap.width||0===r.bitmap.height)continue;const a=o[i][t].rect;e.o.copy(r.bitmap,n,{x:0,y:0},{x:a.x+1,y:a.y+1},r.bitmap);}}this.image=n,this.positions=o;}}e.bl("GlyphAtlas",o);class i{constructor(t){this.tileID=new e.S(t.tileID.overscaledZ,t.tileID.wrap,t.tileID.canonical.z,t.tileID.canonical.x,t.tileID.canonical.y),this.uid=t.uid,this.zoom=t.zoom,this.pixelRatio=t.pixelRatio,this.tileSize=t.tileSize,this.source=t.source,this.overscaling=this.tileID.overscaleFactor(),this.showCollisionBoxes=t.showCollisionBoxes,this.collectResourceTiming=!!t.collectResourceTiming,this.returnDependencies=!!t.returnDependencies,this.promoteId=t.promoteId,this.inFlightDependencies=[];}parse(t,i,r,n){return e._(this,void 0,void 0,(function*(){this.status="parsing",this.data=t,this.collisionBoxArray=new e.a5;const a=new e.bm(Object.keys(t.layers).sort()),l=new e.bn(this.tileID,this.promoteId);l.bucketLayerIDs=[];const c={},u={featureIndex:l,iconDependencies:{},patternDependencies:{},glyphDependencies:{},availableImages:r},h=i.familiesBySource[this.source];for(const o in h){const i=t.layers[o];if(!i)continue;1===i.version&&e.w(`Vector tile source "${this.source}" layer "${o}" does not use vector tile spec v2 and therefore may have some rendering errors.`);const n=a.encode(o),d=[];for(let e=0;e=o.maxzoom||"none"!==o.visibility&&(s(t,this.zoom,r),(c[o.id]=o.createBucket({index:l.bucketLayerIDs.length,layers:t,zoom:this.zoom,pixelRatio:this.pixelRatio,overscaling:this.overscaling,collisionBoxArray:this.collisionBoxArray,sourceLayerIndex:n,sourceID:this.source})).populate(d,u,this.tileID.canonical),l.bucketLayerIDs.push(t.map((e=>e.id))));}}const d=e.aF(u.glyphDependencies,(e=>Object.keys(e).map(Number)));this.inFlightDependencies.forEach((e=>null==e?void 0:e.abort())),this.inFlightDependencies=[];let f=Promise.resolve({});if(Object.keys(d).length){const e=new AbortController;this.inFlightDependencies.push(e),f=n.sendAsync({type:"GG",data:{stacks:d,source:this.source,tileID:this.tileID,type:"glyphs"}},e);}const g=Object.keys(u.iconDependencies);let p=Promise.resolve({});if(g.length){const e=new AbortController;this.inFlightDependencies.push(e),p=n.sendAsync({type:"GI",data:{icons:g,source:this.source,tileID:this.tileID,type:"icons"}},e);}const m=Object.keys(u.patternDependencies);let y=Promise.resolve({});if(m.length){const e=new AbortController;this.inFlightDependencies.push(e),y=n.sendAsync({type:"GI",data:{icons:m,source:this.source,tileID:this.tileID,type:"patterns"}},e);}const[v,w,x]=yield Promise.all([f,p,y]),b=new o(v),S=new e.bo(w,x);for(const t in c){const o=c[t];o instanceof e.a6?(s(o.layers,this.zoom,r),e.bp({bucket:o,glyphMap:v,glyphPositions:b.positions,imageMap:w,imagePositions:S.iconPositions,showCollisionBoxes:this.showCollisionBoxes,canonical:this.tileID.canonical})):o.hasPattern&&(o instanceof e.bq||o instanceof e.br||o instanceof e.bs)&&(s(o.layers,this.zoom,r),o.addFeatures(u,this.tileID.canonical,S.patternPositions));}return this.status="done",{buckets:Object.values(c).filter((e=>!e.isEmpty())),featureIndex:l,collisionBoxArray:this.collisionBoxArray,glyphAtlasImage:b.image,imageAtlas:S,glyphMap:this.returnDependencies?v:null,iconMap:this.returnDependencies?w:null,glyphPositions:this.returnDependencies?b.positions:null}}))}}function s(t,o,i){const s=new e.z(o);for(const e of t)e.recalculate(s,i);}class r{constructor(e,t,o){this.actor=e,this.layerIndex=t,this.availableImages=o,this.fetching={},this.loading={},this.loaded={};}loadVectorTile(t,o){return e._(this,void 0,void 0,(function*(){const i=yield e.l(t.request,o);try{return {vectorTile:new e.bt.VectorTile(new e.bu(i.data)),rawData:i.data,cacheControl:i.cacheControl,expires:i.expires}}catch(e){const o=new Uint8Array(i.data);let s=`Unable to parse the tile at ${t.request.url}, `;throw s+=31===o[0]&&139===o[1]?"please make sure the data is not gzipped and that you have configured the relevant header in the server":`got error: ${e.message}`,new Error(s)}}))}loadTile(t){return e._(this,void 0,void 0,(function*(){const o=t.uid,s=!!(t&&t.request&&t.request.collectResourceTiming)&&new e.bv(t.request),r=new i(t);this.loading[o]=r;const n=new AbortController;r.abort=n;try{const i=yield this.loadVectorTile(t,n);if(delete this.loading[o],!i)return null;const a=i.rawData,l={};i.expires&&(l.expires=i.expires),i.cacheControl&&(l.cacheControl=i.cacheControl);const c={};if(s){const e=s.finish();e&&(c.resourceTiming=JSON.parse(JSON.stringify(e)));}r.vectorTile=i.vectorTile;const u=r.parse(i.vectorTile,this.layerIndex,this.availableImages,this.actor);this.loaded[o]=r,this.fetching[o]={rawTileData:a,cacheControl:l,resourceTiming:c};try{const t=yield u;return e.e({rawTileData:a.slice(0)},t,l,c)}finally{delete this.fetching[o];}}catch(e){throw delete this.loading[o],r.status="done",this.loaded[o]=r,e}}))}reloadTile(t){return e._(this,void 0,void 0,(function*(){const o=t.uid;if(!this.loaded||!this.loaded[o])throw new Error("Should not be trying to reload a tile that was never loaded or has been removed");const i=this.loaded[o];if(i.showCollisionBoxes=t.showCollisionBoxes,"parsing"===i.status){const t=yield i.parse(i.vectorTile,this.layerIndex,this.availableImages,this.actor);let s;if(this.fetching[o]){const{rawTileData:i,cacheControl:r,resourceTiming:n}=this.fetching[o];delete this.fetching[o],s=e.e({rawTileData:i.slice(0)},t,r,n);}else s=t;return s}if("done"===i.status&&i.vectorTile)return i.parse(i.vectorTile,this.layerIndex,this.availableImages,this.actor)}))}abortTile(t){return e._(this,void 0,void 0,(function*(){const e=this.loading,o=t.uid;e&&e[o]&&e[o].abort&&(e[o].abort.abort(),delete e[o]);}))}removeTile(t){return e._(this,void 0,void 0,(function*(){this.loaded&&this.loaded[t.uid]&&delete this.loaded[t.uid];}))}}class n{constructor(){this.loaded={};}loadTile(t){return e._(this,void 0,void 0,(function*(){const{uid:o,encoding:i,rawImageData:s,redFactor:r,greenFactor:n,blueFactor:a,baseShift:l}=t,c=s.width+2,u=s.height+2,h=e.b(s)?new e.R({width:c,height:u},yield e.bw(s,-1,-1,c,u)):s,d=new e.bx(o,h,i,r,n,a,l);return this.loaded=this.loaded||{},this.loaded[o]=d,d}))}removeTile(e){const t=this.loaded,o=e.uid;t&&t[o]&&delete t[o];}}function a(e,t){if(0!==e.length){l(e[0],t);for(var o=1;o=Math.abs(a)?o-l+a:a-l+o,o=l;}o+i>=0!=!!t&&e.reverse();}var c=e.by((function e(t,o){var i,s=t&&t.type;if("FeatureCollection"===s)for(i=0;i>31}function I(e,t){for(var o=e.loadGeometry(),i=e.type,s=0,r=0,n=o.length,a=0;ae},D=Math.fround||(C=new Float32Array(1),e=>(C[0]=+e,C[0]));var C;const L=3,O=5,F=6;class z{constructor(e){this.options=Object.assign(Object.create(T),e),this.trees=new Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[];}load(e){const{log:t,minZoom:o,maxZoom:i}=this.options;t&&console.time("total time");const s=`prepare ${e.length} points`;t&&console.time(s),this.points=e;const r=[];for(let t=0;t=o;e--){const o=+Date.now();n=this.trees[e]=this._createTree(this._cluster(n,e)),t&&console.log("z%d: %d clusters in %dms",e,n.numItems,+Date.now()-o);}return t&&console.timeEnd("total time"),this}getClusters(e,t){let o=((e[0]+180)%360+360)%360-180;const i=Math.max(-90,Math.min(90,e[1]));let s=180===e[2]?180:((e[2]+180)%360+360)%360-180;const r=Math.max(-90,Math.min(90,e[3]));if(e[2]-e[0]>=360)o=-180,s=180;else if(o>s){const e=this.getClusters([o,i,180,r],t),n=this.getClusters([-180,i,s,r],t);return e.concat(n)}const n=this.trees[this._limitZoom(t)],a=n.range(G(o),j(r),G(s),j(i)),l=n.data,c=[];for(const e of a){const t=this.stride*e;c.push(l[t+O]>1?A(l,t,this.clusterProps):this.points[l[t+L]]);}return c}getChildren(e){const t=this._getOriginId(e),o=this._getOriginZoom(e),i="No cluster with the specified id.",s=this.trees[o];if(!s)throw new Error(i);const r=s.data;if(t*this.stride>=r.length)throw new Error(i);const n=this.options.radius/(this.options.extent*Math.pow(2,o-1)),a=s.within(r[t*this.stride],r[t*this.stride+1],n),l=[];for(const t of a){const o=t*this.stride;r[o+4]===e&&l.push(r[o+O]>1?A(r,o,this.clusterProps):this.points[r[o+L]]);}if(0===l.length)throw new Error(i);return l}getLeaves(e,t,o){const i=[];return this._appendLeaves(i,e,t=t||10,o=o||0,0),i}getTile(e,t,o){const i=this.trees[this._limitZoom(e)],s=Math.pow(2,e),{extent:r,radius:n}=this.options,a=n/r,l=(o-a)/s,c=(o+1+a)/s,u={features:[]};return this._addTileFeatures(i.range((t-a)/s,l,(t+1+a)/s,c),i.data,t,o,s,u),0===t&&this._addTileFeatures(i.range(1-a/s,l,1,c),i.data,s,o,s,u),t===s-1&&this._addTileFeatures(i.range(0,l,a/s,c),i.data,-1,o,s,u),u.features.length?u:null}getClusterExpansionZoom(e){let t=this._getOriginZoom(e)-1;for(;t<=this.options.maxZoom;){const o=this.getChildren(e);if(t++,1!==o.length)break;e=o[0].properties.cluster_id;}return t}_appendLeaves(e,t,o,i,s){const r=this.getChildren(t);for(const t of r){const r=t.properties;if(r&&r.cluster?s+r.point_count<=i?s+=r.point_count:s=this._appendLeaves(e,r.cluster_id,o,i,s):s1;let l,c,u;if(a)l=E(t,e,this.clusterProps),c=t[e],u=t[e+1];else {const o=this.points[t[e+L]];l=o.properties;const[i,s]=o.geometry.coordinates;c=G(i),u=j(s);}const h={type:1,geometry:[[Math.round(this.options.extent*(c*s-o)),Math.round(this.options.extent*(u*s-i))]],tags:l};let d;d=a||this.options.generateId?t[e+L]:this.points[t[e+L]].id,void 0!==d&&(h.id=d),r.features.push(h);}}_limitZoom(e){return Math.max(this.options.minZoom,Math.min(Math.floor(+e),this.options.maxZoom+1))}_cluster(e,t){const{radius:o,extent:i,reduce:s,minPoints:r}=this.options,n=o/(i*Math.pow(2,t)),a=e.data,l=[],c=this.stride;for(let o=0;ot&&(f+=a[o+O]);}if(f>d&&f>=r){let e,r=i*d,n=u*d,g=-1;const p=((o/c|0)<<5)+(t+1)+this.points.length;for(const i of h){const l=i*c;if(a[l+2]<=t)continue;a[l+2]=t;const u=a[l+O];r+=a[l]*u,n+=a[l+1]*u,a[l+4]=p,s&&(e||(e=this._map(a,o,!0),g=this.clusterProps.length,this.clusterProps.push(e)),s(e,this._map(a,l)));}a[o+4]=p,l.push(r/f,n/f,1/0,p,-1,f),s&&l.push(g);}else {for(let e=0;e1)for(const e of h){const o=e*c;if(!(a[o+2]<=t)){a[o+2]=t;for(let e=0;e>5}_getOriginZoom(e){return (e-this.points.length)%32}_map(e,t,o){if(e[t+O]>1){const i=this.clusterProps[e[t+F]];return o?Object.assign({},i):i}const i=this.points[e[t+L]].properties,s=this.options.map(i);return o&&s===i?Object.assign({},s):s}}function A(e,t,o){return {type:"Feature",id:e[t+L],properties:E(e,t,o),geometry:{type:"Point",coordinates:[(i=e[t],360*(i-.5)),R(e[t+1])]}};var i;}function E(e,t,o){const i=e[t+O],s=i>=1e4?`${Math.round(i/1e3)}k`:i>=1e3?Math.round(i/100)/10+"k":i,r=e[t+F],n=-1===r?{}:Object.assign({},o[r]);return Object.assign(n,{cluster:!0,cluster_id:e[t+L],point_count:i,point_count_abbreviated:s})}function G(e){return e/360+.5}function j(e){const t=Math.sin(e*Math.PI/180),o=.5-.25*Math.log((1+t)/(1-t))/Math.PI;return o<0?0:o>1?1:o}function R(e){const t=(180-360*e)*Math.PI/180;return 360*Math.atan(Math.exp(t))/Math.PI-90}function Z(e,t,o,i){let s=i;const r=t+(o-t>>1);let n,a=o-t;const l=e[t],c=e[t+1],u=e[o],h=e[o+1];for(let i=t+3;is)n=i,s=t;else if(t===s){const e=Math.abs(i-r);ei&&(n-t>3&&Z(e,t,n,i),e[n+2]=s,o-n>3&&Z(e,n,o,i));}function N(e,t,o,i,s,r){let n=s-o,a=r-i;if(0!==n||0!==a){const l=((e-o)*n+(t-i)*a)/(n*n+a*a);l>1?(o=s,i=r):l>0&&(o+=n*l,i+=a*l);}return n=e-o,a=t-i,n*n+a*a}function J(e,t,o,i){const s={id:null==e?null:e,type:t,geometry:o,tags:i,minX:1/0,minY:1/0,maxX:-1/0,maxY:-1/0};if("Point"===t||"MultiPoint"===t||"LineString"===t)W(s,o);else if("Polygon"===t)W(s,o[0]);else if("MultiLineString"===t)for(const e of o)W(s,e);else if("MultiPolygon"===t)for(const e of o)W(s,e[0]);return s}function W(e,t){for(let o=0;o0&&(n+=i?(s*l-a*r)/2:Math.sqrt(Math.pow(a-s,2)+Math.pow(l-r,2))),s=a,r=l;}const a=t.length-3;t[2]=1,Z(t,0,a,o),t[a+2]=1,t.size=Math.abs(n),t.start=0,t.end=t.size;}function q(e,t,o,i){for(let s=0;s1?1:o}function U(e,t,o,i,s,r,n,a){if(i/=t,r>=(o/=t)&&n=i)return null;const l=[];for(const t of e){const e=t.geometry;let r=t.type;const n=0===s?t.minX:t.minY,c=0===s?t.maxX:t.maxY;if(n>=o&&c=i)continue;let u=[];if("Point"===r||"MultiPoint"===r)$(e,u,o,i,s);else if("LineString"===r)K(e,u,o,i,s,!1,a.lineMetrics);else if("MultiLineString"===r)ee(e,u,o,i,s,!1);else if("Polygon"===r)ee(e,u,o,i,s,!0);else if("MultiPolygon"===r)for(const t of e){const e=[];ee(t,e,o,i,s,!0),e.length&&u.push(e);}if(u.length){if(a.lineMetrics&&"LineString"===r){for(const e of u)l.push(J(t.id,r,e,t.tags));continue}"LineString"!==r&&"MultiLineString"!==r||(1===u.length?(r="LineString",u=u[0]):r="MultiLineString"),"Point"!==r&&"MultiPoint"!==r||(r=3===u.length?"Point":"MultiPoint"),l.push(J(t.id,r,u,t.tags));}}return l.length?l:null}function $(e,t,o,i,s){for(let r=0;r=o&&n<=i&&te(t,e[r],e[r+1],e[r+2]);}}function K(e,t,o,i,s,r,n){let a=Q(e);const l=0===s?oe:ie;let c,u,h=e.start;for(let d=0;do&&(u=l(a,f,g,m,y,o),n&&(a.start=h+c*u)):v>i?w=o&&(u=l(a,f,g,m,y,o),x=!0),w>i&&v<=i&&(u=l(a,f,g,m,y,i),x=!0),!r&&x&&(n&&(a.end=h+c*u),t.push(a),a=Q(e)),n&&(h+=c);}let d=e.length-3;const f=e[d],g=e[d+1],p=0===s?f:g;p>=o&&p<=i&&te(a,f,g,e[d+2]),d=a.length-3,r&&d>=3&&(a[d]!==a[0]||a[d+1]!==a[1])&&te(a,a[0],a[1],a[2]),a.length&&t.push(a);}function Q(e){const t=[];return t.size=e.size,t.start=e.start,t.end=e.end,t}function ee(e,t,o,i,s,r){for(const n of e)K(n,t,o,i,s,r,!1);}function te(e,t,o,i){e.push(t,o,i);}function oe(e,t,o,i,s,r){const n=(r-t)/(i-t);return te(e,r,o+(s-o)*n,1),n}function ie(e,t,o,i,s,r){const n=(r-o)/(s-o);return te(e,t+(i-t)*n,r,1),n}function se(e,t){const o=[];for(let i=0;i0&&t.size<(s?n:i))return void(o.numPoints+=t.length/3);const a=[];for(let e=0;en)&&(o.numSimplified++,a.push(t[e],t[e+1])),o.numPoints++;s&&function(e,t){let o=0;for(let t=0,i=e.length,s=i-2;t0===t)for(let t=0,o=e.length;t24)throw new Error("maxZoom should be in the 0-24 range");if(t.promoteId&&t.generateId)throw new Error("promoteId and generateId cannot be used together.");let i=function(e,t){const o=[];if("FeatureCollection"===e.type)for(let i=0;i1&&console.time("creation"),d=this.tiles[h]=le(e,t,o,i,l),this.tileCoords.push({z:t,x:o,y:i}),c)){c>1&&(console.log("tile z%d-%d-%d (features: %d, points: %d, simplified: %d)",t,o,i,d.numFeatures,d.numPoints,d.numSimplified),console.timeEnd("creation"));const e=`z${t}`;this.stats[e]=(this.stats[e]||0)+1,this.total++;}if(d.source=e,null==s){if(t===l.indexMaxZoom||d.numPoints<=l.indexMaxPoints)continue}else {if(t===l.maxZoom||t===s)continue;if(null!=s){const e=s-t;if(o!==r>>e||i!==n>>e)continue}}if(d.source=null,0===e.length)continue;c>1&&console.time("clipping");const f=.5*l.buffer/l.extent,g=.5-f,p=.5+f,m=1+f;let y=null,v=null,w=null,x=null,b=U(e,u,o-f,o+p,0,d.minX,d.maxX,l),S=U(e,u,o+g,o+m,0,d.minX,d.maxX,l);e=null,b&&(y=U(b,u,i-f,i+p,1,d.minY,d.maxY,l),v=U(b,u,i+g,i+m,1,d.minY,d.maxY,l),b=null),S&&(w=U(S,u,i-f,i+p,1,d.minY,d.maxY,l),x=U(S,u,i+g,i+m,1,d.minY,d.maxY,l),S=null),c>1&&console.timeEnd("clipping"),a.push(y||[],t+1,2*o,2*i),a.push(v||[],t+1,2*o,2*i+1),a.push(w||[],t+1,2*o+1,2*i),a.push(x||[],t+1,2*o+1,2*i+1);}}getTile(e,t,o){e=+e,t=+t,o=+o;const i=this.options,{extent:s,debug:r}=i;if(e<0||e>24)return null;const n=1<1&&console.log("drilling down to z%d-%d-%d",e,t,o);let l,c=e,u=t,h=o;for(;!l&&c>0;)c--,u>>=1,h>>=1,l=this.tiles[fe(c,u,h)];return l&&l.source?(r>1&&(console.log("found parent tile z%d-%d-%d",c,u,h),console.time("drilling down")),this.splitTile(l.source,c,u,h,e,t,o),r>1&&console.timeEnd("drilling down"),this.tiles[a]?ne(this.tiles[a],s):null):null}}function fe(e,t,o){return 32*((1<{n.properties=e;const t={};for(const e of a)t[e]=i[e].evaluate(r,n);return t},t.reduce=(e,t)=>{n.properties=t;for(const t of a)r.accumulated=e[t],e[t]=s[t].evaluate(r,n);},t}(t)).load((yield this._pendingData).features):(s=yield this._pendingData,new de(s,t.geojsonVtOptions)),this.loaded={};const o={};if(i){const e=i.finish();e&&(o.resourceTiming={},o.resourceTiming[t.source]=JSON.parse(JSON.stringify(e)));}return o}catch(t){if(delete this._pendingRequest,e.bB(t))return {abandoned:!0};throw t}var s;}))}getData(){return e._(this,void 0,void 0,(function*(){return this._pendingData}))}reloadTile(e){const t=this.loaded;return t&&t[e.uid]?super.reloadTile(e):this.loadTile(e)}loadAndProcessGeoJSON(t,o){return e._(this,void 0,void 0,(function*(){let i=yield this.loadGeoJSON(t,o);if(delete this._pendingRequest,"object"!=typeof i)throw new Error(`Input data given to '${t.source}' is not a valid GeoJSON object.`);if(c(i,!0),t.filter){const o=e.bC(t.filter,{type:"boolean","property-type":"data-driven",overridable:!1,transition:!1});if("error"===o.result)throw new Error(o.value.map((e=>`${e.key}: ${e.message}`)).join(", "));const s=i.features.filter((e=>o.value.evaluate({zoom:0},e)));i={type:"FeatureCollection",features:s};}return i}))}loadGeoJSON(t,o){return e._(this,void 0,void 0,(function*(){const{promoteId:i}=t;if(t.request){const s=yield e.h(t.request,o);return this._dataUpdateable=pe(s.data,i)?me(s.data,i):void 0,s.data}if("string"==typeof t.data)try{const e=JSON.parse(t.data);return this._dataUpdateable=pe(e,i)?me(e,i):void 0,e}catch(e){throw new Error(`Input data given to '${t.source}' is not a valid GeoJSON object.`)}if(!t.dataDiff)throw new Error(`Input data given to '${t.source}' is not a valid GeoJSON object.`);if(!this._dataUpdateable)throw new Error(`Cannot update existing geojson data in ${t.source}`);return function(e,t,o){var i,s,r,n;if(t.removeAll&&e.clear(),t.remove)for(const o of t.remove)e.delete(o);if(t.add)for(const i of t.add){const t=ge(i,o);null!=t&&e.set(t,i);}if(t.update)for(const o of t.update){let t=e.get(o.id);if(null==t)continue;const a=!o.removeAllProperties&&((null===(i=o.removeProperties)||void 0===i?void 0:i.length)>0||(null===(s=o.addOrUpdateProperties)||void 0===s?void 0:s.length)>0);if((o.newGeometry||o.removeAllProperties||a)&&(t=Object.assign({},t),e.set(o.id,t),a&&(t.properties=Object.assign({},t.properties))),o.newGeometry&&(t.geometry=o.newGeometry),o.removeAllProperties)t.properties={};else if((null===(r=o.removeProperties)||void 0===r?void 0:r.length)>0)for(const e of o.removeProperties)Object.prototype.hasOwnProperty.call(t.properties,e)&&delete t.properties[e];if((null===(n=o.addOrUpdateProperties)||void 0===n?void 0:n.length)>0)for(const{key:e,value:i}of o.addOrUpdateProperties)t.properties[e]=i;}}(this._dataUpdateable,t.dataDiff,i),{type:"FeatureCollection",features:Array.from(this._dataUpdateable.values())}}))}removeSource(t){return e._(this,void 0,void 0,(function*(){this._pendingRequest&&this._pendingRequest.abort();}))}getClusterExpansionZoom(e){return this._geoJSONIndex.getClusterExpansionZoom(e.clusterId)}getClusterChildren(e){return this._geoJSONIndex.getChildren(e.clusterId)}getClusterLeaves(e){return this._geoJSONIndex.getLeaves(e.clusterId,e.limit,e.offset)}}class ve{constructor(t){this.self=t,this.actor=new e.F(t),this.layerIndexes={},this.availableImages={},this.workerSources={},this.demWorkerSources={},this.externalWorkerSourceTypes={},this.self.registerWorkerSource=(e,t)=>{if(this.externalWorkerSourceTypes[e])throw new Error(`Worker source with name "${e}" already registered.`);this.externalWorkerSourceTypes[e]=t;},this.self.addProtocol=e.bi,this.self.removeProtocol=e.bj,this.self.registerRTLTextPlugin=t=>{if(e.bD.isParsed())throw new Error("RTL text plugin already registered.");e.bD.setMethods(t);},this.actor.registerMessageHandler("LDT",((e,t)=>this._getDEMWorkerSource(e,t.source).loadTile(t))),this.actor.registerMessageHandler("RDT",((t,o)=>e._(this,void 0,void 0,(function*(){this._getDEMWorkerSource(t,o.source).removeTile(o);})))),this.actor.registerMessageHandler("GCEZ",((t,o)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,o.type,o.source).getClusterExpansionZoom(o)})))),this.actor.registerMessageHandler("GCC",((t,o)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,o.type,o.source).getClusterChildren(o)})))),this.actor.registerMessageHandler("GCL",((t,o)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,o.type,o.source).getClusterLeaves(o)})))),this.actor.registerMessageHandler("LD",((e,t)=>this._getWorkerSource(e,t.type,t.source).loadData(t))),this.actor.registerMessageHandler("GD",((e,t)=>this._getWorkerSource(e,t.type,t.source).getData())),this.actor.registerMessageHandler("LT",((e,t)=>this._getWorkerSource(e,t.type,t.source).loadTile(t))),this.actor.registerMessageHandler("RT",((e,t)=>this._getWorkerSource(e,t.type,t.source).reloadTile(t))),this.actor.registerMessageHandler("AT",((e,t)=>this._getWorkerSource(e,t.type,t.source).abortTile(t))),this.actor.registerMessageHandler("RMT",((e,t)=>this._getWorkerSource(e,t.type,t.source).removeTile(t))),this.actor.registerMessageHandler("RS",((t,o)=>e._(this,void 0,void 0,(function*(){if(!this.workerSources[t]||!this.workerSources[t][o.type]||!this.workerSources[t][o.type][o.source])return;const e=this.workerSources[t][o.type][o.source];delete this.workerSources[t][o.type][o.source],void 0!==e.removeSource&&e.removeSource(o);})))),this.actor.registerMessageHandler("RM",(t=>e._(this,void 0,void 0,(function*(){delete this.layerIndexes[t],delete this.availableImages[t],delete this.workerSources[t],delete this.demWorkerSources[t];})))),this.actor.registerMessageHandler("SR",((t,o)=>e._(this,void 0,void 0,(function*(){this.referrer=o;})))),this.actor.registerMessageHandler("SRPS",((e,t)=>this._syncRTLPluginState(e,t))),this.actor.registerMessageHandler("IS",((t,o)=>e._(this,void 0,void 0,(function*(){this.self.importScripts(o);})))),this.actor.registerMessageHandler("SI",((e,t)=>this._setImages(e,t))),this.actor.registerMessageHandler("UL",((t,o)=>e._(this,void 0,void 0,(function*(){this._getLayerIndex(t).update(o.layers,o.removedIds);})))),this.actor.registerMessageHandler("SL",((t,o)=>e._(this,void 0,void 0,(function*(){this._getLayerIndex(t).replace(o);}))));}_setImages(t,o){return e._(this,void 0,void 0,(function*(){this.availableImages[t]=o;for(const e in this.workerSources[t]){const i=this.workerSources[t][e];for(const e in i)i[e].availableImages=o;}}))}_syncRTLPluginState(t,o){return e._(this,void 0,void 0,(function*(){if(e.bD.isParsed())return e.bD.getState();if("loading"!==o.pluginStatus)return e.bD.setState(o),o;const t=o.pluginURL;if(this.self.importScripts(t),e.bD.isParsed()){const o={pluginStatus:"loaded",pluginURL:t};return e.bD.setState(o),o}throw e.bD.setState({pluginStatus:"error",pluginURL:""}),new Error(`RTL Text Plugin failed to import scripts from ${t}`)}))}_getAvailableImages(e){let t=this.availableImages[e];return t||(t=[]),t}_getLayerIndex(e){let o=this.layerIndexes[e];return o||(o=this.layerIndexes[e]=new t),o}_getWorkerSource(e,t,o){if(this.workerSources[e]||(this.workerSources[e]={}),this.workerSources[e][t]||(this.workerSources[e][t]={}),!this.workerSources[e][t][o]){const i={sendAsync:(t,o)=>(t.targetMapId=e,this.actor.sendAsync(t,o))};switch(t){case"vector":this.workerSources[e][t][o]=new r(i,this._getLayerIndex(e),this._getAvailableImages(e));break;case"geojson":this.workerSources[e][t][o]=new ye(i,this._getLayerIndex(e),this._getAvailableImages(e));break;default:this.workerSources[e][t][o]=new this.externalWorkerSourceTypes[t](i,this._getLayerIndex(e),this._getAvailableImages(e));}}return this.workerSources[e][t][o]}_getDEMWorkerSource(e,t){return this.demWorkerSources[e]||(this.demWorkerSources[e]={}),this.demWorkerSources[e][t]||(this.demWorkerSources[e][t]=new n),this.demWorkerSources[e][t]}}return e.i(self)&&(self.worker=new ve(self)),ve})); + +define("index",["exports","./shared"],(function(t,e){"use strict";var i="4.7.1";let a,s;const o={now:"undefined"!=typeof performance&&performance&&performance.now?performance.now.bind(performance):Date.now.bind(Date),frameAsync:t=>new Promise(((i,a)=>{const s=requestAnimationFrame(i);t.signal.addEventListener("abort",(()=>{cancelAnimationFrame(s),a(e.c());}));})),getImageData(t,e=0){return this.getImageCanvasContext(t).getImageData(-e,-e,t.width+2*e,t.height+2*e)},getImageCanvasContext(t){const e=window.document.createElement("canvas"),i=e.getContext("2d",{willReadFrequently:!0});if(!i)throw new Error("failed to create canvas 2d context");return e.width=t.width,e.height=t.height,i.drawImage(t,0,0,t.width,t.height),i},resolveURL:t=>(a||(a=document.createElement("a")),a.href=t,a.href),hardwareConcurrency:"undefined"!=typeof navigator&&navigator.hardwareConcurrency||4,get prefersReducedMotion(){return !!matchMedia&&(null==s&&(s=matchMedia("(prefers-reduced-motion: reduce)")),s.matches)}};class r{static testProp(t){if(!r.docStyle)return t[0];for(let e=0;e{window.removeEventListener("click",r.suppressClickInternal,!0);}),0);}static getScale(t){const e=t.getBoundingClientRect();return {x:e.width/t.offsetWidth||1,y:e.height/t.offsetHeight||1,boundingClientRect:e}}static getPoint(t,i,a){const s=i.boundingClientRect;return new e.P((a.clientX-s.left)/i.x-t.clientLeft,(a.clientY-s.top)/i.y-t.clientTop)}static mousePos(t,e){const i=r.getScale(t);return r.getPoint(t,i,e)}static touchPos(t,e){const i=[],a=r.getScale(t);for(let s=0;s{l&&d(l),l=null,u=!0;},h.onerror=()=>{c=!0,l=null;},h.src="data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAQAAAAfQ//73v/+BiOh/AAA="),function(t){let i,a,s,o;t.resetRequestQueue=()=>{i=[],a=0,s=0,o={};},t.addThrottleControl=t=>{const e=s++;return o[e]=t,e},t.removeThrottleControl=t=>{delete o[t],l();},t.getImage=(t,a,s=!0)=>new Promise(((o,r)=>{n.supported&&(t.headers||(t.headers={}),t.headers.accept="image/webp,*/*"),e.e(t,{type:"image"}),i.push({abortController:a,requestParameters:t,supportImageRefresh:s,state:"queued",onError:t=>{r(t);},onSuccess:t=>{o(t);}}),l();}));const r=t=>e._(this,void 0,void 0,(function*(){t.state="running";const{requestParameters:i,supportImageRefresh:s,onError:o,onSuccess:r,abortController:n}=t,c=!1===s&&!e.i(self)&&!e.g(i.url)&&(!i.headers||Object.keys(i.headers).reduce(((t,e)=>t&&"accept"===e),!0));a++;const u=c?h(i,n):e.m(i,n);try{const i=yield u;delete t.abortController,t.state="completed",i.data instanceof HTMLImageElement||e.b(i.data)?r(i):i.data&&r({data:yield(d=i.data,"function"==typeof createImageBitmap?e.d(d):e.f(d)),cacheControl:i.cacheControl,expires:i.expires});}catch(e){delete t.abortController,o(e);}finally{a--,l();}var d;})),l=()=>{const t=(()=>{for(const t of Object.keys(o))if(o[t]())return !0;return !1})()?e.a.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME:e.a.MAX_PARALLEL_IMAGE_REQUESTS;for(let e=a;e0;e++){const t=i.shift();t.abortController.signal.aborted?e--:r(t);}},h=(t,i)=>new Promise(((a,s)=>{const o=new Image,r=t.url,n=t.credentials;n&&"include"===n?o.crossOrigin="use-credentials":(n&&"same-origin"===n||!e.s(r))&&(o.crossOrigin="anonymous"),i.signal.addEventListener("abort",(()=>{o.src="",s(e.c());})),o.fetchPriority="high",o.onload=()=>{o.onerror=o.onload=null,a({data:o});},o.onerror=()=>{o.onerror=o.onload=null,i.signal.aborted||s(new Error("Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported."));},o.src=r;}));}(_||(_={})),_.resetRequestQueue();class p{constructor(t){this._transformRequestFn=t;}transformRequest(t,e){return this._transformRequestFn&&this._transformRequestFn(t,e)||{url:t}}setTransformRequest(t){this._transformRequestFn=t;}}function m(t){var i=new e.A(3);return i[0]=t[0],i[1]=t[1],i[2]=t[2],i}var f,g=function(t,e,i){return t[0]=e[0]-i[0],t[1]=e[1]-i[1],t[2]=e[2]-i[2],t};f=new e.A(3),e.A!=Float32Array&&(f[0]=0,f[1]=0,f[2]=0);var v=function(t){var e=t[0],i=t[1];return e*e+i*i};function x(t){const e=[];if("string"==typeof t)e.push({id:"default",url:t});else if(t&&t.length>0){const i=[];for(const{id:a,url:s}of t){const t=`${a}${s}`;-1===i.indexOf(t)&&(i.push(t),e.push({id:a,url:s}));}}return e}function y(t,e,i){const a=t.split("?");return a[0]+=`${e}${i}`,a.join("?")}!function(){var t=new e.A(2);e.A!=Float32Array&&(t[0]=0,t[1]=0);}();class b{constructor(t,e,i,a){this.context=t,this.format=i,this.texture=t.gl.createTexture(),this.update(e,a);}update(t,i,a){const{width:s,height:o}=t,r=!(this.size&&this.size[0]===s&&this.size[1]===o||a),{context:n}=this,{gl:l}=n;if(this.useMipmap=Boolean(i&&i.useMipmap),l.bindTexture(l.TEXTURE_2D,this.texture),n.pixelStoreUnpackFlipY.set(!1),n.pixelStoreUnpack.set(1),n.pixelStoreUnpackPremultiplyAlpha.set(this.format===l.RGBA&&(!i||!1!==i.premultiply)),r)this.size=[s,o],t instanceof HTMLImageElement||t instanceof HTMLCanvasElement||t instanceof HTMLVideoElement||t instanceof ImageData||e.b(t)?l.texImage2D(l.TEXTURE_2D,0,this.format,this.format,l.UNSIGNED_BYTE,t):l.texImage2D(l.TEXTURE_2D,0,this.format,s,o,0,this.format,l.UNSIGNED_BYTE,t.data);else {const{x:i,y:r}=a||{x:0,y:0};t instanceof HTMLImageElement||t instanceof HTMLCanvasElement||t instanceof HTMLVideoElement||t instanceof ImageData||e.b(t)?l.texSubImage2D(l.TEXTURE_2D,0,i,r,l.RGBA,l.UNSIGNED_BYTE,t):l.texSubImage2D(l.TEXTURE_2D,0,i,r,s,o,l.RGBA,l.UNSIGNED_BYTE,t.data);}this.useMipmap&&this.isSizePowerOfTwo()&&l.generateMipmap(l.TEXTURE_2D);}bind(t,e,i){const{context:a}=this,{gl:s}=a;s.bindTexture(s.TEXTURE_2D,this.texture),i!==s.LINEAR_MIPMAP_NEAREST||this.isSizePowerOfTwo()||(i=s.LINEAR),t!==this.filter&&(s.texParameteri(s.TEXTURE_2D,s.TEXTURE_MAG_FILTER,t),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_MIN_FILTER,i||t),this.filter=t),e!==this.wrap&&(s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_S,e),s.texParameteri(s.TEXTURE_2D,s.TEXTURE_WRAP_T,e),this.wrap=e);}isSizePowerOfTwo(){return this.size[0]===this.size[1]&&Math.log(this.size[0])/Math.LN2%1==0}destroy(){const{gl:t}=this.context;t.deleteTexture(this.texture),this.texture=null;}}function w(t){const{userImage:e}=t;return !!(e&&e.render&&e.render())&&(t.data.replace(new Uint8Array(e.data.buffer)),!0)}class T extends e.E{constructor(){super(),this.images={},this.updatedImages={},this.callbackDispatchedThisFrame={},this.loaded=!1,this.requestors=[],this.patterns={},this.atlasImage=new e.R({width:1,height:1}),this.dirty=!0;}isLoaded(){return this.loaded}setLoaded(t){if(this.loaded!==t&&(this.loaded=t,t)){for(const{ids:t,promiseResolve:e}of this.requestors)e(this._getImagesForIds(t));this.requestors=[];}}getImage(t){const i=this.images[t];if(i&&!i.data&&i.spriteData){const t=i.spriteData;i.data=new e.R({width:t.width,height:t.height},t.context.getImageData(t.x,t.y,t.width,t.height).data),i.spriteData=null;}return i}addImage(t,e){if(this.images[t])throw new Error(`Image id ${t} already exist, use updateImage instead`);this._validate(t,e)&&(this.images[t]=e);}_validate(t,i){let a=!0;const s=i.data||i.spriteData;return this._validateStretch(i.stretchX,s&&s.width)||(this.fire(new e.j(new Error(`Image "${t}" has invalid "stretchX" value`))),a=!1),this._validateStretch(i.stretchY,s&&s.height)||(this.fire(new e.j(new Error(`Image "${t}" has invalid "stretchY" value`))),a=!1),this._validateContent(i.content,i)||(this.fire(new e.j(new Error(`Image "${t}" has invalid "content" value`))),a=!1),a}_validateStretch(t,e){if(!t)return !0;let i=0;for(const a of t){if(a[0]{let a=!0;if(!this.isLoaded())for(const e of t)this.images[e]||(a=!1);this.isLoaded()||a?e(this._getImagesForIds(t)):this.requestors.push({ids:t,promiseResolve:e});}))}_getImagesForIds(t){const i={};for(const a of t){let t=this.getImage(a);t||(this.fire(new e.k("styleimagemissing",{id:a})),t=this.getImage(a)),t?i[a]={data:t.data.clone(),pixelRatio:t.pixelRatio,sdf:t.sdf,version:t.version,stretchX:t.stretchX,stretchY:t.stretchY,content:t.content,textFitWidth:t.textFitWidth,textFitHeight:t.textFitHeight,hasRenderCallback:Boolean(t.userImage&&t.userImage.render)}:e.w(`Image "${a}" could not be loaded. Please make sure you have added the image with map.addImage() or a "sprite" property in your style. You can provide missing images by listening for the "styleimagemissing" map event.`);}return i}getPixelSize(){const{width:t,height:e}=this.atlasImage;return {width:t,height:e}}getPattern(t){const i=this.patterns[t],a=this.getImage(t);if(!a)return null;if(i&&i.position.version===a.version)return i.position;if(i)i.position.version=a.version;else {const i={w:a.data.width+2,h:a.data.height+2,x:0,y:0},s=new e.I(i,a);this.patterns[t]={bin:i,position:s};}return this._updatePatternAtlas(),this.patterns[t].position}bind(t){const e=t.gl;this.atlasTexture?this.dirty&&(this.atlasTexture.update(this.atlasImage),this.dirty=!1):this.atlasTexture=new b(t,this.atlasImage,e.RGBA),this.atlasTexture.bind(e.LINEAR,e.CLAMP_TO_EDGE);}_updatePatternAtlas(){const t=[];for(const e in this.patterns)t.push(this.patterns[e].bin);const{w:i,h:a}=e.p(t),s=this.atlasImage;s.resize({width:i||1,height:a||1});for(const t in this.patterns){const{bin:i}=this.patterns[t],a=i.x+1,o=i.y+1,r=this.getImage(t).data,n=r.width,l=r.height;e.R.copy(r,s,{x:0,y:0},{x:a,y:o},{width:n,height:l}),e.R.copy(r,s,{x:0,y:l-1},{x:a,y:o-1},{width:n,height:1}),e.R.copy(r,s,{x:0,y:0},{x:a,y:o+l},{width:n,height:1}),e.R.copy(r,s,{x:n-1,y:0},{x:a-1,y:o},{width:1,height:l}),e.R.copy(r,s,{x:0,y:0},{x:a+n,y:o},{width:1,height:l});}this.dirty=!0;}beginFrame(){this.callbackDispatchedThisFrame={};}dispatchRenderCallbacks(t){for(const i of t){if(this.callbackDispatchedThisFrame[i])continue;this.callbackDispatchedThisFrame[i]=!0;const t=this.getImage(i);t||e.w(`Image with ID: "${i}" was not found`),w(t)&&this.updateImage(i,t);}}}const I=1e20;function E(t,e,i,a,s,o,r,n,l){for(let h=e;h-1);l++,o[l]=n,r[l]=h,r[l+1]=I;}for(let n=0,l=0;n65535)throw new Error("glyphs > 65535 not supported");if(e.ranges[s])return {stack:t,id:i,glyph:a};if(!this.url)throw new Error("glyphsUrl is not set");if(!e.requests[s]){const i=C.loadGlyphRange(t,s,this.url,this.requestManager);e.requests[s]=i;}const o=yield e.requests[s];for(const t in o)this._doesCharSupportLocalGlyph(+t)||(e.glyphs[+t]=o[+t]);return e.ranges[s]=!0,{stack:t,id:i,glyph:o[i]||null}}))}_doesCharSupportLocalGlyph(t){return !!this.localIdeographFontFamily&&/\p{Ideo}|\p{sc=Hang}|\p{sc=Hira}|\p{sc=Kana}/u.test(String.fromCodePoint(t))}_tinySDF(t,i,a){const s=this.localIdeographFontFamily;if(!s)return;if(!this._doesCharSupportLocalGlyph(a))return;let o=t.tinySDF;if(!o){let e="400";/bold/i.test(i)?e="900":/medium/i.test(i)?e="500":/light/i.test(i)&&(e="200"),o=t.tinySDF=new C.TinySDF({fontSize:48,buffer:6,radius:16,cutoff:.25,fontFamily:s,fontWeight:e});}const r=o.draw(String.fromCharCode(a));return {id:a,bitmap:new e.o({width:r.width||60,height:r.height||60},r.data),metrics:{width:r.glyphWidth/2||24,height:r.glyphHeight/2||24,left:r.glyphLeft/2+.5||0,top:r.glyphTop/2-27.5||-8,advance:r.glyphAdvance/2||24,isDoubleResolution:!0}}}}C.loadGlyphRange=function(t,i,a,s){return e._(this,void 0,void 0,(function*(){const o=256*i,r=o+255,n=s.transformRequest(a.replace("{fontstack}",t).replace("{range}",`${o}-${r}`),"Glyphs"),l=yield e.l(n,new AbortController);if(!l||!l.data)throw new Error(`Could not load glyph range. range: ${i}, ${o}-${r}`);const h={};for(const t of e.n(l.data))h[t.id]=t;return h}))},C.TinySDF=class{constructor({fontSize:t=24,buffer:e=3,radius:i=8,cutoff:a=.25,fontFamily:s="sans-serif",fontWeight:o="normal",fontStyle:r="normal"}={}){this.buffer=e,this.cutoff=a,this.radius=i;const n=this.size=t+4*e,l=this._createCanvas(n),h=this.ctx=l.getContext("2d",{willReadFrequently:!0});h.font=`${r} ${o} ${t}px ${s}`,h.textBaseline="alphabetic",h.textAlign="left",h.fillStyle="black",this.gridOuter=new Float64Array(n*n),this.gridInner=new Float64Array(n*n),this.f=new Float64Array(n),this.z=new Float64Array(n+1),this.v=new Uint16Array(n);}_createCanvas(t){const e=document.createElement("canvas");return e.width=e.height=t,e}draw(t){const{width:e,actualBoundingBoxAscent:i,actualBoundingBoxDescent:a,actualBoundingBoxLeft:s,actualBoundingBoxRight:o}=this.ctx.measureText(t),r=Math.ceil(i),n=Math.max(0,Math.min(this.size-this.buffer,Math.ceil(o-s))),l=Math.min(this.size-this.buffer,r+Math.ceil(a)),h=n+2*this.buffer,c=l+2*this.buffer,u=Math.max(h*c,0),d=new Uint8ClampedArray(u),_={data:d,width:h,height:c,glyphWidth:n,glyphHeight:l,glyphTop:r,glyphLeft:0,glyphAdvance:e};if(0===n||0===l)return _;const{ctx:p,buffer:m,gridInner:f,gridOuter:g}=this;p.clearRect(m,m,n,l),p.fillText(t,m,m+r);const v=p.getImageData(m,m,n,l);g.fill(I,0,u),f.fill(0,0,u);for(let t=0;t0?t*t:0,f[a]=t<0?t*t:0;}}E(g,0,0,h,c,h,this.f,this.v,this.z),E(f,m,m,n,l,h,this.f,this.v,this.z);for(let t=0;t1&&(r=t[++o]);const l=Math.abs(n-r.left),h=Math.abs(n-r.right),c=Math.min(l,h);let u;const d=e/i*(a+1);if(r.isDash){const t=a-Math.abs(d);u=Math.sqrt(c*c+t*t);}else u=a-Math.sqrt(c*c+d*d);this.data[s+n]=Math.max(0,Math.min(255,u+128));}}}addRegularDash(t){for(let e=t.length-1;e>=0;--e){const i=t[e],a=t[e+1];i.zeroLength?t.splice(e,1):a&&a.isDash===i.isDash&&(a.left=i.left,t.splice(e,1));}const e=t[0],i=t[t.length-1];e.isDash===i.isDash&&(e.left=i.left-this.width,i.right=e.right+this.width);const a=this.width*this.nextRow;let s=0,o=t[s];for(let e=0;e1&&(o=t[++s]);const i=Math.abs(e-o.left),r=Math.abs(e-o.right),n=Math.min(i,r);this.data[a+e]=Math.max(0,Math.min(255,(o.isDash?n:-n)+128));}}addDash(t,i){const a=i?7:0,s=2*a+1;if(this.nextRow+s>this.height)return e.w("LineAtlas out of space"),null;let o=0;for(let e=0;e{t.terminate();})),this.workers=null);}isPreloaded(){return !!this.active[k]}numActive(){return Object.keys(this.active).length}}const F=Math.floor(o.hardwareConcurrency/2);let B,O;function N(){return B||(B=new L),B}L.workerCount=e.C(globalThis)?Math.max(Math.min(F,3),1):1;class U{constructor(t,i){this.workerPool=t,this.actors=[],this.currentActor=0,this.id=i;const a=this.workerPool.acquire(i);for(let t=0;t{t.remove();})),this.actors=[],t&&this.workerPool.release(this.id);}registerMessageHandler(t,e){for(const i of this.actors)i.registerMessageHandler(t,e);}}function j(){return O||(O=new U(N(),e.G),O.registerMessageHandler("GR",((t,i,a)=>e.m(i,a)))),O}function Z(t,i){const a=e.H();return e.J(a,a,[1,1,0]),e.K(a,a,[.5*t.width,.5*t.height,1]),e.L(a,a,t.calculatePosMatrix(i.toUnwrapped()))}function q(t,e,i,a,s,o){const r=function(t,e,i){if(t)for(const a of t){const t=e[a];if(t&&t.source===i&&"fill-extrusion"===t.type)return !0}else for(const t in e){const a=e[t];if(a.source===i&&"fill-extrusion"===a.type)return !0}return !1}(s&&s.layers,e,t.id),n=o.maxPitchScaleFactor(),l=t.tilesIn(a,n,r);l.sort(V);const h=[];for(const a of l)h.push({wrappedTileID:a.tileID.wrapped().key,queryResults:a.tile.queryRenderedFeatures(e,i,t._state,a.queryGeometry,a.cameraQueryGeometry,a.scale,s,o,n,Z(t.transform,a.tileID))});const c=function(t){const e={},i={};for(const a of t){const t=a.queryResults,s=a.wrappedTileID,o=i[s]=i[s]||{};for(const i in t){const a=t[i],s=o[i]=o[i]||{},r=e[i]=e[i]||[];for(const t of a)s[t.featureIndex]||(s[t.featureIndex]=!0,r.push(t));}}return e}(h);for(const e in c)c[e].forEach((e=>{const i=e.feature,a=t.getFeatureState(i.layer["source-layer"],i.id);i.source=i.layer.source,i.layer["source-layer"]&&(i.sourceLayer=i.layer["source-layer"]),i.state=a;}));return c}function V(t,e){const i=t.tileID,a=e.tileID;return i.overscaledZ-a.overscaledZ||i.canonical.y-a.canonical.y||i.wrap-a.wrap||i.canonical.x-a.canonical.x}function G(t,i,a){return e._(this,void 0,void 0,(function*(){let s=t;if(t.url?s=(yield e.h(i.transformRequest(t.url,"Source"),a)).data:yield o.frameAsync(a),!s)return null;const r=e.M(e.e(s,t),["tiles","minzoom","maxzoom","attribution","bounds","scheme","tileSize","encoding"]);return "vector_layers"in s&&s.vector_layers&&(r.vectorLayerIds=s.vector_layers.map((t=>t.id))),r}))}class H{constructor(t,e){t&&(e?this.setSouthWest(t).setNorthEast(e):Array.isArray(t)&&(4===t.length?this.setSouthWest([t[0],t[1]]).setNorthEast([t[2],t[3]]):this.setSouthWest(t[0]).setNorthEast(t[1])));}setNorthEast(t){return this._ne=t instanceof e.N?new e.N(t.lng,t.lat):e.N.convert(t),this}setSouthWest(t){return this._sw=t instanceof e.N?new e.N(t.lng,t.lat):e.N.convert(t),this}extend(t){const i=this._sw,a=this._ne;let s,o;if(t instanceof e.N)s=t,o=t;else {if(!(t instanceof H))return Array.isArray(t)?4===t.length||t.every(Array.isArray)?this.extend(H.convert(t)):this.extend(e.N.convert(t)):t&&("lng"in t||"lon"in t)&&"lat"in t?this.extend(e.N.convert(t)):this;if(s=t._sw,o=t._ne,!s||!o)return this}return i||a?(i.lng=Math.min(s.lng,i.lng),i.lat=Math.min(s.lat,i.lat),a.lng=Math.max(o.lng,a.lng),a.lat=Math.max(o.lat,a.lat)):(this._sw=new e.N(s.lng,s.lat),this._ne=new e.N(o.lng,o.lat)),this}getCenter(){return new e.N((this._sw.lng+this._ne.lng)/2,(this._sw.lat+this._ne.lat)/2)}getSouthWest(){return this._sw}getNorthEast(){return this._ne}getNorthWest(){return new e.N(this.getWest(),this.getNorth())}getSouthEast(){return new e.N(this.getEast(),this.getSouth())}getWest(){return this._sw.lng}getSouth(){return this._sw.lat}getEast(){return this._ne.lng}getNorth(){return this._ne.lat}toArray(){return [this._sw.toArray(),this._ne.toArray()]}toString(){return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`}isEmpty(){return !(this._sw&&this._ne)}contains(t){const{lng:i,lat:a}=e.N.convert(t);let s=this._sw.lng<=i&&i<=this._ne.lng;return this._sw.lng>this._ne.lng&&(s=this._sw.lng>=i&&i>=this._ne.lng),this._sw.lat<=a&&a<=this._ne.lat&&s}static convert(t){return t instanceof H?t:t?new H(t):t}static fromLngLat(t,i=0){const a=360*i/40075017,s=a/Math.cos(Math.PI/180*t.lat);return new H(new e.N(t.lng-s,t.lat-a),new e.N(t.lng+s,t.lat+a))}adjustAntiMeridian(){const t=new e.N(this._sw.lng,this._sw.lat),i=new e.N(this._ne.lng,this._ne.lat);return new H(t,t.lng>i.lng?new e.N(i.lng+360,i.lat):i)}}class W{constructor(t,e,i){this.bounds=H.convert(this.validateBounds(t)),this.minzoom=e||0,this.maxzoom=i||24;}validateBounds(t){return Array.isArray(t)&&4===t.length?[Math.max(-180,t[0]),Math.max(-90,t[1]),Math.min(180,t[2]),Math.min(90,t[3])]:[-180,-90,180,90]}contains(t){const i=Math.pow(2,t.z),a=Math.floor(e.O(this.bounds.getWest())*i),s=Math.floor(e.Q(this.bounds.getNorth())*i),o=Math.ceil(e.O(this.bounds.getEast())*i),r=Math.ceil(e.Q(this.bounds.getSouth())*i);return t.x>=a&&t.x=s&&t.y{this._options.tiles=t;})),this}setUrl(t){return this.setSourceProperty((()=>{this.url=t,this._options.url=t;})),this}onRemove(){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null);}serialize(){return e.e({},this._options)}loadTile(t){return e._(this,void 0,void 0,(function*(){const e=t.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme),i={request:this.map._requestManager.transformRequest(e,"Tile"),uid:t.uid,tileID:t.tileID,zoom:t.tileID.overscaledZ,tileSize:this.tileSize*t.tileID.overscaleFactor(),type:this.type,source:this.id,pixelRatio:this.map.getPixelRatio(),showCollisionBoxes:this.map.showCollisionBoxes,promoteId:this.promoteId};i.request.collectResourceTiming=this._collectResourceTiming;let a="RT";if(t.actor&&"expired"!==t.state){if("loading"===t.state)return new Promise(((e,i)=>{t.reloadPromise={resolve:e,reject:i};}))}else t.actor=this.dispatcher.getActor(),a="LT";t.abortController=new AbortController;try{const e=yield t.actor.sendAsync({type:a,data:i},t.abortController);if(delete t.abortController,t.aborted)return;this._afterTileLoadWorkerResponse(t,e);}catch(e){if(delete t.abortController,t.aborted)return;if(e&&404!==e.status)throw e;this._afterTileLoadWorkerResponse(t,null);}}))}_afterTileLoadWorkerResponse(t,e){if(e&&e.resourceTiming&&(t.resourceTiming=e.resourceTiming),e&&this.map._refreshExpiredTiles&&t.setExpiryData(e),t.loadVectorData(e,this.map.painter),t.reloadPromise){const e=t.reloadPromise;t.reloadPromise=null,this.loadTile(t).then(e.resolve).catch(e.reject);}}abortTile(t){return e._(this,void 0,void 0,(function*(){t.abortController&&(t.abortController.abort(),delete t.abortController),t.actor&&(yield t.actor.sendAsync({type:"AT",data:{uid:t.uid,type:this.type,source:this.id}}));}))}unloadTile(t){return e._(this,void 0,void 0,(function*(){t.unloadVectorData(),t.actor&&(yield t.actor.sendAsync({type:"RMT",data:{uid:t.uid,type:this.type,source:this.id}}));}))}hasTransition(){return !1}}class X extends e.E{constructor(t,i,a,s){super(),this.id=t,this.dispatcher=a,this.setEventedParent(s),this.type="raster",this.minzoom=0,this.maxzoom=22,this.roundZoom=!0,this.scheme="xyz",this.tileSize=512,this._loaded=!1,this._options=e.e({type:"raster"},i),e.e(this,e.M(i,["url","scheme","tileSize"]));}load(){return e._(this,void 0,void 0,(function*(){this._loaded=!1,this.fire(new e.k("dataloading",{dataType:"source"})),this._tileJSONRequest=new AbortController;try{const t=yield G(this._options,this.map._requestManager,this._tileJSONRequest);this._tileJSONRequest=null,this._loaded=!0,t&&(e.e(this,t),t.bounds&&(this.tileBounds=new W(t.bounds,this.minzoom,this.maxzoom)),this.fire(new e.k("data",{dataType:"source",sourceDataType:"metadata"})),this.fire(new e.k("data",{dataType:"source",sourceDataType:"content"})));}catch(t){this._tileJSONRequest=null,this.fire(new e.j(t));}}))}loaded(){return this._loaded}onAdd(t){this.map=t,this.load();}onRemove(){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null);}setSourceProperty(t){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null),t(),this.load();}setTiles(t){return this.setSourceProperty((()=>{this._options.tiles=t;})),this}setUrl(t){return this.setSourceProperty((()=>{this.url=t,this._options.url=t;})),this}serialize(){return e.e({},this._options)}hasTile(t){return !this.tileBounds||this.tileBounds.contains(t.canonical)}loadTile(t){return e._(this,void 0,void 0,(function*(){const e=t.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme);t.abortController=new AbortController;try{const i=yield _.getImage(this.map._requestManager.transformRequest(e,"Tile"),t.abortController,this.map._refreshExpiredTiles);if(delete t.abortController,t.aborted)return void(t.state="unloaded");if(i&&i.data){this.map._refreshExpiredTiles&&i.cacheControl&&i.expires&&t.setExpiryData({cacheControl:i.cacheControl,expires:i.expires});const e=this.map.painter.context,a=e.gl,s=i.data;t.texture=this.map.painter.getTileTexture(s.width),t.texture?t.texture.update(s,{useMipmap:!0}):(t.texture=new b(e,s,a.RGBA,{useMipmap:!0}),t.texture.bind(a.LINEAR,a.CLAMP_TO_EDGE,a.LINEAR_MIPMAP_NEAREST)),t.state="loaded";}}catch(e){if(delete t.abortController,t.aborted)t.state="unloaded";else if(e)throw t.state="errored",e}}))}abortTile(t){return e._(this,void 0,void 0,(function*(){t.abortController&&(t.abortController.abort(),delete t.abortController);}))}unloadTile(t){return e._(this,void 0,void 0,(function*(){t.texture&&this.map.painter.saveTileTexture(t.texture);}))}hasTransition(){return !1}}class K extends X{constructor(t,i,a,s){super(t,i,a,s),this.type="raster-dem",this.maxzoom=22,this._options=e.e({type:"raster-dem"},i),this.encoding=i.encoding||"mapbox",this.redFactor=i.redFactor,this.greenFactor=i.greenFactor,this.blueFactor=i.blueFactor,this.baseShift=i.baseShift;}loadTile(t){return e._(this,void 0,void 0,(function*(){const i=t.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme),a=this.map._requestManager.transformRequest(i,"Tile");t.neighboringTiles=this._getNeighboringTiles(t.tileID),t.abortController=new AbortController;try{const i=yield _.getImage(a,t.abortController,this.map._refreshExpiredTiles);if(delete t.abortController,t.aborted)return void(t.state="unloaded");if(i&&i.data){const a=i.data;this.map._refreshExpiredTiles&&i.cacheControl&&i.expires&&t.setExpiryData({cacheControl:i.cacheControl,expires:i.expires});const s=e.b(a)&&e.U()?a:yield this.readImageNow(a),o={type:this.type,uid:t.uid,source:this.id,rawImageData:s,encoding:this.encoding,redFactor:this.redFactor,greenFactor:this.greenFactor,blueFactor:this.blueFactor,baseShift:this.baseShift};if(!t.actor||"expired"===t.state){t.actor=this.dispatcher.getActor();const e=yield t.actor.sendAsync({type:"LDT",data:o});t.dem=e,t.needsHillshadePrepare=!0,t.needsTerrainPrepare=!0,t.state="loaded";}}}catch(e){if(delete t.abortController,t.aborted)t.state="unloaded";else if(e)throw t.state="errored",e}}))}readImageNow(t){return e._(this,void 0,void 0,(function*(){if("undefined"!=typeof VideoFrame&&e.V()){const i=t.width+2,a=t.height+2;try{return new e.R({width:i,height:a},yield e.W(t,-1,-1,i,a))}catch(t){}}return o.getImageData(t,1)}))}_getNeighboringTiles(t){const i=t.canonical,a=Math.pow(2,i.z),s=(i.x-1+a)%a,o=0===i.x?t.wrap-1:t.wrap,r=(i.x+1+a)%a,n=i.x+1===a?t.wrap+1:t.wrap,l={};return l[new e.S(t.overscaledZ,o,i.z,s,i.y).key]={backfilled:!1},l[new e.S(t.overscaledZ,n,i.z,r,i.y).key]={backfilled:!1},i.y>0&&(l[new e.S(t.overscaledZ,o,i.z,s,i.y-1).key]={backfilled:!1},l[new e.S(t.overscaledZ,t.wrap,i.z,i.x,i.y-1).key]={backfilled:!1},l[new e.S(t.overscaledZ,n,i.z,r,i.y-1).key]={backfilled:!1}),i.y+10&&e.e(s,{resourceTiming:a}),this.fire(new e.k("data",Object.assign(Object.assign({},s),{sourceDataType:"metadata"}))),this.fire(new e.k("data",Object.assign(Object.assign({},s),{sourceDataType:"content"})));}catch(t){if(this._pendingLoads--,this._removed)return void this.fire(new e.k("dataabort",{dataType:"source"}));this.fire(new e.j(t));}}))}loaded(){return 0===this._pendingLoads}loadTile(t){return e._(this,void 0,void 0,(function*(){const e=t.actor?"RT":"LT";t.actor=this.actor;const i={type:this.type,uid:t.uid,tileID:t.tileID,zoom:t.tileID.overscaledZ,maxZoom:this.maxzoom,tileSize:this.tileSize,source:this.id,pixelRatio:this.map.getPixelRatio(),showCollisionBoxes:this.map.showCollisionBoxes,promoteId:this.promoteId};t.abortController=new AbortController;const a=yield this.actor.sendAsync({type:e,data:i},t.abortController);delete t.abortController,t.unloadVectorData(),t.aborted||t.loadVectorData(a,this.map.painter,"RT"===e);}))}abortTile(t){return e._(this,void 0,void 0,(function*(){t.abortController&&(t.abortController.abort(),delete t.abortController),t.aborted=!0;}))}unloadTile(t){return e._(this,void 0,void 0,(function*(){t.unloadVectorData(),yield this.actor.sendAsync({type:"RMT",data:{uid:t.uid,type:this.type,source:this.id}});}))}onRemove(){this._removed=!0,this.actor.sendAsync({type:"RS",data:{type:this.type,source:this.id}});}serialize(){return e.e({},this._options,{type:this.type,data:this._data})}hasTransition(){return !1}}var Y=e.Y([{name:"a_pos",type:"Int16",components:2},{name:"a_texture_pos",type:"Int16",components:2}]);class Q extends e.E{constructor(t,e,i,a){super(),this.id=t,this.dispatcher=i,this.coordinates=e.coordinates,this.type="image",this.minzoom=0,this.maxzoom=22,this.tileSize=512,this.tiles={},this._loaded=!1,this.setEventedParent(a),this.options=e;}load(t){return e._(this,void 0,void 0,(function*(){this._loaded=!1,this.fire(new e.k("dataloading",{dataType:"source"})),this.url=this.options.url,this._request=new AbortController;try{const e=yield _.getImage(this.map._requestManager.transformRequest(this.url,"Image"),this._request);this._request=null,this._loaded=!0,e&&e.data&&(this.image=e.data,t&&(this.coordinates=t),this._finishLoading());}catch(t){this._request=null,this._loaded=!0,this.fire(new e.j(t));}}))}loaded(){return this._loaded}updateImage(t){return t.url?(this._request&&(this._request.abort(),this._request=null),this.options.url=t.url,this.load(t.coordinates).finally((()=>{this.texture=null;})),this):this}_finishLoading(){this.map&&(this.setCoordinates(this.coordinates),this.fire(new e.k("data",{dataType:"source",sourceDataType:"metadata"})));}onAdd(t){this.map=t,this.load();}onRemove(){this._request&&(this._request.abort(),this._request=null);}setCoordinates(t){this.coordinates=t;const i=t.map(e.Z.fromLngLat);this.tileID=function(t){let i=1/0,a=1/0,s=-1/0,o=-1/0;for(const e of t)i=Math.min(i,e.x),a=Math.min(a,e.y),s=Math.max(s,e.x),o=Math.max(o,e.y);const r=Math.max(s-i,o-a),n=Math.max(0,Math.floor(-Math.log(r)/Math.LN2)),l=Math.pow(2,n);return new e.a1(n,Math.floor((i+s)/2*l),Math.floor((a+o)/2*l))}(i),this.minzoom=this.maxzoom=this.tileID.z;const a=i.map((t=>this.tileID.getTilePoint(t)._round()));return this._boundsArray=new e.$,this._boundsArray.emplaceBack(a[0].x,a[0].y,0,0),this._boundsArray.emplaceBack(a[1].x,a[1].y,e.X,0),this._boundsArray.emplaceBack(a[3].x,a[3].y,0,e.X),this._boundsArray.emplaceBack(a[2].x,a[2].y,e.X,e.X),this.boundsBuffer&&(this.boundsBuffer.destroy(),delete this.boundsBuffer),this.fire(new e.k("data",{dataType:"source",sourceDataType:"content"})),this}prepare(){if(0===Object.keys(this.tiles).length||!this.image)return;const t=this.map.painter.context,i=t.gl;this.boundsBuffer||(this.boundsBuffer=t.createVertexBuffer(this._boundsArray,Y.members)),this.boundsSegments||(this.boundsSegments=e.a0.simpleSegment(0,0,4,2)),this.texture||(this.texture=new b(t,this.image,i.RGBA),this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE));let a=!1;for(const t in this.tiles){const e=this.tiles[t];"loaded"!==e.state&&(e.state="loaded",e.texture=this.texture,a=!0);}a&&this.fire(new e.k("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}loadTile(t){return e._(this,void 0,void 0,(function*(){this.tileID&&this.tileID.equals(t.tileID.canonical)?(this.tiles[String(t.tileID.wrap)]=t,t.buckets={}):t.state="errored";}))}serialize(){return {type:"image",url:this.options.url,coordinates:this.coordinates}}hasTransition(){return !1}}class tt extends Q{constructor(t,e,i,a){super(t,e,i,a),this.roundZoom=!0,this.type="video",this.options=e;}load(){return e._(this,void 0,void 0,(function*(){this._loaded=!1;const t=this.options;this.urls=[];for(const e of t.urls)this.urls.push(this.map._requestManager.transformRequest(e,"Source").url);try{const t=yield e.a3(this.urls);if(this._loaded=!0,!t)return;this.video=t,this.video.loop=!0,this.video.addEventListener("playing",(()=>{this.map.triggerRepaint();})),this.map&&this.video.play(),this._finishLoading();}catch(t){this.fire(new e.j(t));}}))}pause(){this.video&&this.video.pause();}play(){this.video&&this.video.play();}seek(t){if(this.video){const i=this.video.seekable;ti.end(0)?this.fire(new e.j(new e.a2(`sources.${this.id}`,null,`Playback for this video can be set only between the ${i.start(0)} and ${i.end(0)}-second mark.`))):this.video.currentTime=t;}}getVideo(){return this.video}onAdd(t){this.map||(this.map=t,this.load(),this.video&&(this.video.play(),this.setCoordinates(this.coordinates)));}prepare(){if(0===Object.keys(this.tiles).length||this.video.readyState<2)return;const t=this.map.painter.context,i=t.gl;this.boundsBuffer||(this.boundsBuffer=t.createVertexBuffer(this._boundsArray,Y.members)),this.boundsSegments||(this.boundsSegments=e.a0.simpleSegment(0,0,4,2)),this.texture?this.video.paused||(this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE),i.texSubImage2D(i.TEXTURE_2D,0,0,0,i.RGBA,i.UNSIGNED_BYTE,this.video)):(this.texture=new b(t,this.video,i.RGBA),this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE));let a=!1;for(const t in this.tiles){const e=this.tiles[t];"loaded"!==e.state&&(e.state="loaded",e.texture=this.texture,a=!0);}a&&this.fire(new e.k("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}serialize(){return {type:"video",urls:this.urls,coordinates:this.coordinates}}hasTransition(){return this.video&&!this.video.paused}}class et extends Q{constructor(t,i,a,s){super(t,i,a,s),i.coordinates?Array.isArray(i.coordinates)&&4===i.coordinates.length&&!i.coordinates.some((t=>!Array.isArray(t)||2!==t.length||t.some((t=>"number"!=typeof t))))||this.fire(new e.j(new e.a2(`sources.${t}`,null,'"coordinates" property must be an array of 4 longitude/latitude array pairs'))):this.fire(new e.j(new e.a2(`sources.${t}`,null,'missing required property "coordinates"'))),i.animate&&"boolean"!=typeof i.animate&&this.fire(new e.j(new e.a2(`sources.${t}`,null,'optional "animate" property must be a boolean value'))),i.canvas?"string"==typeof i.canvas||i.canvas instanceof HTMLCanvasElement||this.fire(new e.j(new e.a2(`sources.${t}`,null,'"canvas" must be either a string representing the ID of the canvas element from which to read, or an HTMLCanvasElement instance'))):this.fire(new e.j(new e.a2(`sources.${t}`,null,'missing required property "canvas"'))),this.options=i,this.animate=void 0===i.animate||i.animate;}load(){return e._(this,void 0,void 0,(function*(){this._loaded=!0,this.canvas||(this.canvas=this.options.canvas instanceof HTMLCanvasElement?this.options.canvas:document.getElementById(this.options.canvas)),this.width=this.canvas.width,this.height=this.canvas.height,this._hasInvalidDimensions()?this.fire(new e.j(new Error("Canvas dimensions cannot be less than or equal to zero."))):(this.play=function(){this._playing=!0,this.map.triggerRepaint();},this.pause=function(){this._playing&&(this.prepare(),this._playing=!1);},this._finishLoading());}))}getCanvas(){return this.canvas}onAdd(t){this.map=t,this.load(),this.canvas&&this.animate&&this.play();}onRemove(){this.pause();}prepare(){let t=!1;if(this.canvas.width!==this.width&&(this.width=this.canvas.width,t=!0),this.canvas.height!==this.height&&(this.height=this.canvas.height,t=!0),this._hasInvalidDimensions())return;if(0===Object.keys(this.tiles).length)return;const i=this.map.painter.context,a=i.gl;this.boundsBuffer||(this.boundsBuffer=i.createVertexBuffer(this._boundsArray,Y.members)),this.boundsSegments||(this.boundsSegments=e.a0.simpleSegment(0,0,4,2)),this.texture?(t||this._playing)&&this.texture.update(this.canvas,{premultiply:!0}):this.texture=new b(i,this.canvas,a.RGBA,{premultiply:!0});let s=!1;for(const t in this.tiles){const e=this.tiles[t];"loaded"!==e.state&&(e.state="loaded",e.texture=this.texture,s=!0);}s&&this.fire(new e.k("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}serialize(){return {type:"canvas",coordinates:this.coordinates}}hasTransition(){return this._playing}_hasInvalidDimensions(){for(const t of [this.canvas.width,this.canvas.height])if(isNaN(t)||t<=0)return !0;return !1}}const it={},at=t=>{switch(t){case"geojson":return J;case"image":return Q;case"raster":return X;case"raster-dem":return K;case"vector":return $;case"video":return tt;case"canvas":return et}return it[t]},st="RTLPluginLoaded";class ot extends e.E{constructor(){super(...arguments),this.status="unavailable",this.url=null,this.dispatcher=j();}_syncState(t){return this.status=t,this.dispatcher.broadcast("SRPS",{pluginStatus:t,pluginURL:this.url}).catch((t=>{throw this.status="error",t}))}getRTLTextPluginStatus(){return this.status}clearRTLTextPlugin(){this.status="unavailable",this.url=null;}setRTLTextPlugin(t){return e._(this,arguments,void 0,(function*(t,e=!1){if(this.url)throw new Error("setRTLTextPlugin cannot be called multiple times.");if(this.url=o.resolveURL(t),!this.url)throw new Error(`requested url ${t} is invalid`);if("unavailable"===this.status){if(!e)return this._requestImport();this.status="deferred",this._syncState(this.status);}else if("requested"===this.status)return this._requestImport()}))}_requestImport(){return e._(this,void 0,void 0,(function*(){yield this._syncState("loading"),this.status="loaded",this.fire(new e.k(st));}))}lazyLoad(){"unavailable"===this.status?this.status="requested":"deferred"===this.status&&this._requestImport();}}let rt=null;function nt(){return rt||(rt=new ot),rt}class lt{constructor(t,i){this.timeAdded=0,this.fadeEndTime=0,this.tileID=t,this.uid=e.a4(),this.uses=0,this.tileSize=i,this.buckets={},this.expirationTime=null,this.queryPadding=0,this.hasSymbolBuckets=!1,this.hasRTLText=!1,this.dependencies={},this.rtt=[],this.rttCoords={},this.expiredRequestCount=0,this.state="loading";}registerFadeDuration(t){const e=t+this.timeAdded;ee.getLayer(t))).filter(Boolean);if(0!==t.length){a.layers=t,a.stateDependentLayerIds&&(a.stateDependentLayers=a.stateDependentLayerIds.map((e=>t.filter((t=>t.id===e))[0])));for(const e of t)i[e.id]=a;}}return i}(t.buckets,i.style),this.hasSymbolBuckets=!1;for(const t in this.buckets){const i=this.buckets[t];if(i instanceof e.a6){if(this.hasSymbolBuckets=!0,!a)break;i.justReloaded=!0;}}if(this.hasRTLText=!1,this.hasSymbolBuckets)for(const t in this.buckets){const i=this.buckets[t];if(i instanceof e.a6&&i.hasRTLText){this.hasRTLText=!0,nt().lazyLoad();break}}this.queryPadding=0;for(const t in this.buckets){const e=this.buckets[t];this.queryPadding=Math.max(this.queryPadding,i.style.getLayer(t).queryRadius(e));}t.imageAtlas&&(this.imageAtlas=t.imageAtlas),t.glyphAtlasImage&&(this.glyphAtlasImage=t.glyphAtlasImage);}else this.collisionBoxArray=new e.a5;}unloadVectorData(){for(const t in this.buckets)this.buckets[t].destroy();this.buckets={},this.imageAtlasTexture&&this.imageAtlasTexture.destroy(),this.imageAtlas&&(this.imageAtlas=null),this.glyphAtlasTexture&&this.glyphAtlasTexture.destroy(),this.latestFeatureIndex=null,this.state="unloaded";}getBucket(t){return this.buckets[t.id]}upload(t){for(const e in this.buckets){const i=this.buckets[e];i.uploadPending()&&i.upload(t);}const e=t.gl;this.imageAtlas&&!this.imageAtlas.uploaded&&(this.imageAtlasTexture=new b(t,this.imageAtlas.image,e.RGBA),this.imageAtlas.uploaded=!0),this.glyphAtlasImage&&(this.glyphAtlasTexture=new b(t,this.glyphAtlasImage,e.ALPHA),this.glyphAtlasImage=null);}prepare(t){this.imageAtlas&&this.imageAtlas.patchUpdatedImages(t,this.imageAtlasTexture);}queryRenderedFeatures(t,e,i,a,s,o,r,n,l,h){return this.latestFeatureIndex&&this.latestFeatureIndex.rawTileData?this.latestFeatureIndex.query({queryGeometry:a,cameraQueryGeometry:s,scale:o,tileSize:this.tileSize,pixelPosMatrix:h,transform:n,params:r,queryPadding:this.queryPadding*l},t,e,i):{}}querySourceFeatures(t,i){const a=this.latestFeatureIndex;if(!a||!a.rawTileData)return;const s=a.loadVTLayers(),o=i&&i.sourceLayer?i.sourceLayer:"",r=s._geojsonTileLayer||s[o];if(!r)return;const n=e.a7(i&&i.filter),{z:l,x:h,y:c}=this.tileID.canonical,u={z:l,x:h,y:c};for(let i=0;it)e=!1;else if(i)if(this.expirationTime{this.remove(t,s);}),i)),this.data[a].push(s),this.order.push(a),this.order.length>this.max){const t=this._getAndRemoveByKey(this.order[0]);t&&this.onRemove(t);}return this}has(t){return t.wrapped().key in this.data}getAndRemove(t){return this.has(t)?this._getAndRemoveByKey(t.wrapped().key):null}_getAndRemoveByKey(t){const e=this.data[t].shift();return e.timeout&&clearTimeout(e.timeout),0===this.data[t].length&&delete this.data[t],this.order.splice(this.order.indexOf(t),1),e.value}getByKey(t){const e=this.data[t];return e?e[0].value:null}get(t){return this.has(t)?this.data[t.wrapped().key][0].value:null}remove(t,e){if(!this.has(t))return this;const i=t.wrapped().key,a=void 0===e?0:this.data[i].indexOf(e),s=this.data[i][a];return this.data[i].splice(a,1),s.timeout&&clearTimeout(s.timeout),0===this.data[i].length&&delete this.data[i],this.onRemove(s.value),this.order.splice(this.order.indexOf(i),1),this}setMaxSize(t){for(this.max=t;this.order.length>this.max;){const t=this._getAndRemoveByKey(this.order[0]);t&&this.onRemove(t);}return this}filter(t){const e=[];for(const i in this.data)for(const a of this.data[i])t(a.value)||e.push(a);for(const t of e)this.remove(t.value.tileID,t);}}class ct{constructor(){this.state={},this.stateChanges={},this.deletedStates={};}updateState(t,i,a){const s=String(i);if(this.stateChanges[t]=this.stateChanges[t]||{},this.stateChanges[t][s]=this.stateChanges[t][s]||{},e.e(this.stateChanges[t][s],a),null===this.deletedStates[t]){this.deletedStates[t]={};for(const e in this.state[t])e!==s&&(this.deletedStates[t][e]=null);}else if(this.deletedStates[t]&&null===this.deletedStates[t][s]){this.deletedStates[t][s]={};for(const e in this.state[t][s])a[e]||(this.deletedStates[t][s][e]=null);}else for(const e in a)this.deletedStates[t]&&this.deletedStates[t][s]&&null===this.deletedStates[t][s][e]&&delete this.deletedStates[t][s][e];}removeFeatureState(t,e,i){if(null===this.deletedStates[t])return;const a=String(e);if(this.deletedStates[t]=this.deletedStates[t]||{},i&&void 0!==e)null!==this.deletedStates[t][a]&&(this.deletedStates[t][a]=this.deletedStates[t][a]||{},this.deletedStates[t][a][i]=null);else if(void 0!==e)if(this.stateChanges[t]&&this.stateChanges[t][a])for(i in this.deletedStates[t][a]={},this.stateChanges[t][a])this.deletedStates[t][a][i]=null;else this.deletedStates[t][a]=null;else this.deletedStates[t]=null;}getState(t,i){const a=String(i),s=e.e({},(this.state[t]||{})[a],(this.stateChanges[t]||{})[a]);if(null===this.deletedStates[t])return {};if(this.deletedStates[t]){const e=this.deletedStates[t][i];if(null===e)return {};for(const t in e)delete s[t];}return s}initializeTileState(t,e){t.setFeatureState(this.state,e);}coalesceChanges(t,i){const a={};for(const t in this.stateChanges){this.state[t]=this.state[t]||{};const i={};for(const a in this.stateChanges[t])this.state[t][a]||(this.state[t][a]={}),e.e(this.state[t][a],this.stateChanges[t][a]),i[a]=this.state[t][a];a[t]=i;}for(const t in this.deletedStates){this.state[t]=this.state[t]||{};const i={};if(null===this.deletedStates[t])for(const e in this.state[t])i[e]={},this.state[t][e]={};else for(const e in this.deletedStates[t]){if(null===this.deletedStates[t][e])this.state[t][e]={};else for(const i of Object.keys(this.deletedStates[t][e]))delete this.state[t][e][i];i[e]=this.state[t][e];}a[t]=a[t]||{},e.e(a[t],i);}if(this.stateChanges={},this.deletedStates={},0!==Object.keys(a).length)for(const e in t)t[e].setFeatureState(a,i);}}class ut extends e.E{constructor(t,e,i){super(),this.id=t,this.dispatcher=i,this.on("data",(t=>this._dataHandler(t))),this.on("dataloading",(()=>{this._sourceErrored=!1;})),this.on("error",(()=>{this._sourceErrored=this._source.loaded();})),this._source=((t,e,i,a)=>{const s=new(at(e.type))(t,e,i,a);if(s.id!==t)throw new Error(`Expected Source id to be ${t} instead of ${s.id}`);return s})(t,e,i,this),this._tiles={},this._cache=new ht(0,(t=>this._unloadTile(t))),this._timers={},this._cacheTimers={},this._maxTileCacheSize=null,this._maxTileCacheZoomLevels=null,this._loadedParentTiles={},this._coveredTiles={},this._state=new ct,this._didEmitContent=!1,this._updated=!1;}onAdd(t){this.map=t,this._maxTileCacheSize=t?t._maxTileCacheSize:null,this._maxTileCacheZoomLevels=t?t._maxTileCacheZoomLevels:null,this._source&&this._source.onAdd&&this._source.onAdd(t);}onRemove(t){this.clearTiles(),this._source&&this._source.onRemove&&this._source.onRemove(t);}loaded(){if(this._sourceErrored)return !0;if(!this._sourceLoaded)return !1;if(!this._source.loaded())return !1;if(!(void 0===this.used&&void 0===this.usedForTerrain||this.used||this.usedForTerrain))return !0;if(!this._updated)return !1;for(const t in this._tiles){const e=this._tiles[t];if("loaded"!==e.state&&"errored"!==e.state)return !1}return !0}getSource(){return this._source}pause(){this._paused=!0;}resume(){if(!this._paused)return;const t=this._shouldReloadOnResume;this._paused=!1,this._shouldReloadOnResume=!1,t&&this.reload(),this.transform&&this.update(this.transform,this.terrain);}_loadTile(t,i,a){return e._(this,void 0,void 0,(function*(){try{yield this._source.loadTile(t),this._tileLoaded(t,i,a);}catch(i){t.state="errored",404!==i.status?this._source.fire(new e.j(i,{tile:t})):this.update(this.transform,this.terrain);}}))}_unloadTile(t){this._source.unloadTile&&this._source.unloadTile(t);}_abortTile(t){this._source.abortTile&&this._source.abortTile(t),this._source.fire(new e.k("dataabort",{tile:t,coord:t.tileID,dataType:"source"}));}serialize(){return this._source.serialize()}prepare(t){this._source.prepare&&this._source.prepare(),this._state.coalesceChanges(this._tiles,this.map?this.map.painter:null);for(const e in this._tiles){const i=this._tiles[e];i.upload(t),i.prepare(this.map.style.imageManager);}}getIds(){return Object.values(this._tiles).map((t=>t.tileID)).sort(dt).map((t=>t.key))}getRenderableIds(t){const i=[];for(const e in this._tiles)this._isIdRenderable(e,t)&&i.push(this._tiles[e]);return t?i.sort(((t,i)=>{const a=t.tileID,s=i.tileID,o=new e.P(a.canonical.x,a.canonical.y)._rotate(this.transform.angle),r=new e.P(s.canonical.x,s.canonical.y)._rotate(this.transform.angle);return a.overscaledZ-s.overscaledZ||r.y-o.y||r.x-o.x})).map((t=>t.tileID.key)):i.map((t=>t.tileID)).sort(dt).map((t=>t.key))}hasRenderableParent(t){const e=this.findLoadedParent(t,0);return !!e&&this._isIdRenderable(e.tileID.key)}_isIdRenderable(t,e){return this._tiles[t]&&this._tiles[t].hasData()&&!this._coveredTiles[t]&&(e||!this._tiles[t].holdingForFade())}reload(){if(this._paused)this._shouldReloadOnResume=!0;else {this._cache.reset();for(const t in this._tiles)"errored"!==this._tiles[t].state&&this._reloadTile(t,"reloading");}}_reloadTile(t,i){return e._(this,void 0,void 0,(function*(){const e=this._tiles[t];e&&("loading"!==e.state&&(e.state=i),yield this._loadTile(e,t,i));}))}_tileLoaded(t,i,a){t.timeAdded=o.now(),"expired"===a&&(t.refreshedUponExpiration=!0),this._setTileReloadTimer(i,t),"raster-dem"===this.getSource().type&&t.dem&&this._backfillDEM(t),this._state.initializeTileState(t,this.map?this.map.painter:null),t.aborted||this._source.fire(new e.k("data",{dataType:"source",tile:t,coord:t.tileID}));}_backfillDEM(t){const e=this.getRenderableIds();for(let a=0;a1||(Math.abs(i)>1&&(1===Math.abs(i+s)?i+=s:1===Math.abs(i-s)&&(i-=s)),e.dem&&t.dem&&(t.dem.backfillBorder(e.dem,i,a),t.neighboringTiles&&t.neighboringTiles[o]&&(t.neighboringTiles[o].backfilled=!0)));}}getTile(t){return this.getTileByID(t.key)}getTileByID(t){return this._tiles[t]}_retainLoadedChildren(t,e,i,a){for(const s in this._tiles){let o=this._tiles[s];if(a[s]||!o.hasData()||o.tileID.overscaledZ<=e||o.tileID.overscaledZ>i)continue;let r=o.tileID;for(;o&&o.tileID.overscaledZ>e+1;){const t=o.tileID.scaledTo(o.tileID.overscaledZ-1);o=this._tiles[t.key],o&&o.hasData()&&(r=t);}let n=r;for(;n.overscaledZ>e;)if(n=n.scaledTo(n.overscaledZ-1),t[n.key]){a[r.key]=r;break}}}findLoadedParent(t,e){if(t.key in this._loadedParentTiles){const i=this._loadedParentTiles[t.key];return i&&i.tileID.overscaledZ>=e?i:null}for(let i=t.overscaledZ-1;i>=e;i--){const e=t.scaledTo(i),a=this._getLoadedTile(e);if(a)return a}}findLoadedSibling(t){return this._getLoadedTile(t)}_getLoadedTile(t){const e=this._tiles[t.key];return e&&e.hasData()?e:this._cache.getByKey(t.wrapped().key)}updateCacheSize(t){const i=Math.ceil(t.width/this._source.tileSize)+1,a=Math.ceil(t.height/this._source.tileSize)+1,s=Math.floor(i*a*(null===this._maxTileCacheZoomLevels?e.a.MAX_TILE_CACHE_ZOOM_LEVELS:this._maxTileCacheZoomLevels)),o="number"==typeof this._maxTileCacheSize?Math.min(this._maxTileCacheSize,s):s;this._cache.setMaxSize(o);}handleWrapJump(t){const e=Math.round((t-(void 0===this._prevLng?t:this._prevLng))/360);if(this._prevLng=t,e){const t={};for(const i in this._tiles){const a=this._tiles[i];a.tileID=a.tileID.unwrapTo(a.tileID.wrap+e),t[a.tileID.key]=a;}this._tiles=t;for(const t in this._timers)clearTimeout(this._timers[t]),delete this._timers[t];for(const t in this._tiles)this._setTileReloadTimer(t,this._tiles[t]);}}_updateCoveredAndRetainedTiles(t,e,i,a,s,r){const n={},l={},h=Object.keys(t),c=o.now();for(const i of h){const a=t[i],s=this._tiles[i];if(!s||0!==s.fadeEndTime&&s.fadeEndTime<=c)continue;const o=this.findLoadedParent(a,e),r=this.findLoadedSibling(a),h=o||r||null;h&&(this._addTile(h.tileID),n[h.tileID.key]=h.tileID),l[i]=a;}this._retainLoadedChildren(l,a,i,t);for(const e in n)t[e]||(this._coveredTiles[e]=!0,t[e]=n[e]);if(r){const e={},i={};for(const t of s)this._tiles[t.key].hasData()?e[t.key]=t:i[t.key]=t;for(const a in i){const s=i[a].children(this._source.maxzoom);this._tiles[s[0].key]&&this._tiles[s[1].key]&&this._tiles[s[2].key]&&this._tiles[s[3].key]&&(e[s[0].key]=t[s[0].key]=s[0],e[s[1].key]=t[s[1].key]=s[1],e[s[2].key]=t[s[2].key]=s[2],e[s[3].key]=t[s[3].key]=s[3],delete i[a]);}for(const a in i){const s=i[a],o=this.findLoadedParent(s,this._source.minzoom),r=this.findLoadedSibling(s),n=o||r||null;if(n){e[n.tileID.key]=t[n.tileID.key]=n.tileID;for(const t in e)e[t].isChildOf(n.tileID)&&delete e[t];}}for(const t in this._tiles)e[t]||(this._coveredTiles[t]=!0);}}update(t,i){if(!this._sourceLoaded||this._paused)return;let a;this.transform=t,this.terrain=i,this.updateCacheSize(t),this.handleWrapJump(this.transform.center.lng),this._coveredTiles={},this.used||this.usedForTerrain?this._source.tileID?a=t.getVisibleUnwrappedCoordinates(this._source.tileID).map((t=>new e.S(t.canonical.z,t.wrap,t.canonical.z,t.canonical.x,t.canonical.y))):(a=t.coveringTiles({tileSize:this.usedForTerrain?this.tileSize:this._source.tileSize,minzoom:this._source.minzoom,maxzoom:this._source.maxzoom,roundZoom:!this.usedForTerrain&&this._source.roundZoom,reparseOverscaled:this._source.reparseOverscaled,terrain:i}),this._source.hasTile&&(a=a.filter((t=>this._source.hasTile(t))))):a=[];const s=t.coveringZoomLevel(this._source),o=Math.max(s-ut.maxOverzooming,this._source.minzoom),r=Math.max(s+ut.maxUnderzooming,this._source.minzoom);if(this.usedForTerrain){const t={};for(const e of a)if(e.canonical.z>this._source.minzoom){const i=e.scaledTo(e.canonical.z-1);t[i.key]=i;const a=e.scaledTo(Math.max(this._source.minzoom,Math.min(e.canonical.z,5)));t[a.key]=a;}a=a.concat(Object.values(t));}const n=0===a.length&&!this._updated&&this._didEmitContent;this._updated=!0,n&&this.fire(new e.k("data",{sourceDataType:"idle",dataType:"source",sourceId:this.id}));const l=this._updateRetainedTiles(a,s);_t(this._source.type)&&this._updateCoveredAndRetainedTiles(l,o,r,s,a,i);for(const t in l)this._tiles[t].clearFadeHold();const h=e.ab(this._tiles,l);for(const t of h){const e=this._tiles[t];e.hasSymbolBuckets&&!e.holdingForFade()?e.setHoldDuration(this.map._fadeDuration):e.hasSymbolBuckets&&!e.symbolFadeFinished()||this._removeTile(t);}this._updateLoadedParentTileCache(),this._updateLoadedSiblingTileCache();}releaseSymbolFadeTiles(){for(const t in this._tiles)this._tiles[t].holdingForFade()&&this._removeTile(t);}_updateRetainedTiles(t,e){var i;const a={},s={},o=Math.max(e-ut.maxOverzooming,this._source.minzoom),r=Math.max(e+ut.maxUnderzooming,this._source.minzoom),n={};for(const i of t){const t=this._addTile(i);a[i.key]=i,t.hasData()||ethis._source.maxzoom){const t=r.children(this._source.maxzoom)[0],e=this.getTile(t);if(e&&e.hasData()){a[t.key]=t;continue}}else {const t=r.children(this._source.maxzoom);if(a[t[0].key]&&a[t[1].key]&&a[t[2].key]&&a[t[3].key])continue}let n=t.wasRequested();for(let e=r.overscaledZ-1;e>=o;--e){const o=r.scaledTo(e);if(s[o.key])break;if(s[o.key]=!0,t=this.getTile(o),!t&&n&&(t=this._addTile(o)),t){const e=t.hasData();if((e||!(null===(i=this.map)||void 0===i?void 0:i.cancelPendingTileRequestsWhileZooming)||n)&&(a[o.key]=o),n=t.wasRequested(),e)break}}}return a}_updateLoadedParentTileCache(){this._loadedParentTiles={};for(const t in this._tiles){const e=[];let i,a=this._tiles[t].tileID;for(;a.overscaledZ>0;){if(a.key in this._loadedParentTiles){i=this._loadedParentTiles[a.key];break}e.push(a.key);const t=a.scaledTo(a.overscaledZ-1);if(i=this._getLoadedTile(t),i)break;a=t;}for(const t of e)this._loadedParentTiles[t]=i;}}_updateLoadedSiblingTileCache(){this._loadedSiblingTiles={};for(const t in this._tiles){const e=this._tiles[t].tileID,i=this._getLoadedTile(e);this._loadedSiblingTiles[e.key]=i;}}_addTile(t){let i=this._tiles[t.key];if(i)return i;i=this._cache.getAndRemove(t),i&&(this._setTileReloadTimer(t.key,i),i.tileID=t,this._state.initializeTileState(i,this.map?this.map.painter:null),this._cacheTimers[t.key]&&(clearTimeout(this._cacheTimers[t.key]),delete this._cacheTimers[t.key],this._setTileReloadTimer(t.key,i)));const a=i;return i||(i=new lt(t,this._source.tileSize*t.overscaleFactor()),this._loadTile(i,t.key,i.state)),i.uses++,this._tiles[t.key]=i,a||this._source.fire(new e.k("dataloading",{tile:i,coord:i.tileID,dataType:"source"})),i}_setTileReloadTimer(t,e){t in this._timers&&(clearTimeout(this._timers[t]),delete this._timers[t]);const i=e.getExpiryTimeout();i&&(this._timers[t]=setTimeout((()=>{this._reloadTile(t,"expired"),delete this._timers[t];}),i));}_removeTile(t){const e=this._tiles[t];e&&(e.uses--,delete this._tiles[t],this._timers[t]&&(clearTimeout(this._timers[t]),delete this._timers[t]),e.uses>0||(e.hasData()&&"reloading"!==e.state?this._cache.add(e.tileID,e,e.getExpiryTimeout()):(e.aborted=!0,this._abortTile(e),this._unloadTile(e))));}_dataHandler(t){const e=t.sourceDataType;"source"===t.dataType&&"metadata"===e&&(this._sourceLoaded=!0),this._sourceLoaded&&!this._paused&&"source"===t.dataType&&"content"===e&&(this.reload(),this.transform&&this.update(this.transform,this.terrain),this._didEmitContent=!0);}clearTiles(){this._shouldReloadOnResume=!1,this._paused=!1;for(const t in this._tiles)this._removeTile(t);this._cache.reset();}tilesIn(t,i,a){const s=[],o=this.transform;if(!o)return s;const r=a?o.getCameraQueryGeometry(t):t,n=t.map((t=>o.pointCoordinate(t,this.terrain))),l=r.map((t=>o.pointCoordinate(t,this.terrain))),h=this.getIds();let c=1/0,u=1/0,d=-1/0,_=-1/0;for(const t of l)c=Math.min(c,t.x),u=Math.min(u,t.y),d=Math.max(d,t.x),_=Math.max(_,t.y);for(let t=0;t=0&&f[1].y+m>=0){const t=n.map((t=>r.getTilePoint(t))),e=l.map((t=>r.getTilePoint(t)));s.push({tile:a,tileID:r,queryGeometry:t,cameraQueryGeometry:e,scale:p});}}return s}getVisibleCoordinates(t){const e=this.getRenderableIds(t).map((t=>this._tiles[t].tileID));for(const t of e)t.posMatrix=this.transform.calculatePosMatrix(t.toUnwrapped());return e}hasTransition(){if(this._source.hasTransition())return !0;if(_t(this._source.type)){const t=o.now();for(const e in this._tiles)if(this._tiles[e].fadeEndTime>=t)return !0}return !1}setFeatureState(t,e,i){this._state.updateState(t=t||"_geojsonTileLayer",e,i);}removeFeatureState(t,e,i){this._state.removeFeatureState(t=t||"_geojsonTileLayer",e,i);}getFeatureState(t,e){return this._state.getState(t=t||"_geojsonTileLayer",e)}setDependencies(t,e,i){const a=this._tiles[t];a&&a.setDependencies(e,i);}reloadTilesForDependencies(t,e){for(const i in this._tiles)this._tiles[i].hasDependency(t,e)&&this._reloadTile(i,"reloading");this._cache.filter((i=>!i.hasDependency(t,e)));}}function dt(t,e){const i=Math.abs(2*t.wrap)-+(t.wrap<0),a=Math.abs(2*e.wrap)-+(e.wrap<0);return t.overscaledZ-e.overscaledZ||a-i||e.canonical.y-t.canonical.y||e.canonical.x-t.canonical.x}function _t(t){return "raster"===t||"image"===t||"video"===t}ut.maxOverzooming=10,ut.maxUnderzooming=3;class pt{constructor(t,e){this.reset(t,e);}reset(t,e){this.points=t||[],this._distances=[0];for(let t=1;t0?(s-r)/n:0;return this.points[o].mult(1-l).add(this.points[i].mult(l))}}function mt(t,e){let i=!0;return "always"===t||"never"!==t&&"never"!==e||(i=!1),i}class ft{constructor(t,e,i){const a=this.boxCells=[],s=this.circleCells=[];this.xCellCount=Math.ceil(t/i),this.yCellCount=Math.ceil(e/i);for(let t=0;tthis.width||a<0||e>this.height)return [];const n=[];if(t<=0&&e<=0&&this.width<=i&&this.height<=a){if(s)return [{key:null,x1:t,y1:e,x2:i,y2:a}];for(let t=0;t0}hitTestCircle(t,e,i,a,s){const o=t-i,r=t+i,n=e-i,l=e+i;if(r<0||o>this.width||l<0||n>this.height)return !1;const h=[];return this._forEachCell(o,n,r,l,this._queryCellCircle,h,{hitTest:!0,overlapMode:a,circle:{x:t,y:e,radius:i},seenUids:{box:{},circle:{}}},s),h.length>0}_queryCell(t,e,i,a,s,o,r,n){const{seenUids:l,hitTest:h,overlapMode:c}=r,u=this.boxCells[s];if(null!==u){const s=this.bboxes;for(const r of u)if(!l.box[r]){l.box[r]=!0;const u=4*r,d=this.boxKeys[r];if(t<=s[u+2]&&e<=s[u+3]&&i>=s[u+0]&&a>=s[u+1]&&(!n||n(d))&&(!h||!mt(c,d.overlapMode))&&(o.push({key:d,x1:s[u],y1:s[u+1],x2:s[u+2],y2:s[u+3]}),h))return !0}}const d=this.circleCells[s];if(null!==d){const s=this.circles;for(const r of d)if(!l.circle[r]){l.circle[r]=!0;const u=3*r,d=this.circleKeys[r];if(this._circleAndRectCollide(s[u],s[u+1],s[u+2],t,e,i,a)&&(!n||n(d))&&(!h||!mt(c,d.overlapMode))){const t=s[u],e=s[u+1],i=s[u+2];if(o.push({key:d,x1:t-i,y1:e-i,x2:t+i,y2:e+i}),h)return !0}}}return !1}_queryCellCircle(t,e,i,a,s,o,r,n){const{circle:l,seenUids:h,overlapMode:c}=r,u=this.boxCells[s];if(null!==u){const t=this.bboxes;for(const e of u)if(!h.box[e]){h.box[e]=!0;const i=4*e,a=this.boxKeys[e];if(this._circleAndRectCollide(l.x,l.y,l.radius,t[i+0],t[i+1],t[i+2],t[i+3])&&(!n||n(a))&&!mt(c,a.overlapMode))return o.push(!0),!0}}const d=this.circleCells[s];if(null!==d){const t=this.circles;for(const e of d)if(!h.circle[e]){h.circle[e]=!0;const i=3*e,a=this.circleKeys[e];if(this._circlesCollide(t[i],t[i+1],t[i+2],l.x,l.y,l.radius)&&(!n||n(a))&&!mt(c,a.overlapMode))return o.push(!0),!0}}}_forEachCell(t,e,i,a,s,o,r,n){const l=this._convertToXCellCoord(t),h=this._convertToYCellCoord(e),c=this._convertToXCellCoord(i),u=this._convertToYCellCoord(a);for(let d=l;d<=c;d++)for(let l=h;l<=u;l++)if(s.call(this,t,e,i,a,this.xCellCount*l+d,o,r,n))return}_convertToXCellCoord(t){return Math.max(0,Math.min(this.xCellCount-1,Math.floor(t*this.xScale)))}_convertToYCellCoord(t){return Math.max(0,Math.min(this.yCellCount-1,Math.floor(t*this.yScale)))}_circlesCollide(t,e,i,a,s,o){const r=a-t,n=s-e,l=i+o;return l*l>r*r+n*n}_circleAndRectCollide(t,e,i,a,s,o,r){const n=(o-a)/2,l=Math.abs(t-(a+n));if(l>n+i)return !1;const h=(r-s)/2,c=Math.abs(e-(s+h));if(c>h+i)return !1;if(l<=n||c<=h)return !0;const u=l-n,d=c-h;return u*u+d*d<=i*i}}function gt(t,i,a,s,o){const r=e.H();return i?(e.K(r,r,[1/o,1/o,1]),a||e.ad(r,r,s.angle)):e.L(r,s.labelPlaneMatrix,t),r}function vt(t,i,a,s,o){if(i){const i=e.ae(t);return e.K(i,i,[o,o,1]),a||e.ad(i,i,-s.angle),i}return s.glCoordMatrix}function xt(t,i,a,s){let o;s?(o=[t,i,s(t,i),1],e.af(o,o,a)):(o=[t,i,0,1],kt(o,o,a));const r=o[3];return {point:new e.P(o[0]/r,o[1]/r),signedDistanceFromCamera:r,isOccluded:!1}}function yt(t,e){return .5+t/e*.5}function bt(t,e){return t.x>=-e[0]&&t.x<=e[0]&&t.y>=-e[1]&&t.y<=e[1]}function wt(t,i,a,s,o,r,n,l,h,c,u,d,_,p,m){const f=s?t.textSizeData:t.iconSizeData,g=e.ag(f,a.transform.zoom),v=[256/a.width*2+1,256/a.height*2+1],x=s?t.text.dynamicLayoutVertexArray:t.icon.dynamicLayoutVertexArray;x.clear();const y=t.lineVertexArray,b=s?t.text.placedSymbolArray:t.icon.placedSymbolArray,w=a.transform.width/a.transform.height;let T=!1;for(let s=0;sMath.abs(a.x-i.x)*s?{useVertical:!0}:(t===e.ah.vertical?i.ya.x)?{needsFlipping:!0}:null}function Et(t,i,a,s,o,r,n,l,h,c,u){const d=a/24,_=i.lineOffsetX*d,p=i.lineOffsetY*d;let m;if(i.numGlyphs>1){const e=i.glyphStartIndex+i.numGlyphs,a=i.lineStartIndex,r=i.lineStartIndex+i.lineLength,h=Tt(d,l,_,p,s,i,u,t);if(!h)return {notEnoughRoom:!0};const f=xt(h.first.point.x,h.first.point.y,n,t.getElevation).point,g=xt(h.last.point.x,h.last.point.y,n,t.getElevation).point;if(o&&!s){const t=It(i.writingMode,f,g,c);if(t)return t}m=[h.first];for(let o=i.glyphStartIndex+1;o0?n.point:function(t,e,i,a,s,o){return Pt(t,e,i,1,s,o)}(t.tileAnchorPoint,o,a,0,r,t),h=It(i.writingMode,a,l,c);if(h)return h}const a=Mt(d*l.getoffsetX(i.glyphStartIndex),_,p,s,i.segment,i.lineStartIndex,i.lineStartIndex+i.lineLength,t,u);if(!a||t.projectionCache.anyProjectionOccluded)return {notEnoughRoom:!0};m=[a];}for(const t of m)e.aj(h,t.point,t.angle);return {}}function Pt(t,e,i,a,s,o){const r=t.add(t.sub(e)._unit()),n=void 0!==s?xt(r.x,r.y,s,o.getElevation).point:St(r.x,r.y,o).point,l=i.sub(n);return i.add(l._mult(a/l.mag()))}function Ct(t,i,a){const s=i.projectionCache;if(s.projections[t])return s.projections[t];const o=new e.P(i.lineVertexArray.getx(t),i.lineVertexArray.gety(t)),r=St(o.x,o.y,i);if(r.signedDistanceFromCamera>0)return s.projections[t]=r.point,s.anyProjectionOccluded=s.anyProjectionOccluded||r.isOccluded,r.point;const n=t-a.direction;return function(t,e,i,a,s){return Pt(t,e,i,a,void 0,s)}(0===a.distanceFromAnchor?i.tileAnchorPoint:new e.P(i.lineVertexArray.getx(n),i.lineVertexArray.gety(n)),o,a.previousVertex,a.absOffsetX-a.distanceFromAnchor+1,i)}function St(t,e,i){const a=t+i.translation[0],s=e+i.translation[1];let o;return !i.pitchWithMap&&i.projection.useSpecialProjectionForSymbols?(o=i.projection.projectTileCoordinates(a,s,i.unwrappedTileID,i.getElevation),o.point.x=(.5*o.point.x+.5)*i.width,o.point.y=(.5*-o.point.y+.5)*i.height):(o=xt(a,s,i.labelPlaneMatrix,i.getElevation),o.isOccluded=!1),o}function zt(t,e,i){return t._unit()._perp()._mult(e*i)}function Dt(t,i,a,s,o,r,n,l,h){if(l.projectionCache.offsets[t])return l.projectionCache.offsets[t];const c=a.add(i);if(t+h.direction=o)return l.projectionCache.offsets[t]=c,c;const u=Ct(t+h.direction,l,h),d=zt(u.sub(a),n,h.direction),_=a.add(d),p=u.add(d);return l.projectionCache.offsets[t]=e.ak(r,c,_,p)||c,l.projectionCache.offsets[t]}function Mt(t,e,i,a,s,o,r,n,l){const h=a?t-e:t+e;let c=h>0?1:-1,u=0;a&&(c*=-1,u=Math.PI),c<0&&(u+=Math.PI);let d,_=c>0?o+s:o+s+1;n.projectionCache.cachedAnchorPoint?d=n.projectionCache.cachedAnchorPoint:(d=St(n.tileAnchorPoint.x,n.tileAnchorPoint.y,n).point,n.projectionCache.cachedAnchorPoint=d);let p,m,f=d,g=d,v=0,x=0;const y=Math.abs(h),b=[];let w;for(;v+x<=y;){if(_+=c,_=r)return null;v+=x,g=f,m=p;const t={absOffsetX:y,direction:c,distanceFromAnchor:v,previousVertex:g};if(f=Ct(_,n,t),0===i)b.push(g),w=f.sub(g);else {let e;const a=f.sub(g);e=0===a.mag()?zt(Ct(_+c,n,t).sub(f),i,c):zt(a,i,c),m||(m=g.add(e)),p=Dt(_,e,f,o,r,m,i,n,t),b.push(m),w=p.sub(m);}x=w.mag();}const T=w._mult((y-v)/x)._add(m||g),I=u+Math.atan2(f.y-g.y,f.x-g.x);return b.push(T),{point:T,angle:l?I:0,path:b}}const At=new Float32Array([-1/0,-1/0,0,-1/0,-1/0,0,-1/0,-1/0,0,-1/0,-1/0,0]);function Rt(t,e){for(let i=0;i=1;t--)l.push(r.path[t]);for(let t=1;tt.signedDistanceFromCamera<=0))?[]:t.map((t=>t.point));}let m=[];if(l.length>0){const t=l[0].clone(),i=l[0].clone();for(let e=1;e=a.x&&i.x<=s.x&&t.y>=a.y&&i.y<=s.y?[l]:i.xs.x||i.ys.y?[]:e.al([l],a.x,a.y,s.x,s.y);}for(const e of m){o.reset(e,.25*i);let a=0;a=o.length<=.5*i?1:Math.ceil(o.paddedLength/u)+1;for(let e=0;ext(t.x,t.y,i,e.getElevation)))}queryRenderedSymbols(t){if(0===t.length||0===this.grid.keysLength()&&0===this.ignoredGrid.keysLength())return {};const i=[];let a=1/0,s=1/0,o=-1/0,r=-1/0;for(const n of t){const t=new e.P(n.x+Lt,n.y+Lt);a=Math.min(a,t.x),s=Math.min(s,t.y),o=Math.max(o,t.x),r=Math.max(r,t.y),i.push(t);}const n=this.grid.query(a,s,o,r).concat(this.ignoredGrid.query(a,s,o,r)),l={},h={};for(const t of n){const a=t.key;if(void 0===l[a.bucketInstanceId]&&(l[a.bucketInstanceId]={}),l[a.bucketInstanceId][a.featureIndex])continue;const s=[new e.P(t.x1,t.y1),new e.P(t.x2,t.y1),new e.P(t.x2,t.y2),new e.P(t.x1,t.y2)];e.am(i,s)&&(l[a.bucketInstanceId][a.featureIndex]=!0,void 0===h[a.bucketInstanceId]&&(h[a.bucketInstanceId]=[]),h[a.bucketInstanceId].push(a.featureIndex));}return h}insertCollisionBox(t,e,i,a,s,o){(i?this.ignoredGrid:this.grid).insert({bucketInstanceId:a,featureIndex:s,collisionGroupID:o,overlapMode:e},t[0],t[1],t[2],t[3]);}insertCollisionCircles(t,e,i,a,s,o){const r=i?this.ignoredGrid:this.grid,n={bucketInstanceId:a,featureIndex:s,collisionGroupID:o,overlapMode:e};for(let e=0;e=this.screenRightBoundary||athis.screenBottomBoundary}isInsideGrid(t,e,i,a){return i>=0&&t=0&&ethis.projectAndGetPerspectiveRatio(a,t.x,t.y,s,h)));I=t.some((t=>!t.isOccluded)),T=t.map((t=>t.point));}else I=!0;return {box:e.ao(T),allPointsOccluded:!I}}}function Bt(t,i,a){return i*(e.X/(t.tileSize*Math.pow(2,a-t.tileID.overscaledZ)))}class Ot{constructor(t,e,i,a){this.opacity=t?Math.max(0,Math.min(1,t.opacity+(t.placed?e:-e))):a&&i?1:0,this.placed=i;}isHidden(){return 0===this.opacity&&!this.placed}}class Nt{constructor(t,e,i,a,s){this.text=new Ot(t?t.text:null,e,i,s),this.icon=new Ot(t?t.icon:null,e,a,s);}isHidden(){return this.text.isHidden()&&this.icon.isHidden()}}class Ut{constructor(t,e,i){this.text=t,this.icon=e,this.skipFade=i;}}class jt{constructor(){this.invProjMatrix=e.H(),this.viewportMatrix=e.H(),this.circles=[];}}class Zt{constructor(t,e,i,a,s){this.bucketInstanceId=t,this.featureIndex=e,this.sourceLayerIndex=i,this.bucketIndex=a,this.tileID=s;}}class qt{constructor(t){this.crossSourceCollisions=t,this.maxGroupID=0,this.collisionGroups={};}get(t){if(this.crossSourceCollisions)return {ID:0,predicate:null};if(!this.collisionGroups[t]){const e=++this.maxGroupID;this.collisionGroups[t]={ID:e,predicate:t=>t.collisionGroupID===e};}return this.collisionGroups[t]}}function Vt(t,i,a,s,o){const{horizontalAlign:r,verticalAlign:n}=e.au(t);return new e.P(-(r-.5)*i+s[0]*o,-(n-.5)*a+s[1]*o)}class Gt{constructor(t,e,i,a,s,o){this.transform=t.clone(),this.terrain=i,this.collisionIndex=new Ft(this.transform,e),this.placements={},this.opacities={},this.variableOffsets={},this.stale=!1,this.commitTime=0,this.fadeDuration=a,this.retainedQueryData={},this.collisionGroups=new qt(s),this.collisionCircleArrays={},this.collisionBoxArrays=new Map,this.prevPlacement=o,o&&(o.prevPlacement=void 0),this.placedOrientations={};}_getTerrainElevationFunc(t){const e=this.terrain;return e?(i,a)=>e.getElevation(t,i,a):null}getBucketParts(t,i,a,s){const o=a.getBucket(i),r=a.latestFeatureIndex;if(!o||!r||i.id!==o.layerIds[0])return;const n=a.collisionBoxArray,l=o.layers[0].layout,h=o.layers[0].paint,c=Math.pow(2,this.transform.zoom-a.tileID.overscaledZ),u=a.tileSize/e.X,d=a.tileID.toUnwrapped(),_=this.transform.calculatePosMatrix(d),p="map"===l.get("text-pitch-alignment"),m="map"===l.get("text-rotation-alignment"),f=Bt(a,1,this.transform.zoom),g=this.collisionIndex.mapProjection.translatePosition(this.transform,a,h.get("text-translate"),h.get("text-translate-anchor")),v=this.collisionIndex.mapProjection.translatePosition(this.transform,a,h.get("icon-translate"),h.get("icon-translate-anchor")),x=gt(_,p,m,this.transform,f);let y=null;if(p){const t=vt(_,p,m,this.transform,f);y=e.L([],this.transform.labelPlaneMatrix,t);}this.retainedQueryData[o.bucketInstanceId]=new Zt(o.bucketInstanceId,r,o.sourceLayerIndex,o.index,a.tileID);const b={bucket:o,layout:l,translationText:g,translationIcon:v,posMatrix:_,unwrappedTileID:d,textLabelPlaneMatrix:x,labelToScreenMatrix:y,scale:c,textPixelRatio:u,holdingForFade:a.holdingForFade(),collisionBoxArray:n,partiallyEvaluatedTextSize:e.ag(o.textSizeData,this.transform.zoom),collisionGroup:this.collisionGroups.get(o.sourceID)};if(s)for(const e of o.sortKeyRanges){const{sortKey:i,symbolInstanceStart:a,symbolInstanceEnd:s}=e;t.push({sortKey:i,symbolInstanceStart:a,symbolInstanceEnd:s,parameters:b});}else t.push({symbolInstanceStart:0,symbolInstanceEnd:o.symbolInstances.length,parameters:b});}attemptAnchorPlacement(t,i,a,s,o,r,n,l,h,c,u,d,_,p,m,f,g,v,x){const y=e.aq[t.textAnchor],b=[t.textOffset0,t.textOffset1],w=Vt(y,a,s,b,o),T=this.collisionIndex.placeCollisionBox(i,d,l,h,c,n,r,f,u.predicate,x,w);if((!v||this.collisionIndex.placeCollisionBox(v,d,l,h,c,n,r,g,u.predicate,x,w).placeable)&&T.placeable){let t;if(this.prevPlacement&&this.prevPlacement.variableOffsets[_.crossTileID]&&this.prevPlacement.placements[_.crossTileID]&&this.prevPlacement.placements[_.crossTileID].text&&(t=this.prevPlacement.variableOffsets[_.crossTileID].anchor),0===_.crossTileID)throw new Error("symbolInstance.crossTileID can't be 0");return this.variableOffsets[_.crossTileID]={textOffset:b,width:a,height:s,anchor:y,textBoxScale:o,prevAnchor:t},this.markUsedJustification(p,y,_,m),p.allowVerticalPlacement&&(this.markUsedOrientation(p,m,_),this.placedOrientations[_.crossTileID]=m),{shift:w,placedGlyphBoxes:T}}}placeLayerBucketPart(t,i,a){const{bucket:s,layout:o,translationText:r,translationIcon:n,posMatrix:l,unwrappedTileID:h,textLabelPlaneMatrix:c,labelToScreenMatrix:u,textPixelRatio:d,holdingForFade:_,collisionBoxArray:p,partiallyEvaluatedTextSize:m,collisionGroup:f}=t.parameters,g=o.get("text-optional"),v=o.get("icon-optional"),x=e.ar(o,"text-overlap","text-allow-overlap"),y="always"===x,b=e.ar(o,"icon-overlap","icon-allow-overlap"),w="always"===b,T="map"===o.get("text-rotation-alignment"),I="map"===o.get("text-pitch-alignment"),E="none"!==o.get("icon-text-fit"),P="viewport-y"===o.get("symbol-z-order"),C=y&&(w||!s.hasIconData()||v),S=w&&(y||!s.hasTextData()||g);!s.collisionArrays&&p&&s.deserializeCollisionBoxes(p);const z=this._getTerrainElevationFunc(this.retainedQueryData[s.bucketInstanceId].tileID),D=(t,p,w)=>{var P,D;if(i[t.crossTileID])return;if(_)return void(this.placements[t.crossTileID]=new Ut(!1,!1,!1));let M=!1,A=!1,R=!0,k=null,L={box:null,placeable:!1,offscreen:null},F={box:null,placeable:!1,offscreen:null},B=null,O=null,N=null,U=0,j=0,Z=0;p.textFeatureIndex?U=p.textFeatureIndex:t.useRuntimeCollisionCircles&&(U=t.featureIndex),p.verticalTextFeatureIndex&&(j=p.verticalTextFeatureIndex);const q=p.textBox;if(q){const i=i=>{let a=e.ah.horizontal;if(s.allowVerticalPlacement&&!i&&this.prevPlacement){const e=this.prevPlacement.placedOrientations[t.crossTileID];e&&(this.placedOrientations[t.crossTileID]=e,a=e,this.markUsedOrientation(s,a,t));}return a},o=(i,a)=>{if(s.allowVerticalPlacement&&t.numVerticalGlyphVertices>0&&p.verticalTextBox){for(const t of s.writingModes)if(t===e.ah.vertical?(L=a(),F=L):L=i(),L&&L.placeable)break}else L=i();},c=t.textAnchorOffsetStartIndex,u=t.textAnchorOffsetEndIndex;if(u===c){const a=(e,i)=>{const a=this.collisionIndex.placeCollisionBox(e,x,d,l,h,I,T,r,f.predicate,z);return a&&a.placeable&&(this.markUsedOrientation(s,i,t),this.placedOrientations[t.crossTileID]=i),a};o((()=>a(q,e.ah.horizontal)),(()=>{const i=p.verticalTextBox;return s.allowVerticalPlacement&&t.numVerticalGlyphVertices>0&&i?a(i,e.ah.vertical):{box:null,offscreen:null}})),i(L&&L.placeable);}else {let _=e.aq[null===(D=null===(P=this.prevPlacement)||void 0===P?void 0:P.variableOffsets[t.crossTileID])||void 0===D?void 0:D.anchor];const m=(i,o,p)=>{const m=i.x2-i.x1,g=i.y2-i.y1,v=t.textBoxScale,y=E&&"never"===b?o:null;let w=null,P="never"===x?1:2,C="never";_&&P++;for(let e=0;em(q,p.iconBox,e.ah.horizontal)),(()=>{const i=p.verticalTextBox;return s.allowVerticalPlacement&&(!L||!L.placeable)&&t.numVerticalGlyphVertices>0&&i?m(i,p.verticalIconBox,e.ah.vertical):{box:null,occluded:!0,offscreen:null}})),L&&(M=L.placeable,R=L.offscreen);const g=i(L&&L.placeable);if(!M&&this.prevPlacement){const e=this.prevPlacement.variableOffsets[t.crossTileID];e&&(this.variableOffsets[t.crossTileID]=e,this.markUsedJustification(s,e.anchor,t,g));}}}if(B=L,M=B&&B.placeable,R=B&&B.offscreen,t.useRuntimeCollisionCircles){const i=s.text.placedSymbolArray.get(t.centerJustifiedTextSymbolIndex),n=e.ai(s.textSizeData,m,i),d=o.get("text-padding");O=this.collisionIndex.placeCollisionCircles(x,i,s.lineVertexArray,s.glyphOffsetArray,n,l,h,c,u,a,I,f.predicate,t.collisionCircleDiameter,d,r,z),O.circles.length&&O.collisionDetected&&!a&&e.w("Collisions detected, but collision boxes are not shown"),M=y||O.circles.length>0&&!O.collisionDetected,R=R&&O.offscreen;}if(p.iconFeatureIndex&&(Z=p.iconFeatureIndex),p.iconBox){const t=t=>this.collisionIndex.placeCollisionBox(t,b,d,l,h,I,T,n,f.predicate,z,E&&k?k:void 0);F&&F.placeable&&p.verticalIconBox?(N=t(p.verticalIconBox),A=N.placeable):(N=t(p.iconBox),A=N.placeable),R=R&&N.offscreen;}const V=g||0===t.numHorizontalGlyphVertices&&0===t.numVerticalGlyphVertices,G=v||0===t.numIconVertices;V||G?G?V||(A=A&&M):M=A&&M:A=M=A&&M;const H=A&&N.placeable;if(M&&B.placeable&&this.collisionIndex.insertCollisionBox(B.box,x,o.get("text-ignore-placement"),s.bucketInstanceId,F&&F.placeable&&j?j:U,f.ID),H&&this.collisionIndex.insertCollisionBox(N.box,b,o.get("icon-ignore-placement"),s.bucketInstanceId,Z,f.ID),O&&M&&this.collisionIndex.insertCollisionCircles(O.circles,x,o.get("text-ignore-placement"),s.bucketInstanceId,U,f.ID),a&&this.storeCollisionData(s.bucketInstanceId,w,p,B,N,O),0===t.crossTileID)throw new Error("symbolInstance.crossTileID can't be 0");if(0===s.bucketInstanceId)throw new Error("bucket.bucketInstanceId can't be 0");this.placements[t.crossTileID]=new Ut(M||C,A||S,R||s.justReloaded),i[t.crossTileID]=!0;};if(P){if(0!==t.symbolInstanceStart)throw new Error("bucket.bucketInstanceId should be 0");const e=s.getSortedSymbolIndexes(this.transform.angle);for(let t=e.length-1;t>=0;--t){const i=e[t];D(s.symbolInstances.get(i),s.collisionArrays[i],i);}}else for(let e=t.symbolInstanceStart;e=0&&(t.text.placedSymbolArray.get(e).crossTileID=o>=0&&e!==o?0:a.crossTileID);}markUsedOrientation(t,i,a){const s=i===e.ah.horizontal||i===e.ah.horizontalOnly?i:0,o=i===e.ah.vertical?i:0,r=[a.leftJustifiedTextSymbolIndex,a.centerJustifiedTextSymbolIndex,a.rightJustifiedTextSymbolIndex];for(const e of r)t.text.placedSymbolArray.get(e).placedOrientation=s;a.verticalPlacedTextSymbolIndex&&(t.text.placedSymbolArray.get(a.verticalPlacedTextSymbolIndex).placedOrientation=o);}commit(t){this.commitTime=t,this.zoomAtLastRecencyCheck=this.transform.zoom;const e=this.prevPlacement;let i=!1;this.prevZoomAdjustment=e?e.zoomAdjustment(this.transform.zoom):0;const a=e?e.symbolFadeChange(t):1,s=e?e.opacities:{},o=e?e.variableOffsets:{},r=e?e.placedOrientations:{};for(const t in this.placements){const e=this.placements[t],o=s[t];o?(this.opacities[t]=new Nt(o,a,e.text,e.icon),i=i||e.text!==o.text.placed||e.icon!==o.icon.placed):(this.opacities[t]=new Nt(null,a,e.text,e.icon,e.skipFade),i=i||e.text||e.icon);}for(const t in s){const e=s[t];if(!this.opacities[t]){const s=new Nt(e,a,!1,!1);s.isHidden()||(this.opacities[t]=s,i=i||e.text.placed||e.icon.placed);}}for(const t in o)this.variableOffsets[t]||!this.opacities[t]||this.opacities[t].isHidden()||(this.variableOffsets[t]=o[t]);for(const t in r)this.placedOrientations[t]||!this.opacities[t]||this.opacities[t].isHidden()||(this.placedOrientations[t]=r[t]);if(e&&void 0===e.lastPlacementChangeTime)throw new Error("Last placement time for previous placement is not defined");i?this.lastPlacementChangeTime=t:"number"!=typeof this.lastPlacementChangeTime&&(this.lastPlacementChangeTime=e?e.lastPlacementChangeTime:t);}updateLayerOpacities(t,e){const i={};for(const a of e){const e=a.getBucket(t);e&&a.latestFeatureIndex&&t.id===e.layerIds[0]&&this.updateBucketOpacities(e,a.tileID,i,a.collisionBoxArray);}}updateBucketOpacities(t,i,a,s){t.hasTextData()&&(t.text.opacityVertexArray.clear(),t.text.hasVisibleVertices=!1),t.hasIconData()&&(t.icon.opacityVertexArray.clear(),t.icon.hasVisibleVertices=!1),t.hasIconCollisionBoxData()&&t.iconCollisionBox.collisionVertexArray.clear(),t.hasTextCollisionBoxData()&&t.textCollisionBox.collisionVertexArray.clear();const o=t.layers[0],r=o.layout,n=new Nt(null,0,!1,!1,!0),l=r.get("text-allow-overlap"),h=r.get("icon-allow-overlap"),c=o._unevaluatedLayout.hasValue("text-variable-anchor")||o._unevaluatedLayout.hasValue("text-variable-anchor-offset"),u="map"===r.get("text-rotation-alignment"),d="map"===r.get("text-pitch-alignment"),_="none"!==r.get("icon-text-fit"),p=new Nt(null,0,l&&(h||!t.hasIconData()||r.get("icon-optional")),h&&(l||!t.hasTextData()||r.get("text-optional")),!0);!t.collisionArrays&&s&&(t.hasIconCollisionBoxData()||t.hasTextCollisionBoxData())&&t.deserializeCollisionBoxes(s);const m=(t,e,i)=>{for(let a=0;a0,v=this.placedOrientations[s.crossTileID],x=v===e.ah.vertical,y=v===e.ah.horizontal||v===e.ah.horizontalOnly;if(o>0||r>0){const e=te(h.text);m(t.text,o,x?ee:e),m(t.text,r,y?ee:e);const i=h.text.isHidden();[s.rightJustifiedTextSymbolIndex,s.centerJustifiedTextSymbolIndex,s.leftJustifiedTextSymbolIndex].forEach((e=>{e>=0&&(t.text.placedSymbolArray.get(e).hidden=i||x?1:0);})),s.verticalPlacedTextSymbolIndex>=0&&(t.text.placedSymbolArray.get(s.verticalPlacedTextSymbolIndex).hidden=i||y?1:0);const a=this.variableOffsets[s.crossTileID];a&&this.markUsedJustification(t,a.anchor,s,v);const n=this.placedOrientations[s.crossTileID];n&&(this.markUsedJustification(t,"left",s,n),this.markUsedOrientation(t,n,s));}if(g){const e=te(h.icon),i=!(_&&s.verticalPlacedIconSymbolIndex&&x);s.placedIconSymbolIndex>=0&&(m(t.icon,s.numIconVertices,i?e:ee),t.icon.placedSymbolArray.get(s.placedIconSymbolIndex).hidden=h.icon.isHidden()),s.verticalPlacedIconSymbolIndex>=0&&(m(t.icon,s.numVerticalIconVertices,i?ee:e),t.icon.placedSymbolArray.get(s.verticalPlacedIconSymbolIndex).hidden=h.icon.isHidden());}const b=f&&f.has(i)?f.get(i):{text:null,icon:null};if(t.hasIconCollisionBoxData()||t.hasTextCollisionBoxData()){const a=t.collisionArrays[i];if(a){let i=new e.P(0,0);if(a.textBox||a.verticalTextBox){let e=!0;if(c){const t=this.variableOffsets[l];t?(i=Vt(t.anchor,t.width,t.height,t.textOffset,t.textBoxScale),u&&i._rotate(d?this.transform.angle:-this.transform.angle)):e=!1;}if(a.textBox||a.verticalTextBox){let s;a.textBox&&(s=x),a.verticalTextBox&&(s=y),Ht(t.textCollisionBox.collisionVertexArray,h.text.placed,!e||s,b.text,i.x,i.y);}}if(a.iconBox||a.verticalIconBox){const e=Boolean(!y&&a.verticalIconBox);let s;a.iconBox&&(s=e),a.verticalIconBox&&(s=!e),Ht(t.iconCollisionBox.collisionVertexArray,h.icon.placed,s,b.icon,_?i.x:0,_?i.y:0);}}}}if(t.sortFeatures(this.transform.angle),this.retainedQueryData[t.bucketInstanceId]&&(this.retainedQueryData[t.bucketInstanceId].featureSortOrder=t.featureSortOrder),t.hasTextData()&&t.text.opacityVertexBuffer&&t.text.opacityVertexBuffer.updateData(t.text.opacityVertexArray),t.hasIconData()&&t.icon.opacityVertexBuffer&&t.icon.opacityVertexBuffer.updateData(t.icon.opacityVertexArray),t.hasIconCollisionBoxData()&&t.iconCollisionBox.collisionVertexBuffer&&t.iconCollisionBox.collisionVertexBuffer.updateData(t.iconCollisionBox.collisionVertexArray),t.hasTextCollisionBoxData()&&t.textCollisionBox.collisionVertexBuffer&&t.textCollisionBox.collisionVertexBuffer.updateData(t.textCollisionBox.collisionVertexArray),t.text.opacityVertexArray.length!==t.text.layoutVertexArray.length/4)throw new Error(`bucket.text.opacityVertexArray.length (= ${t.text.opacityVertexArray.length}) !== bucket.text.layoutVertexArray.length (= ${t.text.layoutVertexArray.length}) / 4`);if(t.icon.opacityVertexArray.length!==t.icon.layoutVertexArray.length/4)throw new Error(`bucket.icon.opacityVertexArray.length (= ${t.icon.opacityVertexArray.length}) !== bucket.icon.layoutVertexArray.length (= ${t.icon.layoutVertexArray.length}) / 4`);if(t.bucketInstanceId in this.collisionCircleArrays){const e=this.collisionCircleArrays[t.bucketInstanceId];t.placementInvProjMatrix=e.invProjMatrix,t.placementViewportMatrix=e.viewportMatrix,t.collisionCircleArray=e.circles,delete this.collisionCircleArrays[t.bucketInstanceId];}}symbolFadeChange(t){return 0===this.fadeDuration?1:(t-this.commitTime)/this.fadeDuration+this.prevZoomAdjustment}zoomAdjustment(t){return Math.max(0,(this.transform.zoom-t)/1.5)}hasTransitions(t){return this.stale||t-this.lastPlacementChangeTimet}setStale(){this.stale=!0;}}function Ht(t,e,i,a,s,o){a&&0!==a.length||(a=[0,0,0,0]);const r=a[0]-Lt,n=a[1]-Lt,l=a[2]-Lt,h=a[3]-Lt;t.emplaceBack(e?1:0,i?1:0,s||0,o||0,r,n),t.emplaceBack(e?1:0,i?1:0,s||0,o||0,l,n),t.emplaceBack(e?1:0,i?1:0,s||0,o||0,l,h),t.emplaceBack(e?1:0,i?1:0,s||0,o||0,r,h);}const Wt=Math.pow(2,25),$t=Math.pow(2,24),Xt=Math.pow(2,17),Kt=Math.pow(2,16),Jt=Math.pow(2,9),Yt=Math.pow(2,8),Qt=Math.pow(2,1);function te(t){if(0===t.opacity&&!t.placed)return 0;if(1===t.opacity&&t.placed)return 4294967295;const e=t.placed?1:0,i=Math.floor(127*t.opacity);return i*Wt+e*$t+i*Xt+e*Kt+i*Jt+e*Yt+i*Qt+e}const ee=0;function ie(){return {isOccluded:(t,e,i)=>!1,getPitchedTextCorrection:(t,e,i)=>1,get useSpecialProjectionForSymbols(){return !1},projectTileCoordinates(t,e,i,a){throw new Error("Not implemented.")},translatePosition:(t,e,i,a)=>function(t,e,i,a,s=!1){if(!i[0]&&!i[1])return [0,0];const o=s?"map"===a?t.angle:0:"viewport"===a?-t.angle:0;if(o){const t=Math.sin(o),e=Math.cos(o);i=[i[0]*e-i[1]*t,i[0]*t+i[1]*e];}return [s?i[0]:Bt(e,i[0],t.zoom),s?i[1]:Bt(e,i[1],t.zoom)]}(t,e,i,a),getCircleRadiusCorrection:t=>1}}class ae{constructor(t){this._sortAcrossTiles="viewport-y"!==t.layout.get("symbol-z-order")&&!t.layout.get("symbol-sort-key").isConstant(),this._currentTileIndex=0,this._currentPartIndex=0,this._seenCrossTileIDs={},this._bucketParts=[];}continuePlacement(t,e,i,a,s){const o=this._bucketParts;for(;this._currentTileIndext.sortKey-e.sortKey)));this._currentPartIndex!this._forceFullPlacement&&o.now()-a>2;for(;this._currentPlacementIndex>=0;){const a=e[t[this._currentPlacementIndex]],o=this.placement.collisionIndex.transform.zoom;if("symbol"===a.type&&(!a.minzoom||a.minzoom<=o)&&(!a.maxzoom||a.maxzoom>o)){if(this._inProgressLayer||(this._inProgressLayer=new ae(a)),this._inProgressLayer.continuePlacement(i[a.source],this.placement,this._showCollisionBoxes,a,s))return;delete this._inProgressLayer;}this._currentPlacementIndex--;}this._done=!0;}commit(t){return this.placement.commit(t),this.placement}}const oe=512/e.X/2;class re{constructor(t,i,a){this.tileID=t,this.bucketInstanceId=a,this._symbolsByKey={};const s=new Map;for(let t=0;t({x:Math.floor(t.anchorX*oe),y:Math.floor(t.anchorY*oe)}))),crossTileIDs:i.map((t=>t.crossTileID))};if(a.positions.length>128){const t=new e.av(a.positions.length,16,Uint16Array);for(const{x:e,y:i}of a.positions)t.add(e,i);t.finish(),delete a.positions,a.index=t;}this._symbolsByKey[t]=a;}}getScaledCoordinates(t,i){const{x:a,y:s,z:o}=this.tileID.canonical,{x:r,y:n,z:l}=i.canonical,h=oe/Math.pow(2,l-o),c=(n*e.X+t.anchorY)*h,u=s*e.X*oe;return {x:Math.floor((r*e.X+t.anchorX)*h-a*e.X*oe),y:Math.floor(c-u)}}findMatches(t,e,i){const a=this.tileID.canonical.zt))}}class ne{constructor(){this.maxCrossTileID=0;}generate(){return ++this.maxCrossTileID}}class le{constructor(){this.indexes={},this.usedCrossTileIDs={},this.lng=0;}handleWrapJump(t){const e=Math.round((t-this.lng)/360);if(0!==e)for(const t in this.indexes){const i=this.indexes[t],a={};for(const t in i){const s=i[t];s.tileID=s.tileID.unwrapTo(s.tileID.wrap+e),a[s.tileID.key]=s;}this.indexes[t]=a;}this.lng=t;}addBucket(t,e,i){if(this.indexes[t.overscaledZ]&&this.indexes[t.overscaledZ][t.key]){if(this.indexes[t.overscaledZ][t.key].bucketInstanceId===e.bucketInstanceId)return !1;this.removeBucketCrossTileIDs(t.overscaledZ,this.indexes[t.overscaledZ][t.key]);}for(let t=0;tt.overscaledZ)for(const i in s){const o=s[i];o.tileID.isChildOf(t)&&o.findMatches(e.symbolInstances,t,a);}else {const o=s[t.scaledTo(Number(i)).key];o&&o.findMatches(e.symbolInstances,t,a);}}for(let t=0;t{e[t]=!0;}));for(const t in this.layerIndexes)e[t]||delete this.layerIndexes[t];}}const ce=(t,i)=>e.t(t,i&&i.filter((t=>"source.canvas"!==t.identifier))),ue=e.aw();class de extends e.E{constructor(t,i={}){super(),this._rtlPluginLoaded=()=>{for(const t in this.sourceCaches){const e=this.sourceCaches[t].getSource().type;"vector"!==e&&"geojson"!==e||this.sourceCaches[t].reload();}},this.map=t,this.dispatcher=new U(N(),t._getMapId()),this.dispatcher.registerMessageHandler("GG",((t,e)=>this.getGlyphs(t,e))),this.dispatcher.registerMessageHandler("GI",((t,e)=>this.getImages(t,e))),this.imageManager=new T,this.imageManager.setEventedParent(this),this.glyphManager=new C(t._requestManager,i.localIdeographFontFamily),this.lineAtlas=new R(256,512),this.crossTileSymbolIndex=new he,this._spritesImagesIds={},this._layers={},this._order=[],this.sourceCaches={},this.zoomHistory=new e.ax,this._loaded=!1,this._availableImages=[],this._resetUpdates(),this.dispatcher.broadcast("SR",e.ay()),nt().on(st,this._rtlPluginLoaded),this.on("data",(t=>{if("source"!==t.dataType||"metadata"!==t.sourceDataType)return;const e=this.sourceCaches[t.sourceId];if(!e)return;const i=e.getSource();if(i&&i.vectorLayerIds)for(const t in this._layers){const e=this._layers[t];e.source===i.id&&this._validateLayer(e);}}));}loadURL(t,i={},a){this.fire(new e.k("dataloading",{dataType:"style"})),i.validate="boolean"!=typeof i.validate||i.validate;const s=this.map._requestManager.transformRequest(t,"Style");this._loadStyleRequest=new AbortController;const o=this._loadStyleRequest;e.h(s,this._loadStyleRequest).then((t=>{this._loadStyleRequest=null,this._load(t.data,i,a);})).catch((t=>{this._loadStyleRequest=null,t&&!o.signal.aborted&&this.fire(new e.j(t));}));}loadJSON(t,i={},a){this.fire(new e.k("dataloading",{dataType:"style"})),this._frameRequest=new AbortController,o.frameAsync(this._frameRequest).then((()=>{this._frameRequest=null,i.validate=!1!==i.validate,this._load(t,i,a);})).catch((()=>{}));}loadEmpty(){this.fire(new e.k("dataloading",{dataType:"style"})),this._load(ue,{validate:!1});}_load(t,i,a){var s;const o=i.transformStyle?i.transformStyle(a,t):t;if(!i.validate||!ce(this,e.u(o))){this._loaded=!0,this.stylesheet=o;for(const t in o.sources)this.addSource(t,o.sources[t],{validate:!1});o.sprite?this._loadSprite(o.sprite):this.imageManager.setLoaded(!0),this.glyphManager.setURL(o.glyphs),this._createLayers(),this.light=new D(this.stylesheet.light),this.sky=new A(this.stylesheet.sky),this.map.setTerrain(null!==(s=this.stylesheet.terrain)&&void 0!==s?s:null),this.fire(new e.k("data",{dataType:"style"})),this.fire(new e.k("style.load"));}}_createLayers(){const t=e.az(this.stylesheet.layers);this.dispatcher.broadcast("SL",t),this._order=t.map((t=>t.id)),this._layers={},this._serializedLayers=null;for(const i of t){const t=e.aA(i);t.setEventedParent(this,{layer:{id:i.id}}),this._layers[i.id]=t;}}_loadSprite(t,i=!1,a=void 0){let s;this.imageManager.setLoaded(!1),this._spriteRequest=new AbortController,function(t,i,a,s){return e._(this,void 0,void 0,(function*(){const r=x(t),n=a>1?"@2x":"",l={},h={};for(const{id:t,url:a}of r){const o=i.transformRequest(y(a,n,".json"),"SpriteJSON");l[t]=e.h(o,s);const r=i.transformRequest(y(a,n,".png"),"SpriteImage");h[t]=_.getImage(r,s);}return yield Promise.all([...Object.values(l),...Object.values(h)]),function(t,i){return e._(this,void 0,void 0,(function*(){const e={};for(const a in t){e[a]={};const s=o.getImageCanvasContext((yield i[a]).data),r=(yield t[a]).data;for(const t in r){const{width:i,height:o,x:n,y:l,sdf:h,pixelRatio:c,stretchX:u,stretchY:d,content:_,textFitWidth:p,textFitHeight:m}=r[t];e[a][t]={data:null,pixelRatio:c,sdf:h,stretchX:u,stretchY:d,content:_,textFitWidth:p,textFitHeight:m,spriteData:{width:i,height:o,x:n,y:l,context:s}};}}return e}))}(l,h)}))}(t,this.map._requestManager,this.map.getPixelRatio(),this._spriteRequest).then((t=>{if(this._spriteRequest=null,t)for(const e in t){this._spritesImagesIds[e]=[];const a=this._spritesImagesIds[e]?this._spritesImagesIds[e].filter((e=>!(e in t))):[];for(const t of a)this.imageManager.removeImage(t),this._changedImages[t]=!0;for(const a in t[e]){const s="default"===e?a:`${e}:${a}`;this._spritesImagesIds[e].push(s),s in this.imageManager.images?this.imageManager.updateImage(s,t[e][a],!1):this.imageManager.addImage(s,t[e][a]),i&&(this._changedImages[s]=!0);}}})).catch((t=>{this._spriteRequest=null,s=t,this.fire(new e.j(s));})).finally((()=>{this.imageManager.setLoaded(!0),this._availableImages=this.imageManager.listImages(),i&&(this._changed=!0),this.dispatcher.broadcast("SI",this._availableImages),this.fire(new e.k("data",{dataType:"style"})),a&&a(s);}));}_unloadSprite(){for(const t of Object.values(this._spritesImagesIds).flat())this.imageManager.removeImage(t),this._changedImages[t]=!0;this._spritesImagesIds={},this._availableImages=this.imageManager.listImages(),this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new e.k("data",{dataType:"style"}));}_validateLayer(t){const i=this.sourceCaches[t.source];if(!i)return;const a=t.sourceLayer;if(!a)return;const s=i.getSource();("geojson"===s.type||s.vectorLayerIds&&-1===s.vectorLayerIds.indexOf(a))&&this.fire(new e.j(new Error(`Source layer "${a}" does not exist on source "${s.id}" as specified by style layer "${t.id}".`)));}loaded(){if(!this._loaded)return !1;if(Object.keys(this._updatedSources).length)return !1;for(const t in this.sourceCaches)if(!this.sourceCaches[t].loaded())return !1;return !!this.imageManager.isLoaded()}_serializeByIds(t,i=!1){const a=this._serializedAllLayers();if(!t||0===t.length)return Object.values(i?e.aB(a):a);const s=[];for(const o of t)if(a[o]){const t=i?e.aB(a[o]):a[o];s.push(t);}return s}_serializedAllLayers(){let t=this._serializedLayers;if(t)return t;t=this._serializedLayers={};const e=Object.keys(this._layers);for(const i of e){const e=this._layers[i];"custom"!==e.type&&(t[i]=e.serialize());}return t}hasTransitions(){if(this.light&&this.light.hasTransition())return !0;if(this.sky&&this.sky.hasTransition())return !0;for(const t in this.sourceCaches)if(this.sourceCaches[t].hasTransition())return !0;for(const t in this._layers)if(this._layers[t].hasTransition())return !0;return !1}_checkLoaded(){if(!this._loaded)throw new Error("Style is not done loading.")}update(t){if(!this._loaded)return;const i=this._changed;if(i){const e=Object.keys(this._updatedLayers),i=Object.keys(this._removedLayers);(e.length||i.length)&&this._updateWorkerLayers(e,i);for(const t in this._updatedSources){const e=this._updatedSources[t];if("reload"===e)this._reloadSource(t);else {if("clear"!==e)throw new Error(`Invalid action ${e}`);this._clearSource(t);}}this._updateTilesForChangedImages(),this._updateTilesForChangedGlyphs();for(const e in this._updatedPaintProps)this._layers[e].updateTransitions(t);this.light.updateTransitions(t),this.sky.updateTransitions(t),this._resetUpdates();}const a={};for(const t in this.sourceCaches){const e=this.sourceCaches[t];a[t]=e.used,e.used=!1;}for(const e of this._order){const i=this._layers[e];i.recalculate(t,this._availableImages),!i.isHidden(t.zoom)&&i.source&&(this.sourceCaches[i.source].used=!0);}for(const t in a){const i=this.sourceCaches[t];!!a[t]!=!!i.used&&i.fire(new e.k("data",{sourceDataType:"visibility",dataType:"source",sourceId:t}));}this.light.recalculate(t),this.sky.recalculate(t),this.z=t.zoom,i&&this.fire(new e.k("data",{dataType:"style"}));}_updateTilesForChangedImages(){const t=Object.keys(this._changedImages);if(t.length){for(const e in this.sourceCaches)this.sourceCaches[e].reloadTilesForDependencies(["icons","patterns"],t);this._changedImages={};}}_updateTilesForChangedGlyphs(){if(this._glyphsDidChange){for(const t in this.sourceCaches)this.sourceCaches[t].reloadTilesForDependencies(["glyphs"],[""]);this._glyphsDidChange=!1;}}_updateWorkerLayers(t,e){this.dispatcher.broadcast("UL",{layers:this._serializeByIds(t,!1),removedIds:e});}_resetUpdates(){this._changed=!1,this._updatedLayers={},this._removedLayers={},this._updatedSources={},this._updatedPaintProps={},this._changedImages={},this._glyphsDidChange=!1;}setState(t,i={}){var a;this._checkLoaded();const s=this.serialize();if(t=i.transformStyle?i.transformStyle(s,t):t,(null===(a=i.validate)||void 0===a||a)&&ce(this,e.u(t)))return !1;(t=e.aB(t)).layers=e.az(t.layers);const o=e.aC(s,t),r=this._getOperationsToPerform(o);if(r.unimplemented.length>0)throw new Error(`Unimplemented: ${r.unimplemented.join(", ")}.`);if(0===r.operations.length)return !1;for(const t of r.operations)t();return this.stylesheet=t,this._serializedLayers=null,!0}_getOperationsToPerform(t){const e=[],i=[];for(const a of t)switch(a.command){case"setCenter":case"setZoom":case"setBearing":case"setPitch":continue;case"addLayer":e.push((()=>this.addLayer.apply(this,a.args)));break;case"removeLayer":e.push((()=>this.removeLayer.apply(this,a.args)));break;case"setPaintProperty":e.push((()=>this.setPaintProperty.apply(this,a.args)));break;case"setLayoutProperty":e.push((()=>this.setLayoutProperty.apply(this,a.args)));break;case"setFilter":e.push((()=>this.setFilter.apply(this,a.args)));break;case"addSource":e.push((()=>this.addSource.apply(this,a.args)));break;case"removeSource":e.push((()=>this.removeSource.apply(this,a.args)));break;case"setLayerZoomRange":e.push((()=>this.setLayerZoomRange.apply(this,a.args)));break;case"setLight":e.push((()=>this.setLight.apply(this,a.args)));break;case"setGeoJSONSourceData":e.push((()=>this.setGeoJSONSourceData.apply(this,a.args)));break;case"setGlyphs":e.push((()=>this.setGlyphs.apply(this,a.args)));break;case"setSprite":e.push((()=>this.setSprite.apply(this,a.args)));break;case"setSky":e.push((()=>this.setSky.apply(this,a.args)));break;case"setTerrain":e.push((()=>this.map.setTerrain.apply(this,a.args)));break;case"setTransition":e.push((()=>{}));break;default:i.push(a.command);}return {operations:e,unimplemented:i}}addImage(t,i){if(this.getImage(t))return this.fire(new e.j(new Error(`An image named "${t}" already exists.`)));this.imageManager.addImage(t,i),this._afterImageUpdated(t);}updateImage(t,e){this.imageManager.updateImage(t,e);}getImage(t){return this.imageManager.getImage(t)}removeImage(t){if(!this.getImage(t))return this.fire(new e.j(new Error(`An image named "${t}" does not exist.`)));this.imageManager.removeImage(t),this._afterImageUpdated(t);}_afterImageUpdated(t){this._availableImages=this.imageManager.listImages(),this._changedImages[t]=!0,this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new e.k("data",{dataType:"style"}));}listImages(){return this._checkLoaded(),this.imageManager.listImages()}addSource(t,i,a={}){if(this._checkLoaded(),void 0!==this.sourceCaches[t])throw new Error(`Source "${t}" already exists.`);if(!i.type)throw new Error(`The type property must be defined, but only the following properties were given: ${Object.keys(i).join(", ")}.`);if(["vector","raster","geojson","video","image"].indexOf(i.type)>=0&&this._validate(e.u.source,`sources.${t}`,i,null,a))return;this.map&&this.map._collectResourceTiming&&(i.collectResourceTiming=!0);const s=this.sourceCaches[t]=new ut(t,i,this.dispatcher);s.style=this,s.setEventedParent(this,(()=>({isSourceLoaded:s.loaded(),source:s.serialize(),sourceId:t}))),s.onAdd(this.map),this._changed=!0;}removeSource(t){if(this._checkLoaded(),void 0===this.sourceCaches[t])throw new Error("There is no source with this ID");for(const i in this._layers)if(this._layers[i].source===t)return this.fire(new e.j(new Error(`Source "${t}" cannot be removed while layer "${i}" is using it.`)));const i=this.sourceCaches[t];delete this.sourceCaches[t],delete this._updatedSources[t],i.fire(new e.k("data",{sourceDataType:"metadata",dataType:"source",sourceId:t})),i.setEventedParent(null),i.onRemove(this.map),this._changed=!0;}setGeoJSONSourceData(t,e){if(this._checkLoaded(),void 0===this.sourceCaches[t])throw new Error(`There is no source with this ID=${t}`);const i=this.sourceCaches[t].getSource();if("geojson"!==i.type)throw new Error(`geojsonSource.type is ${i.type}, which is !== 'geojson`);i.setData(e),this._changed=!0;}getSource(t){return this.sourceCaches[t]&&this.sourceCaches[t].getSource()}addLayer(t,i,a={}){this._checkLoaded();const s=t.id;if(this.getLayer(s))return void this.fire(new e.j(new Error(`Layer "${s}" already exists on this map.`)));let o;if("custom"===t.type){if(ce(this,e.aD(t)))return;o=e.aA(t);}else {if("source"in t&&"object"==typeof t.source&&(this.addSource(s,t.source),t=e.aB(t),t=e.e(t,{source:s})),this._validate(e.u.layer,`layers.${s}`,t,{arrayIndex:-1},a))return;o=e.aA(t),this._validateLayer(o),o.setEventedParent(this,{layer:{id:s}});}const r=i?this._order.indexOf(i):this._order.length;if(i&&-1===r)this.fire(new e.j(new Error(`Cannot add layer "${s}" before non-existing layer "${i}".`)));else {if(this._order.splice(r,0,s),this._layerOrderChanged=!0,this._layers[s]=o,this._removedLayers[s]&&o.source&&"custom"!==o.type){const t=this._removedLayers[s];delete this._removedLayers[s],t.type!==o.type?this._updatedSources[o.source]="clear":(this._updatedSources[o.source]="reload",this.sourceCaches[o.source].pause());}this._updateLayer(o),o.onAdd&&o.onAdd(this.map);}}moveLayer(t,i){if(this._checkLoaded(),this._changed=!0,!this._layers[t])return void this.fire(new e.j(new Error(`The layer '${t}' does not exist in the map's style and cannot be moved.`)));if(t===i)return;const a=this._order.indexOf(t);this._order.splice(a,1);const s=i?this._order.indexOf(i):this._order.length;i&&-1===s?this.fire(new e.j(new Error(`Cannot move layer "${t}" before non-existing layer "${i}".`))):(this._order.splice(s,0,t),this._layerOrderChanged=!0);}removeLayer(t){this._checkLoaded();const i=this._layers[t];if(!i)return void this.fire(new e.j(new Error(`Cannot remove non-existing layer "${t}".`)));i.setEventedParent(null);const a=this._order.indexOf(t);this._order.splice(a,1),this._layerOrderChanged=!0,this._changed=!0,this._removedLayers[t]=i,delete this._layers[t],this._serializedLayers&&delete this._serializedLayers[t],delete this._updatedLayers[t],delete this._updatedPaintProps[t],i.onRemove&&i.onRemove(this.map);}getLayer(t){return this._layers[t]}getLayersOrder(){return [...this._order]}hasLayer(t){return t in this._layers}setLayerZoomRange(t,i,a){this._checkLoaded();const s=this.getLayer(t);s?s.minzoom===i&&s.maxzoom===a||(null!=i&&(s.minzoom=i),null!=a&&(s.maxzoom=a),this._updateLayer(s)):this.fire(new e.j(new Error(`Cannot set the zoom range of non-existing layer "${t}".`)));}setFilter(t,i,a={}){this._checkLoaded();const s=this.getLayer(t);if(s){if(!e.aE(s.filter,i))return null==i?(s.filter=void 0,void this._updateLayer(s)):void(this._validate(e.u.filter,`layers.${s.id}.filter`,i,null,a)||(s.filter=e.aB(i),this._updateLayer(s)))}else this.fire(new e.j(new Error(`Cannot filter non-existing layer "${t}".`)));}getFilter(t){return e.aB(this.getLayer(t).filter)}setLayoutProperty(t,i,a,s={}){this._checkLoaded();const o=this.getLayer(t);o?e.aE(o.getLayoutProperty(i),a)||(o.setLayoutProperty(i,a,s),this._updateLayer(o)):this.fire(new e.j(new Error(`Cannot style non-existing layer "${t}".`)));}getLayoutProperty(t,i){const a=this.getLayer(t);if(a)return a.getLayoutProperty(i);this.fire(new e.j(new Error(`Cannot get style of non-existing layer "${t}".`)));}setPaintProperty(t,i,a,s={}){this._checkLoaded();const o=this.getLayer(t);o?e.aE(o.getPaintProperty(i),a)||(o.setPaintProperty(i,a,s)&&this._updateLayer(o),this._changed=!0,this._updatedPaintProps[t]=!0,this._serializedLayers=null):this.fire(new e.j(new Error(`Cannot style non-existing layer "${t}".`)));}getPaintProperty(t,e){return this.getLayer(t).getPaintProperty(e)}setFeatureState(t,i){this._checkLoaded();const a=t.source,s=t.sourceLayer,o=this.sourceCaches[a];if(void 0===o)return void this.fire(new e.j(new Error(`The source '${a}' does not exist in the map's style.`)));const r=o.getSource().type;"geojson"===r&&s?this.fire(new e.j(new Error("GeoJSON sources cannot have a sourceLayer parameter."))):"vector"!==r||s?(void 0===t.id&&this.fire(new e.j(new Error("The feature id parameter must be provided."))),o.setFeatureState(s,t.id,i)):this.fire(new e.j(new Error("The sourceLayer parameter must be provided for vector source types.")));}removeFeatureState(t,i){this._checkLoaded();const a=t.source,s=this.sourceCaches[a];if(void 0===s)return void this.fire(new e.j(new Error(`The source '${a}' does not exist in the map's style.`)));const o=s.getSource().type,r="vector"===o?t.sourceLayer:void 0;"vector"!==o||r?i&&"string"!=typeof t.id&&"number"!=typeof t.id?this.fire(new e.j(new Error("A feature id is required to remove its specific state property."))):s.removeFeatureState(r,t.id,i):this.fire(new e.j(new Error("The sourceLayer parameter must be provided for vector source types.")));}getFeatureState(t){this._checkLoaded();const i=t.source,a=t.sourceLayer,s=this.sourceCaches[i];if(void 0!==s)return "vector"!==s.getSource().type||a?(void 0===t.id&&this.fire(new e.j(new Error("The feature id parameter must be provided."))),s.getFeatureState(a,t.id)):void this.fire(new e.j(new Error("The sourceLayer parameter must be provided for vector source types.")));this.fire(new e.j(new Error(`The source '${i}' does not exist in the map's style.`)));}getTransition(){return e.e({duration:300,delay:0},this.stylesheet&&this.stylesheet.transition)}serialize(){if(!this._loaded)return;const t=e.aF(this.sourceCaches,(t=>t.serialize())),i=this._serializeByIds(this._order,!0),a=this.map.getTerrain()||void 0,s=this.stylesheet;return e.aG({version:s.version,name:s.name,metadata:s.metadata,light:s.light,sky:s.sky,center:s.center,zoom:s.zoom,bearing:s.bearing,pitch:s.pitch,sprite:s.sprite,glyphs:s.glyphs,transition:s.transition,sources:t,layers:i,terrain:a},(t=>void 0!==t))}_updateLayer(t){this._updatedLayers[t.id]=!0,t.source&&!this._updatedSources[t.source]&&"raster"!==this.sourceCaches[t.source].getSource().type&&(this._updatedSources[t.source]="reload",this.sourceCaches[t.source].pause()),this._serializedLayers=null,this._changed=!0;}_flattenAndSortRenderedFeatures(t){const e=t=>"fill-extrusion"===this._layers[t].type,i={},a=[];for(let s=this._order.length-1;s>=0;s--){const o=this._order[s];if(e(o)){i[o]=s;for(const e of t){const t=e[o];if(t)for(const e of t)a.push(e);}}}a.sort(((t,e)=>e.intersectionZ-t.intersectionZ));const s=[];for(let o=this._order.length-1;o>=0;o--){const r=this._order[o];if(e(r))for(let t=a.length-1;t>=0;t--){const e=a[t].feature;if(i[e.layer.id]{const a=i.featureSortOrder;if(a){const i=a.indexOf(t.featureIndex);return a.indexOf(e.featureIndex)-i}return e.featureIndex-t.featureIndex}));for(const t of s)e.push(t);}}for(const e in n)n[e].forEach((a=>{const s=a.feature,o=i[t[e].source].getFeatureState(s.layer["source-layer"],s.id);s.source=s.layer.source,s.layer["source-layer"]&&(s.sourceLayer=s.layer["source-layer"]),s.state=o;}));return n}(this._layers,r,this.sourceCaches,t,i,this.placement.collisionIndex,this.placement.retainedQueryData)),this._flattenAndSortRenderedFeatures(o)}querySourceFeatures(t,i){i&&i.filter&&this._validate(e.u.filter,"querySourceFeatures.filter",i.filter,null,i);const a=this.sourceCaches[t];return a?function(t,e){const i=t.getRenderableIds().map((e=>t.getTileByID(e))),a=[],s={};for(let t=0;tt.getTileByID(e))).sort(((t,e)=>e.tileID.overscaledZ-t.tileID.overscaledZ||(t.tileID.isLessThan(e.tileID)?-1:1)));}const a=this.crossTileSymbolIndex.addLayer(i,l[i.source],t.center.lng);r=r||a;}if(this.crossTileSymbolIndex.pruneUnusedLayers(this._order),((s=s||this._layerOrderChanged||0===i)||!this.pauseablePlacement||this.pauseablePlacement.isDone()&&!this.placement.stillRecent(o.now(),t.zoom))&&(this.pauseablePlacement=new se(t,this.map.terrain,this._order,s,e,i,a,this.placement),this._layerOrderChanged=!1),this.pauseablePlacement.isDone()?this.placement.setStale():(this.pauseablePlacement.continuePlacement(this._order,this._layers,l),this.pauseablePlacement.isDone()&&(this.placement=this.pauseablePlacement.commit(o.now()),n=!0),r&&this.pauseablePlacement.placement.setStale()),n||r)for(const t of this._order){const e=this._layers[t];"symbol"===e.type&&this.placement.updateLayerOpacities(e,l[e.source]);}return !this.pauseablePlacement.isDone()||this.placement.hasTransitions(o.now())}_releaseSymbolFadeTiles(){for(const t in this.sourceCaches)this.sourceCaches[t].releaseSymbolFadeTiles();}getImages(t,i){return e._(this,void 0,void 0,(function*(){const t=yield this.imageManager.getImages(i.icons);this._updateTilesForChangedImages();const e=this.sourceCaches[i.source];return e&&e.setDependencies(i.tileID.key,i.type,i.icons),t}))}getGlyphs(t,i){return e._(this,void 0,void 0,(function*(){const t=yield this.glyphManager.getGlyphs(i.stacks),e=this.sourceCaches[i.source];return e&&e.setDependencies(i.tileID.key,i.type,[""]),t}))}getGlyphsUrl(){return this.stylesheet.glyphs||null}setGlyphs(t,i={}){this._checkLoaded(),t&&this._validate(e.u.glyphs,"glyphs",t,null,i)||(this._glyphsDidChange=!0,this.stylesheet.glyphs=t,this.glyphManager.entries={},this.glyphManager.setURL(t));}addSprite(t,i,a={},s){this._checkLoaded();const o=[{id:t,url:i}],r=[...x(this.stylesheet.sprite),...o];this._validate(e.u.sprite,"sprite",r,null,a)||(this.stylesheet.sprite=r,this._loadSprite(o,!0,s));}removeSprite(t){this._checkLoaded();const i=x(this.stylesheet.sprite);if(i.find((e=>e.id===t))){if(this._spritesImagesIds[t])for(const e of this._spritesImagesIds[t])this.imageManager.removeImage(e),this._changedImages[e]=!0;i.splice(i.findIndex((e=>e.id===t)),1),this.stylesheet.sprite=i.length>0?i:void 0,delete this._spritesImagesIds[t],this._availableImages=this.imageManager.listImages(),this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new e.k("data",{dataType:"style"}));}else this.fire(new e.j(new Error(`Sprite "${t}" doesn't exists on this map.`)));}getSprite(){return x(this.stylesheet.sprite)}setSprite(t,i={},a){this._checkLoaded(),t&&this._validate(e.u.sprite,"sprite",t,null,i)||(this.stylesheet.sprite=t,t?this._loadSprite(t,!0,a):(this._unloadSprite(),a&&a(null)));}}var _e=e.Y([{name:"a_pos",type:"Int16",components:2}]);const pe={prelude:me("#ifdef GL_ES\nprecision mediump float;\n#else\n#if !defined(lowp)\n#define lowp\n#endif\n#if !defined(mediump)\n#define mediump\n#endif\n#if !defined(highp)\n#define highp\n#endif\n#endif\n","#ifdef GL_ES\nprecision highp float;\n#else\n#if !defined(lowp)\n#define lowp\n#endif\n#if !defined(mediump)\n#define mediump\n#endif\n#if !defined(highp)\n#define highp\n#endif\n#endif\nvec2 unpack_float(const float packedValue) {int packedIntValue=int(packedValue);int v0=packedIntValue/256;return vec2(v0,packedIntValue-v0*256);}vec2 unpack_opacity(const float packedOpacity) {int intOpacity=int(packedOpacity)/2;return vec2(float(intOpacity)/127.0,mod(packedOpacity,2.0));}vec4 decode_color(const vec2 encodedColor) {return vec4(unpack_float(encodedColor[0])/255.0,unpack_float(encodedColor[1])/255.0\n);}float unpack_mix_vec2(const vec2 packedValue,const float t) {return mix(packedValue[0],packedValue[1],t);}vec4 unpack_mix_color(const vec4 packedColors,const float t) {vec4 minColor=decode_color(vec2(packedColors[0],packedColors[1]));vec4 maxColor=decode_color(vec2(packedColors[2],packedColors[3]));return mix(minColor,maxColor,t);}vec2 get_pattern_pos(const vec2 pixel_coord_upper,const vec2 pixel_coord_lower,const vec2 pattern_size,const float tile_units_to_pixels,const vec2 pos) {vec2 offset=mod(mod(mod(pixel_coord_upper,pattern_size)*256.0,pattern_size)*256.0+pixel_coord_lower,pattern_size);return (tile_units_to_pixels*pos+offset)/pattern_size;}\n#ifdef TERRAIN3D\nuniform sampler2D u_terrain;uniform float u_terrain_dim;uniform mat4 u_terrain_matrix;uniform vec4 u_terrain_unpack;uniform float u_terrain_exaggeration;uniform highp sampler2D u_depth;\n#endif\nconst highp vec4 bitSh=vec4(256.*256.*256.,256.*256.,256.,1.);const highp vec4 bitShifts=vec4(1.)/bitSh;highp float unpack(highp vec4 color) {return dot(color,bitShifts);}highp float depthOpacity(vec3 frag) {\n#ifdef TERRAIN3D\nhighp float d=unpack(texture2D(u_depth,frag.xy*0.5+0.5))+0.0001-frag.z;return 1.0-max(0.0,min(1.0,-d*500.0));\n#else\nreturn 1.0;\n#endif\n}float calculate_visibility(vec4 pos) {\n#ifdef TERRAIN3D\nvec3 frag=pos.xyz/pos.w;highp float d=depthOpacity(frag);if (d > 0.95) return 1.0;return (d+depthOpacity(frag+vec3(0.0,0.01,0.0)))/2.0;\n#else\nreturn 1.0;\n#endif\n}float ele(vec2 pos) {\n#ifdef TERRAIN3D\nvec4 rgb=(texture2D(u_terrain,pos)*255.0)*u_terrain_unpack;return rgb.r+rgb.g+rgb.b-u_terrain_unpack.a;\n#else\nreturn 0.0;\n#endif\n}float get_elevation(vec2 pos) {\n#ifdef TERRAIN3D\nvec2 coord=(u_terrain_matrix*vec4(pos,0.0,1.0)).xy*u_terrain_dim+1.0;vec2 f=fract(coord);vec2 c=(floor(coord)+0.5)/(u_terrain_dim+2.0);float d=1.0/(u_terrain_dim+2.0);float tl=ele(c);float tr=ele(c+vec2(d,0.0));float bl=ele(c+vec2(0.0,d));float br=ele(c+vec2(d,d));float elevation=mix(mix(tl,tr,f.x),mix(bl,br,f.x),f.y);return elevation*u_terrain_exaggeration;\n#else\nreturn 0.0;\n#endif\n}"),background:me("uniform vec4 u_color;uniform float u_opacity;void main() {gl_FragColor=u_color*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec2 a_pos;uniform mat4 u_matrix;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);}"),backgroundPattern:me("uniform vec2 u_pattern_tl_a;uniform vec2 u_pattern_br_a;uniform vec2 u_pattern_tl_b;uniform vec2 u_pattern_br_b;uniform vec2 u_texsize;uniform float u_mix;uniform float u_opacity;uniform sampler2D u_image;varying vec2 v_pos_a;varying vec2 v_pos_b;void main() {vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(u_pattern_tl_a/u_texsize,u_pattern_br_a/u_texsize,imagecoord);vec4 color1=texture2D(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(u_pattern_tl_b/u_texsize,u_pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture2D(u_image,pos2);gl_FragColor=mix(color1,color2,u_mix)*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_pattern_size_a;uniform vec2 u_pattern_size_b;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform float u_scale_a;uniform float u_scale_b;uniform float u_tile_units_to_pixels;attribute vec2 a_pos;varying vec2 v_pos_a;varying vec2 v_pos_b;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,u_scale_a*u_pattern_size_a,u_tile_units_to_pixels,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,u_scale_b*u_pattern_size_b,u_tile_units_to_pixels,a_pos);}"),circle:me("varying vec3 v_data;varying float v_visibility;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define mediump float radius\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define highp vec4 stroke_color\n#pragma mapbox: define mediump float stroke_width\n#pragma mapbox: define lowp float stroke_opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize mediump float radius\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize highp vec4 stroke_color\n#pragma mapbox: initialize mediump float stroke_width\n#pragma mapbox: initialize lowp float stroke_opacity\nvec2 extrude=v_data.xy;float extrude_length=length(extrude);float antialiased_blur=v_data.z;float opacity_t=smoothstep(0.0,antialiased_blur,extrude_length-1.0);float color_t=stroke_width < 0.01 ? 0.0 : smoothstep(antialiased_blur,0.0,extrude_length-radius/(radius+stroke_width));gl_FragColor=v_visibility*opacity_t*mix(color*opacity,stroke_color*stroke_opacity,color_t);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform bool u_scale_with_map;uniform bool u_pitch_with_map;uniform vec2 u_extrude_scale;uniform lowp float u_device_pixel_ratio;uniform highp float u_camera_to_center_distance;attribute vec2 a_pos;varying vec3 v_data;varying float v_visibility;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define mediump float radius\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define highp vec4 stroke_color\n#pragma mapbox: define mediump float stroke_width\n#pragma mapbox: define lowp float stroke_opacity\nvoid main(void) {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize mediump float radius\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize highp vec4 stroke_color\n#pragma mapbox: initialize mediump float stroke_width\n#pragma mapbox: initialize lowp float stroke_opacity\nvec2 extrude=vec2(mod(a_pos,2.0)*2.0-1.0);vec2 circle_center=floor(a_pos*0.5);float ele=get_elevation(circle_center);v_visibility=calculate_visibility(u_matrix*vec4(circle_center,ele,1.0));if (u_pitch_with_map) {vec2 corner_position=circle_center;if (u_scale_with_map) {corner_position+=extrude*(radius+stroke_width)*u_extrude_scale;} else {vec4 projected_center=u_matrix*vec4(circle_center,0,1);corner_position+=extrude*(radius+stroke_width)*u_extrude_scale*(projected_center.w/u_camera_to_center_distance);}gl_Position=u_matrix*vec4(corner_position,ele,1);} else {gl_Position=u_matrix*vec4(circle_center,ele,1);if (u_scale_with_map) {gl_Position.xy+=extrude*(radius+stroke_width)*u_extrude_scale*u_camera_to_center_distance;} else {gl_Position.xy+=extrude*(radius+stroke_width)*u_extrude_scale*gl_Position.w;}}float antialiasblur=-max(1.0/u_device_pixel_ratio/(radius+stroke_width),blur);v_data=vec3(extrude.x,extrude.y,antialiasblur);}"),clippingMask:me("void main() {gl_FragColor=vec4(1.0);}","attribute vec2 a_pos;uniform mat4 u_matrix;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);}"),heatmap:me("uniform highp float u_intensity;varying vec2 v_extrude;\n#pragma mapbox: define highp float weight\n#define GAUSS_COEF 0.3989422804014327\nvoid main() {\n#pragma mapbox: initialize highp float weight\nfloat d=-0.5*3.0*3.0*dot(v_extrude,v_extrude);float val=weight*u_intensity*GAUSS_COEF*exp(d);gl_FragColor=vec4(val,1.0,1.0,1.0);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform float u_extrude_scale;uniform float u_opacity;uniform float u_intensity;attribute vec2 a_pos;varying vec2 v_extrude;\n#pragma mapbox: define highp float weight\n#pragma mapbox: define mediump float radius\nconst highp float ZERO=1.0/255.0/16.0;\n#define GAUSS_COEF 0.3989422804014327\nvoid main(void) {\n#pragma mapbox: initialize highp float weight\n#pragma mapbox: initialize mediump float radius\nvec2 unscaled_extrude=vec2(mod(a_pos,2.0)*2.0-1.0);float S=sqrt(-2.0*log(ZERO/weight/u_intensity/GAUSS_COEF))/3.0;v_extrude=S*unscaled_extrude;vec2 extrude=v_extrude*radius*u_extrude_scale;vec4 pos=vec4(floor(a_pos*0.5)+extrude,get_elevation(floor(a_pos*0.5)),1);gl_Position=u_matrix*pos;}"),heatmapTexture:me("uniform sampler2D u_image;uniform sampler2D u_color_ramp;uniform float u_opacity;varying vec2 v_pos;void main() {float t=texture2D(u_image,v_pos).r;vec4 color=texture2D(u_color_ramp,vec2(t,0.5));gl_FragColor=color*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(0.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_world;attribute vec2 a_pos;varying vec2 v_pos;void main() {gl_Position=u_matrix*vec4(a_pos*u_world,0,1);v_pos.x=a_pos.x;v_pos.y=1.0-a_pos.y;}"),collisionBox:me("varying float v_placed;varying float v_notUsed;void main() {float alpha=0.5;gl_FragColor=vec4(1.0,0.0,0.0,1.0)*alpha;if (v_placed > 0.5) {gl_FragColor=vec4(0.0,0.0,1.0,0.5)*alpha;}if (v_notUsed > 0.5) {gl_FragColor*=.1;}}","attribute vec2 a_anchor_pos;attribute vec2 a_placed;attribute vec2 a_box_real;uniform mat4 u_matrix;uniform vec2 u_pixel_extrude_scale;varying float v_placed;varying float v_notUsed;vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return u_matrix*vec4(posInTile,elevation,1.0);}void main() {gl_Position=projectTileWithElevation(a_anchor_pos,get_elevation(a_anchor_pos));gl_Position.xy=((a_box_real+0.5)*u_pixel_extrude_scale*2.0-1.0)*vec2(1.0,-1.0)*gl_Position.w;if (gl_Position.z/gl_Position.w < 1.1) {gl_Position.z=0.5;}v_placed=a_placed.x;v_notUsed=a_placed.y;}"),collisionCircle:me("varying float v_radius;varying vec2 v_extrude;varying float v_perspective_ratio;varying float v_collision;void main() {float alpha=0.5*min(v_perspective_ratio,1.0);float stroke_radius=0.9*max(v_perspective_ratio,1.0);float distance_to_center=length(v_extrude);float distance_to_edge=abs(distance_to_center-v_radius);float opacity_t=smoothstep(-stroke_radius,0.0,-distance_to_edge);vec4 color=mix(vec4(0.0,0.0,1.0,0.5),vec4(1.0,0.0,0.0,1.0),v_collision);gl_FragColor=color*alpha*opacity_t;}","attribute vec2 a_pos;attribute float a_radius;attribute vec2 a_flags;uniform mat4 u_matrix;uniform mat4 u_inv_matrix;uniform vec2 u_viewport_size;uniform float u_camera_to_center_distance;varying float v_radius;varying vec2 v_extrude;varying float v_perspective_ratio;varying float v_collision;vec3 toTilePosition(vec2 screenPos) {vec4 rayStart=u_inv_matrix*vec4(screenPos,-1.0,1.0);vec4 rayEnd =u_inv_matrix*vec4(screenPos, 1.0,1.0);rayStart.xyz/=rayStart.w;rayEnd.xyz /=rayEnd.w;highp float t=(0.0-rayStart.z)/(rayEnd.z-rayStart.z);return mix(rayStart.xyz,rayEnd.xyz,t);}void main() {vec2 quadCenterPos=a_pos;float radius=a_radius;float collision=a_flags.x;float vertexIdx=a_flags.y;vec2 quadVertexOffset=vec2(mix(-1.0,1.0,float(vertexIdx >=2.0)),mix(-1.0,1.0,float(vertexIdx >=1.0 && vertexIdx <=2.0)));vec2 quadVertexExtent=quadVertexOffset*radius;vec3 tilePos=toTilePosition(quadCenterPos);vec4 clipPos=u_matrix*vec4(tilePos,1.0);highp float camera_to_anchor_distance=clipPos.w;highp float collision_perspective_ratio=clamp(0.5+0.5*(u_camera_to_center_distance/camera_to_anchor_distance),0.0,4.0);float padding_factor=1.2;v_radius=radius;v_extrude=quadVertexExtent*padding_factor;v_perspective_ratio=collision_perspective_ratio;v_collision=collision;gl_Position=vec4(clipPos.xyz/clipPos.w,1.0)+vec4(quadVertexExtent*padding_factor/u_viewport_size*2.0,0.0,0.0);}"),debug:me("uniform highp vec4 u_color;uniform sampler2D u_overlay;varying vec2 v_uv;void main() {vec4 overlay_color=texture2D(u_overlay,v_uv);gl_FragColor=mix(u_color,overlay_color,overlay_color.a);}","attribute vec2 a_pos;varying vec2 v_uv;uniform mat4 u_matrix;uniform float u_overlay_scale;void main() {v_uv=a_pos/8192.0;gl_Position=u_matrix*vec4(a_pos*u_overlay_scale,get_elevation(a_pos),1);}"),fill:me("#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float opacity\ngl_FragColor=color*opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec2 a_pos;uniform mat4 u_matrix;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float opacity\ngl_Position=u_matrix*vec4(a_pos,0,1);}"),fillOutline:me("varying vec2 v_pos;\n#pragma mapbox: define highp vec4 outline_color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 outline_color\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_pos-gl_FragCoord.xy);float alpha=1.0-smoothstep(0.0,1.0,dist);gl_FragColor=outline_color*(alpha*opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec2 a_pos;uniform mat4 u_matrix;uniform vec2 u_world;varying vec2 v_pos;\n#pragma mapbox: define highp vec4 outline_color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 outline_color\n#pragma mapbox: initialize lowp float opacity\ngl_Position=u_matrix*vec4(a_pos,0,1);v_pos=(gl_Position.xy/gl_Position.w+1.0)/2.0*u_world;}"),fillOutlinePattern:me("uniform vec2 u_texsize;uniform sampler2D u_image;uniform float u_fade;varying vec2 v_pos_a;varying vec2 v_pos_b;varying vec2 v_pos;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture2D(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture2D(u_image,pos2);float dist=length(v_pos-gl_FragCoord.xy);float alpha=1.0-smoothstep(0.0,1.0,dist);gl_FragColor=mix(color1,color2,u_fade)*alpha*opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_world;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform vec3 u_scale;attribute vec2 a_pos;varying vec2 v_pos_a;varying vec2 v_pos_b;varying vec2 v_pos;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;gl_Position=u_matrix*vec4(a_pos,0,1);vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileRatio,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileRatio,a_pos);v_pos=(gl_Position.xy/gl_Position.w+1.0)/2.0*u_world;}"),fillPattern:me("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform vec2 u_texsize;uniform float u_fade;uniform sampler2D u_image;varying vec2 v_pos_a;varying vec2 v_pos_b;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture2D(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture2D(u_image,pos2);gl_FragColor=mix(color1,color2,u_fade)*opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform vec3 u_scale;attribute vec2 a_pos;varying vec2 v_pos_a;varying vec2 v_pos_b;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileZoomRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;gl_Position=u_matrix*vec4(a_pos,0,1);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileZoomRatio,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileZoomRatio,a_pos);}"),fillExtrusion:me("varying vec4 v_color;void main() {gl_FragColor=v_color;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec3 u_lightcolor;uniform lowp vec3 u_lightpos;uniform lowp float u_lightintensity;uniform float u_vertical_gradient;uniform lowp float u_opacity;attribute vec2 a_pos;attribute vec4 a_normal_ed;\n#ifdef TERRAIN3D\nattribute vec2 a_centroid;\n#endif\nvarying vec4 v_color;\n#pragma mapbox: define highp float base\n#pragma mapbox: define highp float height\n#pragma mapbox: define highp vec4 color\nvoid main() {\n#pragma mapbox: initialize highp float base\n#pragma mapbox: initialize highp float height\n#pragma mapbox: initialize highp vec4 color\nvec3 normal=a_normal_ed.xyz;\n#ifdef TERRAIN3D\nfloat height_terrain3d_offset=get_elevation(a_centroid);float base_terrain3d_offset=height_terrain3d_offset-(base > 0.0 ? 0.0 : 10.0);\n#else\nfloat height_terrain3d_offset=0.0;float base_terrain3d_offset=0.0;\n#endif\nbase=max(0.0,base)+base_terrain3d_offset;height=max(0.0,height)+height_terrain3d_offset;float t=mod(normal.x,2.0);gl_Position=u_matrix*vec4(a_pos,t > 0.0 ? height : base,1);float colorvalue=color.r*0.2126+color.g*0.7152+color.b*0.0722;v_color=vec4(0.0,0.0,0.0,1.0);vec4 ambientlight=vec4(0.03,0.03,0.03,1.0);color+=ambientlight;float directional=clamp(dot(normal/16384.0,u_lightpos),0.0,1.0);directional=mix((1.0-u_lightintensity),max((1.0-colorvalue+u_lightintensity),1.0),directional);if (normal.y !=0.0) {directional*=((1.0-u_vertical_gradient)+(u_vertical_gradient*clamp((t+base)*pow(height/150.0,0.5),mix(0.7,0.98,1.0-u_lightintensity),1.0)));}v_color.r+=clamp(color.r*directional*u_lightcolor.r,mix(0.0,0.3,1.0-u_lightcolor.r),1.0);v_color.g+=clamp(color.g*directional*u_lightcolor.g,mix(0.0,0.3,1.0-u_lightcolor.g),1.0);v_color.b+=clamp(color.b*directional*u_lightcolor.b,mix(0.0,0.3,1.0-u_lightcolor.b),1.0);v_color*=u_opacity;}"),fillExtrusionPattern:me("uniform vec2 u_texsize;uniform float u_fade;uniform sampler2D u_image;varying vec2 v_pos_a;varying vec2 v_pos_b;varying vec4 v_lighting;\n#pragma mapbox: define lowp float base\n#pragma mapbox: define lowp float height\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float base\n#pragma mapbox: initialize lowp float height\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture2D(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture2D(u_image,pos2);vec4 mixedColor=mix(color1,color2,u_fade);gl_FragColor=mixedColor*v_lighting;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform float u_height_factor;uniform vec3 u_scale;uniform float u_vertical_gradient;uniform lowp float u_opacity;uniform vec3 u_lightcolor;uniform lowp vec3 u_lightpos;uniform lowp float u_lightintensity;attribute vec2 a_pos;attribute vec4 a_normal_ed;\n#ifdef TERRAIN3D\nattribute vec2 a_centroid;\n#endif\nvarying vec2 v_pos_a;varying vec2 v_pos_b;varying vec4 v_lighting;\n#pragma mapbox: define lowp float base\n#pragma mapbox: define lowp float height\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float base\n#pragma mapbox: initialize lowp float height\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec3 normal=a_normal_ed.xyz;float edgedistance=a_normal_ed.w;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;\n#ifdef TERRAIN3D\nfloat height_terrain3d_offset=get_elevation(a_centroid);float base_terrain3d_offset=height_terrain3d_offset-(base > 0.0 ? 0.0 : 10.0);\n#else\nfloat height_terrain3d_offset=0.0;float base_terrain3d_offset=0.0;\n#endif\nbase=max(0.0,base)+base_terrain3d_offset;height=max(0.0,height)+height_terrain3d_offset;float t=mod(normal.x,2.0);float z=t > 0.0 ? height : base;gl_Position=u_matrix*vec4(a_pos,z,1);vec2 pos=normal.x==1.0 && normal.y==0.0 && normal.z==16384.0\n? a_pos\n: vec2(edgedistance,z*u_height_factor);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileRatio,pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileRatio,pos);v_lighting=vec4(0.0,0.0,0.0,1.0);float directional=clamp(dot(normal/16383.0,u_lightpos),0.0,1.0);directional=mix((1.0-u_lightintensity),max((0.5+u_lightintensity),1.0),directional);if (normal.y !=0.0) {directional*=((1.0-u_vertical_gradient)+(u_vertical_gradient*clamp((t+base)*pow(height/150.0,0.5),mix(0.7,0.98,1.0-u_lightintensity),1.0)));}v_lighting.rgb+=clamp(directional*u_lightcolor,mix(vec3(0.0),vec3(0.3),1.0-u_lightcolor),vec3(1.0));v_lighting*=u_opacity;}"),hillshadePrepare:me("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform sampler2D u_image;varying vec2 v_pos;uniform vec2 u_dimension;uniform float u_zoom;uniform vec4 u_unpack;float getElevation(vec2 coord,float bias) {vec4 data=texture2D(u_image,coord)*255.0;data.a=-1.0;return dot(data,u_unpack)/4.0;}void main() {vec2 epsilon=1.0/u_dimension;float a=getElevation(v_pos+vec2(-epsilon.x,-epsilon.y),0.0);float b=getElevation(v_pos+vec2(0,-epsilon.y),0.0);float c=getElevation(v_pos+vec2(epsilon.x,-epsilon.y),0.0);float d=getElevation(v_pos+vec2(-epsilon.x,0),0.0);float e=getElevation(v_pos,0.0);float f=getElevation(v_pos+vec2(epsilon.x,0),0.0);float g=getElevation(v_pos+vec2(-epsilon.x,epsilon.y),0.0);float h=getElevation(v_pos+vec2(0,epsilon.y),0.0);float i=getElevation(v_pos+vec2(epsilon.x,epsilon.y),0.0);float exaggerationFactor=u_zoom < 2.0 ? 0.4 : u_zoom < 4.5 ? 0.35 : 0.3;float exaggeration=u_zoom < 15.0 ? (u_zoom-15.0)*exaggerationFactor : 0.0;vec2 deriv=vec2((c+f+f+i)-(a+d+d+g),(g+h+h+i)-(a+b+b+c))/pow(2.0,exaggeration+(19.2562-u_zoom));gl_FragColor=clamp(vec4(deriv.x/2.0+0.5,deriv.y/2.0+0.5,1.0,1.0),0.0,1.0);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_dimension;attribute vec2 a_pos;attribute vec2 a_texture_pos;varying vec2 v_pos;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);highp vec2 epsilon=1.0/u_dimension;float scale=(u_dimension.x-2.0)/u_dimension.x;v_pos=(a_texture_pos/8192.0)*scale+epsilon;}"),hillshade:me("uniform sampler2D u_image;varying vec2 v_pos;uniform vec2 u_latrange;uniform vec2 u_light;uniform vec4 u_shadow;uniform vec4 u_highlight;uniform vec4 u_accent;\n#define PI 3.141592653589793\nvoid main() {vec4 pixel=texture2D(u_image,v_pos);vec2 deriv=((pixel.rg*2.0)-1.0);float scaleFactor=cos(radians((u_latrange[0]-u_latrange[1])*(1.0-v_pos.y)+u_latrange[1]));float slope=atan(1.25*length(deriv)/scaleFactor);float aspect=deriv.x !=0.0 ? atan(deriv.y,-deriv.x) : PI/2.0*(deriv.y > 0.0 ? 1.0 :-1.0);float intensity=u_light.x;float azimuth=u_light.y+PI;float base=1.875-intensity*1.75;float maxValue=0.5*PI;float scaledSlope=intensity !=0.5 ? ((pow(base,slope)-1.0)/(pow(base,maxValue)-1.0))*maxValue : slope;float accent=cos(scaledSlope);vec4 accent_color=(1.0-accent)*u_accent*clamp(intensity*2.0,0.0,1.0);float shade=abs(mod((aspect+azimuth)/PI+0.5,2.0)-1.0);vec4 shade_color=mix(u_shadow,u_highlight,shade)*sin(scaledSlope)*clamp(intensity*2.0,0.0,1.0);gl_FragColor=accent_color*(1.0-shade_color.a)+shade_color;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;attribute vec2 a_pos;attribute vec2 a_texture_pos;varying vec2 v_pos;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);v_pos=a_texture_pos/8192.0;}"),line:me("uniform lowp float u_device_pixel_ratio;varying vec2 v_width2;varying vec2 v_normal;varying float v_gamma_scale;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);gl_FragColor=color*(alpha*opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\nattribute vec2 a_pos_normal;attribute vec4 a_data;uniform mat4 u_matrix;uniform mediump float u_ratio;uniform vec2 u_units_to_pixels;uniform lowp float u_device_pixel_ratio;varying vec2 v_normal;varying vec2 v_width2;varying float v_gamma_scale;varying highp float v_linesofar;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;v_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*2.0;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);vec4 projected_extrude=u_matrix*vec4(dist/u_ratio,0.0,0.0);gl_Position=u_matrix*vec4(pos+offset2/u_ratio,0.0,1.0)+projected_extrude;\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length(projected_extrude.xy/gl_Position.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_width2=vec2(outset,inset);}"),lineGradient:me("uniform lowp float u_device_pixel_ratio;uniform sampler2D u_image;varying vec2 v_width2;varying vec2 v_normal;varying float v_gamma_scale;varying highp vec2 v_uv;\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);vec4 color=texture2D(u_image,v_uv);gl_FragColor=color*(alpha*opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\nattribute vec2 a_pos_normal;attribute vec4 a_data;attribute float a_uv_x;attribute float a_split_index;uniform mat4 u_matrix;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;uniform vec2 u_units_to_pixels;uniform float u_image_height;varying vec2 v_normal;varying vec2 v_width2;varying float v_gamma_scale;varying highp vec2 v_uv;\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;highp float texel_height=1.0/u_image_height;highp float half_texel_height=0.5*texel_height;v_uv=vec2(a_uv_x,a_split_index*texel_height-half_texel_height);vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);vec4 projected_extrude=u_matrix*vec4(dist/u_ratio,0.0,0.0);gl_Position=u_matrix*vec4(pos+offset2/u_ratio,0.0,1.0)+projected_extrude;\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length(projected_extrude.xy/gl_Position.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_width2=vec2(outset,inset);}"),linePattern:me("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform lowp float u_device_pixel_ratio;uniform vec2 u_texsize;uniform float u_fade;uniform mediump vec3 u_scale;uniform sampler2D u_image;varying vec2 v_normal;varying vec2 v_width2;varying float v_linesofar;varying float v_gamma_scale;varying float v_width;\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileZoomRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;vec2 pattern_size_a=vec2(display_size_a.x*fromScale/tileZoomRatio,display_size_a.y);vec2 pattern_size_b=vec2(display_size_b.x*toScale/tileZoomRatio,display_size_b.y);float aspect_a=display_size_a.y/v_width;float aspect_b=display_size_b.y/v_width;float dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);float x_a=mod(v_linesofar/pattern_size_a.x*aspect_a,1.0);float x_b=mod(v_linesofar/pattern_size_b.x*aspect_b,1.0);float y=0.5*v_normal.y+0.5;vec2 texel_size=1.0/u_texsize;vec2 pos_a=mix(pattern_tl_a*texel_size-texel_size,pattern_br_a*texel_size+texel_size,vec2(x_a,y));vec2 pos_b=mix(pattern_tl_b*texel_size-texel_size,pattern_br_b*texel_size+texel_size,vec2(x_b,y));vec4 color=mix(texture2D(u_image,pos_a),texture2D(u_image,pos_b),u_fade);gl_FragColor=color*alpha*opacity;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\n#define LINE_DISTANCE_SCALE 2.0\nattribute vec2 a_pos_normal;attribute vec4 a_data;uniform mat4 u_matrix;uniform vec2 u_units_to_pixels;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;varying vec2 v_normal;varying vec2 v_width2;varying float v_linesofar;varying float v_gamma_scale;varying float v_width;\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;float a_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*LINE_DISTANCE_SCALE;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);vec4 projected_extrude=u_matrix*vec4(dist/u_ratio,0.0,0.0);gl_Position=u_matrix*vec4(pos+offset2/u_ratio,0.0,1.0)+projected_extrude;\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length(projected_extrude.xy/gl_Position.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_linesofar=a_linesofar;v_width2=vec2(outset,inset);v_width=floorwidth;}"),lineSDF:me("uniform lowp float u_device_pixel_ratio;uniform sampler2D u_image;uniform float u_sdfgamma;uniform float u_mix;varying vec2 v_normal;varying vec2 v_width2;varying vec2 v_tex_a;varying vec2 v_tex_b;varying float v_gamma_scale;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);float sdfdist_a=texture2D(u_image,v_tex_a).a;float sdfdist_b=texture2D(u_image,v_tex_b).a;float sdfdist=mix(sdfdist_a,sdfdist_b,u_mix);alpha*=smoothstep(0.5-u_sdfgamma/floorwidth,0.5+u_sdfgamma/floorwidth,sdfdist);gl_FragColor=color*(alpha*opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\n#define LINE_DISTANCE_SCALE 2.0\nattribute vec2 a_pos_normal;attribute vec4 a_data;uniform mat4 u_matrix;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;uniform vec2 u_patternscale_a;uniform float u_tex_y_a;uniform vec2 u_patternscale_b;uniform float u_tex_y_b;uniform vec2 u_units_to_pixels;varying vec2 v_normal;varying vec2 v_width2;varying vec2 v_tex_a;varying vec2 v_tex_b;varying float v_gamma_scale;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;float a_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*LINE_DISTANCE_SCALE;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);vec4 projected_extrude=u_matrix*vec4(dist/u_ratio,0.0,0.0);gl_Position=u_matrix*vec4(pos+offset2/u_ratio,0.0,1.0)+projected_extrude;\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length(projected_extrude.xy/gl_Position.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_tex_a=vec2(a_linesofar*u_patternscale_a.x/floorwidth,normal.y*u_patternscale_a.y+u_tex_y_a);v_tex_b=vec2(a_linesofar*u_patternscale_b.x/floorwidth,normal.y*u_patternscale_b.y+u_tex_y_b);v_width2=vec2(outset,inset);}"),raster:me("uniform float u_fade_t;uniform float u_opacity;uniform sampler2D u_image0;uniform sampler2D u_image1;varying vec2 v_pos0;varying vec2 v_pos1;uniform float u_brightness_low;uniform float u_brightness_high;uniform float u_saturation_factor;uniform float u_contrast_factor;uniform vec3 u_spin_weights;void main() {vec4 color0=texture2D(u_image0,v_pos0);vec4 color1=texture2D(u_image1,v_pos1);if (color0.a > 0.0) {color0.rgb=color0.rgb/color0.a;}if (color1.a > 0.0) {color1.rgb=color1.rgb/color1.a;}vec4 color=mix(color0,color1,u_fade_t);color.a*=u_opacity;vec3 rgb=color.rgb;rgb=vec3(dot(rgb,u_spin_weights.xyz),dot(rgb,u_spin_weights.zxy),dot(rgb,u_spin_weights.yzx));float average=(color.r+color.g+color.b)/3.0;rgb+=(average-rgb)*u_saturation_factor;rgb=(rgb-0.5)*u_contrast_factor+0.5;vec3 u_high_vec=vec3(u_brightness_low,u_brightness_low,u_brightness_low);vec3 u_low_vec=vec3(u_brightness_high,u_brightness_high,u_brightness_high);gl_FragColor=vec4(mix(u_high_vec,u_low_vec,rgb)*color.a,color.a);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_tl_parent;uniform float u_scale_parent;uniform float u_buffer_scale;attribute vec2 a_pos;attribute vec2 a_texture_pos;varying vec2 v_pos0;varying vec2 v_pos1;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);v_pos0=(((a_texture_pos/8192.0)-0.5)/u_buffer_scale )+0.5;v_pos1=(v_pos0*u_scale_parent)+u_tl_parent;}"),symbolIcon:me("uniform sampler2D u_texture;varying vec2 v_tex;varying float v_fade_opacity;\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\nlowp float alpha=opacity*v_fade_opacity;gl_FragColor=texture2D(u_texture,v_tex)*alpha;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec4 a_pos_offset;attribute vec4 a_data;attribute vec4 a_pixeloffset;attribute vec3 a_projected_pos;attribute float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform highp float u_camera_to_center_distance;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform float u_fade_change;uniform mat4 u_matrix;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform vec2 u_texsize;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform vec2 u_translation;uniform float u_pitched_scale;varying vec2 v_tex;varying float v_fade_opacity;vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return u_matrix*vec4(posInTile,elevation,1.0);}\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);vec2 a_pxoffset=a_pixeloffset.xy;vec2 a_minFontScale=a_pixeloffset.zw/256.0;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=u_is_text ? size/24.0 : size;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;vec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*max(a_minFontScale,fontScale)+a_pxoffset/16.0)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}gl_Position=finalPos;v_tex=a_tex/u_texsize;vec2 fade_opacity=unpack_opacity(a_fade_opacity);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float visibility=calculate_visibility(projectedPoint);v_fade_opacity=max(0.0,min(visibility,fade_opacity[0]+fade_change));}"),symbolSDF:me("#define SDF_PX 8.0\nuniform bool u_is_halo;uniform sampler2D u_texture;uniform highp float u_gamma_scale;uniform lowp float u_device_pixel_ratio;uniform bool u_is_text;varying vec2 v_data0;varying vec3 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nfloat EDGE_GAMMA=0.105/u_device_pixel_ratio;vec2 tex=v_data0.xy;float gamma_scale=v_data1.x;float size=v_data1.y;float fade_opacity=v_data1[2];float fontScale=u_is_text ? size/24.0 : size;lowp vec4 color=fill_color;highp float gamma=EDGE_GAMMA/(fontScale*u_gamma_scale);lowp float inner_edge=(256.0-64.0)/256.0;if (u_is_halo) {color=halo_color;gamma=(halo_blur*1.19/SDF_PX+EDGE_GAMMA)/(fontScale*u_gamma_scale);inner_edge=inner_edge+gamma*gamma_scale;}lowp float dist=texture2D(u_texture,tex).a;highp float gamma_scaled=gamma*gamma_scale;highp float alpha=smoothstep(inner_edge-gamma_scaled,inner_edge+gamma_scaled,dist);if (u_is_halo) {lowp float halo_edge=(6.0-halo_width/fontScale)/SDF_PX;alpha=min(smoothstep(halo_edge-gamma_scaled,halo_edge+gamma_scaled,dist),1.0-alpha);}gl_FragColor=color*(alpha*opacity*fade_opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec4 a_pos_offset;attribute vec4 a_data;attribute vec4 a_pixeloffset;attribute vec3 a_projected_pos;attribute float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform mat4 u_matrix;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform highp float u_camera_to_center_distance;uniform float u_fade_change;uniform vec2 u_texsize;uniform vec2 u_translation;uniform float u_pitched_scale;varying vec2 v_data0;varying vec3 v_data1;vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return u_matrix*vec4(posInTile,elevation,1.0);}\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);vec2 a_pxoffset=a_pixeloffset.xy;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=u_is_text ? size/24.0 : size;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;vec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*fontScale+a_pxoffset)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}float gamma_scale=finalPos.w;gl_Position=finalPos;vec2 fade_opacity=unpack_opacity(a_fade_opacity);float visibility=calculate_visibility(projectedPoint);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float interpolated_fade_opacity=max(0.0,min(visibility,fade_opacity[0]+fade_change));v_data0=a_tex/u_texsize;v_data1=vec3(gamma_scale,size,interpolated_fade_opacity);}"),symbolTextAndIcon:me("#define SDF_PX 8.0\n#define SDF 1.0\n#define ICON 0.0\nuniform bool u_is_halo;uniform sampler2D u_texture;uniform sampler2D u_texture_icon;uniform highp float u_gamma_scale;uniform lowp float u_device_pixel_ratio;varying vec4 v_data0;varying vec4 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nfloat fade_opacity=v_data1[2];if (v_data1.w==ICON) {vec2 tex_icon=v_data0.zw;lowp float alpha=opacity*fade_opacity;gl_FragColor=texture2D(u_texture_icon,tex_icon)*alpha;\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\nreturn;}vec2 tex=v_data0.xy;float EDGE_GAMMA=0.105/u_device_pixel_ratio;float gamma_scale=v_data1.x;float size=v_data1.y;float fontScale=size/24.0;lowp vec4 color=fill_color;highp float gamma=EDGE_GAMMA/(fontScale*u_gamma_scale);lowp float buff=(256.0-64.0)/256.0;if (u_is_halo) {color=halo_color;gamma=(halo_blur*1.19/SDF_PX+EDGE_GAMMA)/(fontScale*u_gamma_scale);buff=(6.0-halo_width/fontScale)/SDF_PX;}lowp float dist=texture2D(u_texture,tex).a;highp float gamma_scaled=gamma*gamma_scale;highp float alpha=smoothstep(buff-gamma_scaled,buff+gamma_scaled,dist);gl_FragColor=color*(alpha*opacity*fade_opacity);\n#ifdef OVERDRAW_INSPECTOR\ngl_FragColor=vec4(1.0);\n#endif\n}","attribute vec4 a_pos_offset;attribute vec4 a_data;attribute vec3 a_projected_pos;attribute float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform mat4 u_matrix;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform highp float u_camera_to_center_distance;uniform float u_fade_change;uniform vec2 u_texsize;uniform vec2 u_texsize_icon;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform vec2 u_translation;uniform float u_pitched_scale;varying vec4 v_data0;varying vec4 v_data1;vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return u_matrix*vec4(posInTile,elevation,1.0);}\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);float is_sdf=a_size[0]-2.0*a_size_min;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=size/24.0;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;vec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*fontScale)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}float gamma_scale=finalPos.w;gl_Position=finalPos;vec2 fade_opacity=unpack_opacity(a_fade_opacity);float visibility=calculate_visibility(projectedPoint);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float interpolated_fade_opacity=max(0.0,min(visibility,fade_opacity[0]+fade_change));v_data0.xy=a_tex/u_texsize;v_data0.zw=a_tex/u_texsize_icon;v_data1=vec4(gamma_scale,size,interpolated_fade_opacity,is_sdf);}"),terrain:me("uniform sampler2D u_texture;uniform vec4 u_fog_color;uniform vec4 u_horizon_color;uniform float u_fog_ground_blend;uniform float u_fog_ground_blend_opacity;uniform float u_horizon_fog_blend;varying vec2 v_texture_pos;varying float v_fog_depth;const float gamma=2.2;vec4 gammaToLinear(vec4 color) {return pow(color,vec4(gamma));}vec4 linearToGamma(vec4 color) {return pow(color,vec4(1.0/gamma));}void main() {vec4 surface_color=texture2D(u_texture,v_texture_pos);if (v_fog_depth > u_fog_ground_blend) {vec4 surface_color_linear=gammaToLinear(surface_color);float blend_color=smoothstep(0.0,1.0,max((v_fog_depth-u_horizon_fog_blend)/(1.0-u_horizon_fog_blend),0.0));vec4 fog_horizon_color_linear=mix(gammaToLinear(u_fog_color),gammaToLinear(u_horizon_color),blend_color);float factor_fog=max(v_fog_depth-u_fog_ground_blend,0.0)/(1.0-u_fog_ground_blend);gl_FragColor=linearToGamma(mix(surface_color_linear,fog_horizon_color_linear,pow(factor_fog,2.0)*u_fog_ground_blend_opacity));} else {gl_FragColor=surface_color;}}","attribute vec3 a_pos3d;uniform mat4 u_matrix;uniform mat4 u_fog_matrix;uniform float u_ele_delta;varying vec2 v_texture_pos;varying float v_fog_depth;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;v_texture_pos=a_pos3d.xy/8192.0;gl_Position=u_matrix*vec4(a_pos3d.xy,ele-ele_delta,1.0);vec4 pos=u_fog_matrix*vec4(a_pos3d.xy,ele,1.0);v_fog_depth=pos.z/pos.w*0.5+0.5;}"),terrainDepth:me("varying float v_depth;const highp vec4 bitSh=vec4(256.*256.*256.,256.*256.,256.,1.);const highp vec4 bitMsk=vec4(0.,vec3(1./256.0));highp vec4 pack(highp float value) {highp vec4 comp=fract(value*bitSh);comp-=comp.xxyz*bitMsk;return comp;}void main() {gl_FragColor=pack(v_depth);}","attribute vec3 a_pos3d;uniform mat4 u_matrix;uniform float u_ele_delta;varying float v_depth;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;gl_Position=u_matrix*vec4(a_pos3d.xy,ele-ele_delta,1.0);v_depth=gl_Position.z/gl_Position.w;}"),terrainCoords:me("precision mediump float;uniform sampler2D u_texture;uniform float u_terrain_coords_id;varying vec2 v_texture_pos;void main() {vec4 rgba=texture2D(u_texture,v_texture_pos);gl_FragColor=vec4(rgba.r,rgba.g,rgba.b,u_terrain_coords_id);}","attribute vec3 a_pos3d;uniform mat4 u_matrix;uniform float u_ele_delta;varying vec2 v_texture_pos;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;v_texture_pos=a_pos3d.xy/8192.0;gl_Position=u_matrix*vec4(a_pos3d.xy,ele-ele_delta,1.0);}"),sky:me("uniform vec4 u_sky_color;uniform vec4 u_horizon_color;uniform float u_horizon;uniform float u_sky_horizon_blend;void main() {float y=gl_FragCoord.y;if (y > u_horizon) {float blend=y-u_horizon;if (blend < u_sky_horizon_blend) {gl_FragColor=mix(u_sky_color,u_horizon_color,pow(1.0-blend/u_sky_horizon_blend,2.0));} else {gl_FragColor=u_sky_color;}}}","attribute vec2 a_pos;void main() {gl_Position=vec4(a_pos,1.0,1.0);}")};function me(t,e){const i=/#pragma mapbox: ([\w]+) ([\w]+) ([\w]+) ([\w]+)/g,a=e.match(/attribute ([\w]+) ([\w]+)/g),s=t.match(/uniform ([\w]+) ([\w]+)([\s]*)([\w]*)/g),o=e.match(/uniform ([\w]+) ([\w]+)([\s]*)([\w]*)/g),r=o?o.concat(s):s,n={};return {fragmentSource:t=t.replace(i,((t,e,i,a,s)=>(n[s]=!0,"define"===e?`\n#ifndef HAS_UNIFORM_u_${s}\nvarying ${i} ${a} ${s};\n#else\nuniform ${i} ${a} u_${s};\n#endif\n`:`\n#ifdef HAS_UNIFORM_u_${s}\n ${i} ${a} ${s} = u_${s};\n#endif\n`))),vertexSource:e=e.replace(i,((t,e,i,a,s)=>{const o="float"===a?"vec2":"vec4",r=s.match(/color/)?"color":o;return n[s]?"define"===e?`\n#ifndef HAS_UNIFORM_u_${s}\nuniform lowp float u_${s}_t;\nattribute ${i} ${o} a_${s};\nvarying ${i} ${a} ${s};\n#else\nuniform ${i} ${a} u_${s};\n#endif\n`:"vec4"===r?`\n#ifndef HAS_UNIFORM_u_${s}\n ${s} = a_${s};\n#else\n ${i} ${a} ${s} = u_${s};\n#endif\n`:`\n#ifndef HAS_UNIFORM_u_${s}\n ${s} = unpack_mix_${r}(a_${s}, u_${s}_t);\n#else\n ${i} ${a} ${s} = u_${s};\n#endif\n`:"define"===e?`\n#ifndef HAS_UNIFORM_u_${s}\nuniform lowp float u_${s}_t;\nattribute ${i} ${o} a_${s};\n#else\nuniform ${i} ${a} u_${s};\n#endif\n`:"vec4"===r?`\n#ifndef HAS_UNIFORM_u_${s}\n ${i} ${a} ${s} = a_${s};\n#else\n ${i} ${a} ${s} = u_${s};\n#endif\n`:`\n#ifndef HAS_UNIFORM_u_${s}\n ${i} ${a} ${s} = unpack_mix_${r}(a_${s}, u_${s}_t);\n#else\n ${i} ${a} ${s} = u_${s};\n#endif\n`})),staticAttributes:a,staticUniforms:r}}class fe{constructor(){this.boundProgram=null,this.boundLayoutVertexBuffer=null,this.boundPaintVertexBuffers=[],this.boundIndexBuffer=null,this.boundVertexOffset=null,this.boundDynamicVertexBuffer=null,this.vao=null;}bind(t,e,i,a,s,o,r,n,l){this.context=t;let h=this.boundPaintVertexBuffers.length!==a.length;for(let t=0;!h&&t({u_matrix:t,u_texture:0,u_ele_delta:i,u_fog_matrix:a,u_fog_color:s?s.properties.get("fog-color"):e.aM.white,u_fog_ground_blend:s?s.properties.get("fog-ground-blend"):1,u_fog_ground_blend_opacity:s?s.calculateFogBlendOpacity(o):0,u_horizon_color:s?s.properties.get("horizon-color"):e.aM.white,u_horizon_fog_blend:s?s.properties.get("horizon-fog-blend"):1});function ve(t){const e=[];for(let i=0;i({u_depth:new e.aH(t,i.u_depth),u_terrain:new e.aH(t,i.u_terrain),u_terrain_dim:new e.aI(t,i.u_terrain_dim),u_terrain_matrix:new e.aJ(t,i.u_terrain_matrix),u_terrain_unpack:new e.aK(t,i.u_terrain_unpack),u_terrain_exaggeration:new e.aI(t,i.u_terrain_exaggeration)}))(t,b),this.binderUniforms=a?a.getUniforms(t,b):[];}draw(t,e,i,a,s,o,r,n,l,h,c,u,d,_,p,m,f,g){const v=t.gl;if(this.failedToCreate)return;if(t.program.set(this.program),t.setDepthMode(i),t.setStencilMode(a),t.setColorMode(s),t.setCullFace(o),n){t.activeTexture.set(v.TEXTURE2),v.bindTexture(v.TEXTURE_2D,n.depthTexture),t.activeTexture.set(v.TEXTURE3),v.bindTexture(v.TEXTURE_2D,n.texture);for(const t in this.terrainUniforms)this.terrainUniforms[t].set(n[t]);}for(const t in this.fixedUniforms)this.fixedUniforms[t].set(r[t]);p&&p.setUniforms(t,this.binderUniforms,d,{zoom:_});let x=0;switch(e){case v.LINES:x=2;break;case v.TRIANGLES:x=3;break;case v.LINE_STRIP:x=1;}for(const i of u.get()){const a=i.vaos||(i.vaos={});(a[l]||(a[l]=new fe)).bind(t,this,h,p?p.getPaintVertexBuffers():[],c,i.vertexOffset,m,f,g),v.drawElements(e,i.primitiveLength*x,v.UNSIGNED_SHORT,i.primitiveOffset*x*2);}}}function ye(t,e,i){const a=1/Bt(i,1,e.transform.tileZoom),s=Math.pow(2,i.tileID.overscaledZ),o=i.tileSize*Math.pow(2,e.transform.tileZoom)/s,r=o*(i.tileID.canonical.x+i.tileID.wrap*s),n=o*i.tileID.canonical.y;return {u_image:0,u_texsize:i.imageAtlasTexture.size,u_scale:[a,t.fromScale,t.toScale],u_fade:t.t,u_pixel_coord_upper:[r>>16,n>>16],u_pixel_coord_lower:[65535&r,65535&n]}}const be=(t,i,a,s)=>{const o=i.style.light,r=o.properties.get("position"),n=[r.x,r.y,r.z],l=function(){var t=new e.A(9);return e.A!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[5]=0,t[6]=0,t[7]=0),t[0]=1,t[4]=1,t[8]=1,t}();"viewport"===o.properties.get("anchor")&&function(t,e){var i=Math.sin(e),a=Math.cos(e);t[0]=a,t[1]=i,t[2]=0,t[3]=-i,t[4]=a,t[5]=0,t[6]=0,t[7]=0,t[8]=1;}(l,-i.transform.angle),function(t,e,i){var a=e[0],s=e[1],o=e[2];t[0]=a*i[0]+s*i[3]+o*i[6],t[1]=a*i[1]+s*i[4]+o*i[7],t[2]=a*i[2]+s*i[5]+o*i[8];}(n,n,l);const h=o.properties.get("color");return {u_matrix:t,u_lightpos:n,u_lightintensity:o.properties.get("intensity"),u_lightcolor:[h.r,h.g,h.b],u_vertical_gradient:+a,u_opacity:s}},we=(t,i,a,s,o,r,n)=>e.e(be(t,i,a,s),ye(r,i,n),{u_height_factor:-Math.pow(2,o.overscaledZ)/n.tileSize/8}),Te=t=>({u_matrix:t}),Ie=(t,i,a,s)=>e.e(Te(t),ye(a,i,s)),Ee=(t,e)=>({u_matrix:t,u_world:e}),Pe=(t,i,a,s,o)=>e.e(Ie(t,i,a,s),{u_world:o}),Ce=(t,e,i,a)=>{const s=t.transform;let o,r;if("map"===a.paint.get("circle-pitch-alignment")){const t=Bt(i,1,s.zoom);o=!0,r=[t,t];}else o=!1,r=s.pixelsToGLUnits;return {u_camera_to_center_distance:s.cameraToCenterDistance,u_scale_with_map:+("map"===a.paint.get("circle-pitch-scale")),u_matrix:t.translatePosMatrix(e.posMatrix,i,a.paint.get("circle-translate"),a.paint.get("circle-translate-anchor")),u_pitch_with_map:+o,u_device_pixel_ratio:t.pixelRatio,u_extrude_scale:r}},Se=(t,e,i)=>({u_matrix:t,u_inv_matrix:e,u_camera_to_center_distance:i.cameraToCenterDistance,u_viewport_size:[i.width,i.height]}),ze=(t,e,i=1)=>({u_matrix:t,u_color:e,u_overlay:0,u_overlay_scale:i}),De=t=>({u_matrix:t}),Me=(t,e,i,a)=>({u_matrix:t,u_extrude_scale:Bt(e,1,i),u_intensity:a}),Ae=(t,i,a,s)=>{const o=e.H();e.aP(o,0,t.width,t.height,0,0,1);const r=t.context.gl;return {u_matrix:o,u_world:[r.drawingBufferWidth,r.drawingBufferHeight],u_image:a,u_color_ramp:s,u_opacity:i.paint.get("heatmap-opacity")}};function Re(t,i){const a=Math.pow(2,i.canonical.z),s=i.canonical.y;return [new e.Z(0,s/a).toLngLat().lat,new e.Z(0,(s+1)/a).toLngLat().lat]}const ke=(t,e,i,a)=>{const s=t.transform;return {u_matrix:Ne(t,e,i,a),u_ratio:1/Bt(e,1,s.zoom),u_device_pixel_ratio:t.pixelRatio,u_units_to_pixels:[1/s.pixelsToGLUnits[0],1/s.pixelsToGLUnits[1]]}},Le=(t,i,a,s,o)=>e.e(ke(t,i,a,o),{u_image:0,u_image_height:s}),Fe=(t,e,i,a,s)=>{const o=t.transform,r=Oe(e,o);return {u_matrix:Ne(t,e,i,s),u_texsize:e.imageAtlasTexture.size,u_ratio:1/Bt(e,1,o.zoom),u_device_pixel_ratio:t.pixelRatio,u_image:0,u_scale:[r,a.fromScale,a.toScale],u_fade:a.t,u_units_to_pixels:[1/o.pixelsToGLUnits[0],1/o.pixelsToGLUnits[1]]}},Be=(t,i,a,s,o,r)=>{const n=t.lineAtlas,l=Oe(i,t.transform),h="round"===a.layout.get("line-cap"),c=n.getDash(s.from,h),u=n.getDash(s.to,h),d=c.width*o.fromScale,_=u.width*o.toScale;return e.e(ke(t,i,a,r),{u_patternscale_a:[l/d,-c.height/2],u_patternscale_b:[l/_,-u.height/2],u_sdfgamma:n.width/(256*Math.min(d,_)*t.pixelRatio)/2,u_image:0,u_tex_y_a:c.y,u_tex_y_b:u.y,u_mix:o.t})};function Oe(t,e){return 1/Bt(t,1,e.tileZoom)}function Ne(t,e,i,a){return t.translatePosMatrix(a?a.posMatrix:e.tileID.posMatrix,e,i.paint.get("line-translate"),i.paint.get("line-translate-anchor"))}const Ue=(t,e,i,a,s)=>{return {u_matrix:t,u_tl_parent:e,u_scale_parent:i,u_buffer_scale:1,u_fade_t:a.mix,u_opacity:a.opacity*s.paint.get("raster-opacity"),u_image0:0,u_image1:1,u_brightness_low:s.paint.get("raster-brightness-min"),u_brightness_high:s.paint.get("raster-brightness-max"),u_saturation_factor:(r=s.paint.get("raster-saturation"),r>0?1-1/(1.001-r):-r),u_contrast_factor:(o=s.paint.get("raster-contrast"),o>0?1/(1-o):1+o),u_spin_weights:je(s.paint.get("raster-hue-rotate"))};var o,r;};function je(t){t*=Math.PI/180;const e=Math.sin(t),i=Math.cos(t);return [(2*i+1)/3,(-Math.sqrt(3)*e-i+1)/3,(Math.sqrt(3)*e-i+1)/3]}const Ze=(t,e,i,a,s,o,r,n,l,h,c,u,d,_)=>{const p=r.transform;return {u_is_size_zoom_constant:+("constant"===t||"source"===t),u_is_size_feature_constant:+("constant"===t||"camera"===t),u_size_t:e?e.uSizeT:0,u_size:e?e.uSize:0,u_camera_to_center_distance:p.cameraToCenterDistance,u_pitch:p.pitch/360*2*Math.PI,u_rotate_symbol:+i,u_aspect_ratio:p.width/p.height,u_fade_change:r.options.fadeDuration?r.symbolFadeChange:1,u_matrix:n,u_label_plane_matrix:l,u_coord_matrix:h,u_is_text:+u,u_pitch_with_map:+a,u_is_along_line:s,u_is_variable_anchor:o,u_texsize:d,u_texture:0,u_translation:c,u_pitched_scale:_}},qe=(t,i,a,s,o,r,n,l,h,c,u,d,_,p,m)=>{const f=n.transform;return e.e(Ze(t,i,a,s,o,r,n,l,h,c,u,d,_,m),{u_gamma_scale:s?Math.cos(f._pitch)*f.cameraToCenterDistance:1,u_device_pixel_ratio:n.pixelRatio,u_is_halo:+p})},Ve=(t,i,a,s,o,r,n,l,h,c,u,d,_,p)=>e.e(qe(t,i,a,s,o,r,n,l,h,c,u,!0,d,!0,p),{u_texsize_icon:_,u_texture_icon:1}),Ge=(t,e,i)=>({u_matrix:t,u_opacity:e,u_color:i}),He=(t,i,a,s,o,r)=>e.e(function(t,e,i,a){const s=i.imageManager.getPattern(t.from.toString()),o=i.imageManager.getPattern(t.to.toString()),{width:r,height:n}=i.imageManager.getPixelSize(),l=Math.pow(2,a.tileID.overscaledZ),h=a.tileSize*Math.pow(2,i.transform.tileZoom)/l,c=h*(a.tileID.canonical.x+a.tileID.wrap*l),u=h*a.tileID.canonical.y;return {u_image:0,u_pattern_tl_a:s.tl,u_pattern_br_a:s.br,u_pattern_tl_b:o.tl,u_pattern_br_b:o.br,u_texsize:[r,n],u_mix:e.t,u_pattern_size_a:s.displaySize,u_pattern_size_b:o.displaySize,u_scale_a:e.fromScale,u_scale_b:e.toScale,u_tile_units_to_pixels:1/Bt(a,1,i.transform.tileZoom),u_pixel_coord_upper:[c>>16,u>>16],u_pixel_coord_lower:[65535&c,65535&u]}}(s,r,a,o),{u_matrix:t,u_opacity:i}),We={fillExtrusion:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_lightpos:new e.aN(t,i.u_lightpos),u_lightintensity:new e.aI(t,i.u_lightintensity),u_lightcolor:new e.aN(t,i.u_lightcolor),u_vertical_gradient:new e.aI(t,i.u_vertical_gradient),u_opacity:new e.aI(t,i.u_opacity)}),fillExtrusionPattern:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_lightpos:new e.aN(t,i.u_lightpos),u_lightintensity:new e.aI(t,i.u_lightintensity),u_lightcolor:new e.aN(t,i.u_lightcolor),u_vertical_gradient:new e.aI(t,i.u_vertical_gradient),u_height_factor:new e.aI(t,i.u_height_factor),u_image:new e.aH(t,i.u_image),u_texsize:new e.aO(t,i.u_texsize),u_pixel_coord_upper:new e.aO(t,i.u_pixel_coord_upper),u_pixel_coord_lower:new e.aO(t,i.u_pixel_coord_lower),u_scale:new e.aN(t,i.u_scale),u_fade:new e.aI(t,i.u_fade),u_opacity:new e.aI(t,i.u_opacity)}),fill:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix)}),fillPattern:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_image:new e.aH(t,i.u_image),u_texsize:new e.aO(t,i.u_texsize),u_pixel_coord_upper:new e.aO(t,i.u_pixel_coord_upper),u_pixel_coord_lower:new e.aO(t,i.u_pixel_coord_lower),u_scale:new e.aN(t,i.u_scale),u_fade:new e.aI(t,i.u_fade)}),fillOutline:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_world:new e.aO(t,i.u_world)}),fillOutlinePattern:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_world:new e.aO(t,i.u_world),u_image:new e.aH(t,i.u_image),u_texsize:new e.aO(t,i.u_texsize),u_pixel_coord_upper:new e.aO(t,i.u_pixel_coord_upper),u_pixel_coord_lower:new e.aO(t,i.u_pixel_coord_lower),u_scale:new e.aN(t,i.u_scale),u_fade:new e.aI(t,i.u_fade)}),circle:(t,i)=>({u_camera_to_center_distance:new e.aI(t,i.u_camera_to_center_distance),u_scale_with_map:new e.aH(t,i.u_scale_with_map),u_pitch_with_map:new e.aH(t,i.u_pitch_with_map),u_extrude_scale:new e.aO(t,i.u_extrude_scale),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_matrix:new e.aJ(t,i.u_matrix)}),collisionBox:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_pixel_extrude_scale:new e.aO(t,i.u_pixel_extrude_scale)}),collisionCircle:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_inv_matrix:new e.aJ(t,i.u_inv_matrix),u_camera_to_center_distance:new e.aI(t,i.u_camera_to_center_distance),u_viewport_size:new e.aO(t,i.u_viewport_size)}),debug:(t,i)=>({u_color:new e.aL(t,i.u_color),u_matrix:new e.aJ(t,i.u_matrix),u_overlay:new e.aH(t,i.u_overlay),u_overlay_scale:new e.aI(t,i.u_overlay_scale)}),clippingMask:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix)}),heatmap:(t,i)=>({u_extrude_scale:new e.aI(t,i.u_extrude_scale),u_intensity:new e.aI(t,i.u_intensity),u_matrix:new e.aJ(t,i.u_matrix)}),heatmapTexture:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_world:new e.aO(t,i.u_world),u_image:new e.aH(t,i.u_image),u_color_ramp:new e.aH(t,i.u_color_ramp),u_opacity:new e.aI(t,i.u_opacity)}),hillshade:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_image:new e.aH(t,i.u_image),u_latrange:new e.aO(t,i.u_latrange),u_light:new e.aO(t,i.u_light),u_shadow:new e.aL(t,i.u_shadow),u_highlight:new e.aL(t,i.u_highlight),u_accent:new e.aL(t,i.u_accent)}),hillshadePrepare:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_image:new e.aH(t,i.u_image),u_dimension:new e.aO(t,i.u_dimension),u_zoom:new e.aI(t,i.u_zoom),u_unpack:new e.aK(t,i.u_unpack)}),line:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_ratio:new e.aI(t,i.u_ratio),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_units_to_pixels:new e.aO(t,i.u_units_to_pixels)}),lineGradient:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_ratio:new e.aI(t,i.u_ratio),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_units_to_pixels:new e.aO(t,i.u_units_to_pixels),u_image:new e.aH(t,i.u_image),u_image_height:new e.aI(t,i.u_image_height)}),linePattern:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_texsize:new e.aO(t,i.u_texsize),u_ratio:new e.aI(t,i.u_ratio),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_image:new e.aH(t,i.u_image),u_units_to_pixels:new e.aO(t,i.u_units_to_pixels),u_scale:new e.aN(t,i.u_scale),u_fade:new e.aI(t,i.u_fade)}),lineSDF:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_ratio:new e.aI(t,i.u_ratio),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_units_to_pixels:new e.aO(t,i.u_units_to_pixels),u_patternscale_a:new e.aO(t,i.u_patternscale_a),u_patternscale_b:new e.aO(t,i.u_patternscale_b),u_sdfgamma:new e.aI(t,i.u_sdfgamma),u_image:new e.aH(t,i.u_image),u_tex_y_a:new e.aI(t,i.u_tex_y_a),u_tex_y_b:new e.aI(t,i.u_tex_y_b),u_mix:new e.aI(t,i.u_mix)}),raster:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_tl_parent:new e.aO(t,i.u_tl_parent),u_scale_parent:new e.aI(t,i.u_scale_parent),u_buffer_scale:new e.aI(t,i.u_buffer_scale),u_fade_t:new e.aI(t,i.u_fade_t),u_opacity:new e.aI(t,i.u_opacity),u_image0:new e.aH(t,i.u_image0),u_image1:new e.aH(t,i.u_image1),u_brightness_low:new e.aI(t,i.u_brightness_low),u_brightness_high:new e.aI(t,i.u_brightness_high),u_saturation_factor:new e.aI(t,i.u_saturation_factor),u_contrast_factor:new e.aI(t,i.u_contrast_factor),u_spin_weights:new e.aN(t,i.u_spin_weights)}),symbolIcon:(t,i)=>({u_is_size_zoom_constant:new e.aH(t,i.u_is_size_zoom_constant),u_is_size_feature_constant:new e.aH(t,i.u_is_size_feature_constant),u_size_t:new e.aI(t,i.u_size_t),u_size:new e.aI(t,i.u_size),u_camera_to_center_distance:new e.aI(t,i.u_camera_to_center_distance),u_pitch:new e.aI(t,i.u_pitch),u_rotate_symbol:new e.aH(t,i.u_rotate_symbol),u_aspect_ratio:new e.aI(t,i.u_aspect_ratio),u_fade_change:new e.aI(t,i.u_fade_change),u_matrix:new e.aJ(t,i.u_matrix),u_label_plane_matrix:new e.aJ(t,i.u_label_plane_matrix),u_coord_matrix:new e.aJ(t,i.u_coord_matrix),u_is_text:new e.aH(t,i.u_is_text),u_pitch_with_map:new e.aH(t,i.u_pitch_with_map),u_is_along_line:new e.aH(t,i.u_is_along_line),u_is_variable_anchor:new e.aH(t,i.u_is_variable_anchor),u_texsize:new e.aO(t,i.u_texsize),u_texture:new e.aH(t,i.u_texture),u_translation:new e.aO(t,i.u_translation),u_pitched_scale:new e.aI(t,i.u_pitched_scale)}),symbolSDF:(t,i)=>({u_is_size_zoom_constant:new e.aH(t,i.u_is_size_zoom_constant),u_is_size_feature_constant:new e.aH(t,i.u_is_size_feature_constant),u_size_t:new e.aI(t,i.u_size_t),u_size:new e.aI(t,i.u_size),u_camera_to_center_distance:new e.aI(t,i.u_camera_to_center_distance),u_pitch:new e.aI(t,i.u_pitch),u_rotate_symbol:new e.aH(t,i.u_rotate_symbol),u_aspect_ratio:new e.aI(t,i.u_aspect_ratio),u_fade_change:new e.aI(t,i.u_fade_change),u_matrix:new e.aJ(t,i.u_matrix),u_label_plane_matrix:new e.aJ(t,i.u_label_plane_matrix),u_coord_matrix:new e.aJ(t,i.u_coord_matrix),u_is_text:new e.aH(t,i.u_is_text),u_pitch_with_map:new e.aH(t,i.u_pitch_with_map),u_is_along_line:new e.aH(t,i.u_is_along_line),u_is_variable_anchor:new e.aH(t,i.u_is_variable_anchor),u_texsize:new e.aO(t,i.u_texsize),u_texture:new e.aH(t,i.u_texture),u_gamma_scale:new e.aI(t,i.u_gamma_scale),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_is_halo:new e.aH(t,i.u_is_halo),u_translation:new e.aO(t,i.u_translation),u_pitched_scale:new e.aI(t,i.u_pitched_scale)}),symbolTextAndIcon:(t,i)=>({u_is_size_zoom_constant:new e.aH(t,i.u_is_size_zoom_constant),u_is_size_feature_constant:new e.aH(t,i.u_is_size_feature_constant),u_size_t:new e.aI(t,i.u_size_t),u_size:new e.aI(t,i.u_size),u_camera_to_center_distance:new e.aI(t,i.u_camera_to_center_distance),u_pitch:new e.aI(t,i.u_pitch),u_rotate_symbol:new e.aH(t,i.u_rotate_symbol),u_aspect_ratio:new e.aI(t,i.u_aspect_ratio),u_fade_change:new e.aI(t,i.u_fade_change),u_matrix:new e.aJ(t,i.u_matrix),u_label_plane_matrix:new e.aJ(t,i.u_label_plane_matrix),u_coord_matrix:new e.aJ(t,i.u_coord_matrix),u_is_text:new e.aH(t,i.u_is_text),u_pitch_with_map:new e.aH(t,i.u_pitch_with_map),u_is_along_line:new e.aH(t,i.u_is_along_line),u_is_variable_anchor:new e.aH(t,i.u_is_variable_anchor),u_texsize:new e.aO(t,i.u_texsize),u_texsize_icon:new e.aO(t,i.u_texsize_icon),u_texture:new e.aH(t,i.u_texture),u_texture_icon:new e.aH(t,i.u_texture_icon),u_gamma_scale:new e.aI(t,i.u_gamma_scale),u_device_pixel_ratio:new e.aI(t,i.u_device_pixel_ratio),u_is_halo:new e.aH(t,i.u_is_halo),u_translation:new e.aO(t,i.u_translation),u_pitched_scale:new e.aI(t,i.u_pitched_scale)}),background:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_opacity:new e.aI(t,i.u_opacity),u_color:new e.aL(t,i.u_color)}),backgroundPattern:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_opacity:new e.aI(t,i.u_opacity),u_image:new e.aH(t,i.u_image),u_pattern_tl_a:new e.aO(t,i.u_pattern_tl_a),u_pattern_br_a:new e.aO(t,i.u_pattern_br_a),u_pattern_tl_b:new e.aO(t,i.u_pattern_tl_b),u_pattern_br_b:new e.aO(t,i.u_pattern_br_b),u_texsize:new e.aO(t,i.u_texsize),u_mix:new e.aI(t,i.u_mix),u_pattern_size_a:new e.aO(t,i.u_pattern_size_a),u_pattern_size_b:new e.aO(t,i.u_pattern_size_b),u_scale_a:new e.aI(t,i.u_scale_a),u_scale_b:new e.aI(t,i.u_scale_b),u_pixel_coord_upper:new e.aO(t,i.u_pixel_coord_upper),u_pixel_coord_lower:new e.aO(t,i.u_pixel_coord_lower),u_tile_units_to_pixels:new e.aI(t,i.u_tile_units_to_pixels)}),terrain:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_texture:new e.aH(t,i.u_texture),u_ele_delta:new e.aI(t,i.u_ele_delta),u_fog_matrix:new e.aJ(t,i.u_fog_matrix),u_fog_color:new e.aL(t,i.u_fog_color),u_fog_ground_blend:new e.aI(t,i.u_fog_ground_blend),u_fog_ground_blend_opacity:new e.aI(t,i.u_fog_ground_blend_opacity),u_horizon_color:new e.aL(t,i.u_horizon_color),u_horizon_fog_blend:new e.aI(t,i.u_horizon_fog_blend)}),terrainDepth:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_ele_delta:new e.aI(t,i.u_ele_delta)}),terrainCoords:(t,i)=>({u_matrix:new e.aJ(t,i.u_matrix),u_texture:new e.aH(t,i.u_texture),u_terrain_coords_id:new e.aI(t,i.u_terrain_coords_id),u_ele_delta:new e.aI(t,i.u_ele_delta)}),sky:(t,i)=>({u_sky_color:new e.aL(t,i.u_sky_color),u_horizon_color:new e.aL(t,i.u_horizon_color),u_horizon:new e.aI(t,i.u_horizon),u_sky_horizon_blend:new e.aI(t,i.u_sky_horizon_blend)})};class $e{constructor(t,e,i){this.context=t;const a=t.gl;this.buffer=a.createBuffer(),this.dynamicDraw=Boolean(i),this.context.unbindVAO(),t.bindElementBuffer.set(this.buffer),a.bufferData(a.ELEMENT_ARRAY_BUFFER,e.arrayBuffer,this.dynamicDraw?a.DYNAMIC_DRAW:a.STATIC_DRAW),this.dynamicDraw||delete e.arrayBuffer;}bind(){this.context.bindElementBuffer.set(this.buffer);}updateData(t){const e=this.context.gl;if(!this.dynamicDraw)throw new Error("Attempted to update data while not in dynamic mode.");this.context.unbindVAO(),this.bind(),e.bufferSubData(e.ELEMENT_ARRAY_BUFFER,0,t.arrayBuffer);}destroy(){this.buffer&&(this.context.gl.deleteBuffer(this.buffer),delete this.buffer);}}const Xe={Int8:"BYTE",Uint8:"UNSIGNED_BYTE",Int16:"SHORT",Uint16:"UNSIGNED_SHORT",Int32:"INT",Uint32:"UNSIGNED_INT",Float32:"FLOAT"};class Ke{constructor(t,e,i,a){this.length=e.length,this.attributes=i,this.itemSize=e.bytesPerElement,this.dynamicDraw=a,this.context=t;const s=t.gl;this.buffer=s.createBuffer(),t.bindVertexBuffer.set(this.buffer),s.bufferData(s.ARRAY_BUFFER,e.arrayBuffer,this.dynamicDraw?s.DYNAMIC_DRAW:s.STATIC_DRAW),this.dynamicDraw||delete e.arrayBuffer;}bind(){this.context.bindVertexBuffer.set(this.buffer);}updateData(t){if(t.length!==this.length)throw new Error(`Length of new data is ${t.length}, which doesn't match current length of ${this.length}`);const e=this.context.gl;this.bind(),e.bufferSubData(e.ARRAY_BUFFER,0,t.arrayBuffer);}enableAttributes(t,e){for(let i=0;i0){const i=e.H();e.aQ(i,m.placementInvProjMatrix,t.transform.glCoordMatrix),e.aQ(i,i,m.placementViewportMatrix),h.push({circleArray:g,circleOffset:u,transform:p.posMatrix,invTransform:i,coord:p}),c+=g.length/4,u=c;}f&&l.draw(r,n.LINES,Oi.disabled,Ui.disabled,t.colorModeForRenderPass(),ji.disabled,{u_matrix:p.posMatrix,u_pixel_extrude_scale:[1/(d=t.transform).width,1/d.height]},t.style.map.terrain&&t.style.map.terrain.getTerrainData(p),a.id,f.layoutVertexBuffer,f.indexBuffer,f.segments,null,t.transform.zoom,null,null,f.collisionVertexBuffer);}var d;if(!o||!h.length)return;const _=t.useProgram("collisionCircle"),p=new e.aR;p.resize(4*c),p._trim();let m=0;for(const t of h)for(let e=0;e=0&&(v[x.associatedIconIndex]={shiftedAnchor:D,angle:M});}else Rt(x.numGlyphs,f);}if(c){g.clear();const i=t.icon.placedSymbolArray;for(let t=0;tt.style.map.terrain.getElevation(l,e,i):null,i="map"===a.layout.get("text-rotation-alignment");wt(h,l.posMatrix,t,o,U,Z,v,c,i,f,l.toUnwrapped(),m.width,m.height,q,e);}const H=l.posMatrix,W=o&&E||G,$=x||W?Vi:U,X=j,K=_&&0!==a.paint.get(o?"text-halo-width":"icon-halo-width").constantOr(1);let J;J=_?h.iconsInText?Ve(I.kind,D,y,v,x,W,t,H,$,X,q,A,F,C):qe(I.kind,D,y,v,x,W,t,H,$,X,q,o,A,!0,C):Ze(I.kind,D,y,v,x,W,t,H,$,X,q,o,A,C);const Y={program:z,buffers:u,uniformValues:J,atlasTexture:R,atlasTextureIcon:B,atlasInterpolation:k,atlasInterpolationIcon:L,isSDF:_,hasHalo:K};if(w&&h.canOverlap){T=!0;const t=u.segments.get();for(const i of t)P.push({segments:new e.a0([i]),sortKey:i.sortKey,state:Y,terrainData:M});}else P.push({segments:u.segments,sortKey:0,state:Y,terrainData:M});}T&&P.sort(((t,e)=>t.sortKey-e.sortKey));for(const e of P){const i=e.state;if(_.activeTexture.set(p.TEXTURE0),i.atlasTexture.bind(i.atlasInterpolation,p.CLAMP_TO_EDGE),i.atlasTextureIcon&&(_.activeTexture.set(p.TEXTURE1),i.atlasTextureIcon&&i.atlasTextureIcon.bind(i.atlasInterpolationIcon,p.CLAMP_TO_EDGE)),i.isSDF){const s=i.uniformValues;i.hasHalo&&(s.u_is_halo=1,Ki(i.buffers,e.segments,a,t,i.program,I,u,d,s,e.terrainData)),s.u_is_halo=0;}Ki(i.buffers,e.segments,a,t,i.program,I,u,d,i.uniformValues,e.terrainData);}}function Ki(t,e,i,a,s,o,r,n,l,h){const c=a.context;s.draw(c,c.gl.TRIANGLES,o,r,n,ji.disabled,l,h,i.id,t.layoutVertexBuffer,t.indexBuffer,e,i.paint,a.transform.zoom,t.programConfigurations.get(i.id),t.dynamicLayoutVertexBuffer,t.opacityVertexBuffer);}function Ji(t,i,a,s){const o=t.context,r=o.gl,n=Ui.disabled,l=new Fi([r.ONE,r.ONE],e.aM.transparent,[!0,!0,!0,!0]),h=i.getBucket(a);if(!h)return;const c=s.key;let u=a.heatmapFbos.get(c);u||(u=Qi(o,i.tileSize,i.tileSize),a.heatmapFbos.set(c,u)),o.bindFramebuffer.set(u.framebuffer),o.viewport.set([0,0,i.tileSize,i.tileSize]),o.clear({color:e.aM.transparent});const d=h.programConfigurations.get(a.id),_=t.useProgram("heatmap",d),p=t.style.map.terrain.getTerrainData(s);_.draw(o,r.TRIANGLES,Oi.disabled,n,l,ji.disabled,Me(s.posMatrix,i,t.transform.zoom,a.paint.get("heatmap-intensity")),p,a.id,h.layoutVertexBuffer,h.indexBuffer,h.segments,a.paint,t.transform.zoom,d);}function Yi(t,e,i){const a=t.context,s=a.gl;a.setColorMode(t.colorModeForRenderPass());const o=ta(a,e),r=i.key,n=e.heatmapFbos.get(r);n&&(a.activeTexture.set(s.TEXTURE0),s.bindTexture(s.TEXTURE_2D,n.colorAttachment.get()),a.activeTexture.set(s.TEXTURE1),o.bind(s.LINEAR,s.CLAMP_TO_EDGE),t.useProgram("heatmapTexture").draw(a,s.TRIANGLES,Oi.disabled,Ui.disabled,t.colorModeForRenderPass(),ji.disabled,Ae(t,e,0,1),null,e.id,t.rasterBoundsBuffer,t.quadTriangleIndexBuffer,t.rasterBoundsSegments,e.paint,t.transform.zoom),n.destroy(),e.heatmapFbos.delete(r));}function Qi(t,e,i){var a,s;const o=t.gl,r=o.createTexture();o.bindTexture(o.TEXTURE_2D,r),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_WRAP_S,o.CLAMP_TO_EDGE),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_WRAP_T,o.CLAMP_TO_EDGE),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_MIN_FILTER,o.LINEAR),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_MAG_FILTER,o.LINEAR);const n=null!==(a=t.HALF_FLOAT)&&void 0!==a?a:o.UNSIGNED_BYTE,l=null!==(s=t.RGBA16F)&&void 0!==s?s:o.RGBA;o.texImage2D(o.TEXTURE_2D,0,l,e,i,0,o.RGBA,n,null);const h=t.createFramebuffer(e,i,!1,!1);return h.colorAttachment.set(r),h}function ta(t,e){return e.colorRampTexture||(e.colorRampTexture=new b(t,e.colorRamp,t.gl.RGBA)),e.colorRampTexture}function ea(t,e,i,a,s){if(!i||!a||!a.imageAtlas)return;const o=a.imageAtlas.patternPositions;let r=o[i.to.toString()],n=o[i.from.toString()];if(!r&&n&&(r=n),!n&&r&&(n=r),!r||!n){const t=s.getPaintProperty(e);r=o[t],n=o[t];}r&&n&&t.setConstantPatternPositions(r,n);}function ia(t,e,i,a,s,o,r){const n=t.context.gl,l="fill-pattern",h=i.paint.get(l),c=h&&h.constantOr(1),u=i.getCrossfadeParameters();let d,_,p,m,f;r?(_=c&&!i.getPaintProperty("fill-outline-color")?"fillOutlinePattern":"fillOutline",d=n.LINES):(_=c?"fillPattern":"fill",d=n.TRIANGLES);const g=h.constantOr(null);for(const h of a){const a=e.getTile(h);if(c&&!a.patternsLoaded())continue;const v=a.getBucket(i);if(!v)continue;const x=v.programConfigurations.get(i.id),y=t.useProgram(_,x),b=t.style.map.terrain&&t.style.map.terrain.getTerrainData(h);c&&(t.context.activeTexture.set(n.TEXTURE0),a.imageAtlasTexture.bind(n.LINEAR,n.CLAMP_TO_EDGE),x.updatePaintBuffers(u)),ea(x,l,g,a,i);const w=b?h:null,T=t.translatePosMatrix(w?w.posMatrix:h.posMatrix,a,i.paint.get("fill-translate"),i.paint.get("fill-translate-anchor"));if(r){m=v.indexBuffer2,f=v.segments2;const e=[n.drawingBufferWidth,n.drawingBufferHeight];p="fillOutlinePattern"===_&&c?Pe(T,t,u,a,e):Ee(T,e);}else m=v.indexBuffer,f=v.segments,p=c?Ie(T,t,u,a):Te(T);y.draw(t.context,d,s,t.stencilModeForClipping(h),o,ji.disabled,p,b,i.id,v.layoutVertexBuffer,m,f,i.paint,t.transform.zoom,x);}}function aa(t,e,i,a,s,o,r){const n=t.context,l=n.gl,h="fill-extrusion-pattern",c=i.paint.get(h),u=c.constantOr(1),d=i.getCrossfadeParameters(),_=i.paint.get("fill-extrusion-opacity"),p=c.constantOr(null);for(const c of a){const a=e.getTile(c),m=a.getBucket(i);if(!m)continue;const f=t.style.map.terrain&&t.style.map.terrain.getTerrainData(c),g=m.programConfigurations.get(i.id),v=t.useProgram(u?"fillExtrusionPattern":"fillExtrusion",g);u&&(t.context.activeTexture.set(l.TEXTURE0),a.imageAtlasTexture.bind(l.LINEAR,l.CLAMP_TO_EDGE),g.updatePaintBuffers(d)),ea(g,h,p,a,i);const x=t.translatePosMatrix(c.posMatrix,a,i.paint.get("fill-extrusion-translate"),i.paint.get("fill-extrusion-translate-anchor")),y=i.paint.get("fill-extrusion-vertical-gradient"),b=u?we(x,t,y,_,c,d,a):be(x,t,y,_);v.draw(n,n.gl.TRIANGLES,s,o,r,ji.backCCW,b,f,i.id,m.layoutVertexBuffer,m.indexBuffer,m.segments,i.paint,t.transform.zoom,g,t.style.map.terrain&&m.centroidVertexBuffer);}}function sa(t,e,i,a,s,o,r){const n=t.context,l=n.gl,h=i.fbo;if(!h)return;const c=t.useProgram("hillshade"),u=t.style.map.terrain&&t.style.map.terrain.getTerrainData(e);n.activeTexture.set(l.TEXTURE0),l.bindTexture(l.TEXTURE_2D,h.colorAttachment.get()),c.draw(n,l.TRIANGLES,s,o,r,ji.disabled,((t,e,i,a)=>{const s=i.paint.get("hillshade-shadow-color"),o=i.paint.get("hillshade-highlight-color"),r=i.paint.get("hillshade-accent-color");let n=i.paint.get("hillshade-illumination-direction")*(Math.PI/180);"viewport"===i.paint.get("hillshade-illumination-anchor")&&(n-=t.transform.angle);const l=!t.options.moving;return {u_matrix:a?a.posMatrix:t.transform.calculatePosMatrix(e.tileID.toUnwrapped(),l),u_image:0,u_latrange:Re(0,e.tileID),u_light:[i.paint.get("hillshade-exaggeration"),n],u_shadow:s,u_highlight:o,u_accent:r}})(t,i,a,u?e:null),u,a.id,t.rasterBoundsBuffer,t.quadTriangleIndexBuffer,t.rasterBoundsSegments);}function oa(t,i,a,s,o,r){const n=t.context,l=n.gl,h=i.dem;if(h&&h.data){const c=h.dim,u=h.stride,d=h.getPixels();if(n.activeTexture.set(l.TEXTURE1),n.pixelStoreUnpackPremultiplyAlpha.set(!1),i.demTexture=i.demTexture||t.getTileTexture(u),i.demTexture){const t=i.demTexture;t.update(d,{premultiply:!1}),t.bind(l.NEAREST,l.CLAMP_TO_EDGE);}else i.demTexture=new b(n,d,l.RGBA,{premultiply:!1}),i.demTexture.bind(l.NEAREST,l.CLAMP_TO_EDGE);n.activeTexture.set(l.TEXTURE0);let _=i.fbo;if(!_){const t=new b(n,{width:c,height:c,data:null},l.RGBA);t.bind(l.LINEAR,l.CLAMP_TO_EDGE),_=i.fbo=n.createFramebuffer(c,c,!0,!1),_.colorAttachment.set(t.texture);}n.bindFramebuffer.set(_.framebuffer),n.viewport.set([0,0,c,c]),t.useProgram("hillshadePrepare").draw(n,l.TRIANGLES,s,o,r,ji.disabled,((t,i)=>{const a=i.stride,s=e.H();return e.aP(s,0,e.X,-e.X,0,0,1),e.J(s,s,[0,-e.X,0]),{u_matrix:s,u_image:1,u_dimension:[a,a],u_zoom:t.overscaledZ,u_unpack:i.getUnpackVector()}})(i.tileID,h),null,a.id,t.rasterBoundsBuffer,t.quadTriangleIndexBuffer,t.rasterBoundsSegments),i.needsHillshadePrepare=!1;}}function ra(t,i,a,s,r,n){const l=s.paint.get("raster-fade-duration");if(!n&&l>0){const s=o.now(),n=(s-t.timeAdded)/l,h=i?(s-i.timeAdded)/l:-1,c=a.getSource(),u=r.coveringZoomLevel({tileSize:c.tileSize,roundZoom:c.roundZoom}),d=!i||Math.abs(i.tileID.overscaledZ-u)>Math.abs(t.tileID.overscaledZ-u),_=d&&t.refreshedUponExpiration?1:e.ac(d?n:1-h,0,1);return t.refreshedUponExpiration&&n>=1&&(t.refreshedUponExpiration=!1),i?{opacity:1,mix:1-_}:{opacity:_,mix:0}}return {opacity:1,mix:0}}const na=new e.aM(1,0,0,1),la=new e.aM(0,1,0,1),ha=new e.aM(0,0,1,1),ca=new e.aM(1,0,1,1),ua=new e.aM(0,1,1,1);function da(t,e,i,a){pa(t,0,e+i/2,t.transform.width,i,a);}function _a(t,e,i,a){pa(t,e-i/2,0,i,t.transform.height,a);}function pa(t,e,i,a,s,o){const r=t.context,n=r.gl;n.enable(n.SCISSOR_TEST),n.scissor(e*t.pixelRatio,i*t.pixelRatio,a*t.pixelRatio,s*t.pixelRatio),r.clear({color:o}),n.disable(n.SCISSOR_TEST);}function ma(t,i,a){const s=t.context,o=s.gl,r=a.posMatrix,n=t.useProgram("debug"),l=Oi.disabled,h=Ui.disabled,c=t.colorModeForRenderPass(),u="$debug",d=t.style.map.terrain&&t.style.map.terrain.getTerrainData(a);s.activeTexture.set(o.TEXTURE0);const _=i.getTileByID(a.key).latestRawTileData,p=Math.floor((_&&_.byteLength||0)/1024),m=i.getTile(a).tileSize,f=512/Math.min(m,512)*(a.overscaledZ/t.transform.zoom)*.5;let g=a.canonical.toString();a.overscaledZ!==a.canonical.z&&(g+=` => ${a.overscaledZ}`),function(t,e){t.initDebugOverlayCanvas();const i=t.debugOverlayCanvas,a=t.context.gl,s=t.debugOverlayCanvas.getContext("2d");s.clearRect(0,0,i.width,i.height),s.shadowColor="white",s.shadowBlur=2,s.lineWidth=1.5,s.strokeStyle="white",s.textBaseline="top",s.font="bold 36px Open Sans, sans-serif",s.fillText(e,5,5),s.strokeText(e,5,5),t.debugOverlayTexture.update(i),t.debugOverlayTexture.bind(a.LINEAR,a.CLAMP_TO_EDGE);}(t,`${g} ${p}kB`),n.draw(s,o.TRIANGLES,l,h,Fi.alphaBlended,ji.disabled,ze(r,e.aM.transparent,f),null,u,t.debugBuffer,t.quadTriangleIndexBuffer,t.debugSegments),n.draw(s,o.LINE_STRIP,l,h,c,ji.disabled,ze(r,e.aM.red),d,u,t.debugBuffer,t.tileBorderIndexBuffer,t.debugSegments);}function fa(t,e,i){const a=t.context,s=a.gl,o=t.colorModeForRenderPass(),r=new Oi(s.LEQUAL,Oi.ReadWrite,t.depthRangeFor3D),n=t.useProgram("terrain"),l=e.getTerrainMesh();a.bindFramebuffer.set(null),a.viewport.set([0,0,t.width,t.height]);for(const h of i){const i=t.renderToTexture.getTexture(h),c=e.getTerrainData(h.tileID);a.activeTexture.set(s.TEXTURE0),s.bindTexture(s.TEXTURE_2D,i.texture);const u=t.transform.calculatePosMatrix(h.tileID.toUnwrapped()),d=e.getMeshFrameDelta(t.transform.zoom),_=t.transform.calculateFogMatrix(h.tileID.toUnwrapped()),p=ge(u,d,_,t.style.sky,t.transform.pitch);n.draw(a,s.TRIANGLES,r,Ui.disabled,o,ji.backCCW,p,c,"terrain",l.vertexBuffer,l.indexBuffer,l.segments);}}class ga{constructor(t,e,i){this.vertexBuffer=t,this.indexBuffer=e,this.segments=i;}destroy(){this.vertexBuffer.destroy(),this.indexBuffer.destroy(),this.segments.destroy(),this.vertexBuffer=null,this.indexBuffer=null,this.segments=null;}}class va{constructor(t,i){this.context=new Bi(t),this.transform=i,this._tileTextures={},this.terrainFacilitator={dirty:!0,matrix:e.an(new Float64Array(16)),renderTime:0},this.setup(),this.numSublayers=ut.maxUnderzooming+ut.maxOverzooming+1,this.depthEpsilon=1/Math.pow(2,16),this.crossTileSymbolIndex=new he;}resize(t,e,i){if(this.width=Math.floor(t*i),this.height=Math.floor(e*i),this.pixelRatio=i,this.context.viewport.set([0,0,this.width,this.height]),this.style)for(const t of this.style._order)this.style._layers[t].resize();}setup(){const t=this.context,i=new e.aX;i.emplaceBack(0,0),i.emplaceBack(e.X,0),i.emplaceBack(0,e.X),i.emplaceBack(e.X,e.X),this.tileExtentBuffer=t.createVertexBuffer(i,_e.members),this.tileExtentSegments=e.a0.simpleSegment(0,0,4,2);const a=new e.aX;a.emplaceBack(0,0),a.emplaceBack(e.X,0),a.emplaceBack(0,e.X),a.emplaceBack(e.X,e.X),this.debugBuffer=t.createVertexBuffer(a,_e.members),this.debugSegments=e.a0.simpleSegment(0,0,4,5);const s=new e.$;s.emplaceBack(0,0,0,0),s.emplaceBack(e.X,0,e.X,0),s.emplaceBack(0,e.X,0,e.X),s.emplaceBack(e.X,e.X,e.X,e.X),this.rasterBoundsBuffer=t.createVertexBuffer(s,Y.members),this.rasterBoundsSegments=e.a0.simpleSegment(0,0,4,2);const o=new e.aX;o.emplaceBack(0,0),o.emplaceBack(1,0),o.emplaceBack(0,1),o.emplaceBack(1,1),this.viewportBuffer=t.createVertexBuffer(o,_e.members),this.viewportSegments=e.a0.simpleSegment(0,0,4,2);const r=new e.aZ;r.emplaceBack(0),r.emplaceBack(1),r.emplaceBack(3),r.emplaceBack(2),r.emplaceBack(0),this.tileBorderIndexBuffer=t.createIndexBuffer(r);const n=new e.aY;n.emplaceBack(0,1,2),n.emplaceBack(2,1,3),this.quadTriangleIndexBuffer=t.createIndexBuffer(n);const l=this.context.gl;this.stencilClearMode=new Ui({func:l.ALWAYS,mask:0},0,255,l.ZERO,l.ZERO,l.ZERO);}clearStencil(){const t=this.context,i=t.gl;this.nextStencilID=1,this.currentStencilSource=void 0;const a=e.H();e.aP(a,0,this.width,this.height,0,0,1),e.K(a,a,[i.drawingBufferWidth,i.drawingBufferHeight,0]),this.useProgram("clippingMask").draw(t,i.TRIANGLES,Oi.disabled,this.stencilClearMode,Fi.disabled,ji.disabled,De(a),null,"$clipping",this.viewportBuffer,this.quadTriangleIndexBuffer,this.viewportSegments);}_renderTileClippingMasks(t,e){if(this.currentStencilSource===t.source||!t.isTileClipped()||!e||!e.length)return;this.currentStencilSource=t.source;const i=this.context,a=i.gl;this.nextStencilID+e.length>256&&this.clearStencil(),i.setColorMode(Fi.disabled),i.setDepthMode(Oi.disabled);const s=this.useProgram("clippingMask");this._tileClippingMaskIDs={};for(const t of e){const e=this._tileClippingMaskIDs[t.key]=this.nextStencilID++,o=this.style.map.terrain&&this.style.map.terrain.getTerrainData(t);s.draw(i,a.TRIANGLES,Oi.disabled,new Ui({func:a.ALWAYS,mask:0},e,255,a.KEEP,a.KEEP,a.REPLACE),Fi.disabled,ji.disabled,De(t.posMatrix),o,"$clipping",this.tileExtentBuffer,this.quadTriangleIndexBuffer,this.tileExtentSegments);}}stencilModeFor3D(){this.currentStencilSource=void 0,this.nextStencilID+1>256&&this.clearStencil();const t=this.nextStencilID++,e=this.context.gl;return new Ui({func:e.NOTEQUAL,mask:255},t,255,e.KEEP,e.KEEP,e.REPLACE)}stencilModeForClipping(t){const e=this.context.gl;return new Ui({func:e.EQUAL,mask:255},this._tileClippingMaskIDs[t.key],0,e.KEEP,e.KEEP,e.REPLACE)}stencilConfigForOverlap(t){const e=this.context.gl,i=t.sort(((t,e)=>e.overscaledZ-t.overscaledZ)),a=i[i.length-1].overscaledZ,s=i[0].overscaledZ-a+1;if(s>1){this.currentStencilSource=void 0,this.nextStencilID+s>256&&this.clearStencil();const t={};for(let i=0;i({u_sky_color:t.properties.get("sky-color"),u_horizon_color:t.properties.get("horizon-color"),u_horizon:(e.height/2+e.getHorizon())*i,u_sky_horizon_blend:t.properties.get("sky-horizon-blend")*e.height/2*i}))(i,t.style.map.transform,t.pixelRatio),r=new Oi(s.LEQUAL,Oi.ReadWrite,[0,1]),n=Ui.disabled,l=t.colorModeForRenderPass(),h=t.useProgram("sky");if(!i.mesh){const t=new e.aX;t.emplaceBack(-1,-1),t.emplaceBack(1,-1),t.emplaceBack(1,1),t.emplaceBack(-1,1);const s=new e.aY;s.emplaceBack(0,1,2),s.emplaceBack(0,2,3),i.mesh=new ga(a.createVertexBuffer(t,_e.members),a.createIndexBuffer(s),e.a0.simpleSegment(0,0,t.length,s.length));}h.draw(a,s.TRIANGLES,r,n,l,ji.disabled,o,void 0,"sky",i.mesh.vertexBuffer,i.mesh.indexBuffer,i.mesh.segments);}(this,this.style.sky),this._showOverdrawInspector=i.showOverdrawInspector,this.depthRangeFor3D=[0,1-(t._order.length+2)*this.numSublayers*this.depthEpsilon],!this.renderToTexture)for(this.renderPass="opaque",this.currentLayer=a.length-1;this.currentLayer>=0;this.currentLayer--){const t=this.style._layers[a[this.currentLayer]],e=s[t.source],i=r[t.source];this._renderTileClippingMasks(t,i),this.renderLayer(this,e,t,i);}for(this.renderPass="translucent",this.currentLayer=0;this.currentLayeri.source&&!i.isHidden(e)?[t.sourceCaches[i.source]]:[])),s=a.filter((t=>"vector"===t.getSource().type)),o=a.filter((t=>"vector"!==t.getSource().type)),r=t=>{(!i||i.getSource().maxzoomr(t))),i||o.forEach((t=>r(t))),i}(this.style,this.transform.zoom);t&&function(t,e,i){for(let a=0;a0),s&&(e.b0(i,a),this.terrainFacilitator.renderTime=Date.now(),this.terrainFacilitator.dirty=!1,function(t,i){const a=t.context,s=a.gl,o=Fi.unblended,r=new Oi(s.LEQUAL,Oi.ReadWrite,[0,1]),n=i.getTerrainMesh(),l=i.sourceCache.getRenderableTiles(),h=t.useProgram("terrainDepth");a.bindFramebuffer.set(i.getFramebuffer("depth").framebuffer),a.viewport.set([0,0,t.width/devicePixelRatio,t.height/devicePixelRatio]),a.clear({color:e.aM.transparent,depth:1});for(const e of l){const l=i.getTerrainData(e.tileID),c={u_matrix:t.transform.calculatePosMatrix(e.tileID.toUnwrapped()),u_ele_delta:i.getMeshFrameDelta(t.transform.zoom)};h.draw(a,s.TRIANGLES,r,Ui.disabled,o,ji.backCCW,c,l,"terrain",n.vertexBuffer,n.indexBuffer,n.segments);}a.bindFramebuffer.set(null),a.viewport.set([0,0,t.width,t.height]);}(this,this.style.map.terrain),function(t,i){const a=t.context,s=a.gl,o=Fi.unblended,r=new Oi(s.LEQUAL,Oi.ReadWrite,[0,1]),n=i.getTerrainMesh(),l=i.getCoordsTexture(),h=i.sourceCache.getRenderableTiles(),c=t.useProgram("terrainCoords");a.bindFramebuffer.set(i.getFramebuffer("coords").framebuffer),a.viewport.set([0,0,t.width/devicePixelRatio,t.height/devicePixelRatio]),a.clear({color:e.aM.transparent,depth:1}),i.coordsIndex=[];for(const e of h){const h=i.getTerrainData(e.tileID);a.activeTexture.set(s.TEXTURE0),s.bindTexture(s.TEXTURE_2D,l.texture);const u={u_matrix:t.transform.calculatePosMatrix(e.tileID.toUnwrapped()),u_terrain_coords_id:(255-i.coordsIndex.length)/255,u_texture:0,u_ele_delta:i.getMeshFrameDelta(t.transform.zoom)};c.draw(a,s.TRIANGLES,r,Ui.disabled,o,ji.backCCW,u,h,"terrain",n.vertexBuffer,n.indexBuffer,n.segments),i.coordsIndex.push(e.tileID.key);}a.bindFramebuffer.set(null),a.viewport.set([0,0,t.width,t.height]);}(this,this.style.map.terrain));}renderLayer(t,i,a,s){if(!a.isHidden(this.transform.zoom)&&("background"===a.type||"custom"===a.type||(s||[]).length))switch(this.id=a.id,a.type){case"symbol":!function(t,i,a,s,o){if("translucent"!==t.renderPass)return;const r=Ui.disabled,n=t.colorModeForRenderPass();(a._unevaluatedLayout.hasValue("text-variable-anchor")||a._unevaluatedLayout.hasValue("text-variable-anchor-offset"))&&function(t,i,a,s,o,r,n,l,h){const c=i.transform,u=ie(),d="map"===o,_="map"===r;for(const o of t){const t=s.getTile(o),r=t.getBucket(a);if(!r||!r.text||!r.text.segments.get().length)continue;const p=e.ag(r.textSizeData,c.zoom),m=Bt(t,1,i.transform.zoom),f=gt(o.posMatrix,_,d,i.transform,m),g="none"!==a.layout.get("icon-text-fit")&&r.hasIconData();if(p){const e=Math.pow(2,c.zoom-t.tileID.overscaledZ),a=i.style.map.terrain?(t,e)=>i.style.map.terrain.getElevation(o,t,e):null,s=u.translatePosition(c,t,n,l);Wi(r,d,_,h,c,f,o.posMatrix,e,p,g,u,s,o.toUnwrapped(),a);}}}(s,t,a,i,a.layout.get("text-rotation-alignment"),a.layout.get("text-pitch-alignment"),a.paint.get("text-translate"),a.paint.get("text-translate-anchor"),o),0!==a.paint.get("icon-opacity").constantOr(1)&&Xi(t,i,a,s,!1,a.paint.get("icon-translate"),a.paint.get("icon-translate-anchor"),a.layout.get("icon-rotation-alignment"),a.layout.get("icon-pitch-alignment"),a.layout.get("icon-keep-upright"),r,n),0!==a.paint.get("text-opacity").constantOr(1)&&Xi(t,i,a,s,!0,a.paint.get("text-translate"),a.paint.get("text-translate-anchor"),a.layout.get("text-rotation-alignment"),a.layout.get("text-pitch-alignment"),a.layout.get("text-keep-upright"),r,n),i.map.showCollisionBoxes&&(qi(t,i,a,s,!0),qi(t,i,a,s,!1));}(t,i,a,s,this.style.placement.variableOffsets);break;case"circle":!function(t,i,a,s){if("translucent"!==t.renderPass)return;const o=a.paint.get("circle-opacity"),r=a.paint.get("circle-stroke-width"),n=a.paint.get("circle-stroke-opacity"),l=!a.layout.get("circle-sort-key").isConstant();if(0===o.constantOr(1)&&(0===r.constantOr(1)||0===n.constantOr(1)))return;const h=t.context,c=h.gl,u=t.depthModeForSublayer(0,Oi.ReadOnly),d=Ui.disabled,_=t.colorModeForRenderPass(),p=[];for(let o=0;ot.sortKey-e.sortKey));for(const e of p){const{programConfiguration:i,program:s,layoutVertexBuffer:o,indexBuffer:r,uniformValues:n,terrainData:l}=e.state;s.draw(h,c.TRIANGLES,u,d,_,ji.disabled,n,l,a.id,o,r,e.segments,a.paint,t.transform.zoom,i);}}(t,i,a,s);break;case"heatmap":!function(t,i,a,s){if(0===a.paint.get("heatmap-opacity"))return;const o=t.context;if(t.style.map.terrain){for(const e of s){const s=i.getTile(e);i.hasRenderableParent(e)||("offscreen"===t.renderPass?Ji(t,s,a,e):"translucent"===t.renderPass&&Yi(t,a,e));}o.viewport.set([0,0,t.width,t.height]);}else "offscreen"===t.renderPass?function(t,i,a,s){const o=t.context,r=o.gl,n=Ui.disabled,l=new Fi([r.ONE,r.ONE],e.aM.transparent,[!0,!0,!0,!0]);((function(t,i,a){const s=t.gl;t.activeTexture.set(s.TEXTURE1),t.viewport.set([0,0,i.width/4,i.height/4]);let o=a.heatmapFbos.get(e.aU);o?(s.bindTexture(s.TEXTURE_2D,o.colorAttachment.get()),t.bindFramebuffer.set(o.framebuffer)):(o=Qi(t,i.width/4,i.height/4),a.heatmapFbos.set(e.aU,o));}))(o,t,a),o.clear({color:e.aM.transparent});for(let e=0;e20&&o.texParameterf(o.TEXTURE_2D,s.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT,s.extTextureFilterAnisotropicMax);const y=t.style.map.terrain&&t.style.map.terrain.getTerrainData(a),b=y?a:null,w=b?b.posMatrix:t.transform.calculatePosMatrix(a.toUnwrapped(),d),T=Ue(w,v||[0,0],g||1,f,i);r instanceof Q?n.draw(s,o.TRIANGLES,c,Ui.disabled,l,ji.disabled,T,y,i.id,r.boundsBuffer,t.quadTriangleIndexBuffer,r.boundsSegments):n.draw(s,o.TRIANGLES,c,h[a.overscaledZ],l,ji.disabled,T,y,i.id,t.rasterBoundsBuffer,t.quadTriangleIndexBuffer,t.rasterBoundsSegments);}}(t,i,a,s);break;case"background":!function(t,e,i,a){const s=i.paint.get("background-color"),o=i.paint.get("background-opacity");if(0===o)return;const r=t.context,n=r.gl,l=t.transform,h=l.tileSize,c=i.paint.get("background-pattern");if(t.isPatternMissing(c))return;const u=!c&&1===s.a&&1===o&&t.opaquePassEnabledForLayer()?"opaque":"translucent";if(t.renderPass!==u)return;const d=Ui.disabled,_=t.depthModeForSublayer(0,"opaque"===u?Oi.ReadWrite:Oi.ReadOnly),p=t.colorModeForRenderPass(),m=t.useProgram(c?"backgroundPattern":"background"),f=a||l.coveringTiles({tileSize:h,terrain:t.style.map.terrain});c&&(r.activeTexture.set(n.TEXTURE0),t.imageManager.bind(t.context));const g=i.getCrossfadeParameters();for(const e of f){const l=a?e.posMatrix:t.transform.calculatePosMatrix(e.toUnwrapped()),u=c?He(l,o,t,c,{tileID:e,tileSize:h},g):Ge(l,o,s),f=t.style.map.terrain&&t.style.map.terrain.getTerrainData(e);m.draw(r,n.TRIANGLES,_,d,p,ji.disabled,u,f,i.id,t.tileExtentBuffer,t.quadTriangleIndexBuffer,t.tileExtentSegments);}}(t,0,a,s);break;case"custom":!function(t,e,i){const a=t.context,s=i.implementation;if("offscreen"===t.renderPass){const e=s.prerender;e&&(t.setCustomLayerDefaults(),a.setColorMode(t.colorModeForRenderPass()),e.call(s,a.gl,t.transform.customLayerMatrix()),a.setDirty(),t.setBaseState());}else if("translucent"===t.renderPass){t.setCustomLayerDefaults(),a.setColorMode(t.colorModeForRenderPass()),a.setStencilMode(Ui.disabled);const e="3d"===s.renderingMode?new Oi(t.context.gl.LEQUAL,Oi.ReadWrite,t.depthRangeFor3D):t.depthModeForSublayer(0,Oi.ReadOnly);a.setDepthMode(e),s.render(a.gl,t.transform.customLayerMatrix(),{farZ:t.transform.farZ,nearZ:t.transform.nearZ,fov:t.transform._fov,modelViewProjectionMatrix:t.transform.modelViewProjectionMatrix,projectionMatrix:t.transform.projectionMatrix}),a.setDirty(),t.setBaseState(),a.bindFramebuffer.set(null);}}(t,0,a);}}translatePosMatrix(t,i,a,s,o){if(!a[0]&&!a[1])return t;const r=o?"map"===s?this.transform.angle:0:"viewport"===s?-this.transform.angle:0;if(r){const t=Math.sin(r),e=Math.cos(r);a=[a[0]*e-a[1]*t,a[0]*t+a[1]*e];}const n=[o?a[0]:Bt(i,a[0],this.transform.zoom),o?a[1]:Bt(i,a[1],this.transform.zoom),0],l=new Float32Array(16);return e.J(l,t,n),l}saveTileTexture(t){const e=this._tileTextures[t.size[0]];e?e.push(t):this._tileTextures[t.size[0]]=[t];}getTileTexture(t){const e=this._tileTextures[t];return e&&e.length>0?e.pop():null}isPatternMissing(t){if(!t)return !1;if(!t.from||!t.to)return !0;const e=this.imageManager.getPattern(t.from.toString()),i=this.imageManager.getPattern(t.to.toString());return !e||!i}useProgram(t,e){this.cache=this.cache||{};const i=t+(e?e.cacheKey:"")+(this._showOverdrawInspector?"/overdraw":"")+(this.style.map.terrain?"/terrain":"");return this.cache[i]||(this.cache[i]=new xe(this.context,pe[t],e,We[t],this._showOverdrawInspector,this.style.map.terrain)),this.cache[i]}setCustomLayerDefaults(){this.context.unbindVAO(),this.context.cullFace.setDefault(),this.context.activeTexture.setDefault(),this.context.pixelStoreUnpack.setDefault(),this.context.pixelStoreUnpackPremultiplyAlpha.setDefault(),this.context.pixelStoreUnpackFlipY.setDefault();}setBaseState(){const t=this.context.gl;this.context.cullFace.set(!1),this.context.viewport.set([0,0,this.width,this.height]),this.context.blendEquation.set(t.FUNC_ADD);}initDebugOverlayCanvas(){null==this.debugOverlayCanvas&&(this.debugOverlayCanvas=document.createElement("canvas"),this.debugOverlayCanvas.width=512,this.debugOverlayCanvas.height=512,this.debugOverlayTexture=new b(this.context,this.debugOverlayCanvas,this.context.gl.RGBA));}destroy(){this.debugOverlayTexture&&this.debugOverlayTexture.destroy();}overLimit(){const{drawingBufferWidth:t,drawingBufferHeight:e}=this.context.gl;return this.width!==t||this.height!==e}}class xa{constructor(t,e){this.points=t,this.planes=e;}static fromInvProjectionMatrix(t,i,a){const s=Math.pow(2,a),o=[[-1,1,-1,1],[1,1,-1,1],[1,-1,-1,1],[-1,-1,-1,1],[-1,1,1,1],[1,1,1,1],[1,-1,1,1],[-1,-1,1,1]].map((a=>{const o=1/(a=e.af([],a,t))[3]/i*s;return e.b1(a,a,[o,o,1/a[3],o])})),r=[[0,1,2],[6,5,4],[0,3,7],[2,1,5],[3,2,6],[0,4,5]].map((t=>{const e=function(t,e){var i=e[0],a=e[1],s=e[2],o=i*i+a*a+s*s;return o>0&&(o=1/Math.sqrt(o)),t[0]=e[0]*o,t[1]=e[1]*o,t[2]=e[2]*o,t}([],function(t,e,i){var a=e[0],s=e[1],o=e[2],r=i[0],n=i[1],l=i[2];return t[0]=s*l-o*n,t[1]=o*r-a*l,t[2]=a*n-s*r,t}([],g([],o[t[0]],o[t[1]]),g([],o[t[2]],o[t[1]]))),i=-((a=e)[0]*(s=o[t[1]])[0]+a[1]*s[1]+a[2]*s[2]);var a,s;return e.concat(i)}));return new xa(o,r)}}class ya{constructor(t,e){this.min=t,this.max=e,this.center=function(t,e,i){return t[0]=.5*e[0],t[1]=.5*e[1],t[2]=.5*e[2],t}([],function(t,e,i){return t[0]=e[0]+i[0],t[1]=e[1]+i[1],t[2]=e[2]+i[2],t}([],this.min,this.max));}quadrant(t){const e=[t%2==0,t<2],i=m(this.min),a=m(this.max);for(let t=0;t=0&&r++;if(0===r)return 0;r!==i.length&&(a=!1);}if(a)return 2;for(let e=0;e<3;e++){let i=Number.MAX_VALUE,a=-Number.MAX_VALUE;for(let s=0;sthis.max[e]-this.min[e])return 0}return 1}}class ba{constructor(t=0,e=0,i=0,a=0){if(isNaN(t)||t<0||isNaN(e)||e<0||isNaN(i)||i<0||isNaN(a)||a<0)throw new Error("Invalid value for edge-insets, top, bottom, left and right must all be numbers");this.top=t,this.bottom=e,this.left=i,this.right=a;}interpolate(t,i,a){return null!=i.top&&null!=t.top&&(this.top=e.y.number(t.top,i.top,a)),null!=i.bottom&&null!=t.bottom&&(this.bottom=e.y.number(t.bottom,i.bottom,a)),null!=i.left&&null!=t.left&&(this.left=e.y.number(t.left,i.left,a)),null!=i.right&&null!=t.right&&(this.right=e.y.number(t.right,i.right,a)),this}getCenter(t,i){const a=e.ac((this.left+t-this.right)/2,0,t),s=e.ac((this.top+i-this.bottom)/2,0,i);return new e.P(a,s)}equals(t){return this.top===t.top&&this.bottom===t.bottom&&this.left===t.left&&this.right===t.right}clone(){return new ba(this.top,this.bottom,this.left,this.right)}toJSON(){return {top:this.top,bottom:this.bottom,left:this.left,right:this.right}}}const wa=85.051129;class Ta{constructor(t,i,a,s,o){this.tileSize=512,this._renderWorldCopies=void 0===o||!!o,this._minZoom=t||0,this._maxZoom=i||22,this._minPitch=null==a?0:a,this._maxPitch=null==s?60:s,this.setMaxBounds(),this.width=0,this.height=0,this._center=new e.N(0,0),this._elevation=0,this.zoom=0,this.angle=0,this._fov=.6435011087932844,this._pitch=0,this._unmodified=!0,this._edgeInsets=new ba,this._posMatrixCache={},this._alignedPosMatrixCache={},this._fogMatrixCache={},this.minElevationForCurrentTile=0;}clone(){const t=new Ta(this._minZoom,this._maxZoom,this._minPitch,this.maxPitch,this._renderWorldCopies);return t.apply(this),t}apply(t){this.tileSize=t.tileSize,this.latRange=t.latRange,this.lngRange=t.lngRange,this.width=t.width,this.height=t.height,this._center=t._center,this._elevation=t._elevation,this.minElevationForCurrentTile=t.minElevationForCurrentTile,this.zoom=t.zoom,this.angle=t.angle,this._fov=t._fov,this._pitch=t._pitch,this._unmodified=t._unmodified,this._edgeInsets=t._edgeInsets.clone(),this._calcMatrices();}get minZoom(){return this._minZoom}set minZoom(t){this._minZoom!==t&&(this._minZoom=t,this.zoom=Math.max(this.zoom,t));}get maxZoom(){return this._maxZoom}set maxZoom(t){this._maxZoom!==t&&(this._maxZoom=t,this.zoom=Math.min(this.zoom,t));}get minPitch(){return this._minPitch}set minPitch(t){this._minPitch!==t&&(this._minPitch=t,this.pitch=Math.max(this.pitch,t));}get maxPitch(){return this._maxPitch}set maxPitch(t){this._maxPitch!==t&&(this._maxPitch=t,this.pitch=Math.min(this.pitch,t));}get renderWorldCopies(){return this._renderWorldCopies}set renderWorldCopies(t){void 0===t?t=!0:null===t&&(t=!1),this._renderWorldCopies=t;}get worldSize(){return this.tileSize*this.scale}get centerOffset(){return this.centerPoint._sub(this.size._div(2))}get size(){return new e.P(this.width,this.height)}get bearing(){return -this.angle/Math.PI*180}set bearing(t){const i=-e.b3(t,-180,180)*Math.PI/180;this.angle!==i&&(this._unmodified=!1,this.angle=i,this._calcMatrices(),this.rotationMatrix=function(){var t=new e.A(4);return e.A!=Float32Array&&(t[1]=0,t[2]=0),t[0]=1,t[3]=1,t}(),function(t,e,i){var a=e[0],s=e[1],o=e[2],r=e[3],n=Math.sin(i),l=Math.cos(i);t[0]=a*l+o*n,t[1]=s*l+r*n,t[2]=a*-n+o*l,t[3]=s*-n+r*l;}(this.rotationMatrix,this.rotationMatrix,this.angle));}get pitch(){return this._pitch/Math.PI*180}set pitch(t){const i=e.ac(t,this.minPitch,this.maxPitch)/180*Math.PI;this._pitch!==i&&(this._unmodified=!1,this._pitch=i,this._calcMatrices());}get fov(){return this._fov/Math.PI*180}set fov(t){t=Math.max(.01,Math.min(60,t)),this._fov!==t&&(this._unmodified=!1,this._fov=t/180*Math.PI,this._calcMatrices());}get zoom(){return this._zoom}set zoom(t){const e=Math.min(Math.max(t,this.minZoom),this.maxZoom);this._zoom!==e&&(this._unmodified=!1,this._zoom=e,this.tileZoom=Math.max(0,Math.floor(e)),this.scale=this.zoomScale(e),this._constrain(),this._calcMatrices());}get center(){return this._center}set center(t){t.lat===this._center.lat&&t.lng===this._center.lng||(this._unmodified=!1,this._center=t,this._constrain(),this._calcMatrices());}get elevation(){return this._elevation}set elevation(t){t!==this._elevation&&(this._elevation=t,this._constrain(),this._calcMatrices());}get padding(){return this._edgeInsets.toJSON()}set padding(t){this._edgeInsets.equals(t)||(this._unmodified=!1,this._edgeInsets.interpolate(this._edgeInsets,t,1),this._calcMatrices());}get centerPoint(){return this._edgeInsets.getCenter(this.width,this.height)}isPaddingEqual(t){return this._edgeInsets.equals(t)}interpolatePadding(t,e,i){this._unmodified=!1,this._edgeInsets.interpolate(t,e,i),this._constrain(),this._calcMatrices();}coveringZoomLevel(t){const e=(t.roundZoom?Math.round:Math.floor)(this.zoom+this.scaleZoom(this.tileSize/t.tileSize));return Math.max(0,e)}getVisibleUnwrappedCoordinates(t){const i=[new e.b4(0,t)];if(this._renderWorldCopies){const a=this.pointCoordinate(new e.P(0,0)),s=this.pointCoordinate(new e.P(this.width,0)),o=this.pointCoordinate(new e.P(this.width,this.height)),r=this.pointCoordinate(new e.P(0,this.height)),n=Math.floor(Math.min(a.x,s.x,o.x,r.x)),l=Math.floor(Math.max(a.x,s.x,o.x,r.x)),h=1;for(let a=n-h;a<=l+h;a++)0!==a&&i.push(new e.b4(a,t));}return i}coveringTiles(t){var i,a;let s=this.coveringZoomLevel(t);const o=s;if(void 0!==t.minzoom&&st.maxzoom&&(s=t.maxzoom);const r=this.pointCoordinate(this.getCameraPoint()),n=e.Z.fromLngLat(this.center),l=Math.pow(2,s),h=[l*r.x,l*r.y,0],c=[l*n.x,l*n.y,0],u=xa.fromInvProjectionMatrix(this.invModelViewProjectionMatrix,this.worldSize,s);let d=t.minzoom||0;!t.terrain&&this.pitch<=60&&this._edgeInsets.top<.1&&(d=s);const _=t.terrain?2/Math.min(this.tileSize,t.tileSize)*this.tileSize:3,p=t=>({aabb:new ya([t*l,0,0],[(t+1)*l,l,0]),zoom:0,x:0,y:0,wrap:t,fullyVisible:!1}),m=[],f=[],g=s,x=t.reparseOverscaled?o:s;if(this._renderWorldCopies)for(let t=1;t<=3;t++)m.push(p(-t)),m.push(p(t));for(m.push(p(0));m.length>0;){const s=m.pop(),o=s.x,r=s.y;let n=s.fullyVisible;if(!n){const t=s.aabb.intersects(u);if(0===t)continue;n=2===t;}const l=t.terrain?h:c,p=s.aabb.distanceX(l),y=s.aabb.distanceY(l),b=Math.max(Math.abs(p),Math.abs(y));if(s.zoom===g||b>_+(1<=d){const t=g-s.zoom,i=h[0]-.5-(o<>1),u=s.zoom+1;let d=s.aabb.quadrant(l);if(t.terrain){const o=new e.S(u,s.wrap,u,h,c),r=t.terrain.getMinMaxElevation(o),n=null!==(i=r.minElevation)&&void 0!==i?i:this.elevation,l=null!==(a=r.maxElevation)&&void 0!==a?a:this.elevation;d=new ya([d.min[0],d.min[1],n],[d.max[0],d.max[1],l]);}m.push({aabb:d,zoom:u,x:h,y:c,wrap:s.wrap,fullyVisible:n});}}return f.sort(((t,e)=>t.distanceSq-e.distanceSq)).map((t=>t.tileID))}resize(t,e){this.width=t,this.height=e,this.pixelsToGLUnits=[2/t,-2/e],this._constrain(),this._calcMatrices();}get unmodified(){return this._unmodified}zoomScale(t){return Math.pow(2,t)}scaleZoom(t){return Math.log(t)/Math.LN2}project(t){const i=e.ac(t.lat,-85.051129,wa);return new e.P(e.O(t.lng)*this.worldSize,e.Q(i)*this.worldSize)}unproject(t){return new e.Z(t.x/this.worldSize,t.y/this.worldSize).toLngLat()}get point(){return this.project(this.center)}getCameraPosition(){return {lngLat:this.pointLocation(this.getCameraPoint()),altitude:Math.cos(this._pitch)*this.cameraToCenterDistance/this._pixelPerMeter+this.elevation}}recalculateZoom(t){const i=this.elevation,a=Math.cos(this._pitch)*this.cameraToCenterDistance/this._pixelPerMeter,s=this.pointLocation(this.centerPoint,t),o=t.getElevationForLngLatZoom(s,this.tileZoom);if(!(this.elevation-o))return;const r=a+i-o,n=Math.cos(this._pitch)*this.cameraToCenterDistance/r/e.b5(1,s.lat),l=this.scaleZoom(n/this.tileSize);this._elevation=o,this._center=s,this.zoom=l;}setLocationAtPoint(t,i){const a=this.pointCoordinate(i),s=this.pointCoordinate(this.centerPoint),o=this.locationCoordinate(t),r=new e.Z(o.x-(a.x-s.x),o.y-(a.y-s.y));this.center=this.coordinateLocation(r),this._renderWorldCopies&&(this.center=this.center.wrap());}locationPoint(t,e){return e?this.coordinatePoint(this.locationCoordinate(t),e.getElevationForLngLatZoom(t,this.tileZoom),this.pixelMatrix3D):this.coordinatePoint(this.locationCoordinate(t))}pointLocation(t,e){return this.coordinateLocation(this.pointCoordinate(t,e))}locationCoordinate(t){return e.Z.fromLngLat(t)}coordinateLocation(t){return t&&t.toLngLat()}pointCoordinate(t,i){if(i){const e=i.pointCoordinate(t);if(null!=e)return e}const a=[t.x,t.y,0,1],s=[t.x,t.y,1,1];e.af(a,a,this.pixelMatrixInverse),e.af(s,s,this.pixelMatrixInverse);const o=a[3],r=s[3],n=a[1]/o,l=s[1]/r,h=a[2]/o,c=s[2]/r,u=h===c?0:(0-h)/(c-h);return new e.Z(e.y.number(a[0]/o,s[0]/r,u)/this.worldSize,e.y.number(n,l,u)/this.worldSize)}coordinatePoint(t,i=0,a=this.pixelMatrix){const s=[t.x*this.worldSize,t.y*this.worldSize,i,1];return e.af(s,s,a),new e.P(s[0]/s[3],s[1]/s[3])}getBounds(){const t=Math.max(0,this.height/2-this.getHorizon());return (new H).extend(this.pointLocation(new e.P(0,t))).extend(this.pointLocation(new e.P(this.width,t))).extend(this.pointLocation(new e.P(this.width,this.height))).extend(this.pointLocation(new e.P(0,this.height)))}getMaxBounds(){return this.latRange&&2===this.latRange.length&&this.lngRange&&2===this.lngRange.length?new H([this.lngRange[0],this.latRange[0]],[this.lngRange[1],this.latRange[1]]):null}getHorizon(){return Math.tan(Math.PI/2-this._pitch)*this.cameraToCenterDistance*.85}setMaxBounds(t){t?(this.lngRange=[t.getWest(),t.getEast()],this.latRange=[t.getSouth(),t.getNorth()],this._constrain()):(this.lngRange=null,this.latRange=[-85.051129,wa]);}calculateTileMatrix(t){const i=t.canonical,a=this.worldSize/this.zoomScale(i.z),s=i.x+Math.pow(2,i.z)*t.wrap,o=e.an(new Float64Array(16));return e.J(o,o,[s*a,i.y*a,0]),e.K(o,o,[a/e.X,a/e.X,1]),o}calculatePosMatrix(t,i=!1){const a=t.key,s=i?this._alignedPosMatrixCache:this._posMatrixCache;if(s[a])return s[a];const o=this.calculateTileMatrix(t);return e.L(o,i?this.alignedModelViewProjectionMatrix:this.modelViewProjectionMatrix,o),s[a]=new Float32Array(o),s[a]}calculateFogMatrix(t){const i=t.key,a=this._fogMatrixCache;if(a[i])return a[i];const s=this.calculateTileMatrix(t);return e.L(s,this.fogMatrix,s),a[i]=new Float32Array(s),a[i]}customLayerMatrix(){return this.mercatorMatrix.slice()}getConstrained(t,i){i=e.ac(+i,this.minZoom,this.maxZoom);const a={center:new e.N(t.lng,t.lat),zoom:i};let s=this.lngRange;if(!this._renderWorldCopies&&null===s){const t=180-1e-10;s=[-t,t];}const o=this.tileSize*this.zoomScale(a.zoom);let r=0,n=o,l=0,h=o,c=0,u=0;const{x:d,y:_}=this.size;if(this.latRange){const t=this.latRange;r=e.Q(t[1])*o,n=e.Q(t[0])*o,n-r<_&&(c=_/(n-r));}s&&(l=e.b3(e.O(s[0])*o,0,o),h=e.b3(e.O(s[1])*o,0,o),hn&&(g=n-t);}if(s){const t=(l+h)/2;let i=p;this._renderWorldCopies&&(i=e.b3(p,t-o/2,t+o/2));const a=d/2;i-ah&&(f=h-a);}if(void 0!==f||void 0!==g){const t=new e.P(null!=f?f:p,null!=g?g:m);a.center=this.unproject.call({worldSize:o},t).wrap();}return a}_constrain(){if(!this.center||!this.width||!this.height||this._constraining)return;this._constraining=!0;const t=this._unmodified,{center:e,zoom:i}=this.getConstrained(this.center,this.zoom);this.center=e,this.zoom=i,this._unmodified=t,this._constraining=!1;}_calcMatrices(){if(!this.height)return;const t=this.centerOffset,i=this.point.x,a=this.point.y;this.cameraToCenterDistance=.5/Math.tan(this._fov/2)*this.height,this._pixelPerMeter=e.b5(1,this.center.lat)*this.worldSize;let s=e.an(new Float64Array(16));e.K(s,s,[this.width/2,-this.height/2,1]),e.J(s,s,[1,-1,0]),this.labelPlaneMatrix=s,s=e.an(new Float64Array(16)),e.K(s,s,[1,-1,1]),e.J(s,s,[-1,-1,0]),e.K(s,s,[2/this.width,2/this.height,1]),this.glCoordMatrix=s;const o=this.cameraToCenterDistance+this._elevation*this._pixelPerMeter/Math.cos(this._pitch),r=Math.min(this.elevation,this.minElevationForCurrentTile),n=o-r*this._pixelPerMeter/Math.cos(this._pitch),l=r<0?n:o,h=Math.PI/2+this._pitch,c=this._fov*(.5+t.y/this.height),u=Math.sin(c)*l/Math.sin(e.ac(Math.PI-h-c,.01,Math.PI-.01)),d=this.getHorizon(),_=2*Math.atan(d/this.cameraToCenterDistance)*(.5+t.y/(2*d)),p=Math.sin(_)*l/Math.sin(e.ac(Math.PI-h-_,.01,Math.PI-.01)),m=Math.min(u,p);this.farZ=1.01*(Math.cos(Math.PI/2-this._pitch)*m+l),this.nearZ=this.height/50,s=new Float64Array(16),e.b6(s,this._fov,this.width/this.height,this.nearZ,this.farZ),s[8]=2*-t.x/this.width,s[9]=2*t.y/this.height,this.projectionMatrix=e.ae(s),e.K(s,s,[1,-1,1]),e.J(s,s,[0,0,-this.cameraToCenterDistance]),e.b7(s,s,this._pitch),e.ad(s,s,this.angle),e.J(s,s,[-i,-a,0]),this.mercatorMatrix=e.K([],s,[this.worldSize,this.worldSize,this.worldSize]),e.K(s,s,[1,1,this._pixelPerMeter]),this.pixelMatrix=e.L(new Float64Array(16),this.labelPlaneMatrix,s),e.J(s,s,[0,0,-this.elevation]),this.modelViewProjectionMatrix=s,this.invModelViewProjectionMatrix=e.as([],s),this.fogMatrix=new Float64Array(16),e.b6(this.fogMatrix,this._fov,this.width/this.height,o,this.farZ),this.fogMatrix[8]=2*-t.x/this.width,this.fogMatrix[9]=2*t.y/this.height,e.K(this.fogMatrix,this.fogMatrix,[1,-1,1]),e.J(this.fogMatrix,this.fogMatrix,[0,0,-this.cameraToCenterDistance]),e.b7(this.fogMatrix,this.fogMatrix,this._pitch),e.ad(this.fogMatrix,this.fogMatrix,this.angle),e.J(this.fogMatrix,this.fogMatrix,[-i,-a,0]),e.K(this.fogMatrix,this.fogMatrix,[1,1,this._pixelPerMeter]),e.J(this.fogMatrix,this.fogMatrix,[0,0,-this.elevation]),this.pixelMatrix3D=e.L(new Float64Array(16),this.labelPlaneMatrix,s);const f=this.width%2/2,g=this.height%2/2,v=Math.cos(this.angle),x=Math.sin(this.angle),y=i-Math.round(i)+v*f+x*g,b=a-Math.round(a)+v*g+x*f,w=new Float64Array(s);if(e.J(w,w,[y>.5?y-1:y,b>.5?b-1:b,0]),this.alignedModelViewProjectionMatrix=w,s=e.as(new Float64Array(16),this.pixelMatrix),!s)throw new Error("failed to invert matrix");this.pixelMatrixInverse=s,this._posMatrixCache={},this._alignedPosMatrixCache={},this._fogMatrixCache={};}maxPitchScaleFactor(){if(!this.pixelMatrixInverse)return 1;const t=this.pointCoordinate(new e.P(0,0)),i=[t.x*this.worldSize,t.y*this.worldSize,0,1];return e.af(i,i,this.pixelMatrix)[3]/this.cameraToCenterDistance}getCameraPoint(){const t=Math.tan(this._pitch)*(this.cameraToCenterDistance||1);return this.centerPoint.add(new e.P(0,t))}getCameraQueryGeometry(t){const i=this.getCameraPoint();if(1===t.length)return [t[0],i];{let a=i.x,s=i.y,o=i.x,r=i.y;for(const e of t)a=Math.min(a,e.x),s=Math.min(s,e.y),o=Math.max(o,e.x),r=Math.max(r,e.y);return [new e.P(a,s),new e.P(o,s),new e.P(o,r),new e.P(a,r),new e.P(a,s)]}}lngLatToCameraDepth(t,i){const a=this.locationCoordinate(t),s=[a.x*this.worldSize,a.y*this.worldSize,i,1];return e.af(s,s,this.modelViewProjectionMatrix),s[2]/s[3]}}function Ia(t,e){let i,a=!1,s=null,o=null;const r=()=>{s=null,a&&(t.apply(o,i),s=setTimeout(r,e),a=!1);};return (...t)=>(a=!0,o=this,i=t,s||r(),s)}class Ea{constructor(t){this._getCurrentHash=()=>{const t=window.location.hash.replace("#","");if(this._hashName){let e;return t.split("&").map((t=>t.split("="))).forEach((t=>{t[0]===this._hashName&&(e=t);})),(e&&e[1]||"").split("/")}return t.split("/")},this._onHashChange=()=>{const t=this._getCurrentHash();if(t.length>=3&&!t.some((t=>isNaN(t)))){const e=this._map.dragRotate.isEnabled()&&this._map.touchZoomRotate.isEnabled()?+(t[3]||0):this._map.getBearing();return this._map.jumpTo({center:[+t[2],+t[1]],zoom:+t[0],bearing:e,pitch:+(t[4]||0)}),!0}return !1},this._updateHashUnthrottled=()=>{const t=window.location.href.replace(/(#.*)?$/,this.getHashString());window.history.replaceState(window.history.state,null,t);},this._removeHash=()=>{const t=this._getCurrentHash();if(0===t.length)return;const e=t.join("/");let i=e;i.split("&").length>0&&(i=i.split("&")[0]),this._hashName&&(i=`${this._hashName}=${e}`);let a=window.location.hash.replace(i,"");a.startsWith("#&")?a=a.slice(0,1)+a.slice(2):"#"===a&&(a="");let s=window.location.href.replace(/(#.+)?$/,a);s=s.replace("&&","&"),window.history.replaceState(window.history.state,null,s);},this._updateHash=Ia(this._updateHashUnthrottled,300),this._hashName=t&&encodeURIComponent(t);}addTo(t){return this._map=t,addEventListener("hashchange",this._onHashChange,!1),this._map.on("moveend",this._updateHash),this}remove(){return removeEventListener("hashchange",this._onHashChange,!1),this._map.off("moveend",this._updateHash),clearTimeout(this._updateHash()),this._removeHash(),delete this._map,this}getHashString(t){const e=this._map.getCenter(),i=Math.round(100*this._map.getZoom())/100,a=Math.ceil((i*Math.LN2+Math.log(512/360/.5))/Math.LN10),s=Math.pow(10,a),o=Math.round(e.lng*s)/s,r=Math.round(e.lat*s)/s,n=this._map.getBearing(),l=this._map.getPitch();let h="";if(h+=t?`/${o}/${r}/${i}`:`${i}/${r}/${o}`,(n||l)&&(h+="/"+Math.round(10*n)/10),l&&(h+=`/${Math.round(l)}`),this._hashName){const t=this._hashName;let e=!1;const i=window.location.hash.slice(1).split("&").map((i=>{const a=i.split("=")[0];return a===t?(e=!0,`${a}=${h}`):i})).filter((t=>t));return e||i.push(`${t}=${h}`),`#${i.join("&")}`}return `#${h}`}}const Pa={linearity:.3,easing:e.b8(0,0,.3,1)},Ca=e.e({deceleration:2500,maxSpeed:1400},Pa),Sa=e.e({deceleration:20,maxSpeed:1400},Pa),za=e.e({deceleration:1e3,maxSpeed:360},Pa),Da=e.e({deceleration:1e3,maxSpeed:90},Pa);class Ma{constructor(t){this._map=t,this.clear();}clear(){this._inertiaBuffer=[];}record(t){this._drainInertiaBuffer(),this._inertiaBuffer.push({time:o.now(),settings:t});}_drainInertiaBuffer(){const t=this._inertiaBuffer,e=o.now();for(;t.length>0&&e-t[0].time>160;)t.shift();}_onMoveEnd(t){if(this._drainInertiaBuffer(),this._inertiaBuffer.length<2)return;const i={zoom:0,bearing:0,pitch:0,pan:new e.P(0,0),pinchAround:void 0,around:void 0};for(const{settings:t}of this._inertiaBuffer)i.zoom+=t.zoomDelta||0,i.bearing+=t.bearingDelta||0,i.pitch+=t.pitchDelta||0,t.panDelta&&i.pan._add(t.panDelta),t.around&&(i.around=t.around),t.pinchAround&&(i.pinchAround=t.pinchAround);const a=this._inertiaBuffer[this._inertiaBuffer.length-1].time-this._inertiaBuffer[0].time,s={};if(i.pan.mag()){const o=Ra(i.pan.mag(),a,e.e({},Ca,t||{}));s.offset=i.pan.mult(o.amount/i.pan.mag()),s.center=this._map.transform.center,Aa(s,o);}if(i.zoom){const t=Ra(i.zoom,a,Sa);s.zoom=this._map.transform.zoom+t.amount,Aa(s,t);}if(i.bearing){const t=Ra(i.bearing,a,za);s.bearing=this._map.transform.bearing+e.ac(t.amount,-179,179),Aa(s,t);}if(i.pitch){const t=Ra(i.pitch,a,Da);s.pitch=this._map.transform.pitch+t.amount,Aa(s,t);}if(s.zoom||s.bearing){const t=void 0===i.pinchAround?i.around:i.pinchAround;s.around=t?this._map.unproject(t):this._map.getCenter();}return this.clear(),e.e(s,{noMoveStart:!0})}}function Aa(t,e){(!t.duration||t.durationi.unproject(t))),l=o.reduce(((t,e,i,a)=>t.add(e.div(a.length))),new e.P(0,0));super(t,{points:o,point:l,lngLats:n,lngLat:i.unproject(l),originalEvent:a}),this._defaultPrevented=!1;}}class Fa extends e.k{preventDefault(){this._defaultPrevented=!0;}get defaultPrevented(){return this._defaultPrevented}constructor(t,e,i){super(t,{originalEvent:i}),this._defaultPrevented=!1;}}class Ba{constructor(t,e){this._map=t,this._clickTolerance=e.clickTolerance;}reset(){delete this._mousedownPos;}wheel(t){return this._firePreventable(new Fa(t.type,this._map,t))}mousedown(t,e){return this._mousedownPos=e,this._firePreventable(new ka(t.type,this._map,t))}mouseup(t){this._map.fire(new ka(t.type,this._map,t));}click(t,e){this._mousedownPos&&this._mousedownPos.dist(e)>=this._clickTolerance||this._map.fire(new ka(t.type,this._map,t));}dblclick(t){return this._firePreventable(new ka(t.type,this._map,t))}mouseover(t){this._map.fire(new ka(t.type,this._map,t));}mouseout(t){this._map.fire(new ka(t.type,this._map,t));}touchstart(t){return this._firePreventable(new La(t.type,this._map,t))}touchmove(t){this._map.fire(new La(t.type,this._map,t));}touchend(t){this._map.fire(new La(t.type,this._map,t));}touchcancel(t){this._map.fire(new La(t.type,this._map,t));}_firePreventable(t){if(this._map.fire(t),t.defaultPrevented)return {}}isEnabled(){return !0}isActive(){return !1}enable(){}disable(){}}class Oa{constructor(t){this._map=t;}reset(){this._delayContextMenu=!1,this._ignoreContextMenu=!0,delete this._contextMenuEvent;}mousemove(t){this._map.fire(new ka(t.type,this._map,t));}mousedown(){this._delayContextMenu=!0,this._ignoreContextMenu=!1;}mouseup(){this._delayContextMenu=!1,this._contextMenuEvent&&(this._map.fire(new ka("contextmenu",this._map,this._contextMenuEvent)),delete this._contextMenuEvent);}contextmenu(t){this._delayContextMenu?this._contextMenuEvent=t:this._ignoreContextMenu||this._map.fire(new ka(t.type,this._map,t)),this._map.listens("contextmenu")&&t.preventDefault();}isEnabled(){return !0}isActive(){return !1}enable(){}disable(){}}class Na{constructor(t){this._map=t;}get transform(){return this._map._requestedCameraState||this._map.transform}get center(){return {lng:this.transform.center.lng,lat:this.transform.center.lat}}get zoom(){return this.transform.zoom}get pitch(){return this.transform.pitch}get bearing(){return this.transform.bearing}unproject(t){return this.transform.pointLocation(e.P.convert(t),this._map.terrain)}}class Ua{constructor(t,e){this._map=t,this._tr=new Na(t),this._el=t.getCanvasContainer(),this._container=t.getContainer(),this._clickTolerance=e.clickTolerance||1;}isEnabled(){return !!this._enabled}isActive(){return !!this._active}enable(){this.isEnabled()||(this._enabled=!0);}disable(){this.isEnabled()&&(this._enabled=!1);}mousedown(t,e){this.isEnabled()&&t.shiftKey&&0===t.button&&(r.disableDrag(),this._startPos=this._lastPos=e,this._active=!0);}mousemoveWindow(t,e){if(!this._active)return;const i=e;if(this._lastPos.equals(i)||!this._box&&i.dist(this._startPos)t.fitScreenCoordinates(a,s,this._tr.bearing,{linear:!0})};this._fireEvent("boxzoomcancel",t);}keydown(t){this._active&&27===t.keyCode&&(this.reset(),this._fireEvent("boxzoomcancel",t));}reset(){this._active=!1,this._container.classList.remove("maplibregl-crosshair"),this._box&&(r.remove(this._box),this._box=null),r.enableDrag(),delete this._startPos,delete this._lastPos;}_fireEvent(t,i){return this._map.fire(new e.k(t,{originalEvent:i}))}}function ja(t,e){if(t.length!==e.length)throw new Error(`The number of touches and points are not equal - touches ${t.length}, points ${e.length}`);const i={};for(let a=0;athis.numTouches)&&(this.aborted=!0),this.aborted||(void 0===this.startTime&&(this.startTime=t.timeStamp),a.length===this.numTouches&&(this.centroid=function(t){const i=new e.P(0,0);for(const e of t)i._add(e);return i.div(t.length)}(i),this.touches=ja(a,i)));}touchmove(t,e,i){if(this.aborted||!this.centroid)return;const a=ja(i,e);for(const t in this.touches){const e=a[t];(!e||e.dist(this.touches[t])>30)&&(this.aborted=!0);}}touchend(t,e,i){if((!this.centroid||t.timeStamp-this.startTime>500)&&(this.aborted=!0),0===i.length){const t=!this.aborted&&this.centroid;if(this.reset(),t)return t}}}class qa{constructor(t){this.singleTap=new Za(t),this.numTaps=t.numTaps,this.reset();}reset(){this.lastTime=1/0,delete this.lastTap,this.count=0,this.singleTap.reset();}touchstart(t,e,i){this.singleTap.touchstart(t,e,i);}touchmove(t,e,i){this.singleTap.touchmove(t,e,i);}touchend(t,e,i){const a=this.singleTap.touchend(t,e,i);if(a){const e=t.timeStamp-this.lastTime<500,i=!this.lastTap||this.lastTap.dist(a)<30;if(e&&i||this.reset(),this.count++,this.lastTime=t.timeStamp,this.lastTap=a,this.count===this.numTaps)return this.reset(),a}}}class Va{constructor(t){this._tr=new Na(t),this._zoomIn=new qa({numTouches:1,numTaps:2}),this._zoomOut=new qa({numTouches:2,numTaps:1}),this.reset();}reset(){this._active=!1,this._zoomIn.reset(),this._zoomOut.reset();}touchstart(t,e,i){this._zoomIn.touchstart(t,e,i),this._zoomOut.touchstart(t,e,i);}touchmove(t,e,i){this._zoomIn.touchmove(t,e,i),this._zoomOut.touchmove(t,e,i);}touchend(t,e,i){const a=this._zoomIn.touchend(t,e,i),s=this._zoomOut.touchend(t,e,i),o=this._tr;return a?(this._active=!0,t.preventDefault(),setTimeout((()=>this.reset()),0),{cameraAnimation:e=>e.easeTo({duration:300,zoom:o.zoom+1,around:o.unproject(a)},{originalEvent:t})}):s?(this._active=!0,t.preventDefault(),setTimeout((()=>this.reset()),0),{cameraAnimation:e=>e.easeTo({duration:300,zoom:o.zoom-1,around:o.unproject(s)},{originalEvent:t})}):void 0}touchcancel(){this.reset();}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class Ga{constructor(t){this._enabled=!!t.enable,this._moveStateManager=t.moveStateManager,this._clickTolerance=t.clickTolerance||1,this._moveFunction=t.move,this._activateOnStart=!!t.activateOnStart,t.assignEvents(this),this.reset();}reset(t){this._active=!1,this._moved=!1,delete this._lastPoint,this._moveStateManager.endMove(t);}_move(...t){const e=this._moveFunction(...t);if(e.bearingDelta||e.pitchDelta||e.around||e.panDelta)return this._active=!0,e}dragStart(t,e){this.isEnabled()&&!this._lastPoint&&this._moveStateManager.isValidStartEvent(t)&&(this._moveStateManager.startMove(t),this._lastPoint=e.length?e[0]:e,this._activateOnStart&&this._lastPoint&&(this._active=!0));}dragMove(t,e){if(!this.isEnabled())return;const i=this._lastPoint;if(!i)return;if(t.preventDefault(),!this._moveStateManager.isValidMoveEvent(t))return void this.reset(t);const a=e.length?e[0]:e;return !this._moved&&a.dist(i){t.mousedown=t.dragStart,t.mousemoveWindow=t.dragMove,t.mouseup=t.dragEnd,t.contextmenu=t=>{t.preventDefault();};},Ka=({enable:t,clickTolerance:e,bearingDegreesPerPixelMoved:i=.8})=>{const a=new Wa({checkCorrectEvent:t=>0===r.mouseButton(t)&&t.ctrlKey||2===r.mouseButton(t)});return new Ga({clickTolerance:e,move:(t,e)=>({bearingDelta:(e.x-t.x)*i}),moveStateManager:a,enable:t,assignEvents:Xa})},Ja=({enable:t,clickTolerance:e,pitchDegreesPerPixelMoved:i=-.5})=>{const a=new Wa({checkCorrectEvent:t=>0===r.mouseButton(t)&&t.ctrlKey||2===r.mouseButton(t)});return new Ga({clickTolerance:e,move:(t,e)=>({pitchDelta:(e.y-t.y)*i}),moveStateManager:a,enable:t,assignEvents:Xa})};class Ya{constructor(t,e){this._clickTolerance=t.clickTolerance||1,this._map=e,this.reset();}reset(){this._active=!1,this._touches={},this._sum=new e.P(0,0);}_shouldBePrevented(t){return t<(this._map.cooperativeGestures.isEnabled()?2:1)}touchstart(t,e,i){return this._calculateTransform(t,e,i)}touchmove(t,e,i){if(this._active){if(!this._shouldBePrevented(i.length))return t.preventDefault(),this._calculateTransform(t,e,i);this._map.cooperativeGestures.notifyGestureBlocked("touch_pan",t);}}touchend(t,e,i){this._calculateTransform(t,e,i),this._active&&this._shouldBePrevented(i.length)&&this.reset();}touchcancel(){this.reset();}_calculateTransform(t,i,a){a.length>0&&(this._active=!0);const s=ja(a,i),o=new e.P(0,0),r=new e.P(0,0);let n=0;for(const t in s){const e=s[t],i=this._touches[t];i&&(o._add(e),r._add(e.sub(i)),n++,s[t]=e);}if(this._touches=s,this._shouldBePrevented(n)||!r.mag())return;const l=r.div(n);return this._sum._add(l),this._sum.mag()Math.abs(t.x)}class rs extends Qa{constructor(t){super(),this._currentTouchCount=0,this._map=t;}reset(){super.reset(),this._valid=void 0,delete this._firstMove,delete this._lastPoints;}touchstart(t,e,i){super.touchstart(t,e,i),this._currentTouchCount=i.length;}_start(t){this._lastPoints=t,os(t[0].sub(t[1]))&&(this._valid=!1);}_move(t,e,i){if(this._map.cooperativeGestures.isEnabled()&&this._currentTouchCount<3)return;const a=t[0].sub(this._lastPoints[0]),s=t[1].sub(this._lastPoints[1]);return this._valid=this.gestureBeginsVertically(a,s,i.timeStamp),this._valid?(this._lastPoints=t,this._active=!0,{pitchDelta:(a.y+s.y)/2*-.5}):void 0}gestureBeginsVertically(t,e,i){if(void 0!==this._valid)return this._valid;const a=t.mag()>=2,s=e.mag()>=2;if(!a&&!s)return;if(!a||!s)return void 0===this._firstMove&&(this._firstMove=i),i-this._firstMove<100&&void 0;const o=t.y>0==e.y>0;return os(t)&&os(e)&&o}}const ns={panStep:100,bearingStep:15,pitchStep:10};class ls{constructor(t){this._tr=new Na(t);const e=ns;this._panStep=e.panStep,this._bearingStep=e.bearingStep,this._pitchStep=e.pitchStep,this._rotationDisabled=!1;}reset(){this._active=!1;}keydown(t){if(t.altKey||t.ctrlKey||t.metaKey)return;let e=0,i=0,a=0,s=0,o=0;switch(t.keyCode){case 61:case 107:case 171:case 187:e=1;break;case 189:case 109:case 173:e=-1;break;case 37:t.shiftKey?i=-1:(t.preventDefault(),s=-1);break;case 39:t.shiftKey?i=1:(t.preventDefault(),s=1);break;case 38:t.shiftKey?a=1:(t.preventDefault(),o=-1);break;case 40:t.shiftKey?a=-1:(t.preventDefault(),o=1);break;default:return}return this._rotationDisabled&&(i=0,a=0),{cameraAnimation:r=>{const n=this._tr;r.easeTo({duration:300,easeId:"keyboardHandler",easing:hs,zoom:e?Math.round(n.zoom)+e*(t.shiftKey?2:1):n.zoom,bearing:n.bearing+i*this._bearingStep,pitch:n.pitch+a*this._pitchStep,offset:[-s*this._panStep,-o*this._panStep],center:n.center},{originalEvent:t});}}}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}disableRotation(){this._rotationDisabled=!0;}enableRotation(){this._rotationDisabled=!1;}}function hs(t){return t*(2-t)}const cs=4.000244140625;class us{constructor(t,e){this._onTimeout=t=>{this._type="wheel",this._delta-=this._lastValue,this._active||this._start(t);},this._map=t,this._tr=new Na(t),this._triggerRenderFrame=e,this._delta=0,this._defaultZoomRate=.01,this._wheelZoomRate=.0022222222222222222;}setZoomRate(t){this._defaultZoomRate=t;}setWheelZoomRate(t){this._wheelZoomRate=t;}isEnabled(){return !!this._enabled}isActive(){return !!this._active||void 0!==this._finishTimeout}isZooming(){return !!this._zooming}enable(t){this.isEnabled()||(this._enabled=!0,this._aroundCenter=!!t&&"center"===t.around);}disable(){this.isEnabled()&&(this._enabled=!1);}_shouldBePrevented(t){return !!this._map.cooperativeGestures.isEnabled()&&!(t.ctrlKey||this._map.cooperativeGestures.isBypassed(t))}wheel(t){if(!this.isEnabled())return;if(this._shouldBePrevented(t))return void this._map.cooperativeGestures.notifyGestureBlocked("wheel_zoom",t);let e=t.deltaMode===WheelEvent.DOM_DELTA_LINE?40*t.deltaY:t.deltaY;const i=o.now(),a=i-(this._lastWheelEventTime||0);this._lastWheelEventTime=i,0!==e&&e%cs==0?this._type="wheel":0!==e&&Math.abs(e)<4?this._type="trackpad":a>400?(this._type=null,this._lastValue=e,this._timeout=setTimeout(this._onTimeout,40,t)):this._type||(this._type=Math.abs(a*e)<200?"trackpad":"wheel",this._timeout&&(clearTimeout(this._timeout),this._timeout=null,e+=this._lastValue)),t.shiftKey&&e&&(e/=4),this._type&&(this._lastWheelEvent=t,this._delta-=e,this._active||this._start(t)),t.preventDefault();}_start(t){if(!this._delta)return;this._frameId&&(this._frameId=null),this._active=!0,this.isZooming()||(this._zooming=!0),this._finishTimeout&&(clearTimeout(this._finishTimeout),delete this._finishTimeout);const i=r.mousePos(this._map.getCanvas(),t),a=this._tr;this._around=i.y>a.transform.height/2-a.transform.getHorizon()?e.N.convert(this._aroundCenter?a.center:a.unproject(i)):e.N.convert(a.center),this._aroundPoint=a.transform.locationPoint(this._around),this._frameId||(this._frameId=!0,this._triggerRenderFrame());}renderFrame(){if(!this._frameId)return;if(this._frameId=null,!this.isActive())return;const t=this._tr.transform;if(0!==this._delta){const e="wheel"===this._type&&Math.abs(this._delta)>cs?this._wheelZoomRate:this._defaultZoomRate;let i=2/(1+Math.exp(-Math.abs(this._delta*e)));this._delta<0&&0!==i&&(i=1/i);const a="number"==typeof this._targetZoom?t.zoomScale(this._targetZoom):t.scale;this._targetZoom=Math.min(t.maxZoom,Math.max(t.minZoom,t.scaleZoom(a*i))),"wheel"===this._type&&(this._startZoom=t.zoom,this._easing=this._smoothOutEasing(200)),this._delta=0;}const i="number"==typeof this._targetZoom?this._targetZoom:t.zoom,a=this._startZoom,s=this._easing;let r,n=!1;const l=o.now()-this._lastWheelEventTime;if("wheel"===this._type&&a&&s&&l){const t=Math.min(l/200,1),o=s(t);r=e.y.number(a,i,o),t<1?this._frameId||(this._frameId=!0):n=!0;}else r=i,n=!0;return this._active=!0,n&&(this._active=!1,this._finishTimeout=setTimeout((()=>{this._zooming=!1,this._triggerRenderFrame(),delete this._targetZoom,delete this._finishTimeout;}),200)),{noInertia:!0,needsRenderFrame:!n,zoomDelta:r-t.zoom,around:this._aroundPoint,originalEvent:this._lastWheelEvent}}_smoothOutEasing(t){let i=e.b9;if(this._prevEase){const t=this._prevEase,a=(o.now()-t.start)/t.duration,s=t.easing(a+.01)-t.easing(a),r=.27/Math.sqrt(s*s+1e-4)*.01,n=Math.sqrt(.0729-r*r);i=e.b8(r,n,.25,1);}return this._prevEase={start:o.now(),duration:t,easing:i},i}reset(){this._active=!1,this._zooming=!1,delete this._targetZoom,this._finishTimeout&&(clearTimeout(this._finishTimeout),delete this._finishTimeout);}}class ds{constructor(t,e){this._clickZoom=t,this._tapZoom=e;}enable(){this._clickZoom.enable(),this._tapZoom.enable();}disable(){this._clickZoom.disable(),this._tapZoom.disable();}isEnabled(){return this._clickZoom.isEnabled()&&this._tapZoom.isEnabled()}isActive(){return this._clickZoom.isActive()||this._tapZoom.isActive()}}class _s{constructor(t){this._tr=new Na(t),this.reset();}reset(){this._active=!1;}dblclick(t,e){return t.preventDefault(),{cameraAnimation:i=>{i.easeTo({duration:300,zoom:this._tr.zoom+(t.shiftKey?-1:1),around:this._tr.unproject(e)},{originalEvent:t});}}}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class ps{constructor(){this._tap=new qa({numTouches:1,numTaps:1}),this.reset();}reset(){this._active=!1,delete this._swipePoint,delete this._swipeTouch,delete this._tapTime,delete this._tapPoint,this._tap.reset();}touchstart(t,e,i){if(!this._swipePoint)if(this._tapTime){const a=e[0],s=t.timeStamp-this._tapTime<500,o=this._tapPoint.dist(a)<30;s&&o?i.length>0&&(this._swipePoint=a,this._swipeTouch=i[0].identifier):this.reset();}else this._tap.touchstart(t,e,i);}touchmove(t,e,i){if(this._tapTime){if(this._swipePoint){if(i[0].identifier!==this._swipeTouch)return;const a=e[0],s=a.y-this._swipePoint.y;return this._swipePoint=a,t.preventDefault(),this._active=!0,{zoomDelta:s/128}}}else this._tap.touchmove(t,e,i);}touchend(t,e,i){if(this._tapTime)this._swipePoint&&0===i.length&&this.reset();else {const a=this._tap.touchend(t,e,i);a&&(this._tapTime=t.timeStamp,this._tapPoint=a);}}touchcancel(){this.reset();}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class ms{constructor(t,e,i){this._el=t,this._mousePan=e,this._touchPan=i;}enable(t){this._inertiaOptions=t||{},this._mousePan.enable(),this._touchPan.enable(),this._el.classList.add("maplibregl-touch-drag-pan");}disable(){this._mousePan.disable(),this._touchPan.disable(),this._el.classList.remove("maplibregl-touch-drag-pan");}isEnabled(){return this._mousePan.isEnabled()&&this._touchPan.isEnabled()}isActive(){return this._mousePan.isActive()||this._touchPan.isActive()}}class fs{constructor(t,e,i){this._pitchWithRotate=t.pitchWithRotate,this._mouseRotate=e,this._mousePitch=i;}enable(){this._mouseRotate.enable(),this._pitchWithRotate&&this._mousePitch.enable();}disable(){this._mouseRotate.disable(),this._mousePitch.disable();}isEnabled(){return this._mouseRotate.isEnabled()&&(!this._pitchWithRotate||this._mousePitch.isEnabled())}isActive(){return this._mouseRotate.isActive()||this._mousePitch.isActive()}}class gs{constructor(t,e,i,a){this._el=t,this._touchZoom=e,this._touchRotate=i,this._tapDragZoom=a,this._rotationDisabled=!1,this._enabled=!0;}enable(t){this._touchZoom.enable(t),this._rotationDisabled||this._touchRotate.enable(t),this._tapDragZoom.enable(),this._el.classList.add("maplibregl-touch-zoom-rotate");}disable(){this._touchZoom.disable(),this._touchRotate.disable(),this._tapDragZoom.disable(),this._el.classList.remove("maplibregl-touch-zoom-rotate");}isEnabled(){return this._touchZoom.isEnabled()&&(this._rotationDisabled||this._touchRotate.isEnabled())&&this._tapDragZoom.isEnabled()}isActive(){return this._touchZoom.isActive()||this._touchRotate.isActive()||this._tapDragZoom.isActive()}disableRotation(){this._rotationDisabled=!0,this._touchRotate.disable();}enableRotation(){this._rotationDisabled=!1,this._touchZoom.isEnabled()&&this._touchRotate.enable();}}class vs{constructor(t,e){this._bypassKey=-1!==navigator.userAgent.indexOf("Mac")?"metaKey":"ctrlKey",this._map=t,this._options=e,this._enabled=!1;}isActive(){return !1}reset(){}_setupUI(){if(this._container)return;const t=this._map.getCanvasContainer();t.classList.add("maplibregl-cooperative-gestures"),this._container=r.create("div","maplibregl-cooperative-gesture-screen",t);let e=this._map._getUIString("CooperativeGesturesHandler.WindowsHelpText");"metaKey"===this._bypassKey&&(e=this._map._getUIString("CooperativeGesturesHandler.MacHelpText"));const i=this._map._getUIString("CooperativeGesturesHandler.MobileHelpText"),a=document.createElement("div");a.className="maplibregl-desktop-message",a.textContent=e,this._container.appendChild(a);const s=document.createElement("div");s.className="maplibregl-mobile-message",s.textContent=i,this._container.appendChild(s),this._container.setAttribute("aria-hidden","true");}_destroyUI(){this._container&&(r.remove(this._container),this._map.getCanvasContainer().classList.remove("maplibregl-cooperative-gestures")),delete this._container;}enable(){this._setupUI(),this._enabled=!0;}disable(){this._enabled=!1,this._destroyUI();}isEnabled(){return this._enabled}isBypassed(t){return t[this._bypassKey]}notifyGestureBlocked(t,i){this._enabled&&(this._map.fire(new e.k("cooperativegestureprevented",{gestureType:t,originalEvent:i})),this._container.classList.add("maplibregl-show"),setTimeout((()=>{this._container.classList.remove("maplibregl-show");}),100));}}const xs=t=>t.zoom||t.drag||t.pitch||t.rotate;class ys extends e.k{}function bs(t){return t.panDelta&&t.panDelta.mag()||t.zoomDelta||t.bearingDelta||t.pitchDelta}class ws{constructor(t,e){this.handleWindowEvent=t=>{this.handleEvent(t,`${t.type}Window`);},this.handleEvent=(t,e)=>{if("blur"===t.type)return void this.stop(!0);this._updatingCamera=!0;const i="renderFrame"===t.type?void 0:t,a={needsRenderFrame:!1},s={},o={},n=t.touches,l=n?this._getMapTouches(n):void 0,h=l?r.touchPos(this._map.getCanvas(),l):r.mousePos(this._map.getCanvas(),t);for(const{handlerName:r,handler:n,allowed:c}of this._handlers){if(!n.isEnabled())continue;let u;this._blockedByActive(o,c,r)?n.reset():n[e||t.type]&&(u=n[e||t.type](t,h,l),this.mergeHandlerResult(a,s,u,r,i),u&&u.needsRenderFrame&&this._triggerRenderFrame()),(u||n.isActive())&&(o[r]=n);}const c={};for(const t in this._previousActiveHandlers)o[t]||(c[t]=i);this._previousActiveHandlers=o,(Object.keys(c).length||bs(a))&&(this._changes.push([a,s,c]),this._triggerRenderFrame()),(Object.keys(o).length||bs(a))&&this._map._stop(!0),this._updatingCamera=!1;const{cameraAnimation:u}=a;u&&(this._inertia.clear(),this._fireEvents({},{},!0),this._changes=[],u(this._map));},this._map=t,this._el=this._map.getCanvasContainer(),this._handlers=[],this._handlersById={},this._changes=[],this._inertia=new Ma(t),this._bearingSnap=e.bearingSnap,this._previousActiveHandlers={},this._eventsInProgress={},this._addDefaultHandlers(e);const i=this._el;this._listeners=[[i,"touchstart",{passive:!0}],[i,"touchmove",{passive:!1}],[i,"touchend",void 0],[i,"touchcancel",void 0],[i,"mousedown",void 0],[i,"mousemove",void 0],[i,"mouseup",void 0],[document,"mousemove",{capture:!0}],[document,"mouseup",void 0],[i,"mouseover",void 0],[i,"mouseout",void 0],[i,"dblclick",void 0],[i,"click",void 0],[i,"keydown",{capture:!1}],[i,"keyup",void 0],[i,"wheel",{passive:!1}],[i,"contextmenu",void 0],[window,"blur",void 0]];for(const[t,e,i]of this._listeners)r.addEventListener(t,e,t===document?this.handleWindowEvent:this.handleEvent,i);}destroy(){for(const[t,e,i]of this._listeners)r.removeEventListener(t,e,t===document?this.handleWindowEvent:this.handleEvent,i);}_addDefaultHandlers(t){const e=this._map,i=e.getCanvasContainer();this._add("mapEvent",new Ba(e,t));const a=e.boxZoom=new Ua(e,t);this._add("boxZoom",a),t.interactive&&t.boxZoom&&a.enable();const s=e.cooperativeGestures=new vs(e,t.cooperativeGestures);this._add("cooperativeGestures",s),t.cooperativeGestures&&s.enable();const o=new Va(e),n=new _s(e);e.doubleClickZoom=new ds(n,o),this._add("tapZoom",o),this._add("clickZoom",n),t.interactive&&t.doubleClickZoom&&e.doubleClickZoom.enable();const l=new ps;this._add("tapDragZoom",l);const h=e.touchPitch=new rs(e);this._add("touchPitch",h),t.interactive&&t.touchPitch&&e.touchPitch.enable(t.touchPitch);const c=Ka(t),u=Ja(t);e.dragRotate=new fs(t,c,u),this._add("mouseRotate",c,["mousePitch"]),this._add("mousePitch",u,["mouseRotate"]),t.interactive&&t.dragRotate&&e.dragRotate.enable();const d=(({enable:t,clickTolerance:e})=>{const i=new Wa({checkCorrectEvent:t=>0===r.mouseButton(t)&&!t.ctrlKey});return new Ga({clickTolerance:e,move:(t,e)=>({around:e,panDelta:e.sub(t)}),activateOnStart:!0,moveStateManager:i,enable:t,assignEvents:Xa})})(t),_=new Ya(t,e);e.dragPan=new ms(i,d,_),this._add("mousePan",d),this._add("touchPan",_,["touchZoom","touchRotate"]),t.interactive&&t.dragPan&&e.dragPan.enable(t.dragPan);const p=new ss,m=new is;e.touchZoomRotate=new gs(i,m,p,l),this._add("touchRotate",p,["touchPan","touchZoom"]),this._add("touchZoom",m,["touchPan","touchRotate"]),t.interactive&&t.touchZoomRotate&&e.touchZoomRotate.enable(t.touchZoomRotate);const f=e.scrollZoom=new us(e,(()=>this._triggerRenderFrame()));this._add("scrollZoom",f,["mousePan"]),t.interactive&&t.scrollZoom&&e.scrollZoom.enable(t.scrollZoom);const g=e.keyboard=new ls(e);this._add("keyboard",g),t.interactive&&t.keyboard&&e.keyboard.enable(),this._add("blockableMapEvent",new Oa(e));}_add(t,e,i){this._handlers.push({handlerName:t,handler:e,allowed:i}),this._handlersById[t]=e;}stop(t){if(!this._updatingCamera){for(const{handler:t}of this._handlers)t.reset();this._inertia.clear(),this._fireEvents({},{},t),this._changes=[];}}isActive(){for(const{handler:t}of this._handlers)if(t.isActive())return !0;return !1}isZooming(){return !!this._eventsInProgress.zoom||this._map.scrollZoom.isZooming()}isRotating(){return !!this._eventsInProgress.rotate}isMoving(){return Boolean(xs(this._eventsInProgress))||this.isZooming()}_blockedByActive(t,e,i){for(const a in t)if(a!==i&&(!e||e.indexOf(a)<0))return !0;return !1}_getMapTouches(t){const e=[];for(const i of t)this._el.contains(i.target)&&e.push(i);return e}mergeHandlerResult(t,i,a,s,o){if(!a)return;e.e(t,a);const r={handlerName:s,originalEvent:a.originalEvent||o};void 0!==a.zoomDelta&&(i.zoom=r),void 0!==a.panDelta&&(i.drag=r),void 0!==a.pitchDelta&&(i.pitch=r),void 0!==a.bearingDelta&&(i.rotate=r);}_applyChanges(){const t={},i={},a={};for(const[s,o,r]of this._changes)s.panDelta&&(t.panDelta=(t.panDelta||new e.P(0,0))._add(s.panDelta)),s.zoomDelta&&(t.zoomDelta=(t.zoomDelta||0)+s.zoomDelta),s.bearingDelta&&(t.bearingDelta=(t.bearingDelta||0)+s.bearingDelta),s.pitchDelta&&(t.pitchDelta=(t.pitchDelta||0)+s.pitchDelta),void 0!==s.around&&(t.around=s.around),void 0!==s.pinchAround&&(t.pinchAround=s.pinchAround),s.noInertia&&(t.noInertia=s.noInertia),e.e(i,o),e.e(a,r);this._updateMapTransform(t,i,a),this._changes=[];}_updateMapTransform(t,e,i){const a=this._map,s=a._getTransformForUpdate(),o=a.terrain;if(!(bs(t)||o&&this._terrainMovement))return this._fireEvents(e,i,!0);let{panDelta:r,zoomDelta:n,bearingDelta:l,pitchDelta:h,around:c,pinchAround:u}=t;void 0!==u&&(c=u),a._stop(!0),c=c||a.transform.centerPoint;const d=s.pointLocation(r?c.sub(r):c);l&&(s.bearing+=l),h&&(s.pitch+=h),n&&(s.zoom+=n),o?this._terrainMovement||!e.drag&&!e.zoom?e.drag&&this._terrainMovement?s.center=s.pointLocation(s.centerPoint.sub(r)):s.setLocationAtPoint(d,c):(this._terrainMovement=!0,this._map._elevationFreeze=!0,s.setLocationAtPoint(d,c)):s.setLocationAtPoint(d,c),a._applyUpdatedTransform(s),this._map._update(),t.noInertia||this._inertia.record(t),this._fireEvents(e,i,!0);}_fireEvents(t,i,a){const s=xs(this._eventsInProgress),r=xs(t),n={};for(const e in t){const{originalEvent:i}=t[e];this._eventsInProgress[e]||(n[`${e}start`]=i),this._eventsInProgress[e]=t[e];}!s&&r&&this._fireEvent("movestart",r.originalEvent);for(const t in n)this._fireEvent(t,n[t]);r&&this._fireEvent("move",r.originalEvent);for(const e in t){const{originalEvent:i}=t[e];this._fireEvent(e,i);}const l={};let h;for(const t in this._eventsInProgress){const{handlerName:e,originalEvent:a}=this._eventsInProgress[t];this._handlersById[e].isActive()||(delete this._eventsInProgress[t],h=i[e]||a,l[`${t}end`]=h);}for(const t in l)this._fireEvent(t,l[t]);const c=xs(this._eventsInProgress),u=(s||r)&&!c;if(u&&this._terrainMovement){this._map._elevationFreeze=!1,this._terrainMovement=!1;const t=this._map._getTransformForUpdate();t.recalculateZoom(this._map.terrain),this._map._applyUpdatedTransform(t);}if(a&&u){this._updatingCamera=!0;const t=this._inertia._onMoveEnd(this._map.dragPan._inertiaOptions),i=t=>0!==t&&-this._bearingSnap{delete this._frameId,this.handleEvent(new ys("renderFrame",{timeStamp:t})),this._applyChanges();}))}_triggerRenderFrame(){void 0===this._frameId&&(this._frameId=this._requestFrame());}}class Ts extends e.E{constructor(t,e){super(),this._renderFrameCallback=()=>{const t=Math.min((o.now()-this._easeStart)/this._easeOptions.duration,1);this._onEaseFrame(this._easeOptions.easing(t)),t<1&&this._easeFrameId?this._easeFrameId=this._requestRenderFrame(this._renderFrameCallback):this.stop();},this._moving=!1,this._zooming=!1,this.transform=t,this._bearingSnap=e.bearingSnap,this.on("moveend",(()=>{delete this._requestedCameraState;}));}getCenter(){return new e.N(this.transform.center.lng,this.transform.center.lat)}setCenter(t,e){return this.jumpTo({center:t},e)}panBy(t,i,a){return t=e.P.convert(t).mult(-1),this.panTo(this.transform.center,e.e({offset:t},i),a)}panTo(t,i,a){return this.easeTo(e.e({center:t},i),a)}getZoom(){return this.transform.zoom}setZoom(t,e){return this.jumpTo({zoom:t},e),this}zoomTo(t,i,a){return this.easeTo(e.e({zoom:t},i),a)}zoomIn(t,e){return this.zoomTo(this.getZoom()+1,t,e),this}zoomOut(t,e){return this.zoomTo(this.getZoom()-1,t,e),this}getBearing(){return this.transform.bearing}setBearing(t,e){return this.jumpTo({bearing:t},e),this}getPadding(){return this.transform.padding}setPadding(t,e){return this.jumpTo({padding:t},e),this}rotateTo(t,i,a){return this.easeTo(e.e({bearing:t},i),a)}resetNorth(t,i){return this.rotateTo(0,e.e({duration:1e3},t),i),this}resetNorthPitch(t,i){return this.easeTo(e.e({bearing:0,pitch:0,duration:1e3},t),i),this}snapToNorth(t,e){return Math.abs(this.getBearing()){if(this._zooming&&(s.zoom=e.y.number(r,g,a)),this._rotating&&(s.bearing=e.y.number(n,c,a)),this._pitching&&(s.pitch=e.y.number(l,u,a)),this._padding&&(s.interpolatePadding(h,d,a),p=s.centerPoint.add(_)),this.terrain&&!t.freezeElevation&&this._updateElevation(a),b)s.setLocationAtPoint(b,w);else {const t=s.zoomScale(s.zoom-r),e=g>r?Math.min(2,y):Math.max(.5,y),i=Math.pow(e,1-a),o=s.unproject(v.add(x.mult(a*i)).mult(t));s.setLocationAtPoint(s.renderWorldCopies?o.wrap():o,p);}this._applyUpdatedTransform(s),this._fireMoveEvents(i);}),(e=>{this.terrain&&t.freezeElevation&&this._finalizeElevation(),this._afterEase(i,e);}),t),this}_prepareEase(t,i,a={}){this._moving=!0,i||a.moving||this.fire(new e.k("movestart",t)),this._zooming&&!a.zooming&&this.fire(new e.k("zoomstart",t)),this._rotating&&!a.rotating&&this.fire(new e.k("rotatestart",t)),this._pitching&&!a.pitching&&this.fire(new e.k("pitchstart",t));}_prepareElevation(t){this._elevationCenter=t,this._elevationStart=this.transform.elevation,this._elevationTarget=this.terrain.getElevationForLngLatZoom(t,this.transform.tileZoom),this._elevationFreeze=!0;}_updateElevation(t){this.transform.minElevationForCurrentTile=this.terrain.getMinTileElevationForLngLatZoom(this._elevationCenter,this.transform.tileZoom);const i=this.terrain.getElevationForLngLatZoom(this._elevationCenter,this.transform.tileZoom);if(t<1&&i!==this._elevationTarget){const e=this._elevationTarget-this._elevationStart;this._elevationStart+=t*(e-(i-(e*t+this._elevationStart))/(1-t)),this._elevationTarget=i;}this.transform.elevation=e.y.number(this._elevationStart,this._elevationTarget,t);}_finalizeElevation(){this._elevationFreeze=!1,this.transform.recalculateZoom(this.terrain);}_getTransformForUpdate(){return this.transformCameraUpdate||this.terrain?(this._requestedCameraState||(this._requestedCameraState=this.transform.clone()),this._requestedCameraState):this.transform}_elevateCameraIfInsideTerrain(t){const e=t.getCameraPosition(),i=this.terrain.getElevationForLngLatZoom(e.lngLat,t.zoom);if(e.altitudethis._elevateCameraIfInsideTerrain(t))),this.transformCameraUpdate&&e.push((t=>this.transformCameraUpdate(t))),!e.length)return;const i=t.clone();for(const t of e){const e=i.clone(),{center:a,zoom:s,pitch:o,bearing:r,elevation:n}=t(e);a&&(e.center=a),void 0!==s&&(e.zoom=s),void 0!==o&&(e.pitch=o),void 0!==r&&(e.bearing=r),void 0!==n&&(e.elevation=n),i.apply(e);}this.transform.apply(i);}_fireMoveEvents(t){this.fire(new e.k("move",t)),this._zooming&&this.fire(new e.k("zoom",t)),this._rotating&&this.fire(new e.k("rotate",t)),this._pitching&&this.fire(new e.k("pitch",t));}_afterEase(t,i){if(this._easeId&&i&&this._easeId===i)return;delete this._easeId;const a=this._zooming,s=this._rotating,o=this._pitching;this._moving=!1,this._zooming=!1,this._rotating=!1,this._pitching=!1,this._padding=!1,a&&this.fire(new e.k("zoomend",t)),s&&this.fire(new e.k("rotateend",t)),o&&this.fire(new e.k("pitchend",t)),this.fire(new e.k("moveend",t));}flyTo(t,i){var a;if(!t.essential&&o.prefersReducedMotion){const a=e.M(t,["center","zoom","bearing","pitch","around"]);return this.jumpTo(a,i)}this.stop(),t=e.e({offset:[0,0],speed:1.2,curve:1.42,easing:e.b9},t);const s=this._getTransformForUpdate(),r=s.zoom,n=s.bearing,l=s.pitch,h=s.padding,c="bearing"in t?this._normalizeBearing(t.bearing,n):n,u="pitch"in t?+t.pitch:l,d="padding"in t?t.padding:s.padding,_=e.P.convert(t.offset);let p=s.centerPoint.add(_);const m=s.pointLocation(p),{center:f,zoom:g}=s.getConstrained(e.N.convert(t.center||m),null!==(a=t.zoom)&&void 0!==a?a:r);this._normalizeCenter(f,s);const v=s.zoomScale(g-r),x=s.project(m),y=s.project(f).sub(x);let b=t.curve;const w=Math.max(s.width,s.height),T=w/v,I=y.mag();if("minZoom"in t){const i=e.ac(Math.min(t.minZoom,r,g),s.minZoom,s.maxZoom),a=w/s.zoomScale(i-r);b=Math.sqrt(a/I*2);}const E=b*b;function P(t){const e=(T*T-w*w+(t?-1:1)*E*E*I*I)/(2*(t?T:w)*E*I);return Math.log(Math.sqrt(e*e+1)-e)}function C(t){return (Math.exp(t)-Math.exp(-t))/2}function S(t){return (Math.exp(t)+Math.exp(-t))/2}const z=P(!1);let D=function(t){return S(z)/S(z+b*t)},M=function(t){return w*((S(z)*(C(e=z+b*t)/S(e))-C(z))/E)/I;var e;},A=(P(!0)-z)/b;if(Math.abs(I)<1e-6||!isFinite(A)){if(Math.abs(w-T)<1e-6)return this.easeTo(t,i);const e=T0,D=t=>Math.exp(e*b*t);}return t.duration="duration"in t?+t.duration:1e3*A/("screenSpeed"in t?+t.screenSpeed/b:+t.speed),t.maxDuration&&t.duration>t.maxDuration&&(t.duration=0),this._zooming=!0,this._rotating=n!==c,this._pitching=u!==l,this._padding=!s.isPaddingEqual(d),this._prepareEase(i,!1),this.terrain&&this._prepareElevation(f),this._ease((a=>{const o=a*A,m=1/D(o);s.zoom=1===a?g:r+s.scaleZoom(m),this._rotating&&(s.bearing=e.y.number(n,c,a)),this._pitching&&(s.pitch=e.y.number(l,u,a)),this._padding&&(s.interpolatePadding(h,d,a),p=s.centerPoint.add(_)),this.terrain&&!t.freezeElevation&&this._updateElevation(a);const v=1===a?f:s.unproject(x.add(y.mult(M(o))).mult(m));s.setLocationAtPoint(s.renderWorldCopies?v.wrap():v,p),this._applyUpdatedTransform(s),this._fireMoveEvents(i);}),(()=>{this.terrain&&t.freezeElevation&&this._finalizeElevation(),this._afterEase(i);}),t),this}isEasing(){return !!this._easeFrameId}stop(){return this._stop()}_stop(t,e){var i;if(this._easeFrameId&&(this._cancelRenderFrame(this._easeFrameId),delete this._easeFrameId,delete this._onEaseFrame),this._onEaseEnd){const t=this._onEaseEnd;delete this._onEaseEnd,t.call(this,e);}return t||null===(i=this.handlers)||void 0===i||i.stop(!1),this}_ease(t,e,i){!1===i.animate||0===i.duration?(t(1),e()):(this._easeStart=o.now(),this._easeOptions=i,this._onEaseFrame=t,this._onEaseEnd=e,this._easeFrameId=this._requestRenderFrame(this._renderFrameCallback));}_normalizeBearing(t,i){t=e.b3(t,-180,180);const a=Math.abs(t-i);return Math.abs(t-360-i)180?-360:i<-180?360:0;}queryTerrainElevation(t){return this.terrain?this.terrain.getElevationForLngLatZoom(e.N.convert(t),this.transform.tileZoom)-this.transform.elevation:null}}const Is={compact:!0,customAttribution:'
    MapLibre'};class Es{constructor(t=Is){this._toggleAttribution=()=>{this._container.classList.contains("maplibregl-compact")&&(this._container.classList.contains("maplibregl-compact-show")?(this._container.setAttribute("open",""),this._container.classList.remove("maplibregl-compact-show")):(this._container.classList.add("maplibregl-compact-show"),this._container.removeAttribute("open")));},this._updateData=t=>{!t||"metadata"!==t.sourceDataType&&"visibility"!==t.sourceDataType&&"style"!==t.dataType&&"terrain"!==t.type||this._updateAttributions();},this._updateCompact=()=>{this._map.getCanvasContainer().offsetWidth<=640||this._compact?!1===this._compact?this._container.setAttribute("open",""):this._container.classList.contains("maplibregl-compact")||this._container.classList.contains("maplibregl-attrib-empty")||(this._container.setAttribute("open",""),this._container.classList.add("maplibregl-compact","maplibregl-compact-show")):(this._container.setAttribute("open",""),this._container.classList.contains("maplibregl-compact")&&this._container.classList.remove("maplibregl-compact","maplibregl-compact-show"));},this._updateCompactMinimize=()=>{this._container.classList.contains("maplibregl-compact")&&this._container.classList.contains("maplibregl-compact-show")&&this._container.classList.remove("maplibregl-compact-show");},this.options=t;}getDefaultPosition(){return "bottom-right"}onAdd(t){return this._map=t,this._compact=this.options.compact,this._container=r.create("details","maplibregl-ctrl maplibregl-ctrl-attrib"),this._compactButton=r.create("summary","maplibregl-ctrl-attrib-button",this._container),this._compactButton.addEventListener("click",this._toggleAttribution),this._setElementTitle(this._compactButton,"ToggleAttribution"),this._innerContainer=r.create("div","maplibregl-ctrl-attrib-inner",this._container),this._updateAttributions(),this._updateCompact(),this._map.on("styledata",this._updateData),this._map.on("sourcedata",this._updateData),this._map.on("terrain",this._updateData),this._map.on("resize",this._updateCompact),this._map.on("drag",this._updateCompactMinimize),this._container}onRemove(){r.remove(this._container),this._map.off("styledata",this._updateData),this._map.off("sourcedata",this._updateData),this._map.off("terrain",this._updateData),this._map.off("resize",this._updateCompact),this._map.off("drag",this._updateCompactMinimize),this._map=void 0,this._compact=void 0,this._attribHTML=void 0;}_setElementTitle(t,e){const i=this._map._getUIString(`AttributionControl.${e}`);t.title=i,t.setAttribute("aria-label",i);}_updateAttributions(){if(!this._map.style)return;let t=[];if(this.options.customAttribution&&(Array.isArray(this.options.customAttribution)?t=t.concat(this.options.customAttribution.map((t=>"string"!=typeof t?"":t))):"string"==typeof this.options.customAttribution&&t.push(this.options.customAttribution)),this._map.style.stylesheet){const t=this._map.style.stylesheet;this.styleOwner=t.owner,this.styleId=t.id;}const e=this._map.style.sourceCaches;for(const i in e){const a=e[i];if(a.used||a.usedForTerrain){const e=a.getSource();e.attribution&&t.indexOf(e.attribution)<0&&t.push(e.attribution);}}t=t.filter((t=>String(t).trim())),t.sort(((t,e)=>t.length-e.length)),t=t.filter(((e,i)=>{for(let a=i+1;a=0)return !1;return !0}));const i=t.join(" | ");i!==this._attribHTML&&(this._attribHTML=i,t.length?(this._innerContainer.innerHTML=i,this._container.classList.remove("maplibregl-attrib-empty")):this._container.classList.add("maplibregl-attrib-empty"),this._updateCompact(),this._editLink=null);}}class Ps{constructor(t={}){this._updateCompact=()=>{const t=this._container.children;if(t.length){const e=t[0];this._map.getCanvasContainer().offsetWidth<=640||this._compact?!1!==this._compact&&e.classList.add("maplibregl-compact"):e.classList.remove("maplibregl-compact");}},this.options=t;}getDefaultPosition(){return "bottom-left"}onAdd(t){this._map=t,this._compact=this.options&&this.options.compact,this._container=r.create("div","maplibregl-ctrl");const e=r.create("a","maplibregl-ctrl-logo");return e.target="_blank",e.rel="noopener nofollow",e.href="https://maplibre.org/",e.setAttribute("aria-label",this._map._getUIString("LogoControl.Title")),e.setAttribute("rel","noopener nofollow"),this._container.appendChild(e),this._container.style.display="block",this._map.on("resize",this._updateCompact),this._updateCompact(),this._container}onRemove(){r.remove(this._container),this._map.off("resize",this._updateCompact),this._map=void 0,this._compact=void 0;}}class Cs{constructor(){this._queue=[],this._id=0,this._cleared=!1,this._currentlyRunning=!1;}add(t){const e=++this._id;return this._queue.push({callback:t,id:e,cancelled:!1}),e}remove(t){const e=this._currentlyRunning,i=e?this._queue.concat(e):this._queue;for(const e of i)if(e.id===t)return void(e.cancelled=!0)}run(t=0){if(this._currentlyRunning)throw new Error("Attempting to run(), but is already running.");const e=this._currentlyRunning=this._queue;this._queue=[];for(const i of e)if(!i.cancelled&&(i.callback(t),this._cleared))break;this._cleared=!1,this._currentlyRunning=!1;}clear(){this._currentlyRunning&&(this._cleared=!0),this._queue=[];}}var Ss=e.Y([{name:"a_pos3d",type:"Int16",components:3}]);class zs extends e.E{constructor(t){super(),this.sourceCache=t,this._tiles={},this._renderableTilesKeys=[],this._sourceTileCache={},this.minzoom=0,this.maxzoom=22,this.tileSize=512,this.deltaZoom=1,t.usedForTerrain=!0,t.tileSize=this.tileSize*2**this.deltaZoom;}destruct(){this.sourceCache.usedForTerrain=!1,this.sourceCache.tileSize=null;}update(t,i){this.sourceCache.update(t,i),this._renderableTilesKeys=[];const a={};for(const s of t.coveringTiles({tileSize:this.tileSize,minzoom:this.minzoom,maxzoom:this.maxzoom,reparseOverscaled:!1,terrain:i}))a[s.key]=!0,this._renderableTilesKeys.push(s.key),this._tiles[s.key]||(s.posMatrix=new Float64Array(16),e.aP(s.posMatrix,0,e.X,0,e.X,0,1),this._tiles[s.key]=new lt(s,this.tileSize));for(const t in this._tiles)a[t]||delete this._tiles[t];}freeRtt(t){for(const e in this._tiles){const i=this._tiles[e];(!t||i.tileID.equals(t)||i.tileID.isChildOf(t)||t.isChildOf(i.tileID))&&(i.rtt=[]);}}getRenderableTiles(){return this._renderableTilesKeys.map((t=>this.getTileByID(t)))}getTileByID(t){return this._tiles[t]}getTerrainCoords(t){const i={};for(const a of this._renderableTilesKeys){const s=this._tiles[a].tileID;if(s.canonical.equals(t.canonical)){const s=t.clone();s.posMatrix=new Float64Array(16),e.aP(s.posMatrix,0,e.X,0,e.X,0,1),i[a]=s;}else if(s.canonical.isChildOf(t.canonical)){const o=t.clone();o.posMatrix=new Float64Array(16);const r=s.canonical.z-t.canonical.z,n=s.canonical.x-(s.canonical.x>>r<>r<>r;e.aP(o.posMatrix,0,h,0,h,0,1),e.J(o.posMatrix,o.posMatrix,[-n*h,-l*h,0]),i[a]=o;}else if(t.canonical.isChildOf(s.canonical)){const o=t.clone();o.posMatrix=new Float64Array(16);const r=t.canonical.z-s.canonical.z,n=t.canonical.x-(t.canonical.x>>r<>r<>r;e.aP(o.posMatrix,0,e.X,0,e.X,0,1),e.J(o.posMatrix,o.posMatrix,[n*h,l*h,0]),e.K(o.posMatrix,o.posMatrix,[1/2**r,1/2**r,0]),i[a]=o;}}return i}getSourceTile(t,e){const i=this.sourceCache._source;let a=t.overscaledZ-this.deltaZoom;if(a>i.maxzoom&&(a=i.maxzoom),a=i.minzoom&&(!s||!s.dem);)s=this.sourceCache.getTileByID(t.scaledTo(a--).key);return s}tilesAfterTime(t=Date.now()){return Object.values(this._tiles).filter((e=>e.timeAdded>=t))}}class Ds{constructor(t,e,i){this.painter=t,this.sourceCache=new zs(e),this.options=i,this.exaggeration="number"==typeof i.exaggeration?i.exaggeration:1,this.qualityFactor=2,this.meshSize=128,this._demMatrixCache={},this.coordsIndex=[],this._coordsTextureSize=1024;}getDEMElevation(t,i,a,s=e.X){var o;if(!(i>=0&&i=0&&at.canonical.z&&(t.canonical.z>=a?s=t.canonical.z-a:e.w("cannot calculate elevation if elevation maxzoom > source.maxzoom"));const o=t.canonical.x-(t.canonical.x>>s<>s<>8<<4|t>>8,i[e+3]=0;const a=new e.R({width:this._coordsTextureSize,height:this._coordsTextureSize},new Uint8Array(i.buffer)),s=new b(t,a,t.gl.RGBA,{premultiply:!1});return s.bind(t.gl.NEAREST,t.gl.CLAMP_TO_EDGE),this._coordsTexture=s,s}pointCoordinate(t){this.painter.maybeDrawDepthAndCoords(!0);const i=new Uint8Array(4),a=this.painter.context,s=a.gl,o=Math.round(t.x*this.painter.pixelRatio/devicePixelRatio),r=Math.round(t.y*this.painter.pixelRatio/devicePixelRatio),n=Math.round(this.painter.height/devicePixelRatio);a.bindFramebuffer.set(this.getFramebuffer("coords").framebuffer),s.readPixels(o,n-r-1,1,1,s.RGBA,s.UNSIGNED_BYTE,i),a.bindFramebuffer.set(null);const l=i[0]+(i[2]>>4<<8),h=i[1]+((15&i[2])<<8),c=this.coordsIndex[255-i[3]],u=c&&this.sourceCache.getTileByID(c);if(!u)return null;const d=this._coordsTextureSize,_=(1<t.id!==e)),this._recentlyUsed.push(t.id);}stampObject(t){t.stamp=++this._stamp;}getOrCreateFreeObject(){for(const t of this._recentlyUsed)if(!this._objects[t].inUse)return this._objects[t];if(this._objects.length>=this._size)throw new Error("No free RenderPool available, call freeAllObjects() required!");const t=this._createObject(this._objects.length);return this._objects.push(t),t}freeObject(t){t.inUse=!1;}freeAllObjects(){for(const t of this._objects)this.freeObject(t);}isFull(){return !(this._objects.length!t.inUse))}}const As={background:!0,fill:!0,line:!0,raster:!0,hillshade:!0};class Rs{constructor(t,e){this.painter=t,this.terrain=e,this.pool=new Ms(t.context,30,e.sourceCache.tileSize*e.qualityFactor);}destruct(){this.pool.destruct();}getTexture(t){return this.pool.getObjectForId(t.rtt[this._stacks.length-1].id).texture}prepareForRender(t,e){this._stacks=[],this._prevType=null,this._rttTiles=[],this._renderableTiles=this.terrain.sourceCache.getRenderableTiles(),this._renderableLayerIds=t._order.filter((i=>!t._layers[i].isHidden(e))),this._coordsDescendingInv={};for(const e in t.sourceCaches){this._coordsDescendingInv[e]={};const i=t.sourceCaches[e].getVisibleCoordinates();for(const t of i){const i=this.terrain.sourceCache.getTerrainCoords(t);for(const t in i)this._coordsDescendingInv[e][t]||(this._coordsDescendingInv[e][t]=[]),this._coordsDescendingInv[e][t].push(i[t]);}}this._coordsDescendingInvStr={};for(const e of t._order){const i=t._layers[e],a=i.source;if(As[i.type]&&!this._coordsDescendingInvStr[a]){this._coordsDescendingInvStr[a]={};for(const t in this._coordsDescendingInv[a])this._coordsDescendingInvStr[a][t]=this._coordsDescendingInv[a][t].map((t=>t.key)).sort().join();}}for(const t of this._renderableTiles)for(const e in this._coordsDescendingInvStr){const i=this._coordsDescendingInvStr[e][t.tileID.key];i&&i!==t.rttCoords[e]&&(t.rtt=[]);}}renderLayer(t){if(t.isHidden(this.painter.transform.zoom))return !1;const i=t.type,a=this.painter,s=this._renderableLayerIds[this._renderableLayerIds.length-1]===t.id;if(As[i]&&(this._prevType&&As[this._prevType]||this._stacks.push([]),this._prevType=i,this._stacks[this._stacks.length-1].push(t.id),!s))return !0;if(As[this._prevType]||As[i]&&s){this._prevType=i;const t=this._stacks.length-1,s=this._stacks[t]||[];for(const i of this._renderableTiles){if(this.pool.isFull()&&(fa(this.painter,this.terrain,this._rttTiles),this._rttTiles=[],this.pool.freeAllObjects()),this._rttTiles.push(i),i.rtt[t]){const e=this.pool.getObjectForId(i.rtt[t].id);if(e.stamp===i.rtt[t].stamp){this.pool.useObject(e);continue}}const o=this.pool.getOrCreateFreeObject();this.pool.useObject(o),this.pool.stampObject(o),i.rtt[t]={id:o.id,stamp:o.stamp},a.context.bindFramebuffer.set(o.fbo.framebuffer),a.context.clear({color:e.aM.transparent,stencil:0}),a.currentStencilSource=void 0;for(let t=0;t{t.touchstart=t.dragStart,t.touchmoveWindow=t.dragMove,t.touchend=t.dragEnd;},Os={showCompass:!0,showZoom:!0,visualizePitch:!1};class Ns{constructor(t,i,a=!1){this.mousedown=t=>{this.startMouse(e.e({},t,{ctrlKey:!0,preventDefault:()=>t.preventDefault()}),r.mousePos(this.element,t)),r.addEventListener(window,"mousemove",this.mousemove),r.addEventListener(window,"mouseup",this.mouseup);},this.mousemove=t=>{this.moveMouse(t,r.mousePos(this.element,t));},this.mouseup=t=>{this.mouseRotate.dragEnd(t),this.mousePitch&&this.mousePitch.dragEnd(t),this.offTemp();},this.touchstart=t=>{1!==t.targetTouches.length?this.reset():(this._startPos=this._lastPos=r.touchPos(this.element,t.targetTouches)[0],this.startTouch(t,this._startPos),r.addEventListener(window,"touchmove",this.touchmove,{passive:!1}),r.addEventListener(window,"touchend",this.touchend));},this.touchmove=t=>{1!==t.targetTouches.length?this.reset():(this._lastPos=r.touchPos(this.element,t.targetTouches)[0],this.moveTouch(t,this._lastPos));},this.touchend=t=>{0===t.targetTouches.length&&this._startPos&&this._lastPos&&this._startPos.dist(this._lastPos){this.mouseRotate.reset(),this.mousePitch&&this.mousePitch.reset(),this.touchRotate.reset(),this.touchPitch&&this.touchPitch.reset(),delete this._startPos,delete this._lastPos,this.offTemp();},this._clickTolerance=10;const s=t.dragRotate._mouseRotate.getClickTolerance(),o=t.dragRotate._mousePitch.getClickTolerance();this.element=i,this.mouseRotate=Ka({clickTolerance:s,enable:!0}),this.touchRotate=(({enable:t,clickTolerance:e,bearingDegreesPerPixelMoved:i=.8})=>{const a=new $a;return new Ga({clickTolerance:e,move:(t,e)=>({bearingDelta:(e.x-t.x)*i}),moveStateManager:a,enable:t,assignEvents:Bs})})({clickTolerance:s,enable:!0}),this.map=t,a&&(this.mousePitch=Ja({clickTolerance:o,enable:!0}),this.touchPitch=(({enable:t,clickTolerance:e,pitchDegreesPerPixelMoved:i=-.5})=>{const a=new $a;return new Ga({clickTolerance:e,move:(t,e)=>({pitchDelta:(e.y-t.y)*i}),moveStateManager:a,enable:t,assignEvents:Bs})})({clickTolerance:o,enable:!0})),r.addEventListener(i,"mousedown",this.mousedown),r.addEventListener(i,"touchstart",this.touchstart,{passive:!1}),r.addEventListener(i,"touchcancel",this.reset);}startMouse(t,e){this.mouseRotate.dragStart(t,e),this.mousePitch&&this.mousePitch.dragStart(t,e),r.disableDrag();}startTouch(t,e){this.touchRotate.dragStart(t,e),this.touchPitch&&this.touchPitch.dragStart(t,e),r.disableDrag();}moveMouse(t,e){const i=this.map,{bearingDelta:a}=this.mouseRotate.dragMove(t,e)||{};if(a&&i.setBearing(i.getBearing()+a),this.mousePitch){const{pitchDelta:a}=this.mousePitch.dragMove(t,e)||{};a&&i.setPitch(i.getPitch()+a);}}moveTouch(t,e){const i=this.map,{bearingDelta:a}=this.touchRotate.dragMove(t,e)||{};if(a&&i.setBearing(i.getBearing()+a),this.touchPitch){const{pitchDelta:a}=this.touchPitch.dragMove(t,e)||{};a&&i.setPitch(i.getPitch()+a);}}off(){const t=this.element;r.removeEventListener(t,"mousedown",this.mousedown),r.removeEventListener(t,"touchstart",this.touchstart,{passive:!1}),r.removeEventListener(window,"touchmove",this.touchmove,{passive:!1}),r.removeEventListener(window,"touchend",this.touchend),r.removeEventListener(t,"touchcancel",this.reset),this.offTemp();}offTemp(){r.enableDrag(),r.removeEventListener(window,"mousemove",this.mousemove),r.removeEventListener(window,"mouseup",this.mouseup),r.removeEventListener(window,"touchmove",this.touchmove,{passive:!1}),r.removeEventListener(window,"touchend",this.touchend);}}let Us;function js(t,i,a){const s=new e.N(t.lng,t.lat);if(t=new e.N(t.lng,t.lat),i){const s=new e.N(t.lng-360,t.lat),o=new e.N(t.lng+360,t.lat),r=a.locationPoint(t).distSqr(i);a.locationPoint(s).distSqr(i)180;){const e=a.locationPoint(t);if(e.x>=0&&e.y>=0&&e.x<=a.width&&e.y<=a.height)break;t.lng>a.center.lng?t.lng-=360:t.lng+=360;}return t.lng!==s.lng&&a.locationPoint(t).y>a.height/2-a.getHorizon()?t:s}const Zs={center:"translate(-50%,-50%)",top:"translate(-50%,0)","top-left":"translate(0,0)","top-right":"translate(-100%,0)",bottom:"translate(-50%,-100%)","bottom-left":"translate(0,-100%)","bottom-right":"translate(-100%,-100%)",left:"translate(0,-50%)",right:"translate(-100%,-50%)"};function qs(t,e,i){const a=t.classList;for(const t in Zs)a.remove(`maplibregl-${i}-anchor-${t}`);a.add(`maplibregl-${i}-anchor-${e}`);}class Vs extends e.E{constructor(t){if(super(),this._onKeyPress=t=>{const e=t.code,i=t.charCode||t.keyCode;"Space"!==e&&"Enter"!==e&&32!==i&&13!==i||this.togglePopup();},this._onMapClick=t=>{const e=t.originalEvent.target,i=this._element;this._popup&&(e===i||i.contains(e))&&this.togglePopup();},this._update=t=>{var e;if(!this._map)return;const i=this._map.loaded()&&!this._map.isMoving();("terrain"===(null==t?void 0:t.type)||"render"===(null==t?void 0:t.type)&&!i)&&this._map.once("render",this._update),this._lngLat=this._map.transform.renderWorldCopies?js(this._lngLat,this._flatPos,this._map.transform):null===(e=this._lngLat)||void 0===e?void 0:e.wrap(),this._flatPos=this._pos=this._map.project(this._lngLat)._add(this._offset),this._map.terrain&&(this._flatPos=this._map.transform.locationPoint(this._lngLat)._add(this._offset));let a="";"viewport"===this._rotationAlignment||"auto"===this._rotationAlignment?a=`rotateZ(${this._rotation}deg)`:"map"===this._rotationAlignment&&(a=`rotateZ(${this._rotation-this._map.getBearing()}deg)`);let s="";"viewport"===this._pitchAlignment||"auto"===this._pitchAlignment?s="rotateX(0deg)":"map"===this._pitchAlignment&&(s=`rotateX(${this._map.getPitch()}deg)`),this._subpixelPositioning||t&&"moveend"!==t.type||(this._pos=this._pos.round()),r.setTransform(this._element,`${Zs[this._anchor]} translate(${this._pos.x}px, ${this._pos.y}px) ${s} ${a}`),o.frameAsync(new AbortController).then((()=>{this._updateOpacity(t&&"moveend"===t.type);})).catch((()=>{}));},this._onMove=t=>{if(!this._isDragging){const e=this._clickTolerance||this._map._clickTolerance;this._isDragging=t.point.dist(this._pointerdownPos)>=e;}this._isDragging&&(this._pos=t.point.sub(this._positionDelta),this._lngLat=this._map.unproject(this._pos),this.setLngLat(this._lngLat),this._element.style.pointerEvents="none","pending"===this._state&&(this._state="active",this.fire(new e.k("dragstart"))),this.fire(new e.k("drag")));},this._onUp=()=>{this._element.style.pointerEvents="auto",this._positionDelta=null,this._pointerdownPos=null,this._isDragging=!1,this._map.off("mousemove",this._onMove),this._map.off("touchmove",this._onMove),"active"===this._state&&this.fire(new e.k("dragend")),this._state="inactive";},this._addDragHandler=t=>{this._element.contains(t.originalEvent.target)&&(t.preventDefault(),this._positionDelta=t.point.sub(this._pos).add(this._offset),this._pointerdownPos=t.point,this._state="pending",this._map.on("mousemove",this._onMove),this._map.on("touchmove",this._onMove),this._map.once("mouseup",this._onUp),this._map.once("touchend",this._onUp));},this._anchor=t&&t.anchor||"center",this._color=t&&t.color||"#3FB1CE",this._scale=t&&t.scale||1,this._draggable=t&&t.draggable||!1,this._clickTolerance=t&&t.clickTolerance||0,this._subpixelPositioning=t&&t.subpixelPositioning||!1,this._isDragging=!1,this._state="inactive",this._rotation=t&&t.rotation||0,this._rotationAlignment=t&&t.rotationAlignment||"auto",this._pitchAlignment=t&&t.pitchAlignment&&"auto"!==t.pitchAlignment?t.pitchAlignment:this._rotationAlignment,this.setOpacity(),this.setOpacity(null==t?void 0:t.opacity,null==t?void 0:t.opacityWhenCovered),t&&t.element)this._element=t.element,this._offset=e.P.convert(t&&t.offset||[0,0]);else {this._defaultMarker=!0,this._element=r.create("div");const i=r.createNS("http://www.w3.org/2000/svg","svg"),a=41,s=27;i.setAttributeNS(null,"display","block"),i.setAttributeNS(null,"height",`${a}px`),i.setAttributeNS(null,"width",`${s}px`),i.setAttributeNS(null,"viewBox",`0 0 ${s} ${a}`);const o=r.createNS("http://www.w3.org/2000/svg","g");o.setAttributeNS(null,"stroke","none"),o.setAttributeNS(null,"stroke-width","1"),o.setAttributeNS(null,"fill","none"),o.setAttributeNS(null,"fill-rule","evenodd");const n=r.createNS("http://www.w3.org/2000/svg","g");n.setAttributeNS(null,"fill-rule","nonzero");const l=r.createNS("http://www.w3.org/2000/svg","g");l.setAttributeNS(null,"transform","translate(3.0, 29.0)"),l.setAttributeNS(null,"fill","#000000");const h=[{rx:"10.5",ry:"5.25002273"},{rx:"10.5",ry:"5.25002273"},{rx:"9.5",ry:"4.77275007"},{rx:"8.5",ry:"4.29549936"},{rx:"7.5",ry:"3.81822308"},{rx:"6.5",ry:"3.34094679"},{rx:"5.5",ry:"2.86367051"},{rx:"4.5",ry:"2.38636864"}];for(const t of h){const e=r.createNS("http://www.w3.org/2000/svg","ellipse");e.setAttributeNS(null,"opacity","0.04"),e.setAttributeNS(null,"cx","10.5"),e.setAttributeNS(null,"cy","5.80029008"),e.setAttributeNS(null,"rx",t.rx),e.setAttributeNS(null,"ry",t.ry),l.appendChild(e);}const c=r.createNS("http://www.w3.org/2000/svg","g");c.setAttributeNS(null,"fill",this._color);const u=r.createNS("http://www.w3.org/2000/svg","path");u.setAttributeNS(null,"d","M27,13.5 C27,19.074644 20.250001,27.000002 14.75,34.500002 C14.016665,35.500004 12.983335,35.500004 12.25,34.500002 C6.7499993,27.000002 0,19.222562 0,13.5 C0,6.0441559 6.0441559,0 13.5,0 C20.955844,0 27,6.0441559 27,13.5 Z"),c.appendChild(u);const d=r.createNS("http://www.w3.org/2000/svg","g");d.setAttributeNS(null,"opacity","0.25"),d.setAttributeNS(null,"fill","#000000");const _=r.createNS("http://www.w3.org/2000/svg","path");_.setAttributeNS(null,"d","M13.5,0 C6.0441559,0 0,6.0441559 0,13.5 C0,19.222562 6.7499993,27 12.25,34.5 C13,35.522727 14.016664,35.500004 14.75,34.5 C20.250001,27 27,19.074644 27,13.5 C27,6.0441559 20.955844,0 13.5,0 Z M13.5,1 C20.415404,1 26,6.584596 26,13.5 C26,15.898657 24.495584,19.181431 22.220703,22.738281 C19.945823,26.295132 16.705119,30.142167 13.943359,33.908203 C13.743445,34.180814 13.612715,34.322738 13.5,34.441406 C13.387285,34.322738 13.256555,34.180814 13.056641,33.908203 C10.284481,30.127985 7.4148684,26.314159 5.015625,22.773438 C2.6163816,19.232715 1,15.953538 1,13.5 C1,6.584596 6.584596,1 13.5,1 Z"),d.appendChild(_);const p=r.createNS("http://www.w3.org/2000/svg","g");p.setAttributeNS(null,"transform","translate(6.0, 7.0)"),p.setAttributeNS(null,"fill","#FFFFFF");const m=r.createNS("http://www.w3.org/2000/svg","g");m.setAttributeNS(null,"transform","translate(8.0, 8.0)");const f=r.createNS("http://www.w3.org/2000/svg","circle");f.setAttributeNS(null,"fill","#000000"),f.setAttributeNS(null,"opacity","0.25"),f.setAttributeNS(null,"cx","5.5"),f.setAttributeNS(null,"cy","5.5"),f.setAttributeNS(null,"r","5.4999962");const g=r.createNS("http://www.w3.org/2000/svg","circle");g.setAttributeNS(null,"fill","#FFFFFF"),g.setAttributeNS(null,"cx","5.5"),g.setAttributeNS(null,"cy","5.5"),g.setAttributeNS(null,"r","5.4999962"),m.appendChild(f),m.appendChild(g),n.appendChild(l),n.appendChild(c),n.appendChild(d),n.appendChild(p),n.appendChild(m),i.appendChild(n),i.setAttributeNS(null,"height",a*this._scale+"px"),i.setAttributeNS(null,"width",s*this._scale+"px"),this._element.appendChild(i),this._offset=e.P.convert(t&&t.offset||[0,-14]);}if(this._element.classList.add("maplibregl-marker"),this._element.addEventListener("dragstart",(t=>{t.preventDefault();})),this._element.addEventListener("mousedown",(t=>{t.preventDefault();})),qs(this._element,this._anchor,"marker"),t&&t.className)for(const e of t.className.split(" "))this._element.classList.add(e);this._popup=null;}addTo(t){return this.remove(),this._map=t,this._element.setAttribute("aria-label",t._getUIString("Marker.Title")),t.getCanvasContainer().appendChild(this._element),t.on("move",this._update),t.on("moveend",this._update),t.on("terrain",this._update),this.setDraggable(this._draggable),this._update(),this._map.on("click",this._onMapClick),this}remove(){return this._opacityTimeout&&(clearTimeout(this._opacityTimeout),delete this._opacityTimeout),this._map&&(this._map.off("click",this._onMapClick),this._map.off("move",this._update),this._map.off("moveend",this._update),this._map.off("terrain",this._update),this._map.off("mousedown",this._addDragHandler),this._map.off("touchstart",this._addDragHandler),this._map.off("mouseup",this._onUp),this._map.off("touchend",this._onUp),this._map.off("mousemove",this._onMove),this._map.off("touchmove",this._onMove),delete this._map),r.remove(this._element),this._popup&&this._popup.remove(),this}getLngLat(){return this._lngLat}setLngLat(t){return this._lngLat=e.N.convert(t),this._pos=null,this._popup&&this._popup.setLngLat(this._lngLat),this._update(),this}getElement(){return this._element}setPopup(t){if(this._popup&&(this._popup.remove(),this._popup=null,this._element.removeEventListener("keypress",this._onKeyPress),this._originalTabIndex||this._element.removeAttribute("tabindex")),t){if(!("offset"in t.options)){const e=38.1,i=13.5,a=Math.abs(i)/Math.SQRT2;t.options.offset=this._defaultMarker?{top:[0,0],"top-left":[0,0],"top-right":[0,0],bottom:[0,-e],"bottom-left":[a,-1*(e-i+a)],"bottom-right":[-a,-1*(e-i+a)],left:[i,-1*(e-i)],right:[-i,-1*(e-i)]}:this._offset;}this._popup=t,this._originalTabIndex=this._element.getAttribute("tabindex"),this._originalTabIndex||this._element.setAttribute("tabindex","0"),this._element.addEventListener("keypress",this._onKeyPress);}return this}setSubpixelPositioning(t){return this._subpixelPositioning=t,this}getPopup(){return this._popup}togglePopup(){const t=this._popup;return this._element.style.opacity===this._opacityWhenCovered?this:t?(t.isOpen()?t.remove():(t.setLngLat(this._lngLat),t.addTo(this._map)),this):this}_updateOpacity(t=!1){var i,a;if(!(null===(i=this._map)||void 0===i?void 0:i.terrain))return void(this._element.style.opacity!==this._opacity&&(this._element.style.opacity=this._opacity));if(t)this._opacityTimeout=null;else {if(this._opacityTimeout)return;this._opacityTimeout=setTimeout((()=>{this._opacityTimeout=null;}),100);}const s=this._map,o=s.terrain.depthAtPoint(this._pos),r=s.terrain.getElevationForLngLatZoom(this._lngLat,s.transform.tileZoom);if(s.transform.lngLatToCameraDepth(this._lngLat,r)-o<.006)return void(this._element.style.opacity=this._opacity);const n=-this._offset.y/s.transform._pixelPerMeter,l=Math.sin(s.getPitch()*Math.PI/180)*n,h=s.terrain.depthAtPoint(new e.P(this._pos.x,this._pos.y-this._offset.y)),c=s.transform.lngLatToCameraDepth(this._lngLat,r+l)-h>.006;(null===(a=this._popup)||void 0===a?void 0:a.isOpen())&&c&&this._popup.remove(),this._element.style.opacity=c?this._opacityWhenCovered:this._opacity;}getOffset(){return this._offset}setOffset(t){return this._offset=e.P.convert(t),this._update(),this}addClassName(t){this._element.classList.add(t);}removeClassName(t){this._element.classList.remove(t);}toggleClassName(t){return this._element.classList.toggle(t)}setDraggable(t){return this._draggable=!!t,this._map&&(t?(this._map.on("mousedown",this._addDragHandler),this._map.on("touchstart",this._addDragHandler)):(this._map.off("mousedown",this._addDragHandler),this._map.off("touchstart",this._addDragHandler))),this}isDraggable(){return this._draggable}setRotation(t){return this._rotation=t||0,this._update(),this}getRotation(){return this._rotation}setRotationAlignment(t){return this._rotationAlignment=t||"auto",this._update(),this}getRotationAlignment(){return this._rotationAlignment}setPitchAlignment(t){return this._pitchAlignment=t&&"auto"!==t?t:this._rotationAlignment,this._update(),this}getPitchAlignment(){return this._pitchAlignment}setOpacity(t,e){return void 0===t&&void 0===e&&(this._opacity="1",this._opacityWhenCovered="0.2"),void 0!==t&&(this._opacity=t),void 0!==e&&(this._opacityWhenCovered=e),this._map&&this._updateOpacity(!0),this}}const Gs={positionOptions:{enableHighAccuracy:!1,maximumAge:0,timeout:6e3},fitBoundsOptions:{maxZoom:15},trackUserLocation:!1,showAccuracyCircle:!0,showUserLocation:!0};let Hs=0,Ws=!1;const $s={maxWidth:100,unit:"metric"};function Xs(t,e,i){const a=i&&i.maxWidth||100,s=t._container.clientHeight/2,o=t.unproject([0,s]),r=t.unproject([a,s]),n=o.distanceTo(r);if(i&&"imperial"===i.unit){const i=3.2808*n;i>5280?Ks(e,a,i/5280,t._getUIString("ScaleControl.Miles")):Ks(e,a,i,t._getUIString("ScaleControl.Feet"));}else i&&"nautical"===i.unit?Ks(e,a,n/1852,t._getUIString("ScaleControl.NauticalMiles")):n>=1e3?Ks(e,a,n/1e3,t._getUIString("ScaleControl.Kilometers")):Ks(e,a,n,t._getUIString("ScaleControl.Meters"));}function Ks(t,e,i,a){const s=function(t){const e=Math.pow(10,`${Math.floor(t)}`.length-1);let i=t/e;return i=i>=10?10:i>=5?5:i>=3?3:i>=2?2:i>=1?1:function(t){const e=Math.pow(10,Math.ceil(-Math.log(t)/Math.LN10));return Math.round(t*e)/e}(i),e*i}(i);t.style.width=e*(s/i)+"px",t.innerHTML=`${s} ${a}`;}const Js={closeButton:!0,closeOnClick:!0,focusAfterOpen:!0,className:"",maxWidth:"240px",subpixelPositioning:!1},Ys=["a[href]","[tabindex]:not([tabindex='-1'])","[contenteditable]:not([contenteditable='false'])","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])"].join(", ");function Qs(t){if(t){if("number"==typeof t){const i=Math.round(Math.abs(t)/Math.SQRT2);return {center:new e.P(0,0),top:new e.P(0,t),"top-left":new e.P(i,i),"top-right":new e.P(-i,i),bottom:new e.P(0,-t),"bottom-left":new e.P(i,-i),"bottom-right":new e.P(-i,-i),left:new e.P(t,0),right:new e.P(-t,0)}}if(t instanceof e.P||Array.isArray(t)){const i=e.P.convert(t);return {center:i,top:i,"top-left":i,"top-right":i,bottom:i,"bottom-left":i,"bottom-right":i,left:i,right:i}}return {center:e.P.convert(t.center||[0,0]),top:e.P.convert(t.top||[0,0]),"top-left":e.P.convert(t["top-left"]||[0,0]),"top-right":e.P.convert(t["top-right"]||[0,0]),bottom:e.P.convert(t.bottom||[0,0]),"bottom-left":e.P.convert(t["bottom-left"]||[0,0]),"bottom-right":e.P.convert(t["bottom-right"]||[0,0]),left:e.P.convert(t.left||[0,0]),right:e.P.convert(t.right||[0,0])}}return Qs(new e.P(0,0))}const to=i;t.AJAXError=e.bh,t.Evented=e.E,t.LngLat=e.N,t.MercatorCoordinate=e.Z,t.Point=e.P,t.addProtocol=e.bi,t.config=e.a,t.removeProtocol=e.bj,t.AttributionControl=Es,t.BoxZoomHandler=Ua,t.CanvasSource=et,t.CooperativeGesturesHandler=vs,t.DoubleClickZoomHandler=ds,t.DragPanHandler=ms,t.DragRotateHandler=fs,t.EdgeInsets=ba,t.FullscreenControl=class extends e.E{constructor(t={}){super(),this._onFullscreenChange=()=>{var t;let e=window.document.fullscreenElement||window.document.mozFullScreenElement||window.document.webkitFullscreenElement||window.document.msFullscreenElement;for(;null===(t=null==e?void 0:e.shadowRoot)||void 0===t?void 0:t.fullscreenElement;)e=e.shadowRoot.fullscreenElement;e===this._container!==this._fullscreen&&this._handleFullscreenChange();},this._onClickFullscreen=()=>{this._isFullscreen()?this._exitFullscreen():this._requestFullscreen();},this._fullscreen=!1,t&&t.container&&(t.container instanceof HTMLElement?this._container=t.container:e.w("Full screen control 'container' must be a DOM element.")),"onfullscreenchange"in document?this._fullscreenchange="fullscreenchange":"onmozfullscreenchange"in document?this._fullscreenchange="mozfullscreenchange":"onwebkitfullscreenchange"in document?this._fullscreenchange="webkitfullscreenchange":"onmsfullscreenchange"in document&&(this._fullscreenchange="MSFullscreenChange");}onAdd(t){return this._map=t,this._container||(this._container=this._map.getContainer()),this._controlContainer=r.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._setupUI(),this._controlContainer}onRemove(){r.remove(this._controlContainer),this._map=null,window.document.removeEventListener(this._fullscreenchange,this._onFullscreenChange);}_setupUI(){const t=this._fullscreenButton=r.create("button","maplibregl-ctrl-fullscreen",this._controlContainer);r.create("span","maplibregl-ctrl-icon",t).setAttribute("aria-hidden","true"),t.type="button",this._updateTitle(),this._fullscreenButton.addEventListener("click",this._onClickFullscreen),window.document.addEventListener(this._fullscreenchange,this._onFullscreenChange);}_updateTitle(){const t=this._getTitle();this._fullscreenButton.setAttribute("aria-label",t),this._fullscreenButton.title=t;}_getTitle(){return this._map._getUIString(this._isFullscreen()?"FullscreenControl.Exit":"FullscreenControl.Enter")}_isFullscreen(){return this._fullscreen}_handleFullscreenChange(){this._fullscreen=!this._fullscreen,this._fullscreenButton.classList.toggle("maplibregl-ctrl-shrink"),this._fullscreenButton.classList.toggle("maplibregl-ctrl-fullscreen"),this._updateTitle(),this._fullscreen?(this.fire(new e.k("fullscreenstart")),this._prevCooperativeGesturesEnabled=this._map.cooperativeGestures.isEnabled(),this._map.cooperativeGestures.disable()):(this.fire(new e.k("fullscreenend")),this._prevCooperativeGesturesEnabled&&this._map.cooperativeGestures.enable());}_exitFullscreen(){window.document.exitFullscreen?window.document.exitFullscreen():window.document.mozCancelFullScreen?window.document.mozCancelFullScreen():window.document.msExitFullscreen?window.document.msExitFullscreen():window.document.webkitCancelFullScreen?window.document.webkitCancelFullScreen():this._togglePseudoFullScreen();}_requestFullscreen(){this._container.requestFullscreen?this._container.requestFullscreen():this._container.mozRequestFullScreen?this._container.mozRequestFullScreen():this._container.msRequestFullscreen?this._container.msRequestFullscreen():this._container.webkitRequestFullscreen?this._container.webkitRequestFullscreen():this._togglePseudoFullScreen();}_togglePseudoFullScreen(){this._container.classList.toggle("maplibregl-pseudo-fullscreen"),this._handleFullscreenChange(),this._map.resize();}},t.GeoJSONSource=J,t.GeolocateControl=class extends e.E{constructor(t){super(),this._onSuccess=t=>{if(this._map){if(this._isOutOfMapMaxBounds(t))return this._setErrorState(),this.fire(new e.k("outofmaxbounds",t)),this._updateMarker(),void this._finish();if(this.options.trackUserLocation)switch(this._lastKnownPosition=t,this._watchState){case"WAITING_ACTIVE":case"ACTIVE_LOCK":case"ACTIVE_ERROR":this._watchState="ACTIVE_LOCK",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case"BACKGROUND":case"BACKGROUND_ERROR":this._watchState="BACKGROUND",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background");break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}this.options.showUserLocation&&"OFF"!==this._watchState&&this._updateMarker(t),this.options.trackUserLocation&&"ACTIVE_LOCK"!==this._watchState||this._updateCamera(t),this.options.showUserLocation&&this._dotElement.classList.remove("maplibregl-user-location-dot-stale"),this.fire(new e.k("geolocate",t)),this._finish();}},this._updateCamera=t=>{const i=new e.N(t.coords.longitude,t.coords.latitude),a=t.coords.accuracy,s=this._map.getBearing(),o=e.e({bearing:s},this.options.fitBoundsOptions),r=H.fromLngLat(i,a);this._map.fitBounds(r,o,{geolocateSource:!0});},this._updateMarker=t=>{if(t){const i=new e.N(t.coords.longitude,t.coords.latitude);this._accuracyCircleMarker.setLngLat(i).addTo(this._map),this._userLocationDotMarker.setLngLat(i).addTo(this._map),this._accuracy=t.coords.accuracy,this.options.showUserLocation&&this.options.showAccuracyCircle&&this._updateCircleRadius();}else this._userLocationDotMarker.remove(),this._accuracyCircleMarker.remove();},this._onZoom=()=>{this.options.showUserLocation&&this.options.showAccuracyCircle&&this._updateCircleRadius();},this._onError=t=>{if(this._map){if(this.options.trackUserLocation)if(1===t.code){this._watchState="OFF",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.disabled=!0;const t=this._map._getUIString("GeolocateControl.LocationNotAvailable");this._geolocateButton.title=t,this._geolocateButton.setAttribute("aria-label",t),void 0!==this._geolocationWatchID&&this._clearWatch();}else {if(3===t.code&&Ws)return;this._setErrorState();}"OFF"!==this._watchState&&this.options.showUserLocation&&this._dotElement.classList.add("maplibregl-user-location-dot-stale"),this.fire(new e.k("error",t)),this._finish();}},this._finish=()=>{this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=void 0;},this._setupUI=()=>{this._map&&(this._container.addEventListener("contextmenu",(t=>t.preventDefault())),this._geolocateButton=r.create("button","maplibregl-ctrl-geolocate",this._container),r.create("span","maplibregl-ctrl-icon",this._geolocateButton).setAttribute("aria-hidden","true"),this._geolocateButton.type="button",this._geolocateButton.disabled=!0);},this._finishSetupUI=t=>{if(this._map){if(!1===t){e.w("Geolocation support is not available so the GeolocateControl will be disabled.");const t=this._map._getUIString("GeolocateControl.LocationNotAvailable");this._geolocateButton.disabled=!0,this._geolocateButton.title=t,this._geolocateButton.setAttribute("aria-label",t);}else {const t=this._map._getUIString("GeolocateControl.FindMyLocation");this._geolocateButton.disabled=!1,this._geolocateButton.title=t,this._geolocateButton.setAttribute("aria-label",t);}this.options.trackUserLocation&&(this._geolocateButton.setAttribute("aria-pressed","false"),this._watchState="OFF"),this.options.showUserLocation&&(this._dotElement=r.create("div","maplibregl-user-location-dot"),this._userLocationDotMarker=new Vs({element:this._dotElement}),this._circleElement=r.create("div","maplibregl-user-location-accuracy-circle"),this._accuracyCircleMarker=new Vs({element:this._circleElement,pitchAlignment:"map"}),this.options.trackUserLocation&&(this._watchState="OFF"),this._map.on("zoom",this._onZoom)),this._geolocateButton.addEventListener("click",(()=>this.trigger())),this._setup=!0,this.options.trackUserLocation&&this._map.on("movestart",(t=>{t.geolocateSource||"ACTIVE_LOCK"!==this._watchState||t.originalEvent&&"resize"===t.originalEvent.type||(this._watchState="BACKGROUND",this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this.fire(new e.k("trackuserlocationend")),this.fire(new e.k("userlocationlostfocus")));}));}},this.options=e.e({},Gs,t);}onAdd(t){return this._map=t,this._container=r.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._setupUI(),function(){return e._(this,arguments,void 0,(function*(t=!1){if(void 0!==Us&&!t)return Us;if(void 0===window.navigator.permissions)return Us=!!window.navigator.geolocation,Us;try{const t=yield window.navigator.permissions.query({name:"geolocation"});Us="denied"!==t.state;}catch(t){Us=!!window.navigator.geolocation;}return Us}))}().then((t=>this._finishSetupUI(t))),this._container}onRemove(){void 0!==this._geolocationWatchID&&(window.navigator.geolocation.clearWatch(this._geolocationWatchID),this._geolocationWatchID=void 0),this.options.showUserLocation&&this._userLocationDotMarker&&this._userLocationDotMarker.remove(),this.options.showAccuracyCircle&&this._accuracyCircleMarker&&this._accuracyCircleMarker.remove(),r.remove(this._container),this._map.off("zoom",this._onZoom),this._map=void 0,Hs=0,Ws=!1;}_isOutOfMapMaxBounds(t){const e=this._map.getMaxBounds(),i=t.coords;return e&&(i.longitudee.getEast()||i.latitudee.getNorth())}_setErrorState(){switch(this._watchState){case"WAITING_ACTIVE":this._watchState="ACTIVE_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active-error");break;case"ACTIVE_LOCK":this._watchState="ACTIVE_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting");break;case"BACKGROUND":this._watchState="BACKGROUND_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting");break;case"ACTIVE_ERROR":break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}}_updateCircleRadius(){const t=this._map.getBounds(),e=t.getSouthEast(),i=t.getNorthEast(),a=e.distanceTo(i),s=Math.ceil(this._accuracy/(a/this._map._container.clientHeight)*2);this._circleElement.style.width=`${s}px`,this._circleElement.style.height=`${s}px`;}trigger(){if(!this._setup)return e.w("Geolocate control triggered before added to a map"),!1;if(this.options.trackUserLocation){switch(this._watchState){case"OFF":this._watchState="WAITING_ACTIVE",this.fire(new e.k("trackuserlocationstart"));break;case"WAITING_ACTIVE":case"ACTIVE_LOCK":case"ACTIVE_ERROR":case"BACKGROUND_ERROR":Hs--,Ws=!1,this._watchState="OFF",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this.fire(new e.k("trackuserlocationend"));break;case"BACKGROUND":this._watchState="ACTIVE_LOCK",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._lastKnownPosition&&this._updateCamera(this._lastKnownPosition),this.fire(new e.k("trackuserlocationstart")),this.fire(new e.k("userlocationfocus"));break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}switch(this._watchState){case"WAITING_ACTIVE":this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case"ACTIVE_LOCK":this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case"OFF":break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}if("OFF"===this._watchState&&void 0!==this._geolocationWatchID)this._clearWatch();else if(void 0===this._geolocationWatchID){let t;this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.setAttribute("aria-pressed","true"),Hs++,Hs>1?(t={maximumAge:6e5,timeout:0},Ws=!0):(t=this.options.positionOptions,Ws=!1),this._geolocationWatchID=window.navigator.geolocation.watchPosition(this._onSuccess,this._onError,t);}}else window.navigator.geolocation.getCurrentPosition(this._onSuccess,this._onError,this.options.positionOptions),this._timeoutId=setTimeout(this._finish,1e4);return !0}_clearWatch(){window.navigator.geolocation.clearWatch(this._geolocationWatchID),this._geolocationWatchID=void 0,this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.setAttribute("aria-pressed","false"),this.options.showUserLocation&&this._updateMarker(null);}},t.Hash=Ea,t.ImageSource=Q,t.KeyboardHandler=ls,t.LngLatBounds=H,t.LogoControl=Ps,t.Map=class extends Ts{constructor(t){e.bf.mark(e.bg.create);const i=Object.assign(Object.assign({},Fs),t);if(null!=i.minZoom&&null!=i.maxZoom&&i.minZoom>i.maxZoom)throw new Error("maxZoom must be greater than or equal to minZoom");if(null!=i.minPitch&&null!=i.maxPitch&&i.minPitch>i.maxPitch)throw new Error("maxPitch must be greater than or equal to minPitch");if(null!=i.minPitch&&i.minPitch<0)throw new Error("minPitch must be greater than or equal to 0");if(null!=i.maxPitch&&i.maxPitch>85)throw new Error("maxPitch must be less than or equal to 85");if(super(new Ta(i.minZoom,i.maxZoom,i.minPitch,i.maxPitch,i.renderWorldCopies),{bearingSnap:i.bearingSnap}),this._idleTriggered=!1,this._crossFadingFactor=1,this._renderTaskQueue=new Cs,this._controls=[],this._mapId=e.a4(),this._contextLost=t=>{t.preventDefault(),this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this.fire(new e.k("webglcontextlost",{originalEvent:t}));},this._contextRestored=t=>{this._setupPainter(),this.resize(),this._update(),this.fire(new e.k("webglcontextrestored",{originalEvent:t}));},this._onMapScroll=t=>{if(t.target===this._container)return this._container.scrollTop=0,this._container.scrollLeft=0,!1},this._onWindowOnline=()=>{this._update();},this._interactive=i.interactive,this._maxTileCacheSize=i.maxTileCacheSize,this._maxTileCacheZoomLevels=i.maxTileCacheZoomLevels,this._failIfMajorPerformanceCaveat=!0===i.failIfMajorPerformanceCaveat,this._preserveDrawingBuffer=!0===i.preserveDrawingBuffer,this._antialias=!0===i.antialias,this._trackResize=!0===i.trackResize,this._bearingSnap=i.bearingSnap,this._refreshExpiredTiles=!0===i.refreshExpiredTiles,this._fadeDuration=i.fadeDuration,this._crossSourceCollisions=!0===i.crossSourceCollisions,this._collectResourceTiming=!0===i.collectResourceTiming,this._locale=Object.assign(Object.assign({},ks),i.locale),this._clickTolerance=i.clickTolerance,this._overridePixelRatio=i.pixelRatio,this._maxCanvasSize=i.maxCanvasSize,this.transformCameraUpdate=i.transformCameraUpdate,this.cancelPendingTileRequestsWhileZooming=!0===i.cancelPendingTileRequestsWhileZooming,this._imageQueueHandle=_.addThrottleControl((()=>this.isMoving())),this._requestManager=new p(i.transformRequest),"string"==typeof i.container){if(this._container=document.getElementById(i.container),!this._container)throw new Error(`Container '${i.container}' not found.`)}else {if(!(i.container instanceof HTMLElement))throw new Error("Invalid type: 'container' must be a String or HTMLElement.");this._container=i.container;}if(i.maxBounds&&this.setMaxBounds(i.maxBounds),this._setupContainer(),this._setupPainter(),this.on("move",(()=>this._update(!1))).on("moveend",(()=>this._update(!1))).on("zoom",(()=>this._update(!0))).on("terrain",(()=>{this.painter.terrainFacilitator.dirty=!0,this._update(!0);})).once("idle",(()=>{this._idleTriggered=!0;})),"undefined"!=typeof window){addEventListener("online",this._onWindowOnline,!1);let t=!1;const e=Ia((t=>{this._trackResize&&!this._removed&&(this.resize(t),this.redraw());}),50);this._resizeObserver=new ResizeObserver((i=>{t?e(i):t=!0;})),this._resizeObserver.observe(this._container);}this.handlers=new ws(this,i),this._hash=i.hash&&new Ea("string"==typeof i.hash&&i.hash||void 0).addTo(this),this._hash&&this._hash._onHashChange()||(this.jumpTo({center:i.center,zoom:i.zoom,bearing:i.bearing,pitch:i.pitch}),i.bounds&&(this.resize(),this.fitBounds(i.bounds,e.e({},i.fitBoundsOptions,{duration:0})))),this.resize(),this._localIdeographFontFamily=i.localIdeographFontFamily,this._validateStyle=i.validateStyle,i.style&&this.setStyle(i.style,{localIdeographFontFamily:i.localIdeographFontFamily}),i.attributionControl&&this.addControl(new Es("boolean"==typeof i.attributionControl?void 0:i.attributionControl)),i.maplibreLogo&&this.addControl(new Ps,i.logoPosition),this.on("style.load",(()=>{this.transform.unmodified&&this.jumpTo(this.style.stylesheet);})),this.on("data",(t=>{this._update("style"===t.dataType),this.fire(new e.k(`${t.dataType}data`,t));})),this.on("dataloading",(t=>{this.fire(new e.k(`${t.dataType}dataloading`,t));})),this.on("dataabort",(t=>{this.fire(new e.k("sourcedataabort",t));}));}_getMapId(){return this._mapId}addControl(t,i){if(void 0===i&&(i=t.getDefaultPosition?t.getDefaultPosition():"top-right"),!t||!t.onAdd)return this.fire(new e.j(new Error("Invalid argument to map.addControl(). Argument must be a control with onAdd and onRemove methods.")));const a=t.onAdd(this);this._controls.push(t);const s=this._controlPositions[i];return -1!==i.indexOf("bottom")?s.insertBefore(a,s.firstChild):s.appendChild(a),this}removeControl(t){if(!t||!t.onRemove)return this.fire(new e.j(new Error("Invalid argument to map.removeControl(). Argument must be a control with onAdd and onRemove methods.")));const i=this._controls.indexOf(t);return i>-1&&this._controls.splice(i,1),t.onRemove(this),this}hasControl(t){return this._controls.indexOf(t)>-1}calculateCameraOptionsFromTo(t,e,i,a){return null==a&&this.terrain&&(a=this.terrain.getElevationForLngLatZoom(i,this.transform.tileZoom)),super.calculateCameraOptionsFromTo(t,e,i,a)}resize(t){var i;const a=this._containerDimensions(),s=a[0],o=a[1],r=this._getClampedPixelRatio(s,o);if(this._resizeCanvas(s,o,r),this.painter.resize(s,o,r),this.painter.overLimit()){const t=this.painter.context.gl;this._maxCanvasSize=[t.drawingBufferWidth,t.drawingBufferHeight];const e=this._getClampedPixelRatio(s,o);this._resizeCanvas(s,o,e),this.painter.resize(s,o,e);}this.transform.resize(s,o),null===(i=this._requestedCameraState)||void 0===i||i.resize(s,o);const n=!this._moving;return n&&(this.stop(),this.fire(new e.k("movestart",t)).fire(new e.k("move",t))),this.fire(new e.k("resize",t)),n&&this.fire(new e.k("moveend",t)),this}_getClampedPixelRatio(t,e){const{0:i,1:a}=this._maxCanvasSize,s=this.getPixelRatio(),o=t*s,r=e*s;return Math.min(o>i?i/o:1,r>a?a/r:1)*s}getPixelRatio(){var t;return null!==(t=this._overridePixelRatio)&&void 0!==t?t:devicePixelRatio}setPixelRatio(t){this._overridePixelRatio=t,this.resize();}getBounds(){return this.transform.getBounds()}getMaxBounds(){return this.transform.getMaxBounds()}setMaxBounds(t){return this.transform.setMaxBounds(H.convert(t)),this._update()}setMinZoom(t){if((t=null==t?-2:t)>=-2&&t<=this.transform.maxZoom)return this.transform.minZoom=t,this._update(),this.getZoom()=this.transform.minZoom)return this.transform.maxZoom=t,this._update(),this.getZoom()>t&&this.setZoom(t),this;throw new Error("maxZoom must be greater than the current minZoom")}getMaxZoom(){return this.transform.maxZoom}setMinPitch(t){if((t=null==t?0:t)<0)throw new Error("minPitch must be greater than or equal to 0");if(t>=0&&t<=this.transform.maxPitch)return this.transform.minPitch=t,this._update(),this.getPitch()85)throw new Error("maxPitch must be less than or equal to 85");if(t>=this.transform.minPitch)return this.transform.maxPitch=t,this._update(),this.getPitch()>t&&this.setPitch(t),this;throw new Error("maxPitch must be greater than the current minPitch")}getMaxPitch(){return this.transform.maxPitch}getRenderWorldCopies(){return this.transform.renderWorldCopies}setRenderWorldCopies(t){return this.transform.renderWorldCopies=t,this._update()}project(t){return this.transform.locationPoint(e.N.convert(t),this.style&&this.terrain)}unproject(t){return this.transform.pointLocation(e.P.convert(t),this.terrain)}isMoving(){var t;return this._moving||(null===(t=this.handlers)||void 0===t?void 0:t.isMoving())}isZooming(){var t;return this._zooming||(null===(t=this.handlers)||void 0===t?void 0:t.isZooming())}isRotating(){var t;return this._rotating||(null===(t=this.handlers)||void 0===t?void 0:t.isRotating())}_createDelegatedListener(t,e,i){if("mouseenter"===t||"mouseover"===t){let a=!1;const s=s=>{const o=e.filter((t=>this.getLayer(t))),r=0!==o.length?this.queryRenderedFeatures(s.point,{layers:o}):[];r.length?a||(a=!0,i.call(this,new ka(t,this,s.originalEvent,{features:r}))):a=!1;};return {layers:e,listener:i,delegates:{mousemove:s,mouseout:()=>{a=!1;}}}}if("mouseleave"===t||"mouseout"===t){let a=!1;const s=s=>{const o=e.filter((t=>this.getLayer(t)));(0!==o.length?this.queryRenderedFeatures(s.point,{layers:o}):[]).length?a=!0:a&&(a=!1,i.call(this,new ka(t,this,s.originalEvent)));},o=e=>{a&&(a=!1,i.call(this,new ka(t,this,e.originalEvent)));};return {layers:e,listener:i,delegates:{mousemove:s,mouseout:o}}}{const a=t=>{const a=e.filter((t=>this.getLayer(t))),s=0!==a.length?this.queryRenderedFeatures(t.point,{layers:a}):[];s.length&&(t.features=s,i.call(this,t),delete t.features);};return {layers:e,listener:i,delegates:{[t]:a}}}}_saveDelegatedListener(t,e){this._delegatedListeners=this._delegatedListeners||{},this._delegatedListeners[t]=this._delegatedListeners[t]||[],this._delegatedListeners[t].push(e);}_removeDelegatedListener(t,e,i){if(!this._delegatedListeners||!this._delegatedListeners[t])return;const a=this._delegatedListeners[t];for(let t=0;te.includes(t)))){for(const t in s.delegates)this.off(t,s.delegates[t]);return void a.splice(t,1)}}}on(t,e,i){if(void 0===i)return super.on(t,e);const a=this._createDelegatedListener(t,"string"==typeof e?[e]:e,i);this._saveDelegatedListener(t,a);for(const t in a.delegates)this.on(t,a.delegates[t]);return this}once(t,e,i){if(void 0===i)return super.once(t,e);const a="string"==typeof e?[e]:e,s=this._createDelegatedListener(t,a,i);for(const e in s.delegates){const o=s.delegates[e];s.delegates[e]=(...e)=>{this._removeDelegatedListener(t,a,i),o(...e);};}this._saveDelegatedListener(t,s);for(const t in s.delegates)this.once(t,s.delegates[t]);return this}off(t,e,i){return void 0===i?super.off(t,e):(this._removeDelegatedListener(t,"string"==typeof e?[e]:e,i),this)}queryRenderedFeatures(t,i){if(!this.style)return [];let a;const s=t instanceof e.P||Array.isArray(t),o=s?t:[[0,0],[this.transform.width,this.transform.height]];if(i=i||(s?{}:t)||{},o instanceof e.P||"number"==typeof o[0])a=[e.P.convert(o)];else {const t=e.P.convert(o[0]),i=e.P.convert(o[1]);a=[t,new e.P(i.x,t.y),i,new e.P(t.x,i.y),t];}return this.style.queryRenderedFeatures(a,i,this.transform)}querySourceFeatures(t,e){return this.style.querySourceFeatures(t,e)}setStyle(t,i){return !1!==(i=e.e({},{localIdeographFontFamily:this._localIdeographFontFamily,validate:this._validateStyle},i)).diff&&i.localIdeographFontFamily===this._localIdeographFontFamily&&this.style&&t?(this._diffStyle(t,i),this):(this._localIdeographFontFamily=i.localIdeographFontFamily,this._updateStyle(t,i))}setTransformRequest(t){return this._requestManager.setTransformRequest(t),this}_getUIString(t){const e=this._locale[t];if(null==e)throw new Error(`Missing UI string '${t}'`);return e}_updateStyle(t,e){if(e.transformStyle&&this.style&&!this.style._loaded)return void this.style.once("style.load",(()=>this._updateStyle(t,e)));const i=this.style&&e.transformStyle?this.style.serialize():void 0;return this.style&&(this.style.setEventedParent(null),this.style._remove(!t)),t?(this.style=new de(this,e||{}),this.style.setEventedParent(this,{style:this.style}),"string"==typeof t?this.style.loadURL(t,e,i):this.style.loadJSON(t,e,i),this):(delete this.style,this)}_lazyInitEmptyStyle(){this.style||(this.style=new de(this,{}),this.style.setEventedParent(this,{style:this.style}),this.style.loadEmpty());}_diffStyle(t,i){if("string"==typeof t){const a=this._requestManager.transformRequest(t,"Style");e.h(a,new AbortController).then((t=>{this._updateDiff(t.data,i);})).catch((t=>{t&&this.fire(new e.j(t));}));}else "object"==typeof t&&this._updateDiff(t,i);}_updateDiff(t,i){try{this.style.setState(t,i)&&this._update(!0);}catch(a){e.w(`Unable to perform style diff: ${a.message||a.error||a}. Rebuilding the style from scratch.`),this._updateStyle(t,i);}}getStyle(){if(this.style)return this.style.serialize()}isStyleLoaded(){return this.style?this.style.loaded():e.w("There is no style added to the map.")}addSource(t,e){return this._lazyInitEmptyStyle(),this.style.addSource(t,e),this._update(!0)}isSourceLoaded(t){const i=this.style&&this.style.sourceCaches[t];if(void 0!==i)return i.loaded();this.fire(new e.j(new Error(`There is no source with ID '${t}'`)));}setTerrain(t){if(this.style._checkLoaded(),this._terrainDataCallback&&this.style.off("data",this._terrainDataCallback),t){const i=this.style.sourceCaches[t.source];if(!i)throw new Error(`cannot load terrain, because there exists no source with ID: ${t.source}`);null===this.terrain&&i.reload();for(const i in this.style._layers){const a=this.style._layers[i];"hillshade"===a.type&&a.source===t.source&&e.w("You are using the same source for a hillshade layer and for 3D terrain. Please consider using two separate sources to improve rendering quality.");}this.terrain=new Ds(this.painter,i,t),this.painter.renderToTexture=new Rs(this.painter,this.terrain),this.transform.minElevationForCurrentTile=this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom),this.transform.elevation=this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom),this._terrainDataCallback=e=>{"style"===e.dataType?this.terrain.sourceCache.freeRtt():"source"===e.dataType&&e.tile&&(e.sourceId!==t.source||this._elevationFreeze||(this.transform.minElevationForCurrentTile=this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom),this.transform.elevation=this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom)),this.terrain.sourceCache.freeRtt(e.tile.tileID));},this.style.on("data",this._terrainDataCallback);}else this.terrain&&this.terrain.sourceCache.destruct(),this.terrain=null,this.painter.renderToTexture&&this.painter.renderToTexture.destruct(),this.painter.renderToTexture=null,this.transform.minElevationForCurrentTile=0,this.transform.elevation=0;return this.fire(new e.k("terrain",{terrain:t})),this}getTerrain(){var t,e;return null!==(e=null===(t=this.terrain)||void 0===t?void 0:t.options)&&void 0!==e?e:null}areTilesLoaded(){const t=this.style&&this.style.sourceCaches;for(const e in t){const i=t[e]._tiles;for(const t in i){const e=i[t];if("loaded"!==e.state&&"errored"!==e.state)return !1}}return !0}removeSource(t){return this.style.removeSource(t),this._update(!0)}getSource(t){return this.style.getSource(t)}addImage(t,i,a={}){const{pixelRatio:s=1,sdf:r=!1,stretchX:n,stretchY:l,content:h,textFitWidth:c,textFitHeight:u}=a;if(this._lazyInitEmptyStyle(),!(i instanceof HTMLImageElement||e.b(i))){if(void 0===i.width||void 0===i.height)return this.fire(new e.j(new Error("Invalid arguments to map.addImage(). The second argument must be an `HTMLImageElement`, `ImageData`, `ImageBitmap`, or object with `width`, `height`, and `data` properties with the same format as `ImageData`")));{const{width:a,height:o,data:d}=i,_=i;return this.style.addImage(t,{data:new e.R({width:a,height:o},new Uint8Array(d)),pixelRatio:s,stretchX:n,stretchY:l,content:h,textFitWidth:c,textFitHeight:u,sdf:r,version:0,userImage:_}),_.onAdd&&_.onAdd(this,t),this}}{const{width:a,height:d,data:_}=o.getImageData(i);this.style.addImage(t,{data:new e.R({width:a,height:d},_),pixelRatio:s,stretchX:n,stretchY:l,content:h,textFitWidth:c,textFitHeight:u,sdf:r,version:0});}}updateImage(t,i){const a=this.style.getImage(t);if(!a)return this.fire(new e.j(new Error("The map has no image with that id. If you are adding a new image use `map.addImage(...)` instead.")));const s=i instanceof HTMLImageElement||e.b(i)?o.getImageData(i):i,{width:r,height:n,data:l}=s;if(void 0===r||void 0===n)return this.fire(new e.j(new Error("Invalid arguments to map.updateImage(). The second argument must be an `HTMLImageElement`, `ImageData`, `ImageBitmap`, or object with `width`, `height`, and `data` properties with the same format as `ImageData`")));if(r!==a.data.width||n!==a.data.height)return this.fire(new e.j(new Error("The width and height of the updated image must be that same as the previous version of the image")));const h=!(i instanceof HTMLImageElement||e.b(i));return a.data.replace(l,h),this.style.updateImage(t,a),this}getImage(t){return this.style.getImage(t)}hasImage(t){return t?!!this.style.getImage(t):(this.fire(new e.j(new Error("Missing required image id"))),!1)}removeImage(t){this.style.removeImage(t);}loadImage(t){return _.getImage(this._requestManager.transformRequest(t,"Image"),new AbortController)}listImages(){return this.style.listImages()}addLayer(t,e){return this._lazyInitEmptyStyle(),this.style.addLayer(t,e),this._update(!0)}moveLayer(t,e){return this.style.moveLayer(t,e),this._update(!0)}removeLayer(t){return this.style.removeLayer(t),this._update(!0)}getLayer(t){return this.style.getLayer(t)}getLayersOrder(){return this.style.getLayersOrder()}setLayerZoomRange(t,e,i){return this.style.setLayerZoomRange(t,e,i),this._update(!0)}setFilter(t,e,i={}){return this.style.setFilter(t,e,i),this._update(!0)}getFilter(t){return this.style.getFilter(t)}setPaintProperty(t,e,i,a={}){return this.style.setPaintProperty(t,e,i,a),this._update(!0)}getPaintProperty(t,e){return this.style.getPaintProperty(t,e)}setLayoutProperty(t,e,i,a={}){return this.style.setLayoutProperty(t,e,i,a),this._update(!0)}getLayoutProperty(t,e){return this.style.getLayoutProperty(t,e)}setGlyphs(t,e={}){return this._lazyInitEmptyStyle(),this.style.setGlyphs(t,e),this._update(!0)}getGlyphs(){return this.style.getGlyphsUrl()}addSprite(t,e,i={}){return this._lazyInitEmptyStyle(),this.style.addSprite(t,e,i,(t=>{t||this._update(!0);})),this}removeSprite(t){return this._lazyInitEmptyStyle(),this.style.removeSprite(t),this._update(!0)}getSprite(){return this.style.getSprite()}setSprite(t,e={}){return this._lazyInitEmptyStyle(),this.style.setSprite(t,e,(t=>{t||this._update(!0);})),this}setLight(t,e={}){return this._lazyInitEmptyStyle(),this.style.setLight(t,e),this._update(!0)}getLight(){return this.style.getLight()}setSky(t){return this._lazyInitEmptyStyle(),this.style.setSky(t),this._update(!0)}getSky(){return this.style.getSky()}setFeatureState(t,e){return this.style.setFeatureState(t,e),this._update()}removeFeatureState(t,e){return this.style.removeFeatureState(t,e),this._update()}getFeatureState(t){return this.style.getFeatureState(t)}getContainer(){return this._container}getCanvasContainer(){return this._canvasContainer}getCanvas(){return this._canvas}_containerDimensions(){let t=0,e=0;return this._container&&(t=this._container.clientWidth||400,e=this._container.clientHeight||300),[t,e]}_setupContainer(){const t=this._container;t.classList.add("maplibregl-map");const e=this._canvasContainer=r.create("div","maplibregl-canvas-container",t);this._interactive&&e.classList.add("maplibregl-interactive"),this._canvas=r.create("canvas","maplibregl-canvas",e),this._canvas.addEventListener("webglcontextlost",this._contextLost,!1),this._canvas.addEventListener("webglcontextrestored",this._contextRestored,!1),this._canvas.setAttribute("tabindex",this._interactive?"0":"-1"),this._canvas.setAttribute("aria-label",this._getUIString("Map.Title")),this._canvas.setAttribute("role","region");const i=this._containerDimensions(),a=this._getClampedPixelRatio(i[0],i[1]);this._resizeCanvas(i[0],i[1],a);const s=this._controlContainer=r.create("div","maplibregl-control-container",t),o=this._controlPositions={};["top-left","top-right","bottom-left","bottom-right"].forEach((t=>{o[t]=r.create("div",`maplibregl-ctrl-${t} `,s);})),this._container.addEventListener("scroll",this._onMapScroll,!1);}_resizeCanvas(t,e,i){this._canvas.width=Math.floor(i*t),this._canvas.height=Math.floor(i*e),this._canvas.style.width=`${t}px`,this._canvas.style.height=`${e}px`;}_setupPainter(){const t={alpha:!0,stencil:!0,depth:!0,failIfMajorPerformanceCaveat:this._failIfMajorPerformanceCaveat,preserveDrawingBuffer:this._preserveDrawingBuffer,antialias:this._antialias||!1};let e=null;this._canvas.addEventListener("webglcontextcreationerror",(i=>{e={requestedAttributes:t},i&&(e.statusMessage=i.statusMessage,e.type=i.type);}),{once:!0});const i=this._canvas.getContext("webgl2",t)||this._canvas.getContext("webgl",t);if(!i){const t="Failed to initialize WebGL";throw e?(e.message=t,new Error(JSON.stringify(e))):new Error(t)}this.painter=new va(i,this.transform),n.testSupport(i);}loaded(){return !this._styleDirty&&!this._sourcesDirty&&!!this.style&&this.style.loaded()}_update(t){return this.style&&this.style._loaded?(this._styleDirty=this._styleDirty||t,this._sourcesDirty=!0,this.triggerRepaint(),this):this}_requestRenderFrame(t){return this._update(),this._renderTaskQueue.add(t)}_cancelRenderFrame(t){this._renderTaskQueue.remove(t);}_render(t){const i=this._idleTriggered?this._fadeDuration:0;if(this.painter.context.setDirty(),this.painter.setBaseState(),this._renderTaskQueue.run(t),this._removed)return;let a=!1;if(this.style&&this._styleDirty){this._styleDirty=!1;const t=this.transform.zoom,s=o.now();this.style.zoomHistory.update(t,s);const r=new e.z(t,{now:s,fadeDuration:i,zoomHistory:this.style.zoomHistory,transition:this.style.getTransition()}),n=r.crossFadingFactor();1===n&&n===this._crossFadingFactor||(a=!0,this._crossFadingFactor=n),this.style.update(r);}this.style&&this._sourcesDirty&&(this._sourcesDirty=!1,this.style._updateSources(this.transform)),this.terrain?(this.terrain.sourceCache.update(this.transform,this.terrain),this.transform.minElevationForCurrentTile=this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom),this._elevationFreeze||(this.transform.elevation=this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom))):(this.transform.minElevationForCurrentTile=0,this.transform.elevation=0),this._placementDirty=this.style&&this.style._updatePlacement(this.painter.transform,this.showCollisionBoxes,i,this._crossSourceCollisions),this.painter.render(this.style,{showTileBoundaries:this.showTileBoundaries,showOverdrawInspector:this._showOverdrawInspector,rotating:this.isRotating(),zooming:this.isZooming(),moving:this.isMoving(),fadeDuration:i,showPadding:this.showPadding}),this.fire(new e.k("render")),this.loaded()&&!this._loaded&&(this._loaded=!0,e.bf.mark(e.bg.load),this.fire(new e.k("load"))),this.style&&(this.style.hasTransitions()||a)&&(this._styleDirty=!0),this.style&&!this._placementDirty&&this.style._releaseSymbolFadeTiles();const s=this._sourcesDirty||this._styleDirty||this._placementDirty;return s||this._repaint?this.triggerRepaint():!this.isMoving()&&this.loaded()&&this.fire(new e.k("idle")),!this._loaded||this._fullyLoaded||s||(this._fullyLoaded=!0,e.bf.mark(e.bg.fullLoad)),this}redraw(){return this.style&&(this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._render(0)),this}remove(){var t;this._hash&&this._hash.remove();for(const t of this._controls)t.onRemove(this);this._controls=[],this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._renderTaskQueue.clear(),this.painter.destroy(),this.handlers.destroy(),delete this.handlers,this.setStyle(null),"undefined"!=typeof window&&removeEventListener("online",this._onWindowOnline,!1),_.removeThrottleControl(this._imageQueueHandle),null===(t=this._resizeObserver)||void 0===t||t.disconnect();const i=this.painter.context.gl.getExtension("WEBGL_lose_context");(null==i?void 0:i.loseContext)&&i.loseContext(),this._canvas.removeEventListener("webglcontextrestored",this._contextRestored,!1),this._canvas.removeEventListener("webglcontextlost",this._contextLost,!1),r.remove(this._canvasContainer),r.remove(this._controlContainer),this._container.classList.remove("maplibregl-map"),e.bf.clearMetrics(),this._removed=!0,this.fire(new e.k("remove"));}triggerRepaint(){this.style&&!this._frameRequest&&(this._frameRequest=new AbortController,o.frameAsync(this._frameRequest).then((t=>{e.bf.frame(t),this._frameRequest=null,this._render(t);})).catch((()=>{})));}get showTileBoundaries(){return !!this._showTileBoundaries}set showTileBoundaries(t){this._showTileBoundaries!==t&&(this._showTileBoundaries=t,this._update());}get showPadding(){return !!this._showPadding}set showPadding(t){this._showPadding!==t&&(this._showPadding=t,this._update());}get showCollisionBoxes(){return !!this._showCollisionBoxes}set showCollisionBoxes(t){this._showCollisionBoxes!==t&&(this._showCollisionBoxes=t,t?this.style._generateCollisionBoxes():this._update());}get showOverdrawInspector(){return !!this._showOverdrawInspector}set showOverdrawInspector(t){this._showOverdrawInspector!==t&&(this._showOverdrawInspector=t,this._update());}get repaint(){return !!this._repaint}set repaint(t){this._repaint!==t&&(this._repaint=t,this.triggerRepaint());}get vertices(){return !!this._vertices}set vertices(t){this._vertices=t,this._update();}get version(){return Ls}getCameraTargetElevation(){return this.transform.elevation}},t.MapMouseEvent=ka,t.MapTouchEvent=La,t.MapWheelEvent=Fa,t.Marker=Vs,t.NavigationControl=class{constructor(t){this._updateZoomButtons=()=>{const t=this._map.getZoom(),e=t===this._map.getMaxZoom(),i=t===this._map.getMinZoom();this._zoomInButton.disabled=e,this._zoomOutButton.disabled=i,this._zoomInButton.setAttribute("aria-disabled",e.toString()),this._zoomOutButton.setAttribute("aria-disabled",i.toString());},this._rotateCompassArrow=()=>{const t=this.options.visualizePitch?`scale(${1/Math.pow(Math.cos(this._map.transform.pitch*(Math.PI/180)),.5)}) rotateX(${this._map.transform.pitch}deg) rotateZ(${this._map.transform.angle*(180/Math.PI)}deg)`:`rotate(${this._map.transform.angle*(180/Math.PI)}deg)`;this._compassIcon.style.transform=t;},this._setButtonTitle=(t,e)=>{const i=this._map._getUIString(`NavigationControl.${e}`);t.title=i,t.setAttribute("aria-label",i);},this.options=e.e({},Os,t),this._container=r.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._container.addEventListener("contextmenu",(t=>t.preventDefault())),this.options.showZoom&&(this._zoomInButton=this._createButton("maplibregl-ctrl-zoom-in",(t=>this._map.zoomIn({},{originalEvent:t}))),r.create("span","maplibregl-ctrl-icon",this._zoomInButton).setAttribute("aria-hidden","true"),this._zoomOutButton=this._createButton("maplibregl-ctrl-zoom-out",(t=>this._map.zoomOut({},{originalEvent:t}))),r.create("span","maplibregl-ctrl-icon",this._zoomOutButton).setAttribute("aria-hidden","true")),this.options.showCompass&&(this._compass=this._createButton("maplibregl-ctrl-compass",(t=>{this.options.visualizePitch?this._map.resetNorthPitch({},{originalEvent:t}):this._map.resetNorth({},{originalEvent:t});})),this._compassIcon=r.create("span","maplibregl-ctrl-icon",this._compass),this._compassIcon.setAttribute("aria-hidden","true"));}onAdd(t){return this._map=t,this.options.showZoom&&(this._setButtonTitle(this._zoomInButton,"ZoomIn"),this._setButtonTitle(this._zoomOutButton,"ZoomOut"),this._map.on("zoom",this._updateZoomButtons),this._updateZoomButtons()),this.options.showCompass&&(this._setButtonTitle(this._compass,"ResetBearing"),this.options.visualizePitch&&this._map.on("pitch",this._rotateCompassArrow),this._map.on("rotate",this._rotateCompassArrow),this._rotateCompassArrow(),this._handler=new Ns(this._map,this._compass,this.options.visualizePitch)),this._container}onRemove(){r.remove(this._container),this.options.showZoom&&this._map.off("zoom",this._updateZoomButtons),this.options.showCompass&&(this.options.visualizePitch&&this._map.off("pitch",this._rotateCompassArrow),this._map.off("rotate",this._rotateCompassArrow),this._handler.off(),delete this._handler),delete this._map;}_createButton(t,e){const i=r.create("button",t,this._container);return i.type="button",i.addEventListener("click",e),i}},t.Popup=class extends e.E{constructor(t){super(),this.remove=()=>(this._content&&r.remove(this._content),this._container&&(r.remove(this._container),delete this._container),this._map&&(this._map.off("move",this._update),this._map.off("move",this._onClose),this._map.off("click",this._onClose),this._map.off("remove",this.remove),this._map.off("mousemove",this._onMouseMove),this._map.off("mouseup",this._onMouseUp),this._map.off("drag",this._onDrag),this._map._canvasContainer.classList.remove("maplibregl-track-pointer"),delete this._map,this.fire(new e.k("close"))),this),this._onMouseUp=t=>{this._update(t.point);},this._onMouseMove=t=>{this._update(t.point);},this._onDrag=t=>{this._update(t.point);},this._update=t=>{var e;if(!this._map||!this._lngLat&&!this._trackPointer||!this._content)return;if(!this._container){if(this._container=r.create("div","maplibregl-popup",this._map.getContainer()),this._tip=r.create("div","maplibregl-popup-tip",this._container),this._container.appendChild(this._content),this.options.className)for(const t of this.options.className.split(" "))this._container.classList.add(t);this._closeButton&&this._closeButton.setAttribute("aria-label",this._map._getUIString("Popup.Close")),this._trackPointer&&this._container.classList.add("maplibregl-popup-track-pointer");}if(this.options.maxWidth&&this._container.style.maxWidth!==this.options.maxWidth&&(this._container.style.maxWidth=this.options.maxWidth),this._lngLat=this._map.transform.renderWorldCopies&&!this._trackPointer?js(this._lngLat,this._flatPos,this._map.transform):null===(e=this._lngLat)||void 0===e?void 0:e.wrap(),this._trackPointer&&!t)return;const i=this._flatPos=this._pos=this._trackPointer&&t?t:this._map.project(this._lngLat);this._map.terrain&&(this._flatPos=this._trackPointer&&t?t:this._map.transform.locationPoint(this._lngLat));let a=this.options.anchor;const s=Qs(this.options.offset);if(!a){const t=this._container.offsetWidth,e=this._container.offsetHeight;let o;o=i.y+s.bottom.ythis._map.transform.height-e?["bottom"]:[],i.xthis._map.transform.width-t/2&&o.push("right"),a=0===o.length?"bottom":o.join("-");}let o=i.add(s[a]);this.options.subpixelPositioning||(o=o.round()),r.setTransform(this._container,`${Zs[a]} translate(${o.x}px,${o.y}px)`),qs(this._container,a,"popup");},this._onClose=()=>{this.remove();},this.options=e.e(Object.create(Js),t);}addTo(t){return this._map&&this.remove(),this._map=t,this.options.closeOnClick&&this._map.on("click",this._onClose),this.options.closeOnMove&&this._map.on("move",this._onClose),this._map.on("remove",this.remove),this._update(),this._focusFirstElement(),this._trackPointer?(this._map.on("mousemove",this._onMouseMove),this._map.on("mouseup",this._onMouseUp),this._container&&this._container.classList.add("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.add("maplibregl-track-pointer")):this._map.on("move",this._update),this.fire(new e.k("open")),this}isOpen(){return !!this._map}getLngLat(){return this._lngLat}setLngLat(t){return this._lngLat=e.N.convert(t),this._pos=null,this._flatPos=null,this._trackPointer=!1,this._update(),this._map&&(this._map.on("move",this._update),this._map.off("mousemove",this._onMouseMove),this._container&&this._container.classList.remove("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.remove("maplibregl-track-pointer")),this}trackPointer(){return this._trackPointer=!0,this._pos=null,this._flatPos=null,this._update(),this._map&&(this._map.off("move",this._update),this._map.on("mousemove",this._onMouseMove),this._map.on("drag",this._onDrag),this._container&&this._container.classList.add("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.add("maplibregl-track-pointer")),this}getElement(){return this._container}setText(t){return this.setDOMContent(document.createTextNode(t))}setHTML(t){const e=document.createDocumentFragment(),i=document.createElement("body");let a;for(i.innerHTML=t;a=i.firstChild,a;)e.appendChild(a);return this.setDOMContent(e)}getMaxWidth(){var t;return null===(t=this._container)||void 0===t?void 0:t.style.maxWidth}setMaxWidth(t){return this.options.maxWidth=t,this._update(),this}setDOMContent(t){if(this._content)for(;this._content.hasChildNodes();)this._content.firstChild&&this._content.removeChild(this._content.firstChild);else this._content=r.create("div","maplibregl-popup-content",this._container);return this._content.appendChild(t),this._createCloseButton(),this._update(),this._focusFirstElement(),this}addClassName(t){return this._container&&this._container.classList.add(t),this}removeClassName(t){return this._container&&this._container.classList.remove(t),this}setOffset(t){return this.options.offset=t,this._update(),this}toggleClassName(t){if(this._container)return this._container.classList.toggle(t)}setSubpixelPositioning(t){this.options.subpixelPositioning=t;}_createCloseButton(){this.options.closeButton&&(this._closeButton=r.create("button","maplibregl-popup-close-button",this._content),this._closeButton.type="button",this._closeButton.innerHTML="×",this._closeButton.addEventListener("click",this._onClose));}_focusFirstElement(){if(!this.options.focusAfterOpen||!this._container)return;const t=this._container.querySelector(Ys);t&&t.focus();}},t.RasterDEMTileSource=K,t.RasterTileSource=X,t.ScaleControl=class{constructor(t){this._onMove=()=>{Xs(this._map,this._container,this.options);},this.setUnit=t=>{this.options.unit=t,Xs(this._map,this._container,this.options);},this.options=Object.assign(Object.assign({},$s),t);}getDefaultPosition(){return "bottom-left"}onAdd(t){return this._map=t,this._container=r.create("div","maplibregl-ctrl maplibregl-ctrl-scale",t.getContainer()),this._map.on("move",this._onMove),this._onMove(),this._container}onRemove(){r.remove(this._container),this._map.off("move",this._onMove),this._map=void 0;}},t.ScrollZoomHandler=us,t.Style=de,t.TerrainControl=class{constructor(t){this._toggleTerrain=()=>{this._map.getTerrain()?this._map.setTerrain(null):this._map.setTerrain(this.options),this._updateTerrainIcon();},this._updateTerrainIcon=()=>{this._terrainButton.classList.remove("maplibregl-ctrl-terrain"),this._terrainButton.classList.remove("maplibregl-ctrl-terrain-enabled"),this._map.terrain?(this._terrainButton.classList.add("maplibregl-ctrl-terrain-enabled"),this._terrainButton.title=this._map._getUIString("TerrainControl.Disable")):(this._terrainButton.classList.add("maplibregl-ctrl-terrain"),this._terrainButton.title=this._map._getUIString("TerrainControl.Enable"));},this.options=t;}onAdd(t){return this._map=t,this._container=r.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._terrainButton=r.create("button","maplibregl-ctrl-terrain",this._container),r.create("span","maplibregl-ctrl-icon",this._terrainButton).setAttribute("aria-hidden","true"),this._terrainButton.type="button",this._terrainButton.addEventListener("click",this._toggleTerrain),this._updateTerrainIcon(),this._map.on("terrain",this._updateTerrainIcon),this._container}onRemove(){r.remove(this._container),this._map.off("terrain",this._updateTerrainIcon),this._map=void 0;}},t.TwoFingersTouchPitchHandler=rs,t.TwoFingersTouchRotateHandler=ss,t.TwoFingersTouchZoomHandler=is,t.TwoFingersTouchZoomRotateHandler=gs,t.VectorTileSource=$,t.VideoSource=tt,t.addSourceType=(t,i)=>e._(void 0,void 0,void 0,(function*(){if(at(t))throw new Error(`A source type called "${t}" already exists.`);((t,e)=>{it[t]=e;})(t,i);})),t.clearPrewarmedResources=function(){const t=B;t&&(t.isPreloaded()&&1===t.numActive()?(t.release(k),B=null):console.warn("Could not clear WebWorkers since there are active Map instances that still reference it. The pre-warmed WebWorker pool can only be cleared when all map instances have been removed with map.remove()"));},t.getMaxParallelImageRequests=function(){return e.a.MAX_PARALLEL_IMAGE_REQUESTS},t.getRTLTextPluginStatus=function(){return nt().getRTLTextPluginStatus()},t.getVersion=function(){return to},t.getWorkerCount=function(){return L.workerCount},t.getWorkerUrl=function(){return e.a.WORKER_URL},t.importScriptInWorkers=function(t){return j().broadcast("IS",t)},t.prewarm=function(){N().acquire(k);},t.setMaxParallelImageRequests=function(t){e.a.MAX_PARALLEL_IMAGE_REQUESTS=t;},t.setRTLTextPlugin=function(t,e){return nt().setRTLTextPlugin(t,e)},t.setWorkerCount=function(t){L.workerCount=t;},t.setWorkerUrl=function(t){e.a.WORKER_URL=t;};})); + +// +// Our custom intro provides a specialized "define()" function, called by the +// AMD modules below, that sets up the worker blob URL and then executes the +// main module, storing its exported value as 'maplibregl' + + +var maplibregl$1 = maplibregl; + +return maplibregl$1; + +})); +//# sourceMappingURL=maplibre-gl.js.map diff --git a/src/app/resources/map/trajectory_map.html b/src/app/resources/map/trajectory_map.html new file mode 100644 index 0000000..043cf27 --- /dev/null +++ b/src/app/resources/map/trajectory_map.html @@ -0,0 +1,363 @@ + + + + + + 电极轨迹地图 + + + + +
    + + + + diff --git a/src/controller/DatasetDetailTab.hpp b/src/controller/DatasetDetailTab.hpp index d4b1676..2360d2e 100644 --- a/src/controller/DatasetDetailTab.hpp +++ b/src/controller/DatasetDetailTab.hpp @@ -6,7 +6,8 @@ namespace geopro::controller { // 详情页签的渲染 kind 全集(Image/GISMap 待 GPR/radar 有活样本再加,YAGNI)。 -enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table }; +// WebMap = trajectory 地图页签占位(真实 GIS 地图待 Qt WebEngine 模块安装后接 QWebEngineView 替换)。 +enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table, WebMap }; // 页签描述符:策略声明每个 dd 类型的页签集(标题/kind/加载键/惰性/分页)。 struct TabSpec { diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index d769005..28518ae 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -83,9 +83,37 @@ struct BarPayload { QString yTitle; }; +// 折线图载荷(dd_trajectory_data 高程页签):单条平滑折线。 +// 类目(x 轴标签,如 "#1".."#40",来自 electrodeNo)+ 对齐的 y 值(高程)+ 系列名 + 线色(hex, +// 如 #5470c6,ECharts 默认蓝,两主题一致)+ 轴标题(x「电极号」/ y「高程」)+ 是否平滑。 +struct LinePayload { + std::vector categories; + std::vector y; + QString seriesName; + QString color; + QString xTitle; + QString yTitle; + bool smooth = true; +}; + +// 轨迹地图点(dd_trajectory_data 地图页签):电极号 + WGS84 经纬度。 +// 服务端 dd/ert/trajectory/line 直接返回 EPSG:4326 经纬(x=经度 lon、y=纬度 lat)。 +struct MapPoint { + int electrodeNo = 0; + double lon = 0; + double lat = 0; +}; + +// 轨迹地图载荷:一组电极经纬点(按电极号顺序)。视图据此在 Leaflet 上画橙色空心圈标记。 +struct MapPayload { + std::vector points; +}; + } // namespace geopro::core Q_DECLARE_METATYPE(geopro::core::ScatterPayload) Q_DECLARE_METATYPE(geopro::core::ContourPayload) Q_DECLARE_METATYPE(geopro::core::TablePayload) Q_DECLARE_METATYPE(geopro::core::BarPayload) +Q_DECLARE_METATYPE(geopro::core::LinePayload) +Q_DECLARE_METATYPE(geopro::core::MapPayload) diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 0dcde7c..5161dde 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(geopro_data STATIC dto/DatasetChartDto.cpp dto/MeasurementDto.cpp dto/GrMeasurementDto.cpp + dto/TrajectoryDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index b6114c0..a4863f6 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -11,6 +11,7 @@ #include "dto/DatasetChartDto.hpp" #include "dto/GrMeasurementDto.hpp" #include "dto/MeasurementDto.hpp" +#include "dto/TrajectoryDto.hpp" #include "model/detail/DetailPayloads.hpp" namespace geopro::data { @@ -112,6 +113,25 @@ QJsonArray grDataArray(const QList& r) { return r[0].data.value(QStringLiteral("value")).toArray(); } +// 轨迹(trajectory):列表与高程同一端点 trajectory/rows(GET,query 参数)。 +// 响应 data = {rowList[40], gridHeaderDisplay[14], ...}。 +net::ApiBatch* trajectoryRowsBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/trajectory/rows?dsObjectId=%1").arg(enc(dsId))), + }; + return new net::ApiBatch(calls, &isFailure); +} + +// 轨迹地图:单请求 trajectory/line(GET,query 参数)。frontCrsCode=EPSG:4326(原版发 EPSG%3A4326, +// 此处用 enc() 对整串 url 编码故 ':' → %3A)。响应 data = {electrodelList[...]}。 +net::ApiBatch* trajectoryLineBatch(net::ApiClient& api, const std::string& dsId) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/trajectory/line?dsObjectId=%1&frontCrsCode=%2") + .arg(enc(dsId), enc("EPSG:4326"))), + }; + return new net::ApiBatch(calls, &isFailure); +} + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} @@ -123,6 +143,9 @@ DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const if (loaderKey == "ert_measurement.rows") return makeMeasurementRows(dsId); if (loaderKey == "gr.bar") return makeGrBar(dsId); if (loaderKey == "gr.rows") return makeGrRows(dsId); + if (loaderKey == "traj.rows") return makeTrajectoryRows(dsId); + if (loaderKey == "traj.elev") return makeTrajectoryElevation(dsId); + if (loaderKey == "traj.map") return makeTrajectoryMap(dsId); throw std::runtime_error("unknown loaderKey: " + loaderKey); } @@ -166,4 +189,22 @@ DetailLoad* ApiDatasetRepository::makeGrRows(const std::string& dsId) { }); } +DetailLoad* ApiDatasetRepository::makeTrajectoryRows(const std::string& dsId) { + return new ApiDetailLoad(trajectoryRowsBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseTrajectoryTable(r[0].data)); + }); +} + +DetailLoad* ApiDatasetRepository::makeTrajectoryElevation(const std::string& dsId) { + return new ApiDetailLoad(trajectoryRowsBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseTrajectoryElevation(r[0].data)); + }); +} + +DetailLoad* ApiDatasetRepository::makeTrajectoryMap(const std::string& dsId) { + return new ApiDetailLoad(trajectoryLineBatch(api_, dsId), [](const QList& r) { + return QVariant::fromValue(dto::parseTrajectoryMap(r[0].data)); + }); +} + } // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index 7dd3897..7ecb997 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -15,6 +15,9 @@ private: DetailLoad* makeMeasurementRows(const std::string& dsId); DetailLoad* makeGrBar(const std::string& dsId); DetailLoad* makeGrRows(const std::string& dsId); + DetailLoad* makeTrajectoryRows(const std::string& dsId); + DetailLoad* makeTrajectoryElevation(const std::string& dsId); + DetailLoad* makeTrajectoryMap(const std::string& dsId); net::ApiClient& api_; }; } // namespace geopro::data diff --git a/src/data/dto/TrajectoryDto.cpp b/src/data/dto/TrajectoryDto.cpp new file mode 100644 index 0000000..c4ff1f2 --- /dev/null +++ b/src/data/dto/TrajectoryDto.cpp @@ -0,0 +1,111 @@ +#include "dto/TrajectoryDto.hpp" + +#include + +#include +#include + +namespace geopro::data::dto { +using namespace geopro::core; + +namespace { + +// ECharts 默认蓝(折线色,数据色——浅/暗主题一致)。 +const QString kLineColor = QStringLiteral("#5470c6"); + +// 把 JSON 值预格式化为单元格 QString(整数不带小数点;null/缺省→空串)。 +QString cellText(const QJsonValue& v) { + if (v.isDouble()) { + const double d = v.toDouble(); + if (d == static_cast(static_cast(d))) + return QString::number(static_cast(d)); + return QString::number(d, 'g', 10); + } + if (v.isString()) return v.toString(); + if (v.isBool()) return v.toBool() ? QStringLiteral("true") : QStringLiteral("false"); + return QString(); // null/undefined +} + +} // namespace + +TablePayload parseGridHeaderTable(const QJsonObject& data) { + TablePayload t; + + // 列定义:gridHeaderDisplay,按 columnSort 升序。 + QJsonArray header = data.value(QStringLiteral("gridHeaderDisplay")).toArray(); + std::vector cols; + cols.reserve(header.size()); + for (const auto& e : header) cols.push_back(e.toObject()); + std::stable_sort(cols.begin(), cols.end(), [](const QJsonObject& a, const QJsonObject& b) { + return a.value(QStringLiteral("columnSort")).toInt() < + b.value(QStringLiteral("columnSort")).toInt(); + }); + for (const auto& c : cols) { + TableColumn col; + col.code = c.value(QStringLiteral("columnCode")).toString(); + col.title = c.value(QStringLiteral("columnNameChn")).toString(); + if (col.title.isEmpty()) col.title = col.code; + col.width = c.value(QStringLiteral("columnWidth")).toInt(); + col.sort = c.value(QStringLiteral("columnSort")).toInt(); + t.columns.push_back(col); + } + + // 行:每格按列码取行对象顶层值。 + const QJsonArray rowList = data.value(QStringLiteral("rowList")).toArray(); + for (const auto& e : rowList) { + const QJsonObject obj = e.toObject(); + std::vector cells; + cells.reserve(t.columns.size()); + for (const auto& col : t.columns) cells.push_back(cellText(obj.value(col.code))); + t.rows.push_back(std::move(cells)); + } + + // 总数(分页用):trajectory 用 __rowTotal,回退本批行数。 + t.total = data.value(QStringLiteral("__rowTotal")).toInt(static_cast(t.rows.size())); + return t; +} + +TablePayload parseTrajectoryTable(const QJsonObject& data) { + return parseGridHeaderTable(data); +} + +LinePayload parseTrajectoryElevation(const QJsonObject& data) { + LinePayload p; + p.seriesName = QStringLiteral("高程"); + p.color = kLineColor; + p.xTitle = QStringLiteral("电极号"); + p.yTitle = QStringLiteral("高程"); + p.smooth = true; + + const QJsonArray rowList = data.value(QStringLiteral("rowList")).toArray(); + for (const auto& e : rowList) { + const QJsonObject obj = e.toObject(); + p.categories.push_back(QStringLiteral("#") + + QString::number(obj.value(QStringLiteral("electrodeNo")).toInt())); + p.y.push_back(obj.value(QStringLiteral("elevation")).toDouble()); + } + return p; +} + +MapPayload parseTrajectoryMap(const QJsonObject& data) { + MapPayload m; + const QJsonArray list = data.value(QStringLiteral("electrodelList")).toArray(); + for (const auto& e : list) { + const QJsonObject obj = e.toObject(); + const QJsonObject coord = obj.value(QStringLiteral("electrodeCoordinate")).toObject(); + const QJsonArray geom = coord.value(QStringLiteral("geometryCoordinates")).toArray(); + if (geom.isEmpty()) continue; // 跳过缺坐标 + const QJsonObject pt = geom.first().toObject(); + const QJsonValue xv = pt.value(QStringLiteral("x")); + const QJsonValue yv = pt.value(QStringLiteral("y")); + if (!xv.isDouble() || !yv.isDouble()) continue; // 跳过 null/非数值 + MapPoint p; + p.electrodeNo = obj.value(QStringLiteral("electrodeNo")).toInt(); + p.lon = xv.toDouble(); + p.lat = yv.toDouble(); + m.points.push_back(p); + } + return m; +} + +} // namespace geopro::data::dto diff --git a/src/data/dto/TrajectoryDto.hpp b/src/data/dto/TrajectoryDto.hpp new file mode 100644 index 0000000..4a85566 --- /dev/null +++ b/src/data/dto/TrajectoryDto.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include "model/detail/DetailPayloads.hpp" + +namespace geopro::data::dto { + +// dd_trajectory_data(ERT 电极坐标 轨迹)。三页签同一端点 dd/ert/trajectory/rows +// (data 形如 {rowList[40], gridHeaderDisplay[14]})。 + +// 通用「gridHeaderDisplay + rowList → TablePayload」解析器(可复用:trajectory 列表 / 未来 dd_grid)。 +// 列定义来自 gridHeaderDisplay(按 columnSort 升序,title=columnNameChn、code=columnCode、 +// width=columnWidth),行来自 rowList(每格按 columnCode 取顶层值,预格式化为 QString;null→空串)。 +// total 优先取 data.__rowTotal,回退本批行数。 +geopro::core::TablePayload parseGridHeaderTable(const QJsonObject& data); + +// 列表页签:直接复用通用解析器(14 列:电极号/平距/斜距/经度·东坐标/纬度·北坐标/高程/线号/ +// 道号/里程/深度/水深/日期/时间/解状态)。 +geopro::core::TablePayload parseTrajectoryTable(const QJsonObject& data); + +// 高程页签:单条平滑折线。类目 "#"+electrodeNo("#1".."#40"),y=elevation, +// x 轴标题「电极号」、y 轴标题「高程」,线色 #5470c6(ECharts 默认蓝),smooth=true。 +geopro::core::LinePayload parseTrajectoryElevation(const QJsonObject& data); + +// 地图页签:电极经纬点。来自 dd/ert/trajectory/line 端点 +// (data.electrodelList[].electrodeCoordinate.geometryCoordinates[0] → {x=lon, y=lat})。 +// 跳过缺失/null 坐标的电极。服务端直接返回 EPSG:4326 经纬,无需投影变换。 +geopro::core::MapPayload parseTrajectoryMap(const QJsonObject& data); + +} // namespace geopro::data::dto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 891a555..c522864 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,7 @@ target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) target_sources(geopro_tests PRIVATE data/test_gr_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_trajectory_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) # 通用仓储分派离线单测(loadAsync 分派 + QVariant payload round-trip)。 target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index cebeef6..de779d1 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -3,6 +3,7 @@ #include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" +#include "panels/chart/TrajectoryStrategy.hpp" using namespace geopro::controller; namespace { struct Fake : IDatasetChartStrategy { @@ -70,3 +71,25 @@ TEST(GrMeasurementStrategy, DrivesBarAndTableTabs) { EXPECT_TRUE(tabs[1].lazy); EXPECT_EQ(tabs[1].loaderKey.toStdString(), "gr.rows"); } + +TEST(TrajectoryStrategy, DrivesMapTableElevationTabs) { + geopro::app::TrajectoryStrategy s; + EXPECT_EQ(s.ddCode(), "dd_trajectory_data"); + const auto tabs = s.tabs(); + ASSERT_EQ(tabs.size(), 3u); + // 地图:WebMap,首位/非 lazy(默认页,loaderKey traj.map → MapPayload;端点 trajectory/line)。 + EXPECT_EQ(tabs[0].title.toStdString(), std::string("地图")); + EXPECT_EQ(tabs[0].kind, ViewKind::WebMap); + EXPECT_FALSE(tabs[0].lazy); + EXPECT_EQ(tabs[0].loaderKey.toStdString(), "traj.map"); + // 列表:Table,lazy。 + EXPECT_EQ(tabs[1].title.toStdString(), std::string("列表")); + EXPECT_EQ(tabs[1].kind, ViewKind::Table); + EXPECT_TRUE(tabs[1].lazy); + EXPECT_EQ(tabs[1].loaderKey.toStdString(), "traj.rows"); + // 高程:LineProfile,非 lazy。 + EXPECT_EQ(tabs[2].title.toStdString(), std::string("高程")); + EXPECT_EQ(tabs[2].kind, ViewKind::LineProfile); + EXPECT_FALSE(tabs[2].lazy); + EXPECT_EQ(tabs[2].loaderKey.toStdString(), "traj.elev"); +} diff --git a/tests/data/test_async_repo_dispatch.cpp b/tests/data/test_async_repo_dispatch.cpp index e9ab843..6fcd3d8 100644 --- a/tests/data/test_async_repo_dispatch.cpp +++ b/tests/data/test_async_repo_dispatch.cpp @@ -53,6 +53,18 @@ TEST(AsyncRepoDispatch, KnownKeysReturnNonNullHandle) { DetailLoad* grRows = repo.loadAsync("gr.rows", "ds1"); ASSERT_NE(grRows, nullptr); grRows->abort(); + + DetailLoad* trajRows = repo.loadAsync("traj.rows", "ds1"); + ASSERT_NE(trajRows, nullptr); + trajRows->abort(); + + DetailLoad* trajElev = repo.loadAsync("traj.elev", "ds1"); + ASSERT_NE(trajElev, nullptr); + trajElev->abort(); + + DetailLoad* trajMap = repo.loadAsync("traj.map", "ds1"); + ASSERT_NE(trajMap, nullptr); + trajMap->abort(); } // 未知 loaderKey 抛 std::runtime_error。 diff --git a/tests/data/test_trajectory_dto.cpp b/tests/data/test_trajectory_dto.cpp new file mode 100644 index 0000000..5ed260d --- /dev/null +++ b/tests/data/test_trajectory_dto.cpp @@ -0,0 +1,179 @@ +#include + +#include +#include + +#include "dto/TrajectoryDto.hpp" + +using namespace geopro::data::dto; +using geopro::core::TableColumnKind; + +namespace { + +// 取自真实夹具 tests/fixtures/dd/ert-trajectory-rows.json 的 data(rowList 裁剪至 20 行, +// gridHeaderDisplay 全量 14 列,逐字一致)。内联避免引入 fixture 路径编译定义。 +const char* kTrajData = R"({ + "gridHeaderDisplay": [ + { "columnCode": "electrodeNo", "columnNameChn": "电极号", "columnSort": 1, "columnWidth": 10 }, + { "columnCode": "horizontalDistance", "columnNameChn": "平距", "columnSort": 2, "columnWidth": 10 }, + { "columnCode": "slopeDistance", "columnNameChn": "斜距", "columnSort": 3, "columnWidth": 10 }, + { "columnCode": "projectX", "columnNameChn": "经度/东坐标", "columnSort": 4, "columnWidth": 10 }, + { "columnCode": "projectY", "columnNameChn": "纬度/北坐标", "columnSort": 5, "columnWidth": 10 }, + { "columnCode": "elevation", "columnNameChn": "高程", "columnSort": 6, "columnWidth": 10 }, + { "columnCode": "lineNo", "columnNameChn": "线号", "columnSort": 7, "columnWidth": 10 }, + { "columnCode": "channelNo", "columnNameChn": "道号", "columnSort": 8, "columnWidth": 10 }, + { "columnCode": "measuredDistance", "columnNameChn": "里程", "columnSort": 9, "columnWidth": 10 }, + { "columnCode": "depth", "columnNameChn": "深度", "columnSort": 10, "columnWidth": 10 }, + { "columnCode": "depthOfWater", "columnNameChn": "水深", "columnSort": 11, "columnWidth": 10 }, + { "columnCode": "date", "columnNameChn": "日期", "columnSort": 12, "columnWidth": 10 }, + { "columnCode": "time", "columnNameChn": "时间", "columnSort": 13, "columnWidth": 10 }, + { "columnCode": "resultStatus", "columnNameChn": "解状态", "columnSort": 14, "columnWidth": 10 } + ], + "__rowTotal": 40, + "rowList": [ + { "electrodeNo": 1, "horizontalDistance": 0, "slopeDistance": 0, "projectX": 516838.884, "projectY": 2494251.836, "elevation": 26.172, "lineNo": "ert1", "channelNo": "ERT1-1", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 2, "horizontalDistance": 1.08, "slopeDistance": 1.24, "projectX": 516840.099, "projectY": 2494252.112, "elevation": 25.551, "lineNo": "ert1", "channelNo": "ERT1-2", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 3, "horizontalDistance": 3.14, "slopeDistance": 3.31, "projectX": 516842.07, "projectY": 2494252.756, "elevation": 25.425, "lineNo": "ert1", "channelNo": "ERT1-3", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 4, "horizontalDistance": 4.94, "slopeDistance": 5.12, "projectX": 516843.874, "projectY": 2494252.792, "elevation": 25.625, "lineNo": "ert1", "channelNo": "ERT1-4", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 5, "horizontalDistance": 7.09, "slopeDistance": 7.28, "projectX": 516846.009, "projectY": 2494253.098, "elevation": 25.615, "lineNo": "ert1", "channelNo": "ERT1-5", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 6, "horizontalDistance": 8.91, "slopeDistance": 9.09, "projectX": 516847.815, "projectY": 2494253.249, "elevation": 25.627, "lineNo": "ert1", "channelNo": "ERT1-6", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 7, "horizontalDistance": 11.04, "slopeDistance": 11.22, "projectX": 516849.899, "projectY": 2494253.713, "elevation": 25.549, "lineNo": "ert1", "channelNo": "ERT1-7", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 8, "horizontalDistance": 13.09, "slopeDistance": 13.27, "projectX": 516851.911, "projectY": 2494254.1, "elevation": 25.49, "lineNo": "ert1", "channelNo": "ERT1-8", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 9, "horizontalDistance": 15.03, "slopeDistance": 15.26, "projectX": 516853.844, "projectY": 2494254.561, "elevation": 25.922, "lineNo": "ert1", "channelNo": "ERT1-9", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 10, "horizontalDistance": 16.95, "slopeDistance": 17.26, "projectX": 516855.736, "projectY": 2494255.216, "elevation": 25.363, "lineNo": "ert1", "channelNo": "ERT1-10", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 11, "horizontalDistance": 18.92, "slopeDistance": 19.24, "projectX": 516857.548, "projectY": 2494255.999, "elevation": 25.354, "lineNo": "ert1", "channelNo": "ERT1-11", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 12, "horizontalDistance": 20.97, "slopeDistance": 21.28, "projectX": 516859.35, "projectY": 2494256.96, "elevation": 25.39, "lineNo": "ert1", "channelNo": "ERT1-12", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 13, "horizontalDistance": 22.92, "slopeDistance": 23.24, "projectX": 516861.033, "projectY": 2494257.96, "elevation": 25.444, "lineNo": "ert1", "channelNo": "ERT1-13", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 14, "horizontalDistance": 24.81, "slopeDistance": 25.12, "projectX": 516862.667, "projectY": 2494258.906, "elevation": 25.48, "lineNo": "ert1", "channelNo": "ERT1-14", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 15, "horizontalDistance": 26.92, "slopeDistance": 27.24, "projectX": 516864.416, "projectY": 2494260.092, "elevation": 25.384, "lineNo": "ert1", "channelNo": "ERT1-15", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 16, "horizontalDistance": 28.68, "slopeDistance": 29.00, "projectX": 516865.639, "projectY": 2494261.366, "elevation": 25.5, "lineNo": "ert1", "channelNo": "ERT1-16", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 17, "horizontalDistance": 30.88, "slopeDistance": 31.19, "projectX": 516867.233, "projectY": 2494262.869, "elevation": 25.54, "lineNo": "ert1", "channelNo": "ERT1-17", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 18, "horizontalDistance": 32.76, "slopeDistance": 33.08, "projectX": 516868.457, "projectY": 2494264.304, "elevation": 25.505, "lineNo": "ert1", "channelNo": "ERT1-18", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 19, "horizontalDistance": 34.71, "slopeDistance": 35.03, "projectX": 516869.61, "projectY": 2494265.873, "elevation": 25.576, "lineNo": "ert1", "channelNo": "ERT1-19", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "electrodeNo": 20, "horizontalDistance": 36.64, "slopeDistance": 36.96, "projectX": 516870.684, "projectY": 2494267.48, "elevation": 25.567, "lineNo": "ert1", "channelNo": "ERT1-20", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" } + ] +})"; + +QJsonObject trajData() { return QJsonDocument::fromJson(kTrajData).object(); } + +// 取自真实夹具 tests/fixtures/dd/ert-trajectory-line.json 的 data(electrodelList 20 个电极, +// 逐字一致;服务端直接返回 EPSG:4326 经纬 x=lon/y=lat)。内联避免引入 fixture 路径编译定义。 +const char* kTrajLineData = R"({ + "id": null, + "electrodelList": [ + { "electrodeNo": 1, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16370297649078, "y": 22.54583186479361, "z": 0 }] } }, + { "electrodeNo": 2, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16371479128762, "y": 22.54583434513919, "z": 0 }] } }, + { "electrodeNo": 3, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16373395960512, "y": 22.545840141157978, "z": 0 }] } }, + { "electrodeNo": 4, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16375149792411, "y": 22.545840448398355, "z": 0 }] } }, + { "electrodeNo": 5, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16377225700053, "y": 22.545843190544776, "z": 0 }] } }, + { "electrodeNo": 6, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16378981598876, "y": 22.54584453624687, "z": 0 }] } }, + { "electrodeNo": 7, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16381008094395, "y": 22.54584870568223, "z": 0 }] } }, + { "electrodeNo": 8, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16382964511763, "y": 22.545852180493952, "z": 0 }] } }, + { "electrodeNo": 9, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16384844206722, "y": 22.54585632432848, "z": 0 }] } }, + { "electrodeNo": 10, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16386684249656, "y": 22.545862220446992, "z": 0 }] } }, + { "electrodeNo": 11, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16388446655657, "y": 22.545869273235397, "z": 0 }] } }, + { "electrodeNo": 12, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16390199529941, "y": 22.545877933516117, "z": 0 }] } }, + { "electrodeNo": 13, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16391836757842, "y": 22.545886947155804, "z": 0 }] } }, + { "electrodeNo": 14, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16393426292046, "y": 22.545895473642467, "z": 0 }] } }, + { "electrodeNo": 15, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16395127881961, "y": 22.545906166262615, "z": 0 }] } }, + { "electrodeNo": 16, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16396318204642, "y": 22.545917658760136, "z": 0 }] } }, + { "electrodeNo": 17, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16397869446884, "y": 22.545931215521435, "z": 0 }] } }, + { "electrodeNo": 18, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16399060913946, "y": 22.545944161886897, "z": 0 }] } }, + { "electrodeNo": 19, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16400183500123, "y": 22.54595831901666, "z": 0 }] } }, + { "electrodeNo": 20, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16401229325622, "y": 22.545972820080397, "z": 0 }] } } + ] +})"; + +QJsonObject trajLineData() { return QJsonDocument::fromJson(kTrajLineData).object(); } + +} // namespace + +TEST(TrajectoryDto, ParsesElevationLineSeries) { + const QJsonObject data = trajData(); + auto line = parseTrajectoryElevation(data); + + // 类目 "#1".."#20"(来自 electrodeNo)。 + ASSERT_EQ(line.categories.size(), 20u); + EXPECT_EQ(line.categories.front().toStdString(), "#1"); + EXPECT_EQ(line.categories.back().toStdString(), "#20"); + + // y = elevation,首值 26.172。 + ASSERT_EQ(line.y.size(), 20u); + EXPECT_DOUBLE_EQ(line.y.front(), 26.172); + EXPECT_DOUBLE_EQ(line.y[1], 25.551); + + // 轴标题 / 系列 / 色 / 平滑。 + EXPECT_EQ(line.xTitle.toStdString(), std::string("电极号")); + EXPECT_EQ(line.yTitle.toStdString(), std::string("高程")); + EXPECT_EQ(line.seriesName.toStdString(), std::string("高程")); + EXPECT_EQ(line.color.toStdString(), "#5470c6"); + EXPECT_TRUE(line.smooth); +} + +TEST(TrajectoryDto, ParsesTableFourteenColumnsOrderedBySort) { + const QJsonObject data = trajData(); + auto t = parseTrajectoryTable(data); + + // 14 列,按 columnSort 升序,title=columnNameChn,首列 电极号。 + ASSERT_EQ(t.columns.size(), 14u); + EXPECT_EQ(t.columns[0].title.toStdString(), std::string("电极号")); + EXPECT_EQ(t.columns[0].code.toStdString(), "electrodeNo"); + EXPECT_EQ(t.columns[1].title.toStdString(), std::string("平距")); + EXPECT_EQ(t.columns[2].title.toStdString(), std::string("斜距")); + EXPECT_EQ(t.columns[5].title.toStdString(), std::string("高程")); + EXPECT_EQ(t.columns[13].title.toStdString(), std::string("解状态")); + // 无 Toggle 列(trajectory 走 gridHeaderDisplay,不追加开关列)。 + for (const auto& c : t.columns) EXPECT_EQ(c.kind, TableColumnKind::Text); + + // 20 行;row0:电极号=1 / projectX=516838.884 / elevation=26.172 / resultStatus=固定解。 + ASSERT_EQ(t.rows.size(), 20u); + ASSERT_EQ(t.rows[0].size(), 14u); + EXPECT_EQ(t.rows[0][0].toStdString(), "1"); // electrodeNo + EXPECT_EQ(t.rows[0][3].toStdString(), "516838.884"); // projectX + EXPECT_EQ(t.rows[0][5].toStdString(), "26.172"); // elevation + EXPECT_EQ(t.rows[0][13].toStdString(), std::string("固定解")); // resultStatus + + // null 单元 → 空串(里程/深度/水深/日期/时间)。 + EXPECT_TRUE(t.rows[0][8].isEmpty()); // measuredDistance null + EXPECT_TRUE(t.rows[0][9].isEmpty()); // depth null + EXPECT_TRUE(t.rows[0][11].isEmpty()); // date null + + // total 取 __rowTotal=40(非本批 20)。 + EXPECT_EQ(t.total, 40); +} + +TEST(TrajectoryDto, ParsesEmptyToValidEmptyPayloads) { + const QJsonObject empty; + + auto line = parseTrajectoryElevation(empty); + EXPECT_EQ(line.categories.size(), 0u); + EXPECT_EQ(line.y.size(), 0u); + EXPECT_EQ(line.xTitle.toStdString(), std::string("电极号")); + EXPECT_TRUE(line.smooth); + + auto t = parseTrajectoryTable(empty); + EXPECT_EQ(t.columns.size(), 0u); + EXPECT_EQ(t.rows.size(), 0u); + EXPECT_EQ(t.total, 0); + + auto m = parseTrajectoryMap(empty); + EXPECT_EQ(m.points.size(), 0u); +} + +TEST(TrajectoryDto, ParsesMapPointsFromLineEndpoint) { + const QJsonObject data = trajLineData(); + auto m = parseTrajectoryMap(data); + + // 20 个电极点(electrodelList)。 + ASSERT_EQ(m.points.size(), 20u); + + // point0:electrodeNo=1,lon≈114.16370,lat≈22.54583(x=lon、y=lat,EPSG:4326 直返)。 + EXPECT_EQ(m.points[0].electrodeNo, 1); + EXPECT_NEAR(m.points[0].lon, 114.16370297649078, 1e-9); + EXPECT_NEAR(m.points[0].lat, 22.54583186479361, 1e-9); + + // point19:electrodeNo=20。 + EXPECT_EQ(m.points[19].electrodeNo, 20); + EXPECT_NEAR(m.points[19].lon, 114.16401229325622, 1e-9); + EXPECT_NEAR(m.points[19].lat, 22.545972820080397, 1e-9); +} diff --git a/tests/fixtures/dd/ert-trajectory-line.json b/tests/fixtures/dd/ert-trajectory-line.json new file mode 100644 index 0000000..1b2db91 --- /dev/null +++ b/tests/fixtures/dd/ert-trajectory-line.json @@ -0,0 +1,29 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "id": null, + "electrodelList": [ + { "electrodeNo": 1, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16370297649078, "y": 22.54583186479361, "z": 0 }] } }, + { "electrodeNo": 2, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16371479128762, "y": 22.54583434513919, "z": 0 }] } }, + { "electrodeNo": 3, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16373395960512, "y": 22.545840141157978, "z": 0 }] } }, + { "electrodeNo": 4, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16375149792411, "y": 22.545840448398355, "z": 0 }] } }, + { "electrodeNo": 5, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16377225700053, "y": 22.545843190544776, "z": 0 }] } }, + { "electrodeNo": 6, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16378981598876, "y": 22.54584453624687, "z": 0 }] } }, + { "electrodeNo": 7, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16381008094395, "y": 22.54584870568223, "z": 0 }] } }, + { "electrodeNo": 8, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16382964511763, "y": 22.545852180493952, "z": 0 }] } }, + { "electrodeNo": 9, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16384844206722, "y": 22.54585632432848, "z": 0 }] } }, + { "electrodeNo": 10, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16386684249656, "y": 22.545862220446992, "z": 0 }] } }, + { "electrodeNo": 11, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16388446655657, "y": 22.545869273235397, "z": 0 }] } }, + { "electrodeNo": 12, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16390199529941, "y": 22.545877933516117, "z": 0 }] } }, + { "electrodeNo": 13, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16391836757842, "y": 22.545886947155804, "z": 0 }] } }, + { "electrodeNo": 14, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16393426292046, "y": 22.545895473642467, "z": 0 }] } }, + { "electrodeNo": 15, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16395127881961, "y": 22.545906166262615, "z": 0 }] } }, + { "electrodeNo": 16, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16396318204642, "y": 22.545917658760136, "z": 0 }] } }, + { "electrodeNo": 17, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16397869446884, "y": 22.545931215521435, "z": 0 }] } }, + { "electrodeNo": 18, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16399060913946, "y": 22.545944161886897, "z": 0 }] } }, + { "electrodeNo": 19, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16400183500123, "y": 22.54595831901666, "z": 0 }] } }, + { "electrodeNo": 20, "electrodeCoordinate": { "crsCode": "EPSG:4326", "closure": null, "geometryType": 1, "geometryCoordinates": [{ "x": 114.16401229325622, "y": 22.545972820080397, "z": 0 }] } } + ] + } +} diff --git a/tests/fixtures/dd/ert-trajectory-rows.json b/tests/fixtures/dd/ert-trajectory-rows.json new file mode 100644 index 0000000..fdd22b1 --- /dev/null +++ b/tests/fixtures/dd/ert-trajectory-rows.json @@ -0,0 +1,46 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "gridHeaderDisplay": [ + { "columnCode": "electrodeNo", "columnNameChn": "电极号", "columnNameEng": "Electrode No", "columnWidth": 10, "columnSort": 1 }, + { "columnCode": "horizontalDistance", "columnNameChn": "平距", "columnNameEng": "Horizontal Distance", "columnWidth": 10, "columnSort": 2 }, + { "columnCode": "slopeDistance", "columnNameChn": "斜距", "columnNameEng": "Slope Distance", "columnWidth": 10, "columnSort": 3 }, + { "columnCode": "projectX", "columnNameChn": "经度/东坐标", "columnNameEng": "Longitude/EastCoordinate", "columnWidth": 10, "columnSort": 4 }, + { "columnCode": "projectY", "columnNameChn": "纬度/北坐标", "columnNameEng": "Latitude/NorthCoordinate", "columnWidth": 10, "columnSort": 5 }, + { "columnCode": "elevation", "columnNameChn": "高程", "columnNameEng": "Elevation", "columnWidth": 10, "columnSort": 6 }, + { "columnCode": "lineNo", "columnNameChn": "线号", "columnNameEng": "Line No", "columnWidth": 10, "columnSort": 7 }, + { "columnCode": "channelNo", "columnNameChn": "道号", "columnNameEng": "Channel No", "columnWidth": 10, "columnSort": 8 }, + { "columnCode": "measuredDistance", "columnNameChn": "里程", "columnNameEng": "Measured Distance", "columnWidth": 10, "columnSort": 9 }, + { "columnCode": "depth", "columnNameChn": "深度", "columnNameEng": "Depth", "columnWidth": 10, "columnSort": 10 }, + { "columnCode": "depthOfWater", "columnNameChn": "水深", "columnNameEng": "Depth of Water", "columnWidth": 10, "columnSort": 11 }, + { "columnCode": "date", "columnNameChn": "日期", "columnNameEng": "Date", "columnWidth": 10, "columnSort": 12 }, + { "columnCode": "time", "columnNameChn": "时间", "columnNameEng": "Time", "columnWidth": 10, "columnSort": 13 }, + { "columnCode": "resultStatus", "columnNameChn": "解状态", "columnNameEng": "Result Status", "columnWidth": 10, "columnSort": 14 } + ], + "trajectoryDataModelHead": null, + "__rowTotal": 40, + "rowList": [ + { "id": "1438893959585792", "electrodeNo": 1, "horizontalDistance": 0, "slopeDistance": 0, "longitude": 0, "latitude": null, "projectX": 516838.884, "projectY": 2494251.836, "elevation": 26.172, "lineNo": "ert1", "channelNo": "ERT1-1", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593984", "electrodeNo": 2, "horizontalDistance": 1.0801616223246715, "slopeDistance": 1.2459494894830485, "longitude": 0, "latitude": null, "projectX": 516840.099, "projectY": 2494252.112, "elevation": 25.551, "lineNo": "ert1", "channelNo": "ERT1-2", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593985", "electrodeNo": 3, "horizontalDistance": 3.1498647133801647, "slopeDistance": 3.3194843662393243, "longitude": 0, "latitude": null, "projectX": 516842.07, "projectY": 2494252.756, "elevation": 25.425, "lineNo": "ert1", "channelNo": "ERT1-3", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593986", "electrodeNo": 4, "horizontalDistance": 4.943098993517602, "slopeDistance": 5.123837209632222, "longitude": 0, "latitude": null, "projectX": 516843.874, "projectY": 2494252.792, "elevation": 25.625, "lineNo": "ert1", "channelNo": "ERT1-4", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593987", "electrodeNo": 5, "horizontalDistance": 7.0998855833076515, "slopeDistance": 7.280646981934403, "longitude": 0, "latitude": null, "projectX": 516846.009, "projectY": 2494253.098, "elevation": 25.615, "lineNo": "ert1", "channelNo": "ERT1-5", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593988", "electrodeNo": 6, "horizontalDistance": 8.912141076960786, "slopeDistance": 9.092942204649502, "longitude": 0, "latitude": null, "projectX": 516847.815, "projectY": 2494253.249, "elevation": 25.627, "lineNo": "ert1", "channelNo": "ERT1-6", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593989", "electrodeNo": 7, "horizontalDistance": 11.045738049466081, "slopeDistance": 11.227964462398532, "longitude": 0, "latitude": null, "projectX": 516849.899, "projectY": 2494253.713, "elevation": 25.549, "lineNo": "ert1", "channelNo": "ERT1-7", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593990", "electrodeNo": 8, "horizontalDistance": 13.093762113301716, "slopeDistance": 13.276838193512734, "longitude": 0, "latitude": null, "projectX": 516851.911, "projectY": 2494254.1, "elevation": 25.49, "lineNo": "ert1", "channelNo": "ERT1-8", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593991", "electrodeNo": 9, "horizontalDistance": 15.033442059887646, "slopeDistance": 15.264042837022707, "longitude": 0, "latitude": null, "projectX": 516853.844, "projectY": 2494254.561, "elevation": 25.922, "lineNo": "ert1", "channelNo": "ERT1-9", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593992", "electrodeNo": 10, "horizontalDistance": 16.955987211873353, "slopeDistance": 17.266206881606404, "longitude": 0, "latitude": null, "projectX": 516855.736, "projectY": 2494255.216, "elevation": 25.363, "lineNo": "ert1", "channelNo": "ERT1-10", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593993", "electrodeNo": 11, "horizontalDistance": 18.929898218381553, "slopeDistance": 19.24013840565029, "longitude": 0, "latitude": null, "projectX": 516857.548, "projectY": 2494255.999, "elevation": 25.354, "lineNo": "ert1", "channelNo": "ERT1-11", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593994", "electrodeNo": 12, "horizontalDistance": 20.971809019338927, "slopeDistance": 21.282366531758235, "longitude": 0, "latitude": null, "projectX": 516859.35, "projectY": 2494256.96, "elevation": 25.39, "lineNo": "ert1", "channelNo": "ERT1-12", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593995", "electrodeNo": 13, "horizontalDistance": 22.92873162714211, "slopeDistance": 23.240034045135115, "longitude": 0, "latitude": null, "projectX": 516861.033, "projectY": 2494257.96, "elevation": 25.444, "lineNo": "ert1", "channelNo": "ERT1-13", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593996", "electrodeNo": 14, "horizontalDistance": 24.816468622227266, "slopeDistance": 25.12811427717324, "longitude": 0, "latitude": null, "projectX": 516862.667, "projectY": 2494258.906, "elevation": 25.48, "lineNo": "ert1", "channelNo": "ERT1-14", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593997", "electrodeNo": 15, "horizontalDistance": 26.92747540642802, "slopeDistance": 27.241302778689754, "longitude": 0, "latitude": null, "projectX": 516864.416, "projectY": 2494260.092, "elevation": 25.384, "lineNo": "ert1", "channelNo": "ERT1-15", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593998", "electrodeNo": 16, "horizontalDistance": 28.68966923369523, "slopeDistance": 29.007310447092884, "longitude": 0, "latitude": null, "projectX": 516865.639, "projectY": 2494261.366, "elevation": 25.5, "lineNo": "ert1", "channelNo": "ERT1-16", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959593999", "electrodeNo": 17, "horizontalDistance": 30.880151207446215, "slopeDistance": 31.19815760682996, "longitude": 0, "latitude": null, "projectX": 516867.233, "projectY": 2494262.869, "elevation": 25.54, "lineNo": "ert1", "channelNo": "ERT1-17", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959594000", "electrodeNo": 18, "horizontalDistance": 32.7659271758147, "slopeDistance": 33.084258347213705, "longitude": 0, "latitude": null, "projectX": 516868.457, "projectY": 2494264.304, "elevation": 25.505, "lineNo": "ert1", "channelNo": "ERT1-18", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959594001", "electrodeNo": 19, "horizontalDistance": 34.71171810948364, "slopeDistance": 35.03134421012479, "longitude": 0, "latitude": null, "projectX": 516869.61, "projectY": 2494265.873, "elevation": 25.576, "lineNo": "ert1", "channelNo": "ERT1-19", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" }, + { "id": "1438893959594002", "electrodeNo": 20, "horizontalDistance": 36.644544469129066, "slopeDistance": 36.96419152342716, "longitude": 0, "latitude": null, "projectX": 516870.684, "projectY": 2494267.48, "elevation": 25.567, "lineNo": "ert1", "channelNo": "ERT1-20", "measuredDistance": null, "depth": null, "depthOfWater": null, "date": null, "time": null, "resultStatus": "固定解" } + ] + } +} From 2cf2b6aaa77ac479d808e3ba70b01bca3446e660 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sat, 13 Jun 2026 17:27:39 +0800 Subject: [PATCH 74/74] =?UTF-8?q?feat(dataset-detail):=20dd=5Fgrid=20?= =?UTF-8?q?=E7=99=BD=E5=8C=96=E6=95=B0=E6=8D=AE=E5=88=97=E8=A1=A8=20+=20?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5?= =?UTF-8?q?(vxe-pager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⑤ dd_grid 详情:单「列表」页签,序号/x/y 三列(均居中),服务端分页。 按原版(vxe-table)实测复刻:序号列前插、按页偏移自增;total 取 data.total; 分页器对齐 vxe-pager(上一页/页码…/下一页 + 前往N页 + 每页条数 50/100/500/1000 默认50 + 共N条记录)。 引擎新增分页能力(通用,后续分页型详情复用): - TablePayload 加 pageNo/pageSize(>0 才渲染分页器;0=全量列表,measurement/trajectory 不受影响) - GridDto.parseGridTable 复用通用 parseGridHeaderTable,前插序号列 + 回填分页态 - 仓储 loadAsync 增 pageNo/pageSize 透传,新增 grid.rows 加载器(端点 dd/ert/grid/rows,默认50条/页) - 控制器新增 loadTabPaged(保留 3 参 loadTab 以维持 tabNeeded 连接) - TablePager 分页器组件 + DataTableView 按 pageSize>0 显隐并转发 pageRequested → DatasetDetailPage/Panel.tabPageNeeded → Controller.loadTabPaged 反向链路 - GridStrategy(dd_grid 单分页页签) 注册入 main 测试:test_grid_dto(序号偏移/total/分页态/空数据) + grid.rows 分派 + GridStrategy 注册 + 控制器 loadTabPaged 透传/默认页参;154/154 通过。 ABI 关键头(DetailPayloads.hpp)变更后全量重编 geopro 代码并验 obj 新鲜度。 --- src/app/CMakeLists.txt | 1 + src/app/main.cpp | 5 + src/app/panels/DatasetDetailPage.cpp | 11 + src/app/panels/DatasetDetailPage.hpp | 3 + src/app/panels/DatasetDetailPanel.cpp | 2 + src/app/panels/DatasetDetailPanel.hpp | 3 + src/app/panels/chart/DataTableView.cpp | 16 ++ src/app/panels/chart/DataTableView.hpp | 11 +- src/app/panels/chart/GridStrategy.hpp | 18 ++ src/app/panels/chart/TablePager.cpp | 188 ++++++++++++++++++ src/app/panels/chart/TablePager.hpp | 48 +++++ src/controller/DatasetDetailController.cpp | 12 +- src/controller/DatasetDetailController.hpp | 8 + src/core/model/detail/DetailPayloads.hpp | 4 + src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetRepository.cpp | 28 ++- src/data/api/ApiDatasetRepository.hpp | 4 +- src/data/dto/GridDto.cpp | 34 ++++ src/data/dto/GridDto.hpp | 18 ++ src/data/repo/IAsyncDatasetRepository.hpp | 5 +- tests/CMakeLists.txt | 1 + tests/app/test_chart_strategy_registry.cpp | 14 ++ .../test_dataset_detail_controller.cpp | 26 ++- tests/data/test_async_repo_dispatch.cpp | 4 + tests/data/test_grid_dto.cpp | 78 ++++++++ tests/fixtures/dd/ert-grid-rows.json | 19 ++ 26 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 src/app/panels/chart/GridStrategy.hpp create mode 100644 src/app/panels/chart/TablePager.cpp create mode 100644 src/app/panels/chart/TablePager.hpp create mode 100644 src/data/dto/GridDto.cpp create mode 100644 src/data/dto/GridDto.hpp create mode 100644 tests/data/test_grid_dto.cpp create mode 100644 tests/fixtures/dd/ert-grid-rows.json diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 12c4a11..04437d0 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -34,6 +34,7 @@ add_executable(geopro_desktop WIN32 panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp panels/chart/DataTableView.cpp + panels/chart/TablePager.cpp panels/chart/BarChartView.cpp panels/chart/LineChartView.cpp panels/chart/TrajectoryMapView.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index 41cdbab..e602d49 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -90,6 +90,7 @@ #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" #include "panels/chart/TrajectoryStrategy.hpp" +#include "panels/chart/GridStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -531,6 +532,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 页签懒加载:lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ── QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl, &geopro::controller::DatasetDetailController::loadTab); + // ── 分页:分页器翻页/改每页条数 → 控制器按页加载 → 回填(同 tabReady 路径,刷新表格+分页器)── + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabPageNeeded, &detailCtrl, + &geopro::controller::DatasetDetailController::loadTabPaged); // context 用 detailPanel:析构即自动断连,避免野指针。window 比 detailPanel 活得久, // 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。 QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel, @@ -924,6 +928,7 @@ int main(int argc, char* argv[]) chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); + chartRegistry.add(std::make_unique()); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index aacae48..16bfd00 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -6,6 +6,7 @@ #include "Glyphs.hpp" #include "PanelHeader.hpp" #include "panels/LoadingOverlay.hpp" +#include "panels/chart/DataTableView.hpp" #include "panels/chart/DetailViewFactory.hpp" #include "panels/chart/IDetailView.hpp" @@ -39,6 +40,16 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const views_[i] = raw; // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 if (spec.lazy) overlays_[static_cast(i)] = new LoadingOverlay(raw->widget()); + // 分页型页签:把表格视图的分页请求冒泡为页信号(携带 dsId/ddCode/tabIndex + 页参数)。 + if (spec.paginated) { + if (auto* table = qobject_cast(raw->widget())) { + const int idx = static_cast(i); + connect(table, &DataTableView::pageRequested, this, + [this, idx](int pageNo, int pageSize) { + emit tabPageNeeded(dsId_, ddCode_, idx, pageNo, pageSize); + }); + } + } panelTabs.append({Glyph::Detail, spec.title, raw->widget(), false}); } const QVector actions = { diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 323b90d..98dc161 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -34,6 +34,9 @@ public: signals: // lazy 页签首次激活且未加载 → 请求懒加载。 void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); + // 分页型页签(paginated)分页器翻页/改每页条数 → 请求按页加载。 + void tabPageNeeded(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, + int pageSize); private: QString dsId_; diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 4567cb6..4688d62 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -30,6 +30,8 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名 // 页内 lazy 页签首次激活 → 冒泡为面板信号(外部接控制器 loadTab)。 connect(p, &DatasetDetailPage::tabNeeded, this, &DatasetDetailPanel::tabNeeded); + // 页内分页器翻页 → 冒泡为面板信号(外部接控制器 loadTabPaged)。 + connect(p, &DatasetDetailPage::tabPageNeeded, this, &DatasetDetailPanel::tabPageNeeded); } setCurrentWidget(p); } diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 5fcf7e8..1707a5d 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -23,6 +23,9 @@ public: signals: void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); // lazy 页首激活 → 懒加载 + // 分页型页签分页器翻页 → 按页加载(外部接控制器 loadTabPaged)。 + void tabPageNeeded(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, + int pageSize); private: DatasetDetailPage* pageFor(const QString& dsId) const; diff --git a/src/app/panels/chart/DataTableView.cpp b/src/app/panels/chart/DataTableView.cpp index c6a9bcf..410edf0 100644 --- a/src/app/panels/chart/DataTableView.cpp +++ b/src/app/panels/chart/DataTableView.cpp @@ -5,6 +5,8 @@ #include #include +#include "panels/chart/TablePager.hpp" + namespace geopro::app { namespace { @@ -126,6 +128,12 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) { table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_)); lay->addWidget(table_); + + // 分页器(默认隐藏;分页型载荷 pageSize>0 时显示)。转发翻页请求给壳。 + pager_ = new TablePager(this); + pager_->hide(); + connect(pager_, &TablePager::pageRequested, this, &DataTableView::pageRequested); + lay->addWidget(pager_); } void DataTableView::setPayload(const QVariant& payload) { @@ -146,6 +154,14 @@ void DataTableView::setPayload(const QVariant& payload) { header->setSectionResizeMode(col, QHeaderView::Stretch); } } + + // 分页器:分页型载荷(pageSize>0,dd_grid)显示并同步状态;否则隐藏(全量列表)。 + if (t.pageSize > 0) { + pager_->setState(t.total, t.pageNo, t.pageSize); + pager_->show(); + } else { + pager_->hide(); + } } } // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.hpp b/src/app/panels/chart/DataTableView.hpp index 72cf498..d0eaaa5 100644 --- a/src/app/panels/chart/DataTableView.hpp +++ b/src/app/panels/chart/DataTableView.hpp @@ -43,7 +43,11 @@ private: const TablePayloadModel* model_; // 不拥有 }; -// 通用数据列表视图:IDetailView + QTableView。measurement/grid/trajectory 列表共用。 +class TablePager; + +// 通用数据列表视图:IDetailView + QTableView(+ 分页型载荷时底部 TablePager 分页器)。 +// measurement/grid/trajectory 列表共用。载荷 pageSize>0(dd_grid)时显示分页器并转发翻页请求; +// 否则隐藏分页器(全量列表)。 class DataTableView : public QWidget, public IDetailView { Q_OBJECT public: @@ -52,9 +56,14 @@ public: QWidget* widget() override { return this; } void setPayload(const QVariant& payload) override; // 坏/空 variant → 保持空态不崩 +signals: + // 分页器请求加载某页(翻页/跳页/改每页条数)。壳据此触发控制器 loadTabPaged。 + void pageRequested(int pageNo, int pageSize); + private: QTableView* table_; TablePayloadModel* model_; + TablePager* pager_; // 分页器(pageSize>0 时显示,否则隐藏) }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridStrategy.hpp b/src/app/panels/chart/GridStrategy.hpp new file mode 100644 index 0000000..45a9613 --- /dev/null +++ b/src/app/panels/chart/GridStrategy.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#include "IDatasetChartStrategy.hpp" // geopro::controller +namespace geopro::app { + +// dd_grid(白化数据)策略:单「列表」页签,服务端分页(vxe-pager)。 +// 列表 = Table(paginated;grid.rows 产 TablePayload:序号/x/y 列 + total/pageNo/pageSize; +// 端点 dd/ert/grid/rows)。非 lazy(单页签,开页即载首页)。 +struct GridStrategy : controller::IDatasetChartStrategy { + std::string ddCode() const override { return "dd_grid"; } + std::vector tabs() const override { + return { + {QStringLiteral("列表"), controller::ViewKind::Table, + QStringLiteral("grid.rows"), /*lazy*/ false, /*paginated*/ true}, + }; + } +}; +} // namespace geopro::app diff --git a/src/app/panels/chart/TablePager.cpp b/src/app/panels/chart/TablePager.cpp new file mode 100644 index 0000000..3656647 --- /dev/null +++ b/src/app/panels/chart/TablePager.cpp @@ -0,0 +1,188 @@ +#include "panels/chart/TablePager.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +// 每页条数选项(实测原版 vxe-pager 下拉:50/100/500/1000,默认 50)。 +const int kPageSizes[] = {50, 100, 500, 1000}; +constexpr int kPagerCount = 7; // 页码窗口(>此值用 … 折叠,对齐 vxe 默认) +constexpr int kJumpStep = 5; // 点击 … 的跳页步长 + +// 分页器样式:常态不设字色(跟随主题/全局样式表);边框用半透明灰(两主题通用); +// hover/选中页用强调蓝 #409EFF(与 DataTableView 开关同色)。 +const char* kPagerQss = R"( +QToolButton { + border: 1px solid rgba(128,128,128,0.35); + border-radius: 3px; + padding: 1px 6px; + min-width: 16px; + min-height: 20px; + background: transparent; +} +QToolButton:hover:enabled { color: #409EFF; border-color: #409EFF; } +QToolButton[active="true"] { color: #409EFF; border-color: #409EFF; font-weight: bold; } +QToolButton:disabled { color: rgba(128,128,128,0.55); } +)"; +} // namespace + +TablePager::TablePager(QWidget* parent) : QWidget(parent) { + setStyleSheet(QString::fromUtf8(kPagerQss)); + + auto* lay = new QHBoxLayout(this); + lay->setContentsMargins(8, 6, 8, 6); + lay->setSpacing(6); + lay->addStretch(1); // 右对齐 + + prevBtn_ = new QToolButton(this); + prevBtn_->setText(QStringLiteral("‹")); + prevBtn_->setCursor(Qt::PointingHandCursor); + connect(prevBtn_, &QToolButton::clicked, this, + [this] { emit pageRequested(pageNo_ - 1, pageSize_); }); + lay->addWidget(prevBtn_); + + numHost_ = new QWidget(this); + numLay_ = new QHBoxLayout(numHost_); + numLay_->setContentsMargins(0, 0, 0, 0); + numLay_->setSpacing(6); + lay->addWidget(numHost_); + + nextBtn_ = new QToolButton(this); + nextBtn_->setText(QStringLiteral("›")); + nextBtn_->setCursor(Qt::PointingHandCursor); + connect(nextBtn_, &QToolButton::clicked, this, + [this] { emit pageRequested(pageNo_ + 1, pageSize_); }); + lay->addWidget(nextBtn_); + + auto* gotoLabel = new QLabel(QStringLiteral("前往"), this); + lay->addWidget(gotoLabel); + + jumpEdit_ = new QLineEdit(this); + jumpEdit_->setFixedWidth(40); + jumpEdit_->setAlignment(Qt::AlignCenter); + jumpValidator_ = new QIntValidator(1, 1, jumpEdit_); + jumpEdit_->setValidator(jumpValidator_); + connect(jumpEdit_, &QLineEdit::returnPressed, this, [this] { + bool ok = false; + int p = jumpEdit_->text().toInt(&ok); + const int pc = pageCount(); + if (!ok) { + jumpEdit_->setText(QString::number(pageNo_)); + return; + } + p = std::min(std::max(1, p), std::max(1, pc)); + if (p == pageNo_) { + jumpEdit_->setText(QString::number(pageNo_)); + return; + } + emit pageRequested(p, pageSize_); + }); + lay->addWidget(jumpEdit_); + + lay->addWidget(new QLabel(QStringLiteral("页"), this)); + + sizeCombo_ = new QComboBox(this); + for (int s : kPageSizes) + sizeCombo_->addItem(QStringLiteral("%1条/页").arg(s), s); + connect(sizeCombo_, &QComboBox::activated, this, [this](int i) { + emit pageRequested(1, sizeCombo_->itemData(i).toInt()); // 改每页条数 → 回第 1 页 + }); + lay->addWidget(sizeCombo_); + + totalLabel_ = new QLabel(this); + lay->addWidget(totalLabel_); + + setState(0, 1, pageSize_); +} + +int TablePager::pageCount() const { + if (pageSize_ <= 0) return 1; + return std::max(1, (total_ + pageSize_ - 1) / pageSize_); +} + +void TablePager::setState(int total, int pageNo, int pageSize) { + total_ = std::max(0, total); + if (pageSize > 0) pageSize_ = pageSize; + const int pc = pageCount(); + pageNo_ = std::min(std::max(1, pageNo), pc); + + prevBtn_->setEnabled(pageNo_ > 1); + nextBtn_->setEnabled(pageNo_ < pc); + + jumpValidator_->setTop(pc); + { + QSignalBlocker b(jumpEdit_); + jumpEdit_->setText(QString::number(pageNo_)); + } + { + QSignalBlocker b(sizeCombo_); + int idx = sizeCombo_->findData(pageSize_); + if (idx < 0) { // 非预设条数(兜底):补一项再选中 + sizeCombo_->addItem(QStringLiteral("%1条/页").arg(pageSize_), pageSize_); + idx = sizeCombo_->findData(pageSize_); + } + sizeCombo_->setCurrentIndex(idx); + } + totalLabel_->setText(QStringLiteral("共 %1 条记录").arg(total_)); + + rebuildNumbers(); +} + +void TablePager::rebuildNumbers() { + // 清空旧页码按钮。 + while (QLayoutItem* it = numLay_->takeAt(0)) { + if (QWidget* w = it->widget()) w->deleteLater(); + delete it; + } + + const int pc = pageCount(); + auto addNum = [this](int p, bool active) { + auto* b = new QToolButton(numHost_); + b->setText(QString::number(p)); + b->setCursor(Qt::PointingHandCursor); + b->setProperty("active", active); + b->setEnabled(!active); // 当前页不可再点 + connect(b, &QToolButton::clicked, this, [this, p] { emit pageRequested(p, pageSize_); }); + numLay_->addWidget(b); + }; + auto addDots = [this](int target) { + auto* b = new QToolButton(numHost_); + b->setText(QStringLiteral("...")); + b->setCursor(Qt::PointingHandCursor); + connect(b, &QToolButton::clicked, this, + [this, target] { emit pageRequested(target, pageSize_); }); + numLay_->addWidget(b); + }; + + if (pc <= kPagerCount) { + for (int p = 1; p <= pc; ++p) addNum(p, p == pageNo_); + return; + } + // 折叠窗口:首页 [ … ] 中段(当前±2) [ … ] 末页。 + addNum(1, pageNo_ == 1); + int left = pageNo_ - 2; + int right = pageNo_ + 2; + if (left < 2) { + left = 2; + right = 5; + } + if (right > pc - 1) { + right = pc - 1; + left = pc - 4; + } + if (left > 2) addDots(std::max(1, pageNo_ - kJumpStep)); + for (int p = left; p <= right; ++p) addNum(p, p == pageNo_); + if (right < pc - 1) addDots(std::min(pc, pageNo_ + kJumpStep)); + addNum(pc, pageNo_ == pc); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/TablePager.hpp b/src/app/panels/chart/TablePager.hpp new file mode 100644 index 0000000..7509f9f --- /dev/null +++ b/src/app/panels/chart/TablePager.hpp @@ -0,0 +1,48 @@ +#pragma once +#include + +class QToolButton; +class QLineEdit; +class QComboBox; +class QLabel; +class QHBoxLayout; +class QIntValidator; + +namespace geopro::app { + +// 分页器(对齐原版 vxe-pager,size--mini):上一页 / 页码(多页带 … 省略跳页)/ 下一页 + +// 「前往 [n] 页」跳页框 + 每页条数下拉 [50/100/500/1000] + 「共 N 条记录」。右对齐。 +// 数据驱动:setState(total,pageNo,pageSize) 重建页码并同步各控件。任何翻页/改每页条数都发 +// pageRequested(改每页条数时回到第 1 页,镜像原版)。仅展示与请求,不持有数据。 +// 配色:常态随主题(不显式设色,跟随全局样式表);hover/选中页用强调蓝 #409EFF(与表格开关同色)。 +class TablePager : public QWidget { + Q_OBJECT +public: + explicit TablePager(QWidget* parent = nullptr); + + // 用总数/当前页/每页条数刷新分页器(重建页码按钮、同步跳页框/下拉/总数文案)。 + void setState(int total, int pageNo, int pageSize); + +signals: + // 请求加载某页(翻页/跳页/改每页条数)。改每页条数时 pageNo=1。 + void pageRequested(int pageNo, int pageSize); + +private: + int pageCount() const; // ceil(total/pageSize),至少 1 + void rebuildNumbers(); // 按当前页重建页码按钮(含 … 跳页) + + int total_ = 0; + int pageNo_ = 1; + int pageSize_ = 50; + + QToolButton* prevBtn_ = nullptr; + QToolButton* nextBtn_ = nullptr; + QWidget* numHost_ = nullptr; + QHBoxLayout* numLay_ = nullptr; + QLineEdit* jumpEdit_ = nullptr; + QIntValidator* jumpValidator_ = nullptr; + QComboBox* sizeCombo_ = nullptr; + QLabel* totalLabel_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index a496f17..5706aa3 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -35,6 +35,16 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd } void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode, int tabIndex) { + loadTabImpl(dsId, ddCode, tabIndex, /*pageNo*/ 1, /*pageSize*/ 0); +} + +void DatasetDetailController::loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, + int pageNo, int pageSize) { + loadTabImpl(dsId, ddCode, tabIndex, pageNo, pageSize); +} + +void DatasetDetailController::loadTabImpl(const QString& dsId, const QString& ddCode, int tabIndex, + int pageNo, int pageSize) { auto* s = registry_.find(ddCode.toStdString()); if (!s) return; // 策略消失(不应发生):静默不加载 const std::vector tabs = s->tabs(); @@ -45,7 +55,7 @@ void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode // 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed,且不留半注册的在飞句柄。 data::DetailLoad* load = nullptr; try { - load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString()); + load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString(), pageNo, pageSize); } catch (const std::exception& e) { qWarning("[detail] loadAsync 失败 id=%s tab=%d key=%s: %s", qUtf8Printable(dsId), tabIndex, qUtf8Printable(spec.loaderKey), e.what()); diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 20b5260..c3f6aaf 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -23,7 +23,11 @@ public slots: // 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。 void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString()); // 加载某页签(lazy 页签首次激活时由壳触发;非 lazy 由 openDataset 自动触发)。 + // 分页型页签(如 dd_grid 列表)首载用默认页(pageNo=1/pageSize=0 → 仓储解析默认每页条数)。 void loadTab(const QString& dsId, const QString& ddCode, int tabIndex); + // 分页加载某页签(分页器翻页/改每页条数时由壳触发)。pageSize=0 → 仓储用该类型默认值。 + void loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, + int pageSize); void focusDataset(const QString& dsId); signals: void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, @@ -33,6 +37,10 @@ signals: void loadFailed(const QString& dsId, const QString& message); void focusRequested(const QString& dsId); private: + // loadTab/loadTabPaged 共用实现:按 (dsId,ddCode,tabIndex) 查 loaderKey,带分页参数异步加载。 + void loadTabImpl(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, + int pageSize); + data::IAsyncDatasetRepository& repo_; ChartStrategyRegistry& registry_; QMap> inflight_; // 按页签槽位的在飞句柄(§5.0 身份比对) diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index 28518ae..762aba3 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -61,10 +61,14 @@ struct TableColumn { }; // 通用表格载荷:列定义 + 预格式化的行(每格 QString)+ 总数(分页用)。 +// 分页(dd_grid 列表,服务端分页 vxe-pager):pageSize>0 时视图渲染分页器,pageNo 为当前页(1 基); +// pageSize=0(默认)= 不分页(measurement/trajectory 全量列表,一次性返回所有行)。 struct TablePayload { std::vector columns; std::vector> rows; int total = 0; + int pageNo = 1; // 当前页(1 基);分页用 + int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager),0=不分页 }; // 柱状图系列:名称(图例/legend)+ 各类目的 y 值 + 填充色(hex,如 #5470c6;数据色,两主题一致)。 diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 5161dde..a192760 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(geopro_data STATIC dto/MeasurementDto.cpp dto/GrMeasurementDto.cpp dto/TrajectoryDto.cpp + dto/GridDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index a4863f6..ee4472d 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -10,6 +10,7 @@ #include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" #include "dto/GrMeasurementDto.hpp" +#include "dto/GridDto.hpp" #include "dto/MeasurementDto.hpp" #include "dto/TrajectoryDto.hpp" #include "model/detail/DetailPayloads.hpp" @@ -132,11 +133,24 @@ net::ApiBatch* trajectoryLineBatch(net::ApiClient& api, const std::string& dsId) return new net::ApiBatch(calls, &isFailure); } +// dd_grid 白化数据列表(服务端分页):单请求 grid/rows(GET,query:dsObjectId/pageNo/pageSize)。 +// 响应 data = {rowList[{x,y,id}], gridHeaderDisplay[x,y], total}。 +net::ApiBatch* gridRowsBatch(net::ApiClient& api, const std::string& dsId, int pageNo, int pageSize) { + QList calls{ + api.getAsync(QStringLiteral("/business/dd/ert/grid/rows?dsObjectId=%1&pageNo=%2&pageSize=%3") + .arg(enc(dsId)) + .arg(pageNo) + .arg(pageSize)), + }; + return new net::ApiBatch(calls, &isFailure); +} + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} -DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId) { +DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId, + int pageNo, int pageSize) { if (loaderKey == "inversion.scatter") return makeInversionScatter(dsId); if (loaderKey == "inversion.grid") return makeInversionGrid(dsId); if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId); @@ -146,6 +160,7 @@ DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const if (loaderKey == "traj.rows") return makeTrajectoryRows(dsId); if (loaderKey == "traj.elev") return makeTrajectoryElevation(dsId); if (loaderKey == "traj.map") return makeTrajectoryMap(dsId); + if (loaderKey == "grid.rows") return makeGridRows(dsId, pageNo, pageSize); throw std::runtime_error("unknown loaderKey: " + loaderKey); } @@ -207,4 +222,15 @@ DetailLoad* ApiDatasetRepository::makeTrajectoryMap(const std::string& dsId) { }); } +DetailLoad* ApiDatasetRepository::makeGridRows(const std::string& dsId, int pageNo, int pageSize) { + // 解析默认值:pageNo<=0→1,pageSize<=0→50(原版默认每页 50 条),再传入 URL 与解析器 + // (保证 URL 参数与「序号」列偏移一致)。 + const int pn = pageNo > 0 ? pageNo : 1; + const int ps = pageSize > 0 ? pageSize : 50; + return new ApiDetailLoad(gridRowsBatch(api_, dsId, pn, ps), + [pn, ps](const QList& r) { + return QVariant::fromValue(dto::parseGridTable(r[0].data, pn, ps)); + }); +} + } // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index 7ecb997..d31cd5d 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -7,7 +7,8 @@ namespace geopro::data { class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); - DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) override; + DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId, + int pageNo = 1, int pageSize = 0) override; private: DetailLoad* makeInversionScatter(const std::string& dsId); DetailLoad* makeInversionGrid(const std::string& dsId); @@ -18,6 +19,7 @@ private: DetailLoad* makeTrajectoryRows(const std::string& dsId); DetailLoad* makeTrajectoryElevation(const std::string& dsId); DetailLoad* makeTrajectoryMap(const std::string& dsId); + DetailLoad* makeGridRows(const std::string& dsId, int pageNo, int pageSize); net::ApiClient& api_; }; } // namespace geopro::data diff --git a/src/data/dto/GridDto.cpp b/src/data/dto/GridDto.cpp new file mode 100644 index 0000000..1187a12 --- /dev/null +++ b/src/data/dto/GridDto.cpp @@ -0,0 +1,34 @@ +#include "dto/GridDto.hpp" + +#include + +#include "dto/TrajectoryDto.hpp" // parseGridHeaderTable(通用 gridHeaderDisplay+rowList 解析器)复用 + +namespace geopro::data::dto { +using namespace geopro::core; + +TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize) { + const int pn = pageNo > 0 ? pageNo : 1; + const int ps = pageSize > 0 ? pageSize : 0; + + TablePayload t = parseGridHeaderTable(data); // x / y 列 + 各行(按 columnSort) + + // 前插「序号」列(vxe seq 列:居中、按页偏移自增)。 + TableColumn seq; + seq.code = QStringLiteral("__seq"); + seq.title = QStringLiteral("序号"); + seq.sort = 0; + t.columns.insert(t.columns.begin(), seq); + + const int base = (pn - 1) * ps; // 本页首行的全局序号偏移(ps=0 → 偏移 0,从 1 起) + for (size_t i = 0; i < t.rows.size(); ++i) + t.rows[i].insert(t.rows[i].begin(), QString::number(base + static_cast(i) + 1)); + + // 总数:dd_grid 用 data.total(parseGridHeaderTable 默认回退本批行数,这里以服务端总数覆盖)。 + t.total = data.value(QStringLiteral("total")).toInt(t.total); + t.pageNo = pn; + t.pageSize = ps; + return t; +} + +} // namespace geopro::data::dto diff --git a/src/data/dto/GridDto.hpp b/src/data/dto/GridDto.hpp new file mode 100644 index 0000000..0728d6d --- /dev/null +++ b/src/data/dto/GridDto.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#include "model/detail/DetailPayloads.hpp" + +namespace geopro::data::dto { + +// dd_grid(白化数据)列表。端点 dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize= +// (服务端分页;data 形如 {rowList[{x,y,id}], gridHeaderDisplay[x,y], total})。 +// +// 渲染(实测 vxe-table 确认):序号 / x / y 三列,全部居中、同色灰字(无特殊着色); +// 底部 vxe-pager(上一页/页码/下一页 + 前往 N 页 + 每页条数选择 [50,100,500,1000] + 共 N 条记录)。 +// +// 复用通用 parseGridHeaderTable(gridHeaderDisplay→x/y 列 + rowList→行),再前插「序号」列 +// (vxe seq 列:按页偏移自增 = (pageNo-1)*pageSize + 行内序号);total 取 data.total(非 __rowTotal); +// 回填 pageNo/pageSize 供视图渲染分页器。pageNo/pageSize 为本次请求参数(仓储已解析默认值后传入)。 +geopro::core::TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize); + +} // namespace geopro::data::dto diff --git a/src/data/repo/IAsyncDatasetRepository.hpp b/src/data/repo/IAsyncDatasetRepository.hpp index 1bf0629..0f8c6bd 100644 --- a/src/data/repo/IAsyncDatasetRepository.hpp +++ b/src/data/repo/IAsyncDatasetRepository.hpp @@ -10,7 +10,10 @@ class IAsyncDatasetRepository { public: virtual ~IAsyncDatasetRepository() = default; // 通用页签加载(tab 引擎):按 loaderKey 分派,载荷经 QVariant 类型擦除。 - virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) = 0; + // pageNo/pageSize 仅分页型 loaderKey(如 grid.rows)使用;其余 loaderKey 忽略。 + // 默认 pageNo=1/pageSize=0 → 非分页加载(分页 loaderKey 自行解析默认每页条数)。 + virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId, + int pageNo = 1, int pageSize = 0) = 0; }; } // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c522864..cf0a2d8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,7 @@ target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) target_sources(geopro_tests PRIVATE data/test_gr_dto.cpp) target_sources(geopro_tests PRIVATE data/test_trajectory_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_grid_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) # 通用仓储分派离线单测(loadAsync 分派 + QVariant payload round-trip)。 target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index de779d1..9a508b6 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -4,6 +4,7 @@ #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" #include "panels/chart/TrajectoryStrategy.hpp" +#include "panels/chart/GridStrategy.hpp" using namespace geopro::controller; namespace { struct Fake : IDatasetChartStrategy { @@ -93,3 +94,16 @@ TEST(TrajectoryStrategy, DrivesMapTableElevationTabs) { EXPECT_FALSE(tabs[2].lazy); EXPECT_EQ(tabs[2].loaderKey.toStdString(), "traj.elev"); } + +TEST(GridStrategy, DrivesSinglePaginatedListTab) { + geopro::app::GridStrategy s; + EXPECT_EQ(s.ddCode(), "dd_grid"); + const auto tabs = s.tabs(); + ASSERT_EQ(tabs.size(), 1u); + // 列表:Table,非 lazy,分页(paginated)。loaderKey grid.rows → TablePayload。 + EXPECT_EQ(tabs[0].title.toStdString(), std::string("列表")); + EXPECT_EQ(tabs[0].kind, ViewKind::Table); + EXPECT_FALSE(tabs[0].lazy); + EXPECT_TRUE(tabs[0].paginated); + EXPECT_EQ(tabs[0].loaderKey.toStdString(), "grid.rows"); +} diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index be8c8a4..b175c96 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -46,7 +46,11 @@ struct StubDetailLoad : data::DetailLoad { // 桩仓储:每个 loaderKey 都造一个新句柄,记录最近一个用于 fire。 struct StubAsyncRepo : data::IAsyncDatasetRepository { StubDetailLoad* last = nullptr; - data::DetailLoad* loadAsync(const std::string&, const std::string&) override { + int lastPageNo = 0, lastPageSize = 0; // 记录最近一次分页参数(验证 loadTabPaged 透传) + data::DetailLoad* loadAsync(const std::string&, const std::string&, int pageNo, + int pageSize) override { + lastPageNo = pageNo; + lastPageSize = pageSize; last = new StubDetailLoad; return last; } @@ -141,6 +145,26 @@ TEST(DatasetDetailController, LoadTabLazyTabStartsLoad) { EXPECT_EQ(ready.takeFirst().at(1).toInt(), 1); } +// loadTab(非分页)用默认页参(pageNo=1/pageSize=0);loadTabPaged 透传分页参数到仓储。 +TEST(DatasetDetailController, LoadTabUsesDefaultPageParams) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + c.loadTab("ds1", "dd_inversion_data", 0); + EXPECT_EQ(repo.lastPageNo, 1); + EXPECT_EQ(repo.lastPageSize, 0); +} + +TEST(DatasetDetailController, LoadTabPagedThreadsPageParams) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + c.loadTabPaged("ds1", "dd_inversion_data", 0, 3, 100); + ASSERT_NE(repo.last, nullptr); + EXPECT_EQ(repo.lastPageNo, 3); + EXPECT_EQ(repo.lastPageSize, 100); +} + TEST(DatasetDetailController, AbortsPreviousOnSameSlotReload) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); diff --git a/tests/data/test_async_repo_dispatch.cpp b/tests/data/test_async_repo_dispatch.cpp index 6fcd3d8..62d4d9a 100644 --- a/tests/data/test_async_repo_dispatch.cpp +++ b/tests/data/test_async_repo_dispatch.cpp @@ -65,6 +65,10 @@ TEST(AsyncRepoDispatch, KnownKeysReturnNonNullHandle) { DetailLoad* trajMap = repo.loadAsync("traj.map", "ds1"); ASSERT_NE(trajMap, nullptr); trajMap->abort(); + + DetailLoad* gridRows = repo.loadAsync("grid.rows", "ds1", 1, 50); // 分页型:带页参 + ASSERT_NE(gridRows, nullptr); + gridRows->abort(); } // 未知 loaderKey 抛 std::runtime_error。 diff --git a/tests/data/test_grid_dto.cpp b/tests/data/test_grid_dto.cpp new file mode 100644 index 0000000..68c0e61 --- /dev/null +++ b/tests/data/test_grid_dto.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include + +#include "dto/GridDto.hpp" + +using namespace geopro::data::dto; +using geopro::core::TableColumnKind; + +namespace { + +// 取自真实夹具 tests/fixtures/dd/ert-grid-rows.json 的 data(rowList 5 行,gridHeaderDisplay +// 两列 x/y,total=62,逐字一致;端点 dd/ert/grid/rows)。内联避免引入 fixture 路径编译定义。 +const char* kGridData = R"({ + "gridHeaderDisplay": [ + { "columnCode": "x", "columnNameChn": "x", "columnNameEng": "x", "columnWidth": 10, "columnSort": 1 }, + { "columnCode": "y", "columnNameChn": "y", "columnNameEng": "y", "columnWidth": 10, "columnSort": 2 } + ], + "functionList": [], + "total": 62, + "rowList": [ + { "x": 2.904, "y": 25.126, "id": "1438944148742144" }, + { "x": 4.897, "y": 25.161, "id": "1438944148742145" }, + { "x": 6.892, "y": 25.247, "id": "1438944148742146" }, + { "x": 8.892, "y": 25.251, "id": "1438944148742147" }, + { "x": 10.891, "y": 25.226, "id": "1438944148742148" } + ] +})"; + +QJsonObject gridData() { return QJsonDocument::fromJson(kGridData).object(); } + +} // namespace + +TEST(GridDto, FirstPagePrependsSeqColumnAndReadsTotal) { + auto t = parseGridTable(gridData(), /*pageNo*/ 1, /*pageSize*/ 50); + + // 三列:序号 / x / y(序号前插,x/y 来自 gridHeaderDisplay 按 columnSort)。 + ASSERT_EQ(t.columns.size(), 3u); + EXPECT_EQ(t.columns[0].title.toStdString(), std::string("序号")); + EXPECT_EQ(t.columns[0].code.toStdString(), "__seq"); + EXPECT_EQ(t.columns[1].title.toStdString(), "x"); + EXPECT_EQ(t.columns[2].title.toStdString(), "y"); + for (const auto& c : t.columns) EXPECT_EQ(c.kind, TableColumnKind::Text); // 无特殊列 + + // 5 行;首行 序号=1 / x=2.904 / y=25.126。 + ASSERT_EQ(t.rows.size(), 5u); + ASSERT_EQ(t.rows[0].size(), 3u); + EXPECT_EQ(t.rows[0][0].toStdString(), "1"); + EXPECT_EQ(t.rows[0][1].toStdString(), "2.904"); + EXPECT_EQ(t.rows[0][2].toStdString(), "25.126"); + EXPECT_EQ(t.rows[4][0].toStdString(), "5"); // 第 5 行序号=5 + + // 总数取 data.total=62(非本批 5 行);分页态回填。 + EXPECT_EQ(t.total, 62); + EXPECT_EQ(t.pageNo, 1); + EXPECT_EQ(t.pageSize, 50); +} + +TEST(GridDto, SeqColumnOffsetsByPage) { + // 第 2 页、每页 50:本页首行全局序号 = (2-1)*50 + 1 = 51。 + auto t = parseGridTable(gridData(), /*pageNo*/ 2, /*pageSize*/ 50); + ASSERT_EQ(t.rows.size(), 5u); + EXPECT_EQ(t.rows[0][0].toStdString(), "51"); + EXPECT_EQ(t.rows[4][0].toStdString(), "55"); + EXPECT_EQ(t.pageNo, 2); + EXPECT_EQ(t.pageSize, 50); +} + +TEST(GridDto, EmptyDataYieldsSeqOnlyColumnNoRows) { + const QJsonObject empty; + auto t = parseGridTable(empty, 1, 50); + // 空数据:仅有前插的「序号」列,无行;total=0。 + ASSERT_EQ(t.columns.size(), 1u); + EXPECT_EQ(t.columns[0].code.toStdString(), "__seq"); + EXPECT_EQ(t.rows.size(), 0u); + EXPECT_EQ(t.total, 0); +} diff --git a/tests/fixtures/dd/ert-grid-rows.json b/tests/fixtures/dd/ert-grid-rows.json new file mode 100644 index 0000000..8cc07ad --- /dev/null +++ b/tests/fixtures/dd/ert-grid-rows.json @@ -0,0 +1,19 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "gridHeaderDisplay": [ + { "columnCode": "x", "columnNameChn": "x", "columnNameEng": "x", "columnWidth": 10, "columnSort": 1 }, + { "columnCode": "y", "columnNameChn": "y", "columnNameEng": "y", "columnWidth": 10, "columnSort": 2 } + ], + "functionList": [], + "total": 62, + "rowList": [ + { "x": 2.904, "y": 25.126, "id": "1438944148742144" }, + { "x": 4.897, "y": 25.161, "id": "1438944148742145" }, + { "x": 6.892, "y": 25.247, "id": "1438944148742146" }, + { "x": 8.892, "y": 25.251, "id": "1438944148742147" }, + { "x": 10.891, "y": 25.226, "id": "1438944148742148" } + ] + } +}
  • e+mR4RjW@}ih2KY0=pf?tu6Nb(1oS#np?V>@89k%{^YaO^r z4{KA3-21JyeIC79%*!}qP`&{@699v|3D(b;XuZ$J)^{btB^7!E>yU)QUPHH7V}Bu2 zD&Da^@613GI;3jdT=_Ykj&KZX88<3#XQ8l?*kx@=JXN=O?TbDd4JL0(aPPL?y%_LY z2=k|1F4cKETFnU)NHuXD-Atx;p<)}Px+TZTzaH(@+tNwc6fB#Bxsuv6Cf1?Tv-z;_ zWo0sJdxsBmF5X{e49lYw`OIAp9-%adr5A)^PEV1(AIp(z1`9qasv9{6#c^VxOVXrE9 zPa$1eQ>f*xb+_y^IKdO*d~lzB+LBjiFhEz%aNdh*u@W0)+J}|0CGgi}G(r_3^|~)x zdZuR!1N)esk%}CVLSyvr*A`aZ>O)Y8v`jzmmNRShE-`HnouH%yi8BAL&;%v|p|lcK z0nC70sGE2UL1!W8@Kf8bqUm^#0Ux!#uS?%aaig~~a7i0CB2Zd9-Sm?+6%|F!Z<%(J zHy48uqj}J%O%X5{nC6lzMX7wPO!w1IEf&Szz2$h527AlNMKWDswPM$7^9Dd z2RS?y#I}5S(7buRlg8hgw!dIjIBRkLme7#*65N9e1>fG_)C<+`P4Na>#>Y8=YyRm} z#qgomzyC2NE{?TyY2czo{*$NP*}FPBe!5540LK9n-9S6Gu^{iB0IW*D8oc>z&Bjb^ zsxaxNQVCZv`0FrSN4*3j8ZrHF+{2%Xv-X|B(_LbgjD?;*lzb_kK{69nwgGm8_pk_W z2xfL`k_+lHO9UlVX}{M?N<>Hkis*th>VfqISTzI^g9NV|9|?(mR4j?;6y?PVaB+^p zy7mb~VyrIwQ+_>Pn^Hmb@QGRce)Jn}TI+WIyLIV*?528-_ZwqXP_5$TSCW!!qxKGD z3y*t3NBy5dEgXSu56)sJ3>1#mX}_9Bn;HJDE-}43?OAZ*#G{{n==VvN{a~%AzMof@ zo~LN4@#s$so)+d7*Tag;tQC~XAcR_{)a%qW z60SzVMQRl~1s@Q=CN&gciCizz5xmHHl>sopBxeeaf+sq-QCyh@ox!bN5Fn4C7X|RN z_E|oLU(8M4aUkd5{-DWIZ5YPg298DxpF0=&m*MuRFFA?VW0$^E4toh(1hDaC9oCol z=kW6OdoFlXCF$jX#iEhtlN(0G&}SvM02f3NN521dZI~CCgty?f4sj>ly;X1HoRN|A zzI|9JSL^zXx4;z*oak1%q0ri~%s?{tVUxAJZhcv?Z%b3RC(^u~;MyvdL2_n9Er?J5 z``W%?31hbW%8q!!nOIhF>eW}}4Gvy|pD|U!F7R@WXqxyC^e*(DfML6=dR9~6Ifvjb z+rFOhD@!g+F28==ccfL#Lsno=z__U;mrp-U00TtMAAft*w!EX9Fq@v8xfcdqcq_Hy zCXeP#^cc_sD;%(Jm2g#kKL`k$V^^YQ+0}a6QZO2+(g>U@HsKf&supt$I+e8yQ5!I* zMgKGbd1zH0|y{G zz^TBQFvFb-D@b<n#g1bie;zoLeMbyr~1lgd)MP_ zn?h$qd&GATloMC;at~hd8IJG0!~$!T`g98O)HmSyv%1k6pjKJ6wtGx3LeRy$>U>?jjm1pt5X6PfaWnX|L9MB-d^5m zu#_3fZ``O)PagHlYtS>Jkl@p3`CY{X_+Cu<`dQq-CYP7a@ z(IUS?C#s6m1cALR`=h->Y5j&fKD<~TzMvZrfFi)s?kS%zBpeuvWVL1Bae0gwx=IGHKK5N&mQX@|{h;GZR zFVN{w+}Mzo+2jlGpL!94;H2tH zsf{DXx{igv;Y*ils<`sS9|nQf_s?)KPAw0M(iF}I`HyYkX*h{ukNB2AX9Zs#Fs_XN zR!gKPtT{u2#?v8$H4g@`8*f3~1fYH=JgxhJK;Lu>?~u!-kyrSRYSRYi znnuIieK2~%hWx*NEiP?R{`e%mVRSGv+Ks&WYUSJ83MUP47}MKtlAWtkw&mSlC+h$4bTqyvqfDeVg1YolS)G7i%+o2=in=DM zuNMl7)JP@lNKg0v!Oi02_5je;9s@%`YksR!0a8sP;WS|>rm8)>oGQSI;i!Zm-v-&k zE(2FU!HI|5N99;DbJV8o!BeMN>zQ}-XwIP%Dj_Xs+F7E5RVgw>Q<0$Xp+UK@GiPl2 zGZ%yS2O)r63ijX%#iR40`oYcs01yC4L_t)fNv*t5EzKUXY+h)9#vC;{EulrNERmBKO^5?#VCmWe90 z8GoFG5D1ztlj254<7uowFQ-T!f)0j1VhW|D^YVzEe=n>TH7zhLL7id3pRpr;ibSFiIR`khdKL5x zsJzn%)_bEiMp~o+ETS=ANp!Gns3Gi;uR$OUa$Y{vMQ9aGMiU?LkI01F2K+l*m}e^l zJ%XSFxODD*IBjSVoL2j9W`Dsi0=U=@tYjerPtX?-bQG@s`xTtH3~0jDu-0WYENgkR z>!f=Uie%MAXf)`a-pKItRSG&}@KD{o>N6zmx48FZi7GpgJq}^CybKYyw1c{1pGj_K zE^+toFNZ8)@BV>v7y9hHTes>!X+gCdvKs~i^!@SjKW^1dnB$d{JocS;EH9D|pQ!Te zNO8%<8i`8p)T@K8msX*7itf;jU!)?j+wLM^S)DBdzN0T~$t2>stifE9kW= zW)~o;C@`wST~V9?w8ZHJXNKq9UtG9jiR+)=dVE4abm9MfiRTOC3bex}*TK_T*_#@RXz`eiK)bOgi>o6qFq+RC4-S4jXis?@Wi;m{E zoCoN@OF7$PvqAPAR zK>t>7?ld}+F_RbDGS~0yuZ#$!u3vAVoZjc3rpp@Ci$|Ipfv4`*I}y+m!#c3+`3-Uv z1^MYp^Eu-F<+?y+Ct+kN0|cfH=mp}Dth5(Fx75vYpul!sQ@}{}(xok_( zPz(E;Qzt7CsuH4F?A7E7N&kT@Pr-@P)l?T#{;ttrlqhwqCxoA{Tt@e4Avo0%ur5J3 zhO}^gibfgPwJm{9T32f{m?x(i4I0R3tSe%YkgNW6xaerR4!o=5+d`Q_G^IdYzw;b;0cxk-E%XWYnnCiVNm6g~j~ zy|SZ)aPKt|Fo{C!^XQH zxT>0m{MJtaOX!V-J*QJ$0U4%(Kmri0wc+tI` z9D+SUXU6-Dj&biF(kc6E(CYJ!-haI9wx~+i@BNhv?mV!;{ytd$-K{Ua(#ZULNB(Am z{e?`~rN8M!&hU!cHt7Flhiwxae%(2cBrerGrqgb98NZR7@IJ#kFT{3b#d4!j82x6K z%ZsG%xN`lYEuyN&!BbK1mJJ*HwOs{_{|%tJ5$Ikd|FJGHi+zXx5IJ;R#I!^GUw@eJ zVbkcP1%vy4>_qpGDTM`2y<%F3x# zrUXIdtq^(je7$U)Tif}V1 zuC7`|mkns0QV2^czjy#PbThoF{1k#VLeST6Ex?|snD|0~fa&Uy*7pMU(69;XVEF!_#Ofa z3(?WHj#Rbp<{M6@c@clwn6FYUM|8c8k`wi}yB%ZdjOvn8~L>f-B)2Cp>VXe=}cH=`Zwd=Y0Fno2{pVNmH(JF zk*q{I z%CDdC86N0Awu9_j>7~@7GnYLEVh4Y1p}yLc73qbiY8ZZGn)mZkKf{pGfTwwSJC=er zRp)vFgHHdnk_6e=S+A8nb)X9ZgM~%xyMb#EGz}I%xDLm=tb?8jD5El>E`66hU7dO@ zEI&qIyvjQ_!{;VI&w#w657PcT?9pF0ac4|V$v(Ft|9FE#PyhniiSIRI=QSjT zHu7hyRgg6@S;5I04xyc^I@hP4GsvD%J1z&-#`qHICd%P}u=za^m-ac9B@UYMhqc{; zlFR&py|HgEG28y|ryq6d#$KU!@MYqU9ICLk_q*?U zIa9HJeP$~@W71+jb%m<@+P{$l=G<$<%k|UV4tTn-Q7iU+lS@G&j~ud))Bo3BCC3g` zy)nYvmb;YFSl6gNG~awWIF-*2iDQ{Db`pt}lP8|+*hwiwya*68A`zq@o)JrHs0a7Ipc%eO-$#`t6ZNz>Xa!aGdLxMe=o6d z?CU05?*3`63W*@Qw8XXo--7!SE$H0C)i^2xOEK-zD%3ia-mPz2_FF?{Hil%b>SWL= zj5-@7oN2JUP*?Fg9N+o0g46*#+k@fPW;a&d{m!L#7%Q+Pelh>p0nb4u)~(>=L%oI% zC=kdLpu@}F!*S`|G8!mK6tTlwmUUUz>j(97q|;3_ZDywM*x{-c<3bwP zF(_o&iDIfJVat|27O#8pD~Z`h>y{E3U7~*5!v%|Z-_da$olVJ6Z&;Q8&Vq+YNgdY3 zPEPn^XNQcK>b@hR@yn}~t=pH!yKS4~&u`NFaHuS$*px63>P3EYgd0|6T)Qf3<>wh- zOIR4~@c#H$rhDaFYqHc@SAFMxCn_42P6+k@-kNB{GpG}Tg zIzm*Lc4^sbM<$J{&q#;<`%v(kZm(K-c*2Or{Kt{=`}hBND)bUB@dPgk;Foxb{~>^7 z*v>hARSmjt4%J+IES>sI!Gy2#&L=m%Iobn@w(1&)^`mB_@NJ*DcN8P#n&oxW;2EA+ zBh*wVwYZnzM^a05S$iuj?rp}Htx;-8ZQ0e%r{u`jh6PQIO#9=P^uPat{vnj#xKWo{ z;uiNY8sE)m{jQ=Wd6z*k68f*fpj^<9(arL-RL?P`S8m(xGxFz+dNsP{v1Jap*TQjv z5=7onyS!ZqUxf|(K2lbv&O6GLaFvkVs7V&&?5|<^lINU_r@9gBKQQNTHObkD=xFN} zG8%!KzHFC_?hmhz_kOPMbYjD%QtTy#3 zesLo^qRne5uBvR${sOp9{PSs*U~$z0I5dG#t@Wm$B=O`s&D$kL#)lQ#-ElReC>Nps z08(JNa`42BhbwBwb(i7$x8W87y#j$<%!+c3UUGx#(ZBsZwWy8s#n3D$k!oc11t;e; z6>RVs=KXrogWze?p6;VlsrC*{sJeG0azR3?lwUKl_uZ_zd%O2r?BPF{M=)sRx^s&j zSKe-Pj-fOC?aMJWLN$*C^_e25D=N`|9c;|EAk8A@soE2V7!nqRF?R>!88n^B!m)T( zNOy;+G+1qK6)gTd92PuyCKz>bQf`qwJ=x*2m3(<^O6T!jt;pQ9i|Ox3^{^{D#3GV6 zpDHF}PKQ)cO`Ti#KOaP8}!ZXyBI0%OhbiZca6DX0COxL(qZ1kvkys^)UWkHiVd=?`4U2!v0E#~!pt1YR5li1XhFLelRn_Prta-`Pi6Vb-x)mJG(1I>-AM~|9} zI;-jRRgFpt_PKj(X7_UB3p5hBuG60Lhr88O$oK9ox7K0P&%KFAMB+KCmZwiRedKI+ zq)^+vz{=+b&O<(er#Msb!(xzR>wqlk^ziDAhC!SRBWP1(P)6g@48#%q>rm*;}-g`*)81d zwQF^ix#CZUwGgGdlj}G#?Yom&?5GNXYTAsJnfLcUi+y`Ds4Nqcot4-KJ*l59=c$zf zZEJdcZK{M7*z%g2RDh+>5bX+S*QRD+$>w_9Dj^>(vtkX7a2?RDq6C(*Fb9%&ff>-j z%utEa{6@Eo(pTPlm*h+coY)sK8JxwDueDri)}9Jx5P|A$y4fmk^4vWMu_H}(Z|0o_ z3L4o;<^J_&akM8n(7lDsF0WInl!n*iI*1{qR!eJ*`gcFHQ2D5-Nj+`U&Bjao)lO_0 zx(9BU_dFaAy16u-YI<*0;;2%E7^XB` zRCcA!(S)aCLT0#uo8fxm-9P)l{>)vcU1C1- zO6WWQt1kVxK{*1ltBMWiBZ2;tH~^0fokv0-aQ7XxHHIWkO76Q zDn5Va%F@l(Ki-_N^Hkxfht~Xi-GJAS$iDAQh<;-Lo}oH2am?PKgHpEt3jH@xcJ125 zWlN4u9$k}i)qAMt$e$Oo;kx55@e-|gNdUjZOZ;zPpf4@j1NZF$k0W0M9rz^h*q6Z@ z7Y8}eY}r8mZbZFGzk7F?jXpDHc&YdrYkNI>NYZMnI!k~U%Q;x3ky>>+kQ{N*Z@Lzq zzR54@oX7GU6dpFGZ_dFT$y>XVUHa@u`E>^p?avB&b}yA(Xv{yMuCIl4W&d*+s~6$R zZ*(Ue#3kl+gN~80Yp3mQVY6Q=zi!!Ejc1V9;o*;dsZX+$DWov+ZS z9&ScMmopI&2%}N|f;DWvgP;d6L*N1ZF9HjDqq+ zcWW#3`bxF7SggvIs~a#zf;;}>ufh`_jh#E!+s1kB+-dlHWu|io)vU_k&12O@8j8{` z(n7B>N;oRN(H-1m4Ot3yM4S3qV?N-b(6LLGHNOSy@1-|8PF7zA9`#YOuW&&3v4;q@Y+E7^UVOT8!DL7Z62($;R2j2nCNX*@XLeCvJQrG z?#BQdp?mk?zYLR6v~|>ARm}Ujz9WBtx`^@{tIDsx=Guon=fY8IrJ-^UA3n2S4%rDY z;-~rS@V4LpQANYuwY!10e8P^1$obx8M?L(h@XzQQ!b*AQEcZ^?Us;_h?$aD1ZWg`dF%m zD}=fUbqC8~Ut6=)hx$n_<*V@!n$ZpgmCptxSvp^{)F#s?@&%Xc0 zr@s-Hb+0E6YEKRJAZi|qs2*OOcFWj#6-RT9oHyTEhsJ{Z!ZVkhq84{{Ca5vzmQgbS zdRCM?LtNK4@Tk=jIyb28WDifru$k8OmtXsg=ztjb+2>~4Z+-c>(I|I~Z4;BI&&=~4 z)*(s{aPuUm4jGxk5~*j$C!JR&i|J*a5nfd(b0Aq=h~-sEeHfkB`*=nS@S%BAac2@c z&&tK15ngoMzTH-pP6Gp}Uw#&zdamx;KC6ks117nTpXV*jl^37yF`3q91Tca0)yi?N z`FMrc3cgG^S2L)uW8c0O0z}?lkwP>;WwNAoP+ zw0pczMCas$LT|*p5CXVCD@#(8j-X+DtQ1xKmD@=6$G5N6ad=l*yc5}tX8P9za_#jp zQNOSj@)&#@G)LT=Io{y)-MbA1<RYL?RTnrTjJ(N?cKTwX4lHu|PGN&2Bn51V$&-5o#2 zF`);NDMM~9SdduXu2ND!0Ok0}!Am|3d8Af-cBMk5ZdoiZ>g)2`bU&H_vGI6CW|_$j z3HBtde=Wcrg)GDW|C)Drp+?vU44g?T&Ti;s1P!}b`! zqPXG{e=ImL>*3Q^IN+1u@qdP)31#=b{rl0TGxeF}P`5yGA{CxFVSoRyIdg{pu&MZb zW@BCti^>+BPP+Wg-#Dt@#9fD`>^s;#3EmnCPMo;CaxL9wHY=#}gjab(j;0B$7e0sV zg6or&#eWN2xO(vdJ9-wNsXW)-orJzgIFHbTrv4v)XuF$UtIgc=x95=Hpeg+!`v5r3 zvkQJiVXNm|jqCS*AoM>8w>ajYRiSgv#V@!W|B-0y&~;ukPrjllrR2iJ({sPO`u?9q zXC8I?(QXd{iyHmLsHulObnWd}eBt1)0X`>Y&lFczLH`AaDl3jmn(${-=%Y<*W%b#; z77ZBmKS}qBBPwLqdH=zR=Xzo1cFs;c9{=Z--tK zxDWp{C?`YzO&E0_2nyGF3~0m!A{%doApwsIyk7rnq&UOoJg}m4Y&;i_plMdcsHFCESF=>RqA# zQ2=M3U9kveK%lGfG^;l-5{=WT)K71B?iE{`dba%*hObv`N(X;#@Km>~eHC?S!d7`Y z0N>Hh#RoX|PjLoK>%h}Tj&wSIv^qGL`pPRd*k}{{{V7+jbRJJ_4MjzgufNImj-iiU z&_Vn;^KL^?me_Yjhx@1eTD?bqruUGx4B(=(HL^NH-}eW!ZYemKs!G73h&-+ zeM?LD(jxl{FtF+to)URPXf#5ITut=aiZ$zfS01P@-p608gD^C-NUrbBm#u*4ohPVHBYkC1BA&L8NY?8;o6%4;F z&;I=4ixSQi9HnXGh9BMH`daJ*XtSgZKZvV~oTJ?WCys5uEC1MorlLH$mwMvg=0QJz zi@m)nr{X3L%ojex*fy@AK0_p`lq;*XWC}7ckQ6zX9T>yJlT7Qirdq|l)7*-CO>(Wl zxi`bMd^F9ASO-@w@EF*Es9**VOD;CH--4wQ`Nbtn|2Ad|OUA1N7T6tN48z5M=R?m9gBzHMouXdrv?ChtUYC{z*@xQH6mfNV-}k$7g=JULsuPo# z{@RIu+iO&G6~H`b%+vRMzmt30s5dm`H4Is6i!r^sW-Zly7{xW(_Nf~4PiTZvi#0F1 zhzdB+q#M`_t2KTC^#JZ;m^nVftrieb2`iL>q~SlVGTUB!;X?JjoBcm(d)_N70@FXfkCEQB)Lyjh>0A)J?Bz6$1vG`uGD`=hg%8M0bDrQ1l%WYr6SAG0t%X)v)<^pyQrFnwOyu%}S;1N%fQ)G4O zh*w)8T{3o7ps;j?Z_9Tp=gC87wCQgbojnT~hy+%E*@bDvz<2KrSw%0{1hOBeASf4t z)<3-ifjjOT-Jv$QtnPN~vX&^5``>*-^CUY2n`~xYPJYmomdBy^VlCaLWt~-huQ4Ky z?guNwU*1&~;7)2~)A#N!3t-}Dq?RB0L9S3C)$N$up{V?Zo7IlNlr?M2v(oR}Y54WK z+>mPHtL~laMRfxX81(yW$1Iuh<|Y2z>|3!ToHlG|tA(+6alqWUKHq(l{m#|$a5Da5 zI2}C1iS)kK5$YD-V32X;9;wvA899W-MlPVNfD**M|{3)J;9E4@^hAh#6(Rcg47Z@$1BROF$hc-cBGG6To?M)&{dV|xzqN=RV% z2iB#QIEHmIn9K}<(z=3^4^;wv_?$)YE4vUnpSH#_wXaEcr>u{j;P-#9v(cPCW6s|unR(m=UW+pgW>?~L5_gt@eTqLJ4 zQ9u8jziL%Jjb>V$UAidieJdhbJUw?Y!)+#@M#Vom}3 z`~HWicz42>Mdqu!yQjFs03w#sX4dM}2CYILJiSBCP2uS}sz=-ENG(xnr23#KEptTG z-Mj7CUt-jJkHJ>`EHbtBTkj+f{5MM9-4g-@v zoCMrV{{Z&)dk8uNoN3U181JBH#V-A(xb^LXtZHBP7e_b!%i^m0O$A5w+R{GDUyE9> z(At5wzFb*&`h-i2N5bk36_uo1e#WlLDq&6E_dTCZ{C)guNz#ucC;~EQl1uvn)n=&A z6e+6|NTs27Z$`h*+#FoZLvS2iiPM$`)>X=qFV_`364a*&-Qyk1G7j6rXn;yC)|nB& zj-j;NLmk9a3|du9@}q!pZEh*Di;F>LLA{U_II)uWw?T2;dYN+@m5}iKDtJn(WOFD7$h6OYTlmUH0CavWC3TzxcbvFrep#oCDQFM-RZz+wPWo z_%6}GHDLUBYaR3&jj*iFrFZAFuAtOnoud2#CzxmW6`nli*4LZr*%rFdkW=bAvIEzfyro#DG7ufwCQx3k7^O1? zw_7U}rtm%8iII^Vh%}AHAmVFc`dD3s=`@DP5if!OMj;T>BOJFrtn1}Rr4l>w5zXr7 z^y@Xw+O^qVTMZ^#bFxskbvlS9;>?q3x9eg7h%OV*!4pyezZ>&of22FCc7%2R_ z!OIZ~(j}v3xW$dN6@yrm%13F8?<}*tpxE$Z9t(wZW||MkrxP0-u&7|R`O;v2N<&m% zmbbS#yRd6lsgSG1x_ACPAWd35(DTM^Q8^wV|J2JWgLq0LjvDJQZd^-1s#?E#!V1kHgu!A%G{3L95T zVB5(Id(bqkb97rmVe$DT&e1-7mYasV>;wD7RmH=;GkrwKg%qZrvHypr`zL8*kx^%C zPIm@$CPJbgG{t1BN}AMyO8FilPck9XTXA36ycbH;MWHx3h)!WyZ z2PIs;Ubp;>iBIQzR1KXLQ^<#grf;kVv-FW|G!!@;q8 zb`1c>{=Tz>TOb+wiWiZBnYS`5=|au*Bis^!#&eKkvq8o1BZ0Z0)RiL-*=-GrM7P|D_XuDH==6(#ZsZ~VE8m@o*k*vOa@R}Kr9RNRf_1#RT zUg$R-$CDgiT$T6#92maj@qe_8dlk2M60V~1*2D7a$`l!bz+`z2itrm1gX(U{bJy4a z&@?ht;i-ou7x!iDRchs0R&c0i{NUJSZ=gjvm3Kx0fN1u1=A!Krae)sb~L-)qmT=s2bRm zf4&<8FaXhEaQ^X=wh7?DU$3dYe>3OcorqV5SnSa2^=bJh&K8{HhD`6mnyMKH1O$BR zX72T8N9k3e_mEDM5{2dUXrf*Dg#;E0jcyPK!=t@X2V6UyYAb@N-^p+)Z~$;z0M8-35CAV$!>KUOMX~mUfD54( z6Fi*#$Nfrl2=27ER@5fyg(fg_4^2{w3S!=RbKvJ&(Rj<$nuN^!%=m@!+;lf}77{G^SA@&>hYEY$`t*qAS=PZkj|hykOVhRo_j-PgsNJY`wH zXsPzxUuCtaLWv8`+L_tm)anQ3gTL=cr#q|5#8o_+SIfA;3UromIs^&14;YZVWv|6^ z(!3-1P2D4aAy{)S0>1Zp07jiJWUxzq5P`x#c0Y{uS*h;*x;%V6{ON>2&z-y{itO|p zrF!*RLw%MycxsH<_R`B~NEFg{RGa_EOo@}k&?4iQ&9kk{XxcIdp>_JR{98uK$pY-qFX>(WZ4 zHPy3Dc1Zu_KH|3zp8}AS%R>S?>#hHcX_N_vzPid6@{!6c?PnCABu`k9hr#}*mZHVVv%`Ht@qcJwF z^sqX4<;azM7v$bQ+cXbWta$8>M$hQg@-3arZe+7?baKlLTq#ojxV9ys?ZBZ52j3PQ zmS2basF3X9h9R~MCcNxIG{KyeR|@Tke4P(L zzd=wc94EK*sl9Rt0`uT42#SI2sFjw^pg@vtxesg;Cu_()grkba{n|2t<{sL|3}Axa zNi9*-rDw*kG+Cr-v9|COml@FVzc-|bLVMBJ9;P+d`9Dj0hciZwY%yGq9j**3OQw0 zGV_k^DmZzV=*T8892tK87!t|7Uq2j;<`C5Lzn4ZTtxHdr^LTaXXSUQ*+5A4v&QvT8fjD@(*58E~;z_0w z(P%3H*Gi2+DAO(+>Fz+o#P|~O>Q&8^iUtgD+_tSyDAc&Pv}LID>ct?Sk$Ddr!v4aDRO?* zy(Pkma)ysBv6o&8-^$o!wIDPzz{M*eA!FA)z~%-|>oN!2<9PT}p+QIS8r;b;54i7f zR2pOln122`^+TwOz|(zU-^U9B;HzF-rvASYC1AvYx{PEr$rewKL}67T!PDDaWBVX6 zIJHEHqqdFVSh7Ru)sprEFo5qUr~G5w_5?5hBZ`)rA&3~{=;+qLp%1t#ECGG&Y_&w9 z`TX;4{5w{0>sI~G+X|_^H`}oCDX_*|UGaBXkm>BxB_d2Wl-VOu`X7-1{O|^<+zZL^$2k?PD-G>!K;pIy1 zugf2_DzyD}x-X{cPEO>47RLcQ(mU@+dixHJA)#0@mS1#^6*S50oOGYw!o2MUCDdKS zKs^SsywR@8z682oQ{hp2dNvgn$m_Xr?{-a#BI)NNXhP$poiV@{20c%t{ZVGn(V70{ zG!@B-UU)LmYjAjTu0#H@2UIsd|FL6joT?$agzC|`Z@M;hhf&3_Sx3`OfZ^0Gi=`tD zz(E5&lG$(*MXU82z1K#My7X;9Q^r1>UzPUP?@m!2zVouHj{+w$z!cNErSNo3Q-L&O zdRsSx)`&+Unv0vZLcmc3&M}vIzIvsGjJ8VSRn{uvy}R+%bsvXc3!Qr)f6J-L_ojK< z?l~-q#S?0-ov#TCqtCJuxdQkbU|kCV1klW;-ENhG~u%Fmg_edjfG9B*~44t%yW-;GzaYP;id`k`t`b!3hAN^EnzEJcXBC16m#~UM|NaxXE@#XS?qny9_ro(M{_AF=j4+Sx`$Z6~s#QwHozO#w+{iBrs>AZ(gL)k08 ziE?mgp@n|i(XX^ry7rUwWtYmvc+w_^n@pxxdOLx(?nc#-8@2Avgt8RCK1c>TaWZKUU;?uczhZqF?ukejSQg;$Cp7zTo6pu>A2I(cdx5 ztJ8nbf1D{kJ79c7!Kufm=g-8F2>jwo3;~ZoqVWtm8jn{Bgaj51iB|P}Z$kSyqI-Dw z^LdW}BS-x73)FSU8+pPqZtzrlvRzwX(23iXkFf{f>G%MGgkW6={RdD0K~f0XX-`)a zJ<~1jUE6)CAN;@!V200~YoiaK?y~m(;W^Ng?&ECxY)u86;`2AiPO_9XDV%` zHh`Xh2G%O{TDi_>kTvFu#nmc)p;V_bAW#qriW&9JfxnmLNeY_e zO7$S(>6mAi$Fm<`LFByIG_%O+Rh$P+dB+;^j-El|aX2c=EiTHXw?EV^01CO8MOF#r zRrhjH7)ev%4Sr#L#^2iwS{=pB8HL4&tLlktM}t<&@b$(sSVWc+7!y!3dGrW@LLpIL7Vy)`C6bzI41u6iYxHWhg3nhpHtE$0Jd@FwnoV$HV~9AWzYB>? z>h(r{y4MstMGIcyC9L5k0sIm#@xOw5WnzI`3tpsgr1wjdtOj*rBCM32 z!lIF2X_;RwO^9Gl>gUvOr>1#(dAW>E$5JTft}ZVZLV2#(A_0seqKOzJzeH*+RRu#r z>6C`-eU&x{V8~R})wlRUV&>k<{Xd!w*@4hm18%L{ck{E4C+*q?Jx`?m{u^ZUatN6M zy%1<*b^L2a z^cWZl{P>AK-Uo)P8T<9(QF&Lw4)5J*H=SC`^tX#GAUiuDkX=@xA~3umgbI3b(EXRQ z4|@cL?$k#_)e@MtwA~DPHJ)zS0ffTf8nVhg2eo_C zyEqR0D8!C3S%MO2*j!J?&^F%s?wVYkiX1YdEl0EZUWwo6DenC`WN{7pYBeV*ORw#u z0=GuMKP34HhwxKFgl}uUTgduzXV8LAN;lP7215EvnguRfIE*=iNwEbbZ zLJh3BdKrRNw5#dMfIGOu!S8WTB*(|W33g-Ptm50SQuGp77aDv~L*Z`!a_|uNHT1x! zF0sElaa#s(@8z#VzhPC8xG^s`{jcA7xsSbv1bQZPh|9@6bhqqEqWb{+v_t*Pj(W7Y zxb$+Jf~R(hqBT2t%>amDVV(r1H--jMgZeN7W4f8~=HN;kHqGPgu8LZ%p?$toE2!>- z`YdxXf2T+a=V6-+3_IK_NBaPf+@Ukuh)-6ahp003X#&_GWNP&tKmh;xFUa}8p)ht4 zV@NC!iM6YweZ|e2cxKm<7?QV~R*9=#xi|nze(r1P(#t~UUIY0>+{7PGsW^=eef*=} z9L?}?Vg%$epKLrt}!eUhIUbQ;6R;1}&%-!&(A`mVe`&Q~v; z*ojFzXNb%FT;b|3G7AgEt5;iAFM-6cp;!@(b2UG<}}92(q( zOVn?IBjJ!Rvz1`|z(OSP3r3K)=b-mmH`Qlsp?ecTXSLJ=uDo-_cZ6x6sJxxcFUg;M z+Qc1l9-6pGztJuHw34IjJA_Sv=be&d{)|Z;&AGg_waP0OYo^DxS@;(oHTY3*u^w$^ zA>d6F4D{yTI@oFF&T-d&ni<=On&s4maU)NzN|DH2aTvA1m?=|v<1re8k+0P|Vo|;X zTr7o1LUke6d?|}ntRJY-1d6+L{Tp!1{AR1^Jj+c)fetqtI-;X~IO-Nvaws~{GpF2yd^Tabh z4sr4%t=JS(o-KTMu{P&74zoAWduR)J*&L83ERz+Ttxexp9sHV8;6&G;DdsB(x4taU z)DrEn>4~>jB9O%d4D)poP$X0LS6gjXieb7BpE- z_p!Aq*QrFJiqm7Zeh=Bi?XSL|x}n?$bg~>SKL5D*-0g^0SqZB#g5-=vVh{ue9AIJn$2dcs-4U#yuC6^z&})opv8f;$;Dbe#a!Iukw3|`{ssh*BFG7-<51ii$68Nkm5 zb=kfB-0_kCeu>_lR{zZ;2QX+aLG{iy?- z)*q=7$#s+Zv58pJjT?1EMUs^(bB`VCPkzeV)vupp@?XW)E+8z9m7LCptivn8vG7c7 zLzc)X%2vqgoU;QDj(wE8);n(FzP~;#yBM$Sz&-syZOs%G#*<5-hHoRYp%vtAp`3;2bbWjg(D%J zQP%;un?*1U90Ak4t)Rl0zSU3E&%ldVcq1Klh>U_gBYChVv>eQ7@FfA?C|Kpb0n1JQ z8-c*S!-^X0H@a)NzXnB9s{_P$#0ZOh^cv0WRbOW9_}ybzMEuGrV2R$kOC^w{?>vh{ z3F6*&>-T;KT8m#Iz4UfkQ?9~m7}GP}f@?!mA+LQXP}l2+&2%0y&l~y72_nIbDb5NTfIckA-?x8fjT8F~5SaA$LhsxWx+7rORQ>Whf zdaK1xbO;|&|L~kq{f`sCz|CYAPl7-oG4@qVs7p&BGP@95pS}N;pi8@{$}s5 z)GF{E14D0pu{U^XKlgq?b?IeyzTD3Ubd7sw0$}mE4+{YSTy`am?Cj|?%$Ccc`20mQ z&fA_3mDQ9%(A!WCpb(BS=@K5pgHn&+B#K<+KlX-=9^6O2cnyxDxR_68$y+x$hP$+< z1d7i+qPQ67?F3iB^Hp(!*FOChKvS>k{fc+{Et$6)#*Q%OqB1jubsWXJuUalW3%FIC z&gjduD}13F(CQ7jb@Ez?rnfuAlG19=000mGNklaAH87F!(E?eCd~B;?qkbhj~n9(S|eAHeV><~ zCdj)hz`J4Dk#ve{&*-aj9`a?yayk_~a3p)oLf=mNXWXjS^3-3hv@Fb1S}Hw$xN1s_ zh0*?Y27j=R1+}Wpce!X5cC4j(!;i1mhal_~5|9}5&q)HhN+nE`7cO$dCD3iL?>DuI zg$p~#16(}C8QX4ca=%>c*G?yfBT#=jsl_I_m&jv25qz#H6e|=4*C$NBs6yr(-DZV0 z=E>>aE%U5VZ&WnNU1HjlB$^5eF+|HO0*2DG3IA9#JSt1@R^hi!vJe=-ad8d(VfCkSr zg$Ud~%~3U|TD5b2`?WC4nbhs14Kh$$#NV5!Uzq3#HV5qNQpsRt3}+GO24VE4?>{>@rZB| z-T{lY5fjh~PdxS)AD0^f>DjLf_xx5eZ;AhhAGQR|%$@5!YnIo#bvZ-V77Y)?A6*cN zKp+4U{Ok09zmjS<-)ne~Ct5hdHPD^($xPqt8T@kxD^H)Onl;x43@`7$A2NHk_v$rS z`&MQ9#xmolxcf#jKr71bkM>53#?agOe!GO(b!K@$U3D#!c{22P*WsrDv)SodO#Q}xjK?J21i9XM>A-i#K1{z?c=t4 z7C}?onnOUqlMcKDg3?EYCnZ=4*(oZ-H7?L8(g%7r;F%O>X3Kx=+^hS)<4gS4p%N*I z&nC$mYZXm=Q#k$ye?$K=yd;2M;wAo9P}-=R6v0yK3|hUhzc=N2l^AUFaw1^SNTo_o zuo98&>q7ivMX*k9@^c%thQ!3ix8DwW;|>4z)qh*@=9~TpPE<;T8VbXLT2xemW8#f^ zz1o1XQt1juLlYe^>3^4x-PVanV4vllkGIyyYWZYWyTmrVhXhyMy#@TaC@dCw4k$Qz zQYF$b{6;`81QJfNurL`i*eMG7?}w*R8&H$-r&q$~T|JV-jxcDMDcgTdSpBt)ZZuD? zu-Wr-4*q4000xMDV^qmSX5x>J`hRG6-Z6>RP;`#(J=Csx9(paXHSHp*iEOrBBZImQ zGzp_Mg~PuX0vG{TJnc>-=Kl*MLQp@b>p)-x3}0Krj#^wxarNKw*|VI@TMP9#q(@)Y ztO=GY^Bvq!U|?YUM|EJP!NL-WLiGEf11*>QOR0O`UjF0yv~DTCo)bRrHE~ttr8nOY z-n|>~)#}LCUZZ(==N~_ZXIReiX+=ZUIfLBf#6&@WLN^)GMhJ0LIx)FmfXvX1wd+M# z^BE0uvA;t-05%-w4hAu^5fw~-Qy}OMI3Y#^Q!=l^8$W=hp;tqY6Ra0KxLw7~(=ZeK zDg10shM*TT$Jq%4_C1QfW0%-p&ck11b+-|kHhp{Wqd(p4-nZY>B;Qv{c?sXmXGe4x zm(q7!QShrAf(*ldh(>obgzm4)ue{6C%5>37Jf9XX6;~;06ZuRgcF<7vz)5Zda*xRc zkA2+_5L8Kb8VM{MaQc|-rFjwZj&j-)z+`7U%^RI}>|W&j7MhUm!>+oU+5V`Y$&*w6 z*w>JK$SLv_vlDs_`n)Ocrk1x2zFYo5G(E{;?-T|U#xC1WSYGNo!d^F!PN`|k&5M28 z-7(~Od92!_>}sP^WN$^2n82d=j_8FZVEsnNcn^svJoD)Cl5H3U1w+7v%oxZo2Hl3* zJ{2#wf*D{(`fq)XUWMvzz328whC@uclmTpjrR;ks{TFkT721iwhF)C}IKe#fMpDC7 zOPYcv_i4XT%qfX^vxBcc_i#1Y72keo)0i)zlhGj^X-;qp&YIiE$1d@;s8;nfD)K~`*Xq}P9x-&NWl7=Ht1^Sx zgn{Aa0c!bGGAxAnc$fv%>w6r@xP=zRnFkMeqH5)MmR%R7Ga4}n6am@uuDm9#B1v8{ zl!6$>u$2dPT_8cbW8Qwd?YX{jqfRB#E*{rr6P63rV?$d0og%IrOF#~I^0n7CsD@2! z=~?iIuWL79%IZ|UBirOs`9-R**)5;K%d5gt=!AC5A5mq6MrL^;z)}b*(<-JLNmRCJ zGd+^<5iK~hJ^O3WDP;|p`n=sH1}iAb_Z?vh8YsV(PV>+X{0vt9Oy)~#R2~CcMg}!k zHFSClBVJ!5ef_l-C8SH2YSJGzE$H9!B_!pG@J7VS5pAjDw@ZW%gz`_mitzPqyVO&s zs&}r>?<+x1b?%(~eISzE6{$E{gxZMhMF+tr!#&y>V42pR( zyDsYMvi54_sk`-FuEe=RT}DQ;Ks#`&_Vl633+Ff=d=xrvoZDZ2_B(d0>hxL8p*1-U zzT{D_`S?XKKufw%Qm8X3aSQ3)>vP+K??y|Fb;raFt^?Eoib^;Dio~ z7soK#$PE!-FfY0I==9vHWLM|76;o(l&%Jz@4d~UnpefxKmcx?B8Y!W>{jnQC|ge0-?{^MLsf5$jOJq_?Zw|>>t_ZyS97G&R z_YZ+y;w8EQd4rJiu!vvGmDM#69U(Mc6EMNiH8#+i?&&{^p0^rxI(WoJB zn120Byu?d93v>n}i-cZqurkaUO~4?H2uP_0?iHx2L8;Lh2zXnMDjJ1oE)wj)z{_M> zM@O7WrRVe2OeXGWT^R}1wFKDLj zuejNmd*oKX5A7<|<~u5ylT>o!(@#ck-U2-baFZTkNdeG{LVe}|y%e~gUfd@>Zy42+ zl}!(zZh_`Gvh3Qfgw>sN>;KWmB^S@9Y`@TFxz!4n0pnxyj;bF1oIZq%ww;FlJn-QA z0wjhBT)OsjR!g+*{p_L)NsfR3maE0pI9k`#t?Axzs+wY>26`a~0`3I}>OA%N|0Di@ z`R^4_mvIY8AmSO`HhLIzatL9txQpcED&bT;wb8k$zi$RF*Sg_p0o%<8Cck958jN)#86<`2F|dyAOC7IYQ?^%GBjnIfMN_ zEN@c?tW7OZ^0gy=+HiQ}aKQe8EjizQL-X>& zGje#jWx-Ro+qgWfoG0gid-bbuY{qKu)gP5_<>*>Gx!tP%yB79J^oO7so%tN?2MI3y z`?;l}T!Tz(wW-`Mm_fb;9jU2=~!w!ZZp8C`L+sQUgr&p~a9m+Ww#>if5zCV&B4`^~Suy2GfR3&$D! zGeBVF1eRS^kVe*EXPO)mgTyoJeGK!D-6y;1doOtr#S3av1+sc%#H+CeogO%al>#Zn z%{1DeaG0>!gMudatw}CwD&&@4Nt4!yXP&SX4_AEtyh>zn?K8g94;7SLG%D?>quD7Q z!O<;U!jEvd=&q_pTf{>o89bOD*yvH4`X||i`gGOg?7e#lObp$-%>|kE=VgkEE_7zw zQ&#g>z)j-&kL^GSi%Vp2y*q^H-9N=~V&Ebp%?-;(_j&O_maR>RTvuJMS~J_;sk5v2 z^+C<7Xon^JO|t+UUwJH(*PZ}w#^1ApKEF`D;}U0NZ&pHtt)Rn!VGJK9;%^5lGBX6n zj$0MZedQIOe*GL@o&Vs_y4=2#-25Ut_l^ym2CzAOOhw5q@+M)_gJhpj@mcAV2=tf+Fbs`#h?^2wBbsH%FaD zYv^)y7rSsnQpiNAUw7%m^Pa3%4e6*5{_tU=53$Wk)aVT|jUn3qiL1R)63>E7w1A%>?>`M%sxVint(o65S#5#q|ZmCXn{o{}1^&IDDlL=9i0*IoP0kG&? zEwJ~SljLfW1r98192`UW_;M{$ZFuv|mg1BL_LTclaa2-^pY~2Be{PgxfMZL-bZ(t| zb8_wE*Zn563y$2qyKL_-g?;N$-(vQq?#@Z$K+KX(u&b}sd z5lWVDvgEQOqNZpp${UXuNGFF7Z3T!V(8&o9`E;FV{i?jgd%U;a4r&gk0OR5DGeb_F z=KTC!&i%c43u2t6L^z<4h;PUG_Op-dBaSf{@casDN~X@{@8E* z`si7+yg>W=@6r>etN;8ko#{@Tai^*v000mGNklTiFQTa(Nem8&T3Sg`Kso^JC$3l!GGScBHOMakEwL|SRB^6}OZozif5c?QFe=rPcN z=1HWu5&TB=Fdqzj18}zN4H_n15itupd210B2?w+`Rpup4UVso6I|z zOz!I9G;DDHsEC(g6ff}-|1f|(y>7hk*JBKsHS$C04KkHpr8aCkSM@Mo{>^-E0@ki^ z%7KO{%s1r?U%y_raby043&WmPfsP2Lr&U_b$?)_cSKMr{@evePJeGt?`L$%gr=18+ zj{YTTXWzu4vyXx%+eL!~PwA6)B(YPi;O?O|HJ9PP1bQJ*3d+T$X^>$z^#2MIWYxcF zq;*(Imo=0e!)A)B&pzI^HSXO`D749Y_FY&qA8cBQ8&Ve=HOd#l_Y8)h=MtRp zug7T!N`attFlpCSTqV19l0&1B@gSq+;T?^`$s46+8%bT6jlKxy z{8a#7qtUJO?24kFXhy|v?n}-zRvERiE-2%A-_Or9PUh;oq3+`=;Ms$q z&j0}j^(0(j=~Rna0ton7>uR-Wu;g9?mOQX{+9Wt3_}P;Jp9QH1%$Jk^AN$!~K=265 zPOt(UX6I!A8zAH7pEXsLU^}*5`maH+zyIwwWmnHR^>U5dzS!&x`Ntk`9;P~mLT@}8 zWVY+U`uvnF70gg#)B+1*Z1K5TLz6BtmOgDo=e1O;izVe*B1boZMs3il^!z#{j)>N( z^$k@D?+98*9Lv$oszC8On_~X@I=k>xP4Ki1-f#>NMP%cO&egOhfc?k0JpB2wf+r@j zTfFnEK*!WSes50z4_)`&-+cy)%5Tzr&F>}WUav{HIf9I3knuS54*`KLBsz|^udh)f zm)O%!E0Ysh_EMF+yyBqAFCzCBAoaIWqN5jvWTFiMCdA(QVy{!QZ^-lkG*4505*m;7 z8XN)I!=FwCPU<8qlKbc}hGdsay`bd2QP)-7W{Y?Xi_hDSov;}GN~lMGTJI1(%(fC{ zQ^Ee;Z_Rpod+FtKfs@-5ZnX-Hu&iX{&mDq4(sz_$$>?Uq(Pl_%lnAMQ_?!;P0rkbw z&)zm40S+Io#3K;#;g(6z-PgJOJjtO>-LMVJaWbpXhl!8r$nUCjMt53EdNC1;l4*~@836Sh7Gy){f{wy`&y0;@%1GqC5~CTEcxVy z;>cl+V;0#p{UMPE?@$_O3DZp!6sJT|ohK1Cs_H7_P?JVpCRa%HoXdQz%!sEU$WCZc zwT|M1BGJ*_e&oPGY-b-b+r10w?#(#xUEah|F59;BvA8!Wsp-s#s&P>kzLTVUQBIj` z%i9*xd{5^$4IF9Ni+$%5cQDD`ij_K6rq9bZNAYWeR30!@4Jy1rULaBMLz7byqrT?WnscsE#l9@ zigI3VO6V+;ep3GN2Sf)&^cyB$C|Ky_9p(BAYvCi+9A*5IB-G-^{5j)Wf((BAF;7^d zm_N3~ocQ}P2av&YW7<+tzqnE{V3J#_qB6j5zZHM?RqkvAVZ5WQB*Ob5*N33l>i#p+llb@c+c^D zR5u^-g9FdKxy1kDk3#`v4%*$j4ZrOu`C)!?NPp(!w*weX_z7?NftGx!HZuT3LA$=G zDPv0|H3;9TArIL1X1RXuBYx)Ys?=XAL+7}-^hx+O&6^Oj^#7H18i9ocjp5f~_5co;Un1osHkDt~=~RZZ zuco>5qcZ);XdEhRj;C|49!u45ill=U@v7;|tVF>HfrQ*syoY|H%hsUBxj~hsn#hq8kv&lNCTZ4^Quh2 zUPdFoq!xq6BC!|}o2J)j!5RmG!@rD1;HRRKof$|p3K#>dAUC~Qi>DKH;4hVgKp{19 z6@f`bqL2o?0fj-CDgeQ;J9@1S9AePvjd}yvg+L;e0txVrD)=Ai2}R;7p#^@O}ZGqUocB7#*a^_QQS;%fu~QAt_lv6B~zODm>N9)m=7n+fL}d~5mcN}FB0 z=k5A)mS^Xk2d}yMQL7iQ`rz`{IrDCOZjZiY()!WgL#xlN9N2!#!-RHcdx@9$_XC4M z_VJ{E23|j|ktKvP-x}wMMcbQ(|v{J!^R9=@dPJ+LXNp zMdbgd^j{cwgVMJ?bFb&1E~`KTP(3D8-nkannHnX{(<|z=rMX9Uu*1EYjR}hgnK96y z*F5+>um4Ae_IZTQ6}MLAvw{PCN77Qek|g|+(sia&wl2!b}id6``XlE@`Hcf#Z$(FlSSsc<}l0XSf@jZA;% zx-<(j0y~0MaFPo>rR>P?BiAJf&;MOI@k!#s(-^(b7g}g?*FHGG`T38cWy{kdA}PTE zR3j3RnJK8QRw7W4vnNp|)sh`?0YliaqijWi)RxOm{9YnMLcz1l=b+jY0dT|*{JcZZ z;p1&32AzZdxVFf2VADeo1w$Yd?%Uh;j|?8{HDpNAroX)sel)oKgzs>=^60<4>R5t=3hI=pdCpoq}@_0Ii9f2*snGrsBNb?rAzW$lJ67^aw3e%E_ z1;z)rzL8A@N1rBu0lbHPD&@@8HvR+$1iX0MN8%xe;K}xM)hYxSqJ3dZ2!vT>bXzd0 zVwZ+O&j7VpTk}|;Q|rAF9C1`zTAC^m+Gj)y*Wx)SoZ;&N+%DLTNmsdt@71SPOxV#$ z$-ug_5~lxWkR8yga6MFoSJ$x^xovEK`yYbiPM#fiuxz4xe_LgYIf>t~BfR^6WUl{w zckLRIGu|z(EepBmY$ArFY+u`3CDZ_4q1odmDwVmo;*sb!3oS3H5ur9ro@|~)dggeQ zmlcI)lb=dum_4uuOi+(RcANNMr zGU+`6sGvQ)-Q-vlYLs#v4fv6tL1rJE=iKI^a6hUw_7~v0wOMQ&x?hL|AN1`E{!m{k z_9-j9q)}0aFnqJzLNL(&{pH9SO+3xsU%XzU1?(!+qez$5kPy;OygCop$VR9KcFcO$ z>5M9gcINQ5>o4OgG41kTR2l;b({lSMmHPHd7+}_G)&0_`^r&{SWZ*6E;I_U6K3t=* zL*lb@Ote8~m91fg4r-8GG$?w807ep4Q2acDr*+8vg^UJa`H}d~-=ny;JV7@<|A6d_ zaqAnT7ApiLIep)2VR^5AoJn!PQ9VqP5jU}kMa9I8G3_cyZgOSdKKaBnn}|f(Yv;M6 zf?9$?E~PZqHK@L7SNeDTJr2{K^y7xM)#4w26d&JMG#5#nSdL zD3&h|0`2?nbAP{Dw>z!j^C2EVY$6zWmM6IVa+`DHS`Chbervp^7aRZjFt_9P>W}Oy zJ9(t?vsDpOrg#h<>;&4aTlIhJEd6z9N>E>xXCytSH?!|JSI`O{^V4rNR9xdVQBV*A z17_Ri3LWnKSfB~Z&;(_&}Bjl|gcaXCppkzELGaaIdO-(CB)w6=Kg*H)wPY91GtU%MJM zn;HE^7s7wbuGZ%su4*cff+5g3ica_K@#9}la{vGk07*naRK?@J4rl_h{n|<+)io7K z8}lTxdR65ep14F!4+5ktoy5ko!)bvNT~XL>^G$7V>CFJG`KJLNS9O20SjwiJ_56nhNT%6arW- z8MJzhOrsPi^eP>}fha5I8-)v4xYgJ&n6;~s{uM9rIXn;5m^jd`ubVCzRDuLFZ zGm>5K;tIJ@2RTGi^=iQE8pQ=7a6%LXqnBw>WHiB%fM*~Dg%V~ESzM*0dJ^S46$*m@ zSBSzP!BxrXuxJ8U|Dv=?1quschzOtIXd(+Is%H3&bRw{@I0~lu%Ky9QnVa?G*(-9n z@}u__gWtTYyt=HS+Rw+6$)xvX_6qR#Id|~}poeEn9ox;bLmsdGdG6JA?;rdyX_v?L zU9VcxMDkel_jU_AImcdqlhf{f(t&-xYpyo4zb1b7mRbLyU$k+sS}6`o28o@Q5>(vWF= zi_V_yaRRs@CmTz7K{^L&NsS=yBBb95{l9}zo4_wR=h+=13}T1RDL!{R{jVJ{i#y4~ z4u9p9n#W05d+zpLYAb^+Z1&LnW0-sEGQ1KR&E%n;2QI&pUYBkh@uOY7Y{(=xTH_GZ zgdv1@YT-!!sH7U zHL6TPj=0WQ6uwJwHH;Rc@dimB#$-~4gbC^e0!8=j!|KjobcPYoCdK?btP7828au_kQMKKp2uEyVv;I`@4-QFqztwQQD(8 zL-n@T)u@p*F#PP2RmD;$TBX{ao@$8#cscNlZW1uS336w3ZtkvHj}dg>%ec2TZ|2E? zG*8RFe#&p9Y6+1Y-sZKZxUrfgpxZ;vlSD3jy_JNph~zDswQ?-UDGcfif|5%H6`_Y> z4RvgGRe8raAD_X3Y|X`y7@E0*Iqj^3$rr(pa0bh#)XU~^?E zJ)MZgKVhIN1tuFVlPhnOY1tGDckIPHJ^_s!=wmCLeR8-%FIVzcS1SIx$XPtT6IJTB z$9bMkY}m2A1dT*|^odmy8ZbAY9XeF;-4D5!ek<|q!wl~8thzhE{|JUb(4c2PdA9J; zX>Oo9>EHpYasF>CdMK<_t(s+Fct3QjmV`unJI+G-@5uswa37|drFfvE8};5Q6lC`g zUait#5k09u(5RKyYU}1X5eS_{m)2>t#Tfmr)g7`SZ{Mz`VNgtBn*kX_}~T5uR7 zliBjS$Yk2~N-#JgTCQw~ywPh7!1LSw%*b{uGS%Hd(I{`f*C9BB?84?3oo5AEkYn|X z|5RL+r6_+LGFq#1VJD6viN}c^#NcTM%yyJse# zU7o!DcvIaCAwPs*Dgf+dDcBk;Bj!6ZUok=_QiN|$f8Z5PUHwCJM1*OS{^E=9MT`75 zZpd49p)8b!|8-gb27&l=m}iwx^}*@V9}brJx)T?Ua+{gp47jzcX-%KJoA&$G!hu7a zRUtvk{WNF;N;$HV0aRT&G8D3V}WKg8t)#jF3b>?AFDx9QDiS7JHqsh zG5ko)>4w}#{M6q{AO2MAF@O>Invc)0wirjIA9?JrCdM7mPJo2VJ63sN(FTv9{&b%J zr$}$QPmjbZS%A;*h{9738uRlULpz_cEu#M?9~~MyUC~%XbnIM#rWLHvfxO3ujcWS| zV1V!7dNE(aaiae!d;u4}*=}Fwas=*{*Wll6^bl3t6_%cyar~gQ4#nrsXYBYR?gOi| zeO+2<>E&wy<0IHC|jd@})R!D1jqDpSjS(TzuCo7Yo zs5puz1AHyu`*95Q#1QF9frK65WlDS1=)iV{w-XX;nhkLjJcQ`^GC=L27Ay2>jY^`Z zODiKg(N(56q#CV4t(A)nIvo;i5Ehs;GiU)6B*tiphA|R}Y&3~2_a5d%WK#56Bhe8@ zaU)=eD4j}AbtBp?%lH!iIrPATnw3+KnUxRNUnT>dM;SDsPz3q3I3~cxN#jRs-11j| zzxRvMg68agbCMNx)V5ri>VpTM)!XA?Lic9EJpYcgF`OLScG!dacRdrfoBkzE(oJaI znwRYFOZ?XWmi@qV{$PmPS$}$NoqWr^no_>Hpjxr^VA+>*d`Z@80LxYSJ=bf}O61ey z9cr3Xw;oAGj=T-FU%E6n){?tFB!s4x>ug*J(*a*rF1JAdL$G3D{s~T}1Ta9vD=xW5 zO5p;Eox`nPaM8K+^uPa#d2=z;bEv%ai0<fc+i$x5@k8kX_CSG*1-6$?F6dmYU%Y8I3f~y&rk| zN{GxmqQM_{{9@!FZ_Djepf$q@nEPL6sYdUA0>Ij@gkChV5V&t?LF~Xu*AU3gV{iFI z=V~5rqIpr{-gz@*hK1}0K>Ze;K7D-V)cW*f-;q)MJ}{rev-aGmP0e9PAmdi}`;O=! zmIzk(w^ruVJm%x5n1Bi9`EcMdQJ2bHobk)|#NtU7)>)Z%8t$Fo$OZZZuX&9h>o#PF zy-JMDe95oB7VY@Eq#|1sGs-z~ka-?<{1kV!N}rZ1YEJ+IFayZIX93(d=qGOXpvi7| zN9)S3rMD_icnk^m~8ly>GW0VwWJ%=8vs={||suweH>uqvk&JVu2*) z(0$*sv$IYu*2wtw^dvACc4h!0Q7B}0i##=CiB8OZ#EzgYE*M62GcluC`zrH}RuA~Z zvhq#Q*(R5m&IP9wP3rz1buxQ$AEn|MeIPp^YsfVydP-?VYF~l#%@EWC3xV_$cA`j? zF1>9{=xe1bSj~1pSEwH=*%NDK@&KkLnJ}fJsyG8f#DEU$Z2$p z*=(q2R1bg0e4u!6j~DJ{IRHqfHLm-&)_0aRwknjpEkB1qdIHPpmV z+F}irVl9H!GFM_rXbGpSY%k55q!zbXsw5|R&7*v?>v4$*sFkhAvftxdZ-WbIt=nwwN8swkX( zv}L9FXmc@`g>mq|S$U%&ybmL+zbRWf?Pfg{jr{djQ-s;RedWpbc^^;kZa(Dry?S3d zZq<`<;6fh%M452Qj=rsV)qnj}^5d%9DF%WK_IGD3|A7p0KsiohKKF9<1SC3^;vNgR z7b+Sv5w{0Ua(U8HFi=eX-;Jz{&~#=P$98B7~8%L}yXB1zh{y2@f{X}%QWf^&{y zH2)~>{Y+1vqDfQxn169;x?W{qg^&_fg|=4$1Jqa40&md3>%!ADHOV;{yOOkWG&8`9 z?CcmcxgTJ9pq_*G&`4Rm=)#gkeU^U^K6ftEb+7^g!{UnC7d%(E|M|pn^lnv~aw&T|l4@IrN%LakHh#EGRh^JaxQltW`Ve{&#D< zhljS;4r!0ZysG@;mm*$e^jU7N8|UuYydAB74gdfU07*naRFVs|Mk5scuBTaW%(Fnw zQ;VzQ(pnXsfzirzYN_6!Fc{vz&B9JOT)+&WcEEx-a5^1fpnQsPvbxN&9t}&=} zdZh-5Lt@Anol-|&nJ9TV4^MJLVu)(0I~GsJ`j7Pl%Ru+O(Jz3le~EuBdf>Oee(OR1 zzA?@0?;n336bi)*Mu(@OnFGFkH|a$Yz;cmm{W2%G1@H|(>*}tTc-|;&Qu;d*Mh2OD_tEZ@ zNLLb9r0E+;KBH( z^ME}(hzhArF42;#$l1C3kt?_g$R=2U6k&x7vI7yXdKI0`0Opiqm|cz%rzlU(gF8J) z0INhIMU&L6?+Y?^#Z_gHem?a76u{dkD7o7`0@y9?o$?zgrI#=JkL_G-B6!Noq>bwx zg54=@ws=jqzCoa+{&sr*h@`&nd4iilfcLO2=q6#wRF~LT`}IJpue9oDsDPtg@l*0!b*Q`^0tH&7cEI z5z9^=wNy%SvM}=F>6H3(p&0=TnGz`+%C1=!@CU4Lelh3Z_j#{=(ak9JPI2~+l7d2s z<3LukdY&1I&epp0ruF}*Lxg)nwkZENKlbeoG3+Jh(zFF7?Y98-*K6?LyKC3zRCrcU z+r4MhsUfhQCA&Ikctg@QF;$K6k_YQ*9j@?ifc37yh#bne-Yo*S27>lO&;l?WLH||A zVZpZ-i9{rEJuS#DaN@7_^e#H{1`=HxI&;Xd@77!EleF>Y!c&Jy&e%!2&2wA%McmBY z7g0EA^qcIscLU5~wQqfylXsNM3M4s4GtAP>!3tlPCt=`GqZjyu_BWqgqylxujfR56 z#>fc9!dc#5f8EVua__to1lqD?$@flkIYrX(Z<;^g!{>MveC@{eqjHe>(b?wu<6&X_TY}iqnpCwUxBn-Xp5DbxzV8~_ z;mHzLbKLt;J8n@xMqqq)m_|iqvIzeUY)9su1}X_NW{i0b`t9p=i-%ZFOh5mK7r?~# z_3B!FX8})fwNP;L?QjMW{h(azKr-C}I)gE%PX5*aSGC@7I;(Mb03FeBL_40@80$%n za3ukHaz|3_)JTVkVYUq8%~Slpx?J(cMedk5*4S8Edd2>3#2*#}9K2gMdc=*-R)&us zZ!4&G^X6C%N3nWM*3~~sS$^cMDnj=N%Cdxa4p&8o(N7&8)UTh_FCi;S_{a9*gkWlr zhXtZ~=Vgu)5k0e)g>U9U5kI^S%hlC_4RiT&t-k_A?@FEx27#g?pDEF~J+6xjT+pgl zob0OXAi*2^#+&z)%XOX|!wU##B$e3aX#}saPnb(U9s@Ub>n^4?yRdNp|@p5z6;cb!7VRPmboP15fO{ThA7-ZmU7XkTpWk>rfYP z9D)0l;2uhKAL}*Pynv{zK8ut1wb$T~5kLIWJlU?R)%TNqhEs_w z({FVB<19cEjdJ>(UDED0<&0A66XBLB?VweY+N&wM0CLVk2C!= zfYAXQ68ParPPouno{{s+jo#p?Zq11bnR_Z4GPw;|#bs9?kl4;all!~$?m~#RZG_KF zD5a;R|Md$mH*4s+?-5MeQ(k>Cez_tXw^>1N* z;U8D?0xtTa5EKU&m9P^>uujG9eK*?ZQJ40YMwT;jW0JKF4}ac3bihQvX?blZD5)tr zdo64Z-A?uw+*+x>m0^e|z&O&ppS8%SMydmMHCP%Ll?G+KmdXSyFGRzm+}H$m1fK4I z!{AVC4P@P@N|}yRC=v5D^%XK29<5OudFi6cN;QFw!ctLcoe@n( z<0u%48-eJ6>p@Bzi9yhPNT9WDQHym=g%VMPtf@efcZ8#8(6PdZOkc8Rf}?93^Jzy% zw?THs1C!p)&MJ}Cpfjj}x4d3OU}5!YgG!_UBr#Z{sl=w-Yjzk-uQgQPSGe{;gV|78 zqd^n&fG5U~(6Ty+=7|R5KwUZ#khox@XS^dI!sQKWb|ek3>|hI){F2=Lhhfk|S~*%J zVymlyO}Q=qGCb@3q0{Lbcuky)>0bG6WZuM=>2vZ&-V0J(9gpw>A)$(g`4(KLM}|s}pM^DjgM%*6IyqjY=#Uq0$(#vqhw z7mjpypkZbwIG<1Ep<(^c0RtSjZ7UQCH7+g|*0Br*wxLSy8(}%^EP9wz@`FaB0YX@x z@qv}YFO`(LB3xFv)R5#;!f~5WvxIyxEwS^XQlJBY$4hY3JCtr#{|xKK0LX zUY>Z!+Hk1nz{?RQ{#fqPJ2G-X*H%hOzQVn~U0LtVpg6l|6_=rIff+DBQJrkG%O(Eo zSOmuocd7Ewe-69gbf}-6h5ZFq3<6W^7PqO>ei}Isg}0n;izSowY72uu8cz{dOP=07 z@bi%T6E)Td53f<|;tLHYHy2KSzw`3#T|z~k=-R=m`bKrY3^x?c!n9F(sSd37rX4bW zBCfob?cTqaOH7*|IsfQ=(c_}_ea2tE3D{roqU7Q&rhkXnn%>zvoa8l^2d>-N@0Vfu zySZfV>6PX6{OKk-2{QT;O7rC1D|YoeoI(5FH6DurLkde+zc5@yQuZ=K!~I6v<;SVM zzY0yLN%-nBztNUmFcmj%X6*U{f`q-74yJjTd$IG6-RCByh>Hd8!)QfkxV4Y@m)%~K!+9Wd?Mr{U;%~`?I-&vU*WY2tQ{v2}nZl%VH446ZV4TAT%Y)x~s|OM* zfB(Jj`SaYLHy1oO#fhJ0&ThuWvv1w1Z%+V2roo(2pCtrt1=JHSe82YttM8@c9J~|p z>X7Cw;dA1%52RH;xbGR?CQbj^&6~Ri4lld%k>3~#uCd71&ef&wtbO>mPVgAc|9I{| zV2`n*rr6(Kr>rA7b{;MRnWRM6(%N#!K47WD#yoLX3E-?f6=AbI+q1viB!0@a(rJhL zS?phRPvRDrVEdd#gF#p(kXCUwGiZ#+$mvIq(Y?H%?)LDfA5}s%p6&zL0j*5cbIRUe zaK31>gAjZ%a0FO)Y(Fd5i+7?r_p&9=!+H2q!1z%V7jqW25xFWSmXTZYiHSoGdq;{j@2K&+fPU5|NyabTJz1&-p zcV!y=jUu5?srUXSBitFYe30v~fX;O(z8&j>K~_G<7AaJQ>G7Q`(_S6yvi)4u#_w~v zT!rnS&%=YbduRVkmumjlRl4cT$MMtMSP^OcY44?Sr&85&6@;fm5!BkYG+zn<)llD(+RJ9+^*WI5u#>U<$a7z}-R9#P0F$u)}9R-HBgxUQ}_!CB|jUmX}5)Jv|1RG|#j4+|Vi;x{*G;lj?6D))R> zeEmH4ozKE1PBeWFD^>*0n&th&H`&vU6!v!{eLcn-Y@E~EX<9GGKN4$?U$1py%R#^!CcU+{0BK0~}(O_>mmVqb_(^r07*naRQgWJZ7Mo@(=#5~cSYw* z0AO;Qp7)5CBS8~TKEv%DnV*JAs0Cvwzeonj3eBH5vKjy(3!U4RwL&9 zV*~q3ScF~!$$5?{ZU9>x{qy)S5DqS7(}fz}FAmT8jMdcElgXqNOBa+?R9(OM0Brf< zledLJacV{mApB;{nBWI%K{J_j5{U>pdwF)Q>H5s*JOkMC)$KQ4HPsTnH>kCzWeMo& zgFo!{nYYLkMr5Lmq1QftwdZrd{$BwQ|KO)Tdh$Yh4!Air9NZhwOZ-C!b0IoWu>-f{ z4DlnEHmPJ91C5AMsrAS2^WL3g^`6F}k-*5wua+i6FoWDl1g!J^N2Uz0@^TrSj-^m6 zXBZf?M!DE3Uqq&35o$wIfy6P)R^Szugs#5VXwQ7yYmxVfS-A$S-cAKwh7Y6k%9WlX zfE)9RFvLLUg+N%AWRUfO{y)RtppF%kUUTXFCe(F+G1=IlaBCr~QK858X$3g!Ou@izs5cK^s7goGQa*sfD z7SXTQNKsgfV~FJnd}L=AxQwMahz=}qHSg)|Xgn%loO9)!MvH_8$qDB)h>`tJDE=yY z%JKlFvz-+mZJ{ugfA=_tFV>=*aluw>FK|78kR9=pIT!o**Al&o9Q{UzaFB{)4N4&FR~Q^1|KG(mI0%Ujy&zmd5`J_W^onb2=OP4hUP0N_A6ygzm1%%|2Mw=WYM%A7|z6 z=MtRI(TmMj-Hmw?QNGlPg&i^5(U&_o12~)Ko(WVAFMAJ{B>KVe#kWT!wnf>vj-vJ2wjT1 z1Ko$1ym@1L0vKS(w_o1)dD`VG!IEeqp1pIWUW0DP7B|;-v>!@=#%nOU*?*{0>K|__d9<-?;_evd zUi7MajVggAXiD2kIq`=AEXgroyp{A{qfy=}*%)iLmUZPA2HNIF(s&TvrX83TzQO;mRiVYI@rP1pY9?e}at;2CWi-5Jb$gpsQJN z;~JjP!OLHEH4Q`7GyOY=9&;WvqC3hUDft?sTp!lAgBMRF);^t1`uI^3n`n6rb>?9M z1B;IL?PPwxQYhGwRQJOKU%)IbJ6?KdzKKK98jPFnaE@m1i5NsdgU-tlg~T8PGDDv& z*-au-%H_l`Yyg{}(Hp+MULE5>9_USTpz*$`;#|=m6EGl(Z7SUI`%?I}_{D(*W<=ddSdq z-*J=26rX!DaMIrvFWar}yP%1y9!P2)^9%RrWHN(NZ&U|CkT(L&GwQ1mSTUrpH0o-R z1P`L;Fn0KK$FMmTPgQ#9J%g^W_p*7>ufNf}MXOMpdt(v9mlU(aq_0qMBa>g8pRl^+ zCb_aA16at-@~2=@rjwAP7nr=l*Y;H~iRi6cOvC$?E49h@cr*L7Fg;t(R8f$K@5Z+{ zkH5WIDZoNMZfJ8{EPFl4u~@Ur(=s!3pk8oCVhqLmhLXBZ9rYjvm`G?Zu3DTSuU=8c z55gJdIWwKG<^xU?neYplaI#Rec4_LiErlC4_Ku2TI6LDv{}#7@fBDJ7l~EfrriC#8 z4U9!0S0uPEi+5Xdxnl3N`V;pX5+WHB`?BMLXrMvxDEj^CtWQ2pA2HJT+i#=X+=$