diff --git a/src/app/modules/map/fb-map.component.ts b/src/app/modules/map/fb-map.component.ts index bafc2cd90..6dadec09b 100644 --- a/src/app/modules/map/fb-map.component.ts +++ b/src/app/modules/map/fb-map.component.ts @@ -36,11 +36,21 @@ import { S57_CLICKABLE_LAYERS, S57_NAMES } from './popovers'; -import { FreeboardOpenlayersModule } from 'src/app/modules/map/ol'; +import { + FreeboardOpenlayersModule, + FreeboardRouteLayerComponent +} from 'src/app/modules/map/ol'; import { CoordsPipe } from 'src/app/lib/pipes'; import { computeDestinationPoint, getGreatCircleBearing } from 'geolib'; -import { toLonLat } from 'ol/proj'; +import { fromLonLat, toLonLat } from 'ol/proj'; +import { + LineString as OlLineString +} from 'ol/geom'; +import { + fromLonLatArray, + mapifyCoords +} from './ol/lib/util'; import { Style, Stroke, Fill } from 'ol/style'; import { Collection, Feature } from 'ol'; import { Feature as GeoJsonFeature } from 'geojson'; @@ -179,6 +189,8 @@ export class FBMapComponent implements OnInit, OnDestroy { @ViewChild(MatMenuTrigger, { static: true }) contextMenu: MatMenuTrigger; @ViewChild('olMap', { static: false }) olMap: MapComponent; + @ViewChild(FreeboardRouteLayerComponent) + routeLayer: FreeboardRouteLayerComponent; scaleUnits = input(''); @@ -266,6 +278,8 @@ export class FBMapComponent implements OnInit, OnDestroy { protected course = inject(CourseService); protected mapInteract = inject(FBMapInteractService); protected mapService = inject(MapService); + /** Source feature whose MultiLineString geometry is updated live during route editing. */ + private routeEditSourceFeature: Feature | null = null; private settings = inject(SettingsFacade); private bottomSheet = inject(MatBottomSheet); private infoPanel = inject(InfoPanelFacade); @@ -446,8 +460,10 @@ export class FBMapComponent implements OnInit, OnDestroy { ) { if (this.overlay().isSelf) { this.overlay.update((current) => { + const newPos = this.dfeat.self.position; + const wo = Math.round((current.position[0] - newPos[0]) / 360) * 360; return Object.assign({}, current, { - position: this.dfeat.self.position, + position: wo !== 0 ? [newPos[0] + wo, newPos[1]] : newPos, vessel: this.dfeat.self }); }); @@ -464,28 +480,16 @@ export class FBMapComponent implements OnInit, OnDestroy { } else { if (this.overlay().type === 'ais') { this.overlay.update((current) => { + const newPos = this.dfeat.ais.get(current.id).position; + const wo = Math.round((current.position[0] - newPos[0]) / 360) * 360; return Object.assign({}, current, { - position: this.dfeat.ais.get(current.id).position, + position: wo !== 0 ? [newPos[0] + wo, newPos[1]] : newPos, vessel: this.dfeat.ais.get(current.id) }); }); } } } - if (this.app.mapExtent()[0] < 180 && this.app.mapExtent()[2] > 180) { - // if dateline is in view adjust overlay position to stay with vessel - - if ( - this.overlay().position[0] < 0 && - this.overlay().position[0] > -180 - ) { - this.overlay.update((current) => { - return Object.assign({}, current, { - position: [current.position[0] + 360, current.position[1]] - }); - }); - } - } } this.drawVesselLines(this.app.data.vessels.self.positionReceived); this.rotateMap(); @@ -846,13 +850,66 @@ export class FBMapComponent implements OnInit, OnDestroy { if (this.mapInteract.draw.features.getLength() === 0) { return; } - this.mapInteract.startModifying(this.overlay()); + this.routeEditSourceFeature = null; if (this.overlay().type === 'route') { - this.mapInteract.measurementCoords = this.skres.fromCache( + const rawCoords = this.skres.fromCache( 'routes', this.overlay().id )[1].feature.geometry.coordinates; + // Keep a reference to the rendered MultiLineString feature so its + // geometry can be updated live after each vertex drop. + this.routeEditSourceFeature = + this.mapInteract.draw.features.getArray()[0] as Feature; + // Give OL Modify a scratch LineString with unwrapped (non-split) coords + // so there are no phantom ±180° boundary vertices during editing. + const mc = mapifyCoords([...rawCoords]); + // Shift the scratch geometry to the world copy the user clicked in. + // OL always returns the primary-world Feature regardless of which + // rendered copy was hit, so we identify the correct copy explicitly. + // + // Strategy: the click's raw Mercator x encodes the world copy (thanks to + // the transform() fix in map.component.ts). mapifyCoords() may produce + // a geometry spanning n world widths (e.g. a route crossing the + // antimeridian multiple times). We search all shifts in the range + // [m-n-1 .. m+n+1] where m = clicked world copy and n = route width in + // world copies, and pick the shift that places the nearest vertex closest + // to the click. + const mercCoords = fromLonLatArray(mc) as Coordinate[]; + const clickMerc = fromLonLat(this.overlay().position as [number, number]); + const clickMercX = clickMerc[0]; + const clickMercY = clickMerc[1]; + const worldWidth = 2 * 20037508.3427892; + let minX = mercCoords[0][0], maxX = mercCoords[0][0]; + for (const c of mercCoords) { + if (c[0] < minX) minX = c[0]; + if (c[0] > maxX) maxX = c[0]; + } + const m = Math.round(clickMercX / worldWidth); + const n = Math.ceil((maxX - minX) / worldWidth); + let bestShift = 0, bestDist = Infinity; + for (let k = m - n - 1; k <= m + n + 1; k++) { + const shift = k * worldWidth; + for (const c of mercCoords) { + const dx = c[0] + shift - clickMercX; + const dy = c[1] - clickMercY; + const dist = dx * dx + dy * dy; // squared Euclidean, no sqrt needed + if (dist < bestDist) { bestDist = dist; bestShift = shift; } + } + } + if (bestShift !== 0) { + mercCoords.forEach((c) => (c[0] += bestShift)); + } + const scratchGeom = new OlLineString(mercCoords); + const scratchFeat = new Feature({ geometry: scratchGeom }); + scratchFeat.setId(this.routeEditSourceFeature.getId()); + scratchFeat.set( + 'pointMetadata', + this.routeEditSourceFeature.get('pointMetadata') + ); + this.mapInteract.draw.features = new Collection([scratchFeat]); + this.mapInteract.measurementCoords = rawCoords; } + this.mapInteract.startModifying(this.overlay()); if (featureType === 'anchor') { this.overlay().type = featureType; this.mapInteract.draw.forSave.id = featureType; @@ -891,8 +948,14 @@ export class FBMapComponent implements OnInit, OnDestroy { } let pc; if (fid.split('.')[0] === 'route') { + // c is a flat Coordinate[] from the scratch LineString (EPSG:3857) pc = this.transformCoordsArray(c); this.mapInteract.measurementCoords = pc; + // Re-split at antimeridian and update all source segment features so + // the rendered route reflects the edit after each vertex drop. + if (this.routeLayer) { + this.routeLayer.updateRouteSegments(fid.split('.')[1], pc); + } } else if (fid.split('.')[0] === 'region') { for (let e = 0; e < c.length; e++) { if (this.isCoordsArray(c[e])) { @@ -1145,6 +1208,11 @@ export class FBMapComponent implements OnInit, OnDestroy { let item = null; const t = id.split('.'); let aid: string; + // Shift stored geographic positions to the clicked world copy so the popup + // appears at the same world copy the user clicked, not the primary world. + const worldOffset = coord ? Math.floor((coord[0] + 180) / 360) * 360 : 0; + const wrapPos = (pos: Position): Position => + worldOffset !== 0 ? [pos[0] + worldOffset, pos[1]] : pos; switch (t[0]) { case 's57': @@ -1198,7 +1266,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = 'ais'; poData.isSelf = true; poData.vessel = this.dfeat.self; - poData.position = this.dfeat.self.position; + poData.position = wrapPos(this.dfeat.self.position); poData.show = true; break; case 'ais-vessels': @@ -1209,7 +1277,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = 'ais'; poData.id = aid; poData.vessel = this.dfeat.ais.get(aid); - poData.position = poData.vessel.position; + poData.position = wrapPos(poData.vessel.position); poData.show = true; break; case 'atons': @@ -1221,7 +1289,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = 'aton'; poData.id = id; poData.aton = this.app.data.atons.get(id); - poData.position = poData.aton.position; + poData.position = wrapPos(poData.aton.position); poData.show = true; break; case 'sar': @@ -1231,7 +1299,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = 'aton'; poData.id = id; poData.aton = this.app.data.sar.get(id); - poData.position = poData.aton.position; + poData.position = wrapPos(poData.aton.position); poData.show = true; break; case 'meteo': @@ -1241,7 +1309,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = t[0]; poData.id = id; poData.aton = this.app.data.meteo.get(id); - poData.position = poData.aton.position; + poData.position = wrapPos(poData.aton.position); poData.show = true; break; case 'aircraft': @@ -1251,7 +1319,7 @@ export class FBMapComponent implements OnInit, OnDestroy { poData.type = t[0]; poData.id = id; poData.aircraft = this.app.data.aircraft.get(id); - poData.position = poData.aircraft.position; + poData.position = wrapPos(poData.aircraft.position); poData.show = true; break; case 'region': diff --git a/src/app/modules/map/ol/lib/charts/layer-chart-bounds.component.ts b/src/app/modules/map/ol/lib/charts/layer-chart-bounds.component.ts index 86b4e3480..533e09254 100644 --- a/src/app/modules/map/ol/lib/charts/layer-chart-bounds.component.ts +++ b/src/app/modules/map/ol/lib/charts/layer-chart-bounds.component.ts @@ -151,8 +151,7 @@ export class ChartBoundsLayerComponent extends FBFeatureLayerComponent { [bounds[2], bounds[3]], [bounds[0], bounds[3]], [bounds[0], bounds[1]] - ], - 0 + ] ); return [fromLonLatArray(rect)]; } diff --git a/src/app/modules/map/ol/lib/map.component.ts b/src/app/modules/map/ol/lib/map.component.ts index b5e0958f9..be702dea4 100644 --- a/src/app/modules/map/ol/lib/map.component.ts +++ b/src/app/modules/map/ol/lib/map.component.ts @@ -19,7 +19,7 @@ import RenderEvent from 'ol/render/Event'; import { MapService } from './map.service'; import { MapReadyEvent } from './models'; import { AsyncSubject } from 'rxjs'; -import { toLonLat, transformExtent } from 'ol/proj'; +import { toLonLat, transform, transformExtent } from 'ol/proj'; import { Coordinate } from 'ol/coordinate'; import { FeatureLike } from 'ol/Feature'; import { Extent } from 'ol/extent'; @@ -262,7 +262,9 @@ export class MapComponent implements OnInit, OnDestroy { private emitRightClickEvent = (event: MouseEvent) => { event.preventDefault(); - const c = this.map.getEventCoordinate(event); + // Use getEventCoordinateInternal (EPSG:3857) so the pixel lookup is + // correct and the lon/lat preserves the world-copy offset. + const c = this.map.getEventCoordinateInternal(event); this.mapRightClick.emit({ features: this.map.getFeaturesAtPixel( this.map.getPixelFromCoordinateInternal(c), @@ -270,7 +272,7 @@ export class MapComponent implements OnInit, OnDestroy { hitTolerance: this.hitTolerance } ), - lonlat: toLonLat(c) + lonlat: transform(c, 'EPSG:3857', 'EPSG:4326') }); }; private emitSingleClickEvent = (event: MapBrowserEvent) => { @@ -286,7 +288,11 @@ export class MapComponent implements OnInit, OnDestroy { features: this.map.getFeaturesAtPixel(event.pixel, { hitTolerance: this.hitTolerance }), - lonlat: toLonLat(event.coordinate) + // Use raw transform (not toLonLat) so the longitude preserves the + // world-copy offset (e.g. 190° for world +1). This ensures that + // fromLonLat() in overlay.component.ts produces the correct Mercator + // position so OL places the popup in the world copy the user clicked. + lonlat: transform(event.coordinate, 'EPSG:3857', 'EPSG:4326') }); } diff --git a/src/app/modules/map/ol/lib/resources/layer-aistargets-track.component.ts b/src/app/modules/map/ol/lib/resources/layer-aistargets-track.component.ts index fb39bcc56..684e7ee11 100644 --- a/src/app/modules/map/ol/lib/resources/layer-aistargets-track.component.ts +++ b/src/app/modules/map/ol/lib/resources/layer-aistargets-track.component.ts @@ -9,7 +9,7 @@ import { Feature } from 'ol'; import { Style, Stroke } from 'ol/style'; import { MultiLineString } from 'ol/geom'; import { MapComponent } from '../map.component'; -import { fromLonLatArray, mapifyCoords } from '../util'; +import { fromLonLatArray, splitAtAntimeridian } from '../util'; import { AISBaseLayerComponent } from './ais-base.component'; // ** Signal K AIS targets track ** @@ -159,16 +159,13 @@ export class AISTargetsTrackLayerComponent extends AISBaseLayerComponent { } } - // ** mapify and transform MultiLineString coordinates + // ** split at antimeridian and transform MultiLineString coordinates // eslint-disable-next-line @typescript-eslint/no-explicit-any parseCoordinates(trk: Array) { // ** handle dateline crossing ** - const tc = trk.map((mls) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lines: Array = []; - mls.forEach((line) => lines.push(mapifyCoords(line))); - return lines; - }); + const tc = trk.map((mls) => + mls.flatMap((line) => splitAtAntimeridian(line)) + ); return fromLonLatArray(tc); } } diff --git a/src/app/modules/map/ol/lib/resources/layer-routes.component.ts b/src/app/modules/map/ol/lib/resources/layer-routes.component.ts index 7cb4977cf..cf913144b 100644 --- a/src/app/modules/map/ol/lib/resources/layer-routes.component.ts +++ b/src/app/modules/map/ol/lib/resources/layer-routes.component.ts @@ -7,12 +7,10 @@ import { } from '@angular/core'; import { Feature } from 'ol'; import { Style, Stroke, Fill, Circle, RegularShape } from 'ol/style'; -import { LineString, Point } from 'ol/geom'; -import { toLonLat } from 'ol/proj'; +import { MultiLineString, Point } from 'ol/geom'; import { MapComponent } from '../map.component'; -import { fromLonLatArray, mapifyCoords } from '../util'; -import { getRhumbLineBearing } from 'geolib'; -import { GeolibInputCoordinates } from 'geolib/es/types'; +import { Coordinate } from '../models'; +import { fromLonLatArray, splitAtAntimeridian } from '../util'; import { FBFeatureLayerComponent } from '../sk-feature.component'; import { FBRoutes } from 'src/app/types'; @@ -62,14 +60,22 @@ export class FreeboardRouteLayerComponent extends FBFeatureLayerComponent { for (const r of routes) { if (r[2]) { // selected - const mc = mapifyCoords(r[1].feature.geometry.coordinates); - const c = fromLonLatArray(mc); + const rawCoords = r[1].feature.geometry.coordinates; + // Split at antimeridian: each segment stays within [-180, 180] so + // OL wrapX can clone every segment into all adjacent world copies. + const multiCoords = splitAtAntimeridian(rawCoords).map( + (seg) => fromLonLatArray(seg) as Coordinate[] + ); const f = new Feature({ - geometry: new LineString(c), + geometry: new MultiLineString(multiCoords), name: r[1].name }); f.setId('route.' + r[0]); f.set('pointMetadata', r[1].feature.properties.coordinatesMeta ?? null); + // Store waypoint Mercator coordinates separately so buildStyle can place + // markers on actual waypoints without having to filter out the + // interpolated ±180° split endpoints from the MultiLineString. + f.set('waypointMercCoords', fromLonLatArray(rawCoords) as Coordinate[]); f.setStyle(this.buildStyle(f)); fa.push(f); } @@ -78,12 +84,28 @@ export class FreeboardRouteLayerComponent extends FBFeatureLayerComponent { this.updateLabels(); } + /** + * Update the rendered LineString geometry after a vertex drag. + * Called from fb-map after each onModifyEnd while editing a route. + * @param routeId The bare route UUID (without the 'route.' prefix) + * @param newCoords New waypoint coordinates in lon/lat (EPSG:4326) + */ + updateRouteSegments(routeId: string, newCoords: Coordinate[]) { + const f = this.source.getFeatureById('route.' + routeId) as Feature; + if (f) { + const multiCoords = splitAtAntimeridian(newCoords).map( + (seg) => fromLonLatArray(seg) as Coordinate[] + ); + (f.getGeometry() as MultiLineString).setCoordinates(multiCoords); + f.set('waypointMercCoords', fromLonLatArray(newCoords) as Coordinate[]); + f.setStyle(this.buildStyle(f)); + } + } + // Route style function buildStyle(feature: Feature) { - const geometry = feature.getGeometry() as LineString; const styles = []; - const id = (feature.getId() as string).split('.').slice(-1)[0]; - const isActive = id === this.activeRoute; + const isActive = (feature.getId() as string).split('.')[1] === this.activeRoute; let ptFill: Fill; if (typeof this.routeStyles === 'undefined') { @@ -115,85 +137,80 @@ export class FreeboardRouteLayerComponent extends FBFeatureLayerComponent { }); } - // point styles - let idx = 0; - const l = geometry.getCoordinates().length; - geometry.forEachSegment((start, end) => { - // start point + // Waypoint Mercator coordinates are stored on the feature at creation/update + // time. They correspond to the actual route waypoints in the primary world + // ([-180°, 180°] → [-20037508, 20037508] m), so OL's wrapX mechanism + // correctly clones the resulting Point markers into every world copy. + const waypoints: Coordinate[] = feature.get('waypointMercCoords') ?? []; + const worldWidth = 2 * 20037508.3427892; + + const l = waypoints.length; + for (let idx = 0; idx < l; idx++) { + const c = waypoints[idx]; if (idx === 0) { + // start point styles.push( new Style({ - geometry: new Point(start), + geometry: new Point(c), image: new Circle({ radius: 5, - stroke: new Stroke({ - width: 1, - color: 'white' - }), + stroke: new Stroke({ width: 1, color: 'white' }), fill: ptFill }) }) ); - } else { - if (isActive) { - styles.push( - new Style({ - geometry: new Point(start), - image: new Circle({ - radius: 4, - stroke: new Stroke({ - width: 1, - color: 'white' - }), - fill: ptFill - }) - }) - ); - } else { - const d = getRhumbLineBearing( - toLonLat(start) as GeolibInputCoordinates, - toLonLat(end) as GeolibInputCoordinates - ); - const rotation = (d * Math.PI) / 180; - styles.push( - new Style({ - geometry: new Point(start), - image: new RegularShape({ - radius: 6, - stroke: new Stroke({ - width: 1, - color: 'white' - }), - fill: ptFill, - points: 3, - angle: 0, - rotateWithView: true, - rotation: rotation - }) - }) - ); - } - } - // last point - if (idx === l - 2) { + } else if (idx === l - 1) { + // end point styles.push( new Style({ - geometry: new Point(end), + geometry: new Point(c), image: new RegularShape({ radius: 6, - stroke: new Stroke({ - width: 1, - color: 'white' - }), + stroke: new Stroke({ width: 1, color: 'white' }), fill: ptFill, points: 4, angle: Math.PI / 4 }) }) ); + } else if (isActive) { + // intermediate waypoint (active route) + styles.push( + new Style({ + geometry: new Point(c), + image: new Circle({ + radius: 4, + stroke: new Stroke({ width: 1, color: 'white' }), + fill: ptFill + }) + }) + ); + } else { + // intermediate waypoint (inactive): arrow pointing towards next waypoint. + // Compute bearing in Mercator space; detect antimeridian crossings by + // checking if |dx| > half a world width, and wrap accordingly so the + // arrow points in the correct direction of travel. + const prev = waypoints[idx - 1]; + let dx = c[0] - prev[0]; + if (dx > worldWidth / 2) dx -= worldWidth; + else if (dx < -worldWidth / 2) dx += worldWidth; + const rotation = Math.atan2(dx, c[1] - prev[1]); + styles.push( + new Style({ + geometry: new Point(c), + image: new RegularShape({ + radius: 6, + stroke: new Stroke({ width: 1, color: 'white' }), + fill: ptFill, + points: 3, + angle: 0, + rotateWithView: true, + rotation: rotation + }) + }) + ); } - idx++; - }); + } return styles; } } diff --git a/src/app/modules/map/ol/lib/resources/layer-tracks.component.ts b/src/app/modules/map/ol/lib/resources/layer-tracks.component.ts index 45f5c9786..ef6876030 100644 --- a/src/app/modules/map/ol/lib/resources/layer-tracks.component.ts +++ b/src/app/modules/map/ol/lib/resources/layer-tracks.component.ts @@ -9,7 +9,7 @@ import { Feature } from 'ol'; import { Style, Text, Stroke } from 'ol/style'; import { MultiLineString } from 'ol/geom'; import { MapComponent } from '../map.component'; -import { fromLonLatArray, mapifyCoords } from '../util'; +import { fromLonLatArray, splitAtAntimeridian } from '../util'; import { SKTrack } from 'src/app/modules'; import { FBFeatureLayerComponent } from '../sk-feature.component'; import { FBTracks } from 'src/app/types'; @@ -98,11 +98,10 @@ export class TrackLayerComponent extends FBFeatureLayerComponent { } } - // ** mapify and transform MultiLineString coordinates + // ** split at antimeridian and transform MultiLineString coordinates // eslint-disable-next-line @typescript-eslint/no-explicit-any parseCoordinates(mls: Array) { - const lines = []; - mls.forEach((line) => lines.push(mapifyCoords(line))); + const lines = mls.flatMap((line) => splitAtAntimeridian(line)); return fromLonLatArray(lines); } } diff --git a/src/app/modules/map/ol/lib/util.ts b/src/app/modules/map/ol/lib/util.ts index b7437aace..8e55d2a49 100644 --- a/src/app/modules/map/ol/lib/util.ts +++ b/src/app/modules/map/ol/lib/util.ts @@ -40,46 +40,89 @@ export function fromLonLatArray( } } -/** DateLine Crossing: - * returns true if point is in the zone for dateline transition - * zoneValue: lower end of 180 to xx range within which Longitude must fall for retun value to be true - **/ -export function inDLCrossingZone(coord: Coordinate, zoneValue = 170) { - return Math.abs(coord[0]) >= zoneValue ? true : false; -} - -// update linestring coords for map display (including dateline crossing) -export function mapifyCoords( - coords: Array, - zoneValue?: number -): Array { - if (coords.length === 0) { +/** + * Unwrap longitudes so that consecutive points never jump by more than 180°, + * then shift the whole line so the first coordinate is in [-180, 180]. + * This keeps the resulting extent within one Mercator world width so that + * OpenLayers wrapX can clone the feature correctly in adjacent world copies. + */ +export function mapifyCoords(coords: Array): Array { + if (coords.length < 2) { return coords; } - let dlCrossing = 0; - const last = coords[0]; - for (let i = 0; i < coords.length; i++) { - if ( - inDLCrossingZone(coords[i], zoneValue) || - inDLCrossingZone(last, zoneValue) - ) { - dlCrossing = - last[0] > 0 && coords[i][0] < 0 - ? 1 - : last[0] < 0 && coords[i][0] > 0 - ? -1 - : 0; - if (dlCrossing === 1) { - coords[i][0] = coords[i][0] + 360; - } - if (dlCrossing === -1) { - coords[i][0] = Math.abs(coords[i][0]) - 360; - } - } + // Normalise the first point to [-180, 180]. + while (coords[0][0] > 180) coords[0][0] -= 360; + while (coords[0][0] < -180) coords[0][0] += 360; + // Unwrap subsequent points relative to their predecessor. + for (let i = 1; i < coords.length; i++) { + while (coords[i][0] - coords[i - 1][0] > 180) coords[i][0] -= 360; + while (coords[i - 1][0] - coords[i][0] > 180) coords[i][0] += 360; } return coords; } +/** + * Split a LineString at each antimeridian (±180°) crossing. + * Input coordinates must be in [-180, 180]. + * Returns an array of segments, each fully within [-180, 180], with + * interpolated endpoints at exactly ±180° where crossings occur. + * Each segment can be passed independently to fromLonLatArray so that + * OpenLayers wrapX cloning works correctly on each compact piece. + */ +/** Convert latitude (degrees) to Mercator Y. */ +function latToMercY(latDeg: number): number { + const latRad = (latDeg * Math.PI) / 180; + return Math.log(Math.tan(Math.PI / 4 + latRad / 2)); +} + +/** Convert Mercator Y back to latitude (degrees). */ +function mercYToLat(mercY: number): number { + return ((2 * Math.atan(Math.exp(mercY)) - Math.PI / 2) * 180) / Math.PI; +} + +export function splitAtAntimeridian(coords: Coordinate[]): Coordinate[][] { + if (coords.length < 2) return coords.length === 0 ? [] : [coords]; + + const segments: Coordinate[][] = []; + let current: Coordinate[] = [[coords[0][0], coords[0][1]]]; + + for (let i = 1; i < coords.length; i++) { + const [x0, y0] = current[current.length - 1]; + const [x1, y1] = [coords[i][0], coords[i][1]]; + const dLon = x1 - x0; + + if (dLon > 180) { + // Westward crossing: x0 is east (positive), x1 is west (negative) + const dLonUnwrapped = dLon - 360; + const t = (-180 - x0) / dLonUnwrapped; + const yIntercept = mercYToLat( + latToMercY(y0) + t * (latToMercY(y1) - latToMercY(y0)) + ); + current.push([-180, yIntercept]); + segments.push(current); + current = [[180, yIntercept], [x1, y1]]; + } else if (dLon < -180) { + // Eastward crossing: x0 is west (negative), x1 is east (positive) + const dLonUnwrapped = dLon + 360; + const t = (180 - x0) / dLonUnwrapped; + const yIntercept = mercYToLat( + latToMercY(y0) + t * (latToMercY(y1) - latToMercY(y0)) + ); + current.push([180, yIntercept]); + segments.push(current); + current = [[-180, yIntercept], [x1, y1]]; + } else { + current.push([x1, y1]); + } + } + + if (current.length >= 2) { + segments.push(current); + } + + return segments; +} + // ** return adjusted radius to correctly render circle on ground at given position. export function mapifyRadius(radius: number, position: Coordinate): number { if (typeof radius === 'undefined' || typeof position === 'undefined') { diff --git a/src/app/modules/map/ol/lib/vessel/layer-vessel-trail.component.ts b/src/app/modules/map/ol/lib/vessel/layer-vessel-trail.component.ts index c6a5e1def..1aebab792 100644 --- a/src/app/modules/map/ol/lib/vessel/layer-vessel-trail.component.ts +++ b/src/app/modules/map/ol/lib/vessel/layer-vessel-trail.component.ts @@ -14,10 +14,10 @@ import { Feature } from 'ol'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { Style, Stroke } from 'ol/style'; -import { Geometry, MultiLineString, LineString } from 'ol/geom'; +import { Geometry, MultiLineString } from 'ol/geom'; import { MapComponent } from '../map.component'; import { Extent, Coordinate } from '../models'; -import { fromLonLatArray, mapifyCoords } from '../util'; +import { fromLonLatArray, splitAtAntimeridian } from '../util'; import { AsyncSubject } from 'rxjs'; // ** Freeboard Vessel trail component ** @@ -131,10 +131,12 @@ export class VesselTrailComponent implements OnInit, OnDestroy, OnChanges { if (!this.localTrail) { return; } - const c = fromLonLatArray(mapifyCoords(this.localTrail)); + const ca = splitAtAntimeridian(this.localTrail).map((seg) => + fromLonLatArray(seg) + ); if (!this.trailLocal) { // create feature - this.trailLocal = new Feature(new LineString(c)); + this.trailLocal = new Feature(new MultiLineString(ca)); this.trailLocal.setId('trail.self.local'); this.trailLocal.setStyle(this.buildStyle('local')); } else { @@ -144,7 +146,7 @@ export class VesselTrailComponent implements OnInit, OnDestroy, OnChanges { ) as Feature; if (this.localTrail && Array.isArray(this.localTrail)) { const g: Geometry = this.trailLocal.getGeometry(); - (g as LineString).setCoordinates(c); + (g as MultiLineString).setCoordinates(ca); } } } @@ -153,9 +155,9 @@ export class VesselTrailComponent implements OnInit, OnDestroy, OnChanges { if (!this.serverTrail) { return; } - const ca = this.serverTrail.map((t: Array) => { - return fromLonLatArray(mapifyCoords(t)); - }); + const ca = this.serverTrail.flatMap((t: Array) => + splitAtAntimeridian(t).map((seg) => fromLonLatArray(seg)) + ); if (!this.trailServer) { // create feature this.trailServer = new Feature(new MultiLineString(ca));