Add PCP architecture docs and TGU ops updates

Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
This commit is contained in:
Дмитрий Соловьев
2026-05-30 14:18:19 +03:00
parent 9fca4d4051
commit c3a1a8b4a1
86 changed files with 10426 additions and 74 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Карта работ КА — Mission Ops</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg-0: oklch(0.18 0.012 165);
--bg-1: oklch(0.215 0.013 165);
--bg-2: oklch(0.255 0.014 165);
--bg-3: oklch(0.30 0.015 165);
--line: oklch(0.34 0.014 165);
--line-soft: oklch(0.28 0.013 165);
--grid: oklch(0.26 0.012 165);
--ink: oklch(0.95 0.01 150);
--ink-1: oklch(0.80 0.012 160);
--ink-2: oklch(0.64 0.012 165);
--ink-3: oklch(0.50 0.012 165);
--t-optical: oklch(0.80 0.14 158);
--t-radar: oklch(0.82 0.13 82);
--t-combo: oklch(0.74 0.15 6);
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
--accent: oklch(0.82 0.13 200);
--warn: oklch(0.80 0.15 55);
--now: oklch(0.86 0.16 25);
--radius: 7px;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "IBM Plex Sans", system-ui, sans-serif;
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--ink);
font-size: 13px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
#root { height: 100vh; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: var(--bg-0); }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
::-webkit-scrollbar-corner { background: var(--bg-0); }
.mono { font-family: var(--mono); }
.tnum { font-variant-numeric: tabular-nums; }
button { font-family: inherit; color: inherit; cursor: pointer; }
a { -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.jsx"></script>
<script src="world.js"></script>
<script src="basemap.js"></script>
<script type="text/babel" src="util.jsx"></script>
<script type="text/babel" src="orbit.jsx"></script>
<script type="text/babel" src="tabs.jsx"></script>
<script type="text/babel" src="mapview.jsx"></script>
<script type="text/babel" src="mapdetails.jsx"></script>
<script type="text/babel" src="mapapp.jsx"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
// Корневой компонент: состояние, фильтры, масштаб, тулбар, сборка строк.
const { useState, useMemo, useEffect, useRef } = React;
// Пресеты масштаба: имя → видимое окно в мс (центрируется на «сейчас» при выборе).
const HOUR = D.HOUR, DAY = D.DAY;
const SCALES = [
{ id: "week", label: "Неделя", span: 7 * DAY, minPx: 64 },
{ id: "2week", label: "2 недели", span: 14 * DAY, minPx: 70 },
{ id: "month", label: "Месяц", span: 30 * DAY, minPx: 80 },
{ id: "range", label: "Весь диапазон", span: D.RANGE_END - D.RANGE_START, minPx: 90 },
];
function App() {
// Видимость групп / скрытые КА.
const [groupShown, setGroupShown] = useState(() => {
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
});
const [scHidden, setScHidden] = useState(() => new Set());
const [typeFilter, setTypeFilter] = useState({ optical: true, radar: true, combo: true });
const [search, setSearch] = useState("");
const [scaleId, setScaleId] = useState("week");
const [selectedIv, setSelectedIv] = useState(() => {
const p = new URLSearchParams(location.search).get("plan");
return p ? p : null;
});
const [selectedSc, setSelectedSc] = useState(null);
const [timelineCollapsed, setTimelineCollapsed] = useState(() => new Set());
const [scrollSignal, setScrollSignal] = useState(0);
const [winCenter, setWinCenter] = useState(D.NOW);
const byId = useMemo(() => {
const m = {}; D.plans.forEach((iv) => (m[iv.id] = iv)); return m;
}, []);
const scById = useMemo(() => {
const m = {}; D.spacecraft.forEach((sc) => (m[sc.id] = sc)); return m;
}, []);
// Масштаб → px/ms (умещаем span в условные 1100px рабочей ширины).
const scale = SCALES.find((s) => s.id === scaleId);
const VIEW_W = 1180;
const pxPerMs = VIEW_W / scale.span;
const rangeStart = D.RANGE_START;
const rangeEnd = D.RANGE_END;
// Фильтрация КА.
const scByGroup = useMemo(() => {
const m = {};
D.spacecraft.forEach((sc) => { (m[sc.groupId] = m[sc.groupId] || []).push(sc); });
return m;
}, []);
const q = search.trim().toLowerCase();
const matchesSearch = (sc) => !q || sc.name.toLowerCase().includes(q) || sc.short.toLowerCase().includes(q);
// Счётчики для легенды.
const counts = useMemo(() => {
const byType = {};
D.spacecraft.forEach((sc) => { byType[sc.type] = (byType[sc.type] || 0) + 1; });
return { byType };
}, []);
// Интервалы по КА (с учётом типового фильтра не трогаем — фильтр по типу скрывает строки целиком).
const ivBySc = useMemo(() => {
const m = {};
D.plans.forEach((iv) => { (m[iv.scId] = m[iv.scId] || []).push(iv); });
return m;
}, []);
// Сборка строк таймлайна.
const rows = useMemo(() => {
const out = [];
for (const g of D.GROUP_DEFS) {
if (!groupShown[g.id]) continue;
const list = (scByGroup[g.id] || [])
.filter((sc) => typeFilter[sc.type])
.filter((sc) => !scHidden.has(sc.id))
.filter(matchesSearch);
if (list.length === 0) continue;
out.push({ kind: "group", group: g, count: list.length });
if (timelineCollapsed.has(g.id)) continue;
for (const sc of list) {
out.push({ kind: "sc", sc, ivs: ivBySc[sc.id] || [] });
}
}
return out;
}, [groupShown, scHidden, typeFilter, q, timelineCollapsed]);
// Цепочка родословной выбранного интервала.
const lineageSet = useMemo(() => {
if (!selectedIv) return null;
const lin = lineageOf(selectedIv, byId);
return new Set(lin.chain);
}, [selectedIv]);
const selIv = selectedIv ? byId[selectedIv] : null;
const selSc = selIv ? scById[selIv.scId] : null;
const selGroup = selSc ? D.GROUP_DEFS.find((g) => g.id === selSc.groupId) : null;
const toggleGroup = (id) => setGroupShown((s) => ({ ...s, [id]: !s[id] }));
const toggleSc = (id) => setScHidden((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const toggleCollapse = (id) => setTimelineCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const onPickSc = (id) => {
setSelectedSc(id);
// прокрутка к первому интервалу этого КА
const ivs = ivBySc[id] || [];
if (ivs.length) { setSelectedIv(null); }
};
// Статистика по видимым.
const stats = useMemo(() => {
let sc = 0, iv = 0, ov = 0;
rows.forEach((r) => {
if (r.kind === "sc") {
sc++;
iv += r.ivs.length;
ov += overlapSegments(r.ivs).length;
}
});
return { sc, iv, ov };
}, [rows]);
const allOn = D.GROUP_DEFS.every((g) => groupShown[g.id]) && scHidden.size === 0;
const showAll = () => { const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); setGroupShown(o); setScHidden(new Set()); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<TopTabs active="plans" mapHref="Map.html" />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `268px 1fr ${selIv ? "340px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
<Sidebar
groups={D.GROUP_DEFS} scByGroup={scByGroup}
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
groupShown={groupShown} toggleGroup={toggleGroup}
scHidden={scHidden} toggleSc={toggleSc}
search={search} setSearch={setSearch}
counts={counts} selectedSc={selectedSc} onPickSc={onPickSc}
/>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* ТУЛБАР */}
<div style={{ height: 50, flex: "none", display: "flex", alignItems: "center", gap: 14, padding: "0 16px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
{/* масштаб */}
<div style={{ display: "flex", gap: 2, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{SCALES.map((s) => (
<button key={s.id} onClick={() => { setScaleId(s.id); setScrollSignal((x) => x + 1); }}
style={{ padding: "5px 11px", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scaleId === s.id ? "var(--bg-3)" : "transparent", color: scaleId === s.id ? "var(--ink)" : "var(--ink-3)" }}>{s.label}</button>
))}
</div>
<button onClick={() => setScrollSignal((x) => x + 1)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, fontWeight: 600, color: "var(--now)" }}>
<span style={{ width: 6, height: 6, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 6px var(--now)" }} />Сейчас
</button>
<div style={{ width: 1, height: 22, background: "var(--line)" }} />
<WorksLegend />
<div style={{ flex: 1 }} />
{/* статистика */}
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
<Stat label="АППАРАТОВ" v={stats.sc} />
<Stat label="ПЛАНОВ" v={stats.iv} />
<Stat label="ПЕРЕСЕЧЕНИЙ" v={stats.ov} accent={stats.ov > 0 ? "var(--now)" : null} />
</div>
{!allOn && (
<button onClick={showAll} style={{ padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, color: "var(--ink-1)" }}>Показать все</button>
)}
</div>
{rows.length === 0 ? (
<div style={{ flex: 1, display: "grid", placeItems: "center", color: "var(--ink-3)", fontSize: 13 }}>
Нет аппаратов по текущим фильтрам.
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }} onClick={() => {}}>
<Timeline
rows={rows} pxPerMs={pxPerMs} rangeStart={rangeStart} rangeEnd={rangeEnd}
now={D.NOW} selectedIv={selectedIv} onSelectIv={setSelectedIv}
lineageSet={lineageSet} byId={byId} scrollSignal={scrollSignal}
timelineCollapsed={timelineCollapsed} toggleCollapse={toggleCollapse}
/>
</div>
)}
</div>
<Details iv={selIv} sc={selSc} group={selGroup} byId={byId} onSelectIv={setSelectedIv} onClose={() => setSelectedIv(null)} />
</div>
</div>
);
}
function WorksLegend() {
const items = [
{ k: "shoot", label: "Съёмка", stripes: true },
{ k: "downlink", label: "Сброс", fill: "var(--accent)" },
{ k: "calib", label: "Калибровка", fill: "oklch(0.78 0.02 200)" },
{ k: "service", label: "Служебная", dashed: true },
];
return (
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ:</span>
{items.map((it) => (
<div key={it.k} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<span style={{
width: 11, height: 9, borderRadius: 2, flex: "none",
background: it.stripes
? "linear-gradient(90deg, var(--t-optical) 0 33%, var(--t-radar) 33% 66%, var(--t-combo) 66% 100%)"
: it.dashed ? "transparent" : it.fill,
border: it.dashed ? "1px dashed var(--ink-3)" : "none",
}} />
<span style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{it.label}</span>
</div>
))}
</div>
);
}
function Stat({ label, v, accent }) { return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.1 }}>
<span className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: accent || "var(--ink)" }}>{v}</span>
<span className="mono" style={{ fontSize: 8, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
@@ -0,0 +1,107 @@
// Web Mercator проекция + слой растровых тайлов (slippy-map) + кэш загрузки.
// Подложки: тёмная (CARTO, OSM-данные), классическая OSM, без тайлов (схема).
(function () {
const TILE = 256;
const DEG = Math.PI / 180, RAD = 180 / Math.PI;
const MAX_LAT = 85.0511;
// lon/lat -> мировые пиксели при размере мира ws (= TILE * 2^z).
function lonToWorldX(lon, ws) { return (lon + 180) / 360 * ws; }
function latToWorldY(lat, ws) {
const l = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
const s = Math.sin(l * DEG);
const y = 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI);
return y * ws;
}
function worldXToLon(wx, ws) { return wx / ws * 360 - 180; }
function worldYToLat(wy, ws) {
const n = Math.PI - 2 * Math.PI * (wy / ws);
return RAD * Math.atan(Math.sinh(n));
}
// Метры на пиксель в точке широты (для перевода км -> px).
function metersPerPixel(lat, ws) {
return Math.cos(lat * DEG) * 2 * Math.PI * 6378137 / ws;
}
const PROVIDERS = {
dark: {
id: "dark", name: "Тёмная",
url: (z, x, y) => `https://a.basemaps.cartocdn.com/dark_all/${z}/${x}/${y}.png`,
scrim: 0.0, label: "© OpenStreetMap · © CARTO", maxZoom: 18, dark: true,
},
osm: {
id: "osm", name: "Светлая",
url: (z, x, y) => `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`,
scrim: 0.34, label: "© OpenStreetMap · © CARTO", maxZoom: 19, dark: false,
},
schema: {
id: "schema", name: "Схема", url: null, scrim: 0, label: "Схематичная основа (оффлайн)", dark: true,
},
};
// Кэш тайлов: key -> {img, loaded, error}.
const cache = new Map();
function getTile(provider, z, x, y, onLoad) {
const key = provider.id + "/" + z + "/" + x + "/" + y;
let rec = cache.get(key);
if (rec) return rec;
rec = { img: new Image(), loaded: false, error: false };
rec.img.crossOrigin = "anonymous";
rec.img.onload = () => { rec.loaded = true; onLoad && onLoad(); };
rec.img.onerror = () => { rec.error = true; };
rec.img.src = provider.url(z, x, y);
cache.set(key, rec);
return rec;
}
// Рисует слой тайлов на ctx для текущего вида.
// view: { centerLon, centerLat, z }, size: {w,h}. onLoad — колбэк перерисовки.
function drawTiles(ctx, provider, view, size, onLoad) {
if (!provider || !provider.url) return;
const { w, h } = size;
const z = view.z;
const tz = Math.max(1, Math.min(provider.maxZoom || 18, Math.round(z)));
const ws = TILE * Math.pow(2, z); // непрерывный размер мира
const k = Math.pow(2, z - tz); // масштаб тайла tz -> экран
const tileScreen = TILE * k;
const n = Math.pow(2, tz);
const cwx = lonToWorldX(view.centerLon, ws);
const cwy = latToWorldY(view.centerLat, ws);
const originX = cwx - w / 2; // мировой px (непрерывный) в левом верхнем углу
const originY = cwy - h / 2;
// Видимый диапазон тайлов в tz-пространстве.
const leftTz = (originX / k) / TILE;
const topTz = (originY / k) / TILE;
const rightTz = ((originX + w) / k) / TILE;
const botTz = ((originY + h) / k) / TILE;
const x0 = Math.floor(leftTz), x1 = Math.floor(rightTz);
const y0 = Math.max(0, Math.floor(topTz)), y1 = Math.min(n - 1, Math.floor(botTz));
ctx.imageSmoothingEnabled = true;
for (let tx = x0; tx <= x1; tx++) {
const wrapX = ((tx % n) + n) % n; // горизонтальный повтор мира
for (let ty = y0; ty <= y1; ty++) {
const rec = getTile(provider, tz, wrapX, ty, onLoad);
const sx = tx * tileScreen - originX;
const sy = ty * tileScreen - originY;
if (rec.loaded) {
ctx.drawImage(rec.img, Math.floor(sx), Math.floor(sy), Math.ceil(tileScreen) + 1, Math.ceil(tileScreen) + 1);
}
}
}
// затемняющий scrim (для светлой OSM, чтобы overlay читался)
if (provider.scrim > 0) {
ctx.fillStyle = `rgba(8,16,12,${provider.scrim})`;
ctx.fillRect(0, 0, w, h);
}
}
window.Basemap = {
TILE, MAX_LAT, PROVIDERS,
lonToWorldX, latToWorldY, worldXToLon, worldYToLat,
metersPerPixel, drawTiles, getTile,
};
})();
+195
View File
@@ -0,0 +1,195 @@
// Тестовые данные планов КА. Детерминированная генерация (seeded RNG),
// чтобы при перезагрузке данные были стабильны.
(function () {
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = mulberry32(20260529);
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const randint = (a, b) => a + Math.floor(rnd() * (b - a + 1));
const HOUR = 3600 * 1000;
const DAY = 24 * HOUR;
// "Сейчас" — фиксируем под текущую дату проекта.
const NOW = new Date("2026-05-29T11:20:00Z").getTime();
// Три типа по аппаратуре (все аппараты — ДЗЗ).
const TYPES = {
optical: { id: "optical", label: "Оптическая", short: "ОПТ" },
radar: { id: "radar", label: "Радиолокационная", short: "РЛ" },
combo: { id: "combo", label: "Комбинированная", short: "ОПТ+РЛ" },
};
// Программы/созвездия (название группы). Каждая группа тяготеет к своим типам.
const GROUP_DEFS = [
{ id: "resurs", name: "Ресурс-ОП", code: "RSO", weights: { optical: 0.8, radar: 0.05, combo: 0.15 } },
{ id: "kanopus", name: "Канопус-В", code: "KNP", weights: { optical: 0.7, radar: 0.1, combo: 0.2 } },
{ id: "kondor", name: "Кондор-ФКА", code: "KND", weights: { optical: 0.05, radar: 0.85, combo: 0.1 } },
{ id: "obzor", name: "Обзор-Р", code: "OBZ", weights: { optical: 0.05, radar: 0.8, combo: 0.15 } },
{ id: "meteor", name: "Метеор-М", code: "MTR", weights: { optical: 0.55, radar: 0.15, combo: 0.3 } },
{ id: "aist", name: "Аист-2", code: "AST", weights: { optical: 0.6, radar: 0.1, combo: 0.3 } },
{ id: "smotr", name: "Смотр-Р", code: "SMT", weights: { optical: 0.1, radar: 0.55, combo: 0.35 } },
];
function weightedType(weights) {
const r = rnd();
let acc = 0;
for (const k of Object.keys(weights)) {
acc += weights[k];
if (r <= acc) return k;
}
return "optical";
}
// Виды РАБОТ внутри плана. У каждой работы — своя категория (kind).
const WORK_KINDS = {
shoot: { id: "shoot", label: "Съёмка" },
downlink: { id: "downlink", label: "Сброс на НКПОР" },
calib: { id: "calib", label: "Калибровка" },
service: { id: "service", label: "Служебная операция" },
};
const WORK_KIND_ORDER = ["shoot", "downlink", "calib", "service"];
// Подпись работы-съёмки зависит от аппаратуры КА.
const SHOOT_LABEL = {
optical: ["Панхром. съёмка", "Мультиспектр. съёмка", "Стереосъёмка", "Площадная съёмка"],
radar: ["РСА съёмка", "Интерферометрия", "Прожекторный режим", "Сканирующий режим"],
combo: ["Совмещ. съёмка", "РСА + панхром.", "Площадная съёмка", "Мониторинг ЧС"],
};
// Названия самих ПЛАНОВ (объемлющих интервалов).
const PLAN_NAMES = ["Суточная программа", "Целевая программа", "Спецзадание", "Программа мониторинга", "Сеансовый план", "Программа ДЗЗ"];
function genWorks(planStart, planEnd, scType, makeId) {
const works = [];
const span = planEnd - planStart;
// Плотность работ ~ 1 на 4–9 часов плана.
let cursor = planStart + randint(0, 2) * HOUR;
let guard = 0;
while (cursor < planEnd - 1 * HOUR && guard < 40) {
guard++;
const kind = pick(WORK_KIND_ORDER);
let dur;
if (kind === "shoot") dur = randint(20, 140) * 60 * 1000; // 20м–2.3ч
else if (kind === "downlink") dur = randint(8, 35) * 60 * 1000; // сброс
else if (kind === "calib") dur = randint(30, 90) * 60 * 1000;
else dur = randint(10, 40) * 60 * 1000; // служебная
const wStart = cursor;
const wEnd = Math.min(planEnd, wStart + dur);
if (wEnd <= wStart) break;
const label = kind === "shoot" ? pick(SHOOT_LABEL[scType]) : WORK_KINDS[kind].label;
works.push({ id: makeId(), kind, label, start: wStart, end: wEnd });
// пауза между работами
cursor = wEnd + randint(20, 180) * 60 * 1000;
}
return works;
}
// Генерация КА.
const spacecraft = [];
let scCounter = 0;
for (const g of GROUP_DEFS) {
const count = randint(8, 16); // суммарно ~80
for (let i = 0; i < count; i++) {
const type = weightedType(g.weights);
scCounter++;
const num = i + 1;
spacecraft.push({
id: `${g.id}-${num}`,
name: `${g.name}${num}`,
short: `${g.code}-${String(num).padStart(2, "0")}`,
groupId: g.id,
type,
orbit: pick(["ССО 510 км", "ССО 575 км", "ССО 700 км", "ССО 480 км"]),
});
}
}
// Видимый горизонт планирования: ~ -16 сут .. +30 сут от "сейчас".
const RANGE_START = NOW - 16 * DAY;
const RANGE_END = NOW + 30 * DAY;
function statusFor(start, end) {
if (end < NOW) return "executed";
if (start <= NOW && NOW <= end) return "active";
return "planned";
}
// Генерация ПЛАНОВ (объемлющих интервалов) с родословной (ревизии плана = потомки).
// Каждый план содержит набор РАБОТ внутри своего интервала.
const plans = [];
let ivCounter = 0;
let workCounter = 0;
const makeWorkId = () => `W-${String(++workCounter).padStart(5, "0")}`;
for (const sc of spacecraft) {
// стартовая точка цепочки
let cursor = RANGE_START + randint(0, 3) * DAY + randint(0, 20) * HOUR;
let prevId = null;
const nChains = randint(2, 4);
for (let c = 0; c < nChains; c++) {
// базовый план в цепочке
const dur = randint(8, 60) * HOUR;
const start = cursor;
const end = start + dur;
ivCounter++;
const baseId = `PL-${String(ivCounter).padStart(4, "0")}`;
const planName = pick(PLAN_NAMES);
plans.push({
id: baseId,
scId: sc.id,
start, end,
title: planName,
rev: 1,
parentId: prevId,
status: statusFor(start, end),
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "ПЦ Дубна", "Авто-планировщик"]),
works: genWorks(start, end, sc.type, makeWorkId),
});
prevId = baseId;
// c вероятностью — ревизия, перекрывающая базовый план (создаёт пересечение)
if (rnd() < 0.45) {
const overlapStart = start + Math.floor(dur * (0.35 + rnd() * 0.4));
const revDur = randint(10, 50) * HOUR;
const revEnd = overlapStart + revDur;
ivCounter++;
const revId = `PL-${String(ivCounter).padStart(4, "0")}`;
plans.push({
id: revId,
scId: sc.id,
start: overlapStart,
end: revEnd,
title: planName,
rev: 2,
parentId: baseId,
status: statusFor(overlapStart, revEnd),
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "Авто-планировщик"]),
works: genWorks(overlapStart, revEnd, sc.type, makeWorkId),
});
prevId = revId;
cursor = revEnd + randint(4, 36) * HOUR;
} else {
cursor = end + randint(6, 48) * HOUR;
}
if (cursor > RANGE_END) break;
}
}
// Индексы родословной: дети по родителю.
const childrenOf = {};
for (const iv of plans) {
if (iv.parentId) {
(childrenOf[iv.parentId] = childrenOf[iv.parentId] || []).push(iv.id);
}
}
window.MissionData = {
HOUR, DAY, NOW, RANGE_START, RANGE_END,
TYPES, GROUP_DEFS, WORK_KINDS, WORK_KIND_ORDER,
spacecraft, plans, childrenOf,
typeOrder: ["optical", "radar", "combo"],
};
})();
@@ -0,0 +1,159 @@
// Правая панель деталей выбранного интервала + лента родословной (предки/потомки).
function StatusBadge({ status }) {
const map = {
executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
};
const s = map[status] || map.planned;
return <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
}
function Field({ label, children, mono }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
<span className={mono ? "mono tnum" : ""} style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
</div>
);
}
function Details({ iv, sc, group, byId, onSelectIv, onClose }) {
if (!iv) {
return (
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10, padding: 24, textAlign: "center" }}>
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" style={{ opacity: .3 }}><rect x="3" y="6" width="18" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /><rect x="3" y="13" width="11" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /></svg>
<div style={{ fontSize: 12, color: "var(--ink-3)", maxWidth: 180 }}>Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.</div>
</div>
);
}
const lin = lineageOf(iv.id, byId);
const dur = iv.end - iv.start;
const col = TYPE_COLOR[sc.type];
const chainRow = (id) => {
const it = byId[id];
const isSel = id === iv.id;
return (
<button key={id} onClick={() => onSelectIv(id)} style={{
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
padding: "7px 9px", borderRadius: 6, border: `1px solid ${isSel ? "var(--accent)" : "var(--line-soft)"}`,
background: isSel ? "oklch(0.82 0.13 200 / 0.12)" : "var(--bg-2)",
}}>
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)", flex: "none" }}>{it.id}</span>
<span style={{ flex: 1, fontSize: 11.5, color: isSel ? "var(--ink)" : "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 4px", flex: "none" }}>r{it.rev}</span>
</button>
);
};
return (
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{iv.id}</span>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{iv.title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
<StatusBadge status={iv.status} />
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {group.name}</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Время */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field label="НАЧАЛО" mono>{fmtDateTime(iv.start)}</Field>
<Field label="КОНЕЦ" mono>{fmtDateTime(iv.end)}</Field>
<Field label="ДЛИТЕЛЬНОСТЬ" mono>{fmtDur(dur)}</Field>
<Field label="РАБОТ В ПЛАНЕ" mono>{iv.works.length}</Field>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Аппарат */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>АППАРАТ</span>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<TypeDot type={sc.type} size={9} />
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{sc.name}</div>
<div style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{D.TYPES[sc.type].label} · {sc.orbit}</div>
</div>
</div>
<Field label="ИСТОЧНИК ПЛАНА">{iv.author}</Field>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Работы внутри плана */}
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ В ПЛАНЕ</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{iv.works.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{iv.works.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Работы не заданы.</div>}
{iv.works.map((wk) => {
const fill = workFill(wk.kind, sc.type);
const isService = wk.kind === "service";
return (
<div key={wk.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ width: 9, height: 9, borderRadius: 2, flex: "none", background: isService ? "transparent" : fill, border: isService ? `1px dashed ${fill}` : "none" }} />
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{wk.label}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)", flex: "none" }}>{fmtTime(wk.start)}{fmtTime(wk.end)}</span>
</div>
);
})}
</div>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* Родословная */}
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РОДОСЛОВНАЯ ПЛАНА</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{lin.chain.length} ревизий</span>
</div>
{lin.ancestors.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}> Предки</span>
{lin.ancestors.map(chainRow)}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--accent)" }}> Текущий</span>
{chainRow(iv.id)}
</div>
{lin.descendants.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}> Потомки</span>
{lin.descendants.map(chainRow)}
</div>
)}
{lin.ancestors.length === 0 && lin.descendants.length === 0 && (
<div style={{ fontSize: 11, color: "var(--ink-3)", padding: "4px 0" }}>Это единственная ревизия плана без предков и потомков.</div>
)}
</div>
</div>
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
<a href={`Map.html?plan=${iv.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
Показать на карте
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>
</a>
</div>
</div>
);
}
Object.assign(window, { Details });
@@ -0,0 +1,162 @@
// Страница «Карта»: вкладки, левые контролы (охват/слои/выбор КА), детали, ?plan=.
const { useState: useStateMA, useMemo: useMemoMA, useEffect: useEffectMA } = React;
const HOUR_MS = 3600000, DAY_MS = 86400000;
const PLAN_PAGE = "План КА — Mission Ops.html";
function MapApp() {
const byId = useMemoMA(() => { const m = {}; D.plans.forEach(p => m[p.id] = p); return m; }, []);
const scById = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = s); return m; }, []);
const ivBySc = useMemoMA(() => { const m = {}; D.plans.forEach(p => (m[p.scId] = m[p.scId] || []).push(p)); return m; }, []);
const paramsBy = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = orbitParams(s)); return m; }, []);
// начальное состояние из URL
const urlParams = new URLSearchParams(location.search);
const urlPlan = urlParams.get("plan");
const urlSc = urlParams.get("sc");
const initPlan = urlPlan && byId[urlPlan] ? urlPlan : null;
const initSc = initPlan ? byId[initPlan].scId : (urlSc && scById[urlSc] ? urlSc : D.spacecraft[0].id);
const [scope, setScope] = useStateMA(initPlan || urlSc ? "one" : "one");
const [selSc, setSelSc] = useStateMA(initSc);
const [selPlan, setSelPlan] = useStateMA(initPlan);
const [search, setSearch] = useStateMA("");
const [layers, setLayers] = useStateMA({ tracks: true, swath: true, shoot: true, downlink: true, stations: true, terminator: true });
const [basemap, setBasemap] = useStateMA("dark");
const sc = scById[selSc];
const plan = selPlan ? byId[selPlan] : null;
// окно времени
const win = useMemoMA(() => {
if (plan) return { t0: plan.start, t1: Math.min(plan.end, plan.start + 3 * DAY_MS) };
return { t0: D.NOW - 3 * HOUR_MS, t1: D.NOW + 9 * HOUR_MS };
}, [plan]);
// список КА в охвате
const scList = useMemoMA(() => {
if (scope === "one") return [sc];
if (scope === "group") return D.spacecraft.filter(s => s.groupId === sc.groupId);
return D.spacecraft;
}, [scope, sc]);
const toggleLayer = (k) => setLayers(l => ({ ...l, [k]: !l[k] }));
const onSelectSc = (id) => { setSelSc(id); setSelPlan(null); if (scope === "all") setScope("group"); };
const onSelectPlan = (id) => { const p = byId[id]; setSelSc(p.scId); setSelPlan(id); setScope("one"); };
// фильтр выбора КА
const q = search.trim().toLowerCase();
const scResults = useMemoMA(() => D.spacecraft.filter(s => !q || s.name.toLowerCase().includes(q) || s.short.toLowerCase().includes(q)), [q]);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<TopTabs active="map" planHref={PLAN_PAGE} />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `288px 1fr ${plan ? "330px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
{/* ЛЕВЫЕ КОНТРОЛЫ */}
<div style={{ background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
{/* подложка */}
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ПОДЛОЖКА</div>
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => (
<button key={id} onClick={() => setBasemap(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: basemap === id ? "var(--bg-3)" : "transparent", color: basemap === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}</button>
))}
</div>
</div>
{/* охват */}
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ОХВАТ</div>
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
{[["one", "1 КА"], ["group", "Группа"], ["all", "Все"]].map(([id, lab]) => (
<button key={id} onClick={() => setScope(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scope === id ? "var(--bg-3)" : "transparent", color: scope === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}</button>
))}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 6 }}>
<TypeDot type={sc.type} size={8} />
<span className="mono">{sc.short}</span>
<span style={{ color: "var(--ink-3)" }}>· {D.GROUP_DEFS.find(g => g.id === sc.groupId).name}</span>
</div>
</div>
{/* окно времени */}
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 5 }}>ОКНО ВРЕМЕНИ</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-1)" }}>{fmtDateTime(win.t0)} {fmtDateTime(win.t1)}</div>
<div style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3 }}>{plan ? `план ${plan.id}` : "сутки вокруг «сейчас»"}</div>
</div>
{/* слои */}
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>СЛОИ</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<LayerRow on={layers.tracks} onClick={() => toggleLayer("tracks")} swatch={<LineSwatch />} label="Трассы КА" />
<LayerRow on={layers.swath} onClick={() => toggleLayer("swath")} swatch={<BandSwatch />} label="Полосы обзора (swath)" />
<LayerRow on={layers.shoot} onClick={() => toggleLayer("shoot")} swatch={<PolySwatch />} label="Контуры маршрутов съёмки" />
<LayerRow on={layers.downlink} onClick={() => toggleLayer("downlink")} swatch={<DownSwatch />} label="Участки сброса" />
<LayerRow on={layers.stations} onClick={() => toggleLayer("stations")} swatch={<StationSwatch />} label="Станции приёма (НКПОР)" />
<LayerRow on={layers.terminator} onClick={() => toggleLayer("terminator")} swatch={<NightSwatch />} label="Терминатор день/ночь" />
</div>
</div>
{/* выбор КА */}
<div style={{ padding: "10px 14px 6px" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТ В ФОКУСЕ</div>
<div style={{ position: "relative" }}>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск КА…"
style={{ width: "100%", padding: "7px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 8px 12px" }}>
{scResults.map((s) => (
<div key={s.id} onClick={() => onSelectSc(s.id)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, cursor: "pointer", background: s.id === selSc ? "var(--bg-3)" : "transparent" }}>
<TypeDot type={s.type} size={7} />
<span className="mono" style={{ flex: 1, fontSize: 11, color: s.id === selSc ? "var(--ink)" : "var(--ink-1)" }}>{s.short}</span>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{(ivBySc[s.id] || []).length} пл.</span>
</div>
))}
</div>
</div>
{/* КАРТА */}
<div style={{ position: "relative", minWidth: 0 }}>
<MapView scList={scList} selectedPlan={plan} window={win} now={D.NOW} layers={layers}
onSelectSc={onSelectSc} paramsBy={paramsBy} scById={scById} basemap={basemap} />
{/* плашка масштаба/охвата */}
<div style={{ position: "absolute", left: 12, top: 12, display: "flex", gap: 8, alignItems: "center", background: "var(--bg-1)", border: "1px solid var(--line)", borderRadius: 7, padding: "6px 10px" }}>
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>ОХВАТ:</span>
<span style={{ fontSize: 11.5, fontWeight: 600 }}>{scope === "one" ? "1 КА" : scope === "group" ? "Группа" : "Все"}</span>
<span style={{ width: 1, height: 14, background: "var(--line)" }} />
<span className="mono tnum" style={{ fontSize: 11.5, color: "var(--ink-1)" }}>{scList.length} КА</span>
</div>
</div>
{/* ДЕТАЛИ ПЛАНА */}
{plan && <MapDetails plan={plan} sc={scById[plan.scId]} params={paramsBy[plan.scId]} byId={byId} onSelectPlan={onSelectPlan} onClose={() => setSelPlan(null)} planPage={PLAN_PAGE} />}
</div>
</div>
);
}
function LayerRow({ on, onClick, swatch, label }) {
return (
<button onClick={onClick} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 6px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.4, textAlign: "left" }}>
<span style={{ width: 18, flex: "none", display: "grid", placeItems: "center" }}>{swatch}</span>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}>{label}</span>
<span style={{ width: 14, height: 14, borderRadius: 4, flex: "none", border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`, background: on ? "var(--accent)" : "transparent", display: "grid", placeItems: "center" }}>
{on && <svg width="9" height="9" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
</button>
);
}
const LineSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="1.4" fill="none" /></svg>;
const BandSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="5" fill="none" opacity="0.25" /></svg>;
const PolySwatch = () => <svg width="18" height="10"><rect x="2" y="2" width="14" height="6" fill="color-mix(in oklch, var(--t-optical) 30%, transparent)" stroke="var(--t-optical)" strokeWidth="1" /></svg>;
const DownSwatch = () => <svg width="18" height="10"><path d="M1 5 H17" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round" /></svg>;
const StationSwatch = () => <svg width="18" height="10"><path d="M9 1 L13 8 L5 8 Z" fill="rgba(180,200,210,0.9)" /></svg>;
const NightSwatch = () => <svg width="18" height="10"><rect x="0" y="0" width="9" height="10" fill="rgba(4,8,12,0.6)" /><rect x="9" y="0" width="9" height="10" fill="#15291f" /></svg>;
ReactDOM.createRoot(document.getElementById("root")).render(<MapApp />);
@@ -0,0 +1,125 @@
// Панель деталей плана на странице «Карта»: сводка, наземные параметры,
// работы (со «съёмка/сброс»), ссылка обратно на таймлайн.
function MapStatusBadge({ status }) {
const map = {
executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
};
const s = map[status] || map.planned;
return <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
}
function MapDetails({ plan, sc, params, byId, onSelectPlan, onClose, planPage }) {
const col = TYPE_COLOR[sc.type];
const shoots = plan.works.filter(w => w.kind === "shoot");
const downs = plan.works.filter(w => w.kind === "downlink");
const STATIONS = window.WorldGeo.GROUND_STATIONS;
// станции, задействованные в сбросах
const usedStations = [];
for (const d of downs) {
const pts = trackPoints(params, d.start, d.end, 10);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
if (ns.st && !usedStations.find(x => x.id === ns.st.id)) usedStations.push(ns.st);
}
return (
<div style={{ background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{plan.id}</span>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
</div>
<div style={{ fontSize: 16, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{plan.title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
<MapStatusBadge status={plan.status} />
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {sc.orbit}</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 15 }}>
{/* наземные параметры */}
<div>
<div className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em", marginBottom: 8 }}>НАЗЕМНЫЕ ПАРАМЕТРЫ</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<KV label="ВЫСОТА ОРБИТЫ">{params.h} км</KV>
<KV label="ПЕРИОД ВИТКА">{(params.T / 60).toFixed(1)} мин</KV>
<KV label="ПОЛОСА ОБЗОРА">{Math.round(params.swathKm)} км</KV>
<KV label="НАКЛОНЕНИЕ">{(params.incl * RAD).toFixed(1)}°</KV>
</div>
</div>
<div style={{ height: 1, background: "var(--line-soft)" }} />
{/* съёмка */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
<span style={{ width: 10, height: 8, background: col, borderRadius: 2, flex: "none" }} />
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>КОНТУРЫ СЪЁМКИ</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{shoots.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{shoots.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Съёмочных маршрутов нет.</div>}
{shoots.map(w => (
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.label}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}{fmtTime(w.end)}</span>
</div>
))}
</div>
</div>
{/* сброс */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
<span style={{ width: 12, height: 3, background: "var(--accent)", borderRadius: 2, flex: "none", boxShadow: "0 0 5px var(--accent)" }} />
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>УЧАСТКИ СБРОСА</span>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{downs.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{downs.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Сбросов нет.</div>}
{downs.map(w => {
const pts = trackPoints(params, w.start, w.end, 10);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
return (
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}> {ns.st.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}{fmtTime(w.end)}</span>
</div>
);
})}
</div>
</div>
{usedStations.length > 0 && (
<div style={{ fontSize: 10.5, color: "var(--ink-3)", lineHeight: 1.5 }}>
Задействованы станции: <span style={{ color: "var(--ink-1)" }}>{usedStations.map(s => s.name).join(", ")}</span>
</div>
)}
</div>
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
<a href={`${planPage}?plan=${plan.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
Открыть в таймлайне планов
</a>
</div>
</div>
);
}
function KV({ label, children }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".06em" }}>{label}</span>
<span className="mono tnum" style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
</div>
);
}
Object.assign(window, { MapDetails });
@@ -0,0 +1,300 @@
// Карта: Web Mercator. Canvas-основа (тайлы OSM/CARTO или векторная схема,
// сетка, трассы, swath, терминатор) + SVG-overlay (станции, «сейчас»,
// контуры съёмки, сброс, hover). Зум/панорама как у slippy-карты.
const { useRef: useRefMV, useState: useStateMV, useEffect: useEffectMV, useMemo: useMemoMV, useCallback } = React;
const STATIONS = window.WorldGeo.GROUND_STATIONS;
const BM = window.Basemap;
const TILE = BM.TILE;
function MapView(props) {
const { scList, selectedPlan, window: win, now, layers, onSelectSc, paramsBy, scById, basemap } = props;
const wrapRef = useRefMV(null);
const canvasRef = useRefMV(null);
const [dims, setDims] = useStateMV({ w: 1000, h: 560 });
const [view, setView] = useStateMV({ centerLon: 45, centerLat: 30, z: 2.2, init: false });
const [hover, setHover] = useStateMV(null);
const [tileTick, setTileTick] = useStateMV(0);
const drag = useRefMV(null);
const provider = BM.PROVIDERS[basemap] || BM.PROVIDERS.dark;
const ws = useMemoMV(() => TILE * Math.pow(2, view.z), [view.z]);
// Инициализация: вписать мир по ширине.
useEffectMV(() => {
if (view.init || !dims.w) return;
const z = Math.max(0.7, Math.log2(dims.w / TILE) - 0.05);
setView((v) => ({ ...v, z, centerLon: 45, centerLat: 28, init: true }));
}, [dims, view.init]);
const project = useCallback((lon, lat) => {
const cwx = BM.lonToWorldX(view.centerLon, ws);
const cwy = BM.latToWorldY(view.centerLat, ws);
let wx = BM.lonToWorldX(lon, ws);
// выбрать ближайшую копию мира по X (горизонтальный повтор)
const half = ws / 2;
while (wx - cwx > half) wx -= ws;
while (wx - cwx < -half) wx += ws;
return {
x: dims.w / 2 + (wx - cwx),
y: dims.h / 2 + (BM.latToWorldY(lat, ws) - cwy),
};
}, [view.centerLon, view.centerLat, ws, dims]);
const unproject = useCallback((sx, sy) => {
const cwx = BM.lonToWorldX(view.centerLon, ws);
const cwy = BM.latToWorldY(view.centerLat, ws);
return {
lon: BM.worldXToLon(cwx + (sx - dims.w / 2), ws),
lat: BM.worldYToLat(cwy + (sy - dims.h / 2), ws),
};
}, [view.centerLon, view.centerLat, ws, dims]);
// ResizeObserver
useEffectMV(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setDims({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el);
setDims({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
// Отрисовка canvas.
useEffectMV(() => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = dims.w * dpr; cv.height = dims.h * dpr;
cv.style.width = dims.w + "px"; cv.style.height = dims.h + "px";
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, dims.w, dims.h);
// фон
ctx.fillStyle = provider.dark ? "#0a1410" : "#0a1410";
ctx.fillRect(0, 0, dims.w, dims.h);
const P = (lon, lat) => project(lon, lat);
if (provider.url) {
// растровая подложка (тайлы)
BM.drawTiles(ctx, provider, view, dims, () => setTileTick((t) => t + 1));
} else {
// векторная схема (оффлайн)
drawVectorWorld(ctx, P, dims);
}
// сетка координат поверх (тонкая)
ctx.strokeStyle = provider.dark ? "rgba(120,180,150,0.10)" : "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let lon = -180; lon <= 180; lon += 30) { const a = P(lon, 84), b = P(lon, -84); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); }
for (let lat = -60; lat <= 60; lat += 30) { const a = P(-180, lat), b = P(180, lat); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); }
ctx.stroke();
// терминатор / ночь
if (layers.terminator) {
const tc = terminatorCurve(now);
ctx.beginPath();
tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
const topLat = tc.nightInNorth ? 84 : -84;
const c1 = P(180, topLat), c2 = P(-180, topLat);
ctx.lineTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y);
ctx.closePath();
ctx.fillStyle = "rgba(4,8,12,0.42)";
ctx.fill();
ctx.strokeStyle = "rgba(255,210,120,0.30)";
ctx.lineWidth = 1;
ctx.beginPath();
tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
// трассы + swath
const TYPE_RGB = { optical: [126,222,180], radar: [232,200,120], combo: [240,150,165] };
const many = scList.length;
const showSwath = layers.swath && many <= 8;
const trackAlpha = many > 40 ? 0.14 : many > 14 ? 0.22 : many > 4 ? 0.4 : 0.72;
for (const sc of scList) {
const p = paramsBy[sc.id];
const rgb = TYPE_RGB[sc.type];
const segs = groundTrack(p, win.t0, win.t1, 900);
const isSelSc = selectedPlan && selectedPlan.scId === sc.id;
if (showSwath || isSelSc) {
for (const seg of segs) {
if (!seg.length) continue;
const midLat = seg[Math.floor(seg.length / 2)].lat;
const mpp = BM.metersPerPixel(midLat, ws);
const wpx = Math.max(1.5, (p.swathKm * 1000) / mpp);
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? 0.16 : 0.09})`;
ctx.lineWidth = wpx; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
}
if (layers.tracks) {
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? 0.98 : trackAlpha})`;
ctx.lineWidth = isSelSc ? 2 : many > 14 ? 0.6 : 0.9;
for (const seg of segs) {
ctx.beginPath();
seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.stroke();
}
}
}
}, [dims, view, scList, selectedPlan, win, now, layers, project, ws, provider, tileTick]);
// ---- overlay (SVG) ----
const planOverlay = useMemoMV(() => {
if (!selectedPlan) return null;
const p = paramsBy[selectedPlan.scId];
const sc = scById[selectedPlan.scId];
const shoots = [], downs = [];
for (const wk of selectedPlan.works) {
if (wk.kind === "shoot" && layers.shoot) {
const pts = trackPoints(p, wk.start, wk.end, 18);
const poly = ribbon(pts, p.swathKm / 2);
shoots.push({ id: wk.id, label: wk.label, poly: poly.map(c => project(c[0], c[1])) });
}
if (wk.kind === "downlink" && layers.downlink) {
const pts = trackPoints(p, wk.start, wk.end, 14);
const mid = pts[Math.floor(pts.length / 2)];
const ns = nearestStation(mid.lat, mid.lon, STATIONS);
downs.push({
id: wk.id, label: wk.label,
line: pts.map(pt => project(pt.lon, pt.lat)),
st: ns.st, stPt: project(ns.st.lon, ns.st.lat), midPt: project(mid.lon, mid.lat),
});
}
}
return { shoots, downs, scType: sc.type };
}, [selectedPlan, layers, project, paramsBy, scById]);
const nowMarkers = useMemoMV(() => {
if (now < win.t0 || now > win.t1) return [];
return scList.map((sc) => {
const s = subPoint(paramsBy[sc.id], now);
return { id: sc.id, short: sc.short, type: sc.type, ...project(s.lon, s.lat) };
});
}, [scList, now, win, project, paramsBy]);
const TYPE_HEX = { optical: "var(--t-optical)", radar: "var(--t-radar)", combo: "var(--t-combo)" };
// ---- pan / zoom ----
const onWheel = (e) => {
e.preventDefault();
const rect = wrapRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const before = unproject(mx, my);
setView((v) => {
const nz = Math.max(0.7, Math.min(9, v.z + (e.deltaY < 0 ? 0.4 : -0.4)));
const nws = TILE * Math.pow(2, nz);
const cwx = BM.lonToWorldX(before.lon, nws) - (mx - dims.w / 2);
const cwy = BM.latToWorldY(before.lat, nws) - (my - dims.h / 2);
return { ...v, z: nz, centerLon: BM.worldXToLon(cwx, nws), centerLat: BM.worldYToLat(cwy, nws) };
});
};
const onDown = (e) => {
drag.current = { x: e.clientX, y: e.clientY, lon: view.centerLon, lat: view.centerLat, moved: false };
};
const onMove = (e) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.current.moved = true;
const cwx = BM.lonToWorldX(drag.current.lon, ws) - dx;
const cwy = BM.latToWorldY(drag.current.lat, ws) - dy;
setView((v) => ({ ...v, centerLon: BM.worldXToLon(cwx, ws), centerLat: Math.max(-82, Math.min(82, BM.worldYToLat(cwy, ws))) }));
};
const onUp = () => { drag.current = null; };
const zoomBy = (f) => setView((v) => ({ ...v, z: Math.max(0.7, Math.min(9, v.z + f)) }));
const resetView = () => setView((v) => ({ ...v, init: false }));
return (
<div ref={wrapRef} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: drag.current ? "grabbing" : "grab", background: "#0a1410" }}
onWheel={onWheel} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0 }} />
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }}>
{planOverlay && planOverlay.shoots.map((s) => (
<polygon key={s.id} points={s.poly.map(p => `${p.x},${p.y}`).join(" ")}
fill={`color-mix(in oklch, ${TYPE_HEX[planOverlay.scType]} 32%, transparent)`}
stroke={TYPE_HEX[planOverlay.scType]} strokeWidth="1.4" style={{ pointerEvents: "all", cursor: "pointer" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: s.label, kind: "Съёмка / контур маршрута" })}
onMouseLeave={() => setHover(null)} />
))}
{planOverlay && planOverlay.downs.map((d) => (
<g key={d.id}>
<line x1={d.midPt.x} y1={d.midPt.y} x2={d.stPt.x} y2={d.stPt.y} stroke="var(--accent)" strokeWidth="1" strokeDasharray="2 3" opacity="0.6" />
<polyline points={d.line.map(p => `${p.x},${p.y}`).join(" ")} fill="none"
stroke="var(--accent)" strokeWidth="3.4" strokeLinecap="round"
style={{ pointerEvents: "all", cursor: "pointer", filter: "drop-shadow(0 0 4px var(--accent))" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: `${d.label}${d.st.name}`, kind: "Сброс на НКПОР" })}
onMouseLeave={() => setHover(null)} />
</g>
))}
{layers.stations && STATIONS.map((st) => {
const q = project(st.lon, st.lat);
const active = planOverlay && planOverlay.downs.some(d => d.st.id === st.id);
return (
<g key={st.id} transform={`translate(${q.x},${q.y})`} style={{ pointerEvents: "all", cursor: "default" }}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: st.name, kind: "Станция приёма (НКПОР)" })}
onMouseLeave={() => setHover(null)}>
<path d="M0,-6 L5,4 L-5,4 Z" fill={active ? "var(--accent)" : "rgba(190,210,220,0.92)"} stroke="#0a1410" strokeWidth="1" />
{view.z > 3.6 && <text x="7" y="3" fontSize="9" fill={provider.dark ? "var(--ink-2)" : "#dfeaf0"} style={{ fontFamily: "var(--mono)", paintOrder: "stroke", stroke: "#0a1410", strokeWidth: 2 }}>{st.name}</text>}
</g>
);
})}
{nowMarkers.map((m) => (
<g key={m.id} transform={`translate(${m.x},${m.y})`} style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={() => onSelectSc(m.id)}
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: m.short, kind: "Подспутниковая точка · сейчас" })}
onMouseLeave={() => setHover(null)}>
<circle r="4.5" fill={TYPE_HEX[m.type]} stroke="#0a1410" strokeWidth="1.5" />
<circle r="8" fill="none" stroke={TYPE_HEX[m.type]} strokeWidth="1" opacity="0.5" />
</g>
))}
</svg>
{hover && (
<div style={{ position: "fixed", left: hover.x + 12, top: hover.y + 12, zIndex: 50, pointerEvents: "none",
background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, padding: "6px 9px", boxShadow: "var(--shadow)", maxWidth: 220 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)" }}>{hover.title}</div>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", marginTop: 2 }}>{hover.kind}</div>
</div>
)}
{/* атрибуция подложки */}
<div className="mono" style={{ position: "absolute", left: 8, bottom: 6, fontSize: 9, color: provider.dark ? "var(--ink-3)" : "rgba(255,255,255,0.8)", background: "rgba(10,20,16,0.55)", padding: "2px 6px", borderRadius: 4, pointerEvents: "none" }}>{provider.label}</div>
<div style={{ position: "absolute", right: 12, bottom: 12, display: "flex", flexDirection: "column", gap: 4 }}>
<ZoomBtn onClick={() => zoomBy(0.5)}>+</ZoomBtn>
<ZoomBtn onClick={() => zoomBy(-0.5)}></ZoomBtn>
<ZoomBtn onClick={resetView} title="Сбросить вид"></ZoomBtn>
</div>
</div>
);
}
// Векторная схема континентов (режим «Схема», оффлайн).
function drawVectorWorld(ctx, P, dims) {
for (const name in window.WorldGeo.LAND) {
const poly = window.WorldGeo.LAND[name];
ctx.beginPath();
poly.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); });
ctx.closePath();
ctx.fillStyle = "#173228"; ctx.fill();
ctx.strokeStyle = "rgba(150,215,180,0.5)"; ctx.lineWidth = 0.9; ctx.stroke();
}
}
function ZoomBtn({ children, onClick, title }) {
return (
<button onClick={onClick} title={title} style={{ width: 30, height: 30, borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-1)", color: "var(--ink-1)", fontSize: 16, lineHeight: 1, display: "grid", placeItems: "center" }}>{children}</button>
);
}
Object.assign(window, { MapView });
@@ -0,0 +1,142 @@
// Орбитальная геометрия: трассы КА (подспутниковая точка), полосы обзора (swath),
// контуры маршрутов съёмки, участки сброса, терминатор день/ночь.
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const RE_KM = 6371;
const MU = 398600.4418; // км^3/с^2
const SIDEREAL = 86164; // период вращения Земли, с
// Детерминированный хэш строки → [0,1).
function hash01(str, salt) {
let h = 2166136261 ^ (salt || 0);
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
// Параметры орбиты КА (ССО). Высоту берём из sc.orbit, наклонение ~98°.
function orbitParams(sc) {
const m = /(\d+)\s*км/.exec(sc.orbit);
const h = m ? +m[1] : 550;
const a = RE_KM + h;
const T = 2 * Math.PI * Math.sqrt((a * a * a) / MU); // период, с
const incl = (97.4 + hash01(sc.id, 7) * 1.6) * DEG; // 97.499.0°
const node0 = hash01(sc.id, 3) * 360 * DEG; // долгота восх. узла @ эпоха
const u0 = hash01(sc.id, 11) * 2 * Math.PI; // фаза
// Ширина полосы обзора (км) по типу аппаратуры.
const swathKm = sc.type === "radar" ? 90 + hash01(sc.id, 5) * 110
: sc.type === "combo" ? 50 + hash01(sc.id, 5) * 70
: 24 + hash01(sc.id, 5) * 36;
return { h, a, T, incl, node0, u0, swathKm };
}
const EPOCH = (window.MissionData ? window.MissionData.RANGE_START : 0);
// Подспутниковая точка в момент t (мс).
function subPoint(p, tMs) {
const dt = (tMs - EPOCH) / 1000; // с
const u = p.u0 + 2 * Math.PI * (dt / p.T);
const lat = Math.asin(Math.sin(p.incl) * Math.sin(u)) * RAD;
let lonRel = Math.atan2(Math.cos(p.incl) * Math.sin(u), Math.cos(u));
let lon = (p.node0 + lonRel) * RAD - (360 / SIDEREAL) * dt;
lon = ((((lon + 180) % 360) + 360) % 360) - 180;
// курс (для перпендикуляра swath) — численно
return { lat, lon, u };
}
// Трасса за [t0,t1] с адаптивным шагом. Возвращает сегменты (разрыв на ±180°).
function groundTrack(p, t0, t1, maxPts) {
const span = t1 - t0;
const idealStep = p.T * 1000 / 36; // ~36 точек на виток
let step = idealStep;
const cap = maxPts || 900;
if (span / step > cap) step = span / cap;
const segs = [];
let cur = [];
let prevLon = null;
for (let t = t0; t <= t1; t += step) {
const s = subPoint(p, t);
if (prevLon !== null && Math.abs(s.lon - prevLon) > 180) {
if (cur.length) segs.push(cur);
cur = [];
}
cur.push({ t, lat: s.lat, lon: s.lon });
prevLon = s.lon;
}
if (cur.length) segs.push(cur);
return segs;
}
// Точки трассы строго внутри [t0,t1] (для footprint работы), без разрыва.
function trackPoints(p, t0, t1, n) {
const pts = [];
const k = Math.max(2, n || 24);
for (let i = 0; i <= k; i++) {
const t = t0 + (t1 - t0) * (i / k);
const s = subPoint(p, t);
pts.push({ t, lat: s.lat, lon: s.lon });
}
return pts;
}
// Лента (ribbon) шириной halfKm по обе стороны трассы → замкнутый полигон [lon,lat].
function ribbon(points, halfKm) {
const half = halfKm / 111; // градусы широты
const left = [], right = [];
for (let i = 0; i < points.length; i++) {
const a = points[Math.max(0, i - 1)];
const b = points[Math.min(points.length - 1, i + 1)];
const cosLat = Math.cos(points[i].lat * DEG) || 0.01;
let dx = (b.lon - a.lon) * cosLat;
let dy = (b.lat - a.lat);
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
// перпендикуляр
const px = -dy, py = dx;
const p = points[i];
left.push([p.lon + (px * half) / cosLat, p.lat + py * half]);
right.push([p.lon - (px * half) / cosLat, p.lat - py * half]);
}
return left.concat(right.reverse());
}
// Видимость станции из подспутниковой точки (грубо: по угловому расстоянию).
function nearestStation(lat, lon, stations) {
let best = null, bd = 1e9;
for (const st of stations) {
const d = Math.hypot((st.lon - lon) * Math.cos(lat * DEG), st.lat - lat);
if (d < bd) { bd = d; best = st; }
}
return { st: best, distDeg: bd };
}
// Подсолнечная точка (примитивно) для терминатора в момент tMs.
function subsolar(tMs) {
const d = new Date(tMs);
const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
const secs = (tMs - dayMs) / 1000;
const lon = 180 - (secs / 86400) * 360; // движется на запад
const N = Math.floor((tMs - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86400000);
const decl = 23.44 * Math.sin(DEG * (360 / 365) * (N - 81)); // склонение
return { lon: ((lon + 180) % 360 + 360) % 360 - 180, lat: decl };
}
// Кривая терминатора: для каждого lon — широта раздела день/ночь.
function terminatorCurve(tMs) {
const ss = subsolar(tMs);
const decl = ss.lat * DEG;
const out = [];
for (let lon = -180; lon <= 180; lon += 4) {
const H = (lon - ss.lon) * DEG;
// граница: tan(lat) = -cos(H)/tan(decl)
let lat;
if (Math.abs(decl) < 0.01) lat = 0;
else lat = Math.atan(-Math.cos(H) / Math.tan(decl)) * RAD;
out.push([lon, lat]);
}
return { curve: out, nightInNorth: ss.lat < 0, subsolar: ss };
}
Object.assign(window, {
orbitParams, subPoint, groundTrack, trackPoints, ribbon,
nearestStation, subsolar, terminatorCurve, DEG, RAD,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -0,0 +1,122 @@
// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами.
const { useState: useStateSB } = React;
function TypeDot({ type, size = 9 }) {
return (
<span style={{
width: size, height: size, borderRadius: 2, flex: "none",
background: TYPE_COLOR[type], display: "inline-block",
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
}} />
);
}
function Check({ on, onClick, dim }) {
return (
<button onClick={onClick} aria-label="toggle" style={{
width: 16, height: 16, flex: "none", borderRadius: 4,
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
background: on ? "var(--accent)" : "transparent",
opacity: dim ? 0.4 : 1,
display: "grid", placeItems: "center", padding: 0, transition: "all .12s",
}}>
{on && (
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
)}
</button>
);
}
function Sidebar(props) {
const { groups, scByGroup, typeFilter, setTypeFilter, groupShown, toggleGroup,
scHidden, toggleSc, search, setSearch, counts, selectedSc, onPickSc } = props;
const [open, setOpen] = useStateSB(() => {
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
});
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-1)", borderRight: "1px solid var(--line)" }}>
{/* Заголовок */}
<div style={{ padding: "14px 14px 10px", borderBottom: "1px solid var(--line-soft)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 22, height: 22, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
</div>
<div>
<div style={{ fontWeight: 700, letterSpacing: ".02em", fontSize: 13 }}>ПЛАН КА</div>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>MISSION OPS · ДЗЗ</div>
</div>
</div>
</div>
{/* Поиск */}
<div style={{ padding: "10px 12px 8px" }}>
<div style={{ position: "relative" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", left: 9, top: 9, opacity: .5 }}><circle cx="11" cy="11" r="7" stroke="var(--ink-2)" strokeWidth="2" /><path d="m20 20-3.5-3.5" stroke="var(--ink-2)" strokeWidth="2" strokeLinecap="round" /></svg>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск КА…"
style={{ width: "100%", padding: "7px 8px 7px 28px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
{search && <button onClick={() => setSearch("")} style={{ position: "absolute", right: 6, top: 6, background: "none", border: "none", color: "var(--ink-3)", fontSize: 14, padding: 2 }}>×</button>}
</div>
</div>
{/* Легенда / фильтр по типам */}
<div style={{ padding: "4px 12px 10px", borderBottom: "1px solid var(--line-soft)" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТУРА</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{D.typeOrder.map((t) => {
const on = typeFilter[t];
return (
<button key={t} onClick={() => setTypeFilter({ ...typeFilter, [t]: !on })}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 7px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.45, textAlign: "left" }}>
<TypeDot type={t} />
<span style={{ flex: 1, fontSize: 12 }}>{D.TYPES[t].label}</span>
<span className="mono tnum" style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{counts.byType[t] || 0}</span>
</button>
);
})}
</div>
</div>
{/* Дерево групп → КА */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 8px 16px" }}>
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", margin: "2px 6px 6px" }}>ГРУППЫ / КА</div>
{groups.map((g) => {
const list = scByGroup[g.id] || [];
const isOpen = open[g.id];
const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
return (
<div key={g.id} style={{ marginBottom: 2 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 6px", borderRadius: 6, background: "var(--bg-2)" }}>
<button onClick={() => setOpen({ ...open, [g.id]: !isOpen })} style={{ background: "none", border: "none", padding: 0, width: 14, height: 14, display: "grid", placeItems: "center", color: "var(--ink-2)" }}>
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: isOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<Check on={groupShown[g.id]} onClick={() => toggleGroup(g.id)} />
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{g.code}</span>
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: groupShown[g.id] ? "var(--ink)" : "var(--ink-3)" }}>{g.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{shownCount}/{list.length}</span>
</div>
{isOpen && (
<div style={{ marginLeft: 14, borderLeft: "1px solid var(--line-soft)", paddingLeft: 4, marginTop: 2 }}>
{list.map((sc) => {
const vis = !scHidden.has(sc.id);
const sel = selectedSc === sc.id;
return (
<div key={sc.id} onClick={() => onPickSc(sc.id)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}>
<Check on={vis} dim={!groupShown[g.id]} onClick={(e) => { e.stopPropagation(); toggleSc(sc.id); }} />
<TypeDot type={sc.type} size={7} />
<span className="mono" style={{ flex: 1, fontSize: 11, color: vis && groupShown[g.id] ? "var(--ink-1)" : "var(--ink-3)" }}>{sc.short}</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
Object.assign(window, { Sidebar, TypeDot });
@@ -0,0 +1,45 @@
// Верхние вкладки навигации «Планы / Карта», общие для обеих страниц.
function TopTabs({ active, planHref, mapHref }) {
const PH = planHref || "План КА — Mission Ops.html";
const MH = mapHref || "Map.html";
const tab = (id, label, href, icon) => {
const on = active === id;
return (
<a href={href} style={{
display: "flex", alignItems: "center", gap: 7, padding: "0 16px", height: "100%",
textDecoration: "none", fontSize: 13, fontWeight: 600,
color: on ? "var(--ink)" : "var(--ink-3)",
borderBottom: `2px solid ${on ? "var(--accent)" : "transparent"}`,
background: on ? "var(--bg-1)" : "transparent",
}}>
<span style={{ display: "grid", placeItems: "center", opacity: on ? 1 : 0.7 }}>{icon}</span>
{label}
</a>
);
};
return (
<div style={{ height: 46, flex: "none", display: "flex", alignItems: "stretch", background: "var(--bg-0)", borderBottom: "1px solid var(--line)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 16px", borderRight: "1px solid var(--line)" }}>
<div style={{ width: 20, height: 20, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
</div>
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".05em", color: "var(--ink-1)" }}>MISSION&nbsp;OPS</span>
</div>
{tab("plans", "Планы", PH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><rect x="3" y="6" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="6" y="12" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="3" y="18" width="9" height="3" rx="1.5" fill="currentColor" /></svg>)}
{tab("map", "Карта", MH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>)}
</div>
);
}
// Цветная метка типа аппаратуры (общая для обеих страниц).
function TypeDot({ type, size = 9 }) {
return (
<span style={{
width: size, height: size, borderRadius: 2, flex: "none",
background: TYPE_COLOR[type], display: "inline-block",
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
}} />
);
}
Object.assign(window, { TopTabs, TypeDot });
@@ -0,0 +1,273 @@
// Таймлайн (Gantt): ось времени, сетка, маркер «сейчас», секции групп,
// раскладка интервалов по лейнам, пометка пересечений, стрелки родословной.
const { useRef: useRefTL, useMemo: useMemoTL, useEffect: useEffectTL, useLayoutEffect } = React;
const LABEL_W = 234;
const LANE_H = 38;
const ROW_PAD = 8;
const GROUP_H = 30;
const AXIS_H = 46;
function Timeline(props) {
const { rows, pxPerMs, rangeStart, rangeEnd, now, selectedIv, onSelectIv,
lineageSet, byId, scrollSignal, timelineCollapsed, toggleCollapse } = props;
const bodyRef = useRefTL(null);
const axisRef = useRefTL(null);
const leftRef = useRefTL(null);
const x = (ms) => (ms - rangeStart) * pxPerMs;
const contentW = (rangeEnd - rangeStart) * pxPerMs;
// Раскладка строк по вертикали + позиции интервалов.
const layout = useMemoTL(() => {
let y = 0;
const placed = [];
const ivPos = {};
for (const r of rows) {
if (r.kind === "group") {
placed.push({ ...r, y, h: GROUP_H });
y += GROUP_H;
} else {
const { laneOf, laneCount } = assignLanes(r.ivs);
const h = laneCount * LANE_H + ROW_PAD * 2;
const overlaps = overlapSegments(r.ivs);
for (const iv of r.ivs) {
const lane = laneOf[iv.id];
ivPos[iv.id] = {
x: x(iv.start), x2: x(iv.end),
cy: y + ROW_PAD + lane * LANE_H + LANE_H / 2,
};
}
placed.push({ ...r, y, h, laneOf, laneCount, overlaps });
y += h;
}
}
return { placed, total: y, ivPos };
}, [rows, pxPerMs, rangeStart]);
// Сетка по дням + засечки оси.
const days = useMemoTL(() => {
const out = [];
const startD = new Date(rangeStart);
let t = Date.UTC(startD.getUTCFullYear(), startD.getUTCMonth(), startD.getUTCDate());
while (t <= rangeEnd) {
const d = new Date(t);
out.push({ t, weekend: d.getUTCDay() === 0 || d.getUTCDay() === 6, dow: d.getUTCDay() });
t += 86400000;
}
return out;
}, [rangeStart, rangeEnd]);
const sub = useMemoTL(() => niceTicks(rangeStart, rangeEnd, pxPerMs, 64), [rangeStart, rangeEnd, pxPerMs]);
const showHourTicks = sub.step < 86400000;
// Синхронизация скролла.
const onBodyScroll = () => {
const b = bodyRef.current;
if (axisRef.current) axisRef.current.scrollLeft = b.scrollLeft;
if (leftRef.current) leftRef.current.scrollTop = b.scrollTop;
};
// Прокрутка к «сейчас».
useEffectTL(() => {
const b = bodyRef.current;
if (!b) return;
const target = x(now) - b.clientWidth * 0.32;
b.scrollTo({ left: Math.max(0, target), behavior: scrollSignal === 0 ? "auto" : "smooth" });
onBodyScroll();
}, [scrollSignal]);
// Стрелки родословной для выделенной цепочки.
const arrows = useMemoTL(() => {
if (!selectedIv) return [];
const lin = lineageOf(selectedIv, byId);
const segs = [];
for (const id of lin.chain) {
(D.childrenOf[id] || []).forEach((c) => {
if (lin.chain.includes(c) && layout.ivPos[id] && layout.ivPos[c]) {
segs.push({ from: layout.ivPos[id], to: layout.ivPos[c] });
}
});
}
return segs;
}, [selectedIv, layout, lineageSet]);
const anySelected = !!selectedIv;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-0)", minWidth: 0 }}>
{/* ОСЬ ВРЕМЕНИ */}
<div style={{ display: "flex", height: AXIS_H, flex: "none", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
<div style={{ width: LABEL_W, flex: "none", borderRight: "1px solid var(--line)", display: "flex", alignItems: "flex-end", padding: "0 12px 7px" }}>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em" }}>КОСМИЧЕСКИЙ АППАРАТ</span>
</div>
<div ref={axisRef} style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<div style={{ width: contentW, height: "100%", position: "relative" }}>
{/* верхний ярус — дни */}
{days.map((d, i) => {
const w = 86400000 * pxPerMs;
return (
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: w, height: 24, borderLeft: "1px solid var(--line-soft)", display: "flex", alignItems: "center", gap: 5, padding: "0 7px", color: d.weekend ? "var(--ink-3)" : "var(--ink-1)" }}>
<span style={{ fontSize: 11, fontWeight: 600 }}>{pad(new Date(d.t).getUTCDate())}</span>
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{MONTHS[new Date(d.t).getUTCMonth()]}</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".05em" }}>{WDAYS[d.dow]}</span>
</div>
);
})}
{/* нижний ярус — засечки */}
{sub.ticks.map((tk, i) => (
<div key={i} style={{ position: "absolute", left: x(tk.t), top: 24, height: 22, borderLeft: "1px solid var(--grid)", paddingLeft: 4, display: "flex", alignItems: "center" }}>
{showHourTicks && <span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{fmtTime(tk.t)}</span>}
</div>
))}
{/* метка «сейчас» */}
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", transform: "translateX(-50%)", display: "flex", alignItems: "flex-start" }}>
<span className="mono" style={{ marginTop: 3, fontSize: 9, fontWeight: 600, color: "var(--bg-0)", background: "var(--now)", padding: "1px 5px", borderRadius: 4, whiteSpace: "nowrap", boxShadow: "0 0 8px var(--now)" }}>СЕЙЧАС {fmtTime(now)}</span>
</div>
</div>
</div>
</div>
{/* ТЕЛО */}
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* Левая колонка подписей */}
<div ref={leftRef} style={{ width: LABEL_W, flex: "none", overflow: "hidden", borderRight: "1px solid var(--line)", background: "var(--bg-1)" }}>
<div style={{ height: layout.total, position: "relative" }}>
{layout.placed.map((r) => r.kind === "group" ? (
<div key={"g" + r.group.id} onClick={() => toggleCollapse(r.group.id)}
style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 7, padding: "0 12px", background: "var(--bg-2)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", cursor: "pointer" }}>
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: timelineCollapsed.has(r.group.id) ? "none" : "rotate(90deg)", transition: "transform .12s", color: "var(--ink-2)", flex: "none" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.group.code}</span>
<span style={{ flex: 1, fontSize: 12, fontWeight: 700 }}>{r.group.name}</span>
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{r.count}</span>
</div>
) : (
<div key={r.sc.id} style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderBottom: "1px solid var(--line-soft)" }}>
<TypeDot type={r.sc.type} size={8} />
<div style={{ minWidth: 0, flex: 1 }}>
<div className="mono" style={{ fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.short}</div>
<div style={{ fontSize: 9.5, color: "var(--ink-3)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.orbit}</div>
</div>
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.ivs.length}</span>
</div>
))}
</div>
</div>
{/* Правый холст */}
<div ref={bodyRef} onScroll={onBodyScroll} style={{ flex: 1, overflow: "auto", position: "relative" }}>
<div style={{ width: contentW, height: layout.total, position: "relative" }}>
{/* фон выходных + сетка дней */}
{days.map((d, i) => (
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: 86400000 * pxPerMs, height: "100%", borderLeft: "1px solid var(--grid)", background: d.weekend ? "oklch(0.22 0.012 165 / 0.5)" : "transparent" }} />
))}
{/* линия «сейчас» */}
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", width: 0, borderLeft: "1.5px dashed var(--now)", zIndex: 6 }} />
{/* строки */}
{layout.placed.map((r) => r.kind === "group" ? (
<div key={"g" + r.group.id} style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, background: "var(--bg-2)", borderTop: "1px solid var(--line)", borderBottom: "1px solid var(--line)", opacity: .95 }} />
) : (
<Row key={r.sc.id} r={r} x={x} contentW={contentW} selectedIv={selectedIv}
onSelectIv={onSelectIv} lineageSet={lineageSet} anySelected={anySelected} />
))}
{/* стрелки родословной */}
{arrows.length > 0 && (
<svg style={{ position: "absolute", left: 0, top: 0, width: contentW, height: layout.total, pointerEvents: "none", zIndex: 7 }}>
<defs>
<marker id="ah" markerWidth="7" markerHeight="7" refX="5.5" refY="3" orient="auto">
<path d="M0 0 L6 3 L0 6 z" fill="var(--accent)" />
</marker>
</defs>
{arrows.map((a, i) => {
const x1 = a.from.x2, y1 = a.from.cy, x2 = a.to.x, y2 = a.to.cy;
const mid = x1 + Math.max(14, (x2 - x1) / 2);
const d = `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 3} ${y2}`;
return <path key={i} d={d} fill="none" stroke="var(--accent)" strokeWidth="1.6" markerEnd="url(#ah)" opacity="0.9" />;
})}
</svg>
)}
</div>
</div>
</div>
</div>
);
}
// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ.
function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) {
return (
<div style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, borderBottom: "1px solid var(--line-soft)" }}>
{/* пересечения планов — штриховка */}
{r.overlaps.map((ov, i) => (
<div key={"ov" + i} className="hatch" style={{ position: "absolute", left: x(ov.start), width: Math.max(2, x(ov.end) - x(ov.start)), top: 2, height: r.h - 4, borderLeft: "1px solid var(--now)", borderRight: "1px solid var(--now)", borderRadius: 2, zIndex: 1, pointerEvents: "none" }} />
))}
{r.ivs.map((plan) => {
const lane = r.laneOf[plan.id];
const left = x(plan.start);
const w = Math.max(4, x(plan.end) - x(plan.start));
const inChain = lineageSet && lineageSet.has(plan.id);
const isSel = selectedIv === plan.id;
const dim = anySelected && !inChain;
const scType = r.sc.type;
const col = TYPE_COLOR[scType];
const hasParent = !!plan.parentId;
const hasChild = (D.childrenOf[plan.id] || []).length > 0;
const bandTop = ROW_PAD + lane * LANE_H;
const bandH = LANE_H - 8;
const wide = w > 78;
const showWorks = w > 14;
const executed = plan.status === "executed";
return (
<div key={plan.id} onClick={(e) => { e.stopPropagation(); onSelectIv(plan.id); }}
title={`План ${plan.id} · ${plan.title} · ${fmtDateTime(plan.start)}${fmtDateTime(plan.end)} · работ: ${plan.works.length}`}
style={{
position: "absolute", left, width: w, top: bandTop, height: bandH,
background: `color-mix(in oklch, ${col} 13%, var(--bg-1))`,
border: `1px solid color-mix(in oklch, ${col} 45%, var(--bg-1))`,
borderLeft: `3px solid ${col}`,
borderRadius: 5, cursor: "pointer", zIndex: isSel ? 5 : 3,
opacity: dim ? 0.26 : executed ? 0.7 : 1,
outline: isSel ? `1.5px solid var(--accent)` : inChain ? `1px solid color-mix(in oklch, var(--accent) 55%, transparent)` : "none",
boxShadow: isSel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18), var(--shadow)" : "none",
overflow: "hidden", transition: "opacity .12s",
}}>
{/* заголовок плана */}
<div style={{ display: "flex", alignItems: "center", gap: 4, height: 14, padding: "0 4px 0 5px" }}>
{hasParent && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть предок"></span>}
{wide && <span className="mono" style={{ flex: 1, fontSize: 9, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", letterSpacing: ".01em" }}>{plan.title}</span>}
{wide && plan.status === "active" && <span style={{ width: 4, height: 4, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 5px var(--now)", flex: "none" }} />}
{wide && plan.rev > 1 && <span className="mono" style={{ flex: "none", fontSize: 7.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 2px" }}>r{plan.rev}</span>}
{!wide && <span style={{ flex: 1 }} />}
{hasChild && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть потомок"></span>}
</div>
{/* трек работ внутри плана */}
{showWorks && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 2, height: 11 }}>
{plan.works.map((wk) => {
const wl = x(wk.start) - left;
const ww = Math.max(1.5, x(wk.end) - x(wk.start));
const fill = workFill(wk.kind, scType);
const isService = wk.kind === "service";
return (
<div key={wk.id} title={`${wk.label} · ${fmtTime(wk.start)}${fmtTime(wk.end)}`}
style={{
position: "absolute", left: wl, width: ww, top: 0, height: 11,
background: isService ? "transparent" : fill,
border: isService ? `1px dashed ${fill}` : "none",
borderRadius: 2, boxSizing: "border-box",
}} />
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
Object.assign(window, { Timeline });
+123
View File
@@ -0,0 +1,123 @@
// Общие утилиты, формат времени, раскладка интервалов по лейнам.
const D = window.MissionData;
const TYPE_COLOR = {
optical: "var(--t-optical)",
radar: "var(--t-radar)",
combo: "var(--t-combo)",
};
const TYPE_DIM = {
optical: "var(--t-optical-dim)",
radar: "var(--t-radar-dim)",
combo: "var(--t-combo-dim)",
};
// Стиль работы внутри плана по её категории. shoot тонируется типом аппаратуры,
// остальные — служебной палитрой, отличной от трёх цветов аппаратуры.
const WORK_KIND_COLOR = {
shoot: null, // = цвет типа КА
downlink: "var(--accent)",
calib: "oklch(0.78 0.02 200)",
service: "var(--ink-3)",
};
function workFill(kind, scType) {
if (kind === "shoot") return TYPE_COLOR[scType];
return WORK_KIND_COLOR[kind];
}
const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
const WDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
function pad(n) { return String(n).padStart(2, "0"); }
function fmtTime(ms) {
const d = new Date(ms);
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function fmtDate(ms) {
const d = new Date(ms);
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]}`;
}
function fmtDateTime(ms) {
const d = new Date(ms);
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${fmtTime(ms)}`;
}
function fmtDur(ms) {
const h = ms / 3600000;
if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} ч`;
const d = Math.floor(h / 24);
const rh = Math.round(h - d * 24);
return rh ? `${d} сут ${rh} ч` : `${d} сут`;
}
// Раскладка интервалов одного КА по лейнам (greedy interval graph).
function assignLanes(ivs) {
const sorted = [...ivs].sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds = []; // конец последнего интервала в каждом лейне
const out = {};
for (const iv of sorted) {
let placed = false;
for (let l = 0; l < laneEnds.length; l++) {
if (iv.start >= laneEnds[l]) {
out[iv.id] = l;
laneEnds[l] = iv.end;
placed = true;
break;
}
}
if (!placed) {
out[iv.id] = laneEnds.length;
laneEnds.push(iv.end);
}
}
return { laneOf: out, laneCount: Math.max(1, laneEnds.length) };
}
// Пересечения по времени между интервалами одного КА.
function overlapSegments(ivs) {
const segs = [];
for (let i = 0; i < ivs.length; i++) {
for (let j = i + 1; j < ivs.length; j++) {
const a = ivs[i], b = ivs[j];
const s = Math.max(a.start, b.start);
const e = Math.min(a.end, b.end);
if (e > s) segs.push({ start: s, end: e, a: a.id, b: b.id });
}
}
return segs;
}
// Цепочка родословной (предки + потомки) для интервала.
function lineageOf(id, byId) {
const anc = [];
let cur = byId[id];
while (cur && cur.parentId) {
anc.unshift(cur.parentId);
cur = byId[cur.parentId];
}
const desc = [];
const stack = [...(D.childrenOf[id] || [])];
while (stack.length) {
const c = stack.shift();
desc.push(c);
(D.childrenOf[c] || []).forEach((x) => stack.push(x));
}
return { ancestors: anc, descendants: desc, chain: [...anc, id, ...desc] };
}
// "Хорошие" шаги сетки времени для оси.
function niceTicks(rangeStart, rangeEnd, pxPerMs, minPx) {
const HOUR = 3600000, DAY = 86400000;
const steps = [1 * HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, 1 * DAY, 2 * DAY, 7 * DAY];
let step = steps[steps.length - 1];
for (const s of steps) { if (s * pxPerMs >= minPx) { step = s; break; } }
const ticks = [];
const startTick = Math.ceil(rangeStart / step) * step;
for (let t = startTick; t <= rangeEnd; t += step) ticks.push({ t, step });
return { ticks, step };
}
Object.assign(window, {
TYPE_COLOR, TYPE_DIM, MONTHS, WDAYS, WORK_KIND_COLOR, workFill,
pad, fmtTime, fmtDate, fmtDateTime, fmtDur,
assignLanes, overlapSegments, lineageOf, niceTicks,
});
@@ -0,0 +1,41 @@
// Схематичные контуры континентов (равнопрямоугольная проекция, [lon, lat])
// и наземные станции приёма (НКПОР). Низкополигональный OPS-стиль, оффлайн.
(function () {
const LAND = {
"Сев. Америка": [[-166,66],[-160,70],[-150,70],[-140,70],[-125,70],[-110,71],[-95,72],[-83,73],[-70,67],[-64,60],[-56,53],[-52,47],[-60,46],[-67,44],[-70,41],[-74,39],[-76,35],[-81,31],[-80,25],[-84,30],[-90,29],[-94,29],[-97,26],[-97,22],[-105,22],[-110,29],[-114,31],[-120,34],[-124,40],[-124,48],[-130,53],[-136,58],[-145,60],[-152,59],[-160,58],[-164,60]],
"Гренландия": [[-46,60],[-32,66],[-22,70],[-20,74],[-28,79],[-40,83],[-55,82],[-60,76],[-53,70],[-50,64]],
"Юж. Америка": [[-78,9],[-70,11],[-60,10],[-51,4],[-50,-1],[-43,-3],[-35,-6],[-37,-13],[-48,-25],[-55,-34],[-62,-40],[-66,-45],[-70,-52],[-73,-54],[-74,-49],[-72,-42],[-71,-33],[-70,-24],[-71,-18],[-76,-14],[-80,-6],[-81,-3],[-79,3]],
"Африка": [[-16,15],[-13,21],[-6,27],[2,34],[10,37],[19,33],[26,32],[32,31],[35,24],[39,16],[43,12],[51,12],[44,3],[41,-3],[40,-11],[35,-18],[30,-26],[22,-34],[18,-35],[14,-24],[11,-13],[9,-1],[6,4],[-2,5],[-9,5],[-14,9]],
"Европа": [[-10,37],[-9,43],[-2,44],[-2,48],[2,51],[0,55],[5,58],[6,62],[11,64],[15,68],[21,70],[26,71],[30,67],[39,68],[42,63],[38,58],[30,59],[28,56],[24,56],[20,54],[14,54],[9,54],[4,51],[-1,49],[-4,48],[-1,46],[-2,43],[-9,40]],
"Азия": [[42,63],[55,68],[68,72],[80,74],[95,77],[110,76],[125,73],[140,72],[160,70],[172,67],[180,66],[178,62],[165,60],[160,54],[155,52],[143,53],[140,48],[135,44],[130,43],[127,38],[122,40],[121,31],[120,23],[110,21],[108,15],[105,9],[100,7],[98,11],[93,18],[88,22],[82,17],[77,8],[73,17],[68,24],[62,25],[57,25],[50,30],[47,38],[50,45],[48,50],[58,55],[50,58],[45,60]],
"Австралия": [[114,-22],[114,-32],[118,-35],[129,-32],[138,-35],[147,-38],[150,-37],[153,-31],[153,-25],[146,-18],[142,-11],[136,-12],[130,-12],[125,-14],[122,-18]],
"Н. Гвинея": [[131,-1],[141,-3],[147,-7],[143,-9],[134,-9],[131,-5]],
"Суматра": [[95,5],[100,1],[104,-5],[100,-3],[96,2]],
"Калимантан": [[109,2],[115,2],[118,-2],[114,-4],[109,-2]],
"Ява": [[105,-6],[114,-7],[110,-8]],
"Япония": [[130,32],[135,34],[140,36],[142,40],[141,45],[138,42],[135,36]],
"Британия": [[-5,50],[-2,51],[0,52],[-1,54],[-3,55],[-5,58],[-7,57],[-6,54],[-10,52]],
"Ирландия": [[-10,52],[-6,52],[-6,55],[-10,54]],
"Мадагаскар": [[44,-12],[50,-15],[49,-22],[45,-25],[44,-19],[43,-15]],
"Н. Зеландия С": [[173,-35],[178,-38],[174,-41],[172,-40],[173,-37]],
"Н. Зеландия Ю": [[170,-41],[172,-43],[170,-46],[167,-46],[168,-44]],
"Исландия": [[-24,65],[-18,66],[-14,65],[-18,64],[-23,64]],
"Антарктида": [[-180,-72],[-150,-75],[-120,-73],[-90,-72],[-60,-78],[-30,-72],[0,-70],[30,-69],[60,-67],[90,-66],[120,-66],[150,-70],[180,-72],[180,-85],[-180,-85]],
};
// Наземные станции приёма (НКПОР) — пункты приёма ДЗЗ.
const GROUND_STATIONS = [
{ id: "msk", name: "Москва", lon: 37.6, lat: 55.75 },
{ id: "dubna", name: "Дубна", lon: 37.17, lat: 56.73 },
{ id: "zhel", name: "Железногорск", lon: 93.5, lat: 56.25 },
{ id: "khab", name: "Хабаровск", lon: 135.07, lat: 48.48 },
{ id: "murm", name: "Мурманск", lon: 33.08, lat: 68.97 },
{ id: "anad", name: "Анадырь", lon: 177.5, lat: 64.73 },
{ id: "novo", name: "Новосибирск", lon: 82.9, lat: 55.03 },
{ id: "svalb", name: "Шпицберген", lon: 15.6, lat: 78.2 },
{ id: "magad", name: "Магадан", lon: 150.8, lat: 59.56 },
{ id: "progress", name: "Прогресс (Антарктида)", lon: 76.4, lat: -69.4 },
];
window.WorldGeo = { LAND, GROUND_STATIONS };
})();
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>План КА — Mission Ops</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
/* Фон — очень тёмный десатурированный тёмно-зелёный (OPS) */
--bg-0: oklch(0.18 0.012 165);
--bg-1: oklch(0.215 0.013 165);
--bg-2: oklch(0.255 0.014 165);
--bg-3: oklch(0.30 0.015 165);
--line: oklch(0.34 0.014 165);
--line-soft: oklch(0.28 0.013 165);
--grid: oklch(0.26 0.012 165);
--ink: oklch(0.95 0.01 150);
--ink-1: oklch(0.80 0.012 160);
--ink-2: oklch(0.64 0.012 165);
--ink-3: oklch(0.50 0.012 165);
/* Типы аппаратуры — одинаковая C/L, разный hue */
--t-optical: oklch(0.80 0.14 158);
--t-radar: oklch(0.82 0.13 82);
--t-combo: oklch(0.74 0.15 6);
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
--accent: oklch(0.82 0.13 200);
--warn: oklch(0.80 0.15 55);
--now: oklch(0.86 0.16 25);
--radius: 7px;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "IBM Plex Sans", system-ui, sans-serif;
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--ink);
font-size: 13px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
#root { height: 100vh; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: var(--bg-0); }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
::-webkit-scrollbar-corner { background: var(--bg-0); }
.mono { font-family: var(--mono); }
.tnum { font-variant-numeric: tabular-nums; }
button { font-family: inherit; color: inherit; cursor: pointer; }
/* диагональная штриховка для пометки пересечений */
.hatch {
background-image: repeating-linear-gradient(
-45deg,
oklch(0.86 0.16 25 / 0.55) 0px,
oklch(0.86 0.16 25 / 0.55) 2px,
transparent 2px,
transparent 6px
);
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.jsx"></script>
<script type="text/babel" src="util.jsx"></script>
<script type="text/babel" src="tabs.jsx"></script>
<script type="text/babel" src="sidebar.jsx"></script>
<script type="text/babel" src="timeline.jsx"></script>
<script type="text/babel" src="details.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>