SRP
This commit is contained in:
97
src/App.vue
Normal file
97
src/App.vue
Normal 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>
|
||||
212
src/components/AddBuildingModal.vue
Normal file
212
src/components/AddBuildingModal.vue
Normal 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>
|
||||
234
src/components/BuildingInfoCanvas.vue
Normal file
234
src/components/BuildingInfoCanvas.vue
Normal 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>
|
||||
80
src/components/DigitalTwin.vue
Normal file
80
src/components/DigitalTwin.vue
Normal 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>
|
||||
299
src/components/SettingsModal.vue
Normal file
299
src/components/SettingsModal.vue
Normal 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>
|
||||
168
src/components/TimeControls.vue
Normal file
168
src/components/TimeControls.vue
Normal 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>
|
||||
111
src/composables/three/useCameraControls.ts
Normal file
111
src/composables/three/useCameraControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
217
src/composables/three/useCityObjects.ts
Normal file
217
src/composables/three/useCityObjects.ts
Normal 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
|
||||
};
|
||||
}
|
||||
242
src/composables/three/useInteraction.ts
Normal file
242
src/composables/three/useInteraction.ts
Normal 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
|
||||
};
|
||||
}
|
||||
151
src/composables/three/useSunSystem.ts
Normal file
151
src/composables/three/useSunSystem.ts
Normal 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
|
||||
};
|
||||
}
|
||||
52
src/composables/three/useThreeScene.ts
Normal file
52
src/composables/three/useThreeScene.ts
Normal 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
|
||||
};
|
||||
}
|
||||
262
src/composables/useSimulation.ts
Normal file
262
src/composables/useSimulation.ts
Normal 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
5
src/main.ts
Normal 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
13
src/style.css
Normal 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
9
src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user