airpredict/空气质量预测/源码/docs/superpowers/plans/2026-07-07-source-charts.md

404 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# /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` — 全屋并发预测 + 组织两图 + 替换 contribTask 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) px100% 线在 100px
export default function RoomBarChart({ rooms, labels, colors, overColor = '#C0392B' }: Props) {
if (!rooms.length) {
return <div className="muted" style={{ padding: 24, textAlign: 'center' }}>暂无全屋数据</div>
}
return (
<div className="rbc">
<div className="rbc-plot">
{rooms.map(room => (
<div className="rbc-room" key={room.name}>
<div className="rbc-bars">
<div className="rbc-100" />
{room.pct.map((v, i) => {
if (v == null) {
return <div className="rbc-bar" key={i}><div className="rbc-col rbc-na" /></div>
}
const over = v >= 100
const h = Math.max(2, Math.min(v, CAP))
return (
<div className="rbc-bar" key={i} title={`${labels[i]}${v.toFixed(0)}% 限值`}>
<div className="rbc-val" style={{ color: over ? overColor : 'var(--muted, #8a8f98)' }}>{v.toFixed(0)}%</div>
<div
className="rbc-col"
style={{
height: h,
background: colors[i],
border: over ? `2px solid ${overColor}` : 'none'
}}
/>
</div>
)
})}
</div>
<div className="rbc-room-nm">{room.name}</div>
</div>
))}
</div>
<div className="rbc-legend">
{labels.map((l, i) => (
<span className="rbc-lg" key={l}><i style={{ background: colors[i] }} />{l}</span>
))}
<span className="rbc-lg rbc-lg-line"><i className="rbc-lg-dash" />100% 限值线</span>
</div>
</div>
)
}
```
- [ ] **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 <div className="muted" style={{ padding: 20 }}>该污染物无材料释放</div>
}
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 (
<div className="pie-wrap">
<svg className="pie-svg" width={180} height={180} viewBox="0 0 180 180">
{slices.map((s, i) => (
<path key={i} d={s.d} fill={s.c} stroke="#fcfcfb" strokeWidth={2}>
<title>{items[i].name}{(items[i].rate * 100).toFixed(1)}%</title>
</path>
))}
</svg>
<div className="pie-legend">
{items.map((it, i) => (
<div className="pie-lg" key={it.name + i}>
<i style={{ background: color(i) }} />
<span className="pie-lg-nm">{it.name}{it.isSrc && <b className="pie-src"> (污染源)</b>}</span>
<span className="pie-lg-v">{(it.rate * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)
}
```
- [ ] **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<RoomBar[]>([])
```
`buildPayload` 函数之后追加(用于按预设房间构造 payload材料映射方式与 `buildPayload` 一致):
```tsx
function buildRoomPayload(room: typeof PRESET_ROOMS[number], std: ApcLimitStandard, map: Record<string, Material>) {
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**
在渲染的 `<div className="stack">` 之后、`{/* 识别结论 card */}` 的 `<div className="card">`(含「识别结论 · 5 项污染物」)之前,插入:
```tsx
<div className="card">
<div className="card-h"><div className="card-t"><span className="bar" />全屋污染概览 · 各房间占 GB50325 限值百分比</div><span className="card-step">概览</span></div>
<RoomBarChart rooms={homeStats} labels={POLLUTANTS.map(p => LABELS[p])} colors={POLLUTANT_COLORS} />
</div>
```
- [ ] **Step 5: 用饼图替换横条贡献区**
将「公式溯源·各材料贡献」card 内的横条块整段替换:
原:
```tsx
<div className="contrib">
{ranked.map((c: any, i) => (
<div className="cb" key={c.id}>
<div className="cb-nm"><span className="rk" style={{ background: col(i) }}>{i + 1}</span>{c.name}{c.isSrc && <span style={{ color: 'var(--bad)', fontWeight: 700 }}> (污染源)</span>}</div>
<div className="cb-track"><div className="cb-fill" style={{ width: Math.max(3, c.rate / maxRate * 100) + '%', background: col(i) }} /></div>
<div className="cb-val"><span className="c" style={{ color: col(i) }}>{(c.rate * 100).toFixed(1)}%</span></div>
</div>
))}
{!ranked.length && <div className="muted">该污染物无材料释放</div>}
</div>
```
改为:
```tsx
<ContribPie items={ranked.map((c: any) => ({ 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` 为空、组件显示「暂无全屋数据」。