diff --git a/空气质量预测/源码/docs/superpowers/plans/2026-07-07-source-charts.md b/空气质量预测/源码/docs/superpowers/plans/2026-07-07-source-charts.md new file mode 100644 index 0000000..704f47a --- /dev/null +++ b/空气质量预测/源码/docs/superpowers/plans/2026-07-07-source-charts.md @@ -0,0 +1,403 @@ +# /source 数据可视化 实现计划 + +> **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:** 给 `/source` 污染源识别页加两个纯 SVG/CSS 手写图表——全屋各房间污染物占 GB50325 限值百分比的柱状图,以及某污染物下各材料贡献率的饼图。 + +**Architecture:** 拆两个纯展示组件 `RoomBarChart`(分组柱状图)与 `ContribPie`(饼图+图例),`source/index.tsx` 在加载时并发预测 4 个预设房间(固定 GB50325)供柱状图,并用饼图替换现有横条贡献展示。不改后端。 + +**Tech Stack:** React 18 + TypeScript + 纯 SVG/CSS。无单测框架 —— 组件用 `npx tsc --noEmit` 类型验证,集成用浏览器验证。 + +## Global Constraints + +- 工作目录:`C:/code/空气质量预测/源码/用户端/iapip-web`。 +- **不引入任何图表库**,纯 SVG/CSS 手写。 +- 污染物顺序固定 `POLLUTANTS = ['methanal','tvoc','benzene','toluene','p_xylene']`,标签 `['甲醛','TVOC','苯','甲苯','二甲苯']`。 +- **柱状图 5 色分类调色板(按污染物,已 dataviz 验证)**:`['#2AA9A0','#3B7DD8','#8B5CF6','#E8A13A','#C2569B']`;超标(pct≥100)用红 `#C0392B` 描边+红色数值。 +- **饼图排名渐变(按占比降序,已 dataviz 验证)**:`['#C0392B','#E67E22','#E0A100','#159A86','#2E7FC1','#6FB1E0','#9AA7B4']`,超 7 项后续复用末色。 +- 数值/图例文字用 ink/muted 文本色,不用系列色(超标数值可用红以示状态)。柱顶标注实际百分比(对比度 WARN 的兜底)。 +- 柱状图固定 GB50325,房间取 `PRESET_ROOMS`(通用 N 个);饼图复用现有 `pol`/`ranked`。 +- 提交在仓库根 `C:/code`(`git add` 用含中文完整路径),每任务末尾提交。 +- 前端 dev 端口 8001;`source/index.tsx` 为 tsx,改动热更新,无需重启。 + +--- + +## 文件结构 + +- `src/pages/source/RoomBarChart.tsx` — **新建**,分组柱状图纯展示组件(Task 1) +- `src/pages/source/ContribPie.tsx` — **新建**,饼图+图例纯展示组件(Task 2) +- `src/pages/source/index.tsx` — 全屋并发预测 + 组织两图 + 替换 contrib(Task 3) +- `src/pages/source/source.css` — 图表样式(Task 1/2 各自追加) + +--- + +## Task 1: RoomBarChart 组件(分组柱状图) + +**Files:** +- Create: `src/pages/source/RoomBarChart.tsx` +- Modify: `src/pages/source/source.css`(追加样式) + +**Interfaces:** +- Produces: `interface RoomBar { name: string; pct: (number | null)[] }`;`export default function RoomBarChart(props: { rooms: RoomBar[]; labels: string[]; colors: string[]; overColor?: string })`。约定:`pct` 长度与 `labels` 一致;`null` 表示无数据不绘制。视觉刻度固定 `CAP=150`(%),即柱高像素 = `min(pct, 150)`,100% 基准线在 100px 处。 + +- [ ] **Step 1: 创建组件** + +创建 `src/pages/source/RoomBarChart.tsx`: + +```tsx +export interface RoomBar { + name: string + pct: (number | null)[] +} + +interface Props { + rooms: RoomBar[] + labels: string[] + colors: string[] + overColor?: string +} + +const CAP = 150 // 视觉封顶百分比,柱高 = min(pct,CAP) px;100% 线在 100px + +export default function RoomBarChart({ rooms, labels, colors, overColor = '#C0392B' }: Props) { + if (!rooms.length) { + return
暂无全屋数据
+ } + return ( +
+
+ {rooms.map(room => ( +
+
+
+ {room.pct.map((v, i) => { + if (v == null) { + return
+ } + const over = v >= 100 + const h = Math.max(2, Math.min(v, CAP)) + return ( +
+
{v.toFixed(0)}%
+
+
+ ) + })} +
+
{room.name}
+
+ ))} +
+
+ {labels.map((l, i) => ( + {l} + ))} + 100% 限值线 +
+
+ ) +} +``` + +- [ ] **Step 2: 追加样式** + +在 `src/pages/source/source.css` 末尾追加: + +```css +/* 全屋污染柱状图 */ +.rbc{padding:4px 2px 0;} +.rbc-plot{display:flex;flex-wrap:wrap;gap:26px 30px;align-items:flex-end;} +.rbc-room{display:flex;flex-direction:column;align-items:center;gap:8px;} +.rbc-bars{position:relative;display:flex;align-items:flex-end;gap:7px;height:150px;padding-top:16px;} +.rbc-100{position:absolute;left:-6px;right:-6px;bottom:100px;border-top:1px dashed #C0392B;opacity:.55;} +.rbc-bar{position:relative;width:15px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;} +.rbc-val{position:absolute;top:-2px;font-size:10px;font-weight:700;white-space:nowrap;} +.rbc-col{width:100%;border-radius:4px 4px 0 0;} +.rbc-na{height:2px;background:#d9dde2;} +.rbc-room-nm{font-size:12.5px;color:var(--ink,#1f2328);font-weight:600;} +.rbc-legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:20px;padding-top:14px;border-top:1px solid #eef0f2;} +.rbc-lg{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted,#8a8f98);} +.rbc-lg i{width:11px;height:11px;border-radius:3px;display:inline-block;} +.rbc-lg-dash{width:14px;height:0;border-top:1px dashed #C0392B;border-radius:0;} +``` + +- [ ] **Step 3: 类型检查** + +Run: +```bash +cd "C:/code/空气质量预测/源码/用户端/iapip-web" && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "error TS" | grep -iE "RoomBarChart" || echo "RoomBarChart 无类型错误" +``` +Expected: `RoomBarChart 无类型错误` + +- [ ] **Step 4: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/source/RoomBarChart.tsx" "空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css" && git commit -m "feat(source): RoomBarChart grouped bar chart component" +``` + +--- + +## Task 2: ContribPie 组件(材料贡献饼图) + +**Files:** +- Create: `src/pages/source/ContribPie.tsx` +- Modify: `src/pages/source/source.css`(追加样式) + +**Interfaces:** +- Produces: `interface PieItem { name: string; rate: number; isSrc: boolean }`;`export default function ContribPie(props: { items: PieItem[]; colors?: string[] })`。约定:`items` 已按 `rate` 降序;扇区角度按 `rate` 占 `sum(rate)` 归一化;配色按序取 `colors`(默认内置排名渐变)。 + +- [ ] **Step 1: 创建组件** + +创建 `src/pages/source/ContribPie.tsx`: + +```tsx +export interface PieItem { + name: string + rate: number + isSrc: boolean +} + +interface Props { + items: PieItem[] + colors?: string[] +} + +const DEFAULT_COLORS = ['#C0392B', '#E67E22', '#E0A100', '#159A86', '#2E7FC1', '#6FB1E0', '#9AA7B4'] + +const R = 74 +const CX = 90 +const CY = 90 + +function arc(cx: number, cy: number, r: number, a0: number, a1: number) { + const p = (a: number) => [cx + r * Math.cos((a - 90) * Math.PI / 180), cy + r * Math.sin((a - 90) * Math.PI / 180)] + const [x0, y0] = p(a0) + const [x1, y1] = p(a1) + const large = a1 - a0 > 180 ? 1 : 0 + return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z` +} + +export default function ContribPie({ items, colors = DEFAULT_COLORS }: Props) { + const total = items.reduce((s, it) => s + it.rate, 0) + if (!items.length || total <= 0) { + return
该污染物无材料释放
+ } + const color = (i: number) => colors[Math.min(i, colors.length - 1)] + let acc = 0 + const slices = items.map((it, i) => { + const a0 = acc / total * 360 + acc += it.rate + const a1 = acc / total * 360 + return { d: arc(CX, CY, R, a0, a1), c: color(i), pct: it.rate / total * 100 } + }) + return ( +
+ + {slices.map((s, i) => ( + + {items[i].name}:{(items[i].rate * 100).toFixed(1)}% + + ))} + +
+ {items.map((it, i) => ( +
+ + {it.name}{it.isSrc && (污染源)} + {(it.rate * 100).toFixed(1)}% +
+ ))} +
+
+ ) +} +``` + +- [ ] **Step 2: 追加样式** + +在 `src/pages/source/source.css` 末尾追加: + +```css +/* 材料贡献饼图 */ +.pie-wrap{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:6px 2px 2px;} +.pie-svg{flex-shrink:0;} +.pie-legend{display:flex;flex-direction:column;gap:8px;min-width:200px;flex:1;} +.pie-lg{display:flex;align-items:center;gap:9px;font-size:13px;} +.pie-lg i{width:12px;height:12px;border-radius:3px;flex-shrink:0;} +.pie-lg-nm{flex:1;color:var(--ink,#1f2328);} +.pie-src{color:#C0392B;font-weight:700;} +.pie-lg-v{font-weight:700;color:var(--ink,#1f2328);} +``` + +- [ ] **Step 3: 类型检查** + +Run: +```bash +cd "C:/code/空气质量预测/源码/用户端/iapip-web" && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "error TS" | grep -iE "ContribPie" || echo "ContribPie 无类型错误" +``` +Expected: `ContribPie 无类型错误` + +- [ ] **Step 4: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/source/ContribPie.tsx" "空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css" && git commit -m "feat(source): ContribPie material contribution pie chart component" +``` + +--- + +## Task 3: 集成到 /source(全屋预测 + 渲染两图 + 替换横条) + +**Files:** +- Modify: `src/pages/source/index.tsx` + +**Interfaces:** +- Consumes: `RoomBarChart`(`{ rooms, labels, colors, overColor }`)、`ContribPie`(`{ items, colors }`)、`PRESET_ROOMS`、`api.predict.space`。 + +- [ ] **Step 1: 引入组件与调色板常量** + +在 `src/pages/source/index.tsx` 顶部 import 段追加: + +```tsx +import RoomBarChart, { RoomBar } from './RoomBarChart' +import ContribPie from './ContribPie' +``` + +在文件内 `const LABELS ...` 之后追加常量: + +```tsx +const POLLUTANT_COLORS = ['#2AA9A0', '#3B7DD8', '#8B5CF6', '#E8A13A', '#C2569B'] +const PIE_COLORS = ['#C0392B', '#E67E22', '#E0A100', '#159A86', '#2E7FC1', '#6FB1E0', '#9AA7B4'] +``` + +- [ ] **Step 2: 新增全屋预测 state 与 payload 辅助函数** + +在组件内 `const [requiredACH, setRequiredACH] = useState(0.5)` 之后追加 state: + +```tsx + const [homeStats, setHomeStats] = useState([]) +``` + +在 `buildPayload` 函数之后追加(用于按预设房间构造 payload,材料映射方式与 `buildPayload` 一致): + +```tsx + function buildRoomPayload(room: typeof PRESET_ROOMS[number], std: ApcLimitStandard, map: Record) { + return { + id: 'src', project_id: 'src', name: room.id, + height: room.height, area: room.area, + env_temp: room.temperature, env_hum: room.humidity, env_vent_rate: room.ventilationRate, + methanal_c_lmt: std.methanal_c_lmt, + tvoc_c_lmt: std.tvoc_c_lmt, + benzene_c_lmt: std.benzene_c_lmt, + toluene_c_lmt: std.toluene_c_lmt, + p_xylene_c_lmt: std.p_xylene_c_lmt, + materials: room.materials.filter(m => map[m.id]).map((m, i) => ({ + ...map[m.id], id: i + 1, project_id: 'src', space_id: 'src', + unit: MTRL_UNIT.AREA, qty: m.a + })) + } + } +``` + +- [ ] **Step 3: 加载时并发预测全屋** + +在初始加载的 `useEffect` 里,`loadRoom('demo', map)` 之后、`catch` 之前追加(用局部 `stds`/`map` 避免读到 stale state;`get`、`POLLUTANTS`、`PRESET_ROOMS`、`api` 均已在文件作用域内): + +```tsx + const gb = stds.find(s => s.id.includes('GB50325')) ?? def + if (gb) { + const settled = await Promise.allSettled( + PRESET_ROOMS.map(r => api.predict.space(buildRoomPayload(r, gb, map))) + ) + const stats: RoomBar[] = [] + settled.forEach((res, idx) => { + if (res.status === 'fulfilled') { + stats.push({ + name: PRESET_ROOMS[idx].name, + pct: POLLUTANTS.map(p => { + const c = get(res.value, `${p}_c`) + const lim = get(gb, `${p}_c_lmt`) + return (typeof lim === 'number' && lim > 0 && typeof c === 'number') ? (c / lim * 100) : null + }) + }) + } + }) + setHomeStats(stats) + } +``` + +> 说明:`def` 为该 `useEffect` 内已声明的默认标准(`const def = stds.find(s => s.id.includes('GB50325')) ?? stds[0]`)。`gb` 复用同一查找,确保柱状图固定 GB50325。 + +- [ ] **Step 4: 结果区顶部插入柱状图 card** + +在渲染的 `
` 之后、`{/* 识别结论 card */}` 的 `
`(含「识别结论 · 5 项污染物」)之前,插入: + +```tsx +
+
全屋污染概览 · 各房间占 GB50325 限值百分比
概览
+ LABELS[p])} colors={POLLUTANT_COLORS} /> +
+``` + +- [ ] **Step 5: 用饼图替换横条贡献区** + +将「公式溯源·各材料贡献」card 内的横条块整段替换: + +原: + +```tsx +
+ {ranked.map((c: any, i) => ( +
+
{i + 1}{c.name}{c.isSrc && (污染源)}
+
+
{(c.rate * 100).toFixed(1)}%
+
+ ))} + {!ranked.length &&
该污染物无材料释放
} +
+``` + +改为: + +```tsx + ({ name: c.name, rate: c.rate, isSrc: c.isSrc }))} colors={PIE_COLORS} /> +``` + +> `maxRate`、`col` 若因此不再被引用会触发未使用告警:`col` 仍用于别处则保留;`maxRate` 若仅此处使用,一并删除其定义行 `const maxRate = ranked[0]?.rate || 1`。实现时按 tsc/eslint 实际提示处理(下一步会跑 tsc)。 + +- [ ] **Step 6: 类型检查** + +Run: +```bash +cd "C:/code/空气质量预测/源码/用户端/iapip-web" && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "error TS" | grep -ivE "eslint" || echo "无新增类型错误" +``` +Expected: `无新增类型错误`(若报 `maxRate`/`col` 未使用,按 Step 5 说明删除对应未用定义后重跑至通过)。 + +- [ ] **Step 7: 浏览器验证** + +登录后打开 `http://localhost:8001/iapip-web/#/source`。 +Expected: +- 结果区顶部出现「全屋污染概览」柱状图:4 个房间、每房间 5 柱、柱顶百分比、100% 红色虚线;某柱 ≥100% 时红色描边+红色数值。 +- 「各材料贡献」显示为饼图 + 右侧百分比图例;切换污染物(甲醛/TVOC/…)饼图随之更新;占比最高扇区红、依次橙金青蓝、越低越淡冷;污染源材料图例标「(污染源)」。 +- 编辑单房间输入不改变顶部全屋概览;控制台无报错。 + +- [ ] **Step 8: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx" && git commit -m "feat(source): integrate whole-home bar chart and contribution pie" +``` + +--- + +## Self-Review 记录 + +- **Spec 覆盖**:图1 柱状图组件→Task 1、数据/集成→Task 3(Step 2-4);图2 饼图组件→Task 2、替换横条→Task 3(Step 5);纯 SVG/CSS、固定 GB50325、并发预测、配色→均覆盖。 +- **类型一致**:`RoomBar{name,pct}`、`PieItem{name,rate,isSrc}` 在 Task 1/2 定义,Task 3 消费一致;`buildRoomPayload(room,std,map)` 签名与调用一致;`POLLUTANT_COLORS`(5)/`PIE_COLORS`(7) 与组件 props 对应。 +- **占位符**:无 TODO;组件代码、样式、集成 diff 均完整;配色为已验证的具体 hex。 +- **风险**:Step 5 删除 `maxRate`/`col` 依 tsc 实际提示(Step 6 兜底);全屋预测依赖 GB50325 存在,缺失时 `homeStats` 为空、组件显示「暂无全屋数据」。