From 71c3bac9fe19fac5e6db0f84c4c7378ba1ae5df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20G=C3=B6gele?= Date: Sat, 29 Nov 2025 16:35:45 +0100 Subject: [PATCH] first draft --- src/App.vue | 46 ++- src/app.js | 4 +- src/components/CustomTlePanel.vue | 514 +++++++++++++++++++++++++++++ src/components/SatelliteSelect.vue | 33 +- src/components/Satvis.vue | 59 +++- src/components/TleDiffModal.vue | 247 ++++++++++++++ src/components/TleEntry.vue | 231 +++++++++++++ src/composables/useCustomTle.ts | 142 ++++++++ src/css/main.css | 76 +++++ src/modules/SatelliteManager.js | 83 +++++ src/stores/sat.js | 25 ++ 11 files changed, 1453 insertions(+), 7 deletions(-) create mode 100644 src/components/CustomTlePanel.vue create mode 100644 src/components/TleDiffModal.vue create mode 100644 src/components/TleEntry.vue create mode 100644 src/composables/useCustomTle.ts diff --git a/src/App.vue b/src/App.vue index 675c99f1f..c1e489d41 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,18 @@ @@ -27,3 +39,35 @@ export default { }, }; + + diff --git a/src/app.js b/src/app.js index d63d9c569..654b746d6 100644 --- a/src/app.js +++ b/src/app.js @@ -7,7 +7,7 @@ import ToastService from "primevue/toastservice"; import * as Sentry from "@sentry/browser"; import { library } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; -import { faLayerGroup, faGlobeAfrica, faMobileAlt, faHammer, faEye } from "@fortawesome/free-solid-svg-icons"; +import { faLayerGroup, faGlobeAfrica, faMobileAlt, faHammer, faEye, faTrash, faLink, faLinkSlash, faSave, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; import { faGithub } from "@fortawesome/free-brands-svg-icons"; import App from "./App.vue"; @@ -53,7 +53,7 @@ app.use(PrimeVue, { // Setup directives and components app.directive("tooltip", Tooltip); app.use(ToastService); -library.add(faLayerGroup, faGlobeAfrica, faMobileAlt, faHammer, faEye, faGithub); +library.add(faLayerGroup, faGlobeAfrica, faMobileAlt, faHammer, faEye, faTrash, faLink, faLinkSlash, faSave, faExclamationTriangle, faGithub); app.component("FontAwesomeIcon", FontAwesomeIcon); // Mount the app diff --git a/src/components/CustomTlePanel.vue b/src/components/CustomTlePanel.vue new file mode 100644 index 000000000..fd1608c24 --- /dev/null +++ b/src/components/CustomTlePanel.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/src/components/SatelliteSelect.vue b/src/components/SatelliteSelect.vue index 356d9c2e9..5045fbd0b 100644 --- a/src/components/SatelliteSelect.vue +++ b/src/components/SatelliteSelect.vue @@ -22,6 +22,11 @@ +
+ Custom TLE + {{ showCustomTle ? "▲" : "▼" }} +
+ @@ -30,13 +35,18 @@ import VueMultiselect from "vue-multiselect"; import { mapWritableState } from "pinia"; import { useSatStore } from "../stores/sat"; +import CustomTlePanel from "./CustomTlePanel.vue"; export default { components: { VueMultiselect, + CustomTlePanel, }, + emits: ["open-menu"], data() { - return {}; + return { + showCustomTle: false, + }; }, computed: { ...mapWritableState(useSatStore, ["availableSatellitesByTag", "availableTags", "enabledSatellites", "enabledTags", "trackedSatellite"]), @@ -55,7 +65,9 @@ export default { }, allEnabledSatellites: { get() { - return this.satellitesEnabledByTag.concat(this.enabledSatellites ?? []); + // Use Set to deduplicate satellites that might be enabled both by tag and individually + const allSats = new Set([...this.satellitesEnabledByTag, ...(this.enabledSatellites ?? [])]); + return [...allSats]; }, set(sats) { const enabledTags = this.availableTags.filter((tag) => !this.availableSatellitesByTag[tag].some((sat) => !sats.includes(sat))); @@ -89,6 +101,23 @@ export default { .satellite-select { width: 300px; } + +.tleToggleTitle { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; +} + +.tleToggleTitle:hover { + color: #fff; +} + +.tleToggleArrow { + font-size: 10px; + padding: 0 5px; +} diff --git a/src/components/TleEntry.vue b/src/components/TleEntry.vue new file mode 100644 index 000000000..d8d85a31b --- /dev/null +++ b/src/components/TleEntry.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/composables/useCustomTle.ts b/src/composables/useCustomTle.ts new file mode 100644 index 000000000..5b3d2a9db --- /dev/null +++ b/src/composables/useCustomTle.ts @@ -0,0 +1,142 @@ +const STORAGE_KEY = "satvis_custom_tles"; + +export interface CustomTleEntry { + tle: string; + tags: string[]; +} + +/** + * Composable for managing custom TLE entries in localStorage + */ +export function useCustomTle() { + /** + * Save custom TLE entries to localStorage + */ + const saveToStorage = (entries: CustomTleEntry[]): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + }; + + /** + * Load custom TLE entries from localStorage + */ + const loadFromStorage = (): CustomTleEntry[] => { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return []; + try { + return JSON.parse(stored) as CustomTleEntry[]; + } catch { + return []; + } + }; + + /** + * Clear all custom TLE entries from localStorage + */ + const clearStorage = (): void => { + localStorage.removeItem(STORAGE_KEY); + }; + + /** + * Add or update a TLE entry in storage + * Updates if satellite with same name exists, otherwise adds new + */ + const addOrUpdateInStorage = (entry: CustomTleEntry): void => { + const entries = loadFromStorage(); + const name = extractSatelliteName(entry.tle); + const existingIndex = entries.findIndex( + (e) => extractSatelliteName(e.tle) === name + ); + + if (existingIndex >= 0) { + entries[existingIndex] = entry; + } else { + entries.push(entry); + } + saveToStorage(entries); + }; + + /** + * Remove a TLE entry from storage by satellite name + */ + const removeFromStorage = (satelliteName: string): void => { + const entries = loadFromStorage(); + const filtered = entries.filter( + (e) => extractSatelliteName(e.tle) !== satelliteName + ); + saveToStorage(filtered); + }; + + /** + * Parse multi-line TLE input text into individual 3-line TLE entries + * Handles input with or without \n escape sequences + */ + const parseTleInput = (input: string): string[] => { + // Replace escaped newlines with actual newlines + const normalized = input.replace(/\\n/g, "\n"); + + // Split into lines and filter empty ones + const lines = normalized + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const tles: string[] = []; + + // Group lines into 3-line TLE sets + for (let i = 0; i < lines.length; i += 3) { + if (i + 2 < lines.length) { + // Validate TLE format (line 1 starts with "1 ", line 2 starts with "2 ") + const line0 = lines[i]; + const line1 = lines[i + 1]; + const line2 = lines[i + 2]; + + if (line1?.startsWith("1 ") && line2?.startsWith("2 ")) { + tles.push([line0, line1, line2].join("\n")); + } + } + } + + return tles; + }; + + /** + * Extract satellite name from TLE string (first line) + */ + const extractSatelliteName = (tle: string): string => { + const firstLine = tle.split("\n")[0]; + let name = firstLine?.trim() ?? ""; + // Handle format where name starts with "0 " + if (name.startsWith("0 ")) { + name = name.substring(2); + } + return name; + }; + + /** + * Validate TLE format + */ + const validateTle = (tle: string): { valid: boolean; error?: string } => { + const lines = tle.split("\n"); + if (lines.length !== 3) { + return { valid: false, error: "TLE must have exactly 3 lines" }; + } + if (!lines[1]?.startsWith("1 ")) { + return { valid: false, error: "Line 1 must start with '1 '" }; + } + if (!lines[2]?.startsWith("2 ")) { + return { valid: false, error: "Line 2 must start with '2 '" }; + } + return { valid: true }; + }; + + return { + saveToStorage, + loadFromStorage, + clearStorage, + addOrUpdateInStorage, + removeFromStorage, + parseTleInput, + extractSatelliteName, + validateTle, + }; +} diff --git a/src/css/main.css b/src/css/main.css index d26541ffc..4e7929bd5 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -151,3 +151,79 @@ a.cesium-toolbar-button { padding: 0px; margin: 0px 3px; } + +/* Custom TLE Input Styles */ +.toolbarTleInput { + display: flex; + flex-direction: column; + padding: 5px 10px; + gap: 5px; +} + +.tleTextarea { + width: 100%; + min-width: 280px; + padding: 8px; + border: 1px solid #555; + border-radius: 4px; + background-color: #1a1a1a; + color: #edffff; + font-family: monospace; + font-size: 11px; + resize: vertical; + box-sizing: border-box; +} + +.tleTextarea::placeholder { + color: #888; +} + +.tleTagInput { + width: 100%; + padding: 6px 8px; + border: 1px solid #555; + border-radius: 4px; + background-color: #1a1a1a; + color: #edffff; + font-size: 12px; + box-sizing: border-box; +} + +.tleTagInput::placeholder { + color: #888; +} + +.tleButtons { + display: flex; + gap: 5px; +} + +.tleButtons .cesium-button { + flex: 1; + padding: 6px 10px; + font-size: 12px; +} + +.savedTleEntry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background-color: #303336; + border-radius: 4px; + margin: 2px 5px; +} + +.savedTleName { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.tleRemoveBtn { + padding: 2px 6px; + font-size: 10px; + min-width: auto; +} diff --git a/src/modules/SatelliteManager.js b/src/modules/SatelliteManager.js index 83eb4d145..0d39e47fc 100644 --- a/src/modules/SatelliteManager.js +++ b/src/modules/SatelliteManager.js @@ -66,6 +66,89 @@ export class SatelliteManager { } } + /** + * Add or update a satellite from TLE data + * If a satellite with the same name exists, it will be removed and re-added with updated TLE + * @param {string} tle - Three-line TLE string + * @param {string[]} tags - Array of tags for the satellite + * @param {boolean} updateStore - Whether to update the store after adding + * @param {boolean} autoEnable - Whether to automatically enable/show the satellite + * @returns {{added: boolean, updated: boolean, name: string}} Result of the operation + */ + addOrUpdateFromTle(tle, tags, updateStore = true, autoEnable = true) { + // Extract satellite name from TLE + let name = tle.split("\n")[0].trim(); + if (name.startsWith("0 ")) { + name = name.substring(2); + } + + const existingSat = this.getSatellite(name); + const wasUpdated = !!existingSat; + const wasEnabled = existingSat ? this.#enabledSatellites.includes(name) || this.satIsActive(existingSat) : false; + + if (existingSat) { + // Remove existing satellite to update with new TLE + this.removeSatellite(name, false); + } + + // Add new satellite + this.addFromTle(tle, tags, updateStore); + + // Auto-enable the satellite if requested or if it was previously enabled + if (autoEnable || wasEnabled) { + if (!this.#enabledSatellites.includes(name)) { + // Use the setter which properly triggers showEnabledSatellites + this.enabledSatellites = [...this.#enabledSatellites, name]; + } else { + // Already in the list, just make sure it's visible + const sat = this.getSatellite(name); + if (sat) { + sat.show(this.#enabledComponents); + } + } + } + + return { + added: !wasUpdated, + updated: wasUpdated, + name, + }; + } + + /** + * Remove a satellite by name + * @param {string} name - Name of the satellite to remove + * @param {boolean} updateStore - Whether to update the store after removal + * @returns {boolean} True if satellite was found and removed + */ + removeSatellite(name, updateStore = true) { + const satIndex = this.satellites.findIndex((sat) => sat.props.name === name); + if (satIndex === -1) { + return false; + } + + const sat = this.satellites[satIndex]; + + // Hide and cleanup satellite components + sat.hide(); + sat.deinit(); + + // Remove from array + this.satellites.splice(satIndex, 1); + + // Remove from enabled satellites if present + if (this.#enabledSatellites.includes(name)) { + this.#enabledSatellites = this.#enabledSatellites.filter((n) => n !== name); + useSatStore().enabledSatellites = this.#enabledSatellites; + } + + if (updateStore) { + this.updateStore(); + } + + return true; + } + #add(newSat) { const existingSat = this.satellites.find((sat) => sat.props.satnum === newSat.props.satnum && sat.props.name === newSat.props.name); if (existingSat) { diff --git a/src/stores/sat.js b/src/stores/sat.js index 05eb12d55..c0e7c6baa 100644 --- a/src/stores/sat.js +++ b/src/stores/sat.js @@ -10,6 +10,7 @@ export const useSatStore = defineStore("sat", { groundStations: [], trackedSatellite: "", overpassMode: "elevation", + customTles: [], // Array of {tle: string, tags: string[]} for user-added TLE data }), urlsync: { enabled: true, @@ -72,6 +73,30 @@ export const useSatStore = defineStore("sat", { url: "overpass", default: "elevation", }, + { + name: "customTles", + url: "tle", + serialize: (v) => { + if (!v || v.length === 0) return ""; + // Format: ["TLE","tag1","tag2"],["TLE2","tag"] + return v.map((entry) => JSON.stringify([entry.tle, ...entry.tags])).join(","); + }, + deserialize: (v) => { + if (!v) return []; + try { + // Wrap with [] if not already wrapped (starts with [[ means already an array of arrays) + const json = v.startsWith("[[") ? v : `[${v}]`; + const parsed = JSON.parse(json); + return parsed.map((arr) => ({ + tle: arr[0], + tags: arr.slice(1), + })); + } catch { + return []; + } + }, + default: [], + }, ], }, });