feat(source): integrate whole-home bar chart and contribution pie
This commit is contained in:
parent
1a4b752a01
commit
5a6945a062
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue