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

18 KiB
Raw Blame History

/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:/codegit add 用含中文完整路径),每任务末尾提交。
  • 前端 dev 端口 8001source/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

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 末尾追加:

/* 全屋污染柱状图 */
.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:

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
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 降序;扇区角度按 ratesum(rate) 归一化;配色按序取 colors(默认内置排名渐变)。

  • Step 1: 创建组件

创建 src/pages/source/ContribPie.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 末尾追加:

/* 材料贡献饼图 */
.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:

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
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_ROOMSapi.predict.space

  • Step 1: 引入组件与调色板常量

src/pages/source/index.tsx 顶部 import 段追加:

import RoomBarChart, { RoomBar } from './RoomBarChart'
import ContribPie from './ContribPie'

在文件内 const LABELS ... 之后追加常量:

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

  const [homeStats, setHomeStats] = useState<RoomBar[]>([])

buildPayload 函数之后追加(用于按预设房间构造 payload材料映射方式与 buildPayload 一致):

  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 stategetPOLLUTANTSPRESET_ROOMSapi 均已在文件作用域内):

        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 项污染物」)之前,插入:

            <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 内的横条块整段替换:

原:

              <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>

改为:

              <ContribPie items={ranked.map((c: any) => ({ name: c.name, rate: c.rate, isSrc: c.isSrc }))} colors={PIE_COLORS} />

maxRatecol 若因此不再被引用会触发未使用告警:col 仍用于别处则保留;maxRate 若仅此处使用,一并删除其定义行 const maxRate = ranked[0]?.rate || 1。实现时按 tsc/eslint 实际提示处理(下一步会跑 tsc

  • Step 6: 类型检查

Run:

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

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 为空、组件显示「暂无全屋数据」。