Initial commit
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
11
MCRLDesktop.iml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
|
||||
</content>
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1746
package-lock.json
generated
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "mcrldesktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"chart.js": "^4.5.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5403
src-tauri/Cargo.lock
generated
Normal file
27
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "mcrldesktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "mcrldesktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
binread = "2.2.0"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
36
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
mod rtlf;
|
||||
|
||||
use crate::rtlf::{RTLFDataRecord, RTLFHeader, RtlfFile};
|
||||
use serde::Serialize;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RtlfDataResponse {
|
||||
header: RTLFHeader,
|
||||
records: Vec<RTLFDataRecord>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn parse_rtlf_file(path: String) -> Result<RtlfDataResponse, String> {
|
||||
let file = File::open(&path).map_err(|e| e.to_string())?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut rtlf = RtlfFile::new(reader).map_err(|e| e.to_string())?;
|
||||
let records = rtlf.read_all_records();
|
||||
|
||||
Ok(RtlfDataResponse {
|
||||
header: rtlf.header,
|
||||
records,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![parse_rtlf_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mcrldesktop_lib::run()
|
||||
}
|
||||
63
src-tauri/src/rtlf.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use binread::BinRead;
|
||||
use serde::Serialize;
|
||||
use std::io::{Error, ErrorKind, Read, Seek, SeekFrom};
|
||||
|
||||
#[derive(BinRead, Debug, Clone, Serialize)]
|
||||
#[br(little)]
|
||||
pub struct RTLFHeader {
|
||||
pub magic_number: [u8; 4],
|
||||
pub version: u32,
|
||||
pub car_id: u32,
|
||||
pub track_name_len: u32,
|
||||
#[br(count = track_name_len, map = |s: Vec<u8>| String::from_utf8_lossy(&s).to_string())]
|
||||
pub track_name: String,
|
||||
}
|
||||
|
||||
#[derive(BinRead, Debug, Clone, Serialize)]
|
||||
#[br(little)]
|
||||
pub struct RTLFDataRecord {
|
||||
pub timestamp: f64,
|
||||
pub gps_long: f64,
|
||||
pub gps_lat: f64,
|
||||
pub facing_x: f64,
|
||||
pub facing_y: f64,
|
||||
pub facing_z: f64,
|
||||
pub acceleration_x: f64,
|
||||
pub acceleration_z: f64,
|
||||
pub acceleration_y: f64,
|
||||
pub speed: f64,
|
||||
}
|
||||
|
||||
pub struct RtlfFile<R: Read + Seek> {
|
||||
pub header: RTLFHeader,
|
||||
reader: R,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> RtlfFile<R> {
|
||||
pub fn new(mut reader: R) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let header = RTLFHeader::read(&mut reader)?;
|
||||
|
||||
if &header.magic_number != b"RTLF" {
|
||||
return Err(Box::new(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"Invalid file format: Magic number mismatch",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self { header, reader })
|
||||
}
|
||||
|
||||
pub fn read_all_records(&mut self) -> Vec<RTLFDataRecord> {
|
||||
let mut records = Vec::new();
|
||||
let _ = self.reader.seek(SeekFrom::Start(self.header_size()));
|
||||
|
||||
while let Ok(record) = RTLFDataRecord::read(&mut self.reader) {
|
||||
records.push(record);
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn header_size(&self) -> u64 {
|
||||
(16 + self.header.track_name_len) as u64
|
||||
}
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "mcrldesktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.asedem.mcrldesktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "mcrldesktop",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
410
src/App.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Line } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS, Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler);
|
||||
|
||||
const fileData = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
const isDark = ref(false);
|
||||
let map: L.Map | null = null;
|
||||
let tileLayer: L.TileLayer | null = null;
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
};
|
||||
|
||||
watch(isDark, () => {
|
||||
if (map) {
|
||||
if (tileLayer) map.removeLayer(tileLayer);
|
||||
const url = isDark.value
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
tileLayer = L.tileLayer(url).addTo(map);
|
||||
}
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!fileData.value) return null;
|
||||
const records = fileData.value.records;
|
||||
const accentColor = isDark.value ? '#3a86ff' : '#0077b6';
|
||||
|
||||
return {
|
||||
labels: records.map((_: any, i: number) => i),
|
||||
datasets: [
|
||||
{
|
||||
label: "Speed",
|
||||
borderColor: accentColor,
|
||||
backgroundColor: isDark.value ? "rgba(58, 134, 255, 0.1)" : "rgba(0, 119, 182, 0.05)",
|
||||
fill: true,
|
||||
data: records.map((r: any) => r.speed),
|
||||
yAxisID: 'y',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
},
|
||||
{
|
||||
label: "G-Force",
|
||||
borderColor: "#ff3e3e",
|
||||
data: records.map((r: any) =>
|
||||
Math.sqrt(r.acceleration_x**2 + r.acceleration_y**2 + r.acceleration_z**2).toFixed(2)
|
||||
),
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
grid: { color: isDark.value ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)' },
|
||||
ticks: { color: isDark.value ? '#888' : '#444' }
|
||||
},
|
||||
y1: { position: 'right', grid: { display: false }, ticks: { color: '#888' } }
|
||||
}
|
||||
}));
|
||||
|
||||
async function initMap(records: any[]) {
|
||||
await nextTick();
|
||||
if (map) map.remove();
|
||||
|
||||
map = L.map('map', { zoomControl: false, attributionControl: false });
|
||||
|
||||
const url = isDark.value
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
tileLayer = L.tileLayer(url).addTo(map);
|
||||
|
||||
const coords = records.map((r: any) => [r.gps_lat, r.gps_long]);
|
||||
L.polyline(coords, { color: '#ff3e3e', weight: 4 }).addTo(map);
|
||||
map.fitBounds(L.polyline(coords).getBounds(), { padding: [30, 30] });
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
const selected = await open({ multiple: false, filters: [{ name: 'RTL Data', extensions: ['rtl'] }] });
|
||||
if (selected) {
|
||||
loading.value = true;
|
||||
try {
|
||||
fileData.value = await invoke("parse_rtlf_file", { path: selected });
|
||||
await initMap(fileData.value.records);
|
||||
} catch (err) { alert(err); } finally { loading.value = false; }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['app-wrapper', isDark ? 'theme-dark' : 'theme-light']">
|
||||
<nav class="sidebar">
|
||||
<div class="logo">MCRL<span>.</span></div>
|
||||
|
||||
<div class="nav-items">
|
||||
<button class="nav-btn active">Dashboard</button>
|
||||
<button class="nav-btn">History</button>
|
||||
<button @click="toggleTheme" class="nav-btn theme-toggle">
|
||||
{{ isDark ? 'Light Mode' : 'Dark Mode' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="selectFile" :disabled="loading" class="import-btn">
|
||||
{{ loading ? 'PARSING...' : 'IMPORT RTL' }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<header class="content-header" v-if="fileData">
|
||||
<h1>{{ fileData.header.track_name }} <small>/ Telemetry Report</small></h1>
|
||||
<div class="header-badges">
|
||||
<span class="badge">CAR #{{ fileData.header.car_id }}</span>
|
||||
<span class="badge">V{{ fileData.header.version }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="fileData" class="dashboard-grid">
|
||||
<div class="card map-card">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<div class="card stats-card">
|
||||
<div class="stat-main">
|
||||
<label>TOP SPEED</label>
|
||||
<div class="val">{{ Math.max(...fileData.records.map(r => r.speed)).toFixed(1) }}<span>m/s</span></div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-sub">
|
||||
<div>
|
||||
<label>SAMPLES</label>
|
||||
<div class="s-val">{{ fileData.records.length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>PEAK G</label>
|
||||
<div class="s-val">1.42</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card graph-card">
|
||||
<div class="graph-header">
|
||||
<span>LIVE TELEMETRY: SPEED & ACCELERATION</span>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Line v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="upload-zone" @click="selectFile">
|
||||
<span class="plus">+</span>
|
||||
</div>
|
||||
<h2>Ready for Analysis</h2>
|
||||
<p>Select an .RTL file to visualize track performance.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Inter', sans-serif; transition: background 0.3s; }
|
||||
|
||||
.theme-light {
|
||||
--bg-app: #f8f9fa;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--border: #e9ecef;
|
||||
--text-main: #1a1a1b;
|
||||
--text-muted: #6c757d;
|
||||
--nav-hover: #f1f3f5;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--bg-app: #0f0f11;
|
||||
--bg-sidebar: #161618;
|
||||
--bg-card: #1c1c1f;
|
||||
--border: #2d2d31;
|
||||
--text-main: #f8f9fa;
|
||||
--text-muted: #888;
|
||||
--nav-hover: #252529;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-wrapper {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: #ff3e3e;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: block; width: 100%; padding: 12px;
|
||||
background: transparent; border: none; border-radius: 6px;
|
||||
text-align: left; color: var(--text-muted); font-weight: 600;
|
||||
cursor: pointer; margin-bottom: 8px; transition: 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn.active, .nav-btn:hover {
|
||||
background: var(--nav-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.import-btn {
|
||||
background: #1a1a1b; color: #fff;
|
||||
border: none; padding: 16px; font-weight: 800; border-radius: 4px;
|
||||
cursor: pointer; transition: 0.3s;
|
||||
}
|
||||
|
||||
.import-btn:hover {
|
||||
background: #ff3e3e;
|
||||
box-shadow: 0 8px 20px rgba(255, 62, 62, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content:
|
||||
space-between;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h1 small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--border); padding: 5px 12px; border-radius: 20px;
|
||||
font-size: 0.7rem; font-weight: 700; margin-left: 8px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: 400px 320px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.map-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction:
|
||||
column; justify-content:
|
||||
center; text-align: center
|
||||
}
|
||||
|
||||
.stat-main label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-main .val {
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-main .val span {
|
||||
font-size: 1rem;
|
||||
color: #ff3e3e;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.stat-sub label {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-sub .s-val {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.graph-card {
|
||||
grid-column: span 2;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.graph-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 230px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #ff3e3e;
|
||||
color: #ff3e3e;
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-size: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
4
src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||