Files
tianpu-ems/frontend/src/pages/BigScreen3D/components/Buildings.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

131 lines
3.4 KiB
TypeScript

import { useMemo } from 'react';
import * as THREE from 'three';
import { Html } from '@react-three/drei';
import { BUILDINGS, COLORS } from '../constants';
interface BuildingsProps {
detailMode?: boolean;
onBuildingClick?: (building: string) => void;
}
function WindowGrid({ width, height, depth }: { width: number; height: number; depth: number }) {
const windows = useMemo(() => {
const cols = 4;
const rows = 3;
const winW = 1.5;
const winH = 0.8;
const winD = 0.05;
const gapX = (width - cols * winW) / (cols + 1);
const gapY = (height - rows * winH) / (rows + 1);
const items: { pos: [number, number, number] }[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = -width / 2 + gapX + winW / 2 + c * (winW + gapX);
const y = -height / 2 + gapY + winH / 2 + r * (winH + gapY);
items.push({ pos: [x, y, depth / 2 + winD / 2] });
}
}
return items;
}, [width, height, depth]);
return (
<group>
{windows.map((w, i) => (
<mesh key={i} position={w.pos}>
<boxGeometry args={[1.5, 0.8, 0.05]} />
<meshStandardMaterial
color="#ffcc66"
emissive="#ffcc66"
emissiveIntensity={0.3}
transparent
opacity={0.6}
/>
</mesh>
))}
</group>
);
}
function Building({
label,
position,
size,
opacity,
onClick,
}: {
label: string;
position: [number, number, number];
size: [number, number, number];
opacity: number;
onClick?: () => void;
}) {
const [w, h, d] = size;
const edgesGeo = useMemo(() => {
const box = new THREE.BoxGeometry(w, h, d);
return new THREE.EdgesGeometry(box);
}, [w, h, d]);
const labelStyle: React.CSSProperties = {
fontSize: '13px',
color: COLORS.text,
background: 'rgba(6, 30, 62, 0.8)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 212, 255, 0.2)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
};
return (
<group position={position} onClick={onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined}>
{/* Main body */}
<mesh castShadow receiveShadow>
<boxGeometry args={[w, h, d]} />
<meshStandardMaterial
color={COLORS.buildingBase}
transparent
opacity={opacity}
/>
</mesh>
{/* Edge highlight */}
<lineSegments geometry={edgesGeo}>
<lineBasicMaterial color="#00d4ff" transparent opacity={0.3} />
</lineSegments>
{/* Windows on front face */}
<WindowGrid width={w} height={h} depth={d} />
{/* Label */}
<Html position={[0, h / 2 + 1.5, 0]} center>
<div style={labelStyle}>{label}</div>
</Html>
</group>
);
}
export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) {
const opacity = detailMode ? 0.15 : 0.85;
return (
<group>
<Building
label={BUILDINGS.east.label}
position={[...BUILDINGS.east.position]}
size={[...BUILDINGS.east.size]}
opacity={opacity}
onClick={onBuildingClick ? () => onBuildingClick('east') : undefined}
/>
<Building
label={BUILDINGS.west.label}
position={[...BUILDINGS.west.position]}
size={[...BUILDINGS.west.size]}
opacity={opacity}
onClick={onBuildingClick ? () => onBuildingClick('west') : undefined}
/>
</group>
);
}