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 @@
-
+
+
+
+
+
{{ slotProps.message.summary }}
+
{{ slotProps.message.detail }}
+
+
+
+
+
@@ -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 @@
No matching satellites
+
+ 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: [],
+ },
],
},
});