pcp-tgu-ops-ui: вкладка Редактор — шаг времени маршрута и формат длительности

- RouteTimeStepper и доработки EditorPanels/TguEditorTab по вводу времени;
- formatDuration: секунды/минуты для коротких интервалов;
- стили tgu-editor.css.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 22:06:06 +03:00
parent c2e2b2922f
commit 240e12f442
4 changed files with 248 additions and 43 deletions
@@ -2,7 +2,8 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kind
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import type { RvaZone } from "../../api/ballisticsApi"; import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; import type { ChangeEvent } from "react";
import type { SurveyRoute } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import type { import type {
@@ -45,11 +46,27 @@ type ShootPanelProps = {
selectedPass?: EditorVisibilityWindow; selectedPass?: EditorVisibilityWindow;
modeId?: string; modeId?: string;
modes: EditorMode[]; modes: EditorMode[];
routeAnchor: SurveyRouteAnchor; /** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */
routeWindow?: { startMs: number; endMs: number };
/** Текущий крен (gamma) маршрута, градусы. */
routeRoll?: number;
/** Окно видимости выбранного прохода — для подписи длительности. */
passStartMs?: number;
passEndMs?: number;
/** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */
routeMinMs?: number;
routeMaxMs?: number;
routeLoading?: boolean; routeLoading?: boolean;
routeError?: string; routeError?: string;
route?: SurveyRoute; route?: SurveyRoute;
onAnchorChange: (anchor: SurveyRouteAnchor) => void; onRouteStartChange: (startMs: number) => void;
onRouteEndChange: (endMs: number) => void;
/** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */
onRouteShift: (deltaMs: number) => void;
/** Изменение крена влево/вправо (градусы, дельта от кнопок). */
onRouteRollChange: (deltaDeg: number) => void;
/** Установка крена напрямую (ручной ввод), градусы. */
onRouteRollSet: (deg: number) => void;
onSelectPass: (pass?: EditorVisibilityWindow) => void; onSelectPass: (pass?: EditorVisibilityWindow) => void;
onModeChange: (modeId: string) => void; onModeChange: (modeId: string) => void;
onAdd: () => void; onAdd: () => void;
@@ -57,6 +74,9 @@ type ShootPanelProps = {
onClearTarget: () => void; onClearTarget: () => void;
}; };
/** Минимальная длительность маршрута, мс (нижняя граница окна). */
const MIN_ROUTE_DURATION_MS = 5_000;
type DownlinkPanelProps = { type DownlinkPanelProps = {
stationOptions: DownlinkStationOption[]; stationOptions: DownlinkStationOption[];
selected?: DownlinkSelection; selected?: DownlinkSelection;
@@ -204,11 +224,20 @@ export function ShootPanel({
selectedPass, selectedPass,
modeId, modeId,
modes, modes,
routeAnchor, routeWindow,
routeRoll,
passStartMs,
passEndMs,
routeMinMs,
routeMaxMs,
routeLoading, routeLoading,
routeError, routeError,
route, route,
onAnchorChange, onRouteStartChange,
onRouteEndChange,
onRouteShift,
onRouteRollChange,
onRouteRollSet,
onSelectPass, onSelectPass,
onModeChange, onModeChange,
onAdd, onAdd,
@@ -287,25 +316,57 @@ export function ShootPanel({
</section> </section>
)} )}
{target && selectedPass && ( {target && selectedPass && routeWindow && (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">4 · маршрут съёмки</div> <div className="tgu-editor-eyebrow">4 · окно маршрута</div>
<div className="tgu-editor-chip-row"> <RouteTimeStepper
<button label="начало"
className={routeAnchor === "CENTERED" ? "is-selected" : ""} value={routeWindow.startMs}
type="button" min={routeMinMs}
onClick={() => onAnchorChange("CENTERED")} max={routeWindow.endMs - MIN_ROUTE_DURATION_MS}
> onChange={onRouteStartChange}
вокруг момента />
</button> <RouteTimeStepper
<button label="конец"
className={routeAnchor === "FORWARD" ? "is-selected" : ""} value={routeWindow.endMs}
type="button" min={routeWindow.startMs + MIN_ROUTE_DURATION_MS}
onClick={() => onAnchorChange("FORWARD")} max={routeMaxMs}
> onChange={onRouteEndChange}
от момента вперёд />
</button> <div className="tgu-editor-panel__hint">
длительность: {formatDuration(routeWindow.endMs - routeWindow.startMs)}
{passStartMs != null && passEndMs != null && ` · окно видимости: ${formatDuration(passEndMs - passStartMs)}`}
</div> </div>
<div className="tgu-editor-time-stepper">
<span>сдвиг назад · вперёд </span>
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => onRouteShift(-30_000)}>30с</button>
<button type="button" onClick={() => onRouteShift(-5_000)}>5с</button>
<button type="button" onClick={() => onRouteShift(5_000)}>+5с</button>
<button type="button" onClick={() => onRouteShift(30_000)}>+30с</button>
</div>
</div>
<div className="tgu-editor-time-stepper">
<span>крен влево · вправо </span>
<div>
<button type="button" onClick={() => onRouteRollChange(-5)}>5°</button>
<button type="button" onClick={() => onRouteRollChange(-1)}>1°</button>
<input
type="number"
step={0.5}
value={routeRoll ?? 0}
onChange={(event) => {
const deg = Number.parseFloat(event.target.value);
if (Number.isFinite(deg)) onRouteRollSet(deg);
}}
/>
<button type="button" onClick={() => onRouteRollChange(1)}>+1°</button>
<button type="button" onClick={() => onRouteRollChange(5)}>+5°</button>
</div>
</div>
{routeLoading ? ( {routeLoading ? (
<div className="tgu-editor-panel__hint">Построение маршрута</div> <div className="tgu-editor-panel__hint">Построение маршрута</div>
) : routeError ? ( ) : routeError ? (
@@ -628,3 +689,47 @@ function TimeStepper({
</label> </label>
); );
} }
/**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (UTC, без даты — дата берётся из текущего значения).
* Значение зажимается в [min, max].
*/
function RouteTimeStepper({
label,
value,
min,
max,
onChange
}: {
label: string;
value: number;
min?: number;
max?: number;
onChange: (value: number) => void;
}) {
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
const onInput = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
if (!raw) return;
// Ввод — только время суток; день оставляем прежним (UTC).
const datePart = new Date(value).toISOString().slice(0, 10);
const ms = parseUtc(`${datePart}T${raw}`);
if (Number.isFinite(ms)) onChange(clamp(ms));
};
return (
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
<span>{label}</span>
{/* lang=ru-RU — чтобы поле показывало время в 24-часовом формате, а не AM/PM. */}
<input lang="ru-RU" type="time" step={1} value={new Date(value).toISOString().slice(11, 19)} onChange={onInput} />
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => change(-30_000)}>30с</button>
<button type="button" onClick={() => change(-5_000)}>5с</button>
<button type="button" onClick={() => change(5_000)}>+5с</button>
<button type="button" onClick={() => change(30_000)}>+30с</button>
</div>
</label>
);
}
@@ -5,7 +5,7 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi";
import { parseUtc } from "../../utils/utcTime"; import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -58,6 +58,12 @@ const SHOOT_WINDOW_MS = 6 * 60 * 1000;
* это значение будет определять бэкенд по профилю наблюдения КА. * это значение будет определять бэкенд по профилю наблюдения КА.
*/ */
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
/** Дефолтная длительность строящегося маршрута съёмки (мс). */
const DEFAULT_ROUTE_DURATION_MS = 30_000;
/** Предел крена (gamma) маршрута при ручной правке, градусы. */
const MAX_ROUTE_ROLL_DEG = 89;
/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */
const ROUTE_TIME_MARGIN_MS = 30 * 60_000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true, tracks: true,
swath: true, swath: true,
@@ -144,7 +150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]); const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>(); const [shootPassesError, setShootPassesError] = useState<string>();
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED"); // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC).
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
const [routeRoll, setRouteRoll] = useState<number>();
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>(); const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
const [routeLoading, setRouteLoading] = useState(false); const [routeLoading, setRouteLoading] = useState(false);
const [routeError, setRouteError] = useState<string>(); const [routeError, setRouteError] = useState<string>();
@@ -292,29 +301,44 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}; };
}, [rightMode, target, noradId, window.fromMs, window.toMs]); }, [rightMode, target, noradId, window.fromMs, window.toMs]);
// При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента
// видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную.
useEffect(() => {
if (rightMode !== "shoot" || !selectedShootPass) {
setRouteWindow(undefined);
setRouteRoll(undefined);
return;
}
const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs;
const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2;
const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
setRouteWindow({ startMs: mid - half, endMs: mid + half });
setRouteRoll(selectedShootPass.rollDeg ?? 0);
}, [rightMode, selectedShootPass]);
useEffect(() => { useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN; const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) { if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) {
setSurveyRoute(undefined); setSurveyRoute(undefined);
setRouteError(undefined); setRouteError(undefined);
setRouteLoading(false); setRouteLoading(false);
return; return;
} }
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
let cancelled = false; let cancelled = false;
setRouteLoading(true); setRouteLoading(true);
setRouteError(undefined); setRouteError(undefined);
void buildSurveyRoute({ void buildSurveyRoute({
satelliteId: numericNorad, satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19), // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
durationSeconds, durationSeconds,
roll: selectedShootPass.rollDeg ?? 0, roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0, revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor anchor: "FORWARD"
}) })
.then((route) => { .then((route) => {
if (!cancelled) setSurveyRoute(route); if (!cancelled) setSurveyRoute(route);
@@ -330,7 +354,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [rightMode, target, selectedShootPass, noradId, routeAnchor]); }, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]);
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
useEffect(() => { useEffect(() => {
@@ -396,21 +420,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
const numericNorad = noradId ? Number(noradId) : NaN; const numericNorad = noradId ? Number(noradId) : NaN;
if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) { if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
const planId = selectedPlan.planId; const planId = selectedPlan.planId;
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
setRouteLoading(true); setRouteLoading(true);
setRouteError(undefined); setRouteError(undefined);
try { try {
const saved = await saveSurveyRoute(planId, { const saved = await saveSurveyRoute(planId, {
satelliteId: numericNorad, satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19), time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
durationSeconds: routeDurationSeconds, durationSeconds: routeDurationSeconds,
roll: selectedShootPass.rollDeg ?? 0, roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0, revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor, anchor: "FORWARD",
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
@@ -424,8 +447,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
} }
} }
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); // Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода).
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); const startMs = routeWindow?.startMs ?? selectedShootPass.startMs;
const endMs = routeWindow?.endMs ?? selectedShootPass.endMs;
const mode = modes.find((item) => item.id === modeId); const mode = modes.find((item) => item.id === modeId);
const id = nextWorkId(idCounterRef); const id = nextWorkId(idCounterRef);
@@ -433,7 +457,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
addShoot(current, { addShoot(current, {
id, id,
startMs, startMs,
endMs: startMs + durationMs, endMs,
label: mode?.label ?? "Съёмка", label: mode?.label ?? "Съёмка",
modeId, modeId,
target target
@@ -752,11 +776,39 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedPass={selectedShootPass} selectedPass={selectedShootPass}
modeId={modeId} modeId={modeId}
modes={modes} modes={modes}
routeAnchor={routeAnchor} routeWindow={routeWindow}
routeRoll={routeRoll}
passStartMs={selectedShootPass?.startMs}
passEndMs={selectedShootPass?.endMs}
routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined}
routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined}
routeLoading={routeLoading} routeLoading={routeLoading}
routeError={routeError} routeError={routeError}
route={surveyRoute} route={surveyRoute}
onAnchorChange={setRouteAnchor} onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
onRouteShift={(deltaMs) =>
setRouteWindow((current) => {
if (!current || selectedShootPass == null) return current;
// Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости.
const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS;
const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS;
let shift = deltaMs;
if (current.endMs + shift > maxMs) shift = maxMs - current.endMs;
if (current.startMs + shift < minMs) shift = minMs - current.startMs;
return { startMs: current.startMs + shift, endMs: current.endMs + shift };
})
}
onRouteRollChange={(deltaDeg) =>
setRouteRoll((current) =>
Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg))
)
}
onRouteRollSet={(deg) =>
setRouteRoll((current) =>
Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current
)
}
onSelectPass={setSelectedShootPass} onSelectPass={setSelectedShootPass}
onModeChange={setModeId} onModeChange={setModeId}
onAdd={addShootWork} onAdd={addShootWork}
@@ -37,6 +37,16 @@ export function formatDateTime(ms: number): string {
} }
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
if (totalSeconds < 60) {
return `${totalSeconds} с`;
}
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
}
const hours = ms / 3_600_000; const hours = ms / 3_600_000;
if (hours < 24) { if (hours < 24) {
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`; return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`;
@@ -910,6 +910,18 @@
text-align: left; text-align: left;
} }
/* Узкая правая панель: строка прохода не помещается в один ряд раскладываем
подпись и параметры видимости в столбец, чтобы текст не обрезался. */
.tgu-editor-pass-list button {
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
}
.tgu-editor-pass-list small {
white-space: normal;
}
.tgu-editor-pass-list span, .tgu-editor-pass-list span,
.tgu-editor-station span { .tgu-editor-station span {
color: var(--text); color: var(--text);
@@ -975,7 +987,8 @@
font-size: 0.62rem; font-size: 0.62rem;
} }
.tgu-editor-time-stepper output { .tgu-editor-time-stepper output,
.tgu-editor-time-stepper input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -988,6 +1001,31 @@
text-align: center; text-align: center;
} }
.tgu-editor-time-stepper input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.5;
cursor: pointer;
}
/* Секундный степпер окна маршрута: поле ввода на своей строке, кнопки ± ниже,
чтобы помещаться в узкую правую панель. */
.tgu-editor-time-stepper--seconds {
gap: 0.3rem;
}
.tgu-editor-time-stepper--seconds > input {
width: 100%;
}
.tgu-editor-time-stepper__steps {
display: flex;
gap: 0.25rem;
}
.tgu-editor-time-stepper__steps button {
flex: 1;
}
.tgu-editor-panel__color { .tgu-editor-panel__color {
position: absolute; position: absolute;
top: 0.2rem; top: 0.2rem;