Skip to content

Commit 4b0e70c

Browse files
feat: Introduce great circle predictor
1 parent 822bb5b commit 4b0e70c

8 files changed

Lines changed: 259 additions & 15 deletions

File tree

src/app/app.config.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ export function cleanConfig(
178178
color: 'rgba(221, 99, 0, 0.5)',
179179
weight: 4,
180180
dash: 'none'
181-
}
181+
},
182+
greatCircleStyle: {
183+
lineLength: { kind: 'distance', value: 0 },
184+
color: '#0080ff',
185+
weight: 1,
186+
dash: 'long',
187+
},
182188
},
183189
aisCogLine: 10,
184190
iconScale: 0.9,
@@ -247,7 +253,13 @@ export function cleanConfig(
247253
color: 'rgba(221, 99, 0, 0.5)',
248254
weight: 4,
249255
dash: 'none'
250-
}
256+
},
257+
greatCircleStyle: {
258+
color: '#0080ff',
259+
weight: 1,
260+
dash: 'long',
261+
lineLength: { kind: 'distance', value: 0 }
262+
},
251263
};
252264
// @todo remove (implemented) v2.22.2
253265
delete (settings as any).vessels.cogLine;
@@ -512,7 +524,13 @@ export function defaultConfig(): IAppConfig {
512524
color: 'rgba(221, 99, 0, 0.5)',
513525
weight: 4,
514526
dash: 'none'
515-
}
527+
},
528+
greatCircleStyle: {
529+
color: '#0080ff',
530+
weight: 1,
531+
dash: 'long',
532+
lineLength: { kind: 'distance', value: 0 }
533+
},
516534
},
517535
aisCogLine: 10, // ais COG line time (mins)
518536
iconScale: 0.9,

src/app/modules/map/fb-map.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,14 @@
753753
>
754754
</fb-cog-line>
755755
}
756+
@if (showGreatCircle) {
757+
<fb-great-circle
758+
[coords]="greatCircleArc()"
759+
[lineStyle]="app.config.vessels.selfLines.greatCircleStyle"
760+
[zIndex]="344"
761+
>
762+
</fb-great-circle>
763+
}
756764
@if (app.config.vessels.windVectors) {
757765
<fb-wind-lines
758766
[twd]="this.dfeat.active.wind.direction"

src/app/modules/map/fb-map.component.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export class FBMapComponent implements OnInit, OnDestroy {
193193
heading: []
194194
});
195195

196+
/** Densified great-circle arc line */
197+
protected greatCircleArc = signal<Position[]>([]);
198+
196199
protected overlay = signal<IPopover>({
197200
id: null,
198201
type: null,
@@ -1492,6 +1495,11 @@ export class FBMapComponent implements OnInit, OnDestroy {
14921495
}
14931496
}
14941497

1498+
protected get showGreatCircle(): boolean {
1499+
const ll = this.app.config.vessels.selfLines.greatCircleStyle?.lineLength;
1500+
return !!ll && ll.value > 0;
1501+
}
1502+
14951503
/** construct vessel lines for rendering */
14961504
protected drawVesselLines(vesselUpdate = false) {
14971505
// update vessel trail
@@ -1530,6 +1538,35 @@ export class FBMapComponent implements OnInit, OnDestroy {
15301538

15311539
return { cog, heading };
15321540
});
1541+
1542+
// render great-circle arc
1543+
this.greatCircleArc.update(() => {
1544+
const cogAngle = this.dfeat.active.cog;
1545+
const pos = this.dfeat.active.position;
1546+
const sog = this.dfeat.active.sog || 0;
1547+
if (cogAngle === null || cogAngle === undefined || !pos) return [];
1548+
const totalMeters = this.lineLengthToMeters(
1549+
this.app.config.vessels.selfLines.greatCircleStyle?.lineLength, sog
1550+
);
1551+
if (totalMeters <= 0) return [];
1552+
return this.densifyGreatCircle(pos, Convert.radiansToDegrees(cogAngle), totalMeters);
1553+
});
1554+
}
1555+
1556+
/**
1557+
* Densify a great-circle arc from an origin at a given initial bearing.
1558+
* Returns an array of [lon, lat] intermediate points.
1559+
*/
1560+
private densifyGreatCircle(
1561+
origin: Position, bearingDeg: number, distMeters: number, segments = 128
1562+
): Position[] {
1563+
const pts: Position[] = [origin];
1564+
const step = distMeters / segments;
1565+
for (let i = 1; i <= segments; i++) {
1566+
const pt = computeDestinationPoint(origin, step * i, bearingDeg);
1567+
pts.push([pt.longitude, pt.latitude]);
1568+
}
1569+
return pts;
15331570
}
15341571

15351572
/** calculate vessel & dest laylines & update signals */

src/app/modules/map/ol/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { AISVesselsLayerComponent } from './lib/resources/layer-aisvessels.compo
4343
import { AISTargetsTrackLayerComponent } from './lib/resources/layer-aistargets-track.component';
4444
import { RangeCirclesComponent } from './lib/navigation/layer-range-circles.component';
4545
import { CogLineComponent } from './lib/navigation/layer-cog-line.component';
46+
import { GreatCircleComponent } from './lib/navigation/layer-great-circle.component';
4647
import { WindLinesComponent } from './lib/navigation/layer-wind-lines.component';
4748
import { RasterChartLayerComponent } from './lib/charts/layer-raster-chart.component';
4849
import { VectorChartLayerComponent } from './lib/charts/layer-vector-chart.component';
@@ -100,6 +101,7 @@ export { AISVesselsLayerComponent } from './lib/resources/layer-aisvessels.compo
100101
export { AISTargetsTrackLayerComponent } from './lib/resources/layer-aistargets-track.component';
101102
export { RangeCirclesComponent } from './lib/navigation/layer-range-circles.component';
102103
export { CogLineComponent } from './lib/navigation/layer-cog-line.component';
104+
export { GreatCircleComponent } from './lib/navigation/layer-great-circle.component';
103105
export { WindLinesComponent } from './lib/navigation/layer-wind-lines.component';
104106
export { RasterChartLayerComponent } from './lib/charts/layer-raster-chart.component';
105107
export { VectorChartLayerComponent } from './lib/charts/layer-vector-chart.component';
@@ -152,6 +154,7 @@ const declarations = [
152154
AISTargetsTrackLayerComponent,
153155
RangeCirclesComponent,
154156
CogLineComponent,
157+
GreatCircleComponent,
155158
WindLinesComponent,
156159
RadarComponent,
157160
RasterChartLayerComponent,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
ChangeDetectorRef,
4+
Component,
5+
Input,
6+
OnChanges,
7+
OnDestroy,
8+
OnInit,
9+
Output,
10+
SimpleChanges,
11+
input,
12+
effect
13+
} from '@angular/core';
14+
import { Layer } from 'ol/layer';
15+
import { Feature } from 'ol';
16+
import VectorLayer from 'ol/layer/Vector';
17+
import VectorSource from 'ol/source/Vector';
18+
import { Style, Stroke } from 'ol/style';
19+
import { MultiLineString } from 'ol/geom';
20+
import { MapComponent } from '../map.component';
21+
import { Extent, Coordinate } from '../models';
22+
import { fromLonLatArray, splitAtAntimeridian, lineDashFor } from '../util';
23+
import { AsyncSubject } from 'rxjs';
24+
import { LineStyleConfig } from 'src/app/types';
25+
26+
/**
27+
* Great-circle arc line component.
28+
*
29+
* Receives an array of [lon, lat] coordinates that are already densified
30+
* (many intermediate great-circle points). Renders them as a multi-vertex
31+
* LineString that visually curves on a Mercator map.
32+
*/
33+
@Component({
34+
selector: 'ol-map > fb-great-circle',
35+
template: '<ng-content></ng-content>',
36+
changeDetection: ChangeDetectionStrategy.OnPush,
37+
standalone: false
38+
})
39+
export class GreatCircleComponent implements OnInit, OnDestroy, OnChanges {
40+
protected layer: Layer;
41+
public source: VectorSource;
42+
protected features: Array<Feature> = [];
43+
44+
@Output() layerReady: AsyncSubject<Layer> = new AsyncSubject();
45+
46+
protected coords = input<Coordinate[]>();
47+
protected lineStyle = input<LineStyleConfig>();
48+
49+
@Input() opacity: number;
50+
@Input() visible: boolean;
51+
@Input() extent: Extent;
52+
@Input() zIndex: number;
53+
@Input() minResolution: number;
54+
@Input() maxResolution: number;
55+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
56+
@Input() layerProperties: { [index: string]: any };
57+
58+
constructor(
59+
protected changeDetectorRef: ChangeDetectorRef,
60+
protected mapComponent: MapComponent
61+
) {
62+
this.changeDetectorRef.detach();
63+
effect(() => {
64+
this.coords();
65+
this.lineStyle();
66+
this.parseInput();
67+
if (this.source) {
68+
this.source.clear();
69+
this.source.addFeatures(this.features);
70+
}
71+
});
72+
}
73+
74+
ngOnInit() {
75+
this.parseInput();
76+
this.source = new VectorSource({ features: this.features });
77+
this.layer = new VectorLayer(
78+
Object.assign(this, { ...this.layerProperties })
79+
);
80+
const map = this.mapComponent.getMap();
81+
if (this.layer && map) {
82+
map.addLayer(this.layer);
83+
map.render();
84+
this.layerReady.next(this.layer);
85+
this.layerReady.complete();
86+
}
87+
}
88+
89+
ngOnChanges(changes: SimpleChanges) {
90+
if (this.layer) {
91+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92+
const properties: { [index: string]: any } = {};
93+
for (const key in changes) {
94+
if (key === 'layerProperties') {
95+
this.layer.setProperties(properties, false);
96+
} else {
97+
properties[key] = changes[key].currentValue;
98+
}
99+
}
100+
this.layer.setProperties(properties, false);
101+
}
102+
}
103+
104+
ngOnDestroy() {
105+
const map = this.mapComponent.getMap();
106+
if (this.layer && map) {
107+
map.removeLayer(this.layer);
108+
map.render();
109+
this.layer = null;
110+
}
111+
}
112+
113+
parseInput() {
114+
const fa: Feature[] = [];
115+
const c = this.coords();
116+
if (Array.isArray(c) && c.length >= 2) {
117+
const projected = splitAtAntimeridian(c.map(p => [p[0], p[1]] as Coordinate))
118+
.map(seg => fromLonLatArray(seg));
119+
const feat = new Feature({
120+
geometry: new MultiLineString(projected)
121+
});
122+
feat.setId('greatCircleSelf');
123+
const ls = this.lineStyle();
124+
const color = ls?.color ?? 'rgba(0,128,255,0.7)';
125+
const lineWidth = ls?.weight ?? 1;
126+
const lineDash = ls ? lineDashFor(ls.dash) : [8, 4];
127+
feat.setStyle(
128+
new Style({
129+
stroke: new Stroke({ color, width: lineWidth, lineDash })
130+
})
131+
);
132+
fa.push(feat);
133+
}
134+
this.features = fa;
135+
}
136+
}

src/app/modules/settings/components/settings-dialog.html

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,46 @@
991991
</fieldset>
992992
</div>
993993
</div>
994+
<div class="setting-card-row" style="padding-bottom: 10px">
995+
<div class="setting-card-row-item">
996+
<fieldset>
997+
<legend>Great Circle Line</legend>
998+
<div
999+
class="setting-card-row"
1000+
style="padding-bottom: 10px"
1001+
>
1002+
<div class="setting-card-row-item">
1003+
<mat-form-field style="width: 110px">
1004+
<mat-label>Length</mat-label>
1005+
<input matInput type="number" min="0"
1006+
[(ngModel)]="facade.settings.vessels.selfLines.greatCircleStyle.lineLength.value"
1007+
(change)="persistModel()"
1008+
>
1009+
</mat-form-field>
1010+
<mat-form-field style="width: 110px">
1011+
<mat-label>Unit</mat-label>
1012+
<mat-select
1013+
[(ngModel)]="facade.settings.vessels.selfLines.greatCircleStyle.lineLength.kind"
1014+
(selectionChange)="persistModel()"
1015+
>
1016+
@for(k of options.lineLengthKinds; track k[0]) {
1017+
<mat-option [value]="k[0]">{{k[1]}}</mat-option>
1018+
}
1019+
</mat-select>
1020+
</mat-form-field>
1021+
</div>
1022+
<div class="setting-card-row-item">
1023+
<linestyle-select
1024+
[color]="facade.settings.vessels.selfLines.greatCircleStyle.color"
1025+
[dash]="facade.settings.vessels.selfLines.greatCircleStyle.dash"
1026+
[weight]="facade.settings.vessels.selfLines.greatCircleStyle.weight"
1027+
(selectionChange)="onGreatCircleStyle($event)"
1028+
></linestyle-select>
1029+
</div>
1030+
</div>
1031+
</fieldset>
1032+
</div>
1033+
</div>
9941034
</fieldset>
9951035
</div>
9961036
<div class="setting-card-row-item">

src/app/modules/settings/components/settings-dialog.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ export class SettingsDialog implements OnInit {
182182
}
183183
}
184184

185+
/** handle great circle line style change */
186+
onGreatCircleStyle(value: { lineStyle: LineStyleDef; config: LineStyleConfig }) {
187+
const l = this.facade.settings.vessels.selfLines.greatCircleStyle;
188+
if (l) {
189+
l.color = value.config.color;
190+
l.dash = value.config.dash;
191+
l.weight = value.config.weight;
192+
this.persistModel();
193+
}
194+
}
195+
185196
/** handle route / navigation line style change */
186197
onRouteStyle(
187198
target: 'activeRoute' | 'defaultRoute' | 'activeSegment' | 'destination',

src/app/types/index.d.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,9 @@ export interface IAppConfig {
143143
windVectors: boolean; // display vessel TWD, AWD vectors
144144
laylines: boolean;
145145
selfLines: {
146-
cog: {
147-
lineLength: ILineLengthDef;
148-
color: string;
149-
weight: number;
150-
dash: LineStyleDash;
151-
};
152-
heading: {
153-
lineLength: ILineLengthDef;
154-
color: string;
155-
weight: number;
156-
dash: LineStyleDash;
157-
};
146+
cog: LineStyleConfig & { lineLength: ILineLengthDef };
147+
heading: LineStyleConfig & { lineLength: ILineLengthDef };
148+
greatCircleStyle: LineStyleConfig & { lineLength: ILineLengthDef };
158149
};
159150
aisCogLine: number; // (minutes) length = cogLine * sog
160151
iconScale: number; // scale to apply to self Vessel icon

0 commit comments

Comments
 (0)