Files
ems-core/frontend/src/pages/BigScreen3D/components/PVPanels.tsx
Du Wenbo 92ec910a13 ems-core v1.0.0: Standard EMS platform core
Shared backend + frontend for multi-customer EMS deployments.
- 12 enterprise modules: quota, cost, charging, maintenance, analysis, etc.
- 120+ API endpoints, 37 database tables
- Customer config mechanism (CUSTOMER env var + YAML config)
- Collectors: Modbus TCP, MQTT, HTTP API, Sungrow iSolarCloud
- Frontend: React 19 + Ant Design + ECharts + Three.js
- Infrastructure: Redis cache, rate limiting, aggregation engine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:14:11 +08:00

108 lines
3.5 KiB
TypeScript

import { useRef, useMemo } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { DEVICE_POSITIONS, PV_ARRAY, COLORS } from '../constants';
interface PVPanelsProps {
devices: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }>;
hoveredId: number | null;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
detailMode?: boolean;
}
const PV_ZONES = [
{ code: 'PV-INV-01', center: DEVICE_POSITIONS['PV-INV-01'].position },
{ code: 'PV-INV-02', center: DEVICE_POSITIONS['PV-INV-02'].position },
{ code: 'PV-INV-03', center: DEVICE_POSITIONS['PV-INV-03'].position },
] as const;
function PVZone({
center,
device,
isHovered,
onHover,
onClick,
}: {
center: readonly [number, number, number];
device: { id: number; code: string; status: string; power?: number; rated_power?: number } | undefined;
isHovered: boolean;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void;
}) {
const groupRef = useRef<THREE.Group>(null);
const panels = useMemo(() => {
const items: { pos: [number, number, number] }[] = [];
const { cols, rows, panelWidth, panelHeight, gap } = PV_ARRAY;
const totalW = cols * panelWidth + (cols - 1) * gap;
const totalD = rows * panelHeight + (rows - 1) * gap;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = -totalW / 2 + panelWidth / 2 + c * (panelWidth + gap);
const z = -totalD / 2 + panelHeight / 2 + r * (panelHeight + gap);
items.push({ pos: [x, 0, z] });
}
}
return items;
}, []);
const ratio = device && device.rated_power ? (device.power ?? 0) / device.rated_power : 0;
const emissiveIntensity = Math.min(ratio * 0.5, 0.5);
return (
<group
ref={groupRef}
position={[center[0], center[1], center[2]]}
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
>
{panels.map((p, i) => (
<mesh
key={i}
position={p.pos}
rotation={[-PV_ARRAY.tiltAngle, 0, 0]}
castShadow
>
<boxGeometry args={[PV_ARRAY.panelWidth, PV_ARRAY.panelDepth, PV_ARRAY.panelHeight]} />
<meshStandardMaterial
color={isHovered ? '#2a347e' : '#1a237e'}
metalness={0.8}
roughness={0.3}
emissive={COLORS.pvGreen}
emissiveIntensity={isHovered ? 0.5 : emissiveIntensity}
/>
</mesh>
))}
</group>
);
}
export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) {
const deviceMap = useMemo(() => {
const map = new Map<string, (typeof devices)[number]>();
devices.forEach((d) => map.set(d.code, d));
return map;
}, [devices]);
return (
<group>
{PV_ZONES.map((zone) => {
const device = deviceMap.get(zone.code);
return (
<PVZone
key={zone.code}
center={zone.center}
device={device}
isHovered={device ? hoveredId === device.id : false}
onHover={onHover}
onClick={onClick}
/>
);
})}
</group>
);
}