Refactoring of various stuff
big mess, I don't remember what I was trying to accomplish there
This commit is contained in:
parent
ffb27c2e0a
commit
8e3dfa54f9
22 changed files with 1083 additions and 647 deletions
|
|
@ -46,12 +46,16 @@
|
|||
InputGroup,
|
||||
InputGroupText,
|
||||
Label,
|
||||
Dropdown,
|
||||
DropdownToggle,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { getSavedPoints, updatePoint } from "$lib/api/points";
|
||||
import { addToast } from "$lib/components/Toast.svelte";
|
||||
import PointEditor from "$lib/components/PointEditor.svelte";
|
||||
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
|
||||
import { getForecast } from "$lib/prediction";
|
||||
import {
|
||||
FlightParametersStore,
|
||||
|
|
@ -61,7 +65,10 @@
|
|||
flightParametersDefaults,
|
||||
} from "$lib/stores";
|
||||
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
|
||||
interface Props {
|
||||
|
|
@ -75,6 +82,9 @@
|
|||
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
|
||||
let selectedPointId = $state($FlightParametersStore.start_point || -1);
|
||||
|
||||
let ascentProfile = $state("standard");
|
||||
let descentProfile = $state("standard");
|
||||
|
||||
// Component References
|
||||
let pointEditorRef: PointEditor | null = null;
|
||||
let curveEditorRef: CurveEditor | null = null;
|
||||
|
|
@ -159,23 +169,14 @@
|
|||
});
|
||||
} else {
|
||||
// Create new point
|
||||
pointEditorRef?.openModalAndCreate(
|
||||
null,
|
||||
{
|
||||
id: 0,
|
||||
pointEditorRef?.open({
|
||||
id: 0, // Assuming 0 or a negative number indicates a new point
|
||||
name: `New Point ${new Date().toLocaleString()}`,
|
||||
lat: $FlightParametersStore.launch_latitude,
|
||||
lon: $FlightParametersStore.launch_longitude,
|
||||
alt: $FlightParametersStore.launch_altitude,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
(savedPoint) => {
|
||||
if (savedPoint) {
|
||||
handlePointSelection(savedPoint.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
// The onSave callback is handled by the onSelectPoint prop on the component
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,8 +199,8 @@
|
|||
|
||||
// Public API
|
||||
export function updateLaunchPosition(lat: number, lng: number) {
|
||||
$FlightParametersStore.launch_latitude = lat;
|
||||
$FlightParametersStore.launch_longitude = lng;
|
||||
$FlightParametersStore.launch_latitude = toFixedNumber(lat, 6);
|
||||
$FlightParametersStore.launch_longitude = toFixedNumber(lng, 6);
|
||||
}
|
||||
|
||||
export function loadFlightParameters(params: FlightParameters) {
|
||||
|
|
@ -260,7 +261,6 @@
|
|||
<FormGroup spacing="mb-2">
|
||||
<Label for="cp-start-point" class="form-label">Точка старта:</Label>
|
||||
<InputGroup size="sm">
|
||||
<div class="position-relative flex-grow-1">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="cp-start-point"
|
||||
|
|
@ -271,44 +271,17 @@
|
|||
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
|
||||
}))}
|
||||
placeholder="Новая точка..."
|
||||
clearable={true}
|
||||
searchPlaceholder="Поиск по точкам..." />
|
||||
{#if selectedPointId !== -1}
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
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">
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
class="flex-fill"
|
||||
size="sm"
|
||||
onclick={() => pointEditorRef?.openModal(true)}
|
||||
onclick={() => pointEditorRef?.open()}
|
||||
title="Открыть список точек">
|
||||
Все точки
|
||||
<Icon name="journal-bookmark-fill" />
|
||||
<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>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
|
||||
|
|
@ -332,6 +305,35 @@
|
|||
</InputGroup>
|
||||
</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">
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
|
||||
|
|
@ -371,19 +373,120 @@
|
|||
</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">
|
||||
<SpoilerGroup label="Профили подъема и спуска" class="mb-2">
|
||||
<Label class="form-label mb-0">Стадия подъема:</Label>
|
||||
<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" />
|
||||
</Button>
|
||||
</SpoilerGroup>
|
||||
{/if}
|
||||
|
||||
<div class="d-grid gap-1">
|
||||
<Button size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
|
||||
<div class="d-flex">
|
||||
<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>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</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
|
||||
}
|
||||
}} />
|
||||
|
|
|
|||
55
src/lib/components/GenericPanel.svelte
Normal file
55
src/lib/components/GenericPanel.svelte
Normal 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>
|
||||
|
|
@ -143,14 +143,14 @@
|
|||
</script>
|
||||
|
||||
<div class="map-container" bind:this={mapContainer}>
|
||||
<div class="card coordinates-display">
|
||||
<!-- <div class="card coordinates-display">
|
||||
<p class="card-text">
|
||||
<b>Lat:</b>
|
||||
{mouseLat.toFixed(6)},
|
||||
<b>Lon:</b>
|
||||
{mouseLng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<slot />
|
||||
{#if map && windData}
|
||||
<WindVisualization {map} {windData} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
export let element: HTMLDivElement | null = null;
|
||||
export let position: 'left' | 'right' = 'left';
|
||||
|
||||
export function getElement() {
|
||||
return element;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="panel-container">
|
||||
<div bind:this={element} class="panel-container-{position}">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -16,10 +16,10 @@
|
|||
import type { SavedScenario } from "$lib/types";
|
||||
import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios";
|
||||
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 { addToast } from "./Toast.svelte";
|
||||
import ScenarioEditor from "./ScenarioEditor.svelte";
|
||||
import { addToast } from "./ui/Toast.svelte";
|
||||
import ScenarioEditor from "$lib/components/editors/ScenarioEditor.svelte";
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
let scenarioUnsaved = $derived(checkScenarioUnsaved());
|
||||
|
|
@ -187,12 +187,13 @@
|
|||
bind:selected={selectedScenarioId}
|
||||
placeholder="Новый сценарий..."
|
||||
searchPlaceholder="Поиск сценариев..."
|
||||
on:change={() => {
|
||||
clearable={true}
|
||||
onChange={() => {
|
||||
if (!scenarioUnsaved) {
|
||||
handleApplySelectedScenario(false);
|
||||
}
|
||||
}} />
|
||||
<Button
|
||||
<!-- <Button
|
||||
size="sm"
|
||||
color="white"
|
||||
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}>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
</Button> -->
|
||||
</div>
|
||||
<Button
|
||||
color="success"
|
||||
|
|
|
|||
|
|
@ -91,33 +91,6 @@
|
|||
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 = () => {
|
||||
if (!map || !windData) return;
|
||||
|
|
@ -140,17 +113,6 @@
|
|||
}).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();
|
||||
};
|
||||
|
|
@ -170,43 +132,43 @@
|
|||
overlays["Тепловая карта"] = heatLayer;
|
||||
}
|
||||
|
||||
layerControl = L.control
|
||||
.layers(null, overlays, {
|
||||
collapsed: false,
|
||||
position: "topright",
|
||||
})
|
||||
.addTo(map);
|
||||
// layerControl = L.control
|
||||
// .layers(null, overlays, {
|
||||
// collapsed: false,
|
||||
// position: "topright",
|
||||
// })
|
||||
// .addTo(map);
|
||||
};
|
||||
// Создание легенды с учетом максимальной скорости
|
||||
const createLegend = (maxSpeed) => {
|
||||
if (!map) return;
|
||||
// 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");
|
||||
div.innerHTML = `
|
||||
<h4>Wind Speed (m/s)</h4>
|
||||
<div class="legend-scale">
|
||||
<div class="legend-color" style="background: #0000FF;"></div>
|
||||
<div class="legend-color" style="background: #00FFFF;"></div>
|
||||
<div class="legend-color" style="background: #00FF00;"></div>
|
||||
<div class="legend-color" style="background: #FFFF00;"></div>
|
||||
<div class="legend-color" style="background: #FF0000;"></div>
|
||||
</div>
|
||||
<div class="legend-labels">
|
||||
<span>0</span>
|
||||
<span>${(maxSpeed * 0.25).toFixed(1)}</span>
|
||||
<span>${(maxSpeed * 0.5).toFixed(1)}</span>
|
||||
<span>${(maxSpeed * 0.75).toFixed(1)}</span>
|
||||
<span>${maxSpeed.toFixed(1)}</span>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
// legend.onAdd = () => {
|
||||
// const div = L.DomUtil.create("div", "wind-heat-legend");
|
||||
// div.innerHTML = `
|
||||
// <h4>Wind Speed (m/s)</h4>
|
||||
// <div class="legend-scale">
|
||||
// <div class="legend-color" style="background: #0000FF;"></div>
|
||||
// <div class="legend-color" style="background: #00FFFF;"></div>
|
||||
// <div class="legend-color" style="background: #00FF00;"></div>
|
||||
// <div class="legend-color" style="background: #FFFF00;"></div>
|
||||
// <div class="legend-color" style="background: #FF0000;"></div>
|
||||
// </div>
|
||||
// <div class="legend-labels">
|
||||
// <span>0</span>
|
||||
// <span>${(maxSpeed * 0.25).toFixed(1)}</span>
|
||||
// <span>${(maxSpeed * 0.5).toFixed(1)}</span>
|
||||
// <span>${(maxSpeed * 0.75).toFixed(1)}</span>
|
||||
// <span>${maxSpeed.toFixed(1)}</span>
|
||||
// </div>
|
||||
// `;
|
||||
// return div;
|
||||
// };
|
||||
|
||||
legend.addTo(map);
|
||||
};
|
||||
// legend.addTo(map);
|
||||
// };
|
||||
|
||||
onMount(() => {
|
||||
if (!map) return;
|
||||
|
|
@ -236,27 +198,19 @@
|
|||
minBufferReady: -1,
|
||||
},
|
||||
});
|
||||
map.addControl(timeDimensionControl);
|
||||
//map.addControl(timeDimensionControl);
|
||||
|
||||
// 4. Создание слоев
|
||||
const velocityLayer = L.timeDimension.layer
|
||||
.windVelocity({
|
||||
displayValues: true,
|
||||
data: timeData,
|
||||
displayOptions: {
|
||||
velocityType: "Wind Speed",
|
||||
position: "bottomleft",
|
||||
},
|
||||
})
|
||||
.addTo(map);
|
||||
|
||||
// 5. Тепловая карта (адаптируйте под ваш формат)
|
||||
const heatLayer = L.timeDimension.layer
|
||||
.heat({
|
||||
radius: 15,
|
||||
data: prepareTimeHeatData(timeData),
|
||||
})
|
||||
.addTo(map);
|
||||
// const velocityLayer = L.timeDimension.layer
|
||||
// .windVelocity({
|
||||
// displayValues: true,
|
||||
// data: timeData,
|
||||
// displayOptions: {
|
||||
// velocityType: "Wind Speed",
|
||||
// position: "bottomleft",
|
||||
// },
|
||||
// })
|
||||
// .addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -273,7 +227,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="layer-controls">
|
||||
<!-- <div class="layer-controls">
|
||||
<div class="control-group">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={showHeatmap} />
|
||||
|
|
@ -284,7 +238,7 @@
|
|||
Векторы ветра
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<style>
|
||||
.layer-controls {
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@
|
|||
Table,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
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 { SavedFlightProfilesStore } from "$lib/stores";
|
||||
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
|
||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
||||
// import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves";
|
||||
import EditableCell from "./EditableCell.svelte";
|
||||
import CurveChart from "./CurveChart.svelte";
|
||||
import EditableCell from "$lib/components/ui/EditableCell.svelte";
|
||||
import CurveChart from "$lib/components/CurveChart.svelte";
|
||||
|
||||
// Mock API functions for now
|
||||
const getSavedCurves = async (): Promise<SavedFlightProfile[]> => {
|
||||
366
src/lib/components/editors/GenericEditor.svelte
Normal file
366
src/lib/components/editors/GenericEditor.svelte
Normal 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>
|
||||
166
src/lib/components/editors/PointEditor.svelte
Normal file
166
src/lib/components/editors/PointEditor.svelte
Normal 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>
|
||||
|
|
@ -13,10 +13,10 @@
|
|||
PaginationLink,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
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 { FlightParametersStore, SavedScenarioStore } from "$lib/stores";
|
||||
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
|
||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
||||
import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios";
|
||||
|
||||
// Props
|
||||
37
src/lib/components/ui/LabelGroup.svelte
Normal file
37
src/lib/components/ui/LabelGroup.svelte
Normal 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>
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { onMount } from 'svelte';
|
||||
import { on } from 'svelte/events';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
options?: { value: any; label:string }[];
|
||||
|
|
@ -12,8 +10,10 @@
|
|||
disabled?: boolean;
|
||||
class?: string;
|
||||
onChange?: (value: any) => void;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
|
||||
let {
|
||||
id = 'select-searchable',
|
||||
options = [],
|
||||
|
|
@ -22,15 +22,16 @@
|
|||
searchPlaceholder = 'Search...',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
onChange,
|
||||
clearable = false,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher<{ change: any }>();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let dropdownElement = $state<HTMLElement>();
|
||||
let selectElement = $state<HTMLElement>();
|
||||
let searchInputElement = $state<HTMLInputElement>();
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
let filteredOptions = $derived(
|
||||
|
|
@ -44,16 +45,30 @@
|
|||
);
|
||||
|
||||
onMount(() => {
|
||||
// Update dropdown position on mount
|
||||
updateDropdownPosition();
|
||||
});
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!selectElement) return;
|
||||
if (!selectElement || !dropdownElement) return;
|
||||
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 = `
|
||||
position: fixed;
|
||||
top: ${rect.bottom}px;
|
||||
top: ${top};
|
||||
bottom: ${bottom};
|
||||
left: ${rect.left}px;
|
||||
min-width: ${rect.width}px;
|
||||
`;
|
||||
|
|
@ -64,8 +79,10 @@
|
|||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
searchTerm = '';
|
||||
// Use next tick to ensure the element is rendered before getting its position
|
||||
Promise.resolve().then(updateDropdownPosition);
|
||||
Promise.resolve().then(() => {
|
||||
updateDropdownPosition();
|
||||
searchInputElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,10 +91,10 @@
|
|||
selected = option.value;
|
||||
isOpen = false;
|
||||
searchTerm = '';
|
||||
dispatch('change', selected);
|
||||
if (restProps.onChange) {
|
||||
restProps.onChange(selected);
|
||||
if (onChange) {
|
||||
onChange(selected);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
|
@ -86,6 +103,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
e.stopPropagation();
|
||||
selected = null;
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', updateDropdownPosition, true);
|
||||
|
|
@ -96,6 +121,12 @@
|
|||
window.removeEventListener('resize', updateDropdownPosition);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && dropdownElement) {
|
||||
updateDropdownPosition();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
|
@ -116,16 +147,28 @@
|
|||
>
|
||||
<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}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOpen}
|
||||
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
||||
<div class="p-2">
|
||||
<input
|
||||
bind:this={searchInputElement}
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchTerm}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,13 +204,33 @@
|
|||
}
|
||||
|
||||
.dropdown-menu {
|
||||
/* position is now set dynamically */
|
||||
z-index: 1000;
|
||||
width: max-content; /* Allow dropdown to grow with its content */
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
max-height: 40vh;
|
||||
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>
|
||||
58
src/lib/components/ui/SpoilerGroup.svelte
Normal file
58
src/lib/components/ui/SpoilerGroup.svelte
Normal 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>
|
||||
|
|
@ -7,14 +7,12 @@
|
|||
label: string;
|
||||
};
|
||||
|
||||
/** An array of tab objects to display. */
|
||||
export let tabs: Tab[] = [];
|
||||
|
||||
/** The id of the currently active tab. Use `bind:activeTab` for two-way binding. */
|
||||
export let activeTab: string;
|
||||
export let justify: 'start' | 'center' | 'end' = 'start';
|
||||
</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)}
|
||||
<button
|
||||
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
|
||||
|
|
@ -12,10 +12,7 @@ export function distHaversine(
|
|||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(rad(p1.lat)) *
|
||||
Math.cos(rad(p2.lat)) *
|
||||
Math.sin(dLong / 2) *
|
||||
Math.sin(dLong / 2);
|
||||
Math.cos(rad(p1.lat)) * 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 d = R * c;
|
||||
|
|
@ -23,17 +20,18 @@ export function distHaversine(
|
|||
return precision ? parseFloat(d.toFixed(precision)) : d;
|
||||
}
|
||||
|
||||
export function bearingHaversine(
|
||||
p1: { lat: number; lng: number },
|
||||
p2: { lat: number; lng: number }
|
||||
): number {
|
||||
export function bearingHaversine(p1: { lat: number; lng: number }, p2: { lat: number; lng: number }): number {
|
||||
const rad = (x: number): number => (x * Math.PI) / 180;
|
||||
|
||||
const dLong = rad(p2.lng - p1.lng);
|
||||
const y = Math.sin(dLong) * Math.cos(rad(p2.lat));
|
||||
const x =
|
||||
Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) -
|
||||
Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong);
|
||||
Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) - Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong);
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,21 @@
|
|||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import PanelContainer from "$lib/components/PanelContainer.svelte";
|
||||
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
|
||||
import TabComponent from "$lib/components/TabComponent.svelte";
|
||||
import PointEditor from "$lib/components/PointEditor.svelte";
|
||||
import TabComponent from "$lib/components/ui/TabComponent.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { PredictionStore } from "$lib/stores";
|
||||
import { addToast, removeToast } from "$lib/components/Toast.svelte";
|
||||
import ToastContainer from '$lib/components/Toast.svelte';
|
||||
import { addToast, removeToast } from "$lib/components/ui/Toast.svelte";
|
||||
import ToastContainer from '$lib/components/ui/Toast.svelte';
|
||||
import L, { point } from "leaflet";
|
||||
import GenericPanel from "$lib/components/GenericPanel.svelte";
|
||||
|
||||
let map: Map | null = null;
|
||||
let panelContainer: PanelContainer | null = null;
|
||||
let controlPanel: ControlPanel | 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(() => {
|
||||
PredictionStore.subscribe((data) => {
|
||||
|
|
@ -73,29 +75,47 @@
|
|||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
|
||||
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
|
||||
<PanelContainer bind:this={panelContainer} >
|
||||
<PanelContainer bind:this={panelContainer} position="left">
|
||||
<TabComponent
|
||||
tabs={[
|
||||
{ id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' },
|
||||
{ id: 'control', icon: 'sliders', label: 'Условия' },
|
||||
{ id: 'settings', icon: 'gear', label: 'Настройки' },
|
||||
{ id: 'about', icon: 'info-circle', label: 'О проекте' }
|
||||
]}
|
||||
bind:activeTab
|
||||
bind:activeTab={activeTabLeft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{#if activeTab === 'control'}
|
||||
{#if activeTabLeft === 'control'}
|
||||
<ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} />
|
||||
{:else if activeTab === 'scenario'}
|
||||
{:else if activeTabLeft === 'scenario'}
|
||||
<ScenarioPanel />
|
||||
{:else if activeTab === 'settings'}
|
||||
<!-- <SettingsPanel /> -->
|
||||
{:else if activeTab === 'about'}
|
||||
{:else if activeTabLeft === 'about'}
|
||||
<!-- <AboutPanel /> -->
|
||||
{/if}
|
||||
</div>
|
||||
</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 />
|
||||
</Map>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
|
||||
import PointEditor from "$lib/components/PointEditor.svelte";
|
||||
import ToastContainer from "$lib/components/Toast.svelte";
|
||||
import { addToast } from "$lib/components/Toast.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import ToastContainer from "$lib/components/ui/Toast.svelte";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
|
||||
// TODO: Implement these imports
|
||||
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores";
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
.panel-container-left {
|
||||
position: absolute;
|
||||
top: var(--panel-top);
|
||||
left: var(--panel-left);
|
||||
|
|
@ -86,6 +86,17 @@
|
|||
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 {
|
||||
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
|
||||
border-radius: var(--bs-border-radius) !important;
|
||||
|
|
@ -132,6 +143,10 @@
|
|||
filter: brightness(0.6);
|
||||
}
|
||||
|
||||
.dropdown-toggle-standalone::after {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px)
|
||||
{
|
||||
.coordinates-display {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue