Repo Health

← К списку

nickitkavedenin/khakaton

src/App.tsx " import React, { useState, useEffect, useRef, useCallback } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import satellite from 'satellite.js'; import './App.css'; // TLE демо-данные const DEMO_TLE = `ISS (ZARYA) 1 25544U 98067A 26080.51782528 .00017526 00000+0 31758-3 0 9998 2 25544 51.6410 264.3022 0002661 188.5029 255.5863 15.50039430500335 STARLINK-1021 1 44713U 19074D 26079.84146843 .00002171 00000+0 14736-3 0 9990 2 44713 53.0538 123.3568 0001096 88.7808 271.3320 15.06428652344105 ONEWEB-0254 1 45127U 20008A 26079.88827821 .00000055 00000+0 00000+0 0 9998 2 45127 87.9066 42.9859 0001567 103.2896 256.8503 13.13923210290091 IRIDIUM 180 1 42803U 17039A 26079.45360886 .00000244 00000+0 10648-3 0 9996 2 42803 86.3967 123.0538 0002017 93.8746 266.2586 14.34220750450857 GLOBALSTAR M079 1 45746U 20034A 26079.91708589 -.00000027 00000+0 00000+0 0 9996 2 45746 52.0208 196.4852 0000623 110.5744 249.5458 12.62289995266666`; // Типы interface SatelliteRecord { id: string; name: string; l1: string; l2: string; satrec: satellite.SatRec; group: string; } interface Position { lat: number; lon: number; heightKm: number; } interface Pass { name: string; group: string; when: Date; distanceKm: number; } // Классификация группировок const classifyGroup = (name: string): string => { const upper = name.toUpperCase(); if (upper.includes('STARLINK')) return 'STARLINK'; if (upper.includes('ONEWEB')) return 'ONEWEB'; if (upper.includes('IRIDIUM')) return 'IRIDIUM'; if (upper.includes('GLOBALSTAR')) return 'GLOBALSTAR'; if (upper.includes('ISS')) return 'ISS'; return 'OTHER'; }; // Парсинг TLE const parseTleText = (text: string): SatelliteRecord[] => { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const out: SatelliteRecord[] = []; for (let i = 0; i + 2 < lines.length; i += 3) { const name = lines[i]; const l1 = lines[i + 1]; const l2 = lines[i + 2]; if (!l1.startsWith('1 ') || !l2.startsWith('2 ')) continue; try { const satrec = satellite.twoline2satrec(l1, l2); out.push({ id: `${name}-${i}`, name, l1, l2, satrec, group: classifyGroup(name) }); } catch { // игнорируем ошибки парсинга } } return out; }; // Расчет позиции спутника const getSatPosition = (record: SatelliteRecord, date: Date): Position | null => { try { const pv = satellite.propagate(record.satrec, date); if (!pv || !pv.position) return null; const gmst = satellite.gstime(date); const gd = satellite.eciToGeodetic(pv.position, gmst); return { lat: satellite.degreesLat(gd.latitude), lon: satellite.degreesLong(gd.longitude), heightKm: gd.height }; } catch { return null; } }; // Расчет расстояния между двумя точками (км) const distanceKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => { const toRad = (d: number) => d * Math.PI / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * 6371 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; // Радиус видимости по высоте const visibilityRadiusKm = (heightKm: number): number => { const re = 6371; if (heightKm <= 0) return 500; return Math.sqrt(Math.max(0, (re + heightKm) ** 2 - re ** 2)); }; // Построение трека const buildTrack = (record: SatelliteRecord, date: Date): [number, number][] => { const points: [number, number][] = []; for (let m = -45; m <= 45; m += 3) { const t = new Date(date.getTime() + m * 60000); const pos = getSatPosition(record, t); if (pos && isFinite(pos.lat) && isFinite(pos.lon)) { points.push([pos.lat, pos.lon]); } } return points; }; // Поиск пролетов над точкой const findPasses = (point: { lat: number; lon: number }, satellites: SatelliteRecord[], fromDate: Date): Pass[] => { const horizonMinutes = 360; const stepMin = 5; const thresholdKm = 500; const passes: Pass[] = []; for (const sat of satellites) { for (let m = 0; m <= horizonMinutes; m += stepMin) { const t = new Date(fromDate.getTime() + m * 60000); const pos = getSatPosition(sat, t); if (!pos) continue; const d = distanceKm(point.lat, point.lon, pos.lat, pos.lon); if (d <= thresholdKm) { passes.push({ name: sat.name, group: sat.group, when: t, distanceKm: d }); break; } } } passes.sort((a, b) => a.when.getTime() - b.when.getTime()); return passes.slice(0, 15); }; function App() { // Состояние const [satellites, setSatellites] = useState<SatelliteRecord[]>([]); const [filteredSats, setFilteredSats] = useState<SatelliteRecord[]>([]); const [loading, setLoading] = useState(false); const [status, setStatus] = useState('Ожидание загрузки...'); const [simMode, setSimMode] = useState(false); const [speed, setSpeed] = useState(60); const [timelineHours, setTimelineHours] = useState(0); const [nameFilter, setNameFilter] = useState(''); const [groupFilter, setGroupFilter] = useState('ALL'); const [limit, setLimit] = useState(120); const [selectedPoint, setSelectedPoint] = useState<{ lat: number; lon: number } | null>(null); const [passes, setPasses] = useState<Pass[]>([]); // Рефы для слоев карты const mapRef = useRef<L.Map | null>(null); const markersRef = useRef<Map<string, L.CircleMarker>>(new Map()); const tracksRef = useRef<Map<string, L.Polyline>>(new Map()); const visRef = useRef<Map<string, L.Circle>>(new Map()); const selectedMarkerRef = useRef<L.Marker | null>(null); // Рефы для симуляции времени const simStartRealRef = useRef(Date.now()); const simStartModelRef = useRef(Date.now()); // Получение модельного времени const getModelTime = useCallback((): Date => { const offsetMs = timelineHours * 3600 * 1000; if (!simMode) { return new Date(Date.now() + offsetMs); } const elapsed = Date.now() - simStartRealRef.current; return new Date(simStartModelRef.current + elapsed * speed + offsetMs); }, [simMode, speed, timelineHours]); // Сброс якоря симуляции const resetSimAnchor = useCallback(() => { simStartRealRef.current = Date.now(); simStartModelRef.current = Date.now(); }, []); // Загрузка TLE из Celestrak const loadFromCelestrak = async () => { setLoading(true); setStatus('Загрузка TLE с Celestrak...'); try { const urls = [ 'https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=tle', 'https://celestrak.org/NORAD/elements/starlink.txt', 'https://celestrak.org/NORAD/elements/iridium.txt' ]; const chunks = await Promise.all(urls.map(async url => { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); })); const parsed = parseTleText(chunks.join('\n')); setSatellites(parsed); setStatus(`Загружено спутников: ${parsed.length}`); } catch (err) { setStatus(`Ошибка загрузки: ${err instanceof Error ? err.message : 'Неизвестная ошибка'}`); } finally { setLoading(false); } }; // Загрузка демо TLE const loadDemo = () => { const parsed = parseTleText(DEMO_TLE); setSatellites(parsed); setStatus(`Демо загружено. Спутников: ${parsed.length}`); }; // Загрузка TLE из файла const loadFromFile = async (file: File) => { setLoading(true); setStatus(`Загрузка файла: ${file.name}...`); try { const text = await file.text(); const parsed = parseTleText(text); setSatellites(parsed); setStatus(`Загружено спутников из файла: ${parsed.length}`); } catch (err) { setStatus(`Ошибка чтения файла: ${err instanceof Error ? err.message : 'Неизвестная ошибка'}`); } finally { setLoading(false); } }; // Фильтрация спутников useEffect(() => { let filtered = satellites; if (nameFilter) { const needle = nameFilter.toUpperCase(); filtered = filtered.filter(s => s.name.toUpperCase().includes(needle)); } if (groupFilter !== 'ALL') { filtered = filtered.filter(s => s.group === groupFilter); } const limitNum = Math.max(10, Math.min(500, limit)); setFilteredSats(filtered.slice(0, limitNum)); }, [satellites, nameFilter, groupFilter, limit]); // Получение уникальных групп для фильтра const groups = ['ALL', ...new Set(satellites.map(s => s.group))]; // Обновление карты const updateMap = useCallback(() => { if (!mapRef.current) return; const date = getModelTime(); const seenIds = new Set<string>(); for (const sat of filteredSats) { seenIds.add(sat.id); const pos = getSatPosition(sat, date); if (!pos) continue; // Маркер let marker = markersRef.current.get(sat.id); if (!marker) { marker = L.circleMarker([pos.lat, pos.lon], { radius: 5, color: '#ffd54f', weight: 2, fillOpacity: 0.8, fillColor: '#ffb74d' }).addTo(mapRef.current); marker.bindPopup(''); markersRef.current.set(sat.id, marker); } else { marker.setLatLng([pos.lat, pos.lon]); } marker.setPopupContent( `<b>${sat.name}</b><br>Группа: ${sat.group}<br>Высота: ${pos.heightKm.toFixed(0)} км<br>` + `Координаты: ${pos.lat.toFixed(2)}°, ${pos.lon.toFixed(2)}°` ); // Трек const trackPts = buildTrack(sat, date); let track = tracksRef.current.get(sat.id); if (!track && trackPts.length > 1) { track = L.polyline(trackPts, { color: '#4fc3f7', weight: 1.5, opacity: 0.5 }).addTo(mapRef.current); tracksRef.current.set(sat.id, track); } else if (track) { track.setLatLngs(trackPts); } // Зона видимости const radiusM = visibilityRadiusKm(pos.heightKm) * 1000; let vis = visRef.current.get(sat.id); if (!vis) { vis = L.circle([pos.lat, pos.lon], { radius: radiusM, color: '#7e57c2', weight: 1, fillOpacity: 0.05 }).addTo(mapRef.current); visRef.current.set(sat.id, vis); } else { vis.setLatLng([pos.lat, pos.lon]); vis.setRadius(radiusM); } } // Удаление неиспользуемых слоев for (const [id, marker] of markersRef.current) { if (!seenIds.has(id)) { marker.remove(); markersRef.current.delete(id); } } for (const [id, track] of tracksRef.current) { if (!seenIds.has(id)) { track.remove(); tracksRef.current.delete(id); } } for (const [id, vis] of visRef.current) { if (!seenIds.has(id)) { vis.remove(); visRef.current.delete(id); } } }, [filteredSats, getModelTime]); // Обновление анализа точки const updatePointAnalysis = useCallback(() => { if (!selectedPoint || filteredSats.length === 0) { setPasses([]); return; } const date = getModelTime(); const foundPasses = findPasses(selectedPoint, filteredSats, date); setPasses(foundPasses); }, [selectedPoint, filteredSats, getModelTime]); // Эффект для обновления карты и анализа useEffect(() => { updateMap(); updatePointAnalysis(); }, [updateMap, updatePointAnalysis]); // Интервал для анимации useEffect(() => { const interval = setInterval(() => { updateMap(); updatePointAnalysis(); }, 100); return () => clearInterval(interval); }, [updateMap, updatePointAnalysis]); // Инициализация карты useEffect(() => { const map = L.map('map').setView([30, 0], 2); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 8, attribution: '&copy; OpenStreetMap contributors' }).addTo(map); map.on('click', (e) => { setSelectedPoint({ lat: e.latlng.lat, lon: e.latlng.lng }); if (selectedMarkerRef.current) { selectedMarkerRef.current.remove(); } selectedMarkerRef.current = L.marker(e.latlng).addTo(map); }); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; }, []); // Группировка для статистики const groupCounts = filteredSats.reduce((acc, sat) => { acc[sat.group] = (acc[sat.group] || 0) + 1; return acc; }, {} as Record<string, number>); return ( <div className="app"> <aside className="sidebar"> <h1>Мониторинг спутников</h1> <p className="subtitle">TLE + симуляция времени + анализ пролётов</p> <section> <h2>Данные</h2> <div className="button-group"> <button onClick={loadFromCelestrak} disabled={loading}> {loading ? 'Загрузка...' : 'Загрузить TLE'} </button> <button onClick={loadDemo}>Демо TLE</button> </div> <label className="file-label"> 📁 Загрузить из файла <input type="file" accept=".tle,.txt" onChange={e => e.target.files?.[0] && loadFromFile(e.target.files[0])} disabled={loading} /> </label> <div className={`status ${loading ? 'loading' : ''}`}>{status}</div> </section> <section> <h2>Фильтры</h2> <label> Название: <input type="text" value={nameFilter} onChange={e => setNameFilter(e.target.value)} placeholder="например STARLINK" /> </label> <label> Группа: <select value={groupFilter} onChange={e => setGroupFilter(e.target.value)}> {groups.map(g => ( <option key={g} value={g}>{g}</option> ))} </select> </label> <label> Лимит отображения: <input type="number" value={limit} onChange={e => setLimit(Number(e.target.value))} min={10} max={500} /> </label> <div className="info-text">Отображается: {filteredSats.length} / {satellites.length}</div> </section> <section> <h2>Время</h2> <label className="checkbox"> <input type="checkbox" checked={simMode} onChange={e => { setSimMode(e.target.checked); resetSimAnchor(); }} /> Режим симуляции </label> <label> Скорость: <input type="range" min="-60" max="600" value={speed} onChange={e => { setSpeed(Number(e.target.value)); resetSimAnchor(); }} /> <span className="value-display">x{speed}</span> </label> <label> Таймлайн: <input type="range" min="-12" max="48" value={timelineHours} onChange={e => setTimelineHours(Number(e.target.value))} /> <span className="value-display">{timelineHours >= 0 ? '+' : ''}{timelineHours} ч</span> </label> <div className="time-display"> {getModelTime().toLocaleString('ru-RU')} </div> </section> <section> <h2>Группировки</h2> <div className="stats"> {Object.entries(groupCounts).map(([group, count]) => ( <div key={group} className="stat-item"> <span>{group}</span> <strong>{count}</strong> </div> ))} </div> </section> <section> <h2>Анализ точки</h2> <p className="hint">👆 Кликните на карту, чтобы выбрать точку</p> {selectedPoint && ( <div className="point-info"> <div className="point-coords"> 📍 {selectedPoint.lat.toFixed(3)}°, {selectedPoint.lon.toFixed(3)}° </div> {passes.length > 0 ? ( <> <div className="nearest-pass"> <strong>Ближайший пролёт:</strong><br /> {passes[0].name} ({passes[0].group})<br /> через {Math.round((passes[0].when.getTime() - getModelTime().getTime()) / 60000)} мин<br /> 📏 {passes[0].distanceKm.toFixed(0)} км </div> <div className="passes-list"> <div className="passes-title">Следующие пролёты:</div> {passes.slice(1, 6).map((p, i) => ( <div key={i} className="pass-item"> <strong>{p.name}</strong> ({p.group})<br /> {p.when.toLocaleString('ru-RU')}<br /> {p.distanceKm.toFixed(0)} км </div> ))} </div> </> ) : ( <div className="no-passes">За 6 часов пролётов не найдено</div> )} </div> )} </section> </aside> <main className="map-container"> <div id="map" className="map"></div> </main> </div> ); } export default App; " src/App.css " * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Segoe UI', Arial, sans-serif; background: #0b1220; color: #e9edf1; } .app { display: flex; min-height: 100vh; } /* Sidebar */ .sidebar { width: 380px; background: #111a2d; border-right: 1px solid #1f2a44; overflow-y: auto; padding: 16px; flex-shrink: 0; } .sidebar h1 { font-size: 20px; margin-bottom: 4px; } .subtitle { font-size: 12px; color: #8e9ec0; margin-bottom: 20px; } section { background: #131f37; border: 1px solid #283758; border-radius: 8px; padding: 12px; margin-bottom: 16px; } section h2 { font-size: 16px; margin-bottom: 12px; border-left: 3px solid #2664ff; padding-left: 8px; } /* Buttons */ .button-group { display: flex; gap: 8px; margin-bottom: 10px; } button { background: #2664ff; border: none; color: white; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; flex: 1; transition: all 0.2s; } button:hover:not(:disabled) { background: #1a52d6; transform: translateY(-1px); } button:disabled { opacity: 0.6; cursor: not-allowed; } /* File input */ .file-label { display: block; background: #1c2945; border: 1px solid #4a6299; border-radius: 6px; padding: 8px; text-align: center; cursor: pointer; font-size: 13px; margin-top: 8px; } .file-label input { display: none; } /* Status */ .status { font-size: 12px; padding: 8px; background: #0a1120; border-radius: 6px; margin-top: 8px; word-break: break-word; } .status.loading { animation: pulse 1s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Form elements */ label { display: block; font-size: 13px; margin-bottom: 10px; } input[type="text"], input[type="number"], select { width: 100%; margin-top: 4px; padding: 8px; background: #0d1730; border: 1px solid #364b74; border-radius: 6px; color: #e9edf1; font-size: 13px; } input[type="range"] { width: calc(100% - 50px); margin-right: 8px; vertical-align: middle; } .checkbox { display: flex; align-items: center; gap: 8px; } .checkbox input { width: auto; margin: 0; } .value-display { display: inline-block; width: 45px; text-align: right; font-size: 12px; color: #ffd54f; } .time-display { background: #0a1120; padding: 8px; border-radius: 6px; text-align: center; font-family: monospace; font-size: 12px; margin-top: 8px; } .info-text { font-size: 12px; color: #8e9ec0; margin-top: 8px; text-align: center; } /* Stats */ .stats { display: flex; flex-direction: column; gap: 6px; } .stat-item { display: flex; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid #1f2a44; font-size: 13px; } .stat-item strong { color: #ffd54f; } /* Point analysis */ .hint { font-size: 11px; color: #8e9ec0; margin-bottom: 8px; } .point-info { font-size: 12px; } .point-coords { background: #0d1730; padding: 8px; border-radius: 6px; text-align: center; margin-bottom: 12px; } .nearest-pass { background: #0d1730; padding: 10px; border-radius: 6px; margin-bottom: 12px; border-left: 3px solid #ffd54f; } .nearest-pass strong { color: #ffd54f; } .passes-list { max-height: 250px; overflow-y: auto; } .passes-title { font-size: 11px; color: #8e9ec0; margin-bottom: 8px; } .pass-item { border-top: 1px dashed #3a4c74; padding: 8px 0; } .pass-item:first-child { border-top: none; } .pass-item strong { color: #ffd54f; } .no-passes { text-align: center; color: #8e9ec0; padding: 16px; } /* Map */ .map-container { flex: 1; min-height: 100vh; } .map { width: 100%; height: 100vh; } /* Scrollbar */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: #0b1220; } ::-webkit-scrollbar-thumb { background: #364b74; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: #4a6299; } " src/main.tsx " import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode> ); " src/index.css " * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } #root { width: 100%; min-height: 100vh; } " index.html " <!doctype html> <html lang="ru"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>RNIIRS - Мониторинг спутников</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html> " vite.config.ts " import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], }) " tsconfig.json " { "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] } " tsconfig.app.json " { "compilerOptions": { "target": "ES2023", "useDefineForClassFields": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "types": ["vite/client"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["src"] } " tsconfig.node.json " { "compilerOptions": { "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", "types": ["node"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["vite.config.ts"] } " package.json " { "name": "rniirs-web-platform", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "leaflet": "^1.9.4", "react": "^19.2.4", "react-dom": "^19.2.4", "satellite.js": "^6.0.2" }, "devDependencies": { "@eslint/js": "^9.39.4", "@types/leaflet": "^1.9.21", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1" } } "

язык не указан · рейтинг — · 👍 0 · ❤️ 0 · 💎 0 · 0 форков · ссылка на SourceCraft

Дата анализа: 29.09.2026 00:59

Нет данных
Скан есть, измерить метрики нельзя
Скачать Markdown

Почему такой балл

Сильные и слабые стороны

Слабые: Документация: нет данных, CI/CD: нет данных, Безопасность: нет данных, Активность: нет данных, Issues: нет данных, Состояние кода: нет данных

Находки и рекомендации

Находок нет — либо скан ещё не считал Score, либо проблем не найдено.

История проверок

  1. 29.09.2026 00:59 — нет данных