From bb390d50dcc93e3799ba8f8d68c08f3dc1f1fabb Mon Sep 17 00:00:00 2001 From: ThePetrovich Date: Tue, 1 Jul 2025 21:09:15 +0800 Subject: [PATCH] Add ruler tool and fix types --- package-lock.json | 14 + package.json | 1 + src/app.html | 3 + src/lib/components/ControlPanel.svelte | 119 +++------ src/lib/components/Map.svelte | 26 +- src/lib/components/PanelContainer.svelte | 7 +- src/lib/components/ScenarioPanel.svelte | 115 +++++++++ src/lib/ext/leaflet-ruler/leaflet-ruler.ts | 286 +++++++++++++++++++++ src/lib/mathutil.ts | 19 +- src/lib/types.ts | 48 +--- src/routes/+page.svelte | 2 +- src/routes/predict/+page.svelte | 35 +-- src/routes/user/account/+page.svelte | 2 +- static/css/custom.css | 22 ++ static/ext/leaflet-ruler/icon.png | Bin 0 -> 756 bytes static/ext/leaflet-ruler/leaflet-ruler.css | 41 +++ 16 files changed, 582 insertions(+), 158 deletions(-) create mode 100644 src/lib/ext/leaflet-ruler/leaflet-ruler.ts create mode 100644 static/ext/leaflet-ruler/icon.png create mode 100644 static/ext/leaflet-ruler/leaflet-ruler.css diff --git a/package-lock.json b/package-lock.json index 7e00222..844ec4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@sveltestrap/sveltestrap": "^7.1.0", + "@types/leaflet": "^1.9.19", "bootstrap-icons": "^1.13.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", @@ -867,6 +868,19 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" + }, + "node_modules/@types/leaflet": { + "version": "1.9.19", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.19.tgz", + "integrity": "sha512-pB+n2daHcZPF2FDaWa+6B0a0mSDf4dPU35y5iTXsx7x/PzzshiX5atYiS1jlBn43X7XvM8AP+AB26lnSk0J4GA==", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", diff --git a/package.json b/package.json index 52e8a81..9d82df6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@sveltestrap/sveltestrap": "^7.1.0", + "@types/leaflet": "^1.9.19", "bootstrap-icons": "^1.13.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", diff --git a/src/app.html b/src/app.html index e1deb10..3ba5379 100644 --- a/src/app.html +++ b/src/app.html @@ -4,7 +4,10 @@ + + + Параметры прогнозирования {#if !isCollapsed} +
+ + + + + + + + +
+ @@ -181,18 +171,7 @@ disabled={selectedProfile !== "Custom"} > Редакт. - - - + @@ -211,25 +190,7 @@ on:click={() => console.log("Not implemented yet")} > Редакт. - - - - - + @@ -255,29 +216,29 @@ > - - - - - -
- - - +
+ + + - - - + + +
- + - +
- - - - -
diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte index ff2ee78..5e6cc7a 100644 --- a/src/lib/components/Map.svelte +++ b/src/lib/components/Map.svelte @@ -1,21 +1,19 @@ -
+
-
\ No newline at end of file +
diff --git a/src/lib/components/ScenarioPanel.svelte b/src/lib/components/ScenarioPanel.svelte index e69de29..4791ced 100644 --- a/src/lib/components/ScenarioPanel.svelte +++ b/src/lib/components/ScenarioPanel.svelte @@ -0,0 +1,115 @@ + + + + + + + + {#if !isCollapsed} + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ {/if} +
diff --git a/src/lib/ext/leaflet-ruler/leaflet-ruler.ts b/src/lib/ext/leaflet-ruler/leaflet-ruler.ts new file mode 100644 index 0000000..40ef2be --- /dev/null +++ b/src/lib/ext/leaflet-ruler/leaflet-ruler.ts @@ -0,0 +1,286 @@ +import * as L from "leaflet"; +import { distHaversine, bearingHaversine } from "$lib/mathutil"; + +// Define an interface for the control's options for type safety. +export interface RulerOptions extends L.ControlOptions { + events?: { + onToggle?: (isActive: boolean) => void; + }; + circleMarker?: L.CircleMarkerOptions; + lineStyle?: L.PolylineOptions; + lengthUnit?: { + display?: string; + decimal?: number; + factor?: number | null; + label?: string; + }; + angleUnit?: { + display?: string; + decimal?: number; + factor?: number | null; + label?: string; + }; +} + +// Define an interface for the measurement result. +interface MeasurementResult { + Bearing: number; + Distance: number; +} + +// Use a modern TypeScript class that extends L.Control. +export class Ruler extends L.Control { + // Override the default options with our custom ones. + public options: RulerOptions = { + position: "topright", + events: { + onToggle: () => {}, + }, + circleMarker: { + color: "red", + radius: 2, + }, + lineStyle: { + color: "red", + dashArray: "1,6", + }, + lengthUnit: { + display: "km", + decimal: 2, + factor: null, + label: "Distance:", + }, + angleUnit: { + display: "°", + decimal: 2, + factor: null, + label: "Bearing:", + }, + }; + + // Declare class properties with types. + private _lastClickTime = 0; + private _map?: L.Map; + private _container?: HTMLElement; + private _choice = false; + private _defaultCursor = ""; + private _allLayers: L.LayerGroup = L.layerGroup(); + private _clickedLatLong: L.LatLng | null = null; + private _clickedPoints: L.LatLng[] = []; + private _totalLength = 0; + private _clickCount = 0; + private _tempLine: L.FeatureGroup = L.featureGroup(); + private _tempPoint: L.FeatureGroup = L.featureGroup(); + private _pointLayer: L.FeatureGroup = L.featureGroup(); + private _polylineLayer: L.FeatureGroup = L.featureGroup(); + private _movingLatLong: L.LatLng | null = null; + private _result: MeasurementResult = { Bearing: 0, Distance: 0 }; + private _addedLength = 0; + + constructor(options?: RulerOptions) { + super(options); + L.Util.setOptions(this, options); + } + + public isActive(): boolean { + return this._choice; + } + + public onAdd(map: L.Map): HTMLElement { + this._map = map; + this._container = L.DomUtil.create("div", "leaflet-bar leaflet-ruler"); + L.DomEvent.disableClickPropagation(this._container); + L.DomEvent.on(this._container, "click", this._toggleMeasure, this); + this._defaultCursor = this._map.getContainer().style.cursor; + this._allLayers = L.layerGroup(); + return this._container; + } + + public onRemove(): void { + if (this._container) { + L.DomEvent.off(this._container, "click", this._toggleMeasure, this); + } + if (this._choice) { + this._toggleMeasure(); // Turn off measurements + } + } + + private _toggleMeasure(): void { + this._choice = !this._choice; + this.options.events?.onToggle?.(this._choice); + + this._clickedLatLong = null; + this._clickedPoints = []; + this._totalLength = 0; + + if (!this._map || !this._container) return; + + const mapContainer = this._map.getContainer(); + + if (this._choice) { + this._map.doubleClickZoom.disable(); + L.DomEvent.on(mapContainer, "keydown", this._escape, this); + L.DomEvent.on(mapContainer, "dblclick", this._closePath, this); + this._container.classList.add("leaflet-ruler-clicked"); + this._clickCount = 0; + this._tempLine = L.featureGroup().addTo(this._allLayers); + this._tempPoint = L.featureGroup().addTo(this._allLayers); + this._pointLayer = L.featureGroup().addTo(this._allLayers); + this._polylineLayer = L.featureGroup().addTo(this._allLayers); + this._allLayers.addTo(this._map); + mapContainer.style.cursor = "crosshair"; + this._map.on("click", this._clicked, this); + this._map.on("mousemove", this._moving, this); + } else { + this._map.doubleClickZoom.enable(); + L.DomEvent.off(mapContainer, "keydown", this._escape, this); + L.DomEvent.off(mapContainer, "dblclick", this._closePath, this); + this._container.classList.remove("leaflet-ruler-clicked"); + this._map.removeLayer(this._allLayers); + this._allLayers = L.layerGroup(); + mapContainer.style.cursor = this._defaultCursor; + this._map.off("click", this._clicked, this); + this._map.off("mousemove", this._moving, this); + } + } + + private _clicked(e: L.LeafletMouseEvent): void { + // hack to prevent adding the same point twice on double click + let clickTime = Date.now(); + if (clickTime - this._lastClickTime < 200) { + this._closePath(); + return; + } + + this._lastClickTime = clickTime; + + this._clickedLatLong = e.latlng; + this._clickedPoints.push(this._clickedLatLong); + L.circleMarker(this._clickedLatLong, this.options.circleMarker).addTo(this._pointLayer); + + if (this._clickCount > 0 && !e.latlng.equals(this._clickedPoints[this._clickedPoints.length - 2], 0.0001)) { + if (this._movingLatLong) { + L.polyline( + [this._clickedPoints[this._clickCount - 1], this._movingLatLong], + this.options.lineStyle + ).addTo(this._polylineLayer); + } + let text: string; + this._totalLength += this._result.Distance; + const angleUnit = this.options.angleUnit!; + const lengthUnit = this.options.lengthUnit!; + + if (this._clickCount > 1) { + text = `${angleUnit.label} ${this._result.Bearing.toFixed(angleUnit.decimal)} ${ + angleUnit.display + }
${lengthUnit.label} ${this._totalLength.toFixed(lengthUnit.decimal)} ${ + lengthUnit.display + }`; + } else { + text = `${angleUnit.label} ${this._result.Bearing.toFixed(angleUnit.decimal)} ${ + angleUnit.display + }
${lengthUnit.label} ${this._result.Distance.toFixed(lengthUnit.decimal)} ${ + lengthUnit.display + }`; + } + L.circleMarker(this._clickedLatLong, this.options.circleMarker) + .bindTooltip(text, { permanent: true, className: "result-tooltip" }) + .addTo(this._pointLayer) + .openTooltip(); + } + this._clickCount++; + } + + private _moving(e: L.LeafletMouseEvent): void { + if (this._clickedLatLong && this._map) { + this._movingLatLong = e.latlng; + + this._tempLine.clearLayers(); + this._tempPoint.clearLayers(); + + this._calculateBearingAndDistance(); + this._addedLength = this._result.Distance + this._totalLength; + + L.polyline([this._clickedLatLong, this._movingLatLong], this.options.lineStyle).addTo(this._tempLine); + + const angleUnit = this.options.angleUnit!; + const lengthUnit = this.options.lengthUnit!; + let text: string; + + if (this._clickCount > 1) { + text = `${angleUnit.label} ${this._result.Bearing.toFixed(angleUnit.decimal)} ${ + angleUnit.display + }
${lengthUnit.label} ${this._addedLength.toFixed(lengthUnit.decimal)} ${ + lengthUnit.display + }
(+${this._result.Distance.toFixed(lengthUnit.decimal)})
`; + } else { + text = `${angleUnit.label} ${this._result.Bearing.toFixed(angleUnit.decimal)} ${ + angleUnit.display + }
${lengthUnit.label} ${this._result.Distance.toFixed(lengthUnit.decimal)} ${ + lengthUnit.display + }`; + } + L.circleMarker(this._movingLatLong, this.options.circleMarker) + .bindTooltip(text, { sticky: true, offset: L.point(0, -40), className: "moving-tooltip" }) + .addTo(this._tempPoint) + .openTooltip(); + } + } + + private _escape(e: Event): void { + if ((e as KeyboardEvent).key === "Escape") { + if (this._clickCount > 0) { + this._closePath(); + } else { + this._toggleMeasure(); + } + } + } + + private _calculateBearingAndDistance(): void { + if (!this._clickedLatLong || !this._movingLatLong) return; + + const f1 = this._clickedLatLong.lat; + const l1 = this._clickedLatLong.lng; + const f2 = this._movingLatLong.lat; + const l2 = this._movingLatLong.lng; + + const angleUnit = this.options.angleUnit!; + const lengthUnit = this.options.lengthUnit!; + + const brng = bearingHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 }); + + const distance = distHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 }); + + if (angleUnit.factor) { + this._result.Bearing = brng * angleUnit.factor; + } else { + this._result.Bearing = brng; + } + + if (lengthUnit.factor) { + this._result.Distance = distance * lengthUnit.factor; + } else { + this._result.Distance = distance; + } + + this._result = { + Bearing: brng, + Distance: distance, + }; + } + + private _closePath(): void { + if (!this._map || !this._container) return; + + this._map.removeLayer(this._tempLine); + this._map.removeLayer(this._tempPoint); + this._choice = false; + this._toggleMeasure(); + } +} + +// Factory function for creating the control, maintaining the Leaflet convention. +export const ruler = (options?: RulerOptions) => { + return new Ruler(options); +}; diff --git a/src/lib/mathutil.ts b/src/lib/mathutil.ts index 75bcebe..eee4bac 100644 --- a/src/lib/mathutil.ts +++ b/src/lib/mathutil.ts @@ -2,7 +2,7 @@ export function distHaversine( p1: { lat: number; lng: number }, p2: { lat: number; lng: number }, precision?: number -): string { +): number { const R = 6371; // Earth's mean radius in km const rad = (x: number): number => (x * Math.PI) / 180; @@ -20,5 +20,20 @@ export function distHaversine( const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const d = R * c; - return d.toFixed(precision ?? 3); + return precision ? parseFloat(d.toFixed(precision)) : d; +} + +export function bearingHaversine( + p1: { lat: number; lng: number }, + p2: { lat: number; lng: number } +): number { + const rad = (x: number): number => (x * Math.PI) / 180; + + const dLong = rad(p2.lng - p1.lng); + const y = Math.sin(dLong) * Math.cos(rad(p2.lat)); + const x = + Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) - + Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong); + + return (Math.atan2(y, x) * 180) / Math.PI; } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 34029d5..388f8d0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,6 +23,11 @@ export interface FlightParameters { version: number; } +export interface Point { + latlng: LatLngLiteral & { alt: number }; + datetime: Date; +} + export interface TelemetryPoint { altitude: number; datetime: string; @@ -42,11 +47,8 @@ export interface RawTelemetry { } export interface Telemetry { - flight_path: [number, number, number][]; - launch: { - latlng: LatLngExpression; - datetime: Date; - }; + flight_path: LatLngExpression[]; + launch: Point; datapoints: TelemetryPoint[]; } @@ -74,38 +76,10 @@ export interface RawPrediction { } export interface Prediction { - flight_path: [number, number, number][]; - launch: { - latlng: LatLngExpression; - datetime: Date; - }; - burst: { - latlng: LatLngExpression; - datetime: Date; - }; - landing: { - latlng: LatLngExpression; - datetime: Date; - }; + flight_path: LatLngExpression[]; + launch: Point; + burst: Point; + landing: Point; profile: string; flight_time: number; } - -export interface Point { - latlng: LatLngLiteral & { alt: number }; - datetime: Date; -} - -export interface PredictionData { - launch: Point; - landing: Point; - burst: Point; - flight_path: LatLngExpression[]; - flight_time: number; -} - -export interface TelemetryData { - launch: Point; - datapoints: TelemetryPoint[]; - flight_path: LatLngExpression[]; -} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f961cbf..cbb97e2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,5 @@
diff --git a/src/routes/predict/+page.svelte b/src/routes/predict/+page.svelte index 667c46e..62fc819 100644 --- a/src/routes/predict/+page.svelte +++ b/src/routes/predict/+page.svelte @@ -4,6 +4,7 @@ import Navbar from "$lib/components/Navbar.svelte"; import PanelContainer from "$lib/components/PanelContainer.svelte"; import TelemetryPanel from '$lib/components/TelemetryPanel.svelte'; + import ScenarioPanel from "$lib/components/ScenarioPanel.svelte"; import TabComponent from "$lib/components/TabComponent.svelte"; import { onMount } from "svelte"; import { PredictionStore } from "$lib/stores"; @@ -13,9 +14,10 @@ import L from "leaflet"; let map: Map | null = null; - let panel: PanelContainer | null = null; + let panelContainer: PanelContainer | null = null; + let controlPanel: ControlPanel | null = null; let selectionToastId: string | null = null; - let activeTab: 'control' | 'telemetry' = 'control'; + let activeTab: 'control' | 'scenario' | 'settings' | 'about' = 'scenario'; onMount(() => { PredictionStore.subscribe((data) => { @@ -24,10 +26,11 @@ } }); console.log("ControlPanel mounted"); - console.log(panel); + console.log(panelContainer); - if (panel) { - let element = panel.getElement(); + if (panelContainer) { + let element = panelContainer.getElement(); + if (!element) return; L.DomEvent.disableClickPropagation(element); L.DomEvent.disableScrollPropagation(element); } @@ -55,27 +58,23 @@ function handleCoordinateSelection(event: CustomEvent<{ lat: number; lng: number }>) { const { lat, lng } = event.detail; - panel?.updateLaunchPosition(lat, lng); + controlPanel?.updateLaunchPosition(lat, lng); console.log(`Selected coordinates: ${lat}, ${lng}`); if (selectionToastId) { removeToast(selectionToastId); selectionToastId = null; } } - - - -
- + {#if activeTab === 'control'} - - {:else if activeTab === 'telemetry'} - + + {:else if activeTab === 'scenario'} + + {:else if activeTab === 'settings'} + + {:else if activeTab === 'about'} + {/if}
diff --git a/src/routes/user/account/+page.svelte b/src/routes/user/account/+page.svelte index 9d97fbd..f8ba827 100644 --- a/src/routes/user/account/+page.svelte +++ b/src/routes/user/account/+page.svelte @@ -1,5 +1,5 @@
diff --git a/static/css/custom.css b/static/css/custom.css index dc82f73..e716523 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -92,6 +92,28 @@ border-radius: var(--bs-border-radius) !important; } +.leaflet-tooltip-top::before { + border-top-color: var(--bs-border-color) !important; +} +.leaflet-tooltip-bottom::before { + border-bottom-color: var(--bs-border-color) !important; +} +.leaflet-tooltip-left::before { + border-left-color: var(--bs-border-color) !important; +} +.leaflet-tooltip-right::before { + border-right-color: var(--bs-border-color) !important; +} + +.leaflet-tooltip { + background-color: var(--bs-body-bg) !important; + border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; + border-radius: var(--bs-border-radius) !important; + color: var(--bs-body-color); + box-shadow: none !important; +} + + @media (max-width: 767.98px) { .coordinates-display { diff --git a/static/ext/leaflet-ruler/icon.png b/static/ext/leaflet-ruler/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..028741e9f3e82d798d46b677fc9ad9badbf28159 GIT binary patch literal 756 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*1mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk5Uj7}P}D}aLRC7!;n>~M1xcvI-p~V4gYs(JQ z{XAtN9r~!Fyl&UZ%d3wTE1&zZ__mqi*`yAyr0&~kb0*KNb6#3@|8DTZ8;b&;_ugFT za_9EWuwMOh%N`wlW%BCtJ!f&5pczKXYeEItSgm+Ee|0yrnVz_ov|erJq_V~pwN*7U z&Z?=lh-wy|?y&vrztZSgO4t4?YDTx$eGarM;?3A<)Uf`O)9j8JRTXbGFW#}p-(rIO zbv>oD1FENbPH#TM_c-bQL(W90ElsCqF;y)5S8}d)Z`kQ?Cx0>g^KrLI4_WPd?Z?Dp z+NF`wM`i}Ku4TKttUcCL_Q=m=T)TNwZJw>ncAO*lcbm5H+A7OA(os(mZyaH&tJU<2 z;kS7EqsW3)EjMJ!t+StW44IdSaIewZf8=>(dY8J2Y7?{Ru_^kG5)CI`V|DkdP?&Y_ zVc+4ICR3u`-+D7~hm8M}T^VdwPFcp ztHiBAvB&WPP=h4MhT#0PlJdl&REF~Ma=pyF?Be9af>gcyqV(DCY@~pS7(8A5T-G@y GGywn@4lo7) literal 0 HcmV?d00001 diff --git a/static/ext/leaflet-ruler/leaflet-ruler.css b/static/ext/leaflet-ruler/leaflet-ruler.css new file mode 100644 index 0000000..69927c3 --- /dev/null +++ b/static/ext/leaflet-ruler/leaflet-ruler.css @@ -0,0 +1,41 @@ +.leaflet-ruler{ + height: 35px; + width: 35px; + background-image: url("./icon.png"); /*
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
*/ + background-repeat: no-repeat; + background-position: center; +} +.leaflet-ruler:hover{ + background-image: url("./icon.png"); /*
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
*/ +} +.leaflet-ruler-clicked{ + height: 35px; + width: 35px; + background-repeat: no-repeat; + background-position: center; + background-image: url("./icon.png"); + border-color: chartreuse !important; +} +.leaflet-bar{ + background-color: #ffffff; +} +.leaflet-control { + cursor: pointer; +} +.result-tooltip{ + background-color: white; + border-width: medium; + border-color: #de0000; + font-size: smaller; +} +.moving-tooltip{ + background-color: rgba(255, 255, 255, .7); + background-clip: padding-box; + opacity: 0.5; + border: dotted; + border-color: red; + font-size: smaller; +} +.plus-length{ + padding-left: 45px; +} \ No newline at end of file