This commit is contained in:
rafaeldpsilva
2025-12-10 14:57:34 +00:00
parent 84f5286126
commit b1ddf0c85e
102 changed files with 0 additions and 497175 deletions

97
src/App.vue Normal file
View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref } from 'vue';
import DigitalTwin from './components/DigitalTwin.vue'
import BuildingInfoCanvas from './components/BuildingInfoCanvas.vue'
import TimeControls from './components/TimeControls.vue'
import SettingsModal from './components/SettingsModal.vue'
import AddBuildingModal from './components/AddBuildingModal.vue'
const isSettingsOpen = ref(false);
const isAddBuildingOpen = ref(false);
</script>
<template>
<DigitalTwin />
<BuildingInfoCanvas />
<TimeControls />
<button class="settings-btn" @click="isSettingsOpen = true">
Settings
</button>
<button class="add-building-btn" @click="isAddBuildingOpen = true">
+ Add Building
</button>
<SettingsModal :isOpen="isSettingsOpen" @close="isSettingsOpen = false" />
<AddBuildingModal :isOpen="isAddBuildingOpen" @close="isAddBuildingOpen = false" />
</template>
<style>
/* CSS Reset / Base styles */
html, body, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.settings-btn {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
padding: 8px 12px;
background: #000;
color: white;
border: 2px solid #FFD700;
font-family: 'Courier New', monospace;
font-weight: bold;
cursor: pointer;
text-transform: uppercase;
box-shadow: 4px 4px 0 rgba(0,0,0,0.5);
transition: all 0.1s;
}
.settings-btn:hover {
background: #111;
color: #FFD700;
transform: translate(1px, 1px);
box-shadow: 3px 3px 0 rgba(0,0,0,0.5);
}
.settings-btn:active {
transform: translate(4px, 4px);
box-shadow: none;
}
.add-building-btn {
position: absolute;
top: 20px;
right: 160px; /* Positioned to the left of Settings */
z-index: 1000;
padding: 8px 12px;
background: #000;
color: white;
border: 2px solid #00FF00; /* Green accent */
font-family: 'Courier New', monospace;
font-weight: bold;
cursor: pointer;
text-transform: uppercase;
box-shadow: 4px 4px 0 rgba(0,0,0,0.5);
transition: all 0.1s;
}
.add-building-btn:hover {
background: #111;
color: #00FF00;
transform: translate(1px, 1px);
box-shadow: 3px 3px 0 rgba(0,0,0,0.5);
}
.add-building-btn:active {
transform: translate(4px, 4px);
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Add New Building</h3>
<button class="close-btn" @click="close">×</button>
</div>
<div class="modal-content">
<div class="setting-group">
<label>Building ID</label>
<input v-model="form.id" type="text" class="input-field" placeholder="e.g. H15" />
</div>
<div class="setting-group">
<label>Type</label>
<select v-model="form.type" class="input-field select-field">
<option value="house">House</option>
<option value="factory">Factory</option>
<option value="office">Office</option>
<option value="shop">Shop</option>
</select>
</div>
<div class="setting-group">
<label>Data Source (CSV Path)</label>
<input v-model="form.csvPath" type="text" class="input-field" placeholder="e.g. data/H01.csv" />
</div>
</div>
<div class="modal-actions">
<button class="btn secondary" @click="close">Cancel</button>
<button class="btn primary" @click="confirm">Add Building</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, reactive } from 'vue';
import { useSimulation } from '../composables/useSimulation';
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const { addBuilding, state } = useSimulation();
const form = reactive({
id: '',
type: 'house',
csvPath: 'data/H01.csv'
});
// Auto-generate ID when opened
watch(() => props.isOpen, (newVal) => {
if (newVal) {
const nextNum = state.value.buildings.length + 1;
form.id = `H${nextNum.toString().padStart(2, '0')}`;
form.type = 'house';
form.csvPath = 'data/H01.csv';
}
});
const close = () => {
emit('close');
};
const confirm = () => {
addBuilding({ ...form });
close();
};
</script>
<style scoped>
/* Reuse styles from SettingsModal to maintain consistency */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal {
background: #1a1a1a;
border: 3px solid #000;
width: 400px;
max-width: 90%;
color: white;
font-family: 'Courier New', monospace;
box-shadow: 10px 10px 0px rgba(0,0,0,0.5);
}
.modal-header {
background: #FFD700;
color: black;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #000;
}
.modal-header h3 {
margin: 0;
text-transform: uppercase;
font-weight: 900;
font-size: 1.1rem;
}
.close-btn {
background: black;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0 8px;
font-weight: bold;
}
.close-btn:hover {
color: #ff4444;
}
.modal-content {
padding: 20px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
text-transform: uppercase;
color: #ffd700;
}
.input-field {
width: 100%;
padding: 8px;
background: #000;
border: 2px solid #333;
color: white;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.input-field:focus {
outline: none;
border-color: #FFD700;
}
.select-field {
cursor: pointer;
}
.modal-actions {
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 2px solid #333;
background: #222;
}
.btn {
padding: 8px 16px;
font-family: 'Courier New', monospace;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
border: 2px solid #000;
box-shadow: 2px 2px 0px #000;
transition: all 0.1s;
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.btn.primary {
background: #FFD700;
color: black;
}
.btn.primary:hover {
background: #ffea70;
}
.btn.secondary {
background: #444;
color: white;
}
.btn.secondary:hover {
background: #555;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div
v-if="selectedBuilding"
class="building-panel"
ref="panelRef"
:class="{ 'flipped': isFlipped }"
:style="panelStyle"
>
<div class="header">
<h3>{{ selectedBuilding.id }} - {{ selectedBuilding.description }}</h3>
<button class="close-btn" @click="closePanel">×</button>
</div>
<div class="content">
<div class="info-row">
<span class="label">Type:</span>
<span class="value">{{ selectedBuilding.type }}</span>
</div>
<div class="section-title">Energy Status</div>
<div class="info-row">
<span class="label">Consumption:</span>
<span class="value">{{ selectedBuilding.consumption }} kWh</span>
</div>
<div class="info-row">
<span class="label">Generation:</span>
<span class="value">{{ selectedBuilding.generation }} kWh</span>
</div>
<div class="section-title">IoT Devices</div>
<p class="iot-text">{{ selectedBuilding.iot || 'No active devices' }}</p>
<div class="actions">
<button class="action-btn">Toggle Lights</button>
<button class="action-btn">View Charts</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSimulation } from '../composables/useSimulation';
import { computed, ref, onMounted, onUnmounted } from 'vue';
const { selectedBuilding } = useSimulation();
const windowWidth = ref(window.innerWidth);
const windowHeight = ref(window.innerHeight);
const updateDimensions = () => {
windowWidth.value = window.innerWidth;
windowHeight.value = window.innerHeight;
};
onMounted(() => window.addEventListener('resize', updateDimensions));
onUnmounted(() => window.removeEventListener('resize', updateDimensions));
const closePanel = () => {
selectedBuilding.value = null;
};
// Panel Constants
const PANEL_WIDTH = 300;
const PANEL_HEIGHT_EST = 350; // Estimated max height
const MARGIN = 20;
const isFlipped = computed(() => {
if (!selectedBuilding.value || !selectedBuilding.value.uiY) return false;
// If top position < panel height + margin, flip to bottom
// Default alignment is translate(-50%, -110%) -> Above the point
// So if uiY < 350, the top of panel is off screen (-negative)
return selectedBuilding.value.uiY < (PANEL_HEIGHT_EST + MARGIN);
});
const panelStyle = computed(() => {
if (!selectedBuilding.value || !selectedBuilding.value.uiX || !selectedBuilding.value.uiY) return {};
let x = selectedBuilding.value.uiX;
let y = selectedBuilding.value.uiY;
// Clamp X to keep 300px panel within screen
// Default transform centers it (-50%), so anchor is center.
// Left edge = x - 150, Right edge = x + 150
const halfWidth = PANEL_WIDTH / 2;
// Check Left Edge
if (x - halfWidth < MARGIN) {
x = halfWidth + MARGIN;
}
// Check Right Edge
else if (x + halfWidth > windowWidth.value - MARGIN) {
x = windowWidth.value - MARGIN - halfWidth;
}
return {
left: x + 'px',
top: y + 'px'
};
});
</script>
<style scoped>
.building-panel {
position: absolute;
width: 300px;
background: rgba(20, 20, 25, 0.95); /* Dark HUD background */
border: 3px solid #000; /* Bold Outline */
border-radius: 0; /* Sharp corners */
box-shadow: 6px 6px 0px rgba(0,0,0,0.4); /* Hard drop shadow */
padding: 0;
overflow: hidden;
font-family: 'Courier New', monospace; /* Tech/Game font */
z-index: 1000;
pointer-events: auto;
color: white;
/* Default: Above the point */
transform: translate(-50%, -110%);
transition: left 0.05s linear, top 0.05s linear; /* Snappier movement */
}
/* Flip to below the point if active */
.building-panel.flipped {
transform: translate(-50%, 10%); /* 10% gap below point */
}
.header {
background: #FFD700; /* Vibrant Gold */
color: black;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #000;
text-transform: uppercase;
}
.header h3 {
margin: 0;
font-size: 1.0rem;
font-weight: 900;
}
.close-btn {
background: black;
border: 2px solid black;
color: white;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
padding: 2px 8px;
font-weight: bold;
}
.close-btn:hover {
background: #ff4444;
}
.content {
padding: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
border-bottom: 2px dashed #444;
padding-bottom: 4px;
}
.label {
color: #aaa;
font-weight: 700;
text-transform: uppercase;
font-size: 0.8rem;
}
.value {
font-weight: 600;
color: #fff;
font-family: monospace;
}
.section-title {
margin-top: 16px;
margin-bottom: 8px;
font-size: 0.8rem;
text-transform: uppercase;
background: #333;
color: #fff;
padding: 4px;
text-align: center;
font-weight: bold;
border: 2px solid #000;
}
.iot-text {
font-size: 0.85rem;
color: #ccc;
background: #111;
padding: 8px;
border: 2px solid #333;
margin: 0;
}
.actions {
margin-top: 20px;
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 8px;
border: 3px solid #000;
border-radius: 0;
background: #ffcc00;
color: black;
font-weight: 900;
cursor: pointer;
text-transform: uppercase;
box-shadow: 3px 3px 0px #000;
transition: all 0.1s;
}
.action-btn:hover {
transform: translate(2px, 2px); /* Click effect */
box-shadow: 1px 1px 0px #000;
}
.action-btn:active {
transform: translate(3px, 3px);
box-shadow: 0px 0px 0px #000;
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useSimulation } from '../composables/useSimulation';
import { useThreeScene } from '../composables/three/useThreeScene';
import { useSunSystem } from '../composables/three/useSunSystem';
import { useCityObjects } from '../composables/three/useCityObjects';
import { useCameraControls } from '../composables/three/useCameraControls';
import { useInteraction } from '../composables/three/useInteraction';
// --- Simulation State ---
const { state, updateTime } = useSimulation();
// --- Composables ---
const { canvasContainer, initScene, getScene, getCamera, getRenderer } = useThreeScene();
// We can't access scene/camera until initScene runs, but we need to setup composables.
// Since scene creation is synchronous in initScene, we can wrap initialization in init().
const init = () => {
initScene(); // Creates scene, camera, renderer
const scene = getScene();
const camera = getCamera();
const renderer = getRenderer();
// 1. City Objects
const { initCity, interactableObjects, clearCity } = useCityObjects();
// @ts-ignore - 'buildings' added to state dynamically
initCity(scene, state.value.buildings);
// Watch for new buildings
watch(() => state.value.buildings.length, () => {
clearCity(scene);
// @ts-ignore
initCity(scene, state.value.buildings);
});
// 2. Sun System
const { initSun, updateSun } = useSunSystem(scene, state);
initSun();
// 3. Camera Controls
const { initControls, updateControls, getControls } = useCameraControls(camera, renderer);
initControls();
// 4. Interaction
const { initInteraction } = useInteraction(camera, renderer, scene, interactableObjects, getControls);
initInteraction();
// 5. Animation Loop
const animate = () => {
requestAnimationFrame(animate);
const delta = 0.016; // Approx 60fps, or use clock
updateTime(delta);
updateSun();
updateControls();
renderer.render(scene, camera);
};
animate();
};
onMounted(() => {
init();
});
</script>
<template>
<div ref="canvasContainer" class="canvas-container"></div>
</template>
<style scoped>
.canvas-container {
width: 100%;
height: 100vh;
display: block;
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Simulation Settings</h3>
<button class="close-btn" @click="close">×</button>
</div>
<div class="modal-content">
<div class="setting-group">
<label>Simulation Mode</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" value="simulated" v-model="simulationModeInput" />
Simulated (Manual Time)
</label>
<label class="radio-label">
<input type="radio" value="live" v-model="simulationModeInput" />
Live (Real Time)
</label>
</div>
</div>
<div class="setting-group">
<label>Brightness: {{ brightnessInput }}</label>
<div class="description">Global light intensity multiplier.</div>
<input
type="range"
min="0.1"
max="2.0"
step="0.1"
v-model="brightnessInput"
class="slider"
/>
</div>
<div class="setting-group">
<label>Max Zoom Height: {{ maxZoomInput }}</label>
<div class="description">Limit how far the camera can zoom out.</div>
<input
type="range"
min="50"
max="300"
step="10"
v-model="maxZoomInput"
class="slider"
/>
</div>
<div class="setting-group">
<label>Zoom Sensitivity: {{ zoomSensitivityInput }}</label>
<div class="description">Speed of zooming in/out.</div>
<input
type="range"
min="1"
max="20"
step="1"
v-model="zoomSensitivityInput"
class="slider"
/>
</div>
<div class="setting-group" v-if="simulationModeInput === 'live'">
<label>Backend URL (Live Mode)</label>
<div class="description">
WebSocket server address for real-time updates.
</div>
<input
v-model="backendUrlInput"
type="text"
placeholder="e.g., http://localhost:8000"
class="input-field"
/>
</div>
<div class="setting-group">
<label>Data Origin (Base URL)</label>
<div class="description">
Prepend this URL to all data file requests. Leave empty for default (local).
</div>
<input
v-model="baseUrlInput"
type="text"
placeholder="e.g., http://localhost:8080/"
class="input-field"
/>
</div>
</div>
<div class="modal-actions">
<button class="btn secondary" @click="close">Cancel</button>
<button class="btn primary" @click="save">Save Settings</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useSimulation } from '../composables/useSimulation';
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const { state, setDataBaseUrl, setBackendUrl, setBrightness, setSimulationMode, setMaxZoom, setZoomSensitivity } = useSimulation();
const baseUrlInput = ref('');
const backendUrlInput = ref('');
const brightnessInput = ref(1.0);
const simulationModeInput = ref<'simulated' | 'live'>('simulated');
const maxZoomInput = ref(120);
const zoomSensitivityInput = ref(5);
// Sync input with state when modal opens
watch(() => props.isOpen, (newVal) => {
if (newVal) {
baseUrlInput.value = state.value.dataBaseUrl || '';
backendUrlInput.value = state.value.backendUrl || 'http://localhost:8000';
brightnessInput.value = state.value.brightness ?? 1.0;
simulationModeInput.value = state.value.simulationMode ?? 'simulated';
maxZoomInput.value = state.value.maxZoom ?? 120;
zoomSensitivityInput.value = state.value.zoomSensitivity ?? 5;
}
});
const close = () => {
emit('close');
};
const save = () => {
setDataBaseUrl(baseUrlInput.value);
setBackendUrl(backendUrlInput.value);
setBrightness(Number(brightnessInput.value));
setSimulationMode(simulationModeInput.value);
setMaxZoom(Number(maxZoomInput.value));
setZoomSensitivity(Number(zoomSensitivityInput.value));
close();
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal {
background: #1a1a1a;
border: 3px solid #000;
width: 400px;
max-width: 90%;
color: white;
font-family: 'Courier New', monospace;
box-shadow: 10px 10px 0px rgba(0,0,0,0.5);
}
.modal-header {
background: #FFD700;
color: black;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #000;
}
.modal-header h3 {
margin: 0;
text-transform: uppercase;
font-weight: 900;
font-size: 1.1rem;
}
.close-btn {
background: black;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0 8px;
font-weight: bold;
}
.close-btn:hover {
color: #ff4444;
}
.modal-content {
padding: 20px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
text-transform: uppercase;
color: #ffd700;
}
.description {
font-size: 0.8rem;
color: #888;
margin-bottom: 8px;
}
.input-field {
width: 100%;
padding: 8px;
background: #000;
border: 2px solid #333;
color: white;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box; /* Fix padding issue */
}
.input-field:focus {
outline: none;
border-color: #FFD700;
}
.modal-actions {
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 2px solid #333;
background: #222;
}
.btn {
padding: 8px 16px;
font-family: 'Courier New', monospace;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
border: 2px solid #000;
box-shadow: 2px 2px 0px #000;
transition: all 0.1s;
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.btn.primary {
background: #FFD700;
color: black;
}
.btn.primary:hover {
background: #ffea70;
}
.btn.secondary {
background: #444;
color: white;
}
.btn.secondary:hover {
background: #555;
}
.radio-group {
display: flex;
gap: 16px;
margin-top: 8px;
}
.radio-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.slider {
width: 100%;
cursor: pointer;
accent-color: #FFD700;
}
</style>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useSimulation } from '../composables/useSimulation';
const { state, togglePlay, setSpeed } = useSimulation();
const formattedTime = computed(() => {
const hours = Math.floor(state.value.currentTime);
const minutes = Math.floor((state.value.currentTime - hours) * 60);
return `Day ${state.value.day} - ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
});
const netEnergy = computed(() => {
return (state.value.totalGeneration || 0) - (state.value.totalConsumption || 0);
});
</script>
<template>
<div class="dashboard-bar">
<!-- Left: Energy Stats -->
<div class="stat-group">
<div class="stat-item">
<span class="label">CONSUMPTION</span>
<span class="value red">{{ Math.round(state.totalConsumption || 0) }} kW</span>
</div>
<div class="stat-item">
<span class="label">GENERATION</span>
<span class="value green">{{ Math.round(state.totalGeneration || 0) }} kW</span>
</div>
<div class="stat-item">
<span class="label">NET GRID</span>
<span class="value" :class="netEnergy >= 0 ? 'green' : 'red'">
{{ netEnergy > 0 ? '+' : ''}}{{ Math.round(netEnergy) }} kW
</span>
</div>
</div>
<!-- Center: Time -->
<div class="time-display">
{{ formattedTime }}
</div>
<!-- Right: Controls -->
<div class="controls">
<div class="speed-controls">
<button @click="setSpeed(1)" :class="{ active: state.speed === 1 }">1x</button>
<button @click="setSpeed(200)" :class="{ active: state.speed === 200 }">200x</button>
</div>
<button class="play-btn" @click="togglePlay" :class="{ active: state.isPlaying }">
{{ state.isPlaying ? 'PAUSE' : 'PLAY' }}
</button>
</div>
</div>
</template>
<style scoped>
.dashboard-bar {
position: absolute;
bottom: 0px;
left: 0;
width: 100%;
height: 80px;
background: #111;
border-top: 4px solid #000;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 40px;
box-sizing: border-box;
color: white;
font-family: 'Courier New', monospace;
z-index: 900;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
}
.stat-group {
display: flex;
gap: 30px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.label {
font-size: 0.75rem;
color: #888;
font-weight: 700;
}
.value {
font-size: 1.4rem;
font-weight: 700;
}
.value.green { color: #00ff00; text-shadow: 0 0 10px rgba(0,255,0,0.3); }
.value.red { color: #ff3333; text-shadow: 0 0 10px rgba(255,0,0,0.3); }
.time-display {
font-size: 1.8rem;
font-weight: 900;
color: #FFD700; /* Gold */
background: #000;
padding: 5px 20px;
border: 2px solid #333;
letter-spacing: 2px;
}
.controls {
display: flex;
gap: 20px;
align-items: center;
}
.speed-controls {
display: flex;
gap: 5px;
background: #222;
padding: 4px;
border: 1px solid #444;
}
button {
background: transparent;
border: 2px solid transparent;
color: #888;
padding: 6px 12px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-weight: bold;
text-transform: uppercase;
transition: all 0.1s;
}
button:hover {
color: white;
background: #333;
}
button.active {
background: #FFD700;
color: black;
box-shadow: 0 0 10px #FFD700;
}
.play-btn {
border: 2px solid #444;
padding: 10px 24px;
font-size: 1.1rem;
background: #222;
color: white;
}
.play-btn:hover {
border-color: #fff;
}
.play-btn.active {
background: #ff3333; /* Stop/Pause color */
color: white;
box-shadow: none;
border-color: #ff0000;
}
</style>

View File

@@ -0,0 +1,111 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { useSimulation } from '../useSimulation';
export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRenderer) {
let controls: OrbitControls;
// Camera Motion State
let moveAnimating = false;
let moveStart: THREE.Vector3 | null = null;
let moveEnd: THREE.Vector3 | null = null;
let contStart: THREE.Vector3 | null = null;
let contEnd: THREE.Vector3 | null = null;
let moveProgress = 0;
const moveDuration = 0.2;
let moveStartTime = 0;
const { state } = useSimulation(); // Access global state
const initControls = () => {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
controls.dampingFactor = 0.05;
controls.enableRotate = false;
controls.enablePan = true;
controls.enableZoom = false;
controls.screenSpacePanning = false; // Fix: Pan on XZ plane only
// Strictly match legacy controls.js
controls.mouseButtons = {
LEFT: THREE.MOUSE.PAN
} as any; // Cast to allow partial object if TS complains, or just because we are overriding defaults
controls.target.set(0, 0, 0);
// Scroll Listeners
controls.addEventListener('change', () => {
if (moveAnimating) return;
controls.target.y = 0;
camera.position.y = Math.min(camera.position.y, state.value.maxZoom || 120);
// This 'change' listener might fight with the zoom logic if not careful.
// But let's just stick to the requested change: Zoom Out limit.
});
renderer.domElement.addEventListener('wheel', (event: WheelEvent) => {
event.preventDefault();
if (!moveAnimating) {
const zoomAmount = state.value.zoomSensitivity || 5;
const maxZoom = state.value.maxZoom || 120; // Default fallback
// Calculate direction from target to camera (view vector)
const dir = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
let targetPos: THREE.Vector3 | null = null;
if (event.deltaY < 0) {
// Zoom Out: Move away from target
targetPos = camera.position.clone().add(dir.multiplyScalar(zoomAmount));
// Check Max Limit
if (targetPos.y > maxZoom) {
// Clamp to max zoom height, but keep angle (roughly, or just stop)
// Simple stop:
targetPos = null;
// Or precise clamping (complex math to find point on vector where y=maxZoom):
// y = y0 + t * dir.y => maxZoom = y0 + t * dir.y => t = (maxZoom - y0) / dir.y
// But simply ignoring the input if it exceeds is safer/smoother for now.
}
} else if (event.deltaY > 0) {
// Zoom In: Move towards target
targetPos = camera.position.clone().sub(dir.multiplyScalar(zoomAmount));
// Check Min Limit
if (targetPos.y < 10) {
targetPos = null;
}
}
if (targetPos) {
moveStart = camera.position.clone();
moveEnd = targetPos;
contStart = controls.target.clone();
contEnd = controls.target.clone(); // Target stays same
moveProgress = 0;
moveStartTime = performance.now() / 1000;
moveAnimating = true;
}
}
}, { passive: false });
};
const updateControls = () => {
// Animation
if (moveAnimating && moveStart && moveEnd && contStart && contEnd) {
const now = performance.now() / 1000;
moveProgress = Math.min((now - moveStartTime) / moveDuration, 1);
camera.position.lerpVectors(moveStart, moveEnd, moveProgress);
controls.target.lerpVectors(contStart, contEnd, moveProgress);
if (moveProgress >= 1) {
moveAnimating = false;
}
controls.update();
}
if (controls) controls.update();
};
return {
initControls,
updateControls,
getControls: () => controls
};
}

View File

@@ -0,0 +1,217 @@
import * as THREE from 'three';
export function useCityObjects() {
const buildings: THREE.Mesh[] = [];
const interactableObjects: THREE.Object3D[] = [];
const sceneObjects: THREE.Object3D[] = [];
// --- Assets & Materials ---
const MATERIALS = {
road: new THREE.MeshLambertMaterial({ color: 0x333333 }),
roadLine: new THREE.MeshBasicMaterial({ color: 0xffffff }),
sidewalk: new THREE.MeshLambertMaterial({ color: 0xcccccc }),
grass: new THREE.MeshLambertMaterial({ color: 0x7cfc00 }),
treeTrunk: new THREE.MeshLambertMaterial({ color: 0x8b4513 }),
treeLeaves: new THREE.MeshLambertMaterial({ color: 0x228b22 }),
};
const buildingTypes: Record<string, any> = {
house: {
geometry: new THREE.BoxGeometry(6, 6, 6),
material: new THREE.MeshLambertMaterial({ color: 0xffaa00 }), // Vibrant Orange
description: "Residential House"
},
factory: {
geometry: new THREE.BoxGeometry(8, 6, 10),
material: new THREE.MeshLambertMaterial({ color: 0xe74c3c }), // Red
description: "Manufacturing Plant"
},
office: {
geometry: new THREE.BoxGeometry(8, 14, 8),
material: new THREE.MeshLambertMaterial({ color: 0x3498db }), // Blue
description: "Office Building"
},
shop: {
geometry: new THREE.BoxGeometry(8, 5, 8),
material: new THREE.MeshLambertMaterial({ color: 0xf1c40f }), // Yellow
description: "Retail Shop"
}
};
// --- City Generation Config ---
const BLOCK_SIZE = 30; // Size of one grid cell (including road half-width)
const ROAD_WIDTH = 8;
const SIDEWALK_WIDTH = 1.5;
const GRID_SIZE = 6; // 6x6 grid
// --- Instanced Mesh Config ---
const MAX_TREES = 200; // Safe upper bound for 6x6 grid with max 3 trees per plot
const GRID_AREA = GRID_SIZE * GRID_SIZE; // 36
const createGenericBuilding = (scene: THREE.Scene, buildingData: any, position: THREE.Vector3, id: string, csvPath: string, rotation: number = 0) => {
const mesh = new THREE.Mesh(buildingData.geometry, buildingData.material);
mesh.position.copy(position);
mesh.rotation.y = rotation;
if (buildingData.geometry.type === 'BoxGeometry') {
mesh.position.y += buildingData.geometry.parameters.height / 2;
}
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = {
id,
type: buildingData.description || 'Generic',
...buildingData,
csvPath
};
interactableObjects.push(mesh);
buildings.push(mesh);
sceneObjects.push(mesh); // Track
scene.add(mesh);
};
const createBuilding = (scene: THREE.Scene, type: string, position: THREE.Vector3, id: string, csvPath: string, rotation: number = 0) => {
const buildingData = buildingTypes[type];
if (!buildingData) {
console.warn(`Building type ${type} not found`);
return;
}
// Adjust position to sit on sidewalk/grass (y=0.5)
const pos = position.clone();
pos.y = 0.5;
createGenericBuilding(scene, buildingData, pos, id, csvPath, rotation);
};
const initGround = (scene: THREE.Scene) => {
// ... (Base creation)
const baseGeo = new THREE.PlaneGeometry(300, 300);
const base = new THREE.Mesh(baseGeo, MATERIALS.road);
// ...
base.rotation.x = -Math.PI / 2;
base.position.y = -0.1;
base.receiveShadow = true;
scene.add(base);
sceneObjects.push(base); // Track
const plots: THREE.Vector3[] = [];
const offset = ((GRID_SIZE - 1) * BLOCK_SIZE) / 2;
// ... (Instanced Meshes)
const sidewalkGeo = new THREE.BoxGeometry(BLOCK_SIZE - ROAD_WIDTH, 0.4, BLOCK_SIZE - ROAD_WIDTH);
const sidewalkMesh = new THREE.InstancedMesh(sidewalkGeo, MATERIALS.sidewalk, GRID_AREA);
sidewalkMesh.receiveShadow = true;
sceneObjects.push(sidewalkMesh); // Track
const grassSize = (BLOCK_SIZE - ROAD_WIDTH) - (SIDEWALK_WIDTH * 2);
const grassGeo = new THREE.BoxGeometry(grassSize, 0.5, grassSize);
const grassMesh = new THREE.InstancedMesh(grassGeo, MATERIALS.grass, GRID_AREA);
grassMesh.receiveShadow = true;
sceneObjects.push(grassMesh); // Track
// ... (Loop to fill instances and plots - remains same)
const dummy = new THREE.Object3D();
let instanceIdx = 0;
for (let ix = 0; ix < GRID_SIZE; ix++) {
for (let iz = 0; iz < GRID_SIZE; iz++) {
const x = (ix * BLOCK_SIZE) - offset;
const z = (iz * BLOCK_SIZE) - offset;
// Position Helper
dummy.position.set(x, 0.2, z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
sidewalkMesh.setMatrixAt(instanceIdx, dummy.matrix);
dummy.position.set(x, 0.25, z);
dummy.updateMatrix();
grassMesh.setMatrixAt(instanceIdx, dummy.matrix);
plots.push(new THREE.Vector3(x, 0, z));
instanceIdx++;
}
}
scene.add(sidewalkMesh);
scene.add(grassMesh);
return plots;
};
const initBuildings = (scene: THREE.Scene, plots: THREE.Vector3[], buildingDefs: any[]) => {
// ... (Trees setup)
const trunkGeo = new THREE.CylinderGeometry(0.5, 0.5, 2, 6);
const leavesGeo = new THREE.ConeGeometry(2, 4, 8);
const trunkMesh = new THREE.InstancedMesh(trunkGeo, MATERIALS.treeTrunk, MAX_TREES);
const leavesMesh = new THREE.InstancedMesh(leavesGeo, MATERIALS.treeLeaves, MAX_TREES);
trunkMesh.castShadow = true; trunkMesh.receiveShadow = true;
leavesMesh.castShadow = true; leavesMesh.receiveShadow = true;
sceneObjects.push(trunkMesh); // Track
sceneObjects.push(leavesMesh); // Track
let treeCount = 0;
const dummy = new THREE.Object3D();
const addTreeInstance = (pos: THREE.Vector3) => {
// ... (remains same)
if (treeCount >= MAX_TREES) return;
dummy.position.copy(pos).add(new THREE.Vector3(0, 1, 0));
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
trunkMesh.setMatrixAt(treeCount, dummy.matrix);
dummy.position.copy(pos).add(new THREE.Vector3(0, 4, 0));
dummy.updateMatrix();
leavesMesh.setMatrixAt(treeCount, dummy.matrix);
treeCount++;
};
let buildingIndex = 0;
for (let i = 0; i < plots.length; i++) {
const plot = plots[i];
if (buildingIndex < buildingDefs.length) {
const def = buildingDefs[buildingIndex];
// Support both 'csv' and 'csvPath' keys for compatibility if needed, but new state uses csvPath
createBuilding(scene, def.type, plot, def.id, def.csvPath || def.csv, 0);
buildingIndex++;
} else {
// Empty plot filler: Trees
addTreeInstance(plot.clone().add(new THREE.Vector3(-5, 0.5, -5)));
if (Math.random() > 0.5) {
addTreeInstance(plot.clone().add(new THREE.Vector3(6, 0.5, 4)));
}
}
}
trunkMesh.count = treeCount;
leavesMesh.count = treeCount;
scene.add(trunkMesh);
scene.add(leavesMesh);
};
const clearCity = (scene: THREE.Scene) => {
sceneObjects.forEach(obj => {
scene.remove(obj);
// Optional: Dispose geometries/materials if strictly needed
});
sceneObjects.length = 0;
buildings.length = 0;
interactableObjects.length = 0;
};
return {
interactableObjects,
initCity: (scene: THREE.Scene, buildingDefs: any[]) => {
const plots = initGround(scene);
initBuildings(scene, plots, buildingDefs);
},
clearCity
};
}

View File

@@ -0,0 +1,242 @@
import * as THREE from 'three';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { useSimulation } from '../useSimulation';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export function useInteraction(
camera: THREE.Camera,
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
interactableObjects: THREE.Object3D[],
getControls: () => OrbitControls
) {
const { selectedBuilding, loadBuildingData } = useSimulation();
let raycaster: THREE.Raycaster;
let mouse: THREE.Vector2;
let transformControls: TransformControls;
let selectionRing: THREE.Mesh;
let transformEnabled = false;
const initSelectionRing = () => {
// Use a base 1x1 square for easy scaling
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.5 });
// Instead of a solid plane, let's use a thick border?
// Or stick to RingGeometry but configured as a square aligned to axes?
// Actually, EdgesGeometry is cleaner for a "line".
// But user liked the "square" (maybe filled or thick?).
// The previous one was RingGeometry(6, 7, 4) -> thick band.
// Let's create a dynamic mesh that is essentially a border.
// We can just use a helper or scale a Ring?
// Let's try scaling a simple Plane with a texture? No texture tool.
// Let's use a BoxHelper style logic but with a thick transparent mesh.
// We will create a RING that is 1x1.
// RingGeometry(0.8, 1, 4) rotated 45deg is a square frame.
// But scaling a rotated frame is hard.
// Let's use a Group with 4 scaled cubes? No.
// Simplest: A PlaneGeometry(1,1) that is solid but transparent yellow (highlight).
// Plus a LineSegments(EdgesGeometry) for border.
// User asked for "highlighting square". The previous one was a ring.
// Let's go with a transparent Plane slightly larger than building.
selectionRing = new THREE.Mesh(geometry, material);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.visible = false;
// Add a border line
const edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffff00 }));
selectionRing.add(line);
scene.add(selectionRing);
};
let selectedMesh: THREE.Mesh | null = null;
let originalMaterial: THREE.Material | THREE.Material[] | null = null;
const clearSelectionHighlight = () => {
if (selectedMesh && originalMaterial) {
// Dispose the cloned material to avoid memory leaks
if (Array.isArray(selectedMesh.material)) {
selectedMesh.material.forEach(m => m.dispose());
} else {
selectedMesh.material.dispose();
}
// Restore original
selectedMesh.material = originalMaterial;
}
selectedMesh = null;
originalMaterial = null;
};
const applySelectionHighlight = (mesh: THREE.Mesh) => {
clearSelectionHighlight(); // Clear previous
selectedMesh = mesh;
if (Array.isArray(mesh.material)) {
originalMaterial = mesh.material;
// Clone isn't straightforward for array, skip for now or we could map clone
} else {
originalMaterial = mesh.material;
const newMat = mesh.material.clone();
if ('emissive' in newMat) {
(newMat as THREE.MeshStandardMaterial).emissive.setHex(0x555555);
}
mesh.material = newMat;
}
};
const updateSelectionRing = (target: THREE.Object3D) => {
if (!target) {
selectionRing.visible = false;
return;
}
// Provide visual feedback
selectionRing.visible = true;
// Calculate Bounds
const box = new THREE.Box3().setFromObject(target);
const size = new THREE.Vector3();
box.getSize(size);
const padding = 2; // Reduced padding for tighter fit
selectionRing.scale.set(size.x + padding, size.z + padding, 1);
selectionRing.position.copy(target.position);
selectionRing.position.y = 0.6; // Raised above grass (0.5)
};
const initInteraction = () => {
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
initSelectionRing();
// Transform Controls
transformControls = new TransformControls(camera, renderer.domElement);
transformControls.setMode('translate');
transformControls.showY = false;
transformControls.enabled = false;
transformControls.addEventListener('dragging-changed', (event) => {
const controls = getControls();
if (controls) controls.enabled = !event.value;
});
try {
scene.add(transformControls.getHelper());
} catch (e) {
console.error("Failed to add TransformControls helper to scene:", e);
}
window.addEventListener('keydown', (event) => {
if (event.key === 'm') {
transformEnabled = !transformEnabled;
transformControls.enabled = transformEnabled;
if (!transformEnabled) {
transformControls.detach();
}
console.log("Move Mode:", transformEnabled ? "ON" : "OFF");
}
});
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerup', onPointerUp);
};
let startX = 0;
let startY = 0;
const onPointerDown = (event: MouseEvent) => {
startX = event.clientX;
startY = event.clientY;
};
const onPointerUp = (event: MouseEvent) => {
const diffX = Math.abs(event.clientX - startX);
const diffY = Math.abs(event.clientY - startY);
// Click Threshold (pixels)
if (diffX > 5 || diffY > 5) {
return; // Dragged, ignore
}
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(interactableObjects);
// Move Mode
if (transformEnabled) {
if (intersects.length > 0) {
const object = intersects[0].object;
const group = object.userData.parentGroup || object;
transformControls.attach(group);
} else {
transformControls.detach();
}
}
// Selection Mode
if (intersects.length > 0) {
const object = intersects[0].object;
const group = object.userData.parentGroup || object;
const data = group.userData;
// Highlight Mesh
if (group instanceof THREE.Mesh) {
applySelectionHighlight(group);
}
// Screen coords for Info Window
const vector = group.position.clone();
vector.project(camera);
const x = (vector.x * .5 + .5) * rect.width;
const y = (-(vector.y * .5) + .5) * rect.height;
selectedBuilding.value = {
id: data.id,
type: data.type,
description: data.description,
consumption: data.consumption || 'Loading...',
generation: data.generation || 'Loading...',
iot: data.iot || 'None',
csvPath: data.csvPath,
uiX: x,
uiY: y
};
if (data.csvPath) {
loadBuildingData(data.id, data.csvPath).then((rows: any) => {
if (selectedBuilding.value && selectedBuilding.value.id === data.id) {
if (rows && rows.length > 0) {
const row = rows[0];
selectedBuilding.value.consumption = row.Consumption;
selectedBuilding.value.generation = row.Generation;
}
}
});
}
updateSelectionRing(group);
} else {
// Only Deselect if we clicked on NOTHING, and we weren't dragging.
selectedBuilding.value = null;
clearSelectionHighlight();
updateSelectionRing(null as any);
}
};
return {
initInteraction
};
}

View File

@@ -0,0 +1,151 @@
import * as THREE from 'three';
import SunCalc from 'suncalc';
import { Ref } from 'vue';
export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: number; brightness?: number }>) {
let directionalLight: THREE.DirectionalLight;
let ambientLight: THREE.AmbientLight;
const COLORS = {
day: new THREE.Color(0xa9d3fd),
night: new THREE.Color(0x0a0a23),
sunriseSun: new THREE.Color(0xa3d1ff),
daySun: new THREE.Color(0xffffa9),
sunsetSun: new THREE.Color(0xffcc25),
nightSun: new THREE.Color(0x222244),
dayAmbient: new THREE.Color(0xa9d3fd),
nightAmbient: new THREE.Color(0x555588),
tempSun: new THREE.Color(),
tempAmbient: new THREE.Color(),
tempBg: new THREE.Color()
};
const initSun = () => {
ambientLight = new THREE.AmbientLight(0x90bbbd, 1.2); // Boosted Ambient
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(20, 30, 10);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.bias = -0.0005;
directionalLight.shadow.normalBias = 0.05;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
scene.add(directionalLight);
};
// --- Gradient Logic ---
const getSunColors = (elevation: number) => {
// Elevation is in radians. Convert to degrees for easier mental mapping.
const deg = elevation * (180 / Math.PI);
// Keyframes
// Day: > 10 deg
if (deg > 10) {
return {
sun: COLORS.daySun,
ambient: COLORS.dayAmbient,
bg: COLORS.day,
intensity: 1.5, // Boosted Sun Intensity
shadow: true
};
}
// Golden Hour / Sunrise / Sunset: 0 to 10 deg
if (deg > 0) {
const t = deg / 10; // 0 to 1
COLORS.tempSun.copy(COLORS.sunsetSun).lerp(COLORS.daySun, t);
COLORS.tempAmbient.copy(COLORS.nightAmbient).lerp(COLORS.dayAmbient, t); // approximate transition
COLORS.tempBg.copy(COLORS.night).lerp(COLORS.day, t); // simple fade
return {
sun: COLORS.tempSun,
ambient: COLORS.tempAmbient,
bg: COLORS.tempBg, // We might want a more "orange" sky here, but lerp is safe
intensity: 0.5 + (1.0 * t), // 0.5 to 1.5
shadow: true
};
}
// Twilight: -6 to 0 deg (Civil Twilight)
if (deg > -6) {
const t = (deg + 6) / 6; // 0 to 1
COLORS.tempSun.copy(COLORS.nightSun).lerp(COLORS.sunsetSun, t);
COLORS.tempAmbient.copy(COLORS.nightAmbient).lerp(COLORS.nightAmbient, t); // Stay darkish
COLORS.tempBg.copy(COLORS.night).lerp(new THREE.Color(0x443355), t); // Purple-ish twilight
return {
sun: COLORS.tempSun,
ambient: COLORS.tempAmbient,
bg: COLORS.tempBg,
intensity: t * 0.5, // 0 to 0.5
shadow: false // Shadows get weird at low angles, disable for clean look
};
}
// Night: < -6 deg
return {
sun: COLORS.nightSun,
ambient: COLORS.nightAmbient,
bg: COLORS.night,
intensity: 0.2, // Moonlight
shadow: false
};
};
const updateSun = () => {
if (!directionalLight || !ambientLight) return;
// Calculate Position
const latitude = 41.17873;
const longitude = -8.60835;
const currentHour = state.value.currentTime;
const now = new Date();
now.setHours(Math.floor(currentHour));
now.setMinutes(Math.floor((currentHour % 1) * 60));
now.setSeconds(0);
const sunPos = SunCalc.getPosition(now, latitude, longitude);
// Position the light
const sunRadius = 100;
const azimuth = sunPos.azimuth;
const elevation = sunPos.altitude;
const sunX = sunRadius * Math.cos(elevation) * Math.cos(azimuth);
const sunY = sunRadius * Math.sin(elevation);
const sunZ = sunRadius * Math.cos(elevation) * Math.sin(azimuth);
if (directionalLight) {
directionalLight.position.set(sunX, sunY, sunZ);
directionalLight.target.position.set(0, 0, 0);
directionalLight.target.updateMatrixWorld();
}
// Update Colors based on elevation
const colors = getSunColors(sunPos.altitude);
// Update Colors based on brightness
const brightness = state.value.brightness ?? 1.0;
directionalLight.color.copy(colors.sun);
directionalLight.intensity = colors.intensity * brightness;
directionalLight.castShadow = colors.shadow;
ambientLight.color.copy(colors.ambient);
ambientLight.intensity = 1.2 * brightness;
scene.background = colors.bg;
};
return {
initSun,
updateSun
};
}

View File

@@ -0,0 +1,52 @@
import * as THREE from 'three';
import { ref, onUnmounted } from 'vue';
export function useThreeScene() {
const canvasContainer = ref<HTMLElement | null>(null);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
const initScene = () => {
if (!canvasContainer.value) return;
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x9ee3f9);
// Camera
const aspect = window.innerWidth / window.innerHeight;
camera = new THREE.PerspectiveCamera(40, aspect, 1, 1000);
camera.position.set(25, 45, 25);
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
canvasContainer.value.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize);
};
const onWindowResize = () => {
if (!renderer || !camera) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize);
if (renderer) renderer.dispose();
});
return {
canvasContainer,
initScene,
getScene: () => scene,
getCamera: () => camera,
getRenderer: () => renderer
};
}

View File

@@ -0,0 +1,262 @@
import { ref } from 'vue';
import Papa from 'papaparse';
import { io, type Socket } from 'socket.io-client';
interface SimulationState {
currentTime: number; // 0 to 24 (hours)
isPlaying: boolean;
speed: number;
day: number;
totalSeconds: number;
totalConsumption: number;
totalGeneration: number;
dataBaseUrl: string;
backendUrl: string;
brightness: number;
simulationMode: 'simulated' | 'live';
maxZoom: number;
zoomSensitivity: number;
buildings: BuildingDefinition[];
}
export interface BuildingDefinition {
id: string;
type: string;
csvPath: string;
}
// ...
interface BuildingData {
id: string;
type: string;
description: string;
consumption: number | string;
generation: number | string;
iot: string;
csvPath?: string;
energyData?: any[];
uiX?: number;
uiY?: number;
}
// (State initialization is already correct with buildings..., just ensure the types match)
// ...
// Initialize with current real-world time
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 0);
const diff = now.getTime() - startOfYear.getTime();
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
const state = ref<SimulationState>({
currentTime: now.getHours() + now.getMinutes() / 60,
isPlaying: false,
speed: 1,
day: dayOfYear,
totalSeconds: 0,
totalConsumption: 0,
totalGeneration: 0,
dataBaseUrl: '',
backendUrl: 'http://localhost:8000',
brightness: 1.0,
simulationMode: 'simulated',
maxZoom: 120,
zoomSensitivity: 5,
buildings: [
{ id: 'H01', type: 'house', csvPath: 'data/H01.csv' },
{ id: 'H02', type: 'factory', csvPath: 'data/H02.csv' },
{ id: 'H03', type: 'office', csvPath: 'data/H03.csv' },
{ id: 'H04', type: 'shop', csvPath: 'data/H04.csv' },
{ id: 'H05', type: 'house', csvPath: 'data/H05.csv' },
{ id: 'H06', type: 'house', csvPath: 'data/H06.csv' },
{ id: 'H07', type: 'house', csvPath: 'data/H07.csv' },
{ id: 'H08', type: 'shop', csvPath: 'data/H08.csv' },
{ id: 'H09', type: 'shop', csvPath: 'data/H09.csv' },
{ id: 'H10', type: 'house', csvPath: 'data/H10.csv' },
{ id: 'H11', type: 'house', csvPath: 'data/H11.csv' },
{ id: 'H12', type: 'house', csvPath: 'data/H12.csv' },
{ id: 'H13', type: 'house', csvPath: 'data/H13.csv' },
{ id: 'H14', type: 'house', csvPath: 'data/H14.csv' },
]
});
const selectedBuilding = ref<BuildingData | null>(null);
let socket: Socket | null = null;
export const useSimulation = () => {
const togglePlay = () => {
state.value.isPlaying = !state.value.isPlaying;
};
const setSpeed = (speed: number) => {
state.value.speed = speed;
};
const setBrightness = (val: number) => {
state.value.brightness = val;
};
const initSocket = () => {
if (socket) return;
console.log(`Connecting to backend: ${state.value.backendUrl}`);
socket = io(state.value.backendUrl);
socket.on('connect', () => {
console.log('Socket connected');
});
socket.on('disconnect', () => {
console.log('Socket disconnected');
});
socket.on('simulation:update', (data: any) => {
// Expected data format: { totalConsumption: number, totalGeneration: number, buildings: { [id]: { consumption: number, generation: number } } }
// OR individual updates. Let's assume a snapshot for now or aggregate.
// If data contains totals:
if (data.totalConsumption !== undefined) state.value.totalConsumption = data.totalConsumption;
if (data.totalGeneration !== undefined) state.value.totalGeneration = data.totalGeneration;
// Update selected building if present
if (selectedBuilding.value && data.buildings && data.buildings[selectedBuilding.value.id]) {
const bUpdate = data.buildings[selectedBuilding.value.id];
selectedBuilding.value.consumption = bUpdate.consumption;
selectedBuilding.value.generation = bUpdate.generation;
}
});
};
const disconnectSocket = () => {
if (socket) {
socket.disconnect();
socket = null;
}
};
const setSimulationMode = (mode: 'simulated' | 'live') => {
state.value.simulationMode = mode;
if (mode === 'live') {
state.value.isPlaying = true; // Auto-play in live? Or just simple update.
initSocket();
} else {
disconnectSocket();
}
};
const setMaxZoom = (val: number) => {
state.value.maxZoom = val;
};
const setZoomSensitivity = (val: number) => {
state.value.zoomSensitivity = val;
};
const setDataBaseUrl = (url: string) => {
state.value.dataBaseUrl = url;
};
const setBackendUrl = (url: string) => {
state.value.backendUrl = url;
// Reconnect if live
if (state.value.simulationMode === 'live' && socket) {
disconnectSocket();
initSocket();
}
};
const loadBuildingData = (_buildingId: string, csvPath: string) => {
// Construct full URL
let fullPath = csvPath;
if (state.value.dataBaseUrl) {
// Check for slash consistency
const base = state.value.dataBaseUrl.endsWith('/') ? state.value.dataBaseUrl : state.value.dataBaseUrl + '/';
// If csvPath starts with data/, we might keep it or not. The user plan said prepending.
// If base is http://.../ and path is data/H01.csv -> http://.../data/H01.csv. This seems correct.
fullPath = base + csvPath;
}
// console.log(`Loading data for ${_buildingId} from ${fullPath}`);
return new Promise((resolve, reject) => {
Papa.parse(fullPath, {
download: true,
header: true,
dynamicTyping: true,
complete: (results) => {
resolve(results.data);
},
error: (err) => {
reject(err);
}
});
});
};
const updateTime = (delta: number) => {
if (state.value.simulationMode === 'live') {
const now = new Date();
state.value.currentTime = now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600;
// Day logic could be complex for real world, let's just ignore or keep rough sync
} else {
if (!state.value.isPlaying) return;
state.value.currentTime += delta * state.value.speed;
if (state.value.currentTime >= 24) {
state.value.currentTime = 0;
state.value.day++;
}
// --- Simulated Community Energy Logic ---
const time = state.value.currentTime;
// Consumption Curve: Base load + Evening Peak (18-22)
// Base Community Load ~4000kW
let load = 4000;
// Peak
if (time > 17 && time < 23) {
load += 2000 * Math.sin(((time - 17) / 6) * Math.PI);
}
// Morning spike
if (time > 6 && time < 9) {
load += 1000 * Math.sin(((time - 6) / 3) * Math.PI);
}
// Generation Curve: Based on simplified Sun Elevation
// Simple Parabola from 6am to 6pm
let gen = 0;
if (time > 6 && time < 18) {
// Peak at 12
const sunFactor = Math.sin(((time - 6) / 12) * Math.PI);
gen = 3500 * sunFactor; // Max 3500kW generation
}
state.value.totalConsumption = Math.floor(load);
state.value.totalGeneration = Math.floor(gen);
}
};
const addBuilding = (def: BuildingDefinition) => {
state.value.buildings.push(def);
};
return {
state,
selectedBuilding,
togglePlay,
setSpeed,
loadBuildingData,
updateTime,
setDataBaseUrl,
setBackendUrl,
setBrightness,
setSimulationMode,
setMaxZoom,
setZoomSensitivity,
addBuilding
};
};

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

13
src/style.css Normal file
View File

@@ -0,0 +1,13 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
declare module 'suncalc' {
export function getPosition(date: Date, lat: number, lng: number): {
azimuth: number;
altitude: number;
};
export function getTimes(date: Date, lat: number, lng: number): any;
}