Initial commit

This commit is contained in:
2026-02-20 20:47:13 +01:00
commit 9a1557ff1d
37 changed files with 7909 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

11
MCRLDesktop.iml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

27
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

36
src-tauri/src/lib.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/**"],
},
},
}));