This commit is contained in:
rafaeldpsilva
2025-12-10 14:24:56 +00:00
parent 0152886cc2
commit 3c4bead503
9 changed files with 614 additions and 212 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Community Digital Twin</title>
<script type="module" crossorigin src="/assets/index-CwCwvZ0Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1evUyPMc.css">
<script type="module" crossorigin src="/assets/index-BXWyap6F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BqNgBlW1.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,13 +1,23 @@
<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'
const isSettingsOpen = ref(false);
</script>
<template>
<DigitalTwin />
<BuildingInfoCanvas />
<TimeControls />
<button class="settings-btn" @click="isSettingsOpen = true">
Settings
</button>
<SettingsModal :isOpen="isSettingsOpen" @close="isSettingsOpen = false" />
</template>
<style>
@@ -19,4 +29,33 @@ html, body, #app {
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;
}
</style>

View File

@@ -0,0 +1,283 @@
<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">
<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, setBrightness, setSimulationMode, setMaxZoom, setZoomSensitivity } = useSimulation();
const baseUrlInput = 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 || '';
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);
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

@@ -1,5 +1,6 @@
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;
@@ -14,6 +15,8 @@ export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRen
const moveDuration = 0.2;
let moveStartTime = 0;
const { state } = useSimulation(); // Access global state
const initControls = () => {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
@@ -32,32 +35,51 @@ export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRen
controls.addEventListener('change', () => {
if (moveAnimating) return;
controls.target.y = 0;
camera.position.y = Math.max(camera.position.y, 20);
camera.position.y = Math.min(camera.position.y, 45);
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 = 5;
if (event.deltaY < 0 && camera.position.y <= 120) { // Increased Zoom Out limit
// Zoom Out (Up)
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 = camera.position.clone().add(new THREE.Vector3(0, zoomAmount, 0));
moveEnd = targetPos;
contStart = controls.target.clone();
contEnd = controls.target.clone(); // Target stays same
moveProgress = 0;
moveStartTime = performance.now() / 1000;
moveAnimating = true;
} else if (event.deltaY > 0 && camera.position.y >= 10) {
// Zoom In (Down)
moveStart = camera.position.clone();
moveEnd = camera.position.clone().add(new THREE.Vector3(0, -zoomAmount, 0));
contStart = controls.target.clone();
contEnd = controls.target.clone();
moveProgress = 0;
moveStartTime = performance.now() / 1000;
moveAnimating = true;
}
}
}, { passive: false });

View File

@@ -2,7 +2,7 @@ import * as THREE from 'three';
import SunCalc from 'suncalc';
import { Ref } from 'vue';
export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: number }>) {
export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: number; brightness?: number }>) {
let directionalLight: THREE.DirectionalLight;
let ambientLight: THREE.AmbientLight;
@@ -14,7 +14,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
sunsetSun: new THREE.Color(0xffcc25),
nightSun: new THREE.Color(0x222244),
dayAmbient: new THREE.Color(0xa9d3fd),
nightAmbient: new THREE.Color(0x222244),
nightAmbient: new THREE.Color(0x555588),
tempSun: new THREE.Color(),
tempAmbient: new THREE.Color(),
tempBg: new THREE.Color()
@@ -94,7 +94,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
sun: COLORS.nightSun,
ambient: COLORS.nightAmbient,
bg: COLORS.night,
intensity: 0,
intensity: 0.2, // Moonlight
shadow: false
};
};
@@ -131,11 +131,15 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
// 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;
directionalLight.intensity = colors.intensity * brightness;
directionalLight.castShadow = colors.shadow;
ambientLight.color.copy(colors.ambient);
ambientLight.intensity = 1.2 * brightness;
scene.background = colors.bg;
};

View File

@@ -9,10 +9,14 @@ interface SimulationState {
totalSeconds: number;
totalConsumption: number;
totalGeneration: number;
dataBaseUrl: string;
brightness: number;
simulationMode: 'simulated' | 'live';
maxZoom: number;
zoomSensitivity: number;
}
interface BuildingData {
id: string;
type: string;
@@ -33,6 +37,7 @@ 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,
@@ -40,7 +45,12 @@ const state = ref<SimulationState>({
day: dayOfYear,
totalSeconds: 0,
totalConsumption: 0,
totalGeneration: 0
totalGeneration: 0,
dataBaseUrl: '',
brightness: 1.0,
simulationMode: 'simulated',
maxZoom: 120,
zoomSensitivity: 5
});
const selectedBuilding = ref<BuildingData | null>(null);
@@ -54,10 +64,43 @@ export const useSimulation = () => {
state.value.speed = speed;
};
const setBrightness = (val: number) => {
state.value.brightness = val;
};
const setSimulationMode = (mode: 'simulated' | 'live') => {
state.value.simulationMode = mode;
if (mode === 'live') {
state.value.isPlaying = true; // Auto-play in live? Or just simple update.
}
};
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 loadBuildingData = (_buildingId: string, csvPath: string) => {
// console.log(`Loading data for ${_buildingId} from ${csvPath}`);
// 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(csvPath, {
Papa.parse(fullPath, {
download: true,
header: true,
dynamicTyping: true,
@@ -72,12 +115,18 @@ export const useSimulation = () => {
};
const updateTime = (delta: number) => {
if (!state.value.isPlaying) return;
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++;
state.value.currentTime += delta * state.value.speed;
if (state.value.currentTime >= 24) {
state.value.currentTime = 0;
state.value.day++;
}
}
// --- Simulated Community Energy Logic ---
@@ -114,6 +163,11 @@ export const useSimulation = () => {
togglePlay,
setSpeed,
loadBuildingData,
updateTime
updateTime,
setDataBaseUrl,
setBrightness,
setSimulationMode,
setMaxZoom,
setZoomSensitivity
};
};

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useSimulation.ts","./src/composables/three/useCameraControls.ts","./src/composables/three/useCityObjects.ts","./src/composables/three/useInteraction.ts","./src/composables/three/useSunSystem.ts","./src/composables/three/useThreeScene.ts","./src/App.vue","./src/components/BuildingInfoCanvas.vue","./src/components/DigitalTwin.vue","./src/components/TimeControls.vue"],"version":"5.6.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useSimulation.ts","./src/composables/three/useCameraControls.ts","./src/composables/three/useCityObjects.ts","./src/composables/three/useInteraction.ts","./src/composables/three/useSunSystem.ts","./src/composables/three/useThreeScene.ts","./src/App.vue","./src/components/BuildingInfoCanvas.vue","./src/components/DigitalTwin.vue","./src/components/SettingsModal.vue","./src/components/TimeControls.vue"],"version":"5.6.3"}