Compare commits
2 commits
8e9f28a6ac
...
3be5d6c515
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be5d6c515 | ||
|
|
8e3dfa54f9 |
24 changed files with 1028 additions and 1176 deletions
|
|
@ -1,318 +0,0 @@
|
||||||
# Wind Layer Debugging Guide
|
|
||||||
|
|
||||||
## ✅ Fixes Applied
|
|
||||||
|
|
||||||
### 1. **Removed Leaflet Dependencies**
|
|
||||||
- ❌ Deleted `src/lib/ext/leaflet-ruler/leaflet-ruler.ts`
|
|
||||||
- This file was causing import errors for missing 'leaflet' module
|
|
||||||
|
|
||||||
### 2. **Fixed Type Definitions**
|
|
||||||
**File:** `src/lib/types.ts`
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```typescript
|
|
||||||
export type LatLngTuple = [number, number];
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```typescript
|
|
||||||
export type LatLngTuple = [number, number] | [number, number, number]; // Support 2D and 3D
|
|
||||||
export interface LatLngLiteral {
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
alt?: number; // Optional altitude
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why:** Prediction and telemetry data includes altitude (3D coordinates), so types need to support `[lat, lng, alt]` tuples.
|
|
||||||
|
|
||||||
### 3. **Dev Server Status**
|
|
||||||
✅ **Server running successfully** on `http://localhost:5175/`
|
|
||||||
✅ No build errors in console
|
|
||||||
✅ Wind layer package installed: `@sakitam-gis/maplibre-wind@2.0.3`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Debugging 500 Internal Server Error
|
|
||||||
|
|
||||||
If you're still seeing a 500 error, check these areas:
|
|
||||||
|
|
||||||
### 1. **Check Browser Console**
|
|
||||||
|
|
||||||
Open browser DevTools (F12) and look for:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Expected success logs:
|
|
||||||
"WindVisualization mounted with MapLibre map"
|
|
||||||
"Wind data available: Array(2)"
|
|
||||||
"Wind data stats - U: [-21.32, 26.80], V: [-21.57, 21.42]"
|
|
||||||
"Wind layers initialized successfully"
|
|
||||||
|
|
||||||
// Error logs to watch for:
|
|
||||||
"Failed to process wind data"
|
|
||||||
"Error initializing wind layers:"
|
|
||||||
"Missing U or V wind components"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Check Network Tab**
|
|
||||||
|
|
||||||
Look for failed requests:
|
|
||||||
- `/src/routes/testVelo.json` - Wind data file
|
|
||||||
- MapLibre GL CSS/JS assets
|
|
||||||
- Wind layer assets
|
|
||||||
|
|
||||||
### 3. **Verify Wind Data File**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if file exists
|
|
||||||
ls -la src/routes/testVelo.json
|
|
||||||
|
|
||||||
# Check file size (should be ~76KB)
|
|
||||||
du -h src/routes/testVelo.json
|
|
||||||
|
|
||||||
# Verify JSON is valid
|
|
||||||
cat src/routes/testVelo.json | python -m json.tool > /dev/null && echo "Valid JSON" || echo "Invalid JSON"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Check Map Component**
|
|
||||||
|
|
||||||
The Map component should pass both props:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<WindVisualization {map} {windData} />
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify in `src/lib/components/Map.svelte`:
|
|
||||||
- Line ~155-157: WindVisualization component exists
|
|
||||||
- `windData` is loaded from fetch
|
|
||||||
- `map` instance is created
|
|
||||||
|
|
||||||
### 5. **SSR (Server-Side Rendering) Issues**
|
|
||||||
|
|
||||||
MapLibre and wind-layer are client-only. Ensure:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In +page.ts or +page.server.ts
|
|
||||||
export const ssr = false;
|
|
||||||
```
|
|
||||||
|
|
||||||
Check: `src/routes/predict/+page.ts`
|
|
||||||
|
|
||||||
### 6. **Build Issues**
|
|
||||||
|
|
||||||
Try clearing cache and rebuilding:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clear SvelteKit cache
|
|
||||||
rm -rf .svelte-kit
|
|
||||||
|
|
||||||
# Clear node_modules (if needed)
|
|
||||||
rm -rf node_modules
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Restart dev server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Test Cases
|
|
||||||
|
|
||||||
### Test 1: Component Loads
|
|
||||||
1. Navigate to `/predict`
|
|
||||||
2. Open console (F12)
|
|
||||||
3. Look for "WindVisualization mounted" message
|
|
||||||
4. ✅ Success if no errors
|
|
||||||
|
|
||||||
### Test 2: Wind Data Loaded
|
|
||||||
1. Check console for "Wind data available: Array(2)"
|
|
||||||
2. Verify data has U-component (parameterNumber: 2)
|
|
||||||
3. Verify data has V-component (parameterNumber: 3)
|
|
||||||
4. ✅ Success if both components present
|
|
||||||
|
|
||||||
### Test 3: Layers Initialize
|
|
||||||
1. Look for "Wind layers initialized successfully"
|
|
||||||
2. Check map has particle animation visible
|
|
||||||
3. Toggle checkboxes work
|
|
||||||
4. ✅ Success if particles animate
|
|
||||||
|
|
||||||
### Test 4: No Console Errors
|
|
||||||
1. Check for any red errors in console
|
|
||||||
2. Common errors:
|
|
||||||
- `Cannot read properties of undefined`
|
|
||||||
- `Module not found`
|
|
||||||
- `addLayer is not a function`
|
|
||||||
3. ✅ Success if no errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Common Errors & Solutions
|
|
||||||
|
|
||||||
### Error: "Cannot find module '@sakitam-gis/maplibre-wind'"
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
npm install @sakitam-gis/maplibre-wind --save
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "map.addLayer is not a function"
|
|
||||||
|
|
||||||
**Cause:** Map not fully initialized
|
|
||||||
|
|
||||||
**Solution:** Component already waits for map load:
|
|
||||||
```javascript
|
|
||||||
if (map.loaded()) {
|
|
||||||
initializeWindLayers();
|
|
||||||
} else {
|
|
||||||
map.on('load', initializeWindLayers);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: "Missing U or V wind components"
|
|
||||||
|
|
||||||
**Cause:** Wind data file corrupted or wrong format
|
|
||||||
|
|
||||||
**Solution:** Verify `testVelo.json` has 2 objects with:
|
|
||||||
- First: `header.parameterNumber: 2` (U-component)
|
|
||||||
- Second: `header.parameterNumber: 3` (V-component)
|
|
||||||
|
|
||||||
### Error: "Failed to process wind data"
|
|
||||||
|
|
||||||
**Cause:** Data structure doesn't match expected format
|
|
||||||
|
|
||||||
**Solution:** Check data has:
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
header: { nx, ny, parameterNumber },
|
|
||||||
data: [/* array of numbers */]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error: Layer already exists
|
|
||||||
|
|
||||||
**Cause:** Trying to add layer that's already on map
|
|
||||||
|
|
||||||
**Solution:** Component checks before adding:
|
|
||||||
```javascript
|
|
||||||
if (!map.getLayer('wind-particles')) {
|
|
||||||
map.addLayer(particleLayer);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Expected Console Output
|
|
||||||
|
|
||||||
### Successful Load:
|
|
||||||
```
|
|
||||||
WindVisualization mounted with MapLibre map
|
|
||||||
Wind data available: Array(2) [{header: {…}, data: Array(65160)}, {header: {…}, data: Array(65160)}]
|
|
||||||
Wind data stats - U: [-21.32, 26.80], V: [-21.57, 21.42]
|
|
||||||
Processed wind data: {uMin: -21.32, uMax: 26.8, vMin: -21.57, vMax: 21.42, rows: 181, cols: 360, data: Array(2)}
|
|
||||||
Wind layers initialized successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layer Toggle:
|
|
||||||
```
|
|
||||||
// When unchecking particle layer
|
|
||||||
(Removes layer from map)
|
|
||||||
|
|
||||||
// When checking particle layer
|
|
||||||
(Adds layer back to map)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Manual Testing
|
|
||||||
|
|
||||||
### Test in Browser Console
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 1. Check if map has wind layers
|
|
||||||
map.getLayer('wind-particles') // Should return layer object
|
|
||||||
map.getLayer('wind-heatmap') // Should return layer object
|
|
||||||
|
|
||||||
// 2. Check if map instance is valid
|
|
||||||
map.loaded() // Should return true
|
|
||||||
|
|
||||||
// 3. Manually toggle layers
|
|
||||||
map.removeLayer('wind-particles')
|
|
||||||
map.addLayer(particleLayer) // If you have reference
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Code Review Checklist
|
|
||||||
|
|
||||||
### WindVisualisation.svelte
|
|
||||||
- [x] Uses `$props()` (Svelte 5 syntax)
|
|
||||||
- [x] Has `prepareWindData()` function
|
|
||||||
- [x] Waits for map load
|
|
||||||
- [x] Has error handling (try/catch)
|
|
||||||
- [x] Cleans up on destroy
|
|
||||||
- [x] Reactive `$effect()` for toggles
|
|
||||||
|
|
||||||
### Map.svelte
|
|
||||||
- [x] Imports WindVisualization component
|
|
||||||
- [x] Fetches wind data from testVelo.json
|
|
||||||
- [x] Passes `map` and `windData` props
|
|
||||||
- [x] Renders WindVisualization component
|
|
||||||
|
|
||||||
### types.ts
|
|
||||||
- [x] Supports 3D coordinates `[lat, lng, alt]`
|
|
||||||
- [x] Optional `alt` in LatLngLiteral
|
|
||||||
- [x] Proper LatLngExpression type
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Performance Notes
|
|
||||||
|
|
||||||
### Particle Count Impact
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// High performance (2000-3000 particles)
|
|
||||||
numParticles: 2000
|
|
||||||
|
|
||||||
// Balanced (5000 particles) - Default
|
|
||||||
numParticles: 5000
|
|
||||||
|
|
||||||
// High quality (10000+ particles) - May lag on slower devices
|
|
||||||
numParticles: 10000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large Datasets
|
|
||||||
|
|
||||||
Current dataset: 65,160 points (360 × 181 grid)
|
|
||||||
- Should render in <1 second
|
|
||||||
- GPU-accelerated via WebGL
|
|
||||||
- No lag on modern browsers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Additional Resources
|
|
||||||
|
|
||||||
- **Wind Layer Repo:** https://github.com/sakitam-fdd/wind-layer
|
|
||||||
- **MapLibre Docs:** https://maplibre.org/maplibre-gl-js/docs/
|
|
||||||
- **Issue Tracker:** Report bugs in wind-layer repo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Final Checklist
|
|
||||||
|
|
||||||
Before reporting an issue, verify:
|
|
||||||
|
|
||||||
- [ ] Dev server running (`npm run dev`)
|
|
||||||
- [ ] No errors in terminal
|
|
||||||
- [ ] Browser console open (F12)
|
|
||||||
- [ ] No red errors in console
|
|
||||||
- [ ] testVelo.json file exists
|
|
||||||
- [ ] MapLibre GL loaded correctly
|
|
||||||
- [ ] Wind layer package installed
|
|
||||||
- [ ] Component props passed correctly
|
|
||||||
- [ ] SSR disabled for map routes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** December 2025
|
|
||||||
**Status:** ✅ Implementation Complete
|
|
||||||
**Known Issues:** None
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
# Wind Layer Implementation Guide
|
|
||||||
|
|
||||||
## 🌬️ Overview
|
|
||||||
|
|
||||||
The project now uses **[@sakitam-gis/maplibre-wind](https://github.com/sakitam-fdd/wind-layer)** for professional wind visualization on MapLibre GL maps.
|
|
||||||
|
|
||||||
## 📦 Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @sakitam-gis/maplibre-wind --save
|
|
||||||
```
|
|
||||||
|
|
||||||
**Package installed:** ✅ Version included in `package.json`
|
|
||||||
|
|
||||||
## 🎨 Features Implemented
|
|
||||||
|
|
||||||
### Wind Particle Animation
|
|
||||||
- **5000 particles** flowing with wind direction
|
|
||||||
- **Color gradient:** Blue → Green → Yellow → Orange → Red
|
|
||||||
- **Smooth animation** with WebGL acceleration
|
|
||||||
- **Configurable speed** and fade effects
|
|
||||||
|
|
||||||
### Heatmap Visualization
|
|
||||||
- **Color-coded intensity** display
|
|
||||||
- **Opacity control** (70% default)
|
|
||||||
- **Display range:** 0-20 m/s
|
|
||||||
- **Rainbow color scheme:** Blue → Cyan → Green → Yellow → Red
|
|
||||||
|
|
||||||
## 🔧 Component Structure
|
|
||||||
|
|
||||||
### File: `src/lib/components/WindVisualisation.svelte`
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `map` - MapLibre GL map instance
|
|
||||||
- `windData` - Wind data in GRIB format (U/V components)
|
|
||||||
|
|
||||||
**State:**
|
|
||||||
- `showHeatmap` - Toggle heatmap layer
|
|
||||||
- `showParticles` - Toggle particle animation layer
|
|
||||||
|
|
||||||
**Functions:**
|
|
||||||
- `initializeWindLayers()` - Creates particle and heatmap layers
|
|
||||||
- `prepareWindData()` - Transforms GRIB data to wind-layer format
|
|
||||||
- Reactive `$effect()` - Toggles layer visibility
|
|
||||||
|
|
||||||
## 📊 Data Format
|
|
||||||
|
|
||||||
### Input: GRIB Wind Data (`testVelo.json`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"header": {
|
|
||||||
"parameterNumber": 2, // U-component
|
|
||||||
"nx": 360, // Grid columns
|
|
||||||
"ny": 181, // Grid rows
|
|
||||||
"lo1": 0.0, // Starting longitude
|
|
||||||
"la1": 90.0 // Starting latitude
|
|
||||||
},
|
|
||||||
"data": [/* U-component values */]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"header": {
|
|
||||||
"parameterNumber": 3, // V-component
|
|
||||||
...
|
|
||||||
},
|
|
||||||
"data": [/* V-component values */]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Output: Wind-Layer Format
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
uMin: number, // Min U-component value
|
|
||||||
uMax: number, // Max U-component value
|
|
||||||
vMin: number, // Min V-component value
|
|
||||||
vMax: number, // Max V-component value
|
|
||||||
rows: number, // Grid rows (ny)
|
|
||||||
cols: number, // Grid columns (nx)
|
|
||||||
data: [Array, Array] // [U-component, V-component]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎛️ Configuration Options
|
|
||||||
|
|
||||||
### Particle Layer
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
renderType: 'particles',
|
|
||||||
styleSpec: {
|
|
||||||
numParticles: 5000, // Number of particles
|
|
||||||
fadeOpacity: 0.996, // Trail fade rate (0.9-0.999)
|
|
||||||
speedFactor: 0.25, // Animation speed multiplier
|
|
||||||
dropRate: 0.003, // Particle regeneration rate
|
|
||||||
dropRateBump: 0.01, // Regeneration boost
|
|
||||||
colors: [ // Color gradient
|
|
||||||
'#3288bd', // Blue
|
|
||||||
'#66c2a5', // Green
|
|
||||||
'#fee08b', // Yellow
|
|
||||||
'#f46d43', // Orange
|
|
||||||
'#d53e4f' // Red
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Heatmap Layer
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
renderType: 'colorize',
|
|
||||||
styleSpec: {
|
|
||||||
opacity: 0.7,
|
|
||||||
colors: [
|
|
||||||
'#0000ff', // Blue
|
|
||||||
'#00ffff', // Cyan
|
|
||||||
'#00ff00', // Green
|
|
||||||
'#ffff00', // Yellow
|
|
||||||
'#ff0000' // Red
|
|
||||||
],
|
|
||||||
displayRange: [0, 20] // Min/max wind speed (m/s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎮 Usage
|
|
||||||
|
|
||||||
### UI Controls
|
|
||||||
|
|
||||||
Located in bottom-left corner of the map:
|
|
||||||
|
|
||||||
- ☑️ **Тепловая карта** - Toggle heatmap visualization
|
|
||||||
- ☑️ **Частицы ветра** - Toggle particle animation (default: ON)
|
|
||||||
|
|
||||||
### Programmatic Control
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In Map.svelte
|
|
||||||
<WindVisualization {map} {windData} />
|
|
||||||
|
|
||||||
// Toggle layers via checkbox binding
|
|
||||||
// Layers automatically add/remove from map
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
1. testVelo.json (GRIB format)
|
|
||||||
↓
|
|
||||||
2. Map.svelte loads data
|
|
||||||
↓
|
|
||||||
3. WindVisualisation component receives:
|
|
||||||
- map instance
|
|
||||||
- windData
|
|
||||||
↓
|
|
||||||
4. prepareWindData() transforms to wind-layer format
|
|
||||||
↓
|
|
||||||
5. WindLayer instances created:
|
|
||||||
- Particle layer
|
|
||||||
- Heatmap layer
|
|
||||||
↓
|
|
||||||
6. Layers added to map
|
|
||||||
↓
|
|
||||||
7. User toggles visibility via checkboxes
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Key Implementation Details
|
|
||||||
|
|
||||||
### 1. Svelte 5 Runes
|
|
||||||
|
|
||||||
Uses modern Svelte 5 syntax:
|
|
||||||
- `$props()` for component props
|
|
||||||
- `$state()` for reactive state
|
|
||||||
- `$effect()` for reactive layer toggling
|
|
||||||
|
|
||||||
### 2. Map Lifecycle
|
|
||||||
|
|
||||||
- Waits for map to load before initializing
|
|
||||||
- Checks `map.loaded()` status
|
|
||||||
- Listens to `'load'` event if not ready
|
|
||||||
|
|
||||||
### 3. Layer Management
|
|
||||||
|
|
||||||
- Checks if layer exists before adding
|
|
||||||
- Removes layers on component destroy
|
|
||||||
- Prevents duplicate layer IDs
|
|
||||||
|
|
||||||
### 4. Error Handling
|
|
||||||
|
|
||||||
- Validates wind data structure
|
|
||||||
- Catches initialization errors
|
|
||||||
- Logs detailed error messages
|
|
||||||
- Graceful degradation on failure
|
|
||||||
|
|
||||||
## 🐛 Debugging
|
|
||||||
|
|
||||||
### Console Logs
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// On mount
|
|
||||||
"WindVisualization mounted with MapLibre map"
|
|
||||||
"Wind data available: [...]"
|
|
||||||
|
|
||||||
// Data processing
|
|
||||||
"Wind data stats - U: [-21.32, 26.80], V: [-21.57, 21.42]"
|
|
||||||
|
|
||||||
// Success
|
|
||||||
"Wind layers initialized successfully"
|
|
||||||
|
|
||||||
// Errors
|
|
||||||
"Missing U or V wind components"
|
|
||||||
"Error initializing wind layers: [error]"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Layer Status
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In browser console
|
|
||||||
map.getLayer('wind-particles') // Should return layer object
|
|
||||||
map.getLayer('wind-heatmap') // Should return layer object
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Resources
|
|
||||||
|
|
||||||
- **GitHub:** https://github.com/sakitam-fdd/wind-layer
|
|
||||||
- **Examples:** https://sakitam-fdd.github.io/wind-layer/examples/
|
|
||||||
- **MapLibre Docs:** https://maplibre.org/maplibre-gl-js/docs/
|
|
||||||
|
|
||||||
## ⚙️ Advanced Customization
|
|
||||||
|
|
||||||
### Adjust Particle Count
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
numParticles: 10000 // More particles (slower performance)
|
|
||||||
numParticles: 2000 // Fewer particles (better performance)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change Animation Speed
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
speedFactor: 0.5 // Faster animation
|
|
||||||
speedFactor: 0.1 // Slower animation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Color Schemes
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Wind speed colors
|
|
||||||
colors: ['#000080', '#0000FF', '#FFFF00', '#FF0000', '#800000']
|
|
||||||
|
|
||||||
// Monochrome
|
|
||||||
colors: ['#FFFFFF', '#CCCCCC', '#999999', '#666666', '#000000']
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adjust Display Range
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
displayRange: [0, 30] // For stronger winds
|
|
||||||
displayRange: [0, 10] // For lighter winds
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Future Enhancements
|
|
||||||
|
|
||||||
Potential additions:
|
|
||||||
- Timeline control for temporal wind data
|
|
||||||
- Arrow vector visualization
|
|
||||||
- Wind speed labels
|
|
||||||
- Custom tile sources for real-time data
|
|
||||||
- Wind barbs (meteorological standard)
|
|
||||||
- Integration with prediction module
|
|
||||||
|
|
||||||
## ✅ Testing Checklist
|
|
||||||
|
|
||||||
- [x] Package installed successfully
|
|
||||||
- [x] Component imports without errors
|
|
||||||
- [x] Wind data loads from testVelo.json
|
|
||||||
- [x] Particle animation displays on map
|
|
||||||
- [x] Heatmap visualization works
|
|
||||||
- [x] Checkboxes toggle layers correctly
|
|
||||||
- [x] No console errors on mount/unmount
|
|
||||||
- [x] Layers clean up on component destroy
|
|
||||||
|
|
||||||
## 📝 Notes
|
|
||||||
|
|
||||||
- Wind data must be in GRIB format with U/V components
|
|
||||||
- Particle layer is GPU-accelerated (requires WebGL)
|
|
||||||
- Large particle counts may impact performance
|
|
||||||
- Data transformation happens client-side
|
|
||||||
- Layers are added above base map tiles
|
|
||||||
- Z-index managed by MapLibre layer order
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Date:** December 2025
|
|
||||||
**Package Version:** @sakitam-gis/maplibre-wind
|
|
||||||
**Status:** ✅ Production Ready
|
|
||||||
|
|
@ -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
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: `New Point ${new Date().toLocaleString()}`,
|
name: `New Point ${new Date().toLocaleString()}`,
|
||||||
lat: $FlightParametersStore.launch_latitude,
|
lat: $FlightParametersStore.launch_latitude,
|
||||||
lon: $FlightParametersStore.launch_longitude,
|
lon: $FlightParametersStore.launch_longitude,
|
||||||
alt: $FlightParametersStore.launch_altitude,
|
alt: $FlightParametersStore.launch_altitude,
|
||||||
},
|
// The onSave callback is handled by the onSelectPoint prop on the component
|
||||||
true,
|
}, false);
|
||||||
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,7 +261,6 @@
|
||||||
<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"
|
||||||
|
|
@ -271,44 +271,17 @@
|
||||||
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
|
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
|
||||||
}))}
|
}))}
|
||||||
placeholder="Новая точка..."
|
placeholder="Новая точка..."
|
||||||
|
clearable={true}
|
||||||
searchPlaceholder="Поиск по точкам..." />
|
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
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="flex-fill"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => pointEditorRef?.openModal(true)}
|
onclick={() => pointEditorRef?.open()}
|
||||||
title="Открыть список точек">
|
title="Открыть список точек">
|
||||||
Все точки
|
<Icon name="journal-bookmark-fill"/>
|
||||||
<Icon name="journal-bookmark-fill" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
<Button
|
</FormGroup>
|
||||||
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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -391,14 +391,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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 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"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,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} disabled />
|
<input type="checkbox" bind:checked={showHeatmap} disabled />
|
||||||
|
|
@ -99,4 +99,4 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
</style>
|
</style> -->
|
||||||
|
|
|
||||||
|
|
@ -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[]> => {
|
||||||
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,
|
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
|
||||||
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">
|
<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}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</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>
|
||||||
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;
|
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"
|
||||||
|
|
@ -12,10 +12,7 @@ export function distHaversine(
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -23,17 +20,18 @@ export function distHaversine(
|
||||||
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 },
|
|
||||||
p2: { lat: number; lng: number }
|
|
||||||
): number {
|
|
||||||
const rad = (x: number): number => (x * Math.PI) / 180;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 TimeLine from "$lib/components/TimeLine.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 GenericPanel from "$lib/components/GenericPanel.svelte";
|
||||||
|
import TimeLine from "$lib/components/TimeLine.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) => {
|
||||||
|
|
@ -81,29 +83,45 @@
|
||||||
<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 />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PanelContainer>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{#if $PredictionStore}
|
{#if $PredictionStore}
|
||||||
<TimeLine prediction={$PredictionStore} on:timeUpdate={handleTimeUpdate} />
|
<TimeLine prediction={$PredictionStore} on:timeUpdate={handleTimeUpdate} />
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -128,6 +128,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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue