feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -10,7 +10,16 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
|
||||
import {
|
||||
buildRegionDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
@@ -42,70 +51,123 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
stations: HydrogenStationFull[];
|
||||
yearKg: number;
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
|
||||
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinceRegions = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const station of stations) {
|
||||
const province = station.province?.trim() || '未归属';
|
||||
totals.set(province, (totals.get(province) ?? 0) + station.kg);
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
|
||||
.sort((a, b) => b.kg - a.kg);
|
||||
}, [stations, yearKg]);
|
||||
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
|
||||
const openStation = (stationId: number) => {
|
||||
const station = stations.find(item => item.id === stationId);
|
||||
if (!station) return;
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
const openRegion = (region: HydrogenRegionShare) => {
|
||||
setSelectedStationId(null);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
|
||||
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
<>
|
||||
<div className="ehb-two-charts-row">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">加氢站加氢量 Top5{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<div className="ehb-chart-box-body h-[260px]">
|
||||
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
|
||||
<BarChart
|
||||
data={top5}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
|
||||
barSize={10}
|
||||
className="cursor-pointer"
|
||||
onClick={(state) => {
|
||||
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
|
||||
if (stationId) openStation(stationId);
|
||||
}}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={180}
|
||||
tick={<RankYAxisTick />}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
|
||||
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
|
||||
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
|
||||
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
|
||||
const value = Number(v ?? 0);
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
||||
}} fontSize={11} fontWeight={700} fill="#475569" />
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">各区域加氢占比{scopeText}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('province')}
|
||||
>
|
||||
按省
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('city')}
|
||||
>
|
||||
按市
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
data={visibleRegions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
className="cursor-pointer outline-none"
|
||||
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
{visibleRegions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -118,16 +180,22 @@ export function DistributionCharts({ top5, regions, yearKg }: DistributionCharts
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
{visibleRegions.map((r, i) => (
|
||||
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user