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>
108 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|