feat(source): integrate whole-home bar chart and contribution pie

This commit is contained in:
zty 2026-07-07 21:31:49 -04:00
parent 1a4b752a01
commit 5a6945a062
1 changed files with 341 additions and 0 deletions

View File

@ -0,0 +1,341 @@
import { useEffect, useMemo, useState } from 'react'
import api from '@/services/api'
import { DATA_TYPE } from '@/common/constants'
import { ApcLimitStandard, Material, MTRL_UNIT, Space } from '@/common/types'
import { PRESET_ROOMS } from './rooms'
import RoomBarChart, { RoomBar } from './RoomBarChart'
import ContribPie from './ContribPie'
import './source.css'
type Pollutant = 'methanal' | 'tvoc' | 'benzene' | 'toluene' | 'p_xylene'
const POLLUTANTS: Pollutant[] = ['methanal', 'tvoc', 'benzene', 'toluene', 'p_xylene']
const LABELS: Record<Pollutant, string> = { methanal: '甲醛', tvoc: 'TVOC', benzene: '苯', toluene: '甲苯', p_xylene: '二甲苯' }
const POLLUTANT_COLORS = ['#2AA9A0', '#3B7DD8', '#8B5CF6', '#E8A13A', '#C2569B']
const PIE_COLORS = ['#C0392B', '#E67E22', '#E0A100', '#159A86', '#2E7FC1', '#6FB1E0', '#9AA7B4']
const get = (obj: any, key: string) => obj?.[key]
const fmt = (n?: number) => Number(n ?? 0).toFixed(3)
const CheckIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 6" /></svg>
)
const BulbIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z" /></svg>
)
interface MatRow { id: string; name: string; area: number; on: boolean }
export default function SourceTracing() {
const [loading, setLoading] = useState(true)
const [matMap, setMatMap] = useState<Record<string, Material>>({})
const [standards, setStandards] = useState<ApcLimitStandard[]>([])
const [standardId, setStandardId] = useState('')
const [roomId, setRoomId] = useState('demo')
const [area, setArea] = useState(19.8)
const [height, setHeight] = useState(3)
const [temperature, setTemperature] = useState(22)
const [humidity, setHumidity] = useState(45)
const [ventilationRate, setVentilationRate] = useState(0.5)
const [mats, setMats] = useState<MatRow[]>([])
const [pol, setPol] = useState<Pollutant>('methanal')
const [result, setResult] = useState<Space | null>(null)
const [requiredACH, setRequiredACH] = useState(0.5)
const [homeStats, setHomeStats] = useState<RoomBar[]>([])
const standard = standards.find(s => s.id === standardId)
const volume = +(area * height).toFixed(2)
const matLabel = (m?: Material) => m?.name || m?.category || m?.material_id || ''
function loadRoom(id: string, map: Record<string, Material> = matMap) {
const room = PRESET_ROOMS.find(r => r.id === id)
if (!room) return
setRoomId(id)
setArea(room.area); setHeight(room.height)
setTemperature(room.temperature); setHumidity(room.humidity)
setVentilationRate(room.ventilationRate)
setMats(room.materials.map(m => ({ id: m.id, name: matLabel(map[m.id]), area: m.a, on: true })))
}
useEffect(() => {
(async () => {
try {
const [stds, matsRes] = await Promise.all([
api.config.getAPCLmtStdList(),
api.material.search(DATA_TYPE.PUBLIC, { size: 300 })
])
setStandards(stds)
const def = stds.find(s => s.id.includes('GB50325')) ?? stds[0]
if (def) setStandardId(def.id)
const map: Record<string, Material> = {}
matsRes.forEach(m => { map[m.material_id] = m })
setMatMap(map)
loadRoom('demo', map)
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)
}
} catch (e) {
// 静默:接口异常时页面保持空态
} finally {
setLoading(false)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function buildPayload(ventRate = ventilationRate) {
const selected = mats.filter(m => m.on && matMap[m.id])
return {
id: 'src', project_id: 'src', name: roomId,
height, area,
env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate,
methanal_c_lmt: standard?.methanal_c_lmt,
tvoc_c_lmt: standard?.tvoc_c_lmt,
benzene_c_lmt: standard?.benzene_c_lmt,
toluene_c_lmt: standard?.toluene_c_lmt,
p_xylene_c_lmt: standard?.p_xylene_c_lmt,
materials: selected.map((m, i) => ({
...matMap[m.id],
id: i + 1, project_id: 'src', space_id: 'src',
unit: MTRL_UNIT.AREA, qty: m.area
}))
}
}
async function runPredict(ventRate = ventilationRate): Promise<Space> {
return await api.predict.space(buildPayload(ventRate))
}
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
}))
}
}
// 输入变化 → 防抖预测
useEffect(() => {
if (!standard || !mats.length) return
// 面积/层高为 0 时体积为 0后端会校验「空间体积」失败改值过程中的瞬时空值跳过本次预测
if (!(area > 0) || !(height > 0)) return
const t = setTimeout(() => { runPredict().then(setResult).catch(() => {}) }, 300)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [area, height, temperature, humidity, ventilationRate, standardId, mats, standard])
const anyOver = useMemo(
() => !!result && POLLUTANTS.some(p => get(result, `${p}_c_exceed`)),
[result]
)
const overNames = useMemo(
() => POLLUTANTS.filter(p => get(result, `${p}_c_exceed`)).map(p => LABELS[p]).join('、'),
[result]
)
const topSourceNames = useMemo(() => {
if (!result) return '—'
const ids = new Set<string>()
POLLUTANTS.forEach(p => {
result.materials?.forEach(m => { if (get(m, `is_${p}_ps`)) ids.add(m.material_id) })
})
return [...ids].map(id => matLabel(matMap[id])).join('、') || '—'
}, [result, matMap])
const ranked = useMemo(() => {
if (!result?.materials) return []
return result.materials
.map(m => ({ id: m.material_id, name: matLabel(matMap[m.material_id]) || m.material_id, rate: get(m, `${pol}_cr`) ?? 0, isSrc: !!get(m, `is_${pol}_ps`) }))
.filter((x: any) => x.rate > 0)
.sort((a: any, b: any) => b.rate - a.rate)
}, [result, pol, matMap])
// 超标时反推达标所需最小换气率
useEffect(() => {
if (!result) return
if (!anyOver) { setRequiredACH(ventilationRate); return }
let cancelled = false
;(async () => {
for (let ach = +(Math.round(ventilationRate * 10) / 10 + 0.1).toFixed(1); ach <= 3.001; ach = +(ach + 0.1).toFixed(1)) {
try {
const r = await runPredict(ach)
if (!POLLUTANTS.some(p => get(r, `${p}_c_exceed`))) {
if (!cancelled) setRequiredACH(+ach.toFixed(1))
return
}
} catch { /* 忽略单次失败 */ }
}
if (!cancelled) setRequiredACH(3)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [result])
if (loading) {
return <div className="s-app"><div className="s-body"><div className="muted" style={{ padding: 40, textAlign: 'center' }}></div></div></div>
}
return (
<div className="s-app">
<div className="s-body">
<div className="s-grid">
{/* 输入 */}
<div className="card">
<div className="card-h">
<div className="card-t"><span className="bar" /></div>
<span className="card-step"></span>
</div>
<div className="fld">
<div className="fld-lab"></div>
<div className="rooms">
{PRESET_ROOMS.map(r => (
<div key={r.id} className={'room-b' + (roomId === r.id ? ' on' : '')} onClick={() => loadRoom(r.id)}>{r.name}</div>
))}
</div>
</div>
<div className="fld">
<div className="row2">
<div><div className="fld-lab"></div><div className="num"><input type="number" step="any" value={area || ''} onChange={e => setArea(parseFloat(e.target.value) || 0)} /><span className="unit">m²</span></div></div>
<div><div className="fld-lab"></div><div className="num"><input type="number" step="any" value={height || ''} onChange={e => setHeight(parseFloat(e.target.value) || 0)} /><span className="unit">m</span></div></div>
</div>
</div>
<div className="fld">
<div className="row2">
<div><div className="fld-lab"></div><div className="num"><input type="number" step="any" value={temperature || ''} onChange={e => setTemperature(parseFloat(e.target.value) || 0)} /><span className="unit"></span></div></div>
<div><div className="fld-lab">湿</div><div className="num"><input type="number" step="any" value={humidity || ''} onChange={e => setHumidity(parseFloat(e.target.value) || 0)} /><span className="unit">%rh</span></div></div>
</div>
</div>
<div className="fld">
<div className="fld-lab"> <span className="v">{(+ventilationRate).toFixed(1)} /h</span></div>
<input className="slider" type="range" min={0.3} max={3} step={0.1} value={ventilationRate} onChange={e => setVentilationRate(+e.target.value)} />
<div className="slider-scale"><span>0.3 </span><span>1.0 </span><span>3.0 </span></div>
</div>
<div className="fld">
<div className="fld-lab"> <span className="v">{standardId}</span></div>
<div className="num">
<select style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }} value={standardId} onChange={e => setStandardId(e.target.value)}>
{standards.map(s => <option key={s.id} value={s.id}>{s.id}</option>)}
</select>
</div>
</div>
<div className="fld" style={{ marginBottom: 0 }}>
<div className="fld-lab"> <span className="muted"> V={volume.toFixed(1)} m³ · </span></div>
<div className="mats">
{mats.map((m, idx) => (
<div key={m.id} className={'mat' + (m.on ? '' : ' off')}>
<div className={'mat-chk' + (m.on ? ' on' : '')} onClick={() => setMats(prev => prev.map((x, i) => i === idx ? { ...x, on: !x.on } : x))}>{m.on && <CheckIcon />}</div>
<div className="mat-main"><div className="mat-nm">{m.name}</div><div className="mat-cat">{matMap[m.id]?.category}</div></div>
<div className="mat-qty"><input type="number" value={m.area} onChange={e => setMats(prev => prev.map((x, i) => i === idx ? { ...x, area: +e.target.value || 0 } : x))} /><span className="u">m²</span></div>
</div>
))}
</div>
</div>
</div>
{/* 结果 */}
<div className="stack">
<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>
<div className="card">
<div className="card-h"><div className="card-t"><span className="bar" /> · 5 </div><span className="card-step"></span></div>
<table className="res-table">
<thead><tr><th></th><th></th><th>({standardId})</th><th></th></tr></thead>
<tbody>
{POLLUTANTS.map(p => {
const over = !!get(result, `${p}_c_exceed`)
return (
<tr key={p}>
<td>{LABELS[p]}</td>
<td className={over ? 'over' : ''}>{fmt(get(result, `${p}_c`))} mg/m³</td>
<td className="muted">{get(standard, `${p}_c_lmt`) ?? '—'}</td>
<td><span className={'chip ' + (over ? 'chip-bad' : 'chip-good')}>{over ? '超标' : '达标'}</span></td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="card">
<div className="card-h">
<div className="card-t"><span className="bar" /> · </div>
<div className="pol-seg">
{POLLUTANTS.map(p => <b key={p} className={pol === p ? 'on' : ''} onClick={() => setPol(p)}>{LABELS[p]}</b>)}
</div>
</div>
<ContribPie items={ranked.map((c: any) => ({ name: c.name, rate: c.rate, isSrc: c.isSrc }))} colors={PIE_COLORS} />
</div>
<div className="card">
<div className="card-h"><div className="card-t"><span className="bar" /></div></div>
<div className={'sugg' + (anyOver ? '' : ' sugg-ok')}>
<div className="sugg-ic"><BulbIcon /></div>
<div style={{ flex: 1 }}>
<div className="sugg-tt">{anyOver ? `${overNames} 超标` : '当前方案全部达标 ✓'}</div>
{anyOver ? (
<>
<div className="sugg-tx"><b>{topSourceNames}</b></div>
<div className="sugg-tips">
<div className="sugg-tip"><span className="tip-dot tip-eco" /> <b>E0 / E1 </b> 60%90%</div>
<div className="sugg-tip"><span className="tip-dot tip-pro" /> PDF </div>
</div>
<div className="sugg-act">
<button className="s-btn s-btn-primary" onClick={() => setVentilationRate(requiredACH)}> {requiredACH.toFixed(1)} /h</button>
<a className="s-btn" href="#/material"></a>
<a className="s-btn s-btn-outline-pro" href="#/home">使 </a>
</div>
</>
) : (
<>
<div className="sugg-tx"> {standardId} </div>
<div className="sugg-tips">
<div className="sugg-tip"><span className="tip-dot tip-pro" /> PDF </div>
</div>
<div className="sugg-act">
<a className="s-btn s-btn-outline-pro" href="#/home">使 </a>
<a className="s-btn" href="#/material"></a>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}