import {
  Component,
  OnInit,
  OnDestroy,
  Input,
  Output,
  EventEmitter,
  ViewChild,
  SimpleChanges,
  signal,
  computed,
  input,
  effect,
  inject,
  NgZone
} from '@angular/core';

import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatBottomSheet } from '@angular/material/bottom-sheet';

// ** OL & popvers **
import {
  PopoverComponent,
  FeatureListPopoverComponent,
  ChartListPopoverComponent,
  AtoNPopoverComponent,
  AircraftPopoverComponent,
  AlarmPopoverComponent,
  ResourcePopoverComponent,
  ResourceSetPopoverComponent,
  VesselPopoverComponent,
  S57PopoverComponent,
  S57_CLICKABLE_LAYERS,
  S57_NAMES
} from './popovers';
import { FreeboardOpenlayersModule } from 'src/app/modules/map/ol';
import { CoordsPipe } from 'src/app/lib/pipes';

import { computeDestinationPoint, getGreatCircleBearing } from 'geolib';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Style, Stroke, Fill } from 'ol/style';
import { Collection, Feature } from 'ol';
import { Feature as GeoJsonFeature } from 'geojson';

import { Convert, TARGET_UNIT } from 'src/app/lib/convert';
import { GeoUtils, Angle } from 'src/app/lib/geoutils';
import { zoomKeyDirection } from 'src/app/lib/zoom-keys';
import { zoomDisplayText } from 'src/app/lib/zoom-display';
import { computeCursorEta, CursorEtaInfo } from './cursor-eta';
import { CursorPin, tapFadeMs, TAP_MAX_DURATION } from './cursor-marker';
import {
  FBRoute,
  FBRoutes,
  LineString,
  MultiLineString,
  Position
} from 'src/app/types';

import { AppFacade } from 'src/app/app.facade';
import { PlotterExtensionService } from 'src/app/modules/plotterext/plotterext.service';

import {
  SKResourceService,
  FBCustomResourceService,
  SKChart,
  SKWaypoint,
  SKVessel,
  SKAtoN,
  SKAircraft,
  SKSaR,
  SKMeteo,
  SKStreamFacade,
  AnchorService,
  NotificationManager,
  CourseService,
  SettingsFacade,
  WeatherForecastModal,
  InfoPanelFacade,
  SKResourceType
} from 'src/app/modules';
import {
  mapControls,
  aircraftStyles,
  sarStyles,
  regionStyles,
  routeStyles,
  anchorStyles,
  alarmStyles,
  destinationStyles,
  laylineStyles,
  drawStyles,
  routeDraftStyles,
  targetAngleStyle,
  raceCourseStyles,
  bearingDistanceStyle
} from './mapconfig';
import { SKRoute } from 'src/app/modules/skresources/resource-classes';
import {
  RouteBuffer,
  RouteBufferRegistry
} from 'src/app/modules/plotterext/route-buffer.registry';
import { ModifyEvent } from 'ol/interaction/Modify';
import { DrawEvent } from 'ol/interaction/Draw';
import { Coordinate } from 'ol/coordinate';
import { SKPosition } from 'src/app/types';
import {
  FBClickEvent,
  FBMapEvent,
  FBPointerEvent,
  zoomOffsetLevel,
  MapComponent
} from './ol/lib/map.component';
import { FeatureLike } from 'ol/Feature';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
  FBMapInteractService,
  DrawFeatureInfo,
  IPopover
} from './fbmap-interact.service';
import { ScaleLine } from 'ol/control';
import { Units } from 'ol/control/ScaleLine';
import { DragBoxEvent } from 'ol/interaction/DragBox';
import { MapService } from './ol/lib/map.service';
import { InteractionDrawComponent } from './ol/lib/interactions/interaction-draw.component';
import { hitToleranceForPointer, worldCopyOffset } from './ol/lib/util';
import { LineString as OLLineString } from 'ol/geom';
import {
  extendRouteAtClick,
  RoutePointMeta,
  shouldExtendRoute,
  WORLD_WIDTH_3857
} from './route-extend';
import { vertexDeleted } from './ol/lib/vertex-delete';
import { modifiedLegIndex } from './route-modify-leg';
import { AppIconDef } from '../icons';
import { LayerWindWeatherComponent } from './ol/lib/resources/layer-wind-weather.component';
import { LayerCurrentsWeatherComponent } from './ol/lib/resources/layer-currents-weather.component';
import { TidalCurrentsLayerComponent } from './ol/lib/resources/tidal-currents-layer.component';
import {
  TidalCurrentsService,
  GridSample
} from './ol/lib/tidal-currents.service';

interface IResource {
  id: string;
  type: string;
  /** Pre-resolved resource. When set (an unsaved route buffer), the properties
   *  dialog opens from it instead of fetching from the server (#583). */
  resource?: FBRoute;
}

interface IFeatureData {
  aircraft: Map<string, SKAircraft>;
  atons: Map<string, SKAtoN>;
  sar: Map<string, SKSaR>;
  meteo: Map<string, SKMeteo>;
  self: SKVessel; //self vessel
  ais: Map<string, SKVessel>; // other vessels
  active: SKVessel; // focussed vessel
  navData: { position: Position; startPosition: Position };
  closest: Array<LineString>;
}

enum INTERACTION_MODE {
  MEASURE,
  DRAW,
  MODIFY
}

/** How long the vessel is held dead centre after follow mode is turned on,
 * before the configured centre offset is applied. */
const OFFSET_GRACE_PERIOD = 2000;

@Component({
  selector: 'fb-map',
  imports: [
    MatTooltipModule,
    MatListModule,
    MatIconModule,
    MatButtonModule,
    MatTooltipModule,
    CoordsPipe,
    MatCardModule,
    MatMenuModule,
    FreeboardOpenlayersModule,
    PopoverComponent,
    FeatureListPopoverComponent,
    ChartListPopoverComponent,
    AtoNPopoverComponent,
    AircraftPopoverComponent,
    AlarmPopoverComponent,
    ResourcePopoverComponent,
    ResourceSetPopoverComponent,
    VesselPopoverComponent,
    S57PopoverComponent,
    LayerWindWeatherComponent,
    LayerCurrentsWeatherComponent,
    TidalCurrentsLayerComponent
  ],
  templateUrl: './fb-map.component.html',
  styleUrls: ['./fb-map.component.css']
})
export class FBMapComponent implements OnInit, OnDestroy {
  @Input() setFocus: string;
  @Input() mapCenter: Position = [0, 0];
  @Input() mapZoom = 1;
  @Input() movingMap = false;
  @Input() northUp = true;
  @Input() measureMode: boolean;
  @Input() drawMode: boolean;
  @Input() modifyMode = false;
  @Input() activeRoute: string;
  @Input() vesselTrail: Array<Position> = [];
  @Input() dblClickZoom = false;
  @Input() overZoomTiles = true;
  @Output() drawEnded: EventEmitter<DrawFeatureInfo> = new EventEmitter();
  @Output() activate: EventEmitter<string> = new EventEmitter();
  @Output() deactivate: EventEmitter<string> = new EventEmitter();
  @Output() info: EventEmitter<IResource> = new EventEmitter();
  @Output() exitMovingMap: EventEmitter<boolean> = new EventEmitter();
  @Output() focusVessel: EventEmitter<string> = new EventEmitter();
  @Output() menuItemSelected: EventEmitter<string> = new EventEmitter();

  @ViewChild(MatMenuTrigger, { static: true }) contextMenu: MatMenuTrigger;
  @ViewChild('olMap', { static: false }) olMap: MapComponent;
  @ViewChild('routeDraw', { static: false })
  routeDraw: InteractionDrawComponent;

  scaleUnits = input<string>('');

  protected perfLaylines = signal<{
    port: MultiLineString;
    starboard: MultiLineString;
  }>({ port: [], starboard: [] });
  protected perfTargetAngle = signal<LineString>([]);
  protected vesselLines = signal<{
    cog: LineString;
    heading: LineString;
  }>({
    cog: [],
    heading: []
  });

  protected overlay = signal<IPopover>({
    id: null,
    type: null,
    icon: null,
    position: [0, 0],
    show: false,
    title: '',
    content: null,
    featureCount: 0,
    readOnly: false,
    isSelf: false
  });

  protected olMapControls = mapControls;
  protected olMapInteractions = signal<Array<{ name: string }>>([]);
  protected mapZoomLevel = signal<number>(1);
  protected mapZoomText = computed(() => zoomDisplayText(this.mapZoomLevel()));
  protected mapCenterPositon = signal<Position>([0, 0]);
  protected mapRotation = signal<number>(0);

  protected showNoteslayer = signal<boolean>(false); //control notes layer display

  // ** map feature styles
  protected featureStyles = {
    route: routeStyles,
    region: regionStyles,
    anchor: anchorStyles,
    alarm: alarmStyles,
    destination: destinationStyles,
    aircraft: aircraftStyles,
    sar: sarStyles,
    layline: laylineStyles,
    targetAngle: targetAngleStyle,
    raceCourse: raceCourseStyles,
    bearingDistance: bearingDistanceStyle
  };

  // Live route edit buffers (unsaved drafts) rendered via a second fb-routes
  // layer in the amber draft style.
  protected draftRouteStyles = routeDraftStyles;
  protected bufferRoutes = computed<FBRoutes>(() =>
    this.routeBuffers
      .live()
      // A saved + clean route renders from the resource layer (green); only
      // unsaved or dirty routes get the amber draft styling.
      .filter((b) => !b.saved || b.dirty)
      .map((b) => this.bufferToFBRoute(b))
  );

  // ** map feature data
  protected dfeat: IFeatureData = {
    aircraft: new Map(),
    atons: new Map(),
    sar: new Map(),
    meteo: new Map(),
    self: new SKVessel(), //self vessel
    ais: new Map(), // other vessels
    active: new SKVessel(), // focussed vessel
    navData: { position: null, startPosition: null },
    closest: []
  };

  private saveTimer;
  private isDirty = false;
  /** A user drag is in flight under the 'offset' pan behavior — the resulting
   * map centre becomes the new vessel centre offset. */
  private userPanned = false;
  private offsetGraceTimer;
  /** True while the vessel is held dead centre just after follow mode is
   * turned on (see startOffsetGrace). */
  private offsetSuppressed = false;
  /**
   * EPSG:3857-metre offset of the world copy the most recent click landed in.
   * Passed to overlays so a popover is drawn in the copy the user clicked, not
   * the primary world. Render-space only — never written to stored data (#572).
   */
  private clickWorldOffset = 0;
  /**
   * Raw EPSG:3857 x of the most recent click, world-copy-aware. Used to align an
   * edited feature with the copy the user is looking at before Modify hit-tests
   * it (a route's own coordinates may be unwrapped past ±180, so the click's
   * world alone isn't enough — align by the feature's extent). See #572.
   */
  private clickMercX = 0;

  // Cursor position readout. A signal so the readout (and measure overlay)
  // refresh when pointer-move runs OUTSIDE the Angular zone (see MapComponent).
  protected mouse = signal<{
    pixel: number[] | null;
    coords: Position;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    xy: any;
  }>({
    pixel: null,
    coords: [0, 0],
    xy: null
  });
  // Bearing/distance/ETA from the vessel to the cursor, shown in the status bar
  // when the "Live ETA at cursor" option is enabled (null = hidden).
  protected cursorInfo = signal<CursorEtaInfo | null>(null);
  /**
   * Holds that readout on a tapped point for a few seconds, marked on the chart
   * (#546). Expiring drops the readout with the marker so a bearing is never
   * left on screen with nothing identifying the point it refers to.
   */
  private cursorPin = new CursorPin(() => this.cursorInfo.set(null));
  /**
   * The pin's marker as a 0-or-1 element list: the fade is a CSS animation, so
   * the template tracks by id to get a *new* element per tap rather than
   * repositioning one whose animation has already run.
   */
  protected cursorMarkers = computed(() => {
    const m = this.cursorPin.marker();
    return m ? [m] : [];
  });
  /** When the gesture in flight pressed down — a tap is a short press. */
  private pointerDownAt = 0;
  contextMenuPosition = { x: '0px', y: '0px' };
  // Empty widget-anchor cell at the last right-click (or null) — gates and
  // drives the "Add widget here" context-menu item (desktop).
  protected addableWidgetCell: ReturnType<
    PlotterExtensionService['addableCellAt']
  > = null;

  private obsList = [];

  private http = inject(HttpClient);
  protected app = inject(AppFacade);
  protected skres = inject(SKResourceService);
  protected skresOther = inject(FBCustomResourceService);
  protected skstream = inject(SKStreamFacade);
  protected anchor = inject(AnchorService);
  protected notiMgr = inject(NotificationManager);
  protected plotterExt = inject(PlotterExtensionService);
  protected course = inject(CourseService);
  protected mapInteract = inject(FBMapInteractService);
  protected mapService = inject(MapService);
  private settings = inject(SettingsFacade);
  private bottomSheet = inject(MatBottomSheet);
  private infoPanel = inject(InfoPanelFacade);
  protected routeBuffers = inject(RouteBufferRegistry);
  private tidalCurrents = inject(TidalCurrentsService);
  private ngZone = inject(NgZone);

  constructor() {
    effect(() => {
      if (this.scaleUnits()) {
        this.setScaleUnits();
      }
    });
    this.toggleDblClickZoom(); // init olMapinterations
  }

  ngAfterViewInit() {
    this.setScaleUnits();
    // ** trigger map focus **
    setTimeout(() => {
      this.setFocus = 'xxx';
    }, 500);
  }

  ngOnInit() {
    // STREAM VESSELS update event
    this.obsList.push(
      this.skstream.vessels$().subscribe(() => this.onVessels())
    );
    // SETTINGS settings.change$ event
    this.obsList.push(
      this.settings.change$.subscribe((r: string[]) => {
        this.renderMapContents(r.includes('fetchNotes'));
        if (r.includes(`trailFromServer`)) {
          if (!this.app.config.vessels.trailFromServer) {
            this.app.selfTrailFromServer.update(() => {
              return [];
            });
          }
        }
      })
    );
  }

  ngOnDestroy() {
    this.stopSaveTimer();
    this.stopOffsetGrace();
    this.cursorPin.release();
    this.obsList.forEach((i) => i.unsubscribe());
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.vesselTrail) {
      this.drawVesselLines();
    }
    if (changes && changes.mapCenter && changes.mapCenter.currentValue) {
      this.mapCenterPositon.set(changes.mapCenter.currentValue);
    }
    if (
      changes &&
      changes.mapZoom &&
      typeof changes.mapZoom.currentValue === 'number'
    ) {
      this.mapZoomLevel.set(changes.mapZoom.currentValue);
      this.renderMapContents(true);
    }
    if (changes && changes.movingMap && !changes.movingMap.firstChange) {
      if (changes.movingMap.currentValue) {
        this.startSaveTimer();
        this.startOffsetGrace();
      } else {
        this.stopSaveTimer();
        // Leave the chart where it is when follow mode is released — snapping
        // back to the vessel would undo the pan that just released it.
        this.stopOffsetGrace();
        this.userPanned = false;
      }
    }
    if (changes && changes.northUp) {
      this.rotateMap();
    }
    if (changes && changes.measureMode) {
      this.applyInteractionMode(
        INTERACTION_MODE.MEASURE,
        changes.measureMode.currentValue
      );
    }
    if (changes && changes.drawMode) {
      this.applyInteractionMode(
        INTERACTION_MODE.DRAW,
        changes.drawMode.currentValue
      );
    }
    if (changes && changes.modifyMode && !changes.modifyMode.firstChange) {
      this.applyInteractionMode(
        INTERACTION_MODE.MODIFY,
        changes.modifyMode.currentValue
      );
    }
    if (changes && changes.dblClickZoom) {
      this.toggleDblClickZoom(changes.dblClickZoom.currentValue);
    }
  }

  // set map scale units
  private setScaleUnits() {
    try {
      const u: Units = ['kilometer', 'm'].includes(this.scaleUnits())
        ? 'metric'
        : 'nautical';
      const c = this.olMap.getMap().getControls().getArray();
      (c[0] as ScaleLine).setUnits(u);
    } catch (err) {
      // no map or scale control
    }
  }

  // format WMS parameters
  protected wmsParams(chart: SKChart) {
    return {
      LAYERS: chart.layers ? chart.layers.join(',') : ''
    };
  }

  // ** periodically persist state (used in movingMap mode)
  private startSaveTimer() {
    if (!this.saveTimer) {
      this.saveTimer = setInterval(() => {
        if (this.isDirty) {
          this.app.saveConfig();
          this.isDirty = false;
        }
      }, 30000);
    }
  }

  private stopSaveTimer() {
    if (this.saveTimer) {
      clearInterval(this.saveTimer);
      this.saveTimer = null;
    }
  }

  // ********** EVENT HANDLERS *****************

  private onVessels() {
    //store last position incase new position is null
    const lastPos = this.dfeat.self.position;
    this.dfeat.self = this.app.data.vessels.self;
    if (!this.dfeat.self.position || !Array.isArray(this.dfeat.self.position)) {
      this.dfeat.self.position = lastPos;
    }
    this.dfeat.ais = this.app.data.vessels.aisTargets;
    this.dfeat.aircraft = this.app.data.aircraft;
    this.dfeat.sar = this.app.data.sar;
    this.dfeat.meteo = this.app.data.meteo;
    this.dfeat.atons = this.app.data.atons;
    this.dfeat.active = this.app.data.vessels.active;
    this.dfeat.navData.position = this.course.courseData().position;
    this.dfeat.navData.startPosition = this.course.courseData().startPosition;
    // calculate CPA lines
    const parseClosest = () => {
      const v = [];
      if (this.app.data.vessels.self.position) {
        this.app.data.vessels.closest.forEach((id: string) => {
          if (this.app.data.vessels.aisTargets.has(id)) {
            const a = this.app.data.vessels.aisTargets.get(id);
            if (a.position) {
              v.push([a.position, this.app.data.vessels.self.position]);
            }
          }
        });
      }
      return v;
    };

    this.dfeat.closest = parseClosest();

    // ** update vessel on map **
    if (this.dfeat.self.positionReceived) {
      this.app.data.vessels.showSelf = true;
    }
    // ** locate vessel popover
    if (
      this.overlay().show &&
      ['ais', 'aton', 'aircraft'].includes(this.overlay().type)
    ) {
      if (this.overlay().isSelf) {
        this.overlay.update((current) => {
          return Object.assign({}, current, {
            position: this.dfeat.self.position,
            vessel: this.dfeat.self
          });
        });
      } else {
        if (
          (this.overlay().type === 'ais' &&
            !this.dfeat.ais.has(this.overlay().id)) ||
          (this.overlay().type === 'atons' &&
            !this.dfeat.atons.has(this.overlay().id)) ||
          (this.overlay().type === 'aircraft' &&
            !this.dfeat.aircraft.has(this.overlay().id))
        ) {
          this.overlay().show = false;
        } else {
          if (this.overlay().type === 'ais') {
            this.overlay.update((current) => {
              return Object.assign({}, current, {
                position: this.dfeat.ais.get(current.id).position,
                vessel: this.dfeat.ais.get(current.id)
              });
            });
          }
        }
      }
      // The popover follows the vessel in whichever world copy it was opened in:
      // position stays canonical and the overlay's worldOffset (preserved across
      // these updates) draws it in the right copy (#572).
    }
    this.drawVesselLines(this.app.data.vessels.self.positionReceived);
    this.rotateMap();
    if (this.movingMap) {
      this.centerVessel();
    }
  }

  // ********** RADAR EVENT HANDLERS *****************

  handleRadarError(error: Error) {
    this.app.showAlert('Radar', error.message);
    this.app.uiCtrl.update((current) => {
      return Object.assign(current, { radarLayer: false });
    });
  }

  // ********** MAP EVENT HANDLERS *****************

  private toggleDblClickZoom(set?: boolean) {
    const olInteractions = [
      { name: 'dragpan' },
      { name: 'dragzoom' },
      { name: 'keyboardpan' },
      // keyboardzoom is handled by onZoomKeydown so +/- preserves the follow
      // offset, the same as the on-screen zoom buttons.
      { name: 'mousewheelzoom' },
      { name: 'pinchzoom' }
    ];

    this.olMapInteractions.update(() => {
      const i = set
        ? [{ name: 'doubleclickzoom' }].concat(olInteractions)
        : [].concat(olInteractions);
      return i;
    });
  }

  // ** handle context menu choices **
  protected onContextMenuAction(action: string, pos: Position) {
    switch (action) {
      case 'add_wpt':
        this.skres.newWaypointAt(pos);
        break;
      case 'add_note':
        this.skres.showNoteEditor({ position: pos });
        break;
      case 'nav_to':
        this.app.data.activeWaypoint = null;
        this.course.courseData().pointNames = [];
        this.course.setDestination({
          latitude: pos[1],
          longitude: pos[0]
        });
        break;
      case 'bearing_dist':
        this.formatPopover('bearing_dist', pos);
        break;
      case 'weather_forecast':
        this.bottomSheet.open(WeatherForecastModal, {
          disableClose: true,
          data: {
            title: 'Forecast',
            position: pos,
            subTitle: 'Location: Cursor Position'
          }
        });
        break;
      case 'measure':
        this.mapInteract.startMeasuring();
        break;
      case 'add_widget':
        if (this.addableWidgetCell) {
          this.plotterExt.openAddWidgetPicker(
            this.addableWidgetCell.anchor,
            this.addableWidgetCell.cell
          );
        }
        break;
      case 'get_feature_info':
        this.getFeatureInfo();
        break;
      default:
        this.menuItemSelected.emit(action);
        break;
    }
  }

  // handle map move / zoom
  protected onMapMoveEnd(e: FBMapEvent) {
    this.app.config.map.zoomLevel = e.zoom;
    this.app.mapZoom.set(e.zoom);

    this.app.mapExtent.update(() => e.extent);
    this.app.mapViewTopCenter.update(() => e.topCenter as Position);
    this.app.mapViewRightCenter.update(() => e.rightCenter as Position);
    this.app.mapViewRotation.update(() => e.rotation);
    this.app.config.map.center = e.lonlat as Position;

    if (this.userPanned) {
      // Adopt where the user left the vessel on screen only once the pan (and
      // any kinetic glide) has settled, so the offset matches what they see.
      // The pan IS the offset gesture, so it also ends the post-tap grace.
      this.userPanned = false;
      this.stopOffsetGrace();
      const outcome = this.app.applyPanGesture(e.lonlat as Position);
      if (outcome === 'release') {
        // Panned the vessel clean off screen: stop following and leave the
        // chart where it is, rather than clamping the vessel back on screen.
        this.exitMovingMap.emit(true);
      } else if (outcome === 'adopted') {
        this.app.saveConfigDebounced();
      }
    }

    this.drawVesselLines();
    if (!this.movingMap) {
      // debounce: a flurry of pans/zooms collapses into one save
      this.app.saveConfigDebounced();
      this.isDirty = false;
    } else {
      this.isDirty = true;
    }

    // render map features
    this.renderMapContents(e.zoomChanged);
  }

  // pointer events
  protected onMapPointerMove(e: FBPointerEvent) {
    this.mouse.set({
      pixel: e.pixel,
      xy: e.coordinate,
      coords: GeoUtils.normaliseCoords(e.lonlat as Position)
    });
    this.updateCursorInfo(e.lonlat as Position);
    if (this.mapInteract.isMeasuring()) {
      if (
        this.mapInteract.measureGeometryType === 'LineString' &&
        this.mapInteract.measurement().coords.length !== 0
      ) {
        const c = e.lonlat;
        const lm = this.mapInteract.distanceFromLastPoint(c as Position);
        const b = getGreatCircleBearing(
          this.mapInteract.measurement().coords.slice(-1)[0],
          c as Position
        );
        this.overlay.update((current) => {
          return Object.assign({}, current, {
            position: c,
            // Keep the live measure label in the pointer's world copy (#572).
            worldOffset: e.worldOffset ?? 0,
            title: `${this.app.formatValueForDisplay(
              lm,
              'm'
            )} ${this.app.formatValueForDisplay(b, 'deg')}`
          });
        });
      } else if (this.mapInteract.measureGeometryType === 'Circle') {
        const c = e.lonlat;
        const lm = this.mapInteract.distanceFromCenter(c as Position);
        const b = getGreatCircleBearing(
          this.mapInteract.measurement().center ?? (c as Position),
          c as Position
        );
        this.overlay.update((current) => {
          return Object.assign({}, current, {
            position: c,
            // Keep the live measure label in the pointer's world copy (#572).
            worldOffset: e.worldOffset ?? 0,
            title: `${this.app.formatValueForDisplay(
              lm,
              'm'
            )} ${this.app.formatValueForDisplay(b, 'deg')}`
          });
        });
      }
    }
  }

  protected onMapPointerDrag() {
    if (!this.app.uiConfig().mapMove) {
      return;
    }
    if (this.app.config.map.panBehavior === 'exit') {
      // pointer-drag runs outside the Angular zone (see MapComponent); re-enter
      // so exiting "move map" mode propagates to the UI. Rare one-off transition.
      this.ngZone.run(() => this.exitMovingMap.emit(true));
    } else if (this.app.config.map.panBehavior === 'offset') {
      // Follow mode stays on; onMapMoveEnd() captures the new look-ahead once
      // the drag settles.
      this.userPanned = true;
    }
  }

  // Update the status-bar cursor bearing/distance/ETA readout. Off unless the
  // "Live ETA at cursor" option is enabled and the vessel position is known.
  // `fromTap` marks the one caller allowed to write while the readout is pinned
  // to a tapped point — the tap that is doing the pinning.
  private updateCursorInfo(cursor: Position, fromTap = false) {
    if (this.cursorPin.active && !fromTap) {
      return;
    }
    const vessel = this.app.data.vessels.self;
    if (
      !this.app.config.display.statusBar?.liveEta ||
      !vessel?.positionReceived ||
      !vessel.position
    ) {
      this.cursorInfo.set(null);
      return;
    }
    // referenceSpeed is stored in the user's display speed unit; convert to m/s.
    const factor =
      Convert.transform(1, 'm/s', this.app.config.units.speed as TARGET_UNIT) ??
      1;
    const refSpeed = this.app.config.display.statusBar.referenceSpeed;
    const referenceSpeedMs =
      factor > 0 && typeof refSpeed === 'number' ? refSpeed / factor : 0;
    this.cursorInfo.set(
      computeCursorEta(vessel.position, cursor, vessel.sog, referenceSpeedMs)
    );
  }

  /**
   * Hold the readout on a tapped point and mark it on the chart. Skipped for a
   * hold (the context-menu gesture) so only a deliberate tap measures.
   */
  private pinCursorAtTap(position: Position, worldOffset: number) {
    if (
      !this.app.config.display.statusBar?.liveEta ||
      Date.now() - this.pointerDownAt > TAP_MAX_DURATION
    ) {
      return;
    }
    this.cursorPin.pin(
      position,
      worldOffset,
      tapFadeMs(this.app.config.display.statusBar.tapFadeSpeed)
    );
    this.updateCursorInfo(position, true);
  }

  protected onMapPointerDown(e: FBPointerEvent) {
    this.pointerDownAt = Date.now();
    this.mouse.update((m) => ({
      ...m,
      coords: GeoUtils.normaliseCoords(e.lonlat as Position)
    }));
    this.contextMenuPosition.x = (e as any).clientX + 'px';
    this.contextMenuPosition.y = (e as any).clientY + 'px';
  }

  protected onMapSingleClick(e) {
    // Remember which world copy this click landed in so any popover it opens is
    // drawn there, and the raw world-aware x so a route edit aligns with it (#572).
    this.clickWorldOffset = e.worldOffset ?? 0;
    this.clickMercX = fromLonLat(e.lonlat)[0] + this.clickWorldOffset;
    this.app.data.map.atClick = {
      features: e.features,
      lonlat: e.lonlat
    };
    if (this.mapInteract.isMeasuring()) {
      // measuring
      this.parseClickInMeasureMode(e.lonlat);
    } else if (
      // drawing
      this.mapInteract.isDrawing() &&
      this.mapInteract.draw.resourceType === 'route'
    ) {
      this.onDrawClick(e.features);
    } else if (
      // modifying a route: a click in open water extends it from the end
      this.mapInteract.isModifying() &&
      this.mapInteract.draw.resourceType === 'route'
    ) {
      this.extendRouteInModify(e);
    } else if (
      //not interacting
      !this.mapInteract.isDrawing() &&
      !this.mapInteract.isModifying()
    ) {
      if (!this.app.config.map.popoverMulti) {
        this.overlay.update((current) => {
          return Object.assign({}, current, {
            show: false
          });
        });
      }
      this.processMapClick(e);
      this.pinCursorAtTap(e.lonlat as Position, e.worldOffset ?? 0);
    }
  }

  /** Handle right click / touch hold */
  protected onMapRightClick(e: {
    features: FeatureLike[];
    lonlat: Position;
    worldOffset?: number;
  }) {
    this.clickWorldOffset = e.worldOffset ?? 0;
    this.clickMercX = fromLonLat(e.lonlat)[0] + this.clickWorldOffset;
    this.app.data.map.atClick = e;
    this.app.debug(`onRightClick()`, this.app.data.map.atClick);
    if (this.mapInteract.isMeasuring()) {
      this.parseClickInMeasureMode(e.lonlat);
    }
  }

  /** Handle Map context menu event */
  protected onMapContextMenu(e: PointerEvent) {
    this.app.debug(`onMapContextMenu()`, this.app.data.map.atClick);
    this.onContextMenu(e);
  }

  /** Handle ol-map container context menu event */
  protected onContextMenu(e: PointerEvent) {
    this.app.debug(`onContextMenu()`, this.app.data.map.atClick);
    if (this.app.uiCtrl().suppressContextMenu || this.overlay().show) {
      return;
    }
    e.preventDefault();
    this.contextMenuPosition.x = e.clientX + 'px';
    this.contextMenuPosition.y = e.clientY + 'px';
    // Resolve the click to an empty widget-anchor cell (desktop right-click
    // equivalent of the press-and-hold add gesture). Null when not over one.
    this.addableWidgetCell = this.plotterExt.addableCellAt(
      e.clientX,
      e.clientY
    );
    this.contextMenu.menuData = { item: this.mouse().coords };
    if (this.mapInteract.isMeasuring()) {
      // The measure point is added by onMapRightClick, which fires alongside
      // this handler with a valid lonlat; just suppress the context menu here.
    } else if (!this.modifyMode) {
      if (!this.mouse().xy) {
        return;
      }
      this.contextMenu.openMenu();
      document
        .getElementsByClassName('cdk-overlay-backdrop')[0]
        .addEventListener('contextmenu', (offEvent) => {
          offEvent.preventDefault(); // prevent default context menu for overlay
          this.contextMenu.closeMenu();
        });
    }
  }

  /** process pointer click event when in measure mode */
  private parseClickInMeasureMode(pos: Position) {
    if (
      this.mapInteract.measureGeometryType === 'LineString' &&
      this.mapInteract.measurement().coords.length !== 0
    ) {
      this.onMeasureClick(pos);
    }
  }

  /** Toggle display of chart feature  */
  protected toggleFeatureSelection(id: string | string[], resType: 'charts') {
    if (resType === 'charts') {
      this.skres.chartSelected(id);
    }
  }

  /** Handle OL interaction start event */
  protected onDragBoxStart(e: DragBoxEvent) {
    let c = toLonLat(e.coordinate);
    this.mapInteract.initBoxCoord(c as Position);
  }

  /** Handle OL interaction end event */
  protected onDragBoxEnd(e: DragBoxEvent) {
    let c = toLonLat(e.coordinate);
    this.mapInteract.stopBoxSelection(c as Position);
  }

  /** Handle OL interaction end event */
  protected onDragBoxCancel(e: DragBoxEvent) {
    this.app.debug(`onDragBoxCancel()...`);
    this.mapInteract.stopBoxSelection();
  }

  /** Handle OL interaction start event */
  protected onMeasureStart(e: DrawEvent) {
    this.app.debug(`onMeasureStart()...`, this.mapInteract.measureGeometryType);
    let ovPosition: any;
    if (this.mapInteract.measureGeometryType === 'LineString') {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      let c = (e.feature.getGeometry() as any)
        .getCoordinates()
        .map((c: Position) => toLonLat(c));
      c = c.slice(0, c.length - 1);
      this.mapInteract.measurementCoords = c;
      ovPosition = c;
    } else {
      const g = e.feature.getGeometry() as any;
      const center = toLonLat(g.getCenter());
      const radius = g.getRadius();
      this.mapInteract.measurementCenter = center as Position;
      this.mapInteract.measurementRadius = radius;
      ovPosition = this.mapInteract.measurementCenter;
      this.app.debug(this.mapInteract.measurement);
    }
    this.formatPopover(null, null);
    this.overlay.update((current) => {
      return Object.assign({}, current, {
        position: ovPosition,
        title: '0',
        show: true,
        type: 'measure'
      });
    });
  }

  /** Process pointer click in MEASURE mode */
  protected onMeasureClick(pt: Position) {
    this.app.debug(`onMeasureClick()...`);
    if (!Array.isArray(pt)) {
      return;
    }
    const lastPt =
      this.mapInteract.measurement().coords[
        this.mapInteract.measurement().coords.length - 1
      ];
    if (pt[0] === lastPt[0] && pt[1] === lastPt[1]) {
      return;
    }
    const lm = this.mapInteract.addMeasurementCoord(pt);
    // ** update popover measurement values
    const c = this.mapInteract.measurement().coords.slice(-2);
    const b = getGreatCircleBearing(c[0], c[1]) ?? 0;
    this.overlay.update((current) => {
      return Object.assign({}, current, {
        position: pt,
        title: `${this.app.formatValueForDisplay(
          lm,
          'm'
        )} ${this.app.formatValueForDisplay(b, 'deg')}`
      });
    });
  }

  /** Handle OL interaction start event */
  protected onMeasureEnd() {
    this.app.debug(`onMeasureEnd()...`);
    this.overlay.update((current) => {
      return Object.assign({}, current, {
        show: false
      });
    });
    this.mapInteract.stopMeasuring();
  }

  /**
   * Process pointer click in DRAW mode
   * @param fa Array of Features
   */
  protected onDrawClick(fa: Feature[]) {
    if (!Array.isArray(fa)) {
      return;
    }
    if (this.mapInteract.draw.resourceType === 'route') {
      let rteCoords: Position[];
      fa.forEach((f: Feature) => {
        if (f.getGeometry().getType() === 'LineString') {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          rteCoords = (f.getGeometry() as any)
            .getCoordinates()
            .map((c: Position) => toLonLat(c));
          rteCoords = rteCoords.slice(0, rteCoords.length - 1);
        }
      });
      this.mapInteract.measurementCoords = rteCoords;
    }
  }

  /**
   * Complete the in-progress draw sketch (as a double-click would), so a
   * "Finish" button can end a route draw. A no-op until the sketch has enough
   * points; OL then dispatches drawend → onDrawEnd through the normal path.
   */
  finishDrawing() {
    this.routeDraw?.finishDrawing();
  }

  /**
   * Undo the last action in the active route interaction (#542): while drawing,
   * remove the last placed point (repeatable back to the first); while
   * modifying, revert the last vertex operation from the session undo stack.
   */
  undoInteraction() {
    if (this.mapInteract.isDrawing()) {
      this.undoDrawPoint();
    } else if (this.mapInteract.isModifying()) {
      this.undoModify();
    }
  }

  /** Remove the last point placed in the current route draw sketch. */
  private undoDrawPoint() {
    this.routeDraw?.removeLastPoint();
    // Keep the distance read-out in step: the sketch just lost its last placed
    // point (the floating cursor point is excluded from measurement coords).
    const coords = this.mapInteract.measurement().coords;
    this.mapInteract.measurementCoords = coords.slice(
      0,
      Math.max(0, coords.length - 1)
    );
  }

  /** Revert the last vertex operation of the current route Modify session. */
  private undoModify() {
    const snap = this.mapInteract.popModifyUndo();
    if (!snap) {
      return;
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const f: any = this.mapInteract.draw.features?.getArray()[0];
    if (!f) {
      return;
    }
    // Capture the geometry before it is replaced: diffed against the restored
    // coordinates it tells the Leg read-out which vertex the undo puts back
    // (#581).
    const undone = f.getGeometry().getCoordinates() as Position[];
    // setCoordinates fires the feature's 'change' event; the active OL Modify
    // interaction listens for it (handleFeatureChange_) and rebuilds its
    // segment/vertex index whenever the geometry changes outside its own drag
    // (changingFeature_ is false here), so the next drag targets the restored
    // vertices — no manual re-sync of the interaction is needed.
    f.getGeometry().setCoordinates(snap.coordinates);
    // Restore per-point metadata on the feature too: updateCoordsMeta mutates
    // pointMetadata in place, and the next modifystart re-reads it from the
    // feature — so without this a later edit would splice a stale, wrong-length
    // metadata array.
    f.set('pointMetadata', snap.coordsMetadata ?? null);
    // Re-baseline session state to the restored geometry so the next op (and a
    // subsequent Finish/Cancel) sees the reverted route.
    this.mapInteract.draw.coordinates = snap.coordinates;
    this.mapInteract.draw.forSave.coordsMetadata = snap.coordsMetadata;
    const pc = this.transformCoordsArray(snap.coordinates) as LineString;
    this.mapInteract.draw.forSave['coords'] = pc;
    this.mapInteract.measurementCoords = pc;
    this.mapInteract.measurementIndex = modifiedLegIndex(
      undone,
      snap.coordinates
    );
  }

  /** Handle OL interaction end event */
  protected onDrawEnd(e: { feature: Feature }) {
    // OL dispatches drawend synchronously from the map's viewport pointer
    // handlers, which run outside the Angular zone (see MapComponent). Re-enter
    // the zone so the save prompt / live-edit draft opened via
    // drawEnded -> handleDrawEnded gets a change-detection pass.
    this.ngZone.run(() => {
      this.mapInteract.stopDrawing(e.feature);
      this.drawEnded.emit(this.mapInteract.draw);
    });
  }

  /** Enter modify mode */
  /** Convert a live route edit buffer to the FBRoute tuple fb-routes renders. */
  /** The route buffer for `routeId` only when it represents an unsaved draft or
   *  a route with pending edits. The registry now also mirrors clean saved
   *  routes, so a plain `routeBuffers.has()` would wrongly treat those as
   *  unsaved. */
  private getUnsavedRouteBuffer(routeId: string): RouteBuffer | undefined {
    const b = this.routeBuffers.get(routeId);
    return b && (!b.saved || b.dirty) ? b : undefined;
  }

  private bufferToFBRoute(b: RouteBuffer): FBRoute {
    const rte = new SKRoute();
    rte.name = b.name ?? '';
    rte.description = b.description ?? '';
    rte.feature.geometry.coordinates = b.points.map(
      (p) => p.position
    ) as LineString;
    // Carry per-point metadata so editing a named draft (which seeds
    // coordsMetadata from the rendered feature's pointMetadata) doesn't drop
    // waypoint names/descriptions on save.
    const coordsMeta = b.points.map((p) => ({
      ...(p.name ? { name: p.name } : {}),
      ...(p.description ? { description: p.description } : {})
    }));
    if (coordsMeta.some((m) => Object.keys(m).length > 0)) {
      rte.feature.properties.coordinatesMeta = coordsMeta;
    }
    rte.distance = GeoUtils.routeLength(rte.feature.geometry.coordinates);
    return [b.routeId, rte, true];
  }

  /** True when the popover's route is an unsaved draft (or has pending edits). */
  protected isUnsavedRoute(): boolean {
    if (this.overlay().type !== 'route') {
      return false;
    }
    const b = this.routeBuffers.get(this.overlay().id);
    return !!b && (!b.saved || b.dirty);
  }

  /**
   * Popover "Save" shortcut for an unsaved route — opens the standard Route
   * Details dialog (same path as the info-panel SAVE) and persists. Saves the
   * user a trip through INFO.
   */
  protected async saveRouteFromPopover() {
    try {
      await this.plotterExt.saveBuffer(this.overlay().id, { dialog: true });
    } catch {
      // saveBuffer surfaced the server error via parseHttpErrorResponse; the
      // buffer stays dirty so the user can retry.
    }
  }

  protected modifyFeature(featureType?: string) {
    if (this.mapInteract.draw.features.getLength() === 0) {
      return;
    }
    // Align the edit feature with the world copy the user is looking at, so OL
    // Modify (which hit-tests raw geometry in view-space, ignoring wrapX) matches
    // where they click. A route's own coordinates may be unwrapped past ±180, so
    // the click's world alone is wrong — align by the feature's extent centre:
    // the whole-world shift that puts the feature centre in the clicked world.
    // It's a whole-world translation, so wrapX still draws the feature in the same
    // place (visually transparent), no vertices are added, and the edited
    // coordinates normalise back to [-180,180] on save (#572).
    const editGeom = (
      this.mapInteract.draw.features.getArray()[0] as Feature
    )?.getGeometry();
    if (editGeom) {
      const ext = editGeom.getExtent();
      const centreX = (ext[0] + ext[2]) / 2;
      const shift = worldCopyOffset(
        this.clickMercX - centreX,
        WORLD_WIDTH_3857
      );
      if (shift) {
        this.mapInteract.draw.features.forEach((f: Feature) =>
          f.getGeometry()?.translate(shift, 0)
        );
      }
    }
    this.mapInteract.startModifying(this.overlay());
    if (this.overlay().type === 'route') {
      const rid = this.overlay().id;
      // Route name for the helper title ("Modify <name>"). An unnamed draft
      // resolves to the "(unsaved)" placeholder from formatPopover(), which we
      // keep on purpose so the title reads "Modify (unsaved)" — a deliberate
      // signal that the route has not been saved.
      const res = this.overlay().resource;
      this.mapInteract.draw.name = Array.isArray(res)
        ? ((res[1] as SKRoute)?.name ?? '')
        : '';
      this.mapInteract.measurementCoords = this.routeBuffers.has(rid)
        ? (this.routeBuffers
            .get(rid)!
            .points.map((p) => p.position) as LineString)
        : this.skres.fromCache('routes', rid)[1].feature.geometry.coordinates;
    }
    if (featureType === 'anchor') {
      this.overlay().type = featureType;
      this.mapInteract.draw.forSave.id = featureType;
    }
  }

  /** Handle OL modify start event */
  protected onModifyStart(e: ModifyEvent) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const f: any = e.features.getArray()[0];
    this.mapInteract.draw.coordinates = f.getGeometry().getCoordinates();
    if (!this.mapInteract.draw.forSave.id) {
      // initialise save info
      this.mapInteract.draw.forSave.id = f.getId();
    }
    if (f.getGeometry().getType() === 'LineString') {
      const meta = f.get('pointMetadata');
      if (meta) {
        this.mapInteract.draw.forSave.coordsMetadata = meta;
      }
    }
  }

  /** Handle OL modify end event */
  protected onModifyEnd(e: ModifyEvent) {
    // Modify interactions dispatch modifyend synchronously from the map's
    // viewport pointer handlers, which run outside the Angular zone (see
    // MapComponent). Re-enter the zone so the post-modify UI (the route-editing
    // toolbar driven by app.data.activeRouteIsEditing / editingId) gets a
    // change-detection pass.
    this.ngZone.run(() => this.applyModifyEnd(e));
  }

  private applyModifyEnd(e: ModifyEvent) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const f: any = e.features.getArray()[0];
    const fid = f.getId();
    const c = f.getGeometry().getCoordinates();
    // Snapshot the pre-operation route geometry so this vertex op (move / add /
    // delete) can be undone within the current Modify session (#542).
    // draw.coordinates holds the geometry as it was at modifystart;
    // coordsMetadata is mutated in place by updateCoordsMeta below, so clone it
    // now.
    if (fid.split('.')[0] === 'route') {
      const meta = this.mapInteract.draw.forSave.coordsMetadata as
        RoutePointMeta[] | undefined;
      this.mapInteract.pushModifyUndo({
        coordinates: (this.mapInteract.draw.coordinates as Position[]).map(
          (co) => [...co] as Position
        ),
        coordsMetadata: meta ? meta.map((m) => ({ ...m })) : undefined
      });
    }
    if (f.getGeometry().getType() === 'LineString') {
      this.updateCoordsMeta(
        this.mapInteract.draw.coordinates,
        c,
        this.mapInteract.draw.forSave.coordsMetadata
      );
    }
    let pc;
    if (fid.split('.')[0] === 'route') {
      pc = this.transformCoordsArray(c);
      this.mapInteract.measurementCoords = pc;
      // Report the leg ending at the vertex just moved rather than whatever the
      // last leg happens to be (#581). draw.coordinates still holds the geometry
      // as it was at modifystart, so it is the pre-operation side of the diff.
      this.mapInteract.measurementIndex = modifiedLegIndex(
        this.mapInteract.draw.coordinates as Position[],
        c as Position[]
      );
    } else if (fid.split('.')[0] === 'region') {
      for (let e = 0; e < c.length; e++) {
        if (this.isCoordsArray(c[e])) {
          c[e] = this.transformCoordsArray(c[e]);
        } else {
          for (let p = 0; p < c[e].length; p++) {
            if (this.isCoordsArray(c[e][p])) {
              c[e][p] = this.transformCoordsArray(c[e][p]);
            } else {
              console.log('Invalid polygon coordinates!');
            }
          }
        }
      }
      pc = c;
    } else {
      // point feature
      pc = toLonLat(c);
      // shift anchor
      if (fid === 'anchor') {
        this.anchor.setAnchorPosition(pc).catch((err: HttpErrorResponse) => {
          this.app.parseHttpErrorResponse(err);
        });
      }
    }
    this.mapInteract.draw.forSave['coords'] = pc;
    if (
      this.app.data.activeRoute &&
      this.mapInteract.draw.forSave.id.indexOf(this.app.data.activeRoute) !== -1
    ) {
      this.app.data.activeRouteIsEditing = true;
    } else {
      this.app.data.activeRouteIsEditing = false;
    }
    this.app.data.editingId = this.mapInteract.draw.forSave.id;

    this.app.debug(this.mapInteract.draw.forSave);
  }

  /**
   * Extend the route being modified by appending a clicked open-water point as
   * the new end point (issue #549) — so a route can be lengthened the same way
   * it is drawn, without leaving modify mode. A click *on* the route belongs to
   * OL Modify (move / insert / delete a vertex), so only clicks that miss it
   * extend the route — and never the click that completes a vertex delete
   * (#608); the state kept in sync here mirrors applyModifyEnd so Undo, Finish
   * and Cancel all see the new point.
   */
  private extendRouteInModify(e: FBClickEvent) {
    const f = this.mapInteract.draw.features?.getArray()[0] as Feature;
    const geom = f?.getGeometry();
    if (!f || geom?.getType() !== 'LineString') {
      return;
    }
    const line = geom as OLLineString;
    // A click within the modify hit tolerance of the route itself is OL Modify's
    // (a vertex move / insert / delete); only a genuine miss extends the route.
    // Use a tolerance >= Modify's own pixelTolerance (OL default 10) so no single
    // click both inserts a vertex and appends an end point. getFeaturesAtPixel is
    // world-copy aware, matching what the user sees under wrapX.
    const tol = hitToleranceForPointer(e.originalEvent?.pointerType, 10, 15);
    const onRoute = this.olMap
      .getMap()
      .getFeaturesAtPixel(e.pixel, { hitTolerance: tol })
      .some((ft) => ft.getId() === f.getId());
    // A delete has already moved the line away from the clicked pixel, so the
    // hit-test above cannot recognise the release that completed it (#608).
    if (
      !shouldExtendRoute({
        onRoute,
        vertexDeleted: vertexDeleted(this.olMap.getMap())
      })
    ) {
      return;
    }
    // Metadata may not be seeded yet if this is the first op of the session (no
    // modifystart has run) — read it from the feature, matching onModifyStart.
    let meta = this.mapInteract.draw.forSave.coordsMetadata as
      RoutePointMeta[] | undefined;
    if (!meta) {
      const fm = f.get('pointMetadata');
      meta = Array.isArray(fm) ? fm : undefined;
    }

    const ext = line.getExtent();
    const result = extendRouteAtClick(
      line.getCoordinates() as Position[],
      meta,
      fromLonLat(e.lonlat) as Position,
      ext[0],
      ext[2]
    );

    // Snapshot the pre-append state so the extension can be undone within the
    // session (#542), mirroring applyModifyEnd.
    this.mapInteract.pushModifyUndo(result.undo);
    line.setCoordinates(result.after);
    // Mirror the length-aligned metadata onto the feature so the next
    // modifystart re-reads a correct-length array.
    if (result.meta) {
      f.set('pointMetadata', result.meta);
    }

    if (!this.mapInteract.draw.forSave.id) {
      this.mapInteract.draw.forSave.id = f.getId();
    }
    this.mapInteract.draw.forSave.coordsMetadata = result.meta;
    this.mapInteract.draw.coordinates = result.after;
    const pc = this.transformCoordsArray(result.after);
    this.mapInteract.draw.forSave['coords'] = pc;
    this.mapInteract.measurementCoords = pc as LineString;
    this.mapInteract.measurementIndex = modifiedLegIndex(
      result.undo.coordinates,
      result.after
    );

    this.app.data.activeRouteIsEditing =
      !!this.app.data.activeRoute &&
      this.mapInteract.draw.forSave.id.indexOf(this.app.data.activeRoute) !==
        -1;
    this.app.data.editingId = this.mapInteract.draw.forSave.id;
  }

  /** Process pointer click in non-interaction mode */
  private processMapClick(e) {
    this.s57Features = {};
    const featureList: Map<
      string,
      {
        id: string;
        coord: Position;
        icon: AppIconDef;
        text: string;
      }
    > = new Map(); // features under pointer
    const chartBoundsFeatures: Map<
      string,
      {
        id: string;
        coord: Position;
        icon: string;
        text: string;
      }
    > = new Map(); // chart bounds under pointer
    const fa = []; // features that can be the target of modify interaction
    let maskPopover = false; // suppress popover display

    // process list of features at click location
    e.features.forEach((feature: Feature) => {
      let id = feature.getId() as string;
      let addToFeatureList = false;
      let aton: SKAtoN;
      let sar: SKSaR;
      let meteo: SKMeteo;
      let aircraft: SKAircraft;
      let vessel: SKVessel;
      let icon: AppIconDef;
      let text: string;
      if (id && typeof id === 'string') {
        const t = id.split('.');
        if (t[0] === 'chart-backdrop') {
          maskPopover = true;
          return;
        }
        if (t[0] === 'chart-bound') {
          chartBoundsFeatures.set(id, {
            id: t[1],
            coord: e.lonlat,
            icon: 'map',
            text: feature.get('name')
          });
        }
        switch (t[0]) {
          case 'rset':
            addToFeatureList = true;
            icon = {
              name: 'star',
              svgIcon: undefined
            };
            text = feature.get('name');
            break;
          case 'alarm':
            addToFeatureList = true;
            icon = {
              name: 'notification_important',
              svgIcon: undefined
            };
            text = `Alarm: ${feature.get('type')}`;
            break;
          case 'anchor':
            addToFeatureList = true;
            icon = {
              name: 'anchor',
              svgIcon: undefined
            };
            text = `${t[0]}`;
            break;
          case 'dest':
            addToFeatureList = true;
            icon = {
              name: 'flag',
              svgIcon: undefined
            };
            text = 'Destination';
            break;
          case 'note':
            icon = {
              svgIcon: feature.get('icon'),
              name: undefined
            };
            addToFeatureList = true;
            const n = this.skres.fromCache('notes', t[1]);
            text = n[1].name ?? '';
            break;
          case 'route':
            icon = {
              svgIcon: 'route',
              name: undefined,
              class: 'icon-route'
            };
            addToFeatureList = true;
            const ub = this.getUnsavedRouteBuffer(t[1]);
            if (ub) {
              text = ub.name || 'Route (unsaved)';
            } else {
              const r = this.skres.fromCache('routes', t[1]);
              text = r[1].name;
            }
            break;
          case 'waypoint':
            icon = {
              name: 'location_on',
              svgIcon: undefined,
              class: 'icon-waypoint'
            };
            addToFeatureList = true;
            const w = this.skres.fromCache('waypoints', t[1]);
            text = w[1].name ?? '';
            break;
          case 'atons':
          case 'aton':
          case 'shore':
            icon = {
              name: 'beenhere',
              svgIcon: undefined
            };
            addToFeatureList = true;
            aton = this.app.data.atons.get(id);
            text = aton ? aton.name || aton.mmsi : '';
            break;
          case 'sar':
            icon = {
              name: 'tour',
              svgIcon: undefined
            };
            addToFeatureList = true;
            sar = this.app.data.sar.get(id);
            text = sar ? sar.name || sar.mmsi : 'SaR Beacon';
            break;
          case 'meteo':
            icon = {
              name: 'air',
              svgIcon: undefined
            };
            addToFeatureList = true;
            meteo = this.app.data.meteo.get(id);
            text = meteo ? meteo.name || meteo.mmsi : 'Weather Station';
            break;
          case 'ais-vessels':
            icon = {
              name: 'directions_boat',
              svgIcon: undefined
            };
            addToFeatureList = true;
            vessel = this.dfeat.ais.get(`vessels.${t[1]}`);
            text = vessel ? vessel.name || vessel.mmsi : '';
            break;
          case 'vessels':
            icon = {
              name: 'directions_boat',
              svgIcon: undefined
            };
            addToFeatureList = true;
            text = this.dfeat.self.name
              ? this.dfeat.self.name + ' (self)'
              : 'self';
            break;
          case 'region':
            addToFeatureList = true;
            icon = {
              name: 'tab_unselected',
              svgIcon: undefined,
              class: 'icon-region'
            };
            text = feature.get('name');
            break;
          case 'aircraft':
            icon = {
              name: 'airplanemode_active',
              svgIcon: undefined
            };
            addToFeatureList = true;
            aircraft = this.app.data.aircraft.get(id);
            text = aircraft ? aircraft.name || aircraft.mmsi : '';
            break;
          case 'tidal':
            addToFeatureList = true;
            icon = {
              name: 'water',
              svgIcon: undefined
            };
            text = feature.get('name');
            this.tidalFeatures[id] = {
              speedKn: Number(feature.get('driftKts')) || 0,
              direction: Number(feature.get('setDeg')) || 0
            };
            break;
        }
      } else if (!id && feature.getProperties) {
        const props = feature.getProperties();
        // S57 features
        if (props['RCID'] && S57_CLICKABLE_LAYERS.has(props['layer'])) {
          id = `s57.${props['LNAM']}`;
          addToFeatureList = true;
          icon = {
            name: 'beenhere',
            svgIcon: undefined
          };
          text = S57_NAMES[props['layer']];
          this.s57Features[id] = props;
        }
      }

      if (addToFeatureList && !featureList.has(id)) {
        featureList.set(id, {
          id: id,
          coord: e.lonlat,
          icon: icon,
          text: text
        });
        fa.push(feature);
      }
    });

    if (chartBoundsFeatures.size > 0) {
      // show list of chart features
      this.formatPopover('chartlist.', e.lonlat, chartBoundsFeatures);
      return;
    }
    if (maskPopover) {
      return;
    }
    this.mapInteract.draw.features = new Collection(fa);
    if (featureList.size === 1) {
      // only 1 feature
      const v = featureList.values().next().value;
      this.formatPopover(v['id'], v['coord']);
    } else if (featureList.size > 1) {
      // show list of features
      this.formatPopover('list.', e.lonlat, featureList);
    }
  }

  private s57Features: Record<string, Record<string, string | number>> = {};
  private tidalFeatures: Record<
    string,
    Pick<GridSample, 'speedKn' | 'direction'>
  > = {};

  // ******** POPOVER ACTIONS ************

  /**
   * build popover for selected feature
   * @param id: feature id
   * @param coord Position to display the popover
   * @param featureList list of map features at the supplied position
   * */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  protected formatPopover(
    id: string,
    coord: Position,
    featureList?: Map<string, any>
  ) {
    if (!id) {
      this.overlay.update((current) => {
        return Object.assign({}, current, { show: false });
      });
      return;
    }

    const poData: IPopover = {
      show: false,
      content: [],
      id: null,
      type: null,
      title: null,
      featureCount: this.mapInteract.draw.features?.getLength(),
      position: coord,
      // Draw the popover in the world copy the opening click landed in. position
      // stays canonical; this only shifts where it renders (#572).
      worldOffset: this.clickWorldOffset,
      readOnly: false,
      isSelf: false
    };

    let item = null;
    const t = id.split('.');
    let aid: string;

    switch (t[0]) {
      case 's57':
        poData.id = id;
        poData.title = 'S57 Feature';
        poData.type = 's57';
        poData.s57Feature = this.s57Features[id];
        poData.position = coord;
        poData.show = true;
        poData.readOnly = true;
        break;
      case 'anchor':
        this.modifyFeature('anchor');
        return;
      case 'bearing_dist':
        const d =
          GeoUtils.distanceTo(this.app.data.vessels.self.position, coord) ?? 0;
        const b =
          getGreatCircleBearing(this.app.data.vessels.self.position, coord) ??
          0;
        poData.type = t[0];
        poData.title = `${this.app.formatValueForDisplay(d, 'm')} ${this.app.formatValueForDisplay(b, 'deg')}`;
        poData.position = coord;
        poData.show = true;
        break;
      case 'list':
        poData.type = t[0];
        poData.title = 'Features';
        poData.content = [];
        featureList.forEach((f) => poData.content.push(f));
        poData.show = true;
        break;
      case 'chartlist':
        poData.type = t[0];
        poData.content = [];
        featureList.forEach((f) => poData.content.push(f));
        poData.show = true;
        break;
      case 'alarm':
        aid = id.split('.').slice(1).join('.');
        const alm = this.notiMgr.getAlert(aid) as any;
        if (!alm) {
          return false;
        }
        poData.type = t[0];
        poData.alarm = alm;
        poData.id = aid;
        poData.show = true;
        break;
      case 'vessels':
        poData.type = 'ais';
        poData.isSelf = true;
        poData.vessel = this.dfeat.self;
        poData.position = this.dfeat.self.position;
        poData.show = true;
        break;
      case 'ais-vessels':
        aid = id.slice(4);
        if (!this.dfeat.ais.has(aid)) {
          return false;
        }
        poData.type = 'ais';
        poData.id = aid;
        poData.vessel = this.dfeat.ais.get(aid);
        poData.position = poData.vessel.position;
        poData.show = true;
        break;
      case 'atons':
      case 'aton':
      case 'shore':
        if (!this.app.data.atons.has(id)) {
          return false;
        }
        poData.type = 'aton';
        poData.id = id;
        poData.aton = this.app.data.atons.get(id);
        poData.position = poData.aton.position;
        poData.show = true;
        break;
      case 'sar':
        if (!this.app.data.sar.has(id)) {
          return false;
        }
        poData.type = 'aton';
        poData.id = id;
        poData.aton = this.app.data.sar.get(id);
        poData.position = poData.aton.position;
        poData.show = true;
        break;
      case 'meteo':
        if (!this.app.data.meteo.has(id)) {
          return false;
        }
        poData.type = t[0];
        poData.id = id;
        poData.aton = this.app.data.meteo.get(id);
        poData.position = poData.aton.position;
        poData.show = true;
        break;
      case 'aircraft':
        if (!this.app.data.aircraft.has(id)) {
          return false;
        }
        poData.type = t[0];
        poData.id = id;
        poData.aircraft = this.app.data.aircraft.get(id);
        poData.position = poData.aircraft.position;
        poData.show = true;
        break;
      case 'tidal': {
        const tf = this.tidalFeatures[id];
        poData.id = id;
        poData.type = 'tidal';
        poData.position = coord;
        poData.show = true;
        poData.readOnly = true;
        if (tf) {
          poData.tidal = {
            speedLabel: this.tidalCurrents.formatSpeed(tf.speedKn),
            directionLabel: `${this.app.formatValueForDisplay(tf.direction, 'deg', { precision: 0 })}T`
          };
        }
        break;
      }
      case 'region':
        item = [this.skres.fromCache('regions', t[1])];
        if (!item) {
          return false;
        }
        poData.id = t[1];
        poData.type = t[0];
        poData.title = 'Region';
        poData.resource = item[0];
        poData.show = true;
        poData.readOnly = item[0][1]?.feature?.properties?.readOnly ?? false;
        if (this.infoPanel.opened()) {
          this.overlay.set(poData);
          this.popoverInfo();
        }
        break;
      case 'note':
        item = this.skres.fromCache('notes', t[1]);
        if (!item) {
          return false;
        }
        poData.readOnly = item[1]?.properties?.readOnly ?? false;
        poData.id = t[1];
        poData.type = t[0];
        poData.title = 'Note';
        poData.resource = item;
        poData.show = true;
        // Single-click-to-details: skip the interim options popover and open the
        // Note details surface (info panel or dialog) straight away.
        if (this.app.config.display.singleClickNoteDetails) {
          poData.show = false;
          this.overlay.set(poData);
          this.popoverInfo();
        } else if (this.infoPanel.opened()) {
          this.overlay.set(poData);
          this.popoverInfo();
        }
        break;
      case 'route': {
        const ub = this.getUnsavedRouteBuffer(t[1]);
        if (ub) {
          // Unsaved live edit buffer (amber draft): build the popover from the
          // registry, not the saved-route cache. The popover header shows the
          // route name, so fall back to "(unsaved)" for an unnamed draft.
          const skr = this.bufferToFBRoute(ub)[1];
          skr.name = skr.name || '(unsaved)';
          poData.id = t[1];
          poData.type = 'route';
          poData.title = 'Route (unsaved)';
          poData.resource = [t[1], skr];
          poData.show = true;
          poData.readOnly = false;
        } else {
          item = [this.skres.fromCache('routes', t[1])];
          if (!item) {
            return false;
          }
          poData.id = t[1];
          poData.type = t[0];
          poData.title = 'Route';
          poData.resource = item[0];
          poData.show = true;
          poData.readOnly = item[0][1]?.feature?.properties?.readOnly ?? false;
        }
        if (this.infoPanel.opened()) {
          this.overlay.set(poData);
          this.popoverInfo();
        }
        break;
      }
      case 'waypoint':
        item = [this.skres.fromCache('waypoints', t[1])];
        if (!item) {
          return false;
        }
        poData.id = t[1];
        poData.type = t[0];
        poData.resource = item[0];
        poData.title = 'Waypoint';
        poData.show = true;
        poData.readOnly = item[0][1]?.feature?.properties?.readOnly ?? false;
        if (this.infoPanel.opened()) {
          this.overlay.set(poData);
          this.popoverInfo();
        }
        break;
      case 'dest':
        poData.id = id;
        poData.type = 'destination';
        poData.resource = this.skres.buildWaypoint(coord) as [
          string,
          SKWaypoint
        ];
        poData.title = this.course.courseData().destPointName ?? 'Destination';
        poData.show = true;
        break;
      case 'rset':
        poData.id = id;
        poData.type = t[0];
        poData.resource = this.skresOther.fromResourceSetCache(id, true);
        poData.title =
          (poData.resource as GeoJsonFeature)?.properties?.name ??
          'Resource Set';
        poData.show = poData.resource ? true : false;
        break;
      default:
        return;
    }
    this.overlay.set(poData);
  }

  /** handle selection from the FeatureList popover */
  protected featureListSelection(feature) {
    // trim the draw.features collection to the selected feature.id
    const sf = new Collection();
    this.mapInteract.draw.features.forEach((e) => {
      if (e.getId() === feature.id) {
        sf.push(e);
      }
    });
    this.mapInteract.draw.features = sf;
    this.formatPopover(feature.id, feature.coord);
  }

  /** handle popover info event */
  protected popoverInfo() {
    const collection = `${this.overlay().type}s` as SKResourceType;
    if (
      ['notes', 'regions', 'waypoints', 'routes'].includes(collection) &&
      this.app.useInfoPanel()
    ) {
      const ub = this.getUnsavedRouteBuffer(this.overlay().id);
      if (collection === 'routes' && ub) {
        // Unsaved live buffer: open the info panel directly from the registry
        // (it is not on the server, so infoPanel.open()'s fetch would fail).
        this.infoPanel.openWith('routes', this.bufferToFBRoute(ub));
      } else {
        this.infoPanel.open(collection, this.overlay().id);
      }
    } else {
      this.skres.resourceProperties(this.overlay());
    }
  }

  /** handle popover closed event */
  protected popoverClosed() {
    this.overlay.update((current) => {
      return Object.assign({}, current, { show: false });
    });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private updateCoordsMeta(startCoords: any[], endCoords: any[], meta: any[]) {
    if (!meta) {
      return;
    }
    const mode =
      endCoords.length > startCoords.length
        ? 'ADD'
        : endCoords.length < startCoords.length
          ? 'DELETE'
          : 'MOVE';

    function stringifyCoords(coords: Coordinate[]): string[] {
      return coords.map((c: Coordinate) => {
        return `${c[0]} - ${c[1]}`;
      });
    }

    if (mode === 'MOVE') {
      return meta;
    }
    startCoords = stringifyCoords(startCoords);
    endCoords = stringifyCoords(endCoords);
    if (mode === 'DELETE') {
      for (let i = 0; i < startCoords.length; i++) {
        if (!endCoords.includes(startCoords[i])) {
          meta.splice(i, 1);
        }
      }
      return meta;
    } else if (mode === 'ADD') {
      for (let i = 0; i < endCoords.length; i++) {
        if (startCoords[i] !== endCoords[i]) {
          meta.splice(i, 0, { name: '' });
          break;
        }
      }
      return meta;
    } else {
      return meta;
    }
  }

  // ****** MAP control functions *******

  // Bound to the map element's keydown (so it only fires when the map has focus,
  // as OL's keyboardzoom did): route +/- through the same offset-preserving zoom
  // as the on-screen buttons instead of OL zooming about the chart centre.
  protected onZoomKeydown(e: KeyboardEvent) {
    const direction = zoomKeyDirection(e);
    if (!direction) {
      return;
    }
    e.preventDefault();
    this.zoomMap(direction === 'in');
  }

  // handle map zoom controls
  protected zoomMap(zoomIn: boolean) {
    const { min, max } = this.app.MAP_ZOOM_EXTENT;
    const current = this.app.config.map.zoomLevel;
    const next = zoomIn
      ? Math.min(current + 1, max)
      : Math.max(current - 1, min);
    if (next === current) {
      return;
    }
    // Following with a screen offset: recompute the centre for the new zoom and
    // set it in the same update as the zoom, so the vessel keeps its on-screen
    // spot instead of zooming about the chart centre and snapping the offset
    // back on the next position fix.
    if (this.movingMap && !this.offsetSuppressed && !this.userPanned) {
      const offset = this.app.config.map.centerOffset;
      if (offset.x || offset.y) {
        this.mapCenterPositon.set(this.app.calcMapCenter(true, next - current));
      }
    }
    this.mapZoomLevel.set(next);
    this.app.config.map.zoomLevel = next;
  }

  // orient map heading up / north up
  private rotateMap() {
    if (this.northUp) {
      this.mapRotation.set(0);
    } else {
      this.mapRotation.update(() => 0 - this.dfeat.active.orientation);
    }
  }

  // center map to active vessel position
  private centerVessel() {
    if (this.userPanned) {
      // A pan is in flight under the 'offset' behavior: a position update
      // arriving mid-drag would snatch the chart back from the user.
      return;
    }
    const pos = this.app.calcMapCenter(!this.offsetSuppressed);
    this.mapCenterPositon.update(() => pos);
  }

  /**
   * Hold the vessel dead centre for a moment after follow mode is turned on
   * before easing out to the configured centre offset. A second tap of the
   * button inside that window therefore leaves the vessel exactly centred,
   * which is how "just centre it" is expressed now that one button does both.
   */
  private startOffsetGrace() {
    this.stopOffsetGrace();
    this.offsetSuppressed = true;
    this.centerVessel();
    this.offsetGraceTimer = setTimeout(() => {
      this.offsetSuppressed = false;
      this.centerVessel();
    }, OFFSET_GRACE_PERIOD);
  }

  private stopOffsetGrace() {
    clearTimeout(this.offsetGraceTimer);
    this.offsetGraceTimer = undefined;
    this.offsetSuppressed = false;
  }

  /** construct vessel lines for rendering */
  protected drawVesselLines(vesselUpdate = false) {
    const z = this.mapZoomLevel();
    const offset = z < 29 ? zoomOffsetLevel[Math.floor(z)] : 60;
    const wMax = 10; // max line length

    // update vessel trail
    if (vesselUpdate) {
      this.app.addToSelfTrail(this.dfeat.self.position);
    }

    // render laylines
    this.buildLaylines();

    // render cog, heading, twd, awa for focused vessel
    this.vesselLines.update(() => {
      const cog = this.dfeat.active.vectors.cog ?? [];

      const sog = this.dfeat.active.sog || 0;
      let hl = 0;
      if (this.app.config.vessels.selfLines.heading.length === -1) {
        hl = (sog > wMax ? wMax : sog) * offset;
      } else {
        hl =
          Convert.nauticalMilesToKm(
            this.app.config.vessels.selfLines.heading.length
          ) * 1000;
      }
      const heading = [
        this.dfeat.active.position,
        GeoUtils.rhumbDestination(
          this.dfeat.active.position,
          this.dfeat.active.orientation,
          hl
        )
      ];

      return {
        cog: cog,
        heading: heading
      };
    });
  }

  /** calculate vessel & dest laylines & update signals */
  private buildLaylines() {
    if (
      this.app.config.vessels.laylines &&
      Array.isArray(this.dfeat.navData.position) &&
      typeof this.dfeat.navData.position[0] === 'number' &&
      typeof this.app.data.vessels.active.heading === 'number'
    ) {
      const twd_deg = Convert.radiansToDegrees(
        this.app.data.vessels.self.wind.direction ?? 0
      );

      const twd_inv = Angle.add(twd_deg, 180);

      const destUpwind =
        Math.abs(
          Angle.difference(this.course.courseData().bearing.value, twd_deg)
        ) < 90;

      // beat angle
      const ba_deg = Convert.radiansToDegrees(
        this.app.data.vessels.self.performance.beatAngle ?? Math.PI / 4
      );

      // gybe angle
      let ga_deg: number;
      let ga_diff: number;
      if (
        typeof this.app.data.vessels.self.performance.gybeAngle === 'number'
      ) {
        ga_deg = Convert.radiansToDegrees(
          this.app.data.vessels.self.performance.gybeAngle
        );
        ga_diff = 180 - Math.abs(ga_deg);
      }

      const destInTarget = destUpwind
        ? Math.abs(
            Angle.difference(this.course.courseData().bearing.value, twd_deg)
          ) < ba_deg
        : Math.abs(
            Angle.difference(this.course.courseData().bearing.value, twd_inv)
          ) < (ga_diff ?? 0);

      const dtg =
        this.app.config.units.distance === 'kilometer'
          ? this.course.courseData().dtg * 1000
          : Convert.nauticalMilesToKm(this.course.courseData().dtg * 1000);

      // mark laylines
      let markLines = [];
      if (destUpwind) {
        const bapt1 = computeDestinationPoint(
          this.dfeat.navData.position,
          dtg,
          Angle.add(twd_inv, ba_deg)
        );
        const bapt2 = computeDestinationPoint(
          this.dfeat.navData.position,
          dtg,
          Angle.add(twd_inv, 0 - ba_deg)
        );

        markLines = [
          [bapt1.longitude, bapt1.latitude],
          this.dfeat.navData.position,
          [bapt2.longitude, bapt2.latitude]
        ];
      } else if (typeof ga_deg === 'number') {
        const gapt1 = computeDestinationPoint(
          this.dfeat.navData.position,
          dtg,
          Angle.add(twd_inv, ga_deg)
        );
        const gapt2 = computeDestinationPoint(
          this.dfeat.navData.position,
          dtg,
          Angle.add(twd_inv, 0 - ga_deg)
        );

        markLines = [
          [gapt1.longitude, gapt1.latitude],
          this.dfeat.navData.position,
          [gapt2.longitude, gapt2.latitude]
        ];
      }

      this.perfTargetAngle.update(() => markLines);

      // vessel laylines
      if (destInTarget) {
        const hbd_deg = Angle.difference(
          twd_deg,
          this.course.courseData().bearing.value
        );
        // Vector lengths
        let b: number;
        let c: number;
        // intersection points
        let ipts: SKPosition;
        let iptp: SKPosition;

        if (destUpwind) {
          // Vector angles
          const C_RAD = Convert.degreesToRadians(ba_deg - hbd_deg);
          const B_RAD = Convert.degreesToRadians(ba_deg + hbd_deg);
          const A_RAD = Math.PI - (B_RAD + C_RAD);
          b = (dtg * Math.sin(B_RAD)) / Math.sin(A_RAD);
          c = (dtg * Math.sin(C_RAD)) / Math.sin(A_RAD);
          // intersection points
          ipts = computeDestinationPoint(
            this.app.data.vessels.active.position,
            b,
            Angle.add(twd_deg, ba_deg)
          );
          iptp = computeDestinationPoint(
            this.app.data.vessels.active.position,
            c,
            Angle.add(twd_deg, 0 - ba_deg)
          );
        } else {
          // downwind
          if (markLines.length !== 0 && typeof ga_diff === 'number') {
            // Vector angles
            const C_RAD = Convert.degreesToRadians(ga_diff - hbd_deg);
            const B_RAD = Convert.degreesToRadians(ga_diff + hbd_deg);
            const A_RAD = Math.PI - (B_RAD + C_RAD);
            b = (dtg * Math.sin(B_RAD)) / Math.sin(A_RAD);
            c = (dtg * Math.sin(C_RAD)) / Math.sin(A_RAD);
            // intersection points
            ipts = computeDestinationPoint(
              this.app.data.vessels.active.position,
              b,
              Angle.add(twd_deg, ga_diff)
            );
            iptp = computeDestinationPoint(
              this.app.data.vessels.active.position,
              c,
              Angle.add(twd_deg, 0 - ga_diff)
            );
          }
        }

        this.perfLaylines.update(() => {
          return {
            port: [
              [
                [iptp.longitude, iptp.latitude],
                this.app.data.vessels.active.position
              ],
              [
                [ipts.longitude, ipts.latitude],
                this.app.data.vessels.active.position
              ]
            ],
            starboard: [
              [[ipts.longitude, ipts.latitude], markLines[1]],
              [markLines[1], [iptp.longitude, iptp.latitude]]
            ]
          };
        });
      }
    }
  }

  // ********************

  // ** delete selected feature **
  protected async deleteFeature(id: string, type: string) {
    switch (type) {
      case 'waypoint':
        this.skres.deleteWaypoint(id);
        break;
      case 'route': {
        const b = this.routeBuffers.get(id);
        if (b && !b.saved) {
          // Unsaved draft — discard the live buffer locally.
          this.routeBuffers.delete(id);
        } else {
          // Saved route (clean or dirty, with or without a buffer) — delete the
          // stored resource by its href (a saved draft keeps its draft routeId),
          // and only drop the live buffer once the server delete is confirmed so
          // a cancel/failure doesn't lose it. hiddenSaved=false so the registry's
          // hidden event reports a permanent delete, not a hide.
          const ok = await this.skres.deleteRoute(b?.href ?? id);
          if (ok && b) {
            this.routeBuffers.delete(id, false);
          }
        }
        break;
      }
      case 'note':
        this.skres.deleteNote(id);
        break;
      case 'region':
        this.skres.deleteRegion(id);
        break;
    }
  }

  // ** hide a displayed feature (non-destructive; resource is untouched)
  protected hideFeature(id: string, type: string) {
    if (type === 'route') {
      // Same effect as unchecking "Show on Map" in the Routes list — remove the
      // route from the displayed set; it reappears when re-selected there.
      this.skres.routeHide(id);
    }
  }

  // ** activate route / waypoint
  protected setActiveFeature() {
    if (this.overlay().type === 'waypoint') {
      this.course.navigateToWaypoint(this.overlay().id);
    } else {
      this.activate.emit(this.overlay().id);
    }
  }

  // ** deactivate route / waypoint
  protected clearActiveFeature() {
    this.deactivate.emit(this.overlay().id);
  }

  // ** emit info event **
  protected itemInfo(id: string, type: string, isSelf = false) {
    if (type === 'ais' && isSelf) {
      this.info.emit({ id: id, type: 'self' });
    } else {
      this.info.emit({ id: id, type: type });
    }
  }

  /**
   * Popover "Points" action → opens the route's reorder dialog. A stored route
   * takes the normal path (the parent fetches it from the server). An unsaved
   * draft / dirty route has no server copy, so pass the buffer-derived route:
   * the parent opens the dialog from it without a fetch and reorders persist
   * back to the buffer (#583).
   */
  protected popoverPoints() {
    const ub = this.getUnsavedRouteBuffer(this.overlay().id);
    this.info.emit({
      id: this.overlay().id,
      type: this.overlay().type,
      resource: ub ? this.bufferToFBRoute(ub) : undefined
    });
  }

  protected setActiveVessel(id: string = null) {
    this.focusVessel.emit(id);
  }

  // ******** Interactions: DRAW / EDIT / MEASURE ************

  /** Apply the selected Interaction mode */
  private applyInteractionMode(mode: INTERACTION_MODE, value: boolean) {
    this.overlay.update((current) => {
      return Object.assign({}, current, { show: false });
    });
    if (mode === INTERACTION_MODE.MEASURE) {
      this.mapInteract.draw.style = value
        ? this.mapInteract.measureGeometryType === 'Circle'
          ? drawStyles.radius
          : drawStyles.measure
        : drawStyles.default;
    }
    if (mode === INTERACTION_MODE.DRAW) {
      this.mapInteract.draw.style =
        this.mapInteract.draw.resourceType === 'route'
          ? drawStyles.route
          : this.mapInteract.draw.resourceType === 'region'
            ? drawStyles.region
            : drawStyles.default;
    }
  }

  // ********************************************************

  private isCoordsArray(ca: Array<Position>) {
    if (Array.isArray(ca)) {
      return Array.isArray(ca[0]) && typeof ca[0][0] === 'number';
    } else {
      return false;
    }
  }

  private transformCoordsArray(ca: Array<Position>) {
    return ca.map((i) => {
      return toLonLat(i);
    });
  }

  // apply default chart style (vectortile)
  applyStyle() {
    return new Style({
      fill: new Fill({
        color: '#e0d10e'
      }),
      stroke: new Stroke({
        color: '#444',
        width: 1
      })
    });
  }

  // ** called by onMapMoveEnd() to render features within map extent
  private renderMapContents(zoomChanged?: boolean) {
    if (this.shouldFetchNotes(zoomChanged)) {
      this.skres.refreshNotes();
      this.app.debug(`fetching Notes...`);
    }
    if (this.shouldFetchResourceSets(zoomChanged)) {
      this.app.debug(`fetching ResourceSets...`);
      this.skresOther.refreshResourceSetsInBounds();
    }
  }

  // returns true when map center has moved a distance > (threshold / 2)
  private mapMoveThresholdExceeded(threshold: number): boolean {
    if (!this.app.data.lastGet) {
      this.app.data.lastGet = this.app.config.map.center;
      return true;
    }
    // ** calc distance from new map center to lastGet
    const d = GeoUtils.distanceTo(
      this.app.data.lastGet,
      this.app.config.map.center
    );
    this.app.debug(`distance map moved: ${d}`);
    // ** if d is more than half the getRadius
    const cr =
      this.app.config.units.distance === 'kilometer'
        ? threshold * 1000
        : Convert.nauticalMilesToKm(threshold) * 1000;

    this.app.debug(`mapMoveThresholdExceeded: ${d >= cr / 2}`);
    if (d >= cr / 2) {
      this.app.data.lastGet = this.app.config.map.center;
      return true;
    } else {
      return false;
    }
  }

  // ** returns true if skresOther.refreshResourceSetsInBounds() should be called
  private shouldFetchResourceSets(zoomChanged: boolean) {
    if (
      this.app.config.resources.fetchRadius !== 0 &&
      this.app.config.resources.fetchFilter
    ) {
      if (!this.skresOther.anyResourceSetSelection()) {
        return false;
      }
      if (zoomChanged) {
        return true;
      }
      return this.mapMoveThresholdExceeded(50);
    } else {
      return false;
    }
  }

  /**
   * @description Determine if Notes should be fetched from the server.
   * @param zoomChanged When true signifies that zoom level has changed.
   * @returns true if skres.refreshNotes() should be called
   */
  private shouldFetchNotes(zoomChanged: boolean) {
    this.showNoteslayer.update(
      () =>
        this.app.config.ui.showNotes &&
        this.app.config.map.zoomLevel >= this.app.config.resources.notes.minZoom
    );

    this.app.debug(`lastGet: ${this.app.data.lastGet}`);
    this.app.debug(`getRadius: ${this.app.config.resources.notes.getRadius}`);

    if (zoomChanged) {
      if (this.mapZoomLevel() < this.app.config.resources.notes.minZoom) {
        return false;
      } else {
        return true;
      }
    }
    return this.mapMoveThresholdExceeded(
      this.app.config.resources.notes.getRadius
    );
  }

  /**
   * @decsription Retrieves and displays feature information
   * @param pos Position of feature(s) to retrieve
   * @todo Experiment
   */
  private getFeatureInfo() {
    const f = [].concat(this.mapService.featureUrls());
    /*fetch(this.featureUrl)
    .then((response) => response.json())
    .then((json) => {
      console.log(json);
    });*/
    /*
    if (!this.app.config.resources.featureServer.url) return;
    // parse url tokens
    const u = this.app.config.resources.featureServer.url
      .replaceAll('%longitude%', pos[0].toString())
      .replaceAll('%latitude%', pos[1].toString())
      .replaceAll('%map:zoom%', this.mapZoomLevel().toFixed(1));

    this.http.get(u).subscribe(
      (r: { type: string; features: Feature[] }) => {
        if (Array.isArray(r?.features)) {
          this.bottomSheet.open(FeaturePropertiesModal, {
            disableClose: true,
            data: r.features
          });
        } else {
          this.app.showAlert('Invalid Response', 'Invalid data received!');
        }
      },
      (error) => {
        this.app.parseHttpErrorResponse(error);
      }
    );
    */
  }
}
