- 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>
201 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|