Files
tianpu-ems/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx
Du Wenbo 6a59f9af76 feat: add 3D interactive dashboard and 2D BigScreen pages
- New /bigscreen-3d route: React Three Fiber 3D campus with buildings,
  PV panels, heat pumps, meters, and sensors — all procedural geometry
- Interactive: hover highlight, click to select, camera fly-in to
  device detail views (PV inverter, heat pump, meter, heat meter, sensor)
- Real-time data: 15s polling for overview, 5s for selected device
- Energy flow particles along PV→Building, Grid→Building, Building→HP paths
- HUD overlay with date/clock, bottom metrics bar, device list panel
- New /bigscreen route: 2D dashboard with energy flow diagram, charts
- New /devices route: device management page
- Vite config: optimizeDeps.force for R3F dep consistency
- Data backfill script for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:43:48 +08:00

201 lines
5.6 KiB
TypeScript

import { useMemo } from 'react';
import { Html } from '@react-three/drei';
import { DEVICE_POSITIONS, COLORS } from '../constants';
interface DeviceMarkersProps {
devices: Array<{
id: number;
code: string;
device_type: string;
name: string;
status: string;
primaryValue?: string;
}>;
hoveredId: number | null;
onHover: (id: number | null) => void;
onClick: (device: { id: number; code: string; device_type: string; name: string; status: string; primaryValue?: string }) => void;
detailMode?: boolean;
}
const labelStyle: React.CSSProperties = {
fontSize: '11px',
color: COLORS.text,
background: 'rgba(6, 30, 62, 0.85)',
padding: '2px 6px',
borderRadius: '3px',
border: '1px solid rgba(0, 212, 255, 0.2)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
textAlign: 'center',
};
function MeterMarker({
device,
position,
isHovered,
accentColor,
onHover,
onClick,
}: {
device: DeviceMarkersProps['devices'][number];
position: [number, number, number];
isHovered: boolean;
accentColor: string;
onHover: (id: number | null) => void;
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
}) {
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
return (
<group
position={[position[0], position[1] + 0.6, position[2]]}
scale={scale}
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); onClick(device); }}
>
{/* Body */}
<mesh castShadow>
<boxGeometry args={[0.8, 1.2, 0.3]} />
<meshStandardMaterial color={accentColor} metalness={0.3} roughness={0.6} />
</mesh>
{/* Front dial */}
<mesh position={[0, 0.1, 0.16]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.25, 0.25, 0.05, 16]} />
<meshStandardMaterial color="#1a1a1a" metalness={0.5} />
</mesh>
{/* Label */}
<Html position={[0, 1, 0]} center>
<div style={labelStyle}>
<div>{device.name}</div>
{device.primaryValue && <div style={{ color: accentColor }}>{device.primaryValue}</div>}
</div>
</Html>
</group>
);
}
function SensorMarker({
device,
position,
isHovered,
onHover,
onClick,
}: {
device: DeviceMarkersProps['devices'][number];
position: [number, number, number];
isHovered: boolean;
onHover: (id: number | null) => void;
onClick: (device: DeviceMarkersProps['devices'][number]) => void;
}) {
const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1];
return (
<group
position={position}
scale={scale}
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
onClick={(e) => { e.stopPropagation(); onClick(device); }}
>
{/* Sphere */}
<mesh castShadow>
<sphereGeometry args={[0.25, 16, 16]} />
<meshStandardMaterial
color={COLORS.sensorPurple}
metalness={0.5}
roughness={0.4}
emissive={COLORS.sensorPurple}
emissiveIntensity={isHovered ? 0.3 : 0.1}
/>
</mesh>
{/* Antenna */}
<mesh position={[0, 0.45, 0]}>
<cylinderGeometry args={[0.02, 0.02, 0.4, 6]} />
<meshStandardMaterial color="#b0b0b0" metalness={0.8} />
</mesh>
{/* Label */}
<Html position={[0, 0.9, 0]} center>
<div style={labelStyle}>
<div>{device.name}</div>
{device.primaryValue && <div style={{ color: COLORS.sensorPurple }}>{device.primaryValue}</div>}
</div>
</Html>
</group>
);
}
export default function DeviceMarkers({ devices, hoveredId, onHover, onClick }: DeviceMarkersProps) {
const categorized = useMemo(() => {
const meters: typeof devices = [];
const sensors: typeof devices = [];
const heatMeters: typeof devices = [];
devices.forEach((d) => {
if (d.device_type === 'heat_meter' || d.code.startsWith('HM-')) {
heatMeters.push(d);
} else if (d.code.startsWith('MTR-')) {
meters.push(d);
} else if (d.code.startsWith('SENSOR-')) {
sensors.push(d);
}
});
return { meters, sensors, heatMeters };
}, [devices]);
return (
<group>
{/* Regular meters */}
{categorized.meters.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<MeterMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
accentColor={COLORS.gridOrange}
onHover={onHover}
onClick={onClick}
/>
);
})}
{/* Heat meters */}
{categorized.heatMeters.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<MeterMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
accentColor={COLORS.alarmRed}
onHover={onHover}
onClick={onClick}
/>
);
})}
{/* Sensors */}
{categorized.sensors.map((d) => {
const posInfo = DEVICE_POSITIONS[d.code];
if (!posInfo) return null;
return (
<SensorMarker
key={d.id}
device={d}
position={posInfo.position}
isHovered={hoveredId === d.id}
onHover={onHover}
onClick={onClick}
/>
);
})}
</group>
);
}