- {t("devices:wizard.labels.protocol")}:
+ {t('devices:wizard.labels.protocol')}:
{deviceData.protocol}
@@ -786,7 +755,7 @@ export const DeviceWizard: React.FC = ({
{deviceData.mqttConfig && (
- {t("devices:wizard.labels.topicSubscribe")}:
+ {t('devices:wizard.labels.topicSubscribe')}:
{deviceData.mqttConfig.topicSubscribe}
@@ -796,7 +765,7 @@ export const DeviceWizard: React.FC = ({
{deviceData.httpConfig && (
- {t("devices:wizard.labels.ipAddress")}:
+ {t('devices:wizard.labels.ipAddress')}:
{deviceData.httpConfig.ipAddress}
@@ -811,26 +780,26 @@ export const DeviceWizard: React.FC = ({
{/* Footer */}
-
+
- {currentStep === "type" ? "Hủy" : "Quay lại"}
+ {currentStep === 'type' ? 'Hủy' : 'Quay lại'}
- {currentStep === "confirm" ? "Hoàn tất" : "Tiếp tục"}
+ {currentStep === 'confirm' ? 'Hoàn tất' : 'Tiếp tục'}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/subscription/SubscriptionCard.tsx b/src/frontend/src/presentation/components/feature/subscription/SubscriptionCard.tsx
index 98c0f13..e77183e 100644
--- a/src/frontend/src/presentation/components/feature/subscription/SubscriptionCard.tsx
+++ b/src/frontend/src/presentation/components/feature/subscription/SubscriptionCard.tsx
@@ -1,104 +1,176 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Trash2, Activity, Wind, Car } from "lucide-react";
-import type { Subscription } from "../../../../domain/models/SubscriptionModels";
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+ Trash2,
+ Activity,
+ Wind,
+ Car,
+ Bell,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ Link as LinkIcon,
+ Filter,
+} from 'lucide-react'
+import type { Subscription } from '../../../../domain/models/SubscriptionModels'
interface SubscriptionCardProps {
- subscription: Subscription;
- onDelete: (id: string) => void;
+ subscription: Subscription
+ onDelete: (id: string) => void
}
-export const SubscriptionCard: React.FC = ({
- subscription,
- onDelete,
-}) => {
- const { t } = useTranslation(["subscription", "common"]);
+export const SubscriptionCard: React.FC = ({ subscription, onDelete }) => {
+ const { t } = useTranslation(['subscription', 'common'])
+
+ const isTrafficFlow = subscription.entities[0]?.type === 'TrafficFlowObserved'
+ const isActive = subscription.status !== 'failed'
+ const hasConditions = subscription.q && subscription.q.length > 0
+ const attributeCount = subscription.watchedAttributes?.length || 0
+
+ // Determine gradient colors based on entity type
+ const gradientClass = isTrafficFlow
+ ? 'bg-gradient-to-br from-blue-500 to-indigo-600'
+ : 'bg-gradient-to-br from-emerald-500 to-teal-600'
return (
-
-
-
- {subscription.entities[0]?.type === "TrafficFlowObserved" ? (
-
- ) : (
-
- )}
+
+ {/* Gradient Header */}
+
+
+
+
+
+ {isTrafficFlow ? (
+
+ ) : (
+
+ )}
+
+
+
+ {subscription.description}
+
+
+
+ {subscription.entities[0]?.type || 'N/A'}
+
+ {isActive && (
+
+ )}
+
+
+
+
onDelete(subscription.id)}
+ className="p-2 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-all duration-200 text-white hover:scale-110"
+ title={t('deleteTooltip')}
+ >
+
+
-
onDelete(subscription.id)}
- className="text-gray-400 hover:text-red-500 transition-colors p-1"
- title={t("deleteTooltip")}
- >
-
-
-
- {subscription.description}
-
-
-
-
-
-
{t("typeLabel")}
-
- {subscription.entities[0]?.type || "N/A"}
-
+ {/* Card Body */}
+
+ {/* Status and Stats Row */}
+
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+
+ {subscription.status || 'active'}
+
+
+
+
+
+ {subscription.timesSent || 0} {t('sentLabel').toLowerCase()}
+
+
- {subscription.watchedAttributes &&
- subscription.watchedAttributes.length > 0 && (
-
- {subscription.watchedAttributes.map((attr) => (
+ {/* Watched Attributes Section */}
+ {attributeCount > 0 && (
+
+
+
+
+ Monitored Attributes ({attributeCount})
+
+
+
+ {subscription.watchedAttributes?.map((attr) => (
{attr}
))}
- )}
+
+ )}
- {subscription.q && (
-
-
- {t("conditionLabel")}
-
-
- {subscription.q}
-
+ {/* Query Conditions Section */}
+ {hasConditions && (
+
+
+
+
+ {t('conditionLabel')}
+
+
+
+
+ {subscription.q}
+
+
+
)}
-
-
- URI: {subscription.notificationUri}
-
-
-
- {subscription.status || "active"}
-
-
- {t("sentLabel")} {subscription.timesSent || 0}
-
+ {/* Notification URI Section */}
+
+
+
+
+
+ Notification Endpoint
+
+
+ {subscription.notificationUri}
+
+
+
+ {/* Footer Accent */}
+
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/AIControlPanel.tsx b/src/frontend/src/presentation/components/feature/sumo/AIControlPanel.tsx
index 8051322..ed15d5b 100644
--- a/src/frontend/src/presentation/components/feature/sumo/AIControlPanel.tsx
+++ b/src/frontend/src/presentation/components/feature/sumo/AIControlPanel.tsx
@@ -1,78 +1,106 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { useAppDispatch, useAppSelector } from "../../../../data/redux/hooks";
+import React, { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAppDispatch, useAppSelector } from '../../../../data/redux/hooks'
import {
activateAIControl,
deactivateAIControl,
performAIStep,
-} from "../../../../data/redux/sumoSlice";
-import { Brain, Circle, Zap } from "lucide-react";
-import { AIStatusCard } from "./components/AIStatusCard";
-import { AIModelDetailsDialog } from "./components/AIModelDetailsDialog";
+} from '../../../../data/redux/sumoSlice'
+import { Brain, Circle, Zap } from 'lucide-react'
+import { AIStatusCard } from './components/AIStatusCard'
+import { AIModelDetailsDialog } from './components/AIModelDetailsDialog'
+import type { AIDecision } from '../../../../domain/models/SumoModels'
+
+export interface AIDecisionLog extends AIDecision {
+ timestamp: number
+}
interface AIControlPanelProps {
- onLog?: (message: string) => void;
+ onLog?: (message: string) => void
+ onDecisionLogsChange?: (logs: AIDecisionLog[]) => void
}
-export const AIControlPanel: React.FC
= ({ onLog }) => {
- const { t } = useTranslation("sumo");
- const dispatch = useAppDispatch();
- const { aiControlState, isAIControlActive, isLoading, simulationState } =
- useAppSelector((state) => state.sumo);
+export const AIControlPanel: React.FC = ({ onLog, onDecisionLogsChange }) => {
+ const { t } = useTranslation('sumo')
+ const dispatch = useAppDispatch()
+ const { aiControlState, isAIControlActive, isLoading, simulationState } = useAppSelector(
+ (state) => state.sumo
+ )
- const aiIntervalRef = useRef(null);
- const [showDetailsDialog, setShowDetailsDialog] = useState(false);
+ const aiIntervalRef = useRef(null)
+ const [showDetailsDialog, setShowDetailsDialog] = useState(false)
+ const decisionLogsRef = useRef([])
// AI Control Loop
useEffect(() => {
if (isAIControlActive && aiControlState) {
aiIntervalRef.current = window.setInterval(async () => {
try {
- const result = await dispatch(performAIStep()).unwrap();
+ const result = await dispatch(performAIStep()).unwrap()
+
+ // Debug: Log the entire result
+ console.log('🔍 AI Step Result:', result)
+ console.log('🔍 Decisions:', result.decisions)
+ console.log('🔍 Decisions length:', result.decisions?.length)
+
+ // Add decisions to log
+ if (result.decisions && result.decisions.length > 0) {
+ const newLogs: AIDecisionLog[] = result.decisions.map((decision) => ({
+ ...decision,
+ timestamp: result.simulationTime,
+ }))
+ const updated = [...newLogs, ...decisionLogsRef.current].slice(0, 50) // Keep last 50 decisions
+ decisionLogsRef.current = updated
+ onDecisionLogsChange?.(updated)
+ console.log('✅ Updated decision logs:', updated.length, 'decisions')
+ } else {
+ console.log('⚠️ No decisions in result')
+ }
+
if (result.totalSwitches > 0) {
onLog?.(
- t("controlPanel.ai.log.decision", {
+ t('controlPanel.ai.log.decision', {
switches: result.totalSwitches,
holds: result.totalHolds,
time: result.simulationTime.toFixed(0),
})
- );
+ )
}
} catch (error) {
- console.error("AI step error:", error);
+ console.error('AI step error:', error)
}
- }, 2000);
+ }, 2000)
return () => {
if (aiIntervalRef.current) {
- clearInterval(aiIntervalRef.current);
+ clearInterval(aiIntervalRef.current)
}
- };
+ }
}
- }, [isAIControlActive, dispatch, onLog, aiControlState]);
+ }, [isAIControlActive, dispatch, onLog, aiControlState, t, onDecisionLogsChange])
const handleEnableAI = async () => {
try {
- await dispatch(activateAIControl()).unwrap();
- onLog?.(t("controlPanel.ai.log.enabled"));
+ await dispatch(activateAIControl()).unwrap()
+ onLog?.(t('controlPanel.ai.log.enabled'))
} catch (error) {
- onLog?.(t("controlPanel.ai.log.enableFailed", { error }));
+ onLog?.(t('controlPanel.ai.log.enableFailed', { error }))
}
- };
+ }
const handleDisableAI = async () => {
try {
- await dispatch(deactivateAIControl()).unwrap();
- onLog?.(t("controlPanel.ai.log.disabled"));
+ await dispatch(deactivateAIControl()).unwrap()
+ onLog?.(t('controlPanel.ai.log.disabled'))
} catch (error) {
- onLog?.(t("controlPanel.ai.log.disableFailed", { error }));
+ onLog?.(t('controlPanel.ai.log.disableFailed', { error }))
}
- };
+ }
return (
<>
@@ -80,7 +108,7 @@ export const AIControlPanel: React.FC = ({ onLog }) => {
- {t("controlPanel.ai.title")}
+ {t('controlPanel.ai.title')}
@@ -95,7 +123,7 @@ export const AIControlPanel: React.FC = ({ onLog }) => {
hover:shadow-lg hover:scale-105 disabled:hover:scale-100"
>
- {t("controlPanel.ai.enable")}
+ {t('controlPanel.ai.enable')}
) : (
= ({ onLog }) => {
hover:shadow-lg hover:scale-105 disabled:hover:scale-100"
>
- {t("controlPanel.ai.disable")}
+ {t('controlPanel.ai.disable')}
)}
{/* AI Status */}
{aiControlState && isAIControlActive && (
-
setShowDetailsDialog(true)}
- />
+ <>
+ setShowDetailsDialog(true)}
+ />
+ >
)}
{/* Disabled State Message */}
@@ -124,10 +154,10 @@ export const AIControlPanel: React.FC = ({ onLog }) => {
- {t("controlPanel.ai.disabledMessage")}
+ {t('controlPanel.ai.disabledMessage')}
- {t("controlPanel.ai.startSumoMessage")}
+ {t('controlPanel.ai.startSumoMessage')}
)}
@@ -141,5 +171,5 @@ export const AIControlPanel: React.FC = ({ onLog }) => {
/>
)}
>
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/SimulationControlPanel.tsx b/src/frontend/src/presentation/components/feature/sumo/SimulationControlPanel.tsx
index ac10766..feca417 100644
--- a/src/frontend/src/presentation/components/feature/sumo/SimulationControlPanel.tsx
+++ b/src/frontend/src/presentation/components/feature/sumo/SimulationControlPanel.tsx
@@ -1,120 +1,114 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { useAppDispatch, useAppSelector } from "../../../../data/redux/hooks";
-import {
- performSimulationStep,
- stopSimulation,
-} from "../../../../data/redux/sumoSlice";
+import React, { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAppDispatch, useAppSelector } from '../../../../data/redux/hooks'
+import { performSimulationStep, stopSimulation } from '../../../../data/redux/sumoSlice'
+import { Play, Pause, RotateCcw } from 'lucide-react'
interface SimulationControlPanelProps {
- onLog?: (message: string) => void;
+ onLog?: (message: string) => void
}
-export const SimulationControlPanel: React.FC = ({
- onLog,
-}) => {
- const { t } = useTranslation("sumo");
- const dispatch = useAppDispatch();
- const { status, isSimulationRunning, simulationState } = useAppSelector(
- (state) => state.sumo
- );
+export const SimulationControlPanel: React.FC = ({ onLog }) => {
+ const { t } = useTranslation('sumo')
+ const dispatch = useAppDispatch()
+ const { status, isSimulationRunning, simulationState } = useAppSelector((state) => state.sumo)
- const [isRunning, setIsRunning] = useState(false);
- const [isPaused, setIsPaused] = useState(false);
- const [simulationSpeed, setSimulationSpeed] = useState(1);
- const [stepInterval, setStepInterval] = useState(null);
+ const [isRunning, setIsRunning] = useState(false)
+ const [isPaused, setIsPaused] = useState(false)
+ const [simulationSpeed, setSimulationSpeed] = useState(1)
+ const [stepInterval, setStepInterval] = useState(null)
// Start simulation (auto-stepping)
const handleStart = () => {
if (!isSimulationRunning) {
- onLog?.(t("controlPanel.simulation.warning.connectFirst"));
- return;
+ onLog?.(t('controlPanel.simulation.warning.connectFirst'))
+ return
}
- setIsRunning(true);
- setIsPaused(false);
- onLog?.(t("controlPanel.simulation.log.started"));
+ setIsRunning(true)
+ setIsPaused(false)
+ onLog?.(t('controlPanel.simulation.log.started'))
// Start auto-stepping based on speed
const interval = setInterval(async () => {
try {
- await dispatch(performSimulationStep()).unwrap();
+ await dispatch(performSimulationStep()).unwrap()
} catch (error) {
- console.error("Step error:", error);
+ console.error('Step error:', error)
}
- }, 1000 / simulationSpeed);
+ }, 1000 / simulationSpeed)
- setStepInterval(interval);
- };
+ setStepInterval(interval)
+ }
// Pause simulation
const handlePause = () => {
if (stepInterval) {
- clearInterval(stepInterval);
- setStepInterval(null);
+ clearInterval(stepInterval)
+ setStepInterval(null)
}
- setIsRunning(false);
- setIsPaused(true);
- onLog?.(t("controlPanel.simulation.log.paused"));
- };
+ setIsRunning(false)
+ setIsPaused(true)
+ onLog?.(t('controlPanel.simulation.log.paused'))
+ }
// Reset simulation
const handleReset = async () => {
if (stepInterval) {
- clearInterval(stepInterval);
- setStepInterval(null);
+ clearInterval(stepInterval)
+ setStepInterval(null)
}
- setIsRunning(false);
- setIsPaused(false);
+ setIsRunning(false)
+ setIsPaused(false)
// Stop SUMO simulation
try {
- await dispatch(stopSimulation()).unwrap();
- onLog?.(t("controlPanel.simulation.log.reset"));
+ await dispatch(stopSimulation()).unwrap()
+ onLog?.(t('controlPanel.simulation.log.reset'))
} catch (error) {
- onLog?.(t("controlPanel.simulation.log.resetFailed", { error }));
+ onLog?.(t('controlPanel.simulation.log.resetFailed', { error }))
}
- };
+ }
// Update simulation speed
const handleSpeedChange = (value: number) => {
- setSimulationSpeed(value);
- onLog?.(t("controlPanel.simulation.log.speedChanged", { speed: value }));
+ setSimulationSpeed(value)
+ onLog?.(t('controlPanel.simulation.log.speedChanged', { speed: value }))
// If currently running, restart with new speed
if (isRunning && stepInterval) {
- clearInterval(stepInterval);
+ clearInterval(stepInterval)
const interval = setInterval(async () => {
try {
- await dispatch(performSimulationStep()).unwrap();
+ await dispatch(performSimulationStep()).unwrap()
} catch (error) {
- console.error("Step error:", error);
+ console.error('Step error:', error)
}
- }, 1000 / value);
+ }, 1000 / value)
- setStepInterval(interval);
+ setStepInterval(interval)
}
- };
+ }
// Cleanup on unmount
React.useEffect(() => {
return () => {
if (stepInterval) {
- clearInterval(stepInterval);
+ clearInterval(stepInterval)
}
- };
- }, [stepInterval]);
+ }
+ }, [stepInterval])
return (
- {t("controlPanel.simulation.title")}
+ {t('controlPanel.simulation.title')}
{/* Control Buttons */}
@@ -122,41 +116,42 @@ export const SimulationControlPanel: React.FC
= ({
- {t("controlPanel.simulation.start")}
+
- {t("controlPanel.simulation.pause")}
+
- {t("controlPanel.simulation.reset")}
+
{/* Simulation Speed Control */}
- {t("controlPanel.simulation.speed")}{" "}
-
- {simulationSpeed}x
-
+ {t('controlPanel.simulation.speed')}{' '}
+ {simulationSpeed}x
= ({
- {t("controlPanel.simulation.status")}
+ {t('controlPanel.simulation.status')}
{isRunning
- ? t("controlPanel.simulation.running")
+ ? t('controlPanel.simulation.running')
: isPaused
- ? t("controlPanel.simulation.paused")
- : t("controlPanel.simulation.stopped")}
+ ? t('controlPanel.simulation.paused')
+ : t('controlPanel.simulation.stopped')}
- {t("controlPanel.simulation.time")}
+ {t('controlPanel.simulation.time')}
- {simulationState?.simulationTime.toFixed(1) || "0.0"}s
+ {simulationState?.simulationTime.toFixed(1) || '0.0'}s
@@ -204,21 +199,21 @@ export const SimulationControlPanel: React.FC
= ({
{!isSimulationRunning && (
- {t("controlPanel.simulation.warning.title")} {" "}
+ {t('controlPanel.simulation.warning.title')} {' '}
{status.connected ? (
<>
- {t("controlPanel.simulation.warning.connectedNotStarted", {
- scenario: status.scenario || "SUMO",
+ {t('controlPanel.simulation.warning.connectedNotStarted', {
+ scenario: status.scenario || 'SUMO',
})}
- {t("controlPanel.simulation.warning.startInstruction")}
+ {t('controlPanel.simulation.warning.startInstruction')}
>
) : (
- <>{t("controlPanel.simulation.warning.notConnected")}>
+ <>{t('controlPanel.simulation.warning.notConnected')}>
)}
)}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/SumoControlPanel.tsx b/src/frontend/src/presentation/components/feature/sumo/SumoControlPanel.tsx
index a9fcbb6..f964f0e 100644
--- a/src/frontend/src/presentation/components/feature/sumo/SumoControlPanel.tsx
+++ b/src/frontend/src/presentation/components/feature/sumo/SumoControlPanel.tsx
@@ -1,121 +1,106 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useState, useEffect, useCallback } from "react";
-import { useTranslation } from "react-i18next";
-import { useAppDispatch, useAppSelector } from "../../../../data/redux/hooks";
+import React, { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAppDispatch, useAppSelector } from '../../../../data/redux/hooks'
import {
startSimulation,
stopSimulation,
fetchSumoStatus,
performSimulationStep,
-} from "../../../../data/redux/sumoSlice";
-import { SumoModelFactory } from "../../../../domain/models/SumoModels";
-import { Settings } from "lucide-react";
-import { SumoStatusDisplay } from "./components/SumoStatusDisplay";
-import { SumoSettings } from "./components/SumoSettings";
-import { SumoActionButtons } from "./components/SumoActionButtons";
+} from '../../../../data/redux/sumoSlice'
+import { SumoModelFactory } from '../../../../domain/models/SumoModels'
+import { Settings } from 'lucide-react'
+import { SumoStatusDisplay } from './components/SumoStatusDisplay'
+import { SumoSettings } from './components/SumoSettings'
+import { SumoActionButtons } from './components/SumoActionButtons'
interface SumoControlPanelProps {
- onLog?: (message: string) => void;
+ onLog?: (message: string) => void
}
-export const SumoControlPanel: React.FC = ({
- onLog,
-}) => {
- const { t } = useTranslation("sumo");
- const dispatch = useAppDispatch();
- const { status, isLoading, isSimulationRunning, simulationState, error } =
- useAppSelector((state) => state.sumo);
+export const SumoControlPanel: React.FC = ({ onLog }) => {
+ const { t } = useTranslation('sumo')
+ const dispatch = useAppDispatch()
+ const { status, isLoading, isSimulationRunning, simulationState } = useAppSelector(
+ (state) => state.sumo
+ )
- const [selectedScenario, setSelectedScenario] = useState("Nga4ThuDuc");
- const [useGUI, setUseGUI] = useState(false);
- const [port, setPort] = useState(8813);
- const [isStepping, setIsStepping] = useState(false);
- const [autoStep, setAutoStep] = useState(false);
+ const [selectedScenario, setSelectedScenario] = useState('Nga4ThuDuc')
+ const [useGUI, setUseGUI] = useState(false)
+ const [port, setPort] = useState(8813)
+ const [isStepping, setIsStepping] = useState(false)
+ const [autoStep, setAutoStep] = useState(false)
const handleStartSumo = async () => {
try {
- const config = SumoModelFactory.createConfiguration(
- selectedScenario,
- useGUI,
- port
- );
- await dispatch(startSimulation(config)).unwrap();
- onLog?.(t("controlPanel.log.started", { scenario: selectedScenario }));
+ const config = SumoModelFactory.createConfiguration(selectedScenario, useGUI, port)
+ await dispatch(startSimulation(config)).unwrap()
+ onLog?.(t('controlPanel.log.started', { scenario: selectedScenario }))
} catch (error) {
- onLog?.(t("controlPanel.log.startFailed", { error }));
+ onLog?.(t('controlPanel.log.startFailed', { error }))
}
- };
+ }
const handleStopSumo = async () => {
try {
- setAutoStep(false);
- setAutoStep(false);
- await dispatch(stopSimulation()).unwrap();
- onLog?.(t("controlPanel.log.stopped"));
+ setAutoStep(false)
+ setAutoStep(false)
+ await dispatch(stopSimulation()).unwrap()
+ onLog?.(t('controlPanel.log.stopped'))
} catch (error) {
- onLog?.(t("controlPanel.log.stopFailed", { error }));
+ onLog?.(t('controlPanel.log.stopFailed', { error }))
}
- };
+ }
const handleStepForward = useCallback(async () => {
- if (!isSimulationRunning) return;
- setIsStepping(true);
+ if (!isSimulationRunning) return
+ setIsStepping(true)
try {
- await dispatch(performSimulationStep()).unwrap();
+ await dispatch(performSimulationStep()).unwrap()
onLog?.(
- t("controlPanel.log.stepExecuted", {
- time: simulationState?.simulationTime || "N/A",
+ t('controlPanel.log.stepExecuted', {
+ time: simulationState?.simulationTime || 'N/A',
})
- );
+ )
} catch (error) {
- onLog?.(t("controlPanel.log.stepFailed", { error }));
+ onLog?.(t('controlPanel.log.stepFailed', { error }))
} finally {
- setIsStepping(false);
+ setIsStepping(false)
}
- }, [
- dispatch,
- isSimulationRunning,
- onLog,
- simulationState?.simulationTime,
- t,
- ]);
+ }, [dispatch, isSimulationRunning, onLog, simulationState?.simulationTime, t])
const handleRefreshStatus = async () => {
try {
- await dispatch(fetchSumoStatus()).unwrap();
- onLog?.(t("controlPanel.log.refreshed"));
+ await dispatch(fetchSumoStatus()).unwrap()
+ onLog?.(t('controlPanel.log.refreshed'))
} catch (error) {
- onLog?.(t("controlPanel.log.refreshFailed", { error }));
+ onLog?.(t('controlPanel.log.refreshFailed', { error }))
}
- };
+ }
useEffect(() => {
if (autoStep && isSimulationRunning && !isStepping) {
const interval = setInterval(() => {
- handleStepForward();
- }, 1000);
- return () => clearInterval(interval);
+ handleStepForward()
+ }, 1000)
+ return () => clearInterval(interval)
}
- }, [autoStep, isSimulationRunning, isStepping, handleStepForward]);
+ }, [autoStep, isSimulationRunning, isStepping, handleStepForward])
return (
- {t("controlPanel.title")}
+ {t('controlPanel.title')}
-
+
= ({
onRefresh={handleRefreshStatus}
/>
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/components/AIDecisionLogPanel.tsx b/src/frontend/src/presentation/components/feature/sumo/components/AIDecisionLogPanel.tsx
new file mode 100644
index 0000000..218659e
--- /dev/null
+++ b/src/frontend/src/presentation/components/feature/sumo/components/AIDecisionLogPanel.tsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2025 Green Wave Team
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { ScrollText } from 'lucide-react'
+import type { AIDecision } from '../../../../../domain/models/SumoModels'
+
+interface AIDecisionLog extends AIDecision {
+ timestamp: number
+}
+
+interface AIDecisionLogPanelProps {
+ decisions: AIDecisionLog[]
+}
+
+export const AIDecisionLogPanel: React.FC = ({ decisions }) => {
+ const { t } = useTranslation('sumo')
+
+ return (
+
+
+
+
+ {t('controlPanel.ai.decisionLog')}
+
+
+
+
+ {decisions.length === 0 ? (
+
+
+
+ {t('controlPanel.ai.noDecisions')}
+
+
+ {t('controlPanel.ai.enableAIToSeeDecisions')}
+
+
+ ) : (
+ decisions.map((log, index) => (
+
+
+
+ {log.tlsId}
+
+
+ t={log.timestamp.toFixed(0)}s
+
+
+
+
+ {log.action}
+
+
+ Phase {log.fromPhase} → {log.toPhase}
+
+
+ {log.reason && (
+
+ {log.reason}
+
+ )}
+
+ ))
+ )}
+
+
+
+ {decisions.length} {t('logs.count')} • {t('flowChart.liveUpdating')}
+
+
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/components/SumoActionButtons.tsx b/src/frontend/src/presentation/components/feature/sumo/components/SumoActionButtons.tsx
index b3a6256..9664dd5 100644
--- a/src/frontend/src/presentation/components/feature/sumo/components/SumoActionButtons.tsx
+++ b/src/frontend/src/presentation/components/feature/sumo/components/SumoActionButtons.tsx
@@ -1,10 +1,10 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React from "react";
-import { useTranslation } from "react-i18next";
+import React from 'react'
+import { useTranslation } from 'react-i18next'
import {
Play,
Square,
@@ -12,19 +12,20 @@ import {
PlayCircle,
PauseCircle,
RefreshCw,
-} from "lucide-react";
+ Loader2,
+} from 'lucide-react'
interface SumoActionButtonsProps {
- isSimulationRunning: boolean;
- isLoading: boolean;
- isStepping: boolean;
- autoStep: boolean;
- setAutoStep: (value: boolean) => void;
- onStart: () => void;
- onStop: () => void;
- onStep: () => void;
- onRefresh: () => void;
- disabled?: boolean;
+ isSimulationRunning: boolean
+ isLoading: boolean
+ isStepping: boolean
+ autoStep: boolean
+ setAutoStep: (value: boolean) => void
+ onStart: () => void
+ onStop: () => void
+ onStep: () => void
+ onRefresh: () => void
+ disabled?: boolean
}
export const SumoActionButtons: React.FC = ({
@@ -39,7 +40,7 @@ export const SumoActionButtons: React.FC = ({
onRefresh,
disabled = false,
}) => {
- const { t } = useTranslation(["traffic", "common"]);
+ const { t } = useTranslation(['traffic', 'common'])
return (
@@ -48,10 +49,10 @@ export const SumoActionButtons: React.FC
= ({
disabled={isSimulationRunning || isLoading || disabled}
className="flex items-center justify-center gap-2 px-4 py-3 eco-gradient-primary text-white font-semibold rounded-lg
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
- hover:shadow-lg hover:scale-105 disabled:hover:scale-100"
+ hover:shadow-lg hover:scale-105 disabled:hover:scale-100 min-w-[120px]"
>
-
- {isLoading ? t("traffic:connecting") : t("common:start")}
+ {isLoading ? : }
+ {t('common:start')}
= ({
disabled={!isSimulationRunning || isLoading || disabled}
className="flex items-center justify-center gap-2 px-4 py-3 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
- hover:shadow-lg hover:scale-105 disabled:hover:scale-100"
+ hover:shadow-lg hover:scale-105 disabled:hover:scale-100 min-w-[120px]"
>
- {t("common:stop")}
+ {t('common:stop')}
@@ -75,7 +76,6 @@ export const SumoActionButtons: React.FC
= ({
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
- {t("traffic:stepForward")}
= ({
disabled:opacity-50 disabled:cursor-not-allowed text-sm
${
autoStep
- ? "bg-red-600 hover:bg-red-700 text-white"
- : "bg-indigo-600 hover:bg-indigo-700 text-white"
+ ? 'bg-red-600 hover:bg-red-700 text-white'
+ : 'bg-indigo-600 hover:bg-indigo-700 text-white'
}`}
>
- {autoStep ? (
-
- ) : (
-
- )}
- {t("traffic:autoStep")}
+ {autoStep ? : }
= ({
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/sumo/components/SumoStatusDisplay.tsx b/src/frontend/src/presentation/components/feature/sumo/components/SumoStatusDisplay.tsx
index 64801fd..fd8accd 100644
--- a/src/frontend/src/presentation/components/feature/sumo/components/SumoStatusDisplay.tsx
+++ b/src/frontend/src/presentation/components/feature/sumo/components/SumoStatusDisplay.tsx
@@ -1,39 +1,34 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Wifi, WifiOff } from "lucide-react";
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { Wifi, WifiOff } from 'lucide-react'
-import type {
- SumoStatus,
- SumoSimulationState,
-} from "../../../../../domain/models/SumoModels";
+import type { SumoStatus, SumoSimulationState } from '../../../../../domain/models/SumoModels'
interface SumoStatusDisplayProps {
- status: SumoStatus;
- simulationState: SumoSimulationState | null;
- error: string | null;
+ status: SumoStatus
+ simulationState: SumoSimulationState | null
}
export const SumoStatusDisplay: React.FC
= ({
status,
simulationState,
- error,
}) => {
- const { t } = useTranslation("sumo");
+ const { t } = useTranslation('sumo')
const getStatusIcon = () => {
- if (status.connected) return ;
- return ;
- };
+ if (status.connected) return
+ return
+ }
const getStatusColor = () => {
- if (status.connected) return "text-emerald-500 dark:text-emerald-400";
- return "text-gray-500 dark:text-gray-400";
- };
+ if (status.connected) return 'text-emerald-500 dark:text-emerald-400'
+ return 'text-gray-500 dark:text-gray-400'
+ }
return (
@@ -41,49 +36,31 @@ export const SumoStatusDisplay: React.FC
= ({
{getStatusIcon()}
-
- {t("status.status")}
-
+
{t('status.status')}
- {status.connected
- ? t("status.connected")
- : t("status.disconnected")}
+ {status.connected ? t('status.connected') : t('status.disconnected')}
-
- {t("status.scenario")}
-
+
{t('status.scenario')}
- {status.scenario || t("status.notRunning")}
+ {status.scenario || t('status.notRunning')}
-
- {t("status.time")}
-
+
{t('status.time')}
- {simulationState?.simulationTime.toFixed(1) || "0.0"}s
+ {simulationState?.simulationTime.toFixed(1) || '0.0'}s
-
- {t("status.vehicles")}
-
+
{t('status.vehicles')}
{simulationState?.vehicleCount || 0}
-
- {error && (
-
-
- {t("status.error")}: {error}
-
-
- )}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/usermap/HealthInsightPanel.tsx b/src/frontend/src/presentation/components/feature/usermap/HealthInsightPanel.tsx
index 9b268f8..abb7c03 100644
--- a/src/frontend/src/presentation/components/feature/usermap/HealthInsightPanel.tsx
+++ b/src/frontend/src/presentation/components/feature/usermap/HealthInsightPanel.tsx
@@ -1,11 +1,11 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React from "react";
-import { useTranslation } from "react-i18next";
-import type { TFunction } from "i18next";
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import type { TFunction } from 'i18next'
import {
Leaf,
AlertTriangle,
@@ -15,72 +15,62 @@ import {
Compass,
MapPin,
Lightbulb,
-} from "lucide-react";
-import type { AirQualityObservedModel } from "../../../../domain/models/AirQualityObservedModel";
+} from 'lucide-react'
+import type { AirQualityObservedModel } from '../../../../domain/models/AirQualityObservedModel'
interface HealthInsightPanelProps {
- airQuality: AirQualityObservedModel;
- isDarkMode: boolean;
+ airQuality: AirQualityObservedModel
+ isDarkMode: boolean
}
-const getAQIState = (
- aqi: number | undefined,
- isDarkMode: boolean,
- t: TFunction<"aqi">
-) => {
- const value = aqi || 0;
+const getAQIState = (aqi: number | undefined, isDarkMode: boolean, t: TFunction<'aqi'>) => {
+ const value = aqi || 0
if (value <= 50) {
return {
- level: t("healthInsight.levels.safe"),
+ level: t('healthInsight.levels.safe'),
icon:
,
- color: "green",
- bgColor: isDarkMode
- ? "bg-green-900/50 border-green-700"
- : "bg-green-50 border-green-200",
- textColor: isDarkMode ? "text-green-300" : "text-green-800",
- iconColor: "text-green-600",
- recommendations: t("healthInsight.advice.safe", {
+ color: 'green',
+ bgColor: isDarkMode ? 'bg-green-900/50 border-green-700' : 'bg-green-50 border-green-200',
+ textColor: isDarkMode ? 'text-green-300' : 'text-green-800',
+ iconColor: 'text-green-600',
+ recommendations: t('healthInsight.advice.safe', {
returnObjects: true,
}) as string[],
- };
+ }
} else if (value <= 150) {
return {
- level: t("healthInsight.levels.poor"),
+ level: t('healthInsight.levels.poor'),
icon:
,
- color: "yellow",
- bgColor: isDarkMode
- ? "bg-yellow-900/50 border-yellow-700"
- : "bg-yellow-50 border-yellow-200",
- textColor: isDarkMode ? "text-yellow-300" : "text-yellow-800",
- iconColor: "text-yellow-600",
- recommendations: t("healthInsight.advice.poor", {
+ color: 'yellow',
+ bgColor: isDarkMode ? 'bg-yellow-900/50 border-yellow-700' : 'bg-yellow-50 border-yellow-200',
+ textColor: isDarkMode ? 'text-yellow-300' : 'text-yellow-800',
+ iconColor: 'text-yellow-600',
+ recommendations: t('healthInsight.advice.poor', {
returnObjects: true,
}) as string[],
- };
+ }
} else {
return {
- level: t("healthInsight.levels.hazardous"),
+ level: t('healthInsight.levels.hazardous'),
icon:
,
- color: "red",
- bgColor: isDarkMode
- ? "bg-red-900/50 border-red-700"
- : "bg-red-50 border-red-200",
- textColor: isDarkMode ? "text-red-300" : "text-red-800",
- iconColor: "text-red-600",
- recommendations: t("healthInsight.advice.hazardous", {
+ color: 'red',
+ bgColor: isDarkMode ? 'bg-red-900/50 border-red-700' : 'bg-red-50 border-red-200',
+ textColor: isDarkMode ? 'text-red-300' : 'text-red-800',
+ iconColor: 'text-red-600',
+ recommendations: t('healthInsight.advice.hazardous', {
returnObjects: true,
}) as string[],
- };
+ }
}
-};
+}
export const HealthInsightPanel: React.FC
= ({
airQuality,
isDarkMode,
}) => {
- const { t } = useTranslation(["aqi", "subscription"]);
- const aqiState = getAQIState(airQuality.airQualityIndex, isDarkMode, t);
+ const { t } = useTranslation(['aqi', 'subscription', 'locations'])
+ const aqiState = getAQIState(airQuality.airQualityIndex, isDarkMode, t)
return (
= ({
{/* Header */}
-
- {aqiState.icon}
-
+
{aqiState.icon}
-
- {t("healthInsight.status")}: {aqiState.level}
+
+ {t('healthInsight.status')}: {aqiState.level}
- {t("healthInsight.index")}: {airQuality.airQualityIndex || "N/A"}
+ {t('healthInsight.index')}: {airQuality.airQualityIndex || 'N/A'}
@@ -114,11 +100,19 @@ export const HealthInsightPanel: React.FC
= ({
- {" "}
- {airQuality.areaServed}
+ {' '}
+ {airQuality.areaServed && airQuality.areaServed.includes('locations.')
+ ? airQuality.areaServed
+ .split(', ')
+ .map((k) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ t(k.trim().replace('locations.', ''), { ns: 'locations' } as any)
+ )
+ .join(', ')
+ : airQuality.areaServed}
)}
@@ -126,70 +120,51 @@ export const HealthInsightPanel: React.FC = ({
{/* Air Quality Details */}
-
+
PM2.5
- {airQuality.pm25 || "N/A"} μg/m³
+ {airQuality.pm25 || 'N/A'} μg/m³
-
+
PM10
- {airQuality.pm10 || "N/A"} μg/m³
+ {airQuality.pm10 || 'N/A'} μg/m³
-
- {t("attributes.temperature", { ns: "subscription" })}
+
+ {t('attributes.temperature', { ns: 'subscription' })}
- {airQuality.temperature || "N/A"}°C
+ {airQuality.temperature || 'N/A'}°C
-
- {t("attributes.humidity", { ns: "subscription" })}
+
+ {t('attributes.humidity', { ns: 'subscription' })}
- {airQuality.relativeHumidity
- ? Math.round(airQuality.relativeHumidity * 100)
- : "N/A"}
- %
+ {airQuality.relativeHumidity ? Math.round(airQuality.relativeHumidity * 100) : 'N/A'}%
@@ -199,20 +174,15 @@ export const HealthInsightPanel: React.FC = ({
- {" "}
- {t("healthInsight.recommendations")}:
+ {t('healthInsight.recommendations')}:
{Array.isArray(aqiState.recommendations) &&
aqiState.recommendations.map((recommendation, index) => (
-
+
{recommendation}
@@ -227,22 +197,18 @@ export const HealthInsightPanel: React.FC = ({
{airQuality.windSpeed && (
- {t("healthInsight.windSpeed")}
- : {airQuality.windSpeed} m/s
+ {t('healthInsight.windSpeed')}:{' '}
+ {airQuality.windSpeed} m/s
)}
{airQuality.windDirection && (
- {" "}
- {t("healthInsight.windDirection")}: {airQuality.windDirection}°
+ {t('healthInsight.windDirection')}:{' '}
+ {airQuality.windDirection}°
)}
@@ -252,12 +218,11 @@ export const HealthInsightPanel: React.FC = ({
{/* Timestamp */}
{airQuality.dateObserved && (
-
- {t("healthInsight.updated")}:{" "}
- {new Date(airQuality.dateObserved).toLocaleString()}
+
+ {t('healthInsight.updated')}: {new Date(airQuality.dateObserved).toLocaleString()}
)}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/components/feature/usermap/UserMapView.tsx b/src/frontend/src/presentation/components/feature/usermap/UserMapView.tsx
index a63e933..24e7c0f 100644
--- a/src/frontend/src/presentation/components/feature/usermap/UserMapView.tsx
+++ b/src/frontend/src/presentation/components/feature/usermap/UserMapView.tsx
@@ -1,46 +1,37 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useEffect, useState } from "react";
-import {
- MapContainer,
- TileLayer,
- Marker,
- Popup,
- Circle,
- useMap,
- useMapEvents,
-} from "react-leaflet";
-import L from "leaflet";
-import { Locate, Navigation } from "lucide-react";
-import "leaflet/dist/leaflet.css";
-import type { AirQualityObservedModel } from "../../../../domain/models/AirQualityObservedModel";
-import type { LocationModel } from "../../../../domain/models/CommonModels";
-import { GeoJSONType } from "../../../../domain/models/CommonModels";
-import { useTranslation } from "react-i18next";
-
-import { TRAFFIC_LOCATIONS } from "../../../../utils/trafficLocations";
+import React, { useEffect, useState } from 'react'
+import { MapContainer, TileLayer, Marker, Popup, Circle, useMap, useMapEvents } from 'react-leaflet'
+import L from 'leaflet'
+import { Locate, Navigation } from 'lucide-react'
+import 'leaflet/dist/leaflet.css'
+import type { AirQualityObservedModel } from '../../../../domain/models/AirQualityObservedModel'
+import type { LocationModel } from '../../../../domain/models/CommonModels'
+import { GeoJSONType } from '../../../../domain/models/CommonModels'
+import { useTranslation } from 'react-i18next'
+
+import { TRAFFIC_LOCATIONS } from '../../../../utils/trafficLocations'
interface UserMapViewProps {
- isDarkMode: boolean;
- airQualityData: AirQualityObservedModel[];
- selectedLocation: LocationModel | null;
- onLocationSelect: (
- location: LocationModel,
- airQuality?: AirQualityObservedModel
- ) => void;
- onTrafficSelect: (scenarioId: string) => void;
- searchQuery: string;
+ isDarkMode: boolean
+ airQualityData: AirQualityObservedModel[]
+ selectedLocation: LocationModel | null
+ onLocationSelect: (location: LocationModel, airQuality?: AirQualityObservedModel) => void
+ onTrafficSelect: (scenarioId: string) => void
+ searchQuery: string
}
// Custom icons for different AQI levels
const getAQIIcon = (aqi: number): L.DivIcon => {
- let color = "#10B981"; // Green for good
- if (aqi > 150) color = "#D9232F"; // Red for hazardous
- else if (aqi > 100) color = "#F59E0B"; // Orange for unhealthy
- else if (aqi > 50) color = "#FBBF24"; // Yellow for moderate
+ let color = '#10B981' // Green for good
+ if (aqi > 150)
+ color = '#D9232F' // Red for hazardous
+ else if (aqi > 100)
+ color = '#F59E0B' // Orange for unhealthy
+ else if (aqi > 50) color = '#FBBF24' // Yellow for moderate
return L.divIcon({
html: `
@@ -61,24 +52,24 @@ const getAQIIcon = (aqi: number): L.DivIcon => {
${aqi}
`,
- className: "custom-aqi-marker",
+ className: 'custom-aqi-marker',
iconSize: [30, 30],
iconAnchor: [15, 15],
- });
-};
+ })
+}
// Component to handle map interactions and logic
const MapController: React.FC<{
- searchQuery: string;
- onLocationSelect: (location: LocationModel) => void;
+ searchQuery: string
+ onLocationSelect: (location: LocationModel) => void
}> = ({ searchQuery, onLocationSelect }) => {
- const map = useMap();
- const [userLocation, setUserLocation] = useState(null);
- const [userAccuracy, setUserAccuracy] = useState(null);
- const [isFollowing, setIsFollowing] = useState(true);
- const [lastUpdate, setLastUpdate] = useState(null);
- const [speed, setSpeed] = useState(null);
- const { t, i18n } = useTranslation(["maps", "common"]);
+ const map = useMap()
+ const [userLocation, setUserLocation] = useState(null)
+ const [userAccuracy, setUserAccuracy] = useState(null)
+ const [isFollowing, setIsFollowing] = useState(true)
+ const [lastUpdate, setLastUpdate] = useState(null)
+ const [speed, setSpeed] = useState(null)
+ const { t, i18n } = useTranslation(['maps', 'common'])
// Handle map clicks
useMapEvents({
@@ -86,59 +77,59 @@ const MapController: React.FC<{
const location: LocationModel = {
type: GeoJSONType.Point,
coordinates: [e.latlng.lng, e.latlng.lat],
- };
- onLocationSelect(location);
+ }
+ onLocationSelect(location)
},
dragstart() {
// Stop following if user manually drags the map
- setIsFollowing(false);
+ setIsFollowing(false)
},
- });
+ })
// Locate user on mount with realtime watching using native Geolocation API
useEffect(() => {
- let watchId: number | null = null;
+ let watchId: number | null = null
- if ("geolocation" in navigator) {
+ if ('geolocation' in navigator) {
watchId = navigator.geolocation.watchPosition(
(position) => {
- const { latitude, longitude, accuracy, speed } = position.coords;
- const latlng = new L.LatLng(latitude, longitude);
+ const { latitude, longitude, accuracy, speed } = position.coords
+ const latlng = new L.LatLng(latitude, longitude)
- setUserLocation(latlng);
- setUserAccuracy(accuracy);
- setLastUpdate(new Date());
- setSpeed(speed);
+ setUserLocation(latlng)
+ setUserAccuracy(accuracy)
+ setLastUpdate(new Date())
+ setSpeed(speed)
// Only auto-center if we are in "following" mode
if (isFollowing) {
map.setView(latlng, map.getZoom() < 16 ? 16 : map.getZoom(), {
animate: true,
- });
+ })
}
},
(error) => {
- console.warn("Location error:", error.message);
+ console.warn('Location error:', error.message)
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
- );
+ )
}
// Cleanup function to stop watching when component unmounts
return () => {
if (watchId !== null) {
- navigator.geolocation.clearWatch(watchId);
+ navigator.geolocation.clearWatch(watchId)
}
- };
- }, [map, isFollowing]);
+ }
+ }, [map, isFollowing])
// Handle search
useEffect(() => {
- if (!searchQuery) return;
+ if (!searchQuery) return
const searchLocation = async () => {
try {
@@ -146,45 +137,45 @@ const MapController: React.FC<{
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(
searchQuery
)}&limit=1`
- );
- const data = await response.json();
+ )
+ const data = await response.json()
if (data && data.length > 0) {
- const result = data[0];
- const lat = parseFloat(result.lat);
- const lng = parseFloat(result.lon);
- const latLng = new L.LatLng(lat, lng);
+ const result = data[0]
+ const lat = parseFloat(result.lat)
+ const lng = parseFloat(result.lon)
+ const latLng = new L.LatLng(lat, lng)
- map.setView(latLng, 15);
- setIsFollowing(false); // Stop following user when searching
+ map.setView(latLng, 15)
+ setIsFollowing(false) // Stop following user when searching
const location: LocationModel = {
type: GeoJSONType.Point,
coordinates: [lng, lat],
- };
- onLocationSelect(location);
+ }
+ onLocationSelect(location)
// Add a temporary popup
L.popup()
.setLatLng(latLng)
.setContent(`${result.display_name}
`)
- .openOn(map);
+ .openOn(map)
}
} catch (error) {
- console.error("Search error:", error);
+ console.error('Search error:', error)
}
- };
+ }
- const timeoutId = setTimeout(searchLocation, 500);
- return () => clearTimeout(timeoutId);
- }, [searchQuery, map, onLocationSelect]);
+ const timeoutId = setTimeout(searchLocation, 500)
+ return () => clearTimeout(timeoutId)
+ }, [searchQuery, map, onLocationSelect])
const handleLocateClick = () => {
- setIsFollowing(true);
+ setIsFollowing(true)
if (userLocation) {
- map.flyTo(userLocation, 16);
+ map.flyTo(userLocation, 16)
}
- };
+ }
return (
<>
@@ -203,19 +194,19 @@ const MapController: React.FC<{
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3);
">
`,
- className: "user-location-marker",
+ className: 'user-location-marker',
iconSize: [24, 24],
iconAnchor: [12, 12],
})}
>
-
{t("userLocation.youAreHere")}
+
{t('userLocation.youAreHere')}
{userAccuracy && (
- {t("userLocation.title")}
+ {t('userLocation.title')}
- {t("userLocation.lat")}:
+ {t('userLocation.lat')}:
{userLocation.lat.toFixed(6)}°
- {t("userLocation.lng")}:
+ {t('userLocation.lng')}:
{userLocation.lng.toFixed(6)}°
{userAccuracy && (
-
- {t("userLocation.accuracy")}:
-
+ {t('userLocation.accuracy')}:
±{Math.round(userAccuracy)}m
)}
{speed !== null && speed > 0 && (
- {t("userLocation.speed")}:
-
- {(speed * 3.6).toFixed(1)} km/h
-
+ {t('userLocation.speed')}:
+ {(speed * 3.6).toFixed(1)} km/h
)}
{lastUpdate && (
-
- {t("userLocation.updated")}:
-
-
- {lastUpdate.toLocaleTimeString(i18n.language)}
-
+ {t('userLocation.updated')}:
+ {lastUpdate.toLocaleTimeString(i18n.language)}
)}
@@ -276,25 +259,17 @@ const MapController: React.FC<{
onClick={handleLocateClick}
className={`p-2 rounded-full shadow-lg transition-colors ${
isFollowing
- ? "bg-blue-500 text-white hover:bg-blue-600"
- : "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
+ ? 'bg-blue-500 text-white hover:bg-blue-600'
+ : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
- title={
- isFollowing
- ? t("userLocation.following")
- : t("userLocation.locateMe")
- }
+ title={isFollowing ? t('userLocation.following') : t('userLocation.locateMe')}
>
- {isFollowing ? (
-
- ) : (
-
- )}
+ {isFollowing ? : }
>
- );
-};
+ )
+}
export const UserMapView: React.FC
= ({
isDarkMode,
@@ -303,10 +278,10 @@ export const UserMapView: React.FC = ({
onTrafficSelect,
searchQuery,
}) => {
- const { t } = useTranslation(["maps", "common", "locations"]);
+ const { t } = useTranslation(['maps', 'common', 'locations'])
const tileUrl = isDarkMode
- ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
- : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
+ ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
+ : 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
return (
@@ -330,14 +305,15 @@ export const UserMapView: React.FC = ({
{/* Heatmap Circles */}
{airQualityData.map((item) => {
- if (!item.location || !item.airQualityIndex) return null;
- const [lng, lat] = item.location.coordinates;
- const intensity = Math.min(item.airQualityIndex / 200, 1);
- const radius = 1000 + intensity * 2000;
+ if (!item.location || !item.airQualityIndex) return null
+ const [lng, lat] = item.location.coordinates
+ const intensity = Math.min(item.airQualityIndex / 200, 1)
+ const radius = 1000 + intensity * 2000
- let color = "#10B981"; // Green
- if (intensity > 0.75) color = "#D9232F"; // Red
- else if (intensity > 0.5) color = "#FBBF24"; // Yellow
+ let color = '#10B981' // Green
+ if (intensity > 0.75)
+ color = '#D9232F' // Red
+ else if (intensity > 0.5) color = '#FBBF24' // Yellow
return (
= ({
stroke: false,
}}
/>
- );
+ )
})}
{/* Traffic Location Markers */}
@@ -376,7 +352,7 @@ export const UserMapView: React.FC = ({
🚦
`,
- className: "traffic-marker",
+ className: 'traffic-marker',
iconSize: [32, 32],
iconAnchor: [16, 16],
})}
@@ -388,18 +364,18 @@ export const UserMapView: React.FC = ({
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {t(loc.nameKey.replace(/^locations\./, "") as any, {
- ns: "locations",
+ {t(loc.nameKey.replace(/^locations\./, '') as any, {
+ ns: 'locations',
})}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {t(loc.descriptionKey.replace(/^locations\./, "") as any, {
- ns: "locations",
+ {t(loc.descriptionKey.replace(/^locations\./, '') as any, {
+ ns: 'locations',
})}
- {t("trafficMarker.clickToView")}
+ {t('trafficMarker.clickToView')}
@@ -408,8 +384,8 @@ export const UserMapView: React.FC = ({
{/* Air Quality Markers */}
{airQualityData.map((data) => {
- if (!data.location) return null;
- const [lng, lat] = data.location.coordinates;
+ if (!data.location) return null
+ const [lng, lat] = data.location.coordinates
return (
= ({
- {data.areaServed || t("location", { ns: "common" })}
+ {data.areaServed && data.areaServed.includes('locations.')
+ ? data.areaServed
+ .split(', ')
+ .map((k) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ t(k.trim().replace('locations.', ''), { ns: 'locations' } as any)
+ )
+ .join(', ')
+ : data.areaServed || t('location', { ns: 'common' })}
- {t("aqi")} {" "}
- {data.airQualityIndex || "N/A"}
+ {t('aqi')} {data.airQualityIndex || 'N/A'}
- {t("pm25")} {data.pm25 || "N/A"} μg/m³
+ {t('pm25')} {data.pm25 || 'N/A'} μg/m³
- PM10: {data.pm10 || "N/A"} μg/m³
+ PM10: {data.pm10 || 'N/A'} μg/m³
- Nhiệt độ: {data.temperature || "N/A"}°C
+ {t('common:temperature', { defaultValue: 'Temperature' })}: {' '}
+ {data.temperature || 'N/A'}°C
- Độ ẩm: {" "}
- {data.relativeHumidity
- ? Math.round(data.relativeHumidity * 100)
- : "N/A"}
- %
+ {t('common:humidity', { defaultValue: 'Humidity' })}: {' '}
+ {data.relativeHumidity ? Math.round(data.relativeHumidity * 100) : 'N/A'}%
- );
+ )
})}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/i18n/locales/en.json b/src/frontend/src/presentation/i18n/locales/en.json
index 782dfdb..c0ec208 100644
--- a/src/frontend/src/presentation/i18n/locales/en.json
+++ b/src/frontend/src/presentation/i18n/locales/en.json
@@ -42,7 +42,9 @@
"warning": "Warning",
"info": "Info",
"success": "Success",
- "type": "Type"
+ "type": "Type",
+ "temperature": "Temperature",
+ "humidity": "Humidity"
},
"locations": {
"nga4ThuDuc": "Thu Duc 4-way Intersection",
@@ -496,7 +498,13 @@
"inProgress": "In Progress",
"completed": "Completed"
},
- "aiTag": "AI"
+ "pm25Critical": "Critical PM2.5 level at {{location}} ({{value}})",
+ "trafficCongestion": "Traffic congestion at {{location}}",
+ "aiTag": "AI",
+ "interventions": {
+ "signalAdjustment": "SignalAdjustment: Extended Green Phase for {{direction}}",
+ "routeRebalancing": "RouteRebalancing: Suggested alternate route via {{route}}"
+ }
},
"user": {
"profile": "Profile",
@@ -814,6 +822,9 @@
"disable": "Disable AI",
"disabledMessage": "AI Control is disabled",
"startSumoMessage": "Start SUMO, then enable AI control",
+ "decisionLog": "AI Decision Log",
+ "noDecisions": "No AI decisions yet",
+ "enableAIToSeeDecisions": "Enable AI Control to see decisions",
"log": {
"enabled": "🧠 AI Traffic Control enabled successfully!",
"enableFailed": "❌ Failed to enable AI: {{error}}",
diff --git a/src/frontend/src/presentation/i18n/locales/vi.json b/src/frontend/src/presentation/i18n/locales/vi.json
index b619bbe..26943e3 100644
--- a/src/frontend/src/presentation/i18n/locales/vi.json
+++ b/src/frontend/src/presentation/i18n/locales/vi.json
@@ -42,7 +42,9 @@
"warning": "Cảnh báo",
"info": "Thông tin",
"success": "Thành công",
- "type": "Loại"
+ "type": "Loại",
+ "temperature": "Nhiệt độ",
+ "humidity": "Độ ẩm"
},
"locations": {
"nga4ThuDuc": "Ngã 4 Thủ Đức",
@@ -496,7 +498,13 @@
"inProgress": "Đang thực hiện",
"completed": "Hoàn thành"
},
- "aiTag": "AI"
+ "pm25Critical": "Chỉ số PM2.5 nguy hại tại {{location}} ({{value}})",
+ "trafficCongestion": "Tắc nghẽn giao thông tại {{location}}",
+ "aiTag": "AI",
+ "interventions": {
+ "signalAdjustment": "Điều chỉnh tín hiệu: Kéo dài pha đèn xanh cho {{direction}}",
+ "routeRebalancing": "Cân bằng tuyến đường: Đề xuất tuyến đường thay thế qua {{route}}"
+ }
},
"user": {
"profile": "Hồ sơ",
@@ -814,6 +822,9 @@
"disable": "Tắt AI",
"disabledMessage": "Điều khiển AI đang tắt",
"startSumoMessage": "Hãy khởi động SUMO, sau đó bật điều khiển AI",
+ "decisionLog": "Nhật ký Quyết định AI",
+ "noDecisions": "Chưa có quyết định AI",
+ "enableAIToSeeDecisions": "Bật Điều khiển AI để xem quyết định",
"log": {
"enabled": "🧠 Điều khiển Giao thông AI đã bật thành công!",
"enableFailed": "❌ Bật AI thất bại: {{error}}",
diff --git a/src/frontend/src/presentation/pages/admin/ControlTrafficPage.tsx b/src/frontend/src/presentation/pages/admin/ControlTrafficPage.tsx
index 0b7394a..f34d641 100644
--- a/src/frontend/src/presentation/pages/admin/ControlTrafficPage.tsx
+++ b/src/frontend/src/presentation/pages/admin/ControlTrafficPage.tsx
@@ -1,103 +1,102 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useState, useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { useAppDispatch, useAppSelector } from "../../../data/redux/hooks";
-import { fetchSumoStatus, fetchSumoState } from "../../../data/redux/sumoSlice";
-import { DashboardHeader } from "../../components/feature/dashboard/DashboardHeader";
-import { SumoControlPanel } from "../../components/feature/sumo/SumoControlPanel";
-import { TrafficMetrics } from "../../components/feature/sumo/TrafficMetrics";
-import { TrafficLightsDisplay } from "../../components/feature/sumo/TrafficLightsDisplay";
-import { AIControlPanel } from "../../components/feature/sumo/AIControlPanel";
-import { SystemLogs } from "../../components/feature/sumo/SystemLogs";
-import { TrafficFlowChart } from "../../components/feature/sumo/TrafficFlowChart";
-import { SimulationControlPanel } from "../../components/feature/sumo/SimulationControlPanel";
+import React, { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAppDispatch, useAppSelector } from '../../../data/redux/hooks'
+import { fetchSumoStatus, fetchSumoState } from '../../../data/redux/sumoSlice'
+import { DashboardHeader } from '../../components/feature/dashboard/DashboardHeader'
+import { SumoControlPanel } from '../../components/feature/sumo/SumoControlPanel'
+import { TrafficMetrics } from '../../components/feature/sumo/TrafficMetrics'
+import { TrafficLightsDisplay } from '../../components/feature/sumo/TrafficLightsDisplay'
+import { AIControlPanel, type AIDecisionLog } from '../../components/feature/sumo/AIControlPanel'
+import { AIDecisionLogPanel } from '../../components/feature/sumo/components/AIDecisionLogPanel'
+import { SystemLogs } from '../../components/feature/sumo/SystemLogs'
+import { TrafficFlowChart } from '../../components/feature/sumo/TrafficFlowChart'
+import { SimulationControlPanel } from '../../components/feature/sumo/SimulationControlPanel'
export const ControlTrafficPage: React.FC = () => {
- const dispatch = useAppDispatch();
- const { status, isSimulationRunning } = useAppSelector((state) => state.sumo);
- const { t } = useTranslation(["traffic", "common"]);
+ const dispatch = useAppDispatch()
+ const { status, isSimulationRunning } = useAppSelector((state) => state.sumo)
+ const { t } = useTranslation(['traffic', 'common'])
// Initialize dark mode from localStorage or system preference
const getInitialDarkMode = () => {
- if (typeof window !== "undefined") {
- const stored = localStorage.getItem("darkMode");
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem('darkMode')
if (stored !== null) {
- return stored === "true";
+ return stored === 'true'
}
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
}
- return false;
- };
+ return false
+ }
- const [isDarkMode, setIsDarkMode] = useState(getInitialDarkMode);
+ const [isDarkMode, setIsDarkMode] = useState(getInitialDarkMode)
const [systemLogs, setSystemLogs] = useState
([
- t("traffic:logs.initSuccess"),
- t("traffic:logs.waitingConnection"),
- ]);
+ t('traffic:logs.initSuccess'),
+ t('traffic:logs.waitingConnection'),
+ ])
+ const [aiDecisionLogs, setAiDecisionLogs] = useState([])
// Apply dark mode class on mount
useEffect(() => {
if (isDarkMode) {
- document.documentElement.classList.add("dark");
+ document.documentElement.classList.add('dark')
} else {
- document.documentElement.classList.remove("dark");
+ document.documentElement.classList.remove('dark')
}
- }, [isDarkMode]);
+ }, [isDarkMode])
// Initialize data on component mount
useEffect(() => {
// Initial fetch of SUMO status
const initializeData = async () => {
try {
- await dispatch(fetchSumoStatus()).unwrap();
- addLog(t("traffic:logs.sumoInitSuccess"));
+ await dispatch(fetchSumoStatus()).unwrap()
+ addLog(t('traffic:logs.sumoInitSuccess'))
} catch (error) {
- addLog(t("traffic:logs.sumoInitFailed", { error }));
+ addLog(t('traffic:logs.sumoInitFailed', { error }))
}
- };
+ }
- initializeData();
+ initializeData()
// Poll status every 5 seconds
const statusInterval = setInterval(() => {
- dispatch(fetchSumoStatus());
- }, 5000);
+ dispatch(fetchSumoStatus())
+ }, 5000)
- return () => clearInterval(statusInterval);
- }, [dispatch, t]);
+ return () => clearInterval(statusInterval)
+ }, [dispatch, t])
// Fetch SUMO state when simulation is running
useEffect(() => {
if (status.connected && isSimulationRunning) {
// Fetch state every 1 second
const stateInterval = setInterval(() => {
- dispatch(fetchSumoState());
- }, 1000);
+ dispatch(fetchSumoState())
+ }, 1000)
- return () => clearInterval(stateInterval);
+ return () => clearInterval(stateInterval)
}
- }, [status.connected, isSimulationRunning, dispatch]);
+ }, [status.connected, isSimulationRunning, dispatch])
const handleThemeToggle = () => {
- const newDarkMode = !isDarkMode;
- setIsDarkMode(newDarkMode);
- localStorage.setItem("darkMode", newDarkMode.toString());
- };
+ const newDarkMode = !isDarkMode
+ setIsDarkMode(newDarkMode)
+ localStorage.setItem('darkMode', newDarkMode.toString())
+ }
const addLog = (message: string) => {
- setSystemLogs((prev) => [...prev, message]);
- };
+ setSystemLogs((prev) => [...prev, message])
+ }
return (
-
+
{/* Main Layout: Left Sidebar (1/4) + Right Content (3/4) */}
@@ -125,10 +124,13 @@ export const ControlTrafficPage: React.FC = () => {
{/* AI Control Panel */}
+ {/* AI Decision Log Panel - Full Width Below */}
+
+
{/* Bottom Row: Traffic Flow Chart */}
@@ -142,13 +144,13 @@ export const ControlTrafficPage: React.FC = () => {
{/* Footer Info */}
- {t("traffic:footer.systemName")}
+ {t('traffic:footer.systemName')}
- {t("traffic:footer.poweredBy")}
+ {t('traffic:footer.poweredBy')}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/pages/admin/ManagerDashboard.tsx b/src/frontend/src/presentation/pages/admin/ManagerDashboard.tsx
index 8030ace..5d3759a 100644
--- a/src/frontend/src/presentation/pages/admin/ManagerDashboard.tsx
+++ b/src/frontend/src/presentation/pages/admin/ManagerDashboard.tsx
@@ -1,17 +1,16 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useState, useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { DashboardHeader } from "../../components/feature/dashboard/DashboardHeader";
-import { KPICard } from "../../components/feature/dashboard/KPICard";
-import { MonitoringChart } from "../../components/feature/dashboard/MonitoringChart";
-import { DeviceHealthPanel } from "../../components/feature/dashboard/DeviceHealthPanel";
-import { PollutionMap } from "../../components/feature/dashboard/PollutionMap";
-import { AlertPanel } from "../../components/feature/dashboard/AlertPanel";
-import { ManualControlPanel } from "../../components/feature/dashboard/ManualControlPanel";
+import React, { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { DashboardHeader } from '../../components/feature/dashboard/DashboardHeader'
+import { KPICard } from '../../components/feature/dashboard/KPICard'
+import { MonitoringChart } from '../../components/feature/dashboard/MonitoringChart'
+import { DeviceHealthPanel } from '../../components/feature/dashboard/DeviceHealthPanel'
+import { AlertPanel } from '../../components/feature/dashboard/AlertPanel'
+import { ManualControlPanel } from '../../components/feature/dashboard/ManualControlPanel'
import type {
DashboardStateModel,
KPICardModel,
@@ -20,118 +19,162 @@ import type {
PollutionHotspot,
AlertLog,
InterventionAction,
-} from "../../../domain/models/DashboardModel";
-import { sumoApi } from "../../../api/sumoApi";
-import { airQualityApi } from "../../../api/airQualityApi";
-import type { AirQualityObservedDto } from "../../../data/dtos/AirQualityDTOs";
-import { TRAFFIC_LOCATIONS } from "../../../utils/trafficLocations";
-import type { TFunction } from "i18next";
+} from '../../../domain/models/DashboardModel'
+import { sumoApi } from '../../../api/sumoApi'
+import { airQualityApi } from '../../../api/airQualityApi'
+import type { AirQualityObservedDto } from '../../../data/dtos/AirQualityDTOs'
+import { TRAFFIC_LOCATIONS } from '../../../utils/trafficLocations'
+import type { TFunction } from 'i18next'
export const ManagerDashboard: React.FC = () => {
const { t } = useTranslation([
- "dashboard",
- "monitoring",
- "traffic",
- "devices",
- "common",
- ]);
+ 'dashboard',
+ 'monitoring',
+ 'traffic',
+ 'devices',
+ 'common',
+ 'locations',
+ 'alerts',
+ ])
// Initialize dark mode from localStorage or system preference
const getInitialDarkMode = () => {
- if (typeof window !== "undefined") {
- const stored = localStorage.getItem("darkMode");
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem('darkMode')
if (stored !== null) {
- return stored === "true";
+ return stored === 'true'
}
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
}
- return false;
- };
+ return false
+ }
- const [isDarkMode, setIsDarkMode] = useState(getInitialDarkMode);
+ const [isDarkMode, setIsDarkMode] = useState(getInitialDarkMode)
// Mock data generator (moved inside component to access t)
const generateMockData = (
- translate: TFunction<["dashboard", "devices", "common"]>
+ translate: TFunction<['dashboard', 'devices', 'common', 'locations', 'alerts']>
): DashboardStateModel => {
- const now = new Date();
+ const now = new Date()
const timePoints = Array.from({ length: 20 }, (_, i) => {
- const time = new Date(now.getTime() - (19 - i) * 5 * 60 * 1000);
- return time.toISOString();
- });
+ const time = new Date(now.getTime() - (19 - i) * 5 * 60 * 1000)
+ return time.toISOString()
+ })
const kpis: KPICardModel[] = [
{
- id: "1",
- title: translate("dashboard:kpi.waitingTime"),
+ id: '1',
+ title: translate('dashboard:kpi.waitingTime'),
value: 45,
- unit: translate("dashboard:kpi.unit.seconds"),
- trend: "down",
+ unit: translate('dashboard:kpi.unit.seconds'),
+ trend: 'down',
trendValue: -12,
- icon: "Clock",
- color: "blue",
+ icon: 'Clock',
+ color: 'blue',
},
{
- id: "2",
- title: "PM2.5",
+ id: '2',
+ title: 'PM2.5',
value: 35,
- unit: "μg/m³",
- trend: "down",
+ unit: 'μg/m³',
+ trend: 'down',
trendValue: -8,
- icon: "Leaf",
- color: "green",
+ icon: 'Leaf',
+ color: 'green',
},
{
- id: "3",
- title: translate("dashboard:kpi.vehicleCount"),
+ id: '3',
+ title: translate('dashboard:kpi.vehicleCount'),
value: 1248,
- unit: translate("dashboard:kpi.unit.vehiclePerHour"),
- trend: "up",
+ unit: translate('dashboard:kpi.unit.vehiclePerHour'),
+ trend: 'up',
trendValue: 5,
- icon: "Car",
- color: "yellow",
+ icon: 'Car',
+ color: 'yellow',
},
{
- id: "4",
- title: translate("dashboard:kpi.aiScore"),
+ id: '4',
+ title: translate('dashboard:kpi.aiScore'),
value: 87,
- unit: "%",
- trend: "up",
+ unit: '%',
+ trend: 'up',
trendValue: 3,
- icon: "Brain",
- color: "blue",
+ icon: 'Brain',
+ color: 'blue',
},
- ];
+ ]
- const monitoringData: MonitoringDataPoint[] = timePoints.map(
- (time, index) => ({
- timestamp: time,
- avgWaitingTime: 40 + Math.sin(index / 3) * 15 + Math.random() * 5,
- pm25Level: 30 + Math.cos(index / 4) * 20 + Math.random() * 8,
- })
- );
+ const monitoringData: MonitoringDataPoint[] = timePoints.map((time, index) => ({
+ timestamp: time,
+ avgWaitingTime: 40 + Math.sin(index / 3) * 15 + Math.random() * 5,
+ pm25Level: 30 + Math.cos(index / 4) * 20 + Math.random() * 8,
+ }))
const rewardData: RewardDataPoint[] = timePoints.map((time) => ({
timestamp: time,
trafficReward: 40 + Math.random() * 20,
environmentReward: 30 + Math.random() * 25,
- }));
-
- const pollutionHotspots: PollutionHotspot[] = TRAFFIC_LOCATIONS.map(
- (loc) => ({
- id: loc.id,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- name: translate(loc.nameKey as any),
- latitude: loc.coordinates[0],
- longitude: loc.coordinates[1],
- pm25: 30 + Math.random() * 30, // Random initial value
- aqi: 50 + Math.random() * 50, // Random initial value
- severity: "medium",
- })
- );
-
- const alerts: AlertLog[] = [];
- const interventions: InterventionAction[] = [];
+ }))
+
+ const pollutionHotspots: PollutionHotspot[] = TRAFFIC_LOCATIONS.map((loc) => ({
+ id: loc.id,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ name: translate(loc.nameKey as any),
+ latitude: loc.coordinates[0],
+ longitude: loc.coordinates[1],
+ pm25: 30 + Math.random() * 30, // Random initial value
+ aqi: 50 + Math.random() * 50, // Random initial value
+ severity: 'medium',
+ }))
+
+ const alerts: AlertLog[] = [
+ {
+ id: 'alert_1',
+ timestamp: new Date(now.getTime() - 15 * 60000).toISOString(),
+ type: 'critical',
+ message: translate('alerts:pm25Critical', {
+ location: 'Ngã 4 Thủ Đức',
+ value: 180,
+ } as any) as unknown as string,
+ resolved: false,
+ },
+ {
+ id: 'alert_2',
+ timestamp: new Date(now.getTime() - 45 * 60000).toISOString(),
+ type: 'warning',
+ message: translate('alerts:trafficCongestion', {
+ location: 'Hàng Xanh',
+ } as any) as unknown as string,
+ resolved: false,
+ },
+ ]
+
+ const interventions: InterventionAction[] = [
+ {
+ id: 'int_1',
+ timestamp: new Date(now.getTime() - 10 * 60000).toISOString(),
+ action: String(
+ translate('alerts:interventions.signalAdjustment', {
+ direction: 'North-South',
+ } as any)
+ ),
+ target: 'Ngã 4 Thủ Đức',
+ status: 'completed',
+ aiTriggered: true,
+ },
+ {
+ id: 'int_2',
+ timestamp: new Date(now.getTime() - 120 * 60000).toISOString(),
+ action: String(
+ translate('alerts:interventions.routeRebalancing', {
+ route: 'Pham Van Dong',
+ } as any)
+ ),
+ target: 'Hàng Xanh',
+ status: 'pending',
+ aiTriggered: false,
+ },
+ ]
return {
kpis,
@@ -141,115 +184,105 @@ export const ManagerDashboard: React.FC = () => {
alerts,
interventions,
lastUpdated: now.toISOString(),
- };
- };
+ }
+ }
- const [dashboardData, setDashboardData] = useState
(() =>
- generateMockData(t)
- );
+ const [dashboardData, setDashboardData] = useState(() => generateMockData(t))
- const [selectedSensor, setSelectedSensor] = useState(
- null
- );
+ const [selectedSensor, setSelectedSensor] = useState(null)
// Apply dark mode class on mount
useEffect(() => {
if (isDarkMode) {
- document.documentElement.classList.add("dark");
+ document.documentElement.classList.add('dark')
} else {
- document.documentElement.classList.remove("dark");
+ document.documentElement.classList.remove('dark')
}
- }, [isDarkMode]);
+ }, [isDarkMode])
// Update titles when language changes
useEffect(() => {
setDashboardData((prev) => ({
...prev,
kpis: prev.kpis.map((kpi, index) => {
- let title = kpi.title;
- let unit = kpi.unit;
+ let title = kpi.title
+ let unit = kpi.unit
if (index === 0) {
- title = t("dashboard:kpi.waitingTime");
- unit = t("dashboard:kpi.unit.seconds");
+ title = t('dashboard:kpi.waitingTime')
+ unit = t('dashboard:kpi.unit.seconds')
} else if (index === 2) {
- title = t("dashboard:kpi.vehicleCount");
- unit = t("dashboard:kpi.unit.vehiclePerHour");
+ title = t('dashboard:kpi.vehicleCount')
+ unit = t('dashboard:kpi.unit.vehiclePerHour')
} else if (index === 3) {
- title = t("dashboard:kpi.aiScore");
+ title = t('dashboard:kpi.aiScore')
}
- return { ...kpi, title, unit };
+ return { ...kpi, title, unit }
}),
pollutionHotspots: prev.pollutionHotspots.map((spot) => ({
...spot,
- name:
- spot.name === "Unknown Location"
- ? t("devices:unknownLocation")
- : spot.name,
+ name: spot.name === 'Unknown Location' ? t('devices:unknownLocation') : spot.name,
})),
- }));
- }, [t]);
+ }))
+ }, [t])
// Real-time data fetching
useEffect(() => {
const fetchData = async () => {
try {
- const now = new Date().toISOString();
+ const now = new Date().toISOString()
// 1. Fetch Sumo State
- let sumoState = null;
+ let sumoState = null
try {
- sumoState = await sumoApi.getSumoState();
+ sumoState = await sumoApi.getSumoState()
} catch {
- console.warn("Could not fetch Sumo state, using previous/mock data");
+ console.warn('Could not fetch Sumo state, using previous/mock data')
}
// 2. Fetch Air Quality Data
- let airQualityData: AirQualityObservedDto[] = [];
+ let airQualityData: AirQualityObservedDto[] = []
try {
- const aqResponse = await airQualityApi.getAll();
+ const aqResponse = await airQualityApi.getAll()
if (Array.isArray(aqResponse)) {
- airQualityData = aqResponse;
+ airQualityData = aqResponse
}
} catch {
- console.warn("Could not fetch Air Quality data");
+ console.warn('Could not fetch Air Quality data')
}
// 3. Process Data
setDashboardData((prev) => {
// Update KPIs
- const newKpis = [...prev.kpis];
+ const newKpis = [...prev.kpis]
// Update Waiting Time (Sumo)
if (sumoState) {
newKpis[0] = {
...newKpis[0],
value: Math.round(sumoState.waiting_time || 0),
- trend: "stable",
- };
+ trend: 'stable',
+ }
// Update Vehicle Count (Sumo)
newKpis[2] = {
...newKpis[2],
value: sumoState.vehicle_count || 0,
- };
+ }
}
// Update PM2.5 (Air Quality)
- let avgPm25 = 0;
+ let avgPm25 = 0
if (airQualityData.length > 0) {
- const totalPm25 = airQualityData.reduce(
- (sum, item) => sum + (item.pm25 || 0),
- 0
- );
- avgPm25 = totalPm25 / airQualityData.length;
+ const totalPm25 = airQualityData.reduce((sum, item) => sum + (item.pm25 || 0), 0)
+ avgPm25 = totalPm25 / airQualityData.length
newKpis[1] = {
...newKpis[1],
value: Math.round(avgPm25),
- };
+ }
} else {
// Fallback to previous value or mock if no data
- avgPm25 = prev.kpis[1].value;
+ avgPm25 = prev.kpis[1].value
}
// Update Monitoring Chart (Hybrid: Keep history, add new point)
@@ -257,38 +290,46 @@ export const ManagerDashboard: React.FC = () => {
timestamp: now,
avgWaitingTime:
sumoState?.waiting_time ||
- prev.monitoringData[prev.monitoringData.length - 1]
- .avgWaitingTime,
+ prev.monitoringData[prev.monitoringData.length - 1].avgWaitingTime,
pm25Level:
airQualityData.length > 0
? avgPm25
: prev.monitoringData[prev.monitoringData.length - 1].pm25Level,
- };
+ }
// Keep last 20 points
- const newMonitoringData = [...prev.monitoringData.slice(1), newPoint];
+ const newMonitoringData = [...prev.monitoringData.slice(1), newPoint]
// Update Pollution Hotspots
const newHotspots: PollutionHotspot[] =
airQualityData.length > 0
? airQualityData.map((aq) => ({
id: aq.id,
- name: aq.areaServed || t("devices:unknownLocation"),
+ name:
+ aq.areaServed && aq.areaServed.includes('locations.')
+ ? aq.areaServed
+ .split(', ')
+ .map((k) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ t(k.trim().replace('locations.', 'locations:') as any)
+ )
+ .join(', ')
+ : aq.areaServed || t('devices:unknownLocation'),
latitude: aq.location?.coordinates[1] || 0,
longitude: aq.location?.coordinates[0] || 0,
pm25: aq.pm25 || 0,
aqi: aq.airQualityIndex || 0,
severity:
(aq.airQualityIndex || 0) > 150
- ? "high"
+ ? 'high'
: (aq.airQualityIndex || 0) > 100
- ? "medium"
- : "low",
+ ? 'medium'
+ : 'low',
}))
- : prev.pollutionHotspots;
+ : prev.pollutionHotspots
// Update Interventions (Mocking AI detection for now as API doesn't return history)
- const newInterventions = prev.interventions;
+ const newInterventions = prev.interventions
return {
...prev,
@@ -297,34 +338,31 @@ export const ManagerDashboard: React.FC = () => {
pollutionHotspots: newHotspots,
interventions: newInterventions,
lastUpdated: now,
- };
- });
+ }
+ })
} catch (error) {
- console.error("Error updating dashboard:", error);
+ console.error('Error updating dashboard:', error)
}
- };
+ }
// Initial fetch
- fetchData();
+ fetchData()
// Poll every 5 seconds
- const interval = setInterval(fetchData, 5000);
+ const interval = setInterval(fetchData, 5000)
- return () => clearInterval(interval);
- }, [t]);
+ return () => clearInterval(interval)
+ }, [t])
const handleThemeToggle = () => {
- const newDarkMode = !isDarkMode;
- setIsDarkMode(newDarkMode);
- localStorage.setItem("darkMode", newDarkMode.toString());
- };
+ const newDarkMode = !isDarkMode
+ setIsDarkMode(newDarkMode)
+ localStorage.setItem('darkMode', newDarkMode.toString())
+ }
return (
-
+
{/* Top Section: Map & Controls */}
@@ -336,12 +374,17 @@ export const ManagerDashboard: React.FC = () => {
))}
- {/* Pollution Map */}
-
-
+ {/* Map Placeholder */}
+
+
+
🗺️
+
+ Map Feature Removed
+
+
+ The map feature has been removed for license compliance.
+
+
@@ -365,10 +408,7 @@ export const ManagerDashboard: React.FC = () => {
{/* Main Monitoring Chart - 65% */}
-
+
{/* Device Health Panel - 35% */}
@@ -380,12 +420,12 @@ export const ManagerDashboard: React.FC = () => {
{/* Last Updated */}
- {t("dashboard:updatedAt", {
- time: new Date(dashboardData.lastUpdated).toLocaleString("vi-VN"),
+ {t('dashboard:updatedAt', {
+ time: new Date(dashboardData.lastUpdated).toLocaleString('vi-VN'),
})}
- );
-};
+ )
+}
diff --git a/src/frontend/src/presentation/pages/user/UserMap.tsx b/src/frontend/src/presentation/pages/user/UserMap.tsx
index ff2f1b9..4153398 100644
--- a/src/frontend/src/presentation/pages/user/UserMap.tsx
+++ b/src/frontend/src/presentation/pages/user/UserMap.tsx
@@ -1,135 +1,83 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import React, { useState, useEffect } from "react";
-import { useSelector, useDispatch } from "react-redux";
-import { UserMapHeader } from "../../components/feature/usermap/UserMapHeader";
-import { UserMapView } from "../../components/feature/usermap/UserMapView";
-import { HealthInsightPanel } from "../../components/feature/usermap/HealthInsightPanel";
-import { AQIGauge } from "../../components/feature/usermap/AQIGauge";
-import { TrafficStatusCard } from "../../components/feature/usermap/TrafficStatusCard";
-import type { AirQualityObservedModel } from "../../../domain/models/AirQualityObservedModel";
-import type { LocationModel } from "../../../domain/models/CommonModels";
-import { AirQualityRepositoryImpl } from "../../../data/repositories/AirQualityRepositoryImpl";
-import { GetAirQualityDataUseCase } from "../../../domain/usecases/airquality/GetAirQualityDataUseCase";
-import { fetchSumoState } from "../../../data/redux/sumoSlice";
-import type { RootState, AppDispatch } from "../../../data/redux/store";
+import React, { useState, useEffect } from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { UserMapHeader } from '../../components/feature/usermap/UserMapHeader'
+
+import { AQIGauge } from '../../components/feature/usermap/AQIGauge'
+import { TrafficStatusCard } from '../../components/feature/usermap/TrafficStatusCard'
+
+import { fetchSumoState } from '../../../data/redux/sumoSlice'
+import type { RootState, AppDispatch } from '../../../data/redux/store'
export const UserMap: React.FC = () => {
- const dispatch = useDispatch();
- const { simulationState } = useSelector((state: RootState) => state.sumo);
-
- const [isDarkMode, setIsDarkMode] = useState(false);
- const [selectedLocation, setSelectedLocation] =
- useState(null);
- const [selectedAirQuality, setSelectedAirQuality] =
- useState(null);
- const [searchQuery, setSearchQuery] = useState("");
- const [airQualityData, setAirQualityData] = useState<
- AirQualityObservedModel[]
- >([]);
+ const dispatch = useDispatch()
+ const { simulationState } = useSelector((state: RootState) => state.sumo)
+
+ const [isDarkMode, setIsDarkMode] = useState(false)
+
+ const [searchQuery, setSearchQuery] = useState('')
// Initialize dark mode
useEffect(() => {
const getInitialDarkMode = () => {
- if (typeof window !== "undefined") {
- const stored = localStorage.getItem("darkMode");
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem('darkMode')
if (stored !== null) {
- return stored === "true";
+ return stored === 'true'
}
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
- }
- return false;
- };
-
- setIsDarkMode(getInitialDarkMode());
- }, []);
-
- // Fetch Air Quality Data
- useEffect(() => {
- const fetchData = async () => {
- try {
- const repository = new AirQualityRepositoryImpl();
- const useCase = new GetAirQualityDataUseCase(repository);
- const data = await useCase.execute();
- setAirQualityData(data);
- } catch (error) {
- console.error("Failed to fetch air quality data:", error);
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
}
- };
+ return false
+ }
- fetchData();
- }, []);
+ setIsDarkMode(getInitialDarkMode())
+ }, [])
// Poll SUMO Traffic Data
useEffect(() => {
// Fetch immediately
- dispatch(fetchSumoState());
+ dispatch(fetchSumoState())
// Poll every 5 seconds
const intervalId = setInterval(() => {
- dispatch(fetchSumoState());
- }, 5000);
+ dispatch(fetchSumoState())
+ }, 5000)
- return () => clearInterval(intervalId);
- }, [dispatch]);
+ return () => clearInterval(intervalId)
+ }, [dispatch])
// Apply dark mode class
useEffect(() => {
if (isDarkMode) {
- document.documentElement.classList.add("dark");
+ document.documentElement.classList.add('dark')
} else {
- document.documentElement.classList.remove("dark");
+ document.documentElement.classList.remove('dark')
}
- }, [isDarkMode]);
+ }, [isDarkMode])
const handleThemeToggle = () => {
- const newDarkMode = !isDarkMode;
- setIsDarkMode(newDarkMode);
- localStorage.setItem("darkMode", newDarkMode.toString());
- };
-
- const handleLocationSelect = (
- location: LocationModel,
- airQuality?: AirQualityObservedModel
- ) => {
- setSelectedLocation(location);
- setSelectedAirQuality(airQuality || null);
- };
+ const newDarkMode = !isDarkMode
+ setIsDarkMode(newDarkMode)
+ localStorage.setItem('darkMode', newDarkMode.toString())
+ }
const handleSearch = (query: string) => {
- setSearchQuery(query);
- // Search functionality will be implemented in the search component
- };
-
- const handleTrafficSelect = (_scenarioId: string) => {
- // For now, we assume selecting a traffic location triggers a fetch of the state
- // In a real app, this might switch the active scenario on the backend
- // or fetch specific data for that scenario.
- // For this demo, we'll just ensure we are fetching the global state
- // and maybe set a local state to show we are focusing on this traffic node.
-
- // If you wanted to switch scenario:
- // dispatch(startSimulation({ scenario: scenarioId, gui: false, port: 8813 }));
-
- // Just fetch state for now
- dispatch(fetchSumoState());
-
- // We could also set a "selectedTrafficNode" state if we wanted to show specific info
- // distinct from the global simulation state, but the request implies showing the card.
- };
+ setSearchQuery(query)
+ }
// State to track last update time
- const [lastUpdated, setLastUpdated] = useState(new Date());
+ const [lastUpdated, setLastUpdated] = useState(new Date())
// Update timestamp when simulationState changes
useEffect(() => {
if (simulationState) {
- setLastUpdated(new Date());
+ setLastUpdated(new Date())
}
- }, [simulationState]);
+ }, [simulationState])
return (
@@ -143,22 +91,22 @@ export const UserMap: React.FC = () => {
{/* Main Content */}
- {/* Map View */}
-
+ {/* Map Placeholder */}
+
+
+
🗺️
+
+ Map Feature Removed
+
+
+ The map feature has been removed for license compliance.
+
+
+
{/* AQI Gauge - Fixed Position (Bottom Left) */}
{/* Traffic Status Card - Fixed Position (Bottom Right) */}
@@ -173,17 +121,7 @@ export const UserMap: React.FC = () => {
/>
)}
-
- {/* Health Insight Panel - Fixed Position (Top Right) */}
- {selectedAirQuality && (
-
-
-
- )}
- );
-};
+ )
+}
diff --git a/src/frontend/src/utils/mockAirQualityData.ts b/src/frontend/src/utils/mockAirQualityData.ts
index 93eb076..b436b74 100644
--- a/src/frontend/src/utils/mockAirQualityData.ts
+++ b/src/frontend/src/utils/mockAirQualityData.ts
@@ -1,140 +1,132 @@
// Copyright (c) 2025 Green Wave Team
-//
+//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
-import type { AirQualityObservedDto } from "../data/dtos/AirQualityDTOs";
+import type { AirQualityObservedDto } from '../data/dtos/AirQualityDTOs'
// Helper to generate deterministic random numbers based on a seed
// This ensures that for the same seed, we get the same "random" number
const seededRandom = (seed: number) => {
- const x = Math.sin(seed++) * 10000;
- return x - Math.floor(x);
-};
+ const x = Math.sin(seed++) * 10000
+ return x - Math.floor(x)
+}
export interface MockLocation {
- id: string;
- nameKey: string;
- areaKey: string;
- coords: [number, number];
- baseAQI: number;
+ id: string
+ nameKey: string
+ areaKey: string
+ coords: [number, number]
+ baseAQI: number
}
const LOCATIONS: MockLocation[] = [
{
- id: "urn:ngsi-ld:AirQualityObserved:ThuDuc:Crossroads",
- nameKey: "locations.thuDucCrossroads",
- areaKey: "locations.thuDucCity, locations.hcmc",
+ id: 'urn:ngsi-ld:AirQualityObserved:ThuDuc:Crossroads',
+ nameKey: 'locations.nga4ThuDuc',
+ areaKey: 'locations.thuDucCity, locations.hcmc',
coords: [106.7718, 10.8505],
baseAQI: 110,
},
{
- id: "urn:ngsi-ld:AirQualityObserved:GoVap:QuangTrung",
- nameKey: "locations.goVapQuangTrung",
- areaKey: "locations.goVapQuangTrung, locations.hcmc",
- coords: [106.6816, 10.822], // Tọa độ xấp xỉ Ngã 5 Chuồng Chó (Quang Trung)
+ id: 'urn:ngsi-ld:AirQualityObserved:GoVap:QuangTrung',
+ nameKey: 'locations.quangTrung',
+ areaKey: 'locations.quangTrung, locations.hcmc',
+ coords: [106.6816, 10.822],
baseAQI: 145,
},
{
- id: "urn:ngsi-ld:AirQualityObserved:HCMC:D1",
- nameKey: "locations.d1Center",
- areaKey: "locations.d1Center, locations.hcmc",
+ id: 'urn:ngsi-ld:AirQualityObserved:HCMC:D1',
+ nameKey: 'locations.d1Center',
+ areaKey: 'locations.d1Center, locations.hcmc',
coords: [106.7009, 10.7769],
baseAQI: 85,
},
{
- id: "urn:ngsi-ld:AirQualityObserved:Hanoi:BaDinh",
- nameKey: "locations.baDinhHanoi",
- areaKey: "locations.baDinhHanoi, locations.hanoi",
+ id: 'urn:ngsi-ld:AirQualityObserved:Hanoi:BaDinh',
+ nameKey: 'locations.baDinhHanoi',
+ areaKey: 'locations.baDinhHanoi, locations.hanoi',
coords: [105.8342, 21.0278],
baseAQI: 160,
},
{
- id: "urn:ngsi-ld:AirQualityObserved:DaNang:HaiChau",
- nameKey: "locations.haiChauDaNang",
- areaKey: "locations.haiChauDaNang, locations.danang",
+ id: 'urn:ngsi-ld:AirQualityObserved:DaNang:HaiChau',
+ nameKey: 'locations.haiChauDaNang',
+ areaKey: 'locations.haiChauDaNang, locations.danang',
coords: [108.2208, 16.0544],
baseAQI: 45,
},
-];
+]
-export const generateMockAirQualityData = (t?: (key: string) => string): AirQualityObservedDto[] => {
+export const generateMockAirQualityData = (
+ t?: (key: string) => string
+): AirQualityObservedDto[] => {
// Create a time slot that changes every 10 seconds
// Date.now() is in ms. /1000 -> seconds. /10 -> 10s blocks.
- const timeSlot = Math.floor(Date.now() / 10000);
+ const timeSlot = Math.floor(Date.now() / 10000)
- const getFluctuatedValue = (
- baseValue: number,
- variance: number,
- id: string
- ) => {
+ const getFluctuatedValue = (baseValue: number, variance: number, id: string) => {
// Use timeSlot + id char code sum as seed to ensure stability within 10s but variety between items
- const idSum = id
- .split("")
- .reduce((acc, char) => acc + char.charCodeAt(0), 0);
- const seed = timeSlot + idSum;
- const rand = seededRandom(seed); // 0..1
- const fluctuation = (rand - 0.5) * 2 * variance; // -variance .. +variance
- return Number((baseValue + fluctuation).toFixed(1));
- };
+ const idSum = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ const seed = timeSlot + idSum
+ const rand = seededRandom(seed) // 0..1
+ const fluctuation = (rand - 0.5) * 2 * variance // -variance .. +variance
+ return Number((baseValue + fluctuation).toFixed(1))
+ }
const getAQILevel = (aqi: number, t?: (key: string) => string) => {
if (!t) {
// Fallback to English if no translation function provided
- if (aqi <= 50) return "Good";
- if (aqi <= 100) return "Moderate";
- if (aqi <= 150) return "Unhealthy for Sensitive Groups";
- if (aqi <= 200) return "Unhealthy";
- if (aqi <= 300) return "Very Unhealthy";
- return "Hazardous";
+ if (aqi <= 50) return 'Good'
+ if (aqi <= 100) return 'Moderate'
+ if (aqi <= 150) return 'Unhealthy for Sensitive Groups'
+ if (aqi <= 200) return 'Unhealthy'
+ if (aqi <= 300) return 'Very Unhealthy'
+ return 'Hazardous'
}
- if (aqi <= 50) return t("aqi.good");
- if (aqi <= 100) return t("aqi.moderate");
- if (aqi <= 150) return t("aqi.unhealthyForSensitive");
- if (aqi <= 200) return t("aqi.unhealthy");
- if (aqi <= 300) return t("aqi.veryUnhealthy");
- return t("aqi.hazardous");
- };
+ if (aqi <= 50) return t('aqi.good')
+ if (aqi <= 100) return t('aqi.moderate')
+ if (aqi <= 150) return t('aqi.unhealthyForSensitive')
+ if (aqi <= 200) return t('aqi.unhealthy')
+ if (aqi <= 300) return t('aqi.veryUnhealthy')
+ return t('aqi.hazardous')
+ }
- const getLocalizedString = (key: string, fallback: string): string => {
- if (!t) return fallback;
- return key.includes(",")
- ? key.split(", ").map(k => t(k)).join(", ")
- : t(key);
- };
+ const getLocalizedString = (key: string): string => {
+ if (!t) return key
+ return key.includes(',')
+ ? key
+ .split(', ')
+ .map((k) => t(k))
+ .join(', ')
+ : t(key)
+ }
return LOCATIONS.map((loc) => {
- const aqi = Math.max(
- 0,
- Math.round(getFluctuatedValue(loc.baseAQI, 20, loc.id))
- );
+ const aqi = Math.max(0, Math.round(getFluctuatedValue(loc.baseAQI, 20, loc.id)))
return {
id: loc.id,
- type: "AirQualityObserved",
+ type: 'AirQualityObserved',
dateObserved: new Date().toISOString(),
location: {
- type: "Point",
+ type: 'Point',
coordinates: loc.coords,
},
airQualityIndex: aqi,
airQualityLevel: getAQILevel(aqi, t),
- areaServed: getLocalizedString(loc.areaKey, "Unknown Area"),
+ areaServed: getLocalizedString(loc.areaKey),
co: Math.max(0, getFluctuatedValue(5, 2, loc.id)),
no2: Math.max(0, getFluctuatedValue(40, 10, loc.id)),
o3: Math.max(0, getFluctuatedValue(25, 10, loc.id)),
pm10: Math.max(0, getFluctuatedValue(aqi * 0.6, 15, loc.id)),
pm25: Math.max(0, getFluctuatedValue(aqi * 0.4, 10, loc.id)),
temperature: getFluctuatedValue(32, 2, loc.id),
- relativeHumidity: Math.min(
- 1,
- Math.max(0, getFluctuatedValue(0.7, 0.1, loc.id))
- ),
+ relativeHumidity: Math.min(1, Math.max(0, getFluctuatedValue(0.7, 0.1, loc.id))),
windSpeed: Math.max(0, getFluctuatedValue(3, 2, loc.id)),
- windDirection:
- Math.abs(Math.round(getFluctuatedValue(180, 180, loc.id))) % 360,
- typeofLocation: "outdoor",
- };
- });
-};
+ windDirection: Math.abs(Math.round(getFluctuatedValue(180, 180, loc.id))) % 360,
+ typeofLocation: 'outdoor',
+ }
+ })
+}