Refactoring of various stuff

big mess, I don't remember what I was trying to accomplish there
This commit is contained in:
ThePetrovich 2025-12-14 18:06:17 +08:00
parent ffb27c2e0a
commit 8e3dfa54f9
22 changed files with 1083 additions and 647 deletions

View file

@ -46,12 +46,16 @@
InputGroup, InputGroup,
InputGroupText, InputGroupText,
Label, Label,
Dropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { getSavedPoints, updatePoint } from "$lib/api/points"; import { getSavedPoints, updatePoint } from "$lib/api/points";
import { addToast } from "$lib/components/Toast.svelte"; import { addToast } from "$lib/components/ui/Toast.svelte";
import PointEditor from "$lib/components/PointEditor.svelte"; import PointEditor from "$lib/components/editors/PointEditor.svelte";
import SelectSearchable from "$lib/components/SelectSearchable.svelte"; import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
import { getForecast } from "$lib/prediction"; import { getForecast } from "$lib/prediction";
import { import {
FlightParametersStore, FlightParametersStore,
@ -61,7 +65,10 @@
flightParametersDefaults, flightParametersDefaults,
} from "$lib/stores"; } from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types"; import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
import CurveEditor from "./CurveEditor.svelte"; import CurveEditor from "$lib/components/editors/CurveEditor.svelte";
import SpoilerGroup from "$lib/components/ui/SpoilerGroup.svelte";
import LabelGroup from "./ui/LabelGroup.svelte";
import { toFixedNumber } from "$lib/mathutil";
// Props // Props
interface Props { interface Props {
@ -75,6 +82,9 @@
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00")); let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
let selectedPointId = $state($FlightParametersStore.start_point || -1); let selectedPointId = $state($FlightParametersStore.start_point || -1);
let ascentProfile = $state("standard");
let descentProfile = $state("standard");
// Component References // Component References
let pointEditorRef: PointEditor | null = null; let pointEditorRef: PointEditor | null = null;
let curveEditorRef: CurveEditor | null = null; let curveEditorRef: CurveEditor | null = null;
@ -159,23 +169,14 @@
}); });
} else { } else {
// Create new point // Create new point
pointEditorRef?.openModalAndCreate( pointEditorRef?.open({
null, id: 0, // Assuming 0 or a negative number indicates a new point
{ name: `New Point ${new Date().toLocaleString()}`,
id: 0, lat: $FlightParametersStore.launch_latitude,
name: `New Point ${new Date().toLocaleString()}`, lon: $FlightParametersStore.launch_longitude,
lat: $FlightParametersStore.launch_latitude, alt: $FlightParametersStore.launch_altitude,
lon: $FlightParametersStore.launch_longitude, // The onSave callback is handled by the onSelectPoint prop on the component
alt: $FlightParametersStore.launch_altitude, }, false);
},
true,
false,
(savedPoint) => {
if (savedPoint) {
handlePointSelection(savedPoint.id);
}
},
);
} }
} }
@ -198,8 +199,8 @@
// Public API // Public API
export function updateLaunchPosition(lat: number, lng: number) { export function updateLaunchPosition(lat: number, lng: number) {
$FlightParametersStore.launch_latitude = lat; $FlightParametersStore.launch_latitude = toFixedNumber(lat, 6);
$FlightParametersStore.launch_longitude = lng; $FlightParametersStore.launch_longitude = toFixedNumber(lng, 6);
} }
export function loadFlightParameters(params: FlightParameters) { export function loadFlightParameters(params: FlightParameters) {
@ -260,56 +261,28 @@
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="cp-start-point" class="form-label">Точка старта:</Label> <Label for="cp-start-point" class="form-label">Точка старта:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<div class="position-relative flex-grow-1"> <SelectSearchable
<SelectSearchable style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;" id="cp-start-point"
id="cp-start-point" selected={selectedPointId}
selected={selectedPointId} onChange={(e) => handlePointSelection(e)}
onChange={(e) => handlePointSelection(e)} options={$SavedPointsStore.map((point) => ({
options={$SavedPointsStore.map((point) => ({ value: point.id,
value: point.id, label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`, }))}
}))} placeholder="Новая точка..."
placeholder="Новая точка..." clearable={true}
searchPlaceholder="Поиск по точкам..." /> searchPlaceholder="Поиск по точкам..." />
{#if selectedPointId !== -1} <Button
<Button color="secondary"
size="sm" size="sm"
color="white" onclick={() => pointEditorRef?.open()}
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center" title="Открыть список точек">
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;" <Icon name="journal-bookmark-fill"/>
on:click={() => handlePointSelection(-1)} </Button>
title="Clear selection">
<Icon name="x" style="font-size: 16px;" />
</Button>
{/if}
</div>
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
<div class="d-flex gap-2 mb-2">
<Button
color="secondary"
class="flex-fill"
size="sm"
onclick={() => pointEditorRef?.openModal(true)}
title="Открыть список точек">
Все точки
<Icon name="journal-bookmark-fill" />
</Button>
<Button
color="primary"
class="flex-fill"
size="sm"
onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты"
disabled={!isPointDirty && selectedPointId !== -1}>
{selectedPointId !== -1 ? "Обновить точку" : "Сохранить точку"}
<Icon name="floppy2-fill" />
</Button>
</div>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label> <Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
@ -332,6 +305,35 @@
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
<div class="d-flex mb-2">
<Button
color="primary"
class="flex-fill"
size="sm"
onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты"
disabled={!isPointDirty && selectedPointId !== -1}>
Сохранить
<Icon name="floppy2-fill" class="ms-1" />
</Button>
<Dropdown size="sm">
<DropdownToggle
class="dropdown-toggle-standalone"
caret
color="primary"
size="sm"
title="Дополнительные действия"
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
</DropdownToggle>
<DropdownMenu>
<DropdownItem class="small">Сохранить как новую...</DropdownItem>
<DropdownItem class="small">Удалить выбранную точку</DropdownItem>
<DropdownItem divider />
<DropdownItem class="small">Сбросить изменения</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label> <Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
@ -371,19 +373,120 @@
</FormGroup> </FormGroup>
</div> </div>
{:else} {:else}
<!-- NOTE: Custom profile UI to be implemented --> <SpoilerGroup label="Профили подъема и спуска" class="mb-2">
<p class="text-muted text-center small my-3">Custom profile editor is not yet implemented.</p> <Label class="form-label mb-0">Стадия подъема:</Label>
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="mb-2"> <div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={ascentProfile} value={"none"} label={"Нет"} />
<Input type="radio" bind:group={ascentProfile} value={"standard"} label={"Стандартная"} />
<Input type="radio" bind:group={ascentProfile} value={"custom"} label={"Пользовательская"} />
</div>
{#if ascentProfile === "custom"}
<InputGroup size="sm" class="mb-2">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point"
selected={selectedPointId}
onChange={() => {}}
options={$SavedPointsStore.map((point) => ({
value: point.id,
label: `test`,
}))}
clearable={true}
placeholder="Выбрать профиль..."
searchPlaceholder="Поиск по профилям..." />
<Button
color="secondary"
size="sm"
onclick={() => pointEditorRef?.open()}
title="Открыть список точек">
<Icon name="pencil"/>
</Button>
</InputGroup>
{:else if ascentProfile === "standard"}
<InputGroup size="sm" class="mb-2">
<Input type="select">
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
<option value={"const"}>Постоянная скорость</option>
<option value={"reverse"}>Аэродинамический спуск (реверс)</option>
<!-- {/each} -->
</Input>
</InputGroup>
{/if}
<Label class="form-label mb-0">Стадия спуска:</Label>
<div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={descentProfile} value={"none"} label={"Нет"} id="cp-descent-stage-none" />
<Input type="radio" bind:group={descentProfile} value={"standard"} label={"Стандартная"} id="cp-descent-stage-std" />
<Input type="radio" bind:group={descentProfile} value={"custom"} label={"Пользовательская"} id="cp-descent-stage-custom" />
</div>
{#if descentProfile === "custom"}
<InputGroup size="sm" class="mb-2">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point"
selected={selectedPointId}
onChange={() => {}}
options={$SavedPointsStore.map((point) => ({
value: point.id,
label: `test`,
}))}
clearable={true}
placeholder="Выбрать профиль..."
searchPlaceholder="Поиск по профилям..." />
<Button
color="secondary"
size="sm"
onclick={() => pointEditorRef?.open()}
title="Открыть список точек">
<Icon name="pencil"/>
</Button>
</InputGroup>
{:else if descentProfile === "standard"}
<InputGroup size="sm" class="mb-2">
<Input type="select">
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
<option value={"drag"}>Аэродинамический спуск</option>
<option value={"const"}>Постоянная скорость</option>
<option value={"const"}>Постоянная скорость (реверс)</option>
<!-- {/each} -->
</Input>
</InputGroup>
{/if}
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="w-100">
Открыть редактор кривых Открыть редактор кривых
<Icon name="graph-up-arrow" /> <Icon name="graph-up-arrow" />
</Button> </Button>
</SpoilerGroup>
{/if} {/if}
<div class="d-grid gap-1"> <div class="d-flex">
<Button size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button> <Button class="flex-fill" size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
<Dropdown size="sm">
<DropdownToggle
class="dropdown-toggle-standalone"
caret
color="primary"
size="sm"
title="Дополнительные действия"
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
</DropdownToggle>
<DropdownMenu>
<DropdownItem class="small">Сохранить</DropdownItem>
<DropdownItem class="small">Сохранить как новый...</DropdownItem>
<DropdownItem divider />
<DropdownItem class="small">Сбросить настройки</DropdownItem>
</DropdownMenu>
</Dropdown>
</div> </div>
</CardBody> </CardBody>
{/if} {/if}
</Card> </Card>
<PointEditor bind:this={pointEditorRef} onSelectPoint={(point: SavedPoint) => handlePointSelection(point.id)} /> <CurveEditor bind:this={curveEditorRef} showTable={true} editor={false} />
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false}/> <PointEditor
bind:this={pointEditorRef}
onSelectPoint={(point: SavedPoint | null) => {
if (point) {
handlePointSelection(point.id);
} else {
handlePointSelection(-1); // Clear selection
}
}} />

View file

@ -0,0 +1,55 @@
<script lang="ts">
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
Icon,
} from "@sveltestrap/sveltestrap";
let isCollapsed = $state(false);
export const collapsePanel = () => {
isCollapsed = true;
};
export const expandPanel = () => {
isCollapsed = false;
};
export const togglePanel = () => {
isCollapsed = !isCollapsed;
};
</script>
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
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)}>
<b class="card-title mb-0 text-white p-0">Заголовок панели</b>
<Button class="p-0" size="sm" color="primary" onclick={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<Icon name="caret-left-fill" class="text-white" />
{:else}
<Icon name="caret-down-fill" class="text-white" />
{/if}
</Button>
</button>
</CardHeader>
{#if !isCollapsed}
<CardBody>
</CardBody>
{/if}
</Card>

View file

@ -143,14 +143,14 @@
</script> </script>
<div class="map-container" bind:this={mapContainer}> <div class="map-container" bind:this={mapContainer}>
<div class="card coordinates-display"> <!-- <div class="card coordinates-display">
<p class="card-text"> <p class="card-text">
<b>Lat:</b> <b>Lat:</b>
{mouseLat.toFixed(6)}, {mouseLat.toFixed(6)},
<b>Lon:</b> <b>Lon:</b>
{mouseLng.toFixed(6)} {mouseLng.toFixed(6)}
</p> </p>
</div> </div> -->
<slot /> <slot />
{#if map && windData} {#if map && windData}
<WindVisualization {map} {windData} /> <WindVisualization {map} {windData} />

View file

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
export let element: HTMLDivElement | null = null; export let element: HTMLDivElement | null = null;
export let position: 'left' | 'right' = 'left';
export function getElement() { export function getElement() {
return element; return element;
} }
</script> </script>
<div bind:this={element} class="panel-container"> <div bind:this={element} class="panel-container-{position}">
<slot /> <slot />
</div> </div>

View file

@ -1,399 +0,0 @@
<script lang="ts">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
InputGroup,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedPoint } from "$lib/types";
import { SavedPointsStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (p: SavedPoint) => {},
onSelectPoint = (p: SavedPoint) => {},
showTable = false,
point = null,
editor = false,
closeOnSave = false,
closeOnDelete = false,
} = $props();
// Runes
let selectedPoint = $derived<SavedPoint | null>(point);
let newPoint = $state<SavedPoint>({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
let isEditing = $state(editor);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
let closeOnSave_ = $state(closeOnSave);
// Table handler
let table = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 10 }));
let search = $derived(table.createSearch(["name"]));
$effect(() => {
if (showTable) {
getSavedPoints().then((pts) => {
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
});
}
if (editor && point) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
}
});
// On mount, fetch points
onMount(async () => {
if (showTable) {
const pts = await getSavedPoints();
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
}
});
// Modal controls
export function openModal(table_: boolean = false) {
showTable = table_;
isOpen = true;
}
export function openModalAndCreate(
point: SavedPoint | null = null,
coordinates: SavedPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 },
close: boolean = false,
table_: boolean = false,
onSaveCallback: (point: SavedPoint) => void = () => {},
) {
if (point) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
} else {
selectedPoint = null;
newPoint = coordinates || { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
}
showTable = table_;
isOpen = true;
closeOnSave_ = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
if (closeOnSave_ != closeOnSave) {
closeOnSave = closeOnSave_;
}
onClose();
}
function handleEditPoint(point: SavedPoint) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
}
function confirmDeletePoint(point: SavedPoint) {
selectedPoint = point;
isConfirmationVisible = true;
}
function handleDeletePoint(point: SavedPoint | null) {
if (!point) return;
deletePoint(point.id)
.then(() => {
$SavedPointsStore = $SavedPointsStore.filter((p) => p.id !== point.id);
SavedPointsStore.set($SavedPointsStore);
addToast({
header: "Точка удалена",
body: `Точка "${point.name}" успешно удалена.`,
color: "success",
});
if (closeOnDelete) {
closeModal();
}
})
.catch((error) => {
showAlert(`Ошибка при удалении точки: ${error.message}`);
console.error("Ошибка при удалении точки:", error);
});
}
export function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(updatedPoint);
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
} else {
savePoint(newPoint)
.then((savedPoint) => {
$SavedPointsStore = [...$SavedPointsStore, savedPoint];
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка сохранена",
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(savedPoint);
})
.catch((error) => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
console.error("Ошибка при сохранении точки:", error);
});
}
}
export function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
export function hideAlert() {
isAlertVisible = false;
alertText = "";
}
export function resetForm() {
selectedPoint = null;
newPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
hideAlert();
}
</script>
<Modal
{isOpen}
toggle={closeModal}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">
{isEditing ? "Редактирование точки" : showTable ? "Сохраненные точки" : "Добавить новую точку"}
</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body">
{#if showTable}
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.set()} />
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 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);"
onclick={() => {
search.value = "";
search.set();
}}
disabled={!search.value}>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<div bind:this={table.element} class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название точки</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit">Действия</th>
</tr>
</thead>
<tbody>
{#each table.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.lat} °</td>
<td>{row.lon} °</td>
<td>{row.alt} м</td>
<td class="fit">
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
<Button
color="success"
size="sm"
onclick={() => {
onSelectPoint(row);
closeModal();
}}>
</Button>
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
<Icon name="pencil" />
</Button>
<Button color="danger" size="sm" onclick={() => confirmDeletePoint(row)}>
<Icon name="trash" />
</Button>
</InputGroup>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => table.setPage("previous")} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => table.setPage("next")} />
</PaginationItem>
</Pagination>
{/if}
{#if showTable && (isEditing || newPoint.lat || newPoint.lon)}<hr />{/if}
<!-- Form for adding/editing points -->
<div>
{#if showTable}
<h5>{isEditing ? "Редактирование точки" : "Добавить новую точку"}</h5>
{/if}
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form
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 />
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
<Label for="lat" class="small">Широта:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lat"
bind:value={newPoint.lat}
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="lon" class="small">Долгота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lon"
bind:value={newPoint.lon}
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="alt" class="small">Высота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="alt"
bind:value={newPoint.alt}
required />
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
<div class="d-grid gap-2 d-md-flex">
<Button type="submit" color="success" size="sm">
{isEditing ? "Обновить точку" : "Сохранить точку"}
</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
{/if}
<span class="flex-grow-1"></span>
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => confirmDeletePoint(newPoint)}>
Удалить точку
</Button>
{:else}
<Button
color="secondary"
size="sm"
type="button"
onclick={() => {
resetForm();
closeModal();
}}>
Закрыть без сохранения
</Button>
{/if}
</div>
</form>
</div>
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title="Подтвердите удаление"
confirmText="Удалить"
cancelText="Отмена"
confirmVariant="danger"
onconfirm={() => {
isConfirmationVisible = false;
handleDeletePoint(selectedPoint);
}}
oncancel={() => {
isConfirmationVisible = false;
}}>
<p>Вы уверены, что хотите удалить эту точку?</p>
</ConfirmationPrompt>

View file

@ -16,10 +16,10 @@
import type { SavedScenario } from "$lib/types"; import type { SavedScenario } from "$lib/types";
import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios"; import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios";
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioStore } from "$lib/stores"; import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioStore } from "$lib/stores";
import SelectSearchable from "$lib/components/SelectSearchable.svelte"; import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { addToast } from "./Toast.svelte"; import { addToast } from "./ui/Toast.svelte";
import ScenarioEditor from "./ScenarioEditor.svelte"; import ScenarioEditor from "$lib/components/editors/ScenarioEditor.svelte";
let isCollapsed = $state(false); let isCollapsed = $state(false);
let scenarioUnsaved = $derived(checkScenarioUnsaved()); let scenarioUnsaved = $derived(checkScenarioUnsaved());
@ -187,12 +187,13 @@
bind:selected={selectedScenarioId} bind:selected={selectedScenarioId}
placeholder="Новый сценарий..." placeholder="Новый сценарий..."
searchPlaceholder="Поиск сценариев..." searchPlaceholder="Поиск сценариев..."
on:change={() => { clearable={true}
onChange={() => {
if (!scenarioUnsaved) { if (!scenarioUnsaved) {
handleApplySelectedScenario(false); handleApplySelectedScenario(false);
} }
}} /> }} />
<Button <!-- <Button
size="sm" size="sm"
color="white" color="white"
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center" class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
@ -202,7 +203,7 @@
}} }}
disabled={selectedScenarioId === -1}> disabled={selectedScenarioId === -1}>
<Icon name="x" style="font-size: 16px;" /> <Icon name="x" style="font-size: 16px;" />
</Button> </Button> -->
</div> </div>
<Button <Button
color="success" color="success"

View file

@ -91,33 +91,6 @@
return heatData; return heatData;
}; };
// Создание тепловой карты
const createHeatLayer = (data) => {
if (!data || data.length === 0) {
console.warn("No valid heat data provided");
return null;
}
try {
return L.heatLayer(data, {
radius: 8, // Увеличьте радиус для глобальной карты
blur: 20,
// maxZoom: 10,
minOpacity: 0.7,
gradient: {
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);
return null;
}
};
// Обновление слоев // Обновление слоев
const updateLayers = () => { const updateLayers = () => {
if (!map || !windData) return; if (!map || !windData) return;
@ -140,17 +113,6 @@
}).addTo(map); }).addTo(map);
} }
// Создаем тепловую карту
if (showHeatmap) {
const heatData = prepareHeatData(windData);
heatLayer = createHeatLayer(heatData);
if (heatLayer) {
heatLayer.addTo(map);
createLegend(Math.max(...heatData.map((point) => point[2])));
}
}
// Обновляем контроль слоев // Обновляем контроль слоев
updateLayerControl(); updateLayerControl();
}; };
@ -170,43 +132,43 @@
overlays["Тепловая карта"] = heatLayer; overlays["Тепловая карта"] = heatLayer;
} }
layerControl = L.control // layerControl = L.control
.layers(null, overlays, { // .layers(null, overlays, {
collapsed: false, // collapsed: false,
position: "topright", // position: "topright",
}) // })
.addTo(map); // .addTo(map);
}; };
// Создание легенды с учетом максимальной скорости // Создание легенды с учетом максимальной скорости
const createLegend = (maxSpeed) => { // const createLegend = (maxSpeed) => {
if (!map) return; // if (!map) return;
legend = L.control({ position: "bottomright" }); // legend = L.control({ position: "bottomright" });
legend.onAdd = () => { // legend.onAdd = () => {
const div = L.DomUtil.create("div", "wind-heat-legend"); // const div = L.DomUtil.create("div", "wind-heat-legend");
div.innerHTML = ` // div.innerHTML = `
<h4>Wind Speed (m/s)</h4> // <h4>Wind Speed (m/s)</h4>
<div class="legend-scale"> // <div class="legend-scale">
<div class="legend-color" style="background: #0000FF;"></div> // <div class="legend-color" style="background: #0000FF;"></div>
<div class="legend-color" style="background: #00FFFF;"></div> // <div class="legend-color" style="background: #00FFFF;"></div>
<div class="legend-color" style="background: #00FF00;"></div> // <div class="legend-color" style="background: #00FF00;"></div>
<div class="legend-color" style="background: #FFFF00;"></div> // <div class="legend-color" style="background: #FFFF00;"></div>
<div class="legend-color" style="background: #FF0000;"></div> // <div class="legend-color" style="background: #FF0000;"></div>
</div> // </div>
<div class="legend-labels"> // <div class="legend-labels">
<span>0</span> // <span>0</span>
<span>${(maxSpeed * 0.25).toFixed(1)}</span> // <span>${(maxSpeed * 0.25).toFixed(1)}</span>
<span>${(maxSpeed * 0.5).toFixed(1)}</span> // <span>${(maxSpeed * 0.5).toFixed(1)}</span>
<span>${(maxSpeed * 0.75).toFixed(1)}</span> // <span>${(maxSpeed * 0.75).toFixed(1)}</span>
<span>${maxSpeed.toFixed(1)}</span> // <span>${maxSpeed.toFixed(1)}</span>
</div> // </div>
`; // `;
return div; // return div;
}; // };
legend.addTo(map); // legend.addTo(map);
}; // };
onMount(() => { onMount(() => {
if (!map) return; if (!map) return;
@ -236,27 +198,19 @@
minBufferReady: -1, minBufferReady: -1,
}, },
}); });
map.addControl(timeDimensionControl); //map.addControl(timeDimensionControl);
// 4. Создание слоев // 4. Создание слоев
const velocityLayer = L.timeDimension.layer // const velocityLayer = L.timeDimension.layer
.windVelocity({ // .windVelocity({
displayValues: true, // displayValues: true,
data: timeData, // data: timeData,
displayOptions: { // displayOptions: {
velocityType: "Wind Speed", // velocityType: "Wind Speed",
position: "bottomleft", // position: "bottomleft",
}, // },
}) // })
.addTo(map); // .addTo(map);
// 5. Тепловая карта (адаптируйте под ваш формат)
const heatLayer = L.timeDimension.layer
.heat({
radius: 15,
data: prepareTimeHeatData(timeData),
})
.addTo(map);
}); });
onDestroy(() => { onDestroy(() => {
@ -273,7 +227,7 @@
} }
</script> </script>
<div class="layer-controls"> <!-- <div class="layer-controls">
<div class="control-group"> <div class="control-group">
<label> <label>
<input type="checkbox" bind:checked={showHeatmap} /> <input type="checkbox" bind:checked={showHeatmap} />
@ -284,7 +238,7 @@
Векторы ветра Векторы ветра
</label> </label>
</div> </div>
</div> </div> -->
<style> <style>
.layer-controls { .layer-controls {

View file

@ -15,13 +15,13 @@
Table, Table,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte"; import { addToast } from "$lib/components/ui/Toast.svelte";
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types"; import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
import { SavedFlightProfilesStore } from "$lib/stores"; import { SavedFlightProfilesStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte"; import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
// import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves"; // import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves";
import EditableCell from "./EditableCell.svelte"; import EditableCell from "$lib/components/ui/EditableCell.svelte";
import CurveChart from "./CurveChart.svelte"; import CurveChart from "$lib/components/CurveChart.svelte";
// Mock API functions for now // Mock API functions for now
const getSavedCurves = async (): Promise<SavedFlightProfile[]> => { const getSavedCurves = async (): Promise<SavedFlightProfile[]> => {

View file

@ -0,0 +1,366 @@
<script module lang="ts">
export type EditorConfig<T> = {
showTable?: boolean;
closeOnSave?: boolean;
closeOnDelete?: boolean;
searchBy?: (keyof T)[];
labels?: {
item?: string;
itemGenitive?: string;
items?: string;
add?: string;
edit?: string;
save?: string;
update?: string;
delete?: string;
cancel?: string;
close?: string;
searchPlaceholder?: string;
};
};
export type EditorApi<T> = {
save: (item: T) => Promise<T>;
update: (item: T) => Promise<T>;
delete: (item: T) => Promise<void>;
};
</script>
<script lang="ts" generics="T extends { id: number; name: string }">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
Input,
InputGroup,
} from "@sveltestrap/sveltestrap";
import { addToast } from "$lib/components/ui/Toast.svelte";
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
import type { Snippet } from "svelte";
type Renderable = (props: any) => Snippet;
let {
// Control
isOpen = $bindable(false),
items = $bindable([] as T[]),
// Snippets
tableHeader,
tableRow,
formFields,
// Configuration
itemFactory,
api,
config = {
showTable: false,
closeOnSave: false,
closeOnDelete: false,
searchBy: ["name"],
labels: {
item: "элемент",
itemGenitive: "элемента",
items: "элементы",
add: "Добавить",
edit: "Редактировать",
save: "Сохранить",
update: "Обновить",
delete: "Удалить",
cancel: "Отмена",
close: "Закрыть",
searchPlaceholder: "Поиск...",
},
},
// Callbacks
onClose = () => {},
onSave = (item: T) => {},
onSelect = (item: T) => {},
} = $props<{
isOpen?: boolean;
items?: T[];
itemFactory: () => T;
api: EditorApi<T>;
config?: EditorConfig<T>;
onClose?: () => void;
onSave?: (item: T) => void;
onSelect?: (item: T) => void;
tableHeader: Renderable;
tableRow: Renderable;
formFields: Renderable;
}>();
let isEditing = $state(false);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let isTableVisible = $derived(config.showTable);
let alertText = $state("");
let selectedItem = $state<T | null>(null);
let currentItem = $state<T>(itemFactory());
const table = $derived(new TableHandler(items, { rowsPerPage: 10 }));
const search = $derived(table.createSearch(config.searchBy));
$effect(() => {
table.setRows(items);
});
export function open(item: T | null = null, showTable: boolean = config.showTable) {
if (item) {
handleEdit(item);
} else {
resetForm(false);
}
isOpen = true;
isTableVisible = showTable;
}
function close() {
isOpen = false;
onClose();
}
function handleEdit(item: T) {
selectedItem = item;
currentItem = { ...item };
isEditing = true;
}
function resetForm(clearSelection = true) {
if (clearSelection) {
selectedItem = null;
}
currentItem = itemFactory();
isEditing = false;
hideAlert();
}
function handleSelect(item: T) {
onSelect(item);
close();
}
async function handleSave() {
try {
if (isEditing && selectedItem) {
const updatedItem = await api.update(currentItem);
items = items.map((i: T) => (i.id === updatedItem.id ? updatedItem : i));
showToast("обновлен(а)", updatedItem.name);
onSave(updatedItem);
} else {
const savedItem = await api.save(currentItem);
items = [...items, savedItem];
showToast("сохранен(а)", savedItem.name);
onSave(savedItem);
}
resetForm();
if (config.closeOnSave) {
close();
}
} catch (error: any) {
showAlert(`Ошибка: ${error.message}`);
}
}
function confirmDelete(item: T) {
selectedItem = item;
isConfirmationVisible = true;
}
async function handleDelete() {
if (!selectedItem) return;
try {
await api.delete(selectedItem);
items = items.filter((i: T) => i.id !== selectedItem!.id);
showToast("удален(а)", selectedItem.name);
if (config.closeOnDelete) {
close();
}
} catch (error: any) {
showAlert(`Ошибка при удалении: ${error.message}`);
} finally {
isConfirmationVisible = false;
resetForm();
}
}
function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
function hideAlert() {
isAlertVisible = false;
alertText = "";
}
function showToast(action: string, name: string) {
addToast({
header: `${config.labels.item.charAt(0).toUpperCase() + config.labels.item.slice(1)} ${action}`,
body: `${config.labels.item.charAt(0).toUpperCase() + config.labels.item.slice(1)} "${name}" успешно ${action}.`,
color: "success",
});
}
const modalTitle = $derived(
isEditing
? `${config.labels.edit} ${config.labels.itemGenitive}`
: config.showTable
? `Сохраненные ${config.labels.items}`
: `${config.labels.add} ${config.labels.itemGenitive}`,
);
const formTitle = $derived(
isEditing
? `${config.labels.edit} ${config.labels.itemGenitive}`
: `${config.labels.add} новый ${config.labels.item}`,
);
const submitButtonText = $derived(isEditing ? config.labels.update : config.labels.save);
</script>
<Modal
{isOpen}
toggle={close}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">{modalTitle}</h5>
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
</div>
<div class="modal-body">
{#if isTableVisible}
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder={config.labels.searchPlaceholder}
bind:value={search.value}
oninput={() => search.set()} />
{#if search.value}
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 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);"
onclick={() => {
search.value = "";
search.set();
}}>
<Icon name="x" style="font-size: 16px;" />
</Button>
{/if}
</div>
<div bind:this={table.element} class="table-responsive">
<table class="table table-sm">
<thead>
{@render tableHeader()}
</thead>
<tbody>
{#each table.rows as row (row.id)}
<tr>
{@render tableRow({ row })}
<td class="fit">
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
<Button
size="sm"
color="secondary"
onclick={() => handleSelect(row as T)}
class="p-0 border-0 bg-transparent text-success px-1"
style="cursor: pointer; font-size: initial;">
<Icon name="check-lg" />
</Button>
<Button
size="sm"
color="primary"
onclick={() => handleEdit(row as T)}
class="p-0 border-0 bg-transparent text-primary px-1"
style="cursor: pointer; font-size: initial;">
<Icon name="pencil" />
</Button>
<Button
size="sm"
color="danger"
on:click={() => confirmDelete(row as T)}
class="p-0 border-0 bg-transparent text-danger px-1"
style="cursor: pointer; font-size: initial;">
<Icon name="trash" />
</Button>
</InputGroup>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => table.setPage("previous")} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => table.setPage("next")} />
</PaginationItem>
</Pagination>
{/if}
{#if isTableVisible && (isEditing || currentItem.id)}<hr />{/if}
<!-- Form for adding/editing -->
<div>
{#if isTableVisible}
<h5>{formTitle}</h5>
{/if}
<Alert color="danger" isOpen={isAlertVisible} toggle={hideAlert} fade={false} class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}>
{@render formFields({ item: currentItem })}
<div class="d-grid gap-2 d-md-flex mt-3">
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
{config.labels.cancel}
</Button>
{/if}
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
{config.labels.delete}
</Button>
{:else}
<Button color="secondary" size="sm" type="button" onclick={close}>
{config.labels.close}
</Button>
{/if}
</div>
</form>
</div>
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title={`Подтвердите удаление ${config.labels.itemGenitive}`}
confirmText={config.labels.delete}
cancelText={config.labels.cancel}
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => (isConfirmationVisible = false)}>
<p>Вы уверены, что хотите удалить {config.labels.item} "{selectedItem?.name}"?</p>
</ConfirmationPrompt>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { onMount, type Snippet } from "svelte";
import { FormGroup, Label, Input } from "@sveltestrap/sveltestrap";
import type { SavedPoint } from "$lib/types";
import { SavedPointsStore } from "$lib/stores";
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
import GenericEditor from "./GenericEditor.svelte";
import type { EditorConfig, EditorApi } from "./GenericEditor.svelte";
type $$Props = {
isOpen?: boolean;
onClose?: () => void;
onSave?: (p: SavedPoint) => void;
onSelectPoint?: (p: SavedPoint) => void;
point?: SavedPoint | null;
editor?: boolean;
config?: Partial<EditorConfig<SavedPoint>>;
api?: Partial<EditorApi<SavedPoint>>;
};
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (p: SavedPoint) => {},
onSelectPoint = (p: SavedPoint) => {},
point = null,
editor = false,
config: propConfig = {},
api: propApi = {},
} = $props();
// State
let points = $state<SavedPoint[]>([]);
let editorRef: GenericEditor<SavedPoint> | null = $state(null);
let config: EditorConfig<SavedPoint> = $state<EditorConfig<SavedPoint>>({
showTable: true,
closeOnSave: false,
closeOnDelete: false,
searchBy: ["name"],
labels: {
item: "точка",
itemGenitive: "точки",
items: "точки",
add: "Добавить",
edit: "Редактирование",
save: "Сохранить",
update: "Обновить",
delete: "Удалить",
cancel: "Отмена",
close: "Закрыть без сохранения",
searchPlaceholder: "Поиск по названию...",
},
});
let api: EditorApi<SavedPoint> = $state<EditorApi<SavedPoint>>({
save: savePoint,
update: updatePoint,
delete: (p: SavedPoint) => deletePoint(p.id),
});
// Load points from store or fetch from API
onMount(async () => {
if ($SavedPointsStore.length > 0) {
points = $SavedPointsStore;
} else if (config.showTable) {
const pts = await getSavedPoints();
points = pts;
SavedPointsStore.set(pts);
}
});
// Sync local state with store changes
$effect(() => {
points = $SavedPointsStore;
});
// Sync store with local state changes
$effect(() => {
SavedPointsStore.set(points);
});
// Open editor in edit mode if point and editor props are set
$effect(() => {
if (editor && point && editorRef) {
editorRef.open(point);
}
});
// Factory function for creating a new point
const pointFactory = (): SavedPoint => ({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
// Public method to control the editor
export function open(item: SavedPoint | null = null, showTable = config.showTable) {
editorRef?.open(item, showTable);
}
</script>
<GenericEditor
bind:this={editorRef}
bind:isOpen
bind:items={points}
onClose={() => onClose()}
onSave={(p) => onSave(p)}
onSelect={(p) => onSelectPoint(p)}
itemFactory={pointFactory}
{api}
{config}>
{#snippet tableHeader()}
<tr>
<th>Название точки</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit">Действия</th>
</tr>
{/snippet}
{#snippet tableRow({ row })}
<td>{row.name}</td>
<td>{row.lat.toFixed(5)} °</td>
<td>{row.lon.toFixed(5)} °</td>
<td>{row.alt} м</td>
{/snippet}
{#snippet formFields({ item })}
<div class="mb-2">
<Label for="name" class="small">Название точки:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={item.name} required />
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
<Label for="lat" class="small">Широта:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lat"
bind:value={item.lat}
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="lon" class="small">Долгота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lon"
bind:value={item.lon}
required />
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="alt" class="small">Высота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="alt"
bind:value={item.alt}
required />
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
{/snippet}
</GenericEditor>

View file

@ -13,10 +13,10 @@
PaginationLink, PaginationLink,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte"; import { addToast } from "$lib/components/ui/Toast.svelte";
import type { SavedScenario } from "$lib/types"; import type { SavedScenario } from "$lib/types";
import { FlightParametersStore, SavedScenarioStore } from "$lib/stores"; import { FlightParametersStore, SavedScenarioStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte"; import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios"; import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios";
// Props // Props

View file

@ -0,0 +1,37 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string | undefined;
label?: string;
children?: () => any;
}
let { id, label = "", class: className = "", children, ...restProps }: Props = $props();
</script>
<div {id} class="spoiler-group {className}" {...restProps}>
<button class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header">
<div class="border-top" style="width: 10px;"></div>
<span class="small text-nowrap ms-2">{label}</span>
<div class="flex-fill border-top ms-2"></div>
</button>
<div class="p-2 border border-top-0 spoiler-content">
{@render children?.()}
</div>
</div>
<style>
.spoiler-header {
margin-bottom: -0.75em;
}
.spoiler-content {
padding-top: 0.75em !important;
}
.spoiler-icon {
line-height: 1;
padding-bottom: 0.1em;
}
</style>

View file

@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { on } from 'svelte/events';
interface Props extends HTMLAttributes<HTMLDivElement> { interface Props extends HTMLAttributes<HTMLDivElement> {
options?: { value: any; label:string }[]; options?: { value: any; label:string }[];
@ -12,8 +10,10 @@
disabled?: boolean; disabled?: boolean;
class?: string; class?: string;
onChange?: (value: any) => void; onChange?: (value: any) => void;
clearable?: boolean;
} }
let { let {
id = 'select-searchable', id = 'select-searchable',
options = [], options = [],
@ -22,15 +22,16 @@
searchPlaceholder = 'Search...', searchPlaceholder = 'Search...',
disabled = false, disabled = false,
class: className = '', class: className = '',
onChange,
clearable = false,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const dispatch = createEventDispatcher<{ change: any }>();
let isOpen = $state(false); let isOpen = $state(false);
let searchTerm = $state(''); let searchTerm = $state('');
let dropdownElement = $state<HTMLElement>(); let dropdownElement = $state<HTMLElement>();
let selectElement = $state<HTMLElement>(); let selectElement = $state<HTMLElement>();
let searchInputElement = $state<HTMLInputElement>();
let dropdownStyle = $state(''); let dropdownStyle = $state('');
let filteredOptions = $derived( let filteredOptions = $derived(
@ -44,16 +45,30 @@
); );
onMount(() => { onMount(() => {
// Update dropdown position on mount
updateDropdownPosition(); updateDropdownPosition();
}); });
function updateDropdownPosition() { function updateDropdownPosition() {
if (!selectElement) return; if (!selectElement || !dropdownElement) return;
const rect = selectElement.getBoundingClientRect(); const rect = selectElement.getBoundingClientRect();
const dropdownHeight = dropdownElement.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
let top, bottom;
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
top = `${rect.bottom}px`;
bottom = 'auto';
} else {
top = 'auto';
bottom = `${window.innerHeight - rect.top}px`;
}
dropdownStyle = ` dropdownStyle = `
position: fixed; position: fixed;
top: ${rect.bottom}px; top: ${top};
bottom: ${bottom};
left: ${rect.left}px; left: ${rect.left}px;
min-width: ${rect.width}px; min-width: ${rect.width}px;
`; `;
@ -64,8 +79,10 @@
isOpen = !isOpen; isOpen = !isOpen;
if (isOpen) { if (isOpen) {
searchTerm = ''; searchTerm = '';
// Use next tick to ensure the element is rendered before getting its position Promise.resolve().then(() => {
Promise.resolve().then(updateDropdownPosition); updateDropdownPosition();
searchInputElement?.focus();
});
} }
} }
} }
@ -74,10 +91,10 @@
selected = option.value; selected = option.value;
isOpen = false; isOpen = false;
searchTerm = ''; searchTerm = '';
dispatch('change', selected); if (onChange) {
if (restProps.onChange) { onChange(selected);
restProps.onChange(selected);
} }
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@ -86,6 +103,14 @@
} }
} }
function clearSelection(e: Event) {
e.stopPropagation();
selected = null;
if (onChange) {
onChange(null);
}
}
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
window.addEventListener('scroll', updateDropdownPosition, true); window.addEventListener('scroll', updateDropdownPosition, true);
@ -96,6 +121,12 @@
window.removeEventListener('resize', updateDropdownPosition); window.removeEventListener('resize', updateDropdownPosition);
}; };
}); });
$effect(() => {
if (isOpen && dropdownElement) {
updateDropdownPosition();
}
});
</script> </script>
<svelte:window onclick={handleClickOutside} /> <svelte:window onclick={handleClickOutside} />
@ -116,16 +147,28 @@
> >
<span class:text-muted={!selected}>{selectedLabel || placeholder}</span> <span class:text-muted={!selected}>{selectedLabel || placeholder}</span>
{#if clearable && selected != null}
<button
type="button"
class="clear-btn"
tabindex="-1"
aria-label="Clear selection"
onclick={clearSelection}
>
&#10005;
</button>
{/if}
{#if isOpen} {#if isOpen}
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}> <div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
<div class="p-2"> <div class="p-2">
<input <input
bind:this={searchInputElement}
type="text" type="text"
class="form-control form-control-sm" class="form-control form-control-sm"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
bind:value={searchTerm} bind:value={searchTerm}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
autofocus
/> />
</div> </div>
@ -161,13 +204,33 @@
} }
.dropdown-menu { .dropdown-menu {
/* position is now set dynamically */
z-index: 1000; z-index: 1000;
width: max-content; /* Allow dropdown to grow with its content */ width: max-content;
} }
.options-list { .options-list {
max-height: 40vh; max-height: 40vh;
overflow-y: auto; overflow-y: auto;
} }
.clear-btn {
position: absolute;
top: 50%;
right: 2rem;
transform: translateY(-50%);
background: none;
border: none;
color: #2a2a2a;
font-size: 1rem;
cursor: pointer;
z-index: 2;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:focus {
outline: none;
}
</style> </style>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string | undefined;
label?: string;
expanded?: boolean;
children?: () => any;
}
let {
id,
label = "",
expanded = $bindable(false),
class: className = "",
children,
...restProps
}: Props = $props();
</script>
<div {id} class="spoiler-group {className}" {...restProps}>
<button
class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}>
<span class="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon" class:expanded>
{expanded ? "" : "+"}
</span>
<span class="small text-nowrap ms-1">{label}</span>
<div class="flex-fill border-top ms-1"></div>
</button>
{#if expanded}
<div class="p-2 border border-top-0 spoiler-content">
{@render children?.()}
</div>
{:else}
<div style="padding-top: 0.75em;" class={className}></div>
{/if}
</div>
<style>
.spoiler-header {
margin-bottom: -0.75em;
}
.spoiler-content {
padding-top: 0.75em !important;
}
.spoiler-icon {
line-height: 1;
padding-bottom: 0.1em;
}
.btn:hover .spoiler-icon {
color: var(--bs-dark) !important;
}
</style>

View file

@ -7,14 +7,12 @@
label: string; label: string;
}; };
/** An array of tab objects to display. */
export let tabs: Tab[] = []; export let tabs: Tab[] = [];
/** The id of the currently active tab. Use `bind:activeTab` for two-way binding. */
export let activeTab: string; export let activeTab: string;
export let justify: 'start' | 'center' | 'end' = 'start';
</script> </script>
<div class="d-flex justify-content-start mb-1 gap-1"> <div class="d-flex justify-content-{justify} mb-1 gap-1">
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button <button
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1" class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"

View file

@ -1,39 +1,37 @@
export function distHaversine( export function distHaversine(
p1: { lat: number; lng: number }, p1: { lat: number; lng: number },
p2: { lat: number; lng: number }, p2: { lat: number; lng: number },
precision?: number precision?: number
): number { ): number {
const R = 6371; // Earth's mean radius in km const R = 6371; // Earth's mean radius in km
const rad = (x: number): number => (x * Math.PI) / 180; const rad = (x: number): number => (x * Math.PI) / 180;
const dLat = rad(p2.lat - p1.lat); const dLat = rad(p2.lat - p1.lat);
const dLong = rad(p2.lng - p1.lng); const dLong = rad(p2.lng - p1.lng);
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(rad(p1.lat)) * Math.cos(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.sin(dLong / 2) * Math.sin(dLong / 2);
Math.cos(rad(p2.lat)) *
Math.sin(dLong / 2) *
Math.sin(dLong / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = R * c; const d = R * c;
return precision ? parseFloat(d.toFixed(precision)) : d; return precision ? parseFloat(d.toFixed(precision)) : d;
} }
export function bearingHaversine( export function bearingHaversine(p1: { lat: number; lng: number }, p2: { lat: number; lng: number }): number {
p1: { lat: number; lng: number }, const rad = (x: number): number => (x * Math.PI) / 180;
p2: { lat: number; lng: number }
): number {
const rad = (x: number): number => (x * Math.PI) / 180;
const dLong = rad(p2.lng - p1.lng); const dLong = rad(p2.lng - p1.lng);
const y = Math.sin(dLong) * Math.cos(rad(p2.lat)); const y = Math.sin(dLong) * Math.cos(rad(p2.lat));
const x = const x =
Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) - Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) - Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong);
Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong);
return (Math.atan2(y, x) * 180) / Math.PI; return (Math.atan2(y, x) * 180) / Math.PI;
}
export function toFixedNumber(num: number, digits: number, base: number = 10): number {
const pow = Math.pow(base ?? 10, digits);
return Math.round(num * pow) / pow;
} }

View file

@ -4,19 +4,21 @@
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import PanelContainer from "$lib/components/PanelContainer.svelte"; import PanelContainer from "$lib/components/PanelContainer.svelte";
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte"; import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
import TabComponent from "$lib/components/TabComponent.svelte"; import TabComponent from "$lib/components/ui/TabComponent.svelte";
import PointEditor from "$lib/components/PointEditor.svelte"; import PointEditor from "$lib/components/editors/PointEditor.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { PredictionStore } from "$lib/stores"; import { PredictionStore } from "$lib/stores";
import { addToast, removeToast } from "$lib/components/Toast.svelte"; import { addToast, removeToast } from "$lib/components/ui/Toast.svelte";
import ToastContainer from '$lib/components/Toast.svelte'; import ToastContainer from '$lib/components/ui/Toast.svelte';
import L, { point } from "leaflet"; import L, { point } from "leaflet";
import GenericPanel from "$lib/components/GenericPanel.svelte";
let map: Map | null = null; let map: Map | null = null;
let panelContainer: PanelContainer | null = null; let panelContainer: PanelContainer | null = null;
let controlPanel: ControlPanel | null = null; let controlPanel: ControlPanel | null = null;
let selectionToastId: string | null = null; let selectionToastId: string | null = null;
let activeTab: 'control' | 'scenario' | 'settings' | 'about' = 'scenario'; let activeTabLeft: 'control' | 'scenario' | 'about' = 'scenario';
let activeTabRight: 'layers' | 'settings' | 'results' = 'results';
onMount(() => { onMount(() => {
PredictionStore.subscribe((data) => { PredictionStore.subscribe((data) => {
@ -73,29 +75,47 @@
<Navbar /> <Navbar />
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar --> <div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}> <Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
<PanelContainer bind:this={panelContainer} > <PanelContainer bind:this={panelContainer} position="left">
<TabComponent <TabComponent
tabs={[ tabs={[
{ id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' }, { id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' },
{ id: 'control', icon: 'sliders', label: 'Условия' }, { id: 'control', icon: 'sliders', label: 'Условия' },
{ id: 'settings', icon: 'gear', label: 'Настройки' },
{ id: 'about', icon: 'info-circle', label: 'О проекте' } { id: 'about', icon: 'info-circle', label: 'О проекте' }
]} ]}
bind:activeTab bind:activeTab={activeTabLeft}
/> />
<div> <div>
{#if activeTab === 'control'} {#if activeTabLeft === 'control'}
<ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} /> <ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} />
{:else if activeTab === 'scenario'} {:else if activeTabLeft === 'scenario'}
<ScenarioPanel /> <ScenarioPanel />
{:else if activeTab === 'settings'} {:else if activeTabLeft === 'about'}
<!-- <SettingsPanel /> -->
{:else if activeTab === 'about'}
<!-- <AboutPanel /> --> <!-- <AboutPanel /> -->
{/if} {/if}
</div> </div>
</PanelContainer> </PanelContainer>
<PanelContainer position="right">
<TabComponent
justify="end"
tabs={[
{ id: 'results', icon: 'bar-chart-line', label: 'Результаты' },
{ id: 'layers', icon: 'layers', label: 'Слои' },
{ id: 'settings', icon: 'gear', label: 'Настройки' },
]}
bind:activeTab={activeTabRight}
/>
<div>
{#if activeTabRight === 'results'}
<GenericPanel />
{:else if activeTabRight === 'layers'}
<GenericPanel />
{:else if activeTabLeft === 'settings'}
<!-- <SettingsPanel /> -->
{/if}
</div>
</PanelContainer>
<ToastContainer /> <ToastContainer />
</Map> </Map>
</main> </main>

View file

@ -16,9 +16,9 @@
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import Footer from "$lib/components/Footer.svelte"; import Footer from "$lib/components/Footer.svelte";
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte"; import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
import PointEditor from "$lib/components/PointEditor.svelte"; import PointEditor from "$lib/components/editors/PointEditor.svelte";
import ToastContainer from "$lib/components/Toast.svelte"; import ToastContainer from "$lib/components/ui/Toast.svelte";
import { addToast } from "$lib/components/Toast.svelte"; import { addToast } from "$lib/components/ui/Toast.svelte";
// TODO: Implement these imports // TODO: Implement these imports
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores"; import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores";

View file

@ -75,7 +75,7 @@
white-space: nowrap; white-space: nowrap;
} }
.panel-container { .panel-container-left {
position: absolute; position: absolute;
top: var(--panel-top); top: var(--panel-top);
left: var(--panel-left); left: var(--panel-left);
@ -86,6 +86,17 @@
z-index: 1001; z-index: 1001;
} }
.panel-container-right {
position: absolute;
top: var(--panel-top);
right: var(--panel-left);
width: 23rem;
max-height: 90vh;
max-width: calc(100vw - var(--panel-left) - var(--panel-left));
overflow-y: auto;
z-index: 1001;
}
.leaflet-bar { .leaflet-bar {
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important; border-radius: var(--bs-border-radius) !important;
@ -132,6 +143,10 @@
filter: brightness(0.6); filter: brightness(0.6);
} }
.dropdown-toggle-standalone::after {
margin-left: 0;
}
@media (max-width: 767.98px) @media (max-width: 767.98px)
{ {
.coordinates-display { .coordinates-display {