{$mode objfpc}{$H+}
unit uFormationOverlay;

// CC 20260924 formation-edit overlay for the focus group (phase 1, read-only):
//  station markers, off-station/reform lines, airborne aircraft at their actual
//  positions, and the selected item's patrol region (ring x sector wedges).
//  Owns one render-only layer; Show rebuilds the objects each tick.
// CC 20260924 units are drawn with their own icon from the group icon strip (as
//  the GE draws each unit's symbol in its formation display); the selected one
//  gets a yellow square behind it. Landed aircraft have no marker of their own:
//  selecting one highlights the ship/base it is aboard. Formation rings/spokes/labels stay in mainform (uRangeRings/uZoneLabels).
//  See plans/Plan-FormationEdit-20260924.md.

interface

uses
  Classes, Types, Graphics, ImgList,
  TGClasses, Globe4, TGSysUtils, GlobeUtils, TGPresenters, TGObjects,
  uHCTypeDefs,
  uGroupSnapshot, uFormationMath;

const
  // CC 20260924 colours are BGR; clBlack is fine here (no image masks)
  FO_OUTLINE_COLOR     = TColor($00000000);  // marker outlines
  FO_STATION_FILL      = TColor($00FFFFFF);  // white diamond
  FO_AIR_FILL          = TColor($00FFC080);  // light blue triangle
  FO_SELECTED_FILL     = TColor($0000FFFF);  // yellow, the GE's formation highlight
  FO_ICON_MASK         = TColor($00000000);  // icon strip transparent colour
  FO_ACTUAL_FILL       = TColor($00404040);  // dark grey dot at a reforming unit's actual position
  FO_REFORM_LINE_COLOR = TColor($00404040);  // actual -> station
  FO_ORDER_LINE_COLOR  = TColor($00404040);  // actual -> pending order target (dashed)
  FO_REGION_COLOR      = TColor($0000FFFF);  // selected patrol region outline
  FO_TITLE_COLOR       = TColor($00000000);

  FO_STATION_PX   = 9;
  FO_AIR_PX       = 11;
  FO_ICON_PX      = 16;   // CC 20260924 unit icons (group icon strip cell)
  FO_HALO_PX      = 22;   // CC 20260924 yellow square behind the selected icon
  FO_ACTUAL_PX    = 5;
  FO_REGION_PEN_PX = 2;
  FO_TITLE_FONT_PX = 10;
  FO_TITLE_OFFSET_PX = 3;

  // CC 20260924 a unit further than this from its station gets a reform line
  FO_OFFSTATION_NM = 0.5;

  // CC 20260924 arc resolution for patrol region outlines
  FO_ARC_STEP_DEG = 3;

  // CC 20260924 HitTest radius, device pixels
  FO_HIT_PX = 8;

type
  // CC 20260924 one clickable marker (station or airborne aircraft)
  TFormationMarker = record
    ObjID: Integer;
    PtLL: TPointLL;
    UnitUUID: string;            // the unit the marker belongs to
    IsAir: Boolean;
    HitKind: TFormationItemKind; // what a click on it selects
    HitUUID: string;
  end;

  TFormationOverlay = class
  private
    FGlobe: TGlobe4;
    FLayer: TGlobeLayer;
    FNextID: Integer;
    FSnap: TGroupSnapshot;
    FHasSnap: Boolean;
    FSelKind: TFormationItemKind;
    FSelUUID: string;
    FMarkers: array of TFormationMarker;
    FObjIDs: array of Integer;       // everything Show created except the region
    FRegionIDs: array of Integer;    // selection region objects
    FIcons: TCustomImageList;        // CC 20260924 group icon strip (mainform.icons)
    FDispSide: uchar;                // CC 20260924 side colour the group is shown in
    FIconPres: array of Integer;     // CC 20260924 icon index -> presenter ID, -1 = not made yet
    // presenter IDs (created once, live as long as the layer)
    FPresStation: Integer;           // fallback when a unit type has no icon
    FPresAir: Integer;
    FPresHalo: Integer;              // CC 20260924
    FPresActual: Integer;
    FPresReformLine, FPresOrderLine: Integer;
    FPresRegion: Integer;
    function CreatePointPres(aType: TPointPresenterType; aSizePx: Integer;
      aFill: TColor; aTitled: Boolean): Integer;
    function CreateLinePres(aColor: TColor; aStyle: TGlobePenStyle; aWidthPx: Integer): Integer;
    function AddObject(aPresID: Integer; const aTitle: string): TGeoDataObject;
    procedure RemoveIDs(const aIDs: array of Integer);
    procedure ClearRegion;
    function GroupCentre: TPointLL;
    function OffsetLL(xOff, yOff: LongInt): TPointLL;
    function IsMarkerSelected(const aMarker: TFormationMarker): Boolean;
    function UnitPresenter(aUType: HCMT; aIsAir: Boolean): Integer;  // CC 20260924
    procedure BuildRegion;
    procedure AddRegionCells(aRings, aSectors: UInt64);
  public
    constructor Create(aGlobe: TGlobe4; aIcons: TCustomImageList);
    destructor Destroy; override;
    procedure Show(const aSnap: TGroupSnapshot; aDispSide: uchar);
    procedure Hide;
    procedure SetSelection(aKind: TFormationItemKind; const aUUID: string);
    function HitTest(X, Y: Integer; out aKind: TFormationItemKind; out aUUID: string): Boolean;
  end;

implementation

uses
  SysUtils,
  uHCUtils, uGroupIcon;

function SliverLL(sLat, sLon: LongInt): TPointLL;
begin
  Result := PointLLH(
    DecimalToGlobeUnits(HCLon2Deg(sLon)),
    DecimalToGlobeUnits(HCLat2Deg(sLat)), 0);
end;

// CC 20260924 point at a compass bearing (any integer degrees) and range from pCentre
function BearingPoint(const pCentre: TPointLL; bearingDeg, rangeNM: Integer): TPointLL;
begin
  bearingDeg := bearingDeg mod 360;
  if bearingDeg < 0 then Inc(bearingDeg, 360);
  Result := AngleDistToGCPoint(pCentre, DecimalToGlobeUnits(bearingDeg),
    GlobeUnitsFrom(rangeNM, NauticalMile));
end;

// CC 20260924 arc from fromDeg to toDeg (either direction), both ends included
procedure AddArc(aChain: TPointStore; const pCentre: TPointLL;
  rangeNM, fromDeg, toDeg: Integer);
var
  lDeg, lStep: Integer;
begin
  if toDeg >= fromDeg then lStep := FO_ARC_STEP_DEG else lStep := -FO_ARC_STEP_DEG;
  lDeg := fromDeg;
  while ((lStep > 0) and (lDeg < toDeg)) or ((lStep < 0) and (lDeg > toDeg)) do
  begin
    aChain.Add(BearingPoint(pCentre, lDeg, rangeNM));
    Inc(lDeg, lStep);
  end;
  aChain.Add(BearingPoint(pCentre, toDeg, rangeNM));
end;

{ TFormationOverlay }

constructor TFormationOverlay.Create(aGlobe: TGlobe4; aIcons: TCustomImageList);
var
  i: Integer;
begin
  inherited Create;
  FGlobe := aGlobe;
  FIcons := aIcons;
  if FIcons <> nil then
  begin
    SetLength(FIconPres, FIcons.Count);
    for i := 0 to Length(FIconPres) - 1 do
      FIconPres[i] := -1;
  end;
  FNextID := 1;
  FSelKind := fikNone;
  FSelUUID := '';
  FHasSnap := False;

  FLayer := TGlobeLayer.Create(aGlobe);
  FLayer.Name := 'FormationOverlay';
  FLayer.Enabled := False;  // render-only; not hit-testable (HitTest does its own)

  FPresStation    := CreatePointPres(ppDiamond,    FO_STATION_PX,  FO_STATION_FILL,  True);
  FPresAir        := CreatePointPres(ppTriangleUp, FO_AIR_PX,      FO_AIR_FILL,      True);
  FPresHalo       := CreatePointPres(ppSquare,     FO_HALO_PX,     FO_SELECTED_FILL, False);
  FPresActual     := CreatePointPres(ppCircle,     FO_ACTUAL_PX,   FO_ACTUAL_FILL,   False);
  FPresReformLine := CreateLinePres(FO_REFORM_LINE_COLOR, TGSysUtils.psSolid, 1);
  FPresOrderLine  := CreateLinePres(FO_ORDER_LINE_COLOR,  TGSysUtils.psDash,  1);
  FPresRegion     := CreateLinePres(FO_REGION_COLOR, TGSysUtils.psSolid, FO_REGION_PEN_PX);
end;

destructor TFormationOverlay.Destroy;
var
  i: Integer;
  lPres: TGlobePresenter;
begin
  // CC 20260924 must run before the globe is destroyed (the globe frees its layers)
  Hide;
  if FLayer <> nil then
  begin
    // the icon presenters don't own their pictures (same as the group icons)
    for i := 0 to Length(FIconPres) - 1 do
      if FIconPres[i] >= 0 then
      begin
        lPres := FLayer.Presenters.ByID(FIconPres[i], False);
        if lPres <> nil then
          TPointPresenter(lPres).Image.Free;
      end;
    FLayer.Free;  // notifies the globe, frees our presenters
  end;
  FLayer := nil;
  inherited Destroy;
end;

function TFormationOverlay.CreatePointPres(aType: TPointPresenterType;
  aSizePx: Integer; aFill: TColor; aTitled: Boolean): Integer;
var
  lPres: TPointPresenter;
begin
  lPres := TPointPresenter.Create(FGlobe, 0);
  FLayer.Presenters.Add(lPres);  // assigns a unique PresenterID
  with lPres do
  begin
    PointType := aType;
    PointUnit := Pixel;
    PointSize := aSizePx;
    PointPen.Define(FO_OUTLINE_COLOR, TGSysUtils.psSolid, 1, Pixel);
    PointBrush.Define(aFill, TGSysUtils.bsSolid);
    if aTitled then
    begin
      TitleFont.FontColor := FO_TITLE_COLOR;
      TitleFont.FontUnit := Pixel;
      TitleFont.FontSize := FO_TITLE_FONT_PX;
      TitleAlignment := taRight;
      TitleOffset := FO_TITLE_OFFSET_PX;
    end
    else
      TitleAlignment := taNone;
  end;
  Result := lPres.PresenterID;
end;

// CC 20260924 presenter for a unit's icon (one per icon index, made on first use),
//  titled like the other markers; falls back to the diamond/triangle when the
//  unit type has no icon in the strip
function TFormationOverlay.UnitPresenter(aUType: HCMT; aIsAir: Boolean): Integer;
var
  lIdx: Integer;
  lPres: TPointPresenter;
begin
  lIdx := -1;
  if FIcons <> nil then
    lIdx := GetIconIndex(FDispSide, '', AnsiChar(aUType));
  if (lIdx < 0) or (lIdx >= Length(FIconPres)) then
  begin
    if aIsAir then Result := FPresAir else Result := FPresStation;
    Exit;
  end;
  if FIconPres[lIdx] < 0 then
  begin
    lPres := TPointPresenter.Create(FGlobe, 0);
    FLayer.Presenters.Add(lPres);
    with lPres do
    begin
      PointType := ppImage;
      PointUnit := Pixel;
      Image := TPicture.Create;
      Image.Bitmap.SetSize(FO_ICON_PX, FO_ICON_PX);
      Image.Bitmap.Canvas.Brush.Color := FO_ICON_MASK;
      Image.Bitmap.Canvas.FillRect(0, 0, FO_ICON_PX, FO_ICON_PX);
      FIcons.Draw(Image.Bitmap.Canvas, 0, 0, lIdx, True);
      Image.Bitmap.TransparentColor := FO_ICON_MASK;
      Image.Bitmap.Transparent := True;
      PointSize := FO_ICON_PX;
      MaxPointSize := FO_ICON_PX;
      TitleFont.FontColor := FO_TITLE_COLOR;
      TitleFont.FontUnit := Pixel;
      TitleFont.FontSize := FO_TITLE_FONT_PX;
      TitleAlignment := taRight;
      TitleOffset := FO_TITLE_OFFSET_PX + FO_ICON_PX div 2;
    end;
    FIconPres[lIdx] := lPres.PresenterID;
  end;
  Result := FIconPres[lIdx];
end;

function TFormationOverlay.CreateLinePres(aColor: TColor; aStyle: TGlobePenStyle;
  aWidthPx: Integer): Integer;
var
  lPres: TPolylinePresenter;
begin
  lPres := TPolylinePresenter.Create(FGlobe, 0);
  FLayer.Presenters.Add(lPres);
  lPres.PolyPen.Define(aColor, aStyle, aWidthPx, Pixel);
  lPres.TitleAlignment := taNone;
  Result := lPres.PresenterID;
end;

function TFormationOverlay.AddObject(aPresID: Integer; const aTitle: string): TGeoDataObject;
begin
  Result := TGeoDataObject.Create(FLayer.Objects);
  Result.PresenterID := aPresID;
  Result.Title := aTitle;
  Result.ID := FNextID;
  Inc(FNextID);
end;

procedure TFormationOverlay.RemoveIDs(const aIDs: array of Integer);
var
  i: Integer;
  lObj: TGlobeObject;
begin
  for i := High(aIDs) downto 0 do
  begin
    lObj := FLayer.ObjectByID(aIDs[i]);
    if lObj <> nil then
    begin
      FLayer.Objects.Delete(lObj);
      lObj.Free;
    end;
  end;
end;

procedure TFormationOverlay.ClearRegion;
begin
  RemoveIDs(FRegionIDs);
  SetLength(FRegionIDs, 0);
end;

function TFormationOverlay.GroupCentre: TPointLL;
begin
  Result := SliverLL(FSnap.sLat, FSnap.sLon);
end;

function TFormationOverlay.OffsetLL(xOff, yOff: LongInt): TPointLL;
var
  lLat, lLon: LongInt;
begin
  FormationOffsetToLatLon(FSnap.sLat, FSnap.sLon, xOff, yOff, lLat, lLon);
  Result := SliverLL(lLat, lLon);
end;

procedure TFormationOverlay.Hide;
begin
  if FLayer = nil then Exit;
  ClearRegion;
  RemoveIDs(FObjIDs);
  SetLength(FObjIDs, 0);
  SetLength(FMarkers, 0);
  FHasSnap := False;
  FGlobe.Invalidate;
end;

procedure TFormationOverlay.Show(const aSnap: TGroupSnapshot; aDispSide: uchar);
var
  i, j, n: Integer;
  lUnit: TUnitSnapshot;
  lStationLL, lActualLL, lOrderLL: TPointLL;
  lActXOff, lActYOff: LongInt;
  lOffNM: Double;
  lObj: TGeoDataObject;
  lAirGroup: Boolean;  // CC 20260924

  procedure KeepID(aObj: TGeoDataObject);
  begin
    SetLength(FObjIDs, Length(FObjIDs) + 1);
    FObjIDs[High(FObjIDs)] := aObj.ID;
  end;

  // CC 20260924 adds the marker object itself; a selected marker gets its yellow
  //  square first so the square draws behind the icon
  procedure AddMarker(aPresID: Integer; const aTitle: string; const aLL: TPointLL;
    const aUnitUUID: string; aIsAir: Boolean; aHitKind: TFormationItemKind;
    const aHitUUID: string);
  var
    lM: TFormationMarker;
    lHalo: TGeoDataObject;
  begin
    lM.ObjID := -1;
    lM.PtLL := aLL;
    lM.UnitUUID := aUnitUUID;
    lM.IsAir := aIsAir;
    lM.HitKind := aHitKind;
    lM.HitUUID := aHitUUID;
    if IsMarkerSelected(lM) then
    begin
      lHalo := AddObject(FPresHalo, '');
      lHalo.Centroid := aLL;
      KeepID(lHalo);
    end;
    lObj := AddObject(aPresID, aTitle);
    lObj.Centroid := aLL;
    KeepID(lObj);
    lM.ObjID := lObj.ID;
    n := Length(FMarkers);
    SetLength(FMarkers, n + 1);
    FMarkers[n] := lM;
  end;

  procedure AddLine(aPresID: Integer; const aFrom, aTo: TPointLL);
  begin
    lObj := AddObject(aPresID, '');
    lObj.Chains[0].Add(aFrom);
    lObj.Chains[0].Add(aTo);
    KeepID(lObj);
  end;

begin
  Hide;
  FSnap := aSnap;
  FDispSide := aDispSide;
  FHasSnap := True;

  // CC 20260924 in an aircraft/helicopter group the air units ARE the formation
  //  members, so they get stations like ships; the airborne-patrol markers
  //  below are only for patrol air units flying over a surface group
  lAirGroup := HCMTIsAir(FSnap.gtype);

  // 1-2. formation members: reform lines first so the markers draw on top of them
  for i := 0 to High(FSnap.Units) do
  begin
    lUnit := FSnap.Units[i];
    if lUnit.IsAir and not lAirGroup then Continue;
    lStationLL := OffsetLL(lUnit.xOff, lUnit.yOff);
    lActualLL := SliverLL(lUnit.sLat, lUnit.sLon);
    LatLonToFormationOffset(FSnap.sLat, FSnap.sLon, lUnit.sLat, lUnit.sLon,
      lActXOff, lActYOff);
    lOffNM := Sqrt(Sqr(Double(lActXOff - lUnit.xOff)) +
      Sqr(Double(lActYOff - lUnit.yOff))) / DU_PER_NM;
    if lUnit.HasFormationOrder or (lOffNM > FO_OFFSTATION_NM) then
    begin
      if lOffNM > FO_OFFSTATION_NM then
        AddLine(FPresReformLine, lActualLL, lStationLL);
      if lUnit.HasFormationOrder and
         ((lUnit.OrderXOff <> lUnit.xOff) or (lUnit.OrderYOff <> lUnit.yOff)) then
      begin
        lOrderLL := OffsetLL(lUnit.OrderXOff, lUnit.OrderYOff);
        AddLine(FPresOrderLine, lActualLL, lOrderLL);
      end;
      lObj := AddObject(FPresActual, '');
      lObj.Centroid := lActualLL;
      KeepID(lObj);
    end;
  end;

  for i := 0 to High(FSnap.Units) do
  begin
    lUnit := FSnap.Units[i];
    if lUnit.IsAir and not lAirGroup then Continue;
    lStationLL := OffsetLL(lUnit.xOff, lUnit.yOff);
    AddMarker(UnitPresenter(lUnit.UType, lUnit.IsAir), lUnit.Name, lStationLL,
      lUnit.UUID, False, fikUnit, lUnit.UUID);
  end;

  // 3. airborne aircraft (not on an attack) at their actual position; a click
  //  selects the patrol they fly, if any, else the unit
  for i := 0 to High(FSnap.Units) do
  begin
    lUnit := FSnap.Units[i];
    if lAirGroup or not lUnit.IsAir or (lUnit.CombatFlag <> 0) then Continue;
    lActualLL := SliverLL(lUnit.sLat, lUnit.sLon);
    AddMarker(UnitPresenter(lUnit.UType, True), lUnit.Name, lActualLL,
      lUnit.UUID, True, fikUnit, lUnit.UUID);
    for j := 0 to High(FSnap.Patrols) do
      if (FSnap.Patrols[j].AircraftUnitUUID <> '') and
         (FSnap.Patrols[j].AircraftUnitUUID = lUnit.UUID) then
      begin
        FMarkers[High(FMarkers)].HitKind := fikPatrol;
        FMarkers[High(FMarkers)].HitUUID := FSnap.Patrols[j].EventUUID;
        Break;
      end;
  end;

  // 4. selection region (marker highlights were added with the markers)
  BuildRegion;
  FGlobe.Invalidate;
end;

function TFormationOverlay.IsMarkerSelected(const aMarker: TFormationMarker): Boolean;
var
  i: Integer;
begin
  Result := False;
  if FSelUUID = '' then Exit;
  case FSelKind of
    fikUnit:
      Result := aMarker.UnitUUID = FSelUUID;
    fikPatrol:
      for i := 0 to High(FSnap.Patrols) do
        if FSnap.Patrols[i].EventUUID = FSelUUID then
        begin
          Result := (FSnap.Patrols[i].AircraftUnitUUID <> '') and
            (FSnap.Patrols[i].AircraftUnitUUID = aMarker.UnitUUID);
          Break;
        end;
    fikAsset:
      // an asset has no region; highlight the ship/base carrying it
      for i := 0 to High(FSnap.Assets) do
        if FSnap.Assets[i].UUID = FSelUUID then
        begin
          Result := (not aMarker.IsAir) and
            (FSnap.Assets[i].OwnerUnitUUID = aMarker.UnitUUID);
          Break;
        end;
  end;
end;

// CC 20260924 outline every cell as one closed chain: outer arc clockwise, then
//  the inner arc back (or the centre for a pie slice); a full circle is one or
//  two plain circles
procedure TFormationOverlay.AddRegionCells(aRings, aSectors: UInt64);
var
  lZones: TFormationZones;
  lCells: TRegionCells;
  lCentre: TPointLL;
  lObj: TGeoDataObject;
  lChain: TPointStore;
  i, k, lChainIdx: Integer;
begin
  for k := 0 to High(lZones) do
    lZones[k] := FSnap.Zones[k];
  lCells := PatrolOutlineCells(aRings, aSectors, lZones);  // CC 20260924 outer border only
  if Length(lCells) = 0 then Exit;

  lCentre := GroupCentre;
  lObj := AddObject(FPresRegion, '');
  lChainIdx := 0;
  for i := 0 to High(lCells) do
    with lCells[i] do
    begin
      if OuterNM <= 0 then Continue;
      if EndDeg - StartDeg >= 360 then
      begin
        lObj.Chains.Count := lChainIdx + 1;
        AddArc(lObj.Chains[lChainIdx], lCentre, OuterNM, 0, 360);
        Inc(lChainIdx);
        if InnerNM > 0 then
        begin
          lObj.Chains.Count := lChainIdx + 1;
          AddArc(lObj.Chains[lChainIdx], lCentre, InnerNM, 0, 360);
          Inc(lChainIdx);
        end;
      end
      else
      begin
        lObj.Chains.Count := lChainIdx + 1;
        lChain := lObj.Chains[lChainIdx];
        AddArc(lChain, lCentre, OuterNM, StartDeg, EndDeg);
        if InnerNM > 0 then
          AddArc(lChain, lCentre, InnerNM, EndDeg, StartDeg)
        else
          lChain.Add(lCentre);
        lChain.Add(lChain[0]);  // close the wedge
        Inc(lChainIdx);
      end;
    end;

  SetLength(FRegionIDs, Length(FRegionIDs) + 1);
  FRegionIDs[High(FRegionIDs)] := lObj.ID;
end;

procedure TFormationOverlay.BuildRegion;
var
  i: Integer;
begin
  ClearRegion;
  if not FHasSnap or (FSelUUID = '') then Exit;
  case FSelKind of
    fikUnit:
      for i := 0 to High(FSnap.Units) do
        if FSnap.Units[i].UUID = FSelUUID then
        begin
          AddRegionCells(FSnap.Units[i].Rings, FSnap.Units[i].Sectors);
          Break;
        end;
    fikPatrol:
      for i := 0 to High(FSnap.Patrols) do
        if FSnap.Patrols[i].EventUUID = FSelUUID then
        begin
          AddRegionCells(FSnap.Patrols[i].Rings, FSnap.Patrols[i].Sectors);
          Break;
        end;
  end;
end;

procedure TFormationOverlay.SetSelection(aKind: TFormationItemKind; const aUUID: string);
var
  lSnap: TGroupSnapshot;
begin
  FSelKind := aKind;
  FSelUUID := aUUID;
  if aKind = fikNone then FSelUUID := '';
  if not FHasSnap then Exit;  // applied on the next Show
  // CC 20260924 the highlight square has to sit behind the icon, so rebuild
  //  (from a copy: Show replaces FSnap)
  lSnap := FSnap;
  Show(lSnap, FDispSide);
end;

function TFormationOverlay.HitTest(X, Y: Integer; out aKind: TFormationItemKind;
  out aUUID: string): Boolean;
var
  i, lBest, lD2, lBestD2: Integer;
  ptXY: TPoint;
begin
  aKind := fikNone;
  aUUID := '';
  lBest := -1;
  lBestD2 := Sqr(FO_HIT_PX) + 1;
  ptXY := Point(0, 0);
  for i := 0 to High(FMarkers) do
    if FGlobe.Projection.LLToDeviceXY(FMarkers[i].PtLL.iLongX,
         FMarkers[i].PtLL.iLatY, ptXY) then
    begin
      lD2 := Sqr(X - ptXY.X) + Sqr(Y - ptXY.Y);
      if lD2 < lBestD2 then
      begin
        lBestD2 := lD2;
        lBest := i;
      end;
    end;
  Result := lBest >= 0;
  if Result then
  begin
    aKind := FMarkers[lBest].HitKind;
    aUUID := FMarkers[lBest].HitUUID;
  end;
end;

end.
