Initial implementation of custom profile editor + formatting

This commit is contained in:
ThePetrovich 2025-07-09 20:14:47 +08:00
parent 82b36f96d0
commit ffb27c2e0a
21 changed files with 3045 additions and 2034 deletions

View file

@ -2,5 +2,7 @@
"tabWidth": 4,
"endOfLine": "lf",
"printWidth": 120,
"useTabs": false
"useTabs": true,
"htmlWhitespaceSensitivity": "ignore",
"bracketSameLine": true
}

96
package-lock.json generated
View file

@ -11,17 +11,23 @@
"@sveltestrap/sveltestrap": "^7.1.0",
"@types/leaflet": "^1.9.19",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0",
"leaflet-timedimension": "^1.1.1",
"leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
"leaflet.heat": "^0.2.0",
"luxon": "^3.6.1",
"svelte5-chartjs": "^1.0.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2",
"@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8",
"svelte-check": "^4.0.0",
@ -484,6 +490,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@polka/url": {
"version": "1.0.0-next.28",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
@ -884,6 +895,12 @@
"@types/geojson": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true
},
"node_modules/@vincjo/datatables": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.5.0.tgz",
@ -935,6 +952,38 @@
}
]
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-luxon": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz",
"integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==",
"peerDependencies": {
"chart.js": ">=3.0.0",
"luxon": ">=1.0.0"
}
},
"node_modules/chartjs-plugin-dragdata": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/chartjs-plugin-dragdata/-/chartjs-plugin-dragdata-2.3.1.tgz",
"integrity": "sha512-CFD1e2d+gyH9EXb92qWu4Zb2zVoY4OtrbJYLMoGWOInE7ftoOD//4B0/k9IvKzQbdVU3JsPqUQI9KHcMpqQVfg==",
"dependencies": {
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0"
},
"peerDependencies": {
"chart.js": "^3.9.1 || ^4.0.1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -967,6 +1016,34 @@
"node": ">= 0.6"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -1163,6 +1240,14 @@
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -1399,6 +1484,15 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte5-chartjs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte5-chartjs/-/svelte5-chartjs-1.0.0.tgz",
"integrity": "sha512-SMk+D5ECbsoeFurKE/Nr9sqD4H3WqZkQ4eLxwchDSh8gu7YSGN3ASXYCz9kzFhrH2QGQYpebHwLIMHg7FOI/7A==",
"peerDependencies": {
"chart.js": "^3.5.0 || ^4.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",

View file

@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2",
"@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8",
"svelte-check": "^4.0.0",
@ -25,11 +26,16 @@
"@sveltestrap/sveltestrap": "^7.1.0",
"@types/leaflet": "^1.9.19",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0",
"leaflet-timedimension": "^1.1.1",
"leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
"leaflet.heat": "^0.2.0",
"luxon": "^3.6.1",
"svelte5-chartjs": "^1.0.0"
}
}

View file

@ -3,14 +3,14 @@
let {
isOpen = $bindable(false),
title = 'Confirm Action',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmVariant = 'primary',
cancelVariant = 'secondary',
title = "Confirm Action",
confirmText = "Confirm",
cancelText = "Cancel",
confirmVariant = "primary",
cancelVariant = "secondary",
onconfirm,
oncancel,
children
children,
} = $props();
function handleConfirm() {

View file

@ -61,6 +61,7 @@
flightParametersDefaults,
} from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
import CurveEditor from "./CurveEditor.svelte";
// Props
interface Props {
@ -76,6 +77,7 @@
// Component References
let pointEditorRef: PointEditor | null = null;
let curveEditorRef: CurveEditor | null = null;
// Derived State
let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null);
@ -224,8 +226,7 @@
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
style="cursor:pointer;"
onclick={handleToggleCollapse}
>
onclick={handleToggleCollapse}>
<b class="card-title mb-0 text-white p-0">Условия прогнозирования</b>
<Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования">
<Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" />
@ -270,8 +271,7 @@
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
}))}
placeholder="Новая точка..."
searchPlaceholder="Поиск по точкам..."
/>
searchPlaceholder="Поиск по точкам..." />
{#if selectedPointId !== -1}
<Button
size="sm"
@ -279,8 +279,7 @@
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;"
on:click={() => handlePointSelection(-1)}
title="Clear selection"
>
title="Clear selection">
<Icon name="x" style="font-size: 16px;" />
</Button>
{/if}
@ -294,8 +293,7 @@
class="flex-fill"
size="sm"
onclick={() => pointEditorRef?.openModal(true)}
title="Открыть список точек"
>
title="Открыть список точек">
Все точки
<Icon name="journal-bookmark-fill" />
</Button>
@ -306,8 +304,7 @@
size="sm"
onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты"
disabled={!isPointDirty && selectedPointId !== -1}
>
disabled={!isPointDirty && selectedPointId !== -1}>
{selectedPointId !== -1 ? "Обновить точку" : "Сохранить точку"}
<Icon name="floppy2-fill" />
</Button>
@ -321,16 +318,14 @@
type="number"
step="0.000001"
bind:value={$FlightParametersStore.launch_latitude}
placeholder="Latitude"
/>
placeholder="Latitude" />
<InputGroupText>/</InputGroupText>
<Input
id="cp-longitude"
type="number"
step="0.000001"
bind:value={$FlightParametersStore.launch_longitude}
placeholder="Longitude"
/>
placeholder="Longitude" />
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
<Icon name="geo-alt-fill" />
</Button>
@ -344,8 +339,7 @@
type="number"
id="cp-start-height"
class="form-control-sm"
bind:value={$FlightParametersStore.launch_altitude}
/>
bind:value={$FlightParametersStore.launch_altitude} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label>
@ -353,8 +347,7 @@
type="number"
id="cp-burst-altitude"
class="form-control-sm"
bind:value={$FlightParametersStore.burst_altitude}
/>
bind:value={$FlightParametersStore.burst_altitude} />
</FormGroup>
</div>
@ -366,8 +359,7 @@
type="number"
id="cp-ascent-rate"
class="form-control-sm"
bind:value={$FlightParametersStore.ascent_rate}
/>
bind:value={$FlightParametersStore.ascent_rate} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label>
@ -375,13 +367,16 @@
type="number"
id="cp-descent-rate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
bind:value={$FlightParametersStore.descent_rate} />
</FormGroup>
</div>
{:else}
<!-- NOTE: Custom profile UI to be implemented -->
<p class="text-muted text-center small my-3">Custom profile editor is not yet implemented.</p>
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="mb-2">
Открыть редактор кривых
<Icon name="graph-up-arrow" />
</Button>
{/if}
<div class="d-grid gap-1">
@ -391,3 +386,4 @@
{/if}
</Card>
<PointEditor bind:this={pointEditorRef} onSelectPoint={(point: SavedPoint) => handlePointSelection(point.id)} />
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false}/>

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Chart, type TooltipItem } from "chart.js/auto";
import "chartjs-adapter-luxon";
import chartjsPluginDragdata from "chartjs-plugin-dragdata";
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
import { DateTime } from "luxon";
Chart.register(chartjsPluginDragdata);
// Props
let {
curve,
onUpdate,
} = $props<{
curve: SavedFlightProfile;
onUpdate: (points: RateCurvePoint[]) => void;
}>();
// State
let canvasElement: HTMLCanvasElement;
let chart: Chart | null = $state(null);
// Reactive derived state for chart data
let chartData = $derived(calculateChartData(curve.rate_profile_data));
// def resolve_constraints_to_abs_time(constraints):
// """
// Convert relative constraints to absolute time constraints.
// Args:
// constraints: List of [time_constraint, altitude_constraint, vertical_rate]
// where -1 indicates no constraint
// Returns:
// List of [absolute_time, rate] pairs
// """
// abs_constraints = []
// current_time = 0
// current_alt = 0
// for constraint in constraints:
// time_constraint, alt_constraint, rate = constraint
// # Calculate time to reach this constraint
// if time_constraint != -1:
// if alt_constraint != -1:
// # Both time and altitude constraints exist
// time_for_alt = (alt_constraint - current_alt) / rate if rate != 0 else 0
// resolved_time = min(time_constraint, time_for_alt)
// else:
// # Only time constraint
// resolved_time = time_constraint
// else:
// # Only altitude constraint (or invalid case)
// if alt_constraint != -1:
// resolved_time = (alt_constraint - current_alt) / rate if rate != 0 else 0
// else:
// resolved_time = 0 # Invalid case, raise an error or handle as needed
// if resolved_time < 0:
// resolved_time = 0
// current_time += resolved_time
// current_alt += resolved_time * rate
// abs_constraints.append([current_time, rate])
// return abs_constraints
// # Usage:
// test_data2 = [
// [1000, 6000, 5],
// [-1, 14000, 4],
// [3000, -1, 0],
// [-1, 10000, -2],
// [-1, 40000, 3],
// [1000, 6000, -10],
// [-1, 14000, 4],
// [3000, -1, 0],
// [-1, 10000, -2],
// ]
// abs_constraints = resolve_constraints_to_abs_time(test_data2)
// def quick_propagator(abs_constraints):
// T = [0]
// A = [0] # Initialize with the starting altitude
// for i in range(len(abs_constraints)):
// A.append(A[-1] + ((abs_constraints[i][0]-T[-1]) * abs_constraints[i][1]))
// T.append(abs_constraints[i][0])
// return T, A
// T, A = quick_propagator(abs_constraints)
// plt.plot(T, A)
function calculateChartData(points: RateCurvePoint[]) {
const data: { x: number; y: number }[] = [];
let currentTime = 0;
let currentAltitude = 0;
data.push({ x: currentTime, y: currentAltitude });
for (const point of points) {
const { time_constraint, alt_constraint, rate } = point;
let resolved_time = 0;
if (time_constraint !== -1) {
if (alt_constraint !== -1) {
// Both time and altitude constraints exist
const time_for_alt = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
resolved_time = Math.min(time_constraint, time_for_alt);
} else {
// Only time constraint
resolved_time = time_constraint;
}
} else {
// Only altitude constraint (or invalid case)
if (alt_constraint !== -1) {
resolved_time = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
} else {
resolved_time = 0; // Invalid case
}
}
if (resolved_time < 0) {
resolved_time = 0; // Prevent time from going backwards
}
currentTime += resolved_time;
currentAltitude += resolved_time * rate;
data.push({ x: currentTime, y: currentAltitude });
}
return data;
}
function updateChart() {
if (!chart) return;
chart.data.datasets[0].data = chartData;
chart.update("none");
}
function handleDragEnd(e: any, datasetIndex: number, index: number, value: { x: number; y: number }) {
if (index === 0) {
// Prevent dragging the start point
updateChart(); // Revert change
return;
}
// Prevent dragging past neighbor points on the X axis
const prevPointX = chartData[index - 1].x;
const nextPointX = chartData[index + 1] ? chartData[index + 1].x : Infinity;
if (value.x <= prevPointX || value.x >= nextPointX) {
updateChart();
return;
}
const newPoints = JSON.parse(JSON.stringify(curve.rate_profile_data));
const pointToUpdate = newPoints[index - 1];
const prevPointData = chartData[index - 1];
const newSegmentDuration = value.x - prevPointData.x;
const newAltitude = value.y;
const newAltDiff = newAltitude - prevPointData.y;
// Update altitude constraint if it exists
if (pointToUpdate.alt_constraint !== -1) {
pointToUpdate.alt_constraint = Math.round(newAltitude);
}
// Update time constraint if it exists
if (pointToUpdate.time_constraint !== -1) {
pointToUpdate.time_constraint = Math.round(newSegmentDuration);
}
// Always recalculate the rate based on the new position.
// The logic in calculateChartData will then determine if time or altitude is the driving constraint.
if (newSegmentDuration > 0) {
pointToUpdate.rate = parseFloat((newAltDiff / newSegmentDuration).toFixed(2));
} else {
pointToUpdate.rate = 0;
}
onUpdate(newPoints);
}
onMount(() => {
const ctx = canvasElement.getContext("2d");
if (!ctx) return;
chart = new Chart(ctx, {
type: "line",
data: {
datasets: [
{
label: "Профиль высоты",
data: chartData,
borderColor: "rgb(75, 192, 192)",
backgroundColor: "rgba(75, 192, 192, 0.5)",
stepped: false,
fill: false,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: "rgb(75, 192, 192)",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: "linear",
position: "bottom",
title: {
display: true,
text: "Время от старта T0+ (сек)",
},
},
y: {
title: {
display: true,
text: "Высота над ур. моря (м)",
},
},
},
plugins: {
dragData: {
round: 0,
onDragEnd: handleDragEnd,
dragX: true, // Enable horizontal dragging
},
tooltip: {
callbacks: {
label: function (context: TooltipItem<"line">) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
label += `${context.parsed.y.toFixed(2)} m`;
}
if (context.parsed.x !== null) {
const duration = DateTime.fromSeconds(context.parsed.x);
const timeString = duration.toFormat("HH:mm:ss");
label += ` at ${timeString}`;
}
return label;
},
},
},
},
},
} as any);
});
$effect(() => {
if (chart) {
updateChart();
}
});
onDestroy(() => {
chart?.destroy();
});
</script>
<div style="position: relative; height: 100%; min-height: 250px;">
<canvas bind:this={canvasElement}></canvas>
{#if !chart}
<div
class="text-center text-muted"
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
Loading chart...
</div>
{/if}
</div>

View file

@ -0,0 +1,607 @@
<script lang="ts">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
InputGroup,
Table,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
import { SavedFlightProfilesStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
// import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves";
import EditableCell from "./EditableCell.svelte";
import CurveChart from "./CurveChart.svelte";
// Mock API functions for now
const getSavedCurves = async (): Promise<SavedFlightProfile[]> => {
console.log("Fetching saved curves");
return [];
};
const saveCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
console.log("Saving curve", curve);
const newCurve = { ...curve, id: Date.now() };
return newCurve;
};
const updateCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
console.log("Updating curve", curve);
return curve;
};
const deleteCurve = async (id: number): Promise<void> => {
console.log("Deleting curve", id);
};
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (p: SavedFlightProfile) => {},
onSelectCurve = (p: SavedFlightProfile) => {},
showTable = false,
curve = null,
editor = false,
closeOnSave = false,
closeOnDelete = false,
} = $props();
// Runes
let selectedCurve = $derived<SavedFlightProfile | null>(curve);
let newCurve = $state<SavedFlightProfile>({ id: 0, name: "", rate_profile_data: [] });
let newPoint = $state<RateCurvePoint>({ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 });
let isEditing = $state(editor);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
let closeOnSave_ = $state(closeOnSave);
// Table handler
let curvesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
let search = $derived(curvesTable.createSearch(["name"]));
$effect(() => {
if (showTable) {
getSavedCurves().then((curves) => {
$SavedFlightProfilesStore = curves;
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
});
}
if (editor && curve) {
selectedCurve = curve;
newCurve = { ...curve };
isEditing = true;
}
});
// Ensure curve points are always sorted by the order field
$effect(() => {
newCurve.rate_profile_data.sort((a, b) => a.order - b.order);
});
// On mount, fetch curves
onMount(async () => {
if (showTable) {
const curves = await getSavedCurves();
$SavedFlightProfilesStore = curves;
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
}
});
// Modal controls
export function openModal(table_: boolean = false) {
showTable = table_;
isOpen = true;
}
export function openModalAndCreate(
curve: SavedFlightProfile | null = null,
close: boolean = false,
table_: boolean = false,
onSaveCallback: (curve: SavedFlightProfile) => void = () => {},
) {
if (curve) {
selectedCurve = curve;
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
isEditing = true;
} else {
selectedCurve = null;
newCurve = { id: 0, name: "", rate_profile_data: [] };
isEditing = false;
}
showTable = table_;
isOpen = true;
closeOnSave_ = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
if (closeOnSave_ != closeOnSave) {
closeOnSave = closeOnSave_;
}
onClose();
}
function handleEditCurve(curve: SavedFlightProfile) {
selectedCurve = curve;
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
isEditing = true;
showTable = false; // Switch to editor view
}
function confirmDeleteCurve(curve: SavedFlightProfile) {
selectedCurve = curve;
isConfirmationVisible = true;
}
function handleDeleteCurve(curve: SavedFlightProfile | null) {
if (!curve) return;
deleteCurve(curve.id)
.then(() => {
$SavedFlightProfilesStore = $SavedFlightProfilesStore.filter((p) => p.id !== curve.id);
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve deleted",
body: `Curve "${curve.name}" has been deleted.`,
color: "success",
});
if (closeOnDelete) {
closeModal();
}
})
.catch((error) => {
showAlert(`Error deleting curve: ${error.message}`);
});
}
export function handleSaveCurve() {
if (isEditing && selectedCurve) {
updateCurve(newCurve)
.then((updatedCurve) => {
$SavedFlightProfilesStore = $SavedFlightProfilesStore.map((p) =>
p.id === updatedCurve.id ? updatedCurve : p,
);
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve updated",
body: `Curve "${updatedCurve.name}" has been updated.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(updatedCurve);
})
.catch((error) => {
showAlert(`Error updating curve: ${error.message}`);
});
} else {
saveCurve(newCurve)
.then((savedCurve) => {
$SavedFlightProfilesStore = [...$SavedFlightProfilesStore, savedCurve];
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve saved",
body: `Curve "${savedCurve.name}" has been saved.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(savedCurve);
resetForm();
})
.catch((error) => {
showAlert(`Error saving curve: ${error.message}`);
});
}
}
function validateConstraints(point: RateCurvePoint): boolean {
if (point.time_constraint <= 0 && point.time_constraint !== -1) {
showAlert("Time constraint invalid, must be > 0 or -1 for no constraint.");
return false;
}
if (point.alt_constraint < 0 && point.alt_constraint !== -1) {
showAlert("Altitude constraint invalid, must be >= 0 or -1 for no constraint.");
return false;
}
if (point.alt_constraint === -1 && point.time_constraint === -1) {
showAlert("At least one constraint must be set (time or altitude).");
return false;
}
return true;
}
function addPoint() {
if (validateConstraints(newPoint)) {
const maxOrder = newCurve.rate_profile_data.reduce((max, p) => Math.max(max, p.order), -1);
newPoint.order = maxOrder + 1;
newCurve.rate_profile_data.push({ ...newPoint });
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
newPoint = { order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 };
isAlertVisible = false; // Hide alert after successful addition
}
}
function removePoint(index: number) {
newCurve.rate_profile_data.splice(index, 1);
// Re-index the order of remaining points
newCurve.rate_profile_data.forEach((point, i) => {
point.order = i;
});
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
}
function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
try {
const rate_profile_data: RateCurvePoint[] = text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line, index) => {
const [order, time_constraint, alt_constraint, rate] = line.split(",").map(Number);
if (isNaN(time_constraint) || isNaN(alt_constraint) || isNaN(rate)) {
throw new Error("Invalid number in CSV file.");
}
// Use file line order as the canonical order
return { order: index, time_constraint, alt_constraint, rate };
});
newCurve.rate_profile_data = rate_profile_data;
addToast({
header: "CSV imported",
body: `${rate_profile_data.length} rate_profile_data loaded.`,
color: "success",
});
} catch (error: any) {
showAlert(`Error parsing CSV: ${error.message}`);
}
};
reader.readAsText(file);
}
export function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
export function hideAlert() {
isAlertVisible = false;
alertText = "";
}
export function resetForm() {
selectedCurve = null;
newCurve = { id: 0, name: "", rate_profile_data: [] };
isEditing = false;
hideAlert();
}
function movePoint(index: number, direction: number) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= newCurve.rate_profile_data.length) return;
// Swap order values
const tempOrder = newCurve.rate_profile_data[index].order;
newCurve.rate_profile_data[index].order = newCurve.rate_profile_data[newIndex].order;
newCurve.rate_profile_data[newIndex].order = tempOrder;
// Trigger reactivity, the $effect will sort the array
newCurve.rate_profile_data = [...newCurve.rate_profile_data];
}
</script>
<Modal
{isOpen}
toggle={closeModal}
size="xl"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">
{showTable ? "Ascent/Descent Curves" : isEditing ? "Edit Curve" : "Create New Curve"}
</h5>
<Button close onclick={closeModal} />
</div>
<div class="modal-body">
{#if showTable}
<!-- Curve Selection Table -->
<div class="d-flex justify-content-between mb-2">
<InputGroup>
<Input
type="text"
placeholder="Search by name..."
bind:value={search.value}
oninput={() => search.set()} />
<Button
onclick={() => {
search.value = "";
search.set();
}}>
<Icon name="x" />
</Button>
</InputGroup>
<Button
color="primary"
on:click={() => {
showTable = false;
isEditing = false;
resetForm();
}}>
<Icon name="plus-lg" class="me-1" /> Create New
</Button>
</div>
<div bind:this={curvesTable.element} class="table-responsive">
<Table class="table-sm mb-0">
<thead>
<tr>
<th style="width: 70%;">Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each curvesTable.rows as curve (curve.id)}
<tr>
<td>{curve.name}</td>
<td>
<Button size="sm" color="primary" on:click={() => onSelectCurve(curve)}>
<Icon name="check-lg" />
</Button>
<Button
size="sm"
color="secondary"
on:click={() => handleEditCurve(curve)}
class="ms-1">
<Icon name="pencil" />
</Button>
<Button
size="sm"
color="danger"
on:click={() => confirmDeleteCurve(curve)}
class="ms-1">
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous on:click={() => curvesTable.setPage("previous")} />
</PaginationItem>
{#each curvesTable.pagesWithEllipsis as page}
<PaginationItem active={curvesTable.currentPage === page}>
<PaginationLink on:click={() => curvesTable.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next on:click={() => curvesTable.setPage("next")} />
</PaginationItem>
</Pagination>
{:else}
<!-- Curve Editor -->
<!-- Points Table -->
<div class="row">
<div class="col-lg-6">
<div class="mb-2">
<Label for="name" class="small">Curve Name:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newCurve.name} required />
</div>
<h6>Точки профиля</h6>
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<div class="table-responsive small" style="max-height: 300px;" bind:this={curvesTable.element}>
<table class="table table-sm border mb-0">
<thead>
<tr>
<th style="width: 49.8px;"></th>
<th>
Время (сек)
<span
title="Время в секундах от предыдущей точки"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th>
Высота (м)
<span
title="Высота в метрах над уровнем моря"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th>
Скорость (м/с)
<span
title="Вертикальная скорость в метрах в секунду (положительная - подъем, отрицательная - спуск)"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th></th>
</tr>
</thead>
<tbody>
{#each newCurve.rate_profile_data as point, i (point.order)}
{@const isFirst = i === 0}
{@const isLast = i === newCurve.rate_profile_data.length - 1}
<tr style="height: 36.8px; vertical-align: middle;">
<td class="text-center align-middle" style="cursor: grab; width: 49.8px;">
<div class="d-flex flex-row">
<Button
size="sm"
class="p-0 border-0 bg-transparent text-body px-1"
on:click={() => movePoint(i, -1)}
disabled={isFirst}>
<Icon name="chevron-up" />
</Button>
<Button
size="sm"
class="p-0 border-0 bg-transparent text-body px-1"
on:click={() => movePoint(i, 1)}
disabled={isLast}>
<Icon name="chevron-down" />
</Button>
</div>
</td>
<EditableCell
bind:value={point.time_constraint}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valuePrefix="+"
valueSuffix=" сек"
emptyValue={-1} />
<EditableCell
bind:value={point.alt_constraint}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valueSuffix=" м"
emptyValue={-1} />
<EditableCell
bind:value={point.rate}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valueSuffix=" м/c" />
<td class="text-center align-middle">
<Button
size="sm"
color="danger"
on:click={() => removePoint(i)}
class="p-0 border-0 bg-transparent text-danger px-1"
style="cursor: pointer; font-size: initial;">
<Icon name="trash" />
</Button>
</td>
</tr>
{:else}
<tr style="height: 36.8px; vertical-align: middle;">
<td colspan="5" class="text-center text-muted">No points added yet</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Time (s)"
bind:value={newPoint.time_constraint} />
</td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Altitude (m)"
bind:value={newPoint.alt_constraint} />
</td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Rate (m/s)"
bind:value={newPoint.rate} />
</td>
<td class="text-center align-middle">
<Button
size="sm"
color="success"
on:click={addPoint}
class="p-0 border-0 bg-transparent px-1 text-success"
style="cursor: pointer; display: table;">
Добавить
</Button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="col-lg-6">
<CurveChart
curve={newCurve}
onUpdate={(updatedPoints: RateCurvePoint[]) => {
newCurve.rate_profile_data = updatedPoints;
}} />
</div>
</div>
<!-- Import/Export -->
<div class="d-flex justify-content-between">
<div>
<Label for="import-csv" class="small">Import from CSV</Label>
<Input
type="file"
id="import-csv"
accept=".csv"
on:change={handleFileUpload}
class="form-control-sm" />
</div>
</div>
<hr />
<div class="d-grid gap-2 d-md-flex justify-content-end">
{#if showTable}
<Button
color="secondary"
size="sm"
on:click={() => {
showTable = true;
resetForm();
}}>
Back to List
</Button>
{/if}
<Button type="submit" color="success" size="sm" onclick={handleSaveCurve}>
{isEditing ? "Update Curve" : "Save New Curve"}
</Button>
</div>
{/if}
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title="Confirm Deletion"
confirmText="Delete"
cancelText="Cancel"
confirmVariant="danger"
onconfirm={() => {
isConfirmationVisible = false;
handleDeleteCurve(selectedCurve);
}}
oncancel={() => {
isConfirmationVisible = false;
}}>
<p>Are you sure you want to delete this curve?</p>
</ConfirmationPrompt>

View file

@ -0,0 +1,45 @@
<script lang="ts">
let { value = $bindable(), onchange = () => {}, valuePrefix = "", valueSuffix = "", emptyValue=null, emptyPlaceholder="-" } = $props();
let editing = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
if (inputEl) inputEl.focus();
});
function startEditing() {
editing = true;
}
function stopEditing() {
editing = false;
onchange();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
stopEditing();
} else if (event.key === "Escape") {
editing = false;
}
}
</script>
<td onclick={startEditing} onfocusin={startEditing}>
{#if editing}
<input
type="number"
class="form-control form-control-sm border-0"
bind:this={inputEl}
bind:value
onblur={stopEditing}
onkeydown={handleKeydown} />
{:else}
{#if value === emptyValue || value === null || value === undefined}
<span class="text-muted">{emptyPlaceholder}</span>
{:else}
<span>{valuePrefix}{value}{valueSuffix}</span>
{/if}
{/if}
</td>

View file

@ -9,13 +9,11 @@
src="/logo-full-ru-dark.svg"
class="d-inline-block align-middle img-fluid"
alt="ООО «ЯКС»"
width="250"
/>
width="250" />
</a>
</div>
</div>
<div class="col-lg-8 offset-lg-1">
</div>
<div class="col-lg-8 offset-lg-1"></div>
</div>
</div>
<div class="container pb-4">
@ -26,7 +24,8 @@
<div class="col-6 text-end small">
<div>
<p>
<a class="text-decoration-none" href="/usage_policy">Условия использования</a> -
<a class="text-decoration-none" href="/usage_policy">Условия использования</a>
-
<a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a>
</p>
</div>

View file

@ -92,7 +92,9 @@
const range = distHaversine(launch.latlng, landing.latlng, 1);
const f_hours = Math.floor(flight_time / 3600);
const f_minutes = Math.floor((flight_time % 3600) / 60).toString().padStart(2, "0");
const f_minutes = Math.floor((flight_time % 3600) / 60)
.toString()
.padStart(2, "0");
const flighttime = `${f_hours}hr${f_minutes}`;
L.marker(launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
@ -112,7 +114,9 @@
title: `Telemetry at ${point.datetime}`,
icon: telemetryIcon,
})
.bindPopup(`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`)
.bindPopup(
`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
)
.addTo(plotLayerGroup);
});
@ -149,6 +153,6 @@
</div>
<slot />
{#if map && windData}
<WindVisualization {map} windData={windData} />
<WindVisualization {map} {windData} />
{/if}
</div>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { checkAuthenticated, logout, whoami } from '$lib/auth';
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { checkAuthenticated, logout, whoami } from "$lib/auth";
import {
Collapse,
Dropdown,
@ -14,8 +14,8 @@
NavLink,
Navbar,
NavbarBrand,
NavbarToggler
} from '@sveltestrap/sveltestrap';
NavbarToggler,
} from "@sveltestrap/sveltestrap";
// State for the navbar toggler
let isOpen = false;
@ -33,15 +33,13 @@
} else {
user = null;
if ($page.url.pathname !== '/')
goto('/login'); // Redirect to login if not authenticated
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
}
} catch (error) {
console.error('Authentication check failed:', error);
console.error("Authentication check failed:", error);
isAuthenticated = false;
user = null;
if ($page.url.pathname !== '/')
goto('/login'); // Redirect to login if not authenticated
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
}
});
@ -50,12 +48,13 @@
logout();
isAuthenticated = false;
user = null;
goto('/');
goto("/");
} catch (error) {
console.error('Logout failed:', error);
console.error("Logout failed:", error);
}
}
</script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
<NavbarBrand href="/" class="nav-full-height">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
@ -67,7 +66,7 @@
<NavLink
href="/predict"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/predict'}>
active={$page.url.pathname === "/predict"}>
Прогнозирование
</NavLink>
</NavItem>
@ -75,7 +74,7 @@
<NavLink
href="/track"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/track'}>
active={$page.url.pathname === "/track"}>
Слежение
</NavLink>
</NavItem>
@ -84,7 +83,7 @@
{#if isAuthenticated === true && user}
<Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0">
{user ?? 'Пользователь'}
{user ?? "Пользователь"}
</DropdownToggle>
<DropdownMenu end>
<DropdownItem href="/user/account">Учетная запись</DropdownItem>
@ -100,7 +99,7 @@
<NavLink
href="/login"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}>
active={$page.url.pathname === "/login"}>
Войти
</NavLink>
</NavItem>

View file

@ -206,10 +206,11 @@
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}
>
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">{isEditing ? "Редактирование точки" : showTable ? "Сохраненные точки" : "Добавить новую точку"}</h5>
<h5 class="modal-title">
{isEditing ? "Редактирование точки" : showTable ? "Сохраненные точки" : "Добавить новую точку"}
</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body">
@ -220,8 +221,7 @@
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.set()}
/>
oninput={() => search.set()} />
<Button
size="sm"
color="white"
@ -231,8 +231,7 @@
search.value = "";
search.set();
}}
disabled={!search.value}
>
disabled={!search.value}>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
@ -259,7 +258,10 @@
<Button
color="success"
size="sm"
onclick={() => {onSelectPoint(row); closeModal();}}>
onclick={() => {
onSelectPoint(row);
closeModal();
}}>
</Button>
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
@ -302,8 +304,7 @@
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2"
>
class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
@ -311,8 +312,7 @@
onsubmit={(e) => {
e.preventDefault();
handleSavePoint();
}}
>
}}>
<div class="mb-2">
<Label for="name" class="small">Название точки:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newPoint.name} required />
@ -326,8 +326,7 @@
step="any"
id="lat"
bind:value={newPoint.lat}
required
/>
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
@ -338,8 +337,7 @@
step="any"
id="lon"
bind:value={newPoint.lon}
required
/>
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
@ -350,8 +348,7 @@
step="any"
id="alt"
bind:value={newPoint.alt}
required
/>
required />
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
@ -375,8 +372,7 @@
onclick={() => {
resetForm();
closeModal();
}}
>
}}>
Закрыть без сохранения
</Button>
{/if}
@ -398,7 +394,6 @@
}}
oncancel={() => {
isConfirmationVisible = false;
}}
>
}}>
<p>Вы уверены, что хотите удалить эту точку?</p>
</ConfirmationPrompt>

View file

@ -30,14 +30,12 @@
scenario_data = {
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "",
model: "",
dataset: "",
prediction_mode: "",
},
},
} as SavedScenario,
} = $props();
// Runes
@ -64,14 +62,12 @@
scenario_data: SavedScenario = {
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "",
model: "",
dataset: "",
prediction_mode: "",
},
},
close: boolean = false,
onSaveCallback: (point: SavedScenario) => void = () => {},
) {
@ -184,8 +180,7 @@
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}
>
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">Редактирование сценария</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
@ -198,8 +193,7 @@
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2"
>
class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
@ -207,8 +201,7 @@
onsubmit={(e) => {
e.preventDefault();
handleSaveScenario();
}}
>
}}>
<div class="mb-2">
<Label for="name" class="small">Название сценария:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newScenario.name} required />
@ -222,9 +215,7 @@
{/if}
<span class="flex-grow-1"></span>
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => {}}>
Удалить сценарий
</Button>
<Button color="danger" size="sm" type="button" onclick={() => {}}>Удалить сценарий</Button>
{:else}
<Button
color="secondary"
@ -233,8 +224,7 @@
onclick={() => {
resetForm();
closeModal();
}}
>
}}>
Закрыть без сохранения
</Button>
{/if}
@ -256,7 +246,6 @@
}}
oncancel={() => {
isConfirmationVisible = false;
}}
>
}}>
<p>Вы уверены, что хотите удалить этот сценарий?</p>
</ConfirmationPrompt>

View file

@ -49,8 +49,7 @@
}
const flightParameters = $FlightParametersStore;
const savedData = savedScenario.template_data;
const savedFlightParameters = savedData.flight_parameters;
const savedFlightParameters = savedScenario.flight_parameters;
// Compare flight parameters excluding launch_datetime
console.log("Comparing flight parameters:", flightParameters, savedFlightParameters);
@ -91,13 +90,11 @@
{
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "test",
model: "test",
dataset: "test",
prediction_mode: $ScenarioStore.template_data.prediction_mode
},
prediction_mode: $ScenarioStore.prediction_mode,
},
true,
handleModalSave,
@ -109,7 +106,7 @@
const selectedScenario = $SavedScenarioStore.find((scenario) => scenario.id === selectedScenarioId);
if (selectedScenario) {
$ScenarioStore = selectedScenario;
$FlightParametersStore = selectedScenario.template_data.flight_parameters;
$FlightParametersStore = selectedScenario.flight_parameters;
scenarioUnsaved = false;
writeLocalStorage("scenario", $ScenarioStore);
writeLocalStorage("flightParameters", $FlightParametersStore);
@ -155,15 +152,13 @@
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
style="cursor:pointer;"
>
style="cursor:pointer;">
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
style="width:100%;"
aria-label="Свернуть/развернуть параметры прогнозирования"
onclick={() => (isCollapsed = !isCollapsed)}
>
onclick={() => (isCollapsed = !isCollapsed)}>
<b class="card-title mb-0 text-white p-0">Сценарий прогнозирования</b>
<Button class="p-0" size="sm" color="primary" onclick={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
@ -196,8 +191,7 @@
if (!scenarioUnsaved) {
handleApplySelectedScenario(false);
}
}}
/>
}} />
<Button
size="sm"
color="white"
@ -206,12 +200,16 @@
on:click={() => {
selectedScenarioId = -1;
}}
disabled={selectedScenarioId === -1}
>
disabled={selectedScenarioId === -1}>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<Button color="success" title="Применить сценарий" onclick={() => {handleApplySelectedScenario(true)}}>
<Button
color="success"
title="Применить сценарий"
onclick={() => {
handleApplySelectedScenario(true);
}}>
<span></span>
</Button>
</InputGroup>
@ -228,8 +226,7 @@
size="sm"
title="Сохранить текущие условия как сценарий"
onclick={handleSaveCurrentScenario}
disabled={!scenarioUnsaved && selectedScenarioId !== -1}
>
disabled={!scenarioUnsaved && selectedScenarioId !== -1}>
{selectedScenarioId !== -1 ? "Обновить сценарий" : "Сохранить сценарий"}
<Icon name="floppy2-fill" />
</Button>
@ -240,13 +237,16 @@
<FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Режим сценария:</Label>
<InputGroup size="sm">
<Input type="select" id="scenarioMode" bind:value={$ScenarioStore.template_data.prediction_mode}
<Input
type="select"
id="scenarioMode"
bind:value={$ScenarioStore.prediction_mode}
on:change={() => {
scenarioUnsaved = true;
}}>
{#each Object.entries(PREDICTION_MODE_MAP) as [key, value]}
<option {value}
>{PPREDICTION_MODE_NAMES[key as keyof typeof PPREDICTION_MODE_NAMES]}
<option {value}>
{PPREDICTION_MODE_NAMES[key as keyof typeof PPREDICTION_MODE_NAMES]}
{key}
</option>
{/each}
@ -289,8 +289,7 @@
<Button
color="primary"
title="Edit Saved Locations"
onclick={() => console.log("Not implemented yet")}
>
onclick={() => console.log("Not implemented yet")}>
<span>Экспорт</span>
<Icon name="file-earmark-arrow-down" />
</Button>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon } from '@sveltestrap/sveltestrap';
import { Icon } from "@sveltestrap/sveltestrap";
type Tab = {
id: string;
@ -20,8 +20,7 @@
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
class:active={activeTab === tab.id}
on:click={() => (activeTab = tab.id)}
type="button"
>
type="button">
<Icon name={tab.icon} class="custom-tab-icon" />
<span class="custom-tab-label">{tab.label}</span>
</button>

View file

@ -1,15 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
} from '@sveltestrap/sveltestrap';
import { onMount } from "svelte";
import { Card, CardHeader, CardBody, Button, FormGroup, Label, Input, InputGroup } from "@sveltestrap/sveltestrap";
//import { telemetryStore } from '../lib/telemetry.ts'; // Import your telemetry store
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = {};
@ -36,8 +27,7 @@
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
style="cursor:pointer;"
>
style="cursor:pointer;">
<b class="card-title mb-0 p-0">Последние данные телеметрии</b>
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
@ -47,11 +37,9 @@
height="16"
fill="currentColor"
class="bi bi-caret-left-fill"
viewBox="0 0 16 16"
>
viewBox="0 0 16 16">
<path
d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z"
/>
d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
</svg>
{:else}
<svg
@ -60,11 +48,9 @@
height="16"
fill="currentColor"
class="bi bi-caret-down"
viewBox="0 0 16 16"
>
viewBox="0 0 16 16">
<path
d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"
/>
d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" />
</svg>
{/if}
</Button>
@ -74,24 +60,23 @@
<FormGroup spacing="mb-2">
<Label class="small">Широта:</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.latitude || 'N/A'} readonly />
<Input type="text" value={telemetry.latitude || "N/A"} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">Долгота:</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.longitude || 'N/A'} readonly />
<Input type="text" value={telemetry.longitude || "N/A"} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">Высота (м):</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.altitude || 'N/A'} readonly />
<Input type="text" value={telemetry.altitude || "N/A"} readonly />
</InputGroup>
</FormGroup>
</CardBody>
{/if}
</Card>

View file

@ -1,5 +1,5 @@
<script context="module">
import { writable } from 'svelte/store';
import { writable } from "svelte/store";
/**
* @typedef {'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'} ToastColor
@ -16,14 +16,14 @@
export const toasts = writable([]);
const TOAST_ICONS = {
primary: 'info-circle-fill',
secondary: 'info-circle-fill',
success: 'check-circle-fill',
danger: 'exclamation-triangle-fill',
warning: 'exclamation-circle-fill',
info: 'info-circle-fill',
light: 'lightbulb',
dark: 'question'
primary: "info-circle-fill",
secondary: "info-circle-fill",
success: "check-circle-fill",
danger: "exclamation-triangle-fill",
warning: "exclamation-circle-fill",
info: "info-circle-fill",
light: "lightbulb",
dark: "question",
};
/**
@ -59,13 +59,12 @@
</script>
<script>
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
import { Toast, ToastBody, ToastHeader, Icon } from "@sveltestrap/sveltestrap";
/**
* Removes a toast from the list by its ID.
* @param {string} id
*/
</script>
<!--
@ -88,11 +87,14 @@
isOpen={true}
autohide={!toast.persistent}
delay={5000}
color={toast.color || 'info'}
on:close={() => removeToast(toast.id)}
>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color || 'text-info'}`}>
<Icon slot="icon" name={TOAST_ICONS[toast.color ? toast.color : 'info']} class="me-2" color={toast.color || 'info'} />
color={toast.color || "info"}
on:close={() => removeToast(toast.id)}>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color || "text-info"}`}>
<Icon
slot="icon"
name={TOAST_ICONS[toast.color ? toast.color : "info"]}
class="me-2"
color={toast.color || "info"} />
{toast.header}
</ToastHeader>
<ToastBody>

View file

@ -1,11 +1,11 @@
<script>
import { onMount, onDestroy } from 'svelte';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet-velocity/dist/leaflet-velocity.css';
import 'leaflet-velocity/dist/leaflet-velocity';
import 'leaflet.heat';
import 'leaflet-timedimension';
import { onMount, onDestroy } from "svelte";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet-velocity/dist/leaflet-velocity.css";
import "leaflet-velocity/dist/leaflet-velocity";
import "leaflet.heat";
import "leaflet-timedimension";
export let map; // принимаем карту из родительского компонента
export let windData;
@ -31,12 +31,11 @@
return {
[refTime]: {
u: windData[0].data, // U-компонента (первый объект в массиве)
v: windData[1].data // V-компонента (второй объект)
}
v: windData[1].data, // V-компонента (второй объект)
},
};
};
// Функция для нормализации данных тепловой карты
const prepareHeatData = (windData) => {
if (!windData || windData.length < 2) {
@ -45,8 +44,8 @@
}
// Получаем U и V компоненты
const uComponent = windData.find(item => item.header.parameterNumber === 2);
const vComponent = windData.find(item => item.header.parameterNumber === 3);
const uComponent = windData.find((item) => item.header.parameterNumber === 2);
const vComponent = windData.find((item) => item.header.parameterNumber === 3);
if (!uComponent || !vComponent) {
console.warn("Missing wind components");
@ -106,12 +105,12 @@
// maxZoom: 10,
minOpacity: 0.7,
gradient: {
0.1: 'blue',
0.3: 'cyan',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
0.1: "blue",
0.3: "cyan",
0.5: "lime",
0.7: "yellow",
1.0: "red",
},
});
} catch (e) {
console.error("Failed to create heat layer:", e);
@ -133,11 +132,11 @@
velocityLayer = L.velocityLayer({
displayValues: true,
displayOptions: {
velocityType: 'Wind Speed',
position: 'bottomright',
emptyString: 'No wind data',
velocityType: "Wind Speed",
position: "bottomright",
emptyString: "No wind data",
},
data: windData
data: windData,
}).addTo(map);
}
@ -148,7 +147,7 @@
if (heatLayer) {
heatLayer.addTo(map);
createLegend(Math.max(...heatData.map(point => point[2])));
createLegend(Math.max(...heatData.map((point) => point[2])));
}
}
@ -164,26 +163,28 @@
const overlays = {};
if (velocityLayer) {
overlays['Векторы ветра'] = velocityLayer;
overlays["Векторы ветра"] = velocityLayer;
}
if (heatLayer) {
overlays['Тепловая карта'] = heatLayer;
overlays["Тепловая карта"] = heatLayer;
}
layerControl = L.control.layers(null, overlays, {
layerControl = L.control
.layers(null, overlays, {
collapsed: false,
position: 'topright'
}).addTo(map);
position: "topright",
})
.addTo(map);
};
// Создание легенды с учетом максимальной скорости
const createLegend = (maxSpeed) => {
if (!map) return;
legend = L.control({ position: 'bottomright' });
legend = L.control({ position: "bottomright" });
legend.onAdd = () => {
const div = L.DomUtil.create('div', 'wind-heat-legend');
const div = L.DomUtil.create("div", "wind-heat-legend");
div.innerHTML = `
<h4>Wind Speed (m/s)</h4>
<div class="legend-scale">
@ -221,37 +222,41 @@
// Инициализация TimeDimension
timeDimension = new L.TimeDimension({
period: "PT1H", // Интервал 1 час
timeInterval: '${firstTime}/${firstTime}',
timeInterval: "${firstTime}/${firstTime}",
});
// Добавляем контролы времени
timeDimensionControl = new L.Control.TimeDimension({
timeDimension,
position: 'bottomleft',
position: "bottomleft",
// autoPlay: true,
playerOptions: {
// transitionTime: 1000,
loop: false,
minBufferReady: -1
}
minBufferReady: -1,
},
});
map.addControl(timeDimensionControl);
// 4. Создание слоев
const velocityLayer = L.timeDimension.layer.windVelocity({
const velocityLayer = L.timeDimension.layer
.windVelocity({
displayValues: true,
data: timeData,
displayOptions: {
velocityType: 'Wind Speed',
position: 'bottomleft'
}
}).addTo(map);
velocityType: "Wind Speed",
position: "bottomleft",
},
})
.addTo(map);
// 5. Тепловая карта (адаптируйте под ваш формат)
const heatLayer = L.timeDimension.layer.heat({
const heatLayer = L.timeDimension.layer
.heat({
radius: 15,
data: prepareTimeHeatData(timeData)
}).addTo(map);
data: prepareTimeHeatData(timeData),
})
.addTo(map);
});
onDestroy(() => {
@ -265,18 +270,22 @@
// Реактивность на изменение параметров
$: if (map && windData) {
updateLayers();
};
}
</script>
<div class="layer-controls">
<div class="control-group">
<label>
<input type="checkbox" bind:checked={showHeatmap}> Тепловая карта
<input type="checkbox" bind:checked={showHeatmap} />
Тепловая карта
</label>
<label>
<input type="checkbox" bind:checked={showVectors}> Векторы ветра
<input type="checkbox" bind:checked={showVectors} />
Векторы ветра
</label>
</div>
</div>
<style>
.layer-controls {
position: absolute;
@ -286,7 +295,7 @@
background: rgba(255, 255, 255, 0.8);
padding: 10px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
.control-group {
@ -306,7 +315,7 @@
padding: 8px 10px;
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
line-height: 1.2;
color: #333;
font-family: Arial, sans-serif;

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenario, TemplateData } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenario } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
@ -52,7 +52,7 @@ export const FlightParametersStore = writable<FlightParameters>(
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults)
);
export const templateDataDefaults: TemplateData = {
export const templateDataDefaults = {
description: "",
prediction_mode: "",
model: "",
@ -63,7 +63,7 @@ export const templateDataDefaults: TemplateData = {
export const scenarioDefaults: SavedScenario = {
id: -1,
name: "Новый сценарий",
template_data: templateDataDefaults,
...templateDataDefaults,
}
export const ScenarioStore = writable<SavedScenario>(

View file

@ -48,15 +48,6 @@ export const PPREDICTION_MODE_NAMES = {
ensemble: "Ансамблевый"
};
export interface TemplateData {
description: string;
prediction_mode: string;
model: string;
dataset: string;
flight_parameters: FlightParameters;
}
export interface Point {
latlng: LatLngLiteral & { alt?: number };
datetime: Date;
@ -126,14 +117,26 @@ export interface SavedPoint {
alt: number;
}
export interface RateCurvePoint {
order: number; // Order in the curve
time_constraint: number; // Time in seconds
alt_constraint: number; // Altitude constraint in meters
rate: number; // Rate in m/s
}
export interface SavedFlightProfile {
id: number;
name: string;
rate_profile_data: object;
type?: string;
rate_profile_data: RateCurvePoint[];
}
export interface SavedScenario {
id: number;
name: string;
template_data: TemplateData;
description: string;
prediction_mode: string;
model: string;
dataset: string;
flight_parameters: FlightParameters;
}