using System.Numerics;
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Rendering;
using XFEEDToolBox.Core.Services;
using XFEEDToolBox.Rendering;
namespace XFEEDToolBox.Controls;
/// <summary>Interactive system-space scene. Selection is UI-only; the explicit Go command owns all game control.</summary>
public sealed class LocalSystemMapView:UserControl
{
private readonly Grid host=new(){Background=Brush(7,16,26)};
private readonly Canvas labels=new(){IsHitTestVisible=false};
private readonly RectangleGeometry clip=new();
private readonly TextBlock empty=new(){Text="等待读取真实地点坐标",Foreground=Brush(139,159,181),FontSize=15};
private readonly List<(Border Border,TextBlock Text)> labelPool=[];
private readonly List<(Grid Host,GameIcon Icon,Border Badge,TextBlock Count)> iconPool=[];
private IReadOnlyList<LocalSystemMarkerGroup> markerGroups=[];
private readonly LocalSystemViewLock viewLock=new();
private LocalSystemGraphics? graphics;
private Compositor? compositor;
private ContainerVisual? visual;
private SpriteVisual? pixelsVisual;
private LocalSystemScene? scene;
private IReadOnlyList<LocalSystemProjection> projections=[];
private LocalSystemCamera camera=LocalSystemCamera.Overview;
private string? selected;
private long system;
private bool loaded,scheduled,dragging,moved,panning;
private bool overview=true;
private bool displayOnly;
private Point previous,pressed;
private double overlayLeft,overlayRight;
private HashSet<string>? markerIdentities;
public event EventHandler<SystemLocation>? LocationSelected;
public event Action<string>? SceneStatusChanged;
public event Action<string,string>? OrbitStatusChanged;
public string GraphicsError{get;private set;}="";
public long FrameCount=>graphics?.Frames??0;
internal LocalSystemCamera CameraState=>camera;
internal LocalSystemOrientation ViewOrientationForProbe=>viewLock.Orientation;
internal IReadOnlyList<LocalSystemProjection> ProjectedLocations=>projections;
internal double ProjectionWidth=>Math.Max(140,ActualWidth);
private double CameraProjectionWidth=>Math.Max(140,ActualWidth-overlayLeft-overlayRight);
internal LocalSystemCamera OverviewCameraForProbe=>LocalSystemCamera.OverviewFor(CameraProjectionWidth,ActualHeight);
internal IReadOnlyList<LocalSystemProjection> MarkerLocations=>projections.Where(IsMarkerVisible).ToArray();
internal IReadOnlyList<LocalSystemProjection> InteractiveLocations=>MarkerLocations.Where(point=>LocalSystemMarkerLayout.Celestial(point.Node.Location))
.Concat(markerGroups.SelectMany(group=>group.Members.Select(point=>point with{X=group.Anchor.X,Y=group.Anchor.Y,Radius=12}))).ToArray();
private bool IsMarkerVisible(LocalSystemProjection point)=>markerIdentities?.Contains(point.Node.Location.Identity)??true;
internal object OrbitPresentationForProbe=>new{metadata=scene?.OrbitCatalogueCount??0,verified=scene?.VerifiedOrbitCount??0,displayed=scene?.Orbits.Count??0,
status=scene?.OrbitStatus??"轨道参数待接入",issues=scene?.OrbitUnavailableReasons,
paths=scene?.Orbits.Select(path=>new{child=path.Child.Identity,parent=path.ParentIdentity,path.ParentDisplayName,path.FocusPosition,path.Orbit.Source,path.Orbit.Quality,
samples=path.Points.Count,actualMetres=path.ActualPoints,displayPoints=path.Points})};
internal object PresentationForProbe
{
get
{
var containerSize=visual?.Size??Vector2.Zero;
var containerRelative=visual?.RelativeSizeAdjustment??Vector2.Zero;
var spriteSize=pixelsVisual?.Size??Vector2.Zero;
var spriteRelative=pixelsVisual?.RelativeSizeAdjustment??Vector2.Zero;
var effectiveContainer=containerSize+containerRelative*new Vector2((float)host.ActualWidth,(float)host.ActualHeight);
var effectiveSprite=spriteSize+spriteRelative*effectiveContainer;
return new
{
loaded,scheduled,GraphicsError,frames=FrameCount,
control=new{width=ActualWidth,height=ActualHeight},
host=new{width=host.ActualWidth,height=host.ActualHeight},
container=new{exists=visual is not null,width=containerSize.X,height=containerSize.Y,relativeWidth=containerRelative.X,relativeHeight=containerRelative.Y,
effectiveWidth=effectiveContainer.X,effectiveHeight=effectiveContainer.Y},
sprite=new{exists=pixelsVisual is not null,width=spriteSize.X,height=spriteSize.Y,relativeWidth=spriteRelative.X,relativeHeight=spriteRelative.Y,
effectiveWidth=effectiveSprite.X,effectiveHeight=effectiveSprite.Y},
surface=new{width=graphics?.Surface.Size.Width??0,height=graphics?.Surface.Size.Height??0}
};
}
}
public LocalSystemMapView()
{
var root=new Grid{Clip=clip};root.Children.Add(host);root.Children.Add(labels);labels.Children.Add(empty);Content=root;
Loaded+=(_,_)=>{loaded=true;EnsureGraphics();ResizePresentation();Invalidate();};
Unloaded+=(_,_)=>{loaded=false;StopScheduling();dragging=false;};
SizeChanged+=(_,_)=>{clip.Rect=new(0,0,ActualWidth,ActualHeight);ResizePresentation();Invalidate();};
host.SizeChanged+=(_,_)=>{ResizePresentation();Invalidate();};
IsTabStop=true;ManipulationMode=ManipulationModes.None;
PointerPressed+=Pressed;PointerMoved+=Moved;PointerReleased+=Released;
PointerCanceled+=(_,_)=>dragging=false;PointerCaptureLost+=(_,_)=>dragging=false;
PointerWheelChanged+=(_,e)=>{Zoom(e.GetCurrentPoint(this).Properties.MouseWheelDelta/120d);e.Handled=true;};
DoubleTapped+=(_,e)=>{var hit=LocalSystemScene.HitTest(InteractiveLocations,e.GetPosition(this).X,e.GetPosition(this).Y).FirstOrDefault();if(hit is not null){Select(hit.Node.Location);FocusSelection();e.Handled=true;}};
KeyDown+=(_,e)=>
{
if(e.Key==Windows.System.VirtualKey.Home){ResetCamera();e.Handled=true;}
else if(e.Key==Windows.System.VirtualKey.Add){Zoom(1);e.Handled=true;}
else if(e.Key==Windows.System.VirtualKey.Subtract){Zoom(-1);e.Handled=true;}
};
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(this,"当前星系3D场景:点击选择,双击聚焦,左键拖动旋转,右键拖动平移,滚轮缩放,Home总览。");
}
public void SetSnapshot(CurrentSystemSnapshot? snapshot,IReadOnlyList<SystemLocation>? visible=null)
{
displayOnly=snapshot?.DisplayOnly==true;
if(snapshot is null){scene=null;projections=[];markerIdentities=null;selected=null;Invalidate();return;}
if(system!=snapshot.SystemAddress){system=snapshot.SystemAddress;camera=LocalSystemCamera.Overview;overview=true;selected=null;}
// A verified orbital frame cancels the rotating native reference. If
// that anchor is temporarily absent, retain the last coherent geometry.
markerIdentities=visible?.Select(location=>location.Identity).ToHashSet(StringComparer.Ordinal);
if(displayOnly)scene=new(snapshot);
else if(viewLock.Observe(snapshot))scene=new(snapshot,null,viewLock.Orientation);
Invalidate();
}
public void SetSelection(string? identity){if(selected==identity)return;selected=identity;Invalidate();}
public void SetOverlayInsets(double left,double right){overlayLeft=Math.Max(0,left);overlayRight=Math.Max(0,right);Invalidate();}
public void ResetCamera(){camera=LocalSystemCamera.Overview;overview=true;Invalidate();}
public void FocusSelection()
{
if(scene?.Nodes.FirstOrDefault(node=>node.Location.Identity==selected) is not {} node)return;
overview=false;camera=camera.Focus(node.Position);Invalidate();
}
public void Zoom(double steps){overview=false;camera=camera.Zoom(steps);Invalidate();}
private void EnsureGraphics()
{
if(graphics is not null||GraphicsError.Length>0)return;
try
{
compositor=ElementCompositionPreview.GetElementVisual(host).Compositor;graphics=new(compositor);
visual=compositor.CreateContainerVisual();pixelsVisual=compositor.CreateSpriteVisual();pixelsVisual.RelativeSizeAdjustment=Vector2.One;
var surface=compositor.CreateSurfaceBrush(graphics.Surface);surface.Stretch=CompositionStretch.Fill;pixelsVisual.Brush=surface;visual.Children.InsertAtTop(pixelsVisual);
ElementCompositionPreview.SetElementChildVisual(host,visual);
ResizePresentation();
}
catch(Exception error)
{
GraphicsError=error.Message;empty.Text="3D渲染暂不可用,可从左侧列表选择地点。";
App.Runtime.ReportDiagnostic("current-system.graphics","星系场景",error,ActivitySeverity.Error,"3D场景初始化失败,地点列表仍可使用。");
}
}
private void ResizePresentation()
{
// The relative-size sprite fills this container, not the XAML host directly.
// An unsized ContainerVisual stays 0x0 even while GPU readback has valid pixels.
if(visual is not null)visual.Size=new((float)Math.Max(0,host.ActualWidth),(float)Math.Max(0,host.ActualHeight));
}
private void Invalidate(){if(!loaded||scheduled)return;scheduled=true;CompositionTarget.Rendering+=RenderFrame;}
private void StopScheduling(){if(!scheduled)return;CompositionTarget.Rendering-=RenderFrame;scheduled=false;}
private void RenderFrame(object? sender,object e){StopScheduling();if(loaded)Draw();}
internal void RenderNowForProbe(){StopScheduling();EnsureGraphics();Draw();}
private static Vector4 Colour(SystemLocationCategory kind)=>kind switch
{
SystemLocationCategory.Star=>new(1,.41f,.14f,1),
SystemLocationCategory.Planet=>new(.38f,.53f,.69f,1),
SystemLocationCategory.LandablePlanet=>new(.63f,.49f,.34f,1),
SystemLocationCategory.Station or SystemLocationCategory.Carrier=>new(.73f,.82f,.89f,1),
SystemLocationCategory.Signal=>new(.42f,.82f,.76f,1),_=>new(.63f,.71f,.8f,1)
};
private void Draw()
{
ResizePresentation();
if(ActualWidth<2||ActualHeight<2)return;
var width=ProjectionWidth;var height=ActualHeight;
if(overview)camera=OverviewCameraForProbe;
projections=scene?.Project(camera,CameraProjectionWidth,height,selected).Select(point=>point with{X=point.X+overlayLeft}).ToArray()??[];
markerGroups=LocalSystemMarkerLayout.Build(projections,MarkerLocations);
if(GraphicsError.Length==0)empty.Text=scene is null?"等待读取真实地点坐标":"当前筛选没有已定位地点,可调整搜索或地点类型。";
empty.Visibility=scene is null||scene.Nodes.Count==0||GraphicsError.Length>0?Visibility.Visible:Visibility.Collapsed;
Canvas.SetLeft(empty,24);Canvas.SetTop(empty,60);
var bodies=new List<LocalSystemBody>();var lines=new List<LocalSystemLine>();
void Line(Vector3d from,Vector3d to,Vector4 colour)
{
if(Project(from) is not {} a||Project(to) is not {} b)return;
lines.Add(new(new((float)a.X,(float)a.Y,0,0),colour));lines.Add(new(new((float)b.X,(float)b.Y,0,0),colour));
}
for(var index=-12;index<=12;index++)
{
var at=index*.5;var alpha=index==0?.28f:.09f;
Line(new(at,-.08,-6),new(at,-.08,6),new(.2f,.43f,.59f,alpha));
Line(new(-6,-.08,at),new(6,-.08,at),new(.2f,.43f,.59f,alpha));
}
if(scene is not null)foreach(var path in scene.Orbits.OrderBy(path=>path.Child.Identity==selected))
{
var colour=path.Child.Identity==selected?new Vector4(1,.68f,.28f,.95f):new Vector4(.32f,.58f,.78f,.4f);
for(var index=1;index<path.Points.Count;index++)Line(path.Points[index-1],path.Points[index],colour);
}
foreach(var point in projections)
{
var kind=point.Node.Location.Category;
if(kind is not (SystemLocationCategory.Star or SystemLocationCategory.Planet or SystemLocationCategory.LandablePlanet))continue;
var style=kind==SystemLocationCategory.Star?0:1;
var colour=Colour(kind);
if(kind==SystemLocationCategory.Star){var spectral=LocalSystemStellarStyle.Colour(point.Node.Location.NativeStarType);colour=new(spectral.R/255f,spectral.G/255f,spectral.B/255f,1);}
bodies.Add(new(new((float)point.X,(float)point.Y,(float)point.Radius,0),colour,
new((float)point.Light.X,(float)point.Light.Y,(float)point.Light.Z,0),new(style,point.Selected?1:0,point.Node.Location.Key%31,(float)point.Radius)));
}
if(scene?.PlayerPosition is {} ship&&Project(ship) is {} player)
{
var angle=0d;
if(scene.PlayerForward is {} forward){var facing=camera.RotateVector(forward);angle=-Math.Atan2(facing.X,facing.Y);}
bodies.Add(new(new((float)player.X,(float)player.Y,9,0),new(.33f,.87f,.78f,1),default,new(4,0,0,(float)angle)));
if(scene.Nodes.FirstOrDefault(node=>node.Location.Identity==selected) is {} destination)
Line(ship,destination.Position,new(.94f,.63f,.24f,.38f));
}
if(graphics is not null)
{
try{graphics.Draw(bodies.ToArray(),lines.ToArray(),ActualWidth,height,XamlRoot?.RasterizationScale??1);}
catch(Exception error)
{
GraphicsError=error.Message;empty.Text="3D渲染暂不可用,可从左侧列表选择地点。";empty.Visibility=Visibility.Visible;
App.Runtime.ReportDiagnostic("current-system.graphics","星系场景",error,ActivitySeverity.Error,"3D场景绘制失败。");
}
}
PlaceIcons();PlaceLabels(width,height);
OrbitStatusChanged?.Invoke(scene?.OrbitStatus??"轨道参数待接入",scene?.OrbitDescription(selected)??"等待真实轨道参数。");
SceneStatusChanged?.Invoke(scene is null?"等待真实地点坐标":displayOnly?$"公开目录示意 · {scene.Nodes.Count} 个地点":$"已定位 {scene.Nodes.Count} 个地点 · 显示 {MarkerLocations.Count} 个标记");
}
private (double X,double Y,double Depth)? Project(Vector3d point)
=>camera.Project(point,CameraProjectionWidth,ActualHeight) is {} value?(value.X+overlayLeft,value.Y,value.Depth):null;
private void PlaceIcons()
{
var count=0;
foreach(var group in markerGroups)
{
var point=group.Anchor;
// Celestial bodies retain their shaded 3D surface. All other
// destinations use exactly the same asset resolver as the sidebar.
if(point.Node.Location.Category is SystemLocationCategory.Star or SystemLocationCategory.Planet or SystemLocationCategory.LandablePlanet)continue;
if(count==iconPool.Count)
{
var icon=new GameIcon();var container=new Grid{IsHitTestVisible=false};
container.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse{Stroke=Brush(255,174,59),StrokeThickness=1,Opacity=.8});
container.Children.Add(icon);
var number=new TextBlock{FontSize=10,Foreground=Brush(229,238,247),Margin=new(3,0,3,0)};
var badge=new Border{Child=number,Background=Brush(18,34,48),CornerRadius=new(7),HorizontalAlignment=HorizontalAlignment.Right,VerticalAlignment=VerticalAlignment.Bottom,Margin=new(0,0,-7,-5)};
container.Children.Add(badge);iconPool.Add((container,icon,badge,number));labels.Children.Insert(0,container);
}
var marker=iconPool[count++];var size=22d;
marker.Icon.IconKey=GameUiIcons.ForLocation(point.Node.Location);marker.Icon.Width=size;marker.Icon.Height=size;
marker.Count.Text=group.Members.Count.ToString();marker.Badge.Visibility=group.Members.Count>1?Visibility.Visible:Visibility.Collapsed;
marker.Host.Width=size+8;marker.Host.Height=size+8;marker.Host.Visibility=Visibility.Visible;
marker.Host.Children[0].Visibility=point.Selected?Visibility.Visible:Visibility.Collapsed;
Canvas.SetLeft(marker.Host,point.X-(size+8)/2);Canvas.SetTop(marker.Host,point.Y-(size+8)/2);
}
for(var index=count;index<iconPool.Count;index++)iconPool[index].Host.Visibility=Visibility.Collapsed;
}
internal object[] IconsForProbe=>markerGroups.Select(group=>(object)new{group.Anchor.Node.Location.Identity,icon=GameUiIcons.ForLocation(group.Anchor.Node.Location).ToString(),
asset=GameUiIcons.Asset(GameUiIcons.ForLocation(group.Anchor.Node.Location)),group.Anchor.X,group.Anchor.Y,members=group.Members.Select(point=>point.Node.Location.Identity)}).ToArray();
private void PlaceLabels(double width,double height)
{
var boxes=new List<Rect>();var count=0;
var candidates=MarkerLocations.Where(point=>LocalSystemMarkerLayout.Celestial(point.Node.Location)).Concat(markerGroups.Select(group=>group.Anchor))
.OrderByDescending(point=>point.Selected).ThenBy(point=>point.Node.Location.Category==SystemLocationCategory.Star?0:1)
.ThenBy(point=>point.Node.Location.DistanceMetres??double.MaxValue);
foreach(var point in candidates)
{
if(count>=28)break;
var text=point.Node.Location.DisplayName;var labelWidth=Math.Clamp(text.Length*7+14,90,180);var labelHeight=point.Selected?44d:26d;
var choices=new[]{new Rect(point.X+point.Radius+8,point.Y-10,labelWidth,labelHeight),new Rect(point.X-point.Radius-labelWidth-8,point.Y-10,labelWidth,labelHeight),
new Rect(point.X-labelWidth/2,point.Y+point.Radius+8,labelWidth,labelHeight),new Rect(point.X-labelWidth/2,point.Y-point.Radius-labelHeight-8,labelWidth,labelHeight)};
var place=choices.FirstOrDefault(box=>box.X>=overlayLeft+12&&box.Y>=64&&box.Right<=width-overlayRight-12&&box.Bottom<=height-64
&&!projections.Where(body=>LocalSystemMarkerLayout.Celestial(body.Node.Location)).Any(body=>
Math.Pow(body.X-Math.Clamp(body.X,box.X,box.Right),2)+Math.Pow(body.Y-Math.Clamp(body.Y,box.Y,box.Bottom),2)<Math.Pow(body.Radius+5,2))
&&!boxes.Any(other=>other.X<box.Right&&other.Right>box.X&&other.Y<box.Bottom&&other.Bottom>box.Y));
if(place.Width==0)continue;
if(count==labelPool.Count)
{
var block=new TextBlock{FontSize=12,TextTrimming=TextTrimming.CharacterEllipsis,MaxLines=2};
var border=new Border{Child=block,Background=new SolidColorBrush(Windows.UI.Color.FromArgb(185,8,18,29)),Padding=new(5,3,5,3),CornerRadius=new(4)};
labelPool.Add((border,block));labels.Children.Add(border);
}
var label=labelPool[count++];label.Text.Text=point.Selected?text+"\n"+point.Node.Location.DistanceText:text;
label.Text.Foreground=point.Selected?Brush(255,179,71):Brush(187,207,224);label.Text.FontWeight=point.Selected?Microsoft.UI.Text.FontWeights.SemiBold:Microsoft.UI.Text.FontWeights.Normal;
label.Border.Width=place.Width;label.Border.Height=place.Height;label.Border.Visibility=Visibility.Visible;
Canvas.SetLeft(label.Border,place.X);Canvas.SetTop(label.Border,place.Y);boxes.Add(place);
}
for(var i=count;i<labelPool.Count;i++)labelPool[i].Border.Visibility=Visibility.Collapsed;
}
private void Pressed(object sender,PointerRoutedEventArgs e)
{
var point=e.GetCurrentPoint(this);if(!point.Properties.IsLeftButtonPressed&&!point.Properties.IsRightButtonPressed)return;
Focus(FocusState.Pointer);dragging=true;moved=false;panning=point.Properties.IsRightButtonPressed;previous=pressed=point.Position;CapturePointer(e.Pointer);e.Handled=true;
}
private void Moved(object sender,PointerRoutedEventArgs e)
{
if(!dragging)return;var point=e.GetCurrentPoint(this).Position;var dx=point.X-previous.X;var dy=point.Y-previous.Y;
if(Math.Abs(point.X-pressed.X)+Math.Abs(point.Y-pressed.Y)>4)moved=true;
overview=false;camera=panning?camera.Pan(dx,dy,CameraProjectionWidth,ActualHeight):camera.Rotate(dx,dy);previous=point;Invalidate();e.Handled=true;
}
private void Released(object sender,PointerRoutedEventArgs e)
{
if(!dragging)return;dragging=false;ReleasePointerCapture(e.Pointer);
if(!moved&&!panning)SelectAt(e.GetCurrentPoint(this).Position);e.Handled=true;
}
private void Select(SystemLocation location){selected=location.Identity;LocationSelected?.Invoke(this,location);Invalidate();}
private void SelectAt(Point point)
{
var hits=LocalSystemScene.HitTest(InteractiveLocations,point.X,point.Y);
if(hits.Count==0)return;
if(hits.Count==1){Select(hits[0].Node.Location);return;}
var picker=new ListView{ItemsSource=hits.Select(hit=>hit.Node.Location.DisplayName+" · "+hit.Node.Location.KindText).ToArray(),MaxHeight=340,MinWidth=260,SelectionMode=ListViewSelectionMode.Single};
var flyout=new Flyout{Content=picker};
picker.SelectionChanged+=(_,_)=>{if(picker.SelectedIndex is var index&&index>=0&&index<hits.Count){Select(hits[index].Node.Location);flyout.Hide();}};
flyout.ShowAt(this,new Microsoft.UI.Xaml.Controls.Primitives.FlyoutShowOptions{Position=point});
}
internal void SelectAtForProbe(Point point)
{
// Same production hit testing; a deterministic explicit first hit avoids opening a test-only flyout.
if(LocalSystemScene.HitTest(InteractiveLocations,point.X,point.Y).FirstOrDefault() is {} hit)Select(hit.Node.Location);
}
internal void RotateForProbe(double dx,double dy){overview=false;camera=camera.Rotate(dx,dy);RenderNowForProbe();}
internal void PanForProbe(double dx,double dy){overview=false;camera=camera.Pan(dx,dy,CameraProjectionWidth,ActualHeight);RenderNowForProbe();}
internal (int Width,int Height,byte[] Pixels) CapturePixelsForProbe()=>graphics?.ReadPixels()??throw new InvalidOperationException(GraphicsError);
internal async Task<IDisposable> FreezeForXamlProbeAsync()
{
RenderNowForProbe();var frame=graphics?.ReadPixels()??throw new InvalidOperationException(GraphicsError);
var bitmap=new Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap(frame.Width,frame.Height);
using(var stream=bitmap.PixelBuffer.AsStream())await stream.WriteAsync(frame.Pixels);bitmap.Invalidate();
var snapshot=new Image{Source=bitmap,Stretch=Stretch.Fill,IsHitTestVisible=false};ElementCompositionPreview.SetElementChildVisual(host,null);host.Children.Add(snapshot);
return new SnapshotScope(()=>{host.Children.Remove(snapshot);ElementCompositionPreview.SetElementChildVisual(host,visual);Invalidate();});
}
private sealed class SnapshotScope(Action restore):IDisposable{public void Dispose()=>restore();}
private static SolidColorBrush Brush(byte r,byte g,byte b)=>new(Windows.UI.Color.FromArgb(255,r,g,b));
}
using System.Numerics;
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Rendering;
using XFEEDToolBox.Core.Services;
using XFEEDToolBox.Rendering;
namespace XFEEDToolBox.Controls;
/// <summary>Interactive system-space scene. Selection is UI-only; the explicit Go command owns all game control.</summary>
public sealed class LocalSystemMapView:UserControl
{
private readonly Grid host=new(){Background=Brush(7,16,26)};
private readonly Canvas labels=new(){IsHitTestVisible=false};
private readonly RectangleGeometry clip=new();
private readonly TextBlock empty=new(){Text="等待读取真实地点坐标",Foreground=Brush(139,159,181),FontSize=15};
private readonly List<(Border Border,TextBlock Text)> labelPool=[];
private readonly List<(Grid Host,GameIcon Icon,Border Badge,TextBlock Count)> iconPool=[];
private IReadOnlyList<LocalSystemMarkerGroup> markerGroups=[];
private readonly LocalSystemViewLock viewLock=new();
private LocalSystemGraphics? graphics;
private Compositor? compositor;
private ContainerVisual? visual;
private SpriteVisual? pixelsVisual;
private LocalSystemScene? scene;
private IReadOnlyList<LocalSystemProjection> projections=[];
private LocalSystemCamera camera=LocalSystemCamera.Overview;
private string? selected;
private long system;
private bool loaded,scheduled,dragging,moved,panning;
private bool overview=true;
private bool displayOnly;
private Point previous,pressed;
private double overlayLeft,overlayRight;
private HashSet<string>? markerIdentities;
public event EventHandler<SystemLocation>? LocationSelected;
public event Action<string>? SceneStatusChanged;
public event Action<string,string>? OrbitStatusChanged;
public string GraphicsError{get;private set;}="";
public long FrameCount=>graphics?.Frames??0;
internal LocalSystemCamera CameraState=>camera;
internal LocalSystemOrientation ViewOrientationForProbe=>viewLock.Orientation;
internal IReadOnlyList<LocalSystemProjection> ProjectedLocations=>projections;
internal double ProjectionWidth=>Math.Max(140,ActualWidth);
private double CameraProjectionWidth=>Math.Max(140,ActualWidth-overlayLeft-overlayRight);
internal LocalSystemCamera OverviewCameraForProbe=>LocalSystemCamera.OverviewFor(CameraProjectionWidth,ActualHeight);
internal IReadOnlyList<LocalSystemProjection> MarkerLocations=>projections.Where(IsMarkerVisible).ToArray();
internal IReadOnlyList<LocalSystemProjection> InteractiveLocations=>MarkerLocations.Where(point=>LocalSystemMarkerLayout.Celestial(point.Node.Location))
.Concat(markerGroups.SelectMany(group=>group.Members.Select(point=>point with{X=group.Anchor.X,Y=group.Anchor.Y,Radius=12}))).ToArray();
private bool IsMarkerVisible(LocalSystemProjection point)=>markerIdentities?.Contains(point.Node.Location.Identity)??true;
internal object OrbitPresentationForProbe=>new{metadata=scene?.OrbitCatalogueCount??0,verified=scene?.VerifiedOrbitCount??0,displayed=scene?.Orbits.Count??0,
status=scene?.OrbitStatus??"轨道参数待接入",issues=scene?.OrbitUnavailableReasons,
paths=scene?.Orbits.Select(path=>new{child=path.Child.Identity,parent=path.ParentIdentity,path.ParentDisplayName,path.FocusPosition,path.Orbit.Source,path.Orbit.Quality,
samples=path.Points.Count,actualMetres=path.ActualPoints,displayPoints=path.Points})};
internal object PresentationForProbe
{
get
{
var containerSize=visual?.Size??Vector2.Zero;
var containerRelative=visual?.RelativeSizeAdjustment??Vector2.Zero;
var spriteSize=pixelsVisual?.Size??Vector2.Zero;
var spriteRelative=pixelsVisual?.RelativeSizeAdjustment??Vector2.Zero;
var effectiveContainer=containerSize+containerRelative*new Vector2((float)host.ActualWidth,(float)host.ActualHeight);
var effectiveSprite=spriteSize+spriteRelative*effectiveContainer;
return new
{
loaded,scheduled,GraphicsError,frames=FrameCount,
control=new{width=ActualWidth,height=ActualHeight},
host=new{width=host.ActualWidth,height=host.ActualHeight},
container=new{exists=visual is not null,width=containerSize.X,height=containerSize.Y,relativeWidth=containerRelative.X,relativeHeight=containerRelative.Y,
effectiveWidth=effectiveContainer.X,effectiveHeight=effectiveContainer.Y},
sprite=new{exists=pixelsVisual is not null,width=spriteSize.X,height=spriteSize.Y,relativeWidth=spriteRelative.X,relativeHeight=spriteRelative.Y,
effectiveWidth=effectiveSprite.X,effectiveHeight=effectiveSprite.Y},
surface=new{width=graphics?.Surface.Size.Width??0,height=graphics?.Surface.Size.Height??0}
};
}
}
public LocalSystemMapView()
{
var root=new Grid{Clip=clip};root.Children.Add(host);root.Children.Add(labels);labels.Children.Add(empty);Content=root;
Loaded+=(_,_)=>{loaded=true;EnsureGraphics();ResizePresentation();Invalidate();};
Unloaded+=(_,_)=>{loaded=false;StopScheduling();dragging=false;};
SizeChanged+=(_,_)=>{clip.Rect=new(0,0,ActualWidth,ActualHeight);ResizePresentation();Invalidate();};
host.SizeChanged+=(_,_)=>{ResizePresentation();Invalidate();};
IsTabStop=true;ManipulationMode=ManipulationModes.None;
PointerPressed+=Pressed;PointerMoved+=Moved;PointerReleased+=Released;
PointerCanceled+=(_,_)=>dragging=false;PointerCaptureLost+=(_,_)=>dragging=false;
PointerWheelChanged+=(_,e)=>{Zoom(e.GetCurrentPoint(this).Properties.MouseWheelDelta/120d);e.Handled=true;};
DoubleTapped+=(_,e)=>{var hit=LocalSystemScene.HitTest(InteractiveLocations,e.GetPosition(this).X,e.GetPosition(this).Y).FirstOrDefault();if(hit is not null){Select(hit.Node.Location);FocusSelection();e.Handled=true;}};
KeyDown+=(_,e)=>
{
if(e.Key==Windows.System.VirtualKey.Home){ResetCamera();e.Handled=true;}
else if(e.Key==Windows.System.VirtualKey.Add){Zoom(1);e.Handled=true;}
else if(e.Key==Windows.System.VirtualKey.Subtract){Zoom(-1);e.Handled=true;}
};
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(this,"当前星系3D场景:点击选择,双击聚焦,左键拖动旋转,右键拖动平移,滚轮缩放,Home总览。");
}
public void SetSnapshot(CurrentSystemSnapshot? snapshot,IReadOnlyList<SystemLocation>? visible=null)
{
displayOnly=snapshot?.DisplayOnly==true;
if(snapshot is null){scene=null;projections=[];markerIdentities=null;selected=null;Invalidate();return;}
if(system!=snapshot.SystemAddress){system=snapshot.SystemAddress;camera=LocalSystemCamera.Overview;overview=true;selected=null;}
// A verified orbital frame cancels the rotating native reference. If
// that anchor is temporarily absent, retain the last coherent geometry.
markerIdentities=visible?.Select(location=>location.Identity).ToHashSet(StringComparer.Ordinal);
if(displayOnly)scene=new(snapshot);
else if(viewLock.Observe(snapshot))scene=new(snapshot,null,viewLock.Orientation);
Invalidate();
}
public void SetSelection(string? identity){if(selected==identity)return;selected=identity;Invalidate();}
public void SetOverlayInsets(double left,double right){overlayLeft=Math.Max(0,left);overlayRight=Math.Max(0,right);Invalidate();}
public void ResetCamera(){camera=LocalSystemCamera.Overview;overview=true;Invalidate();}
public void FocusSelection()
{
if(scene?.Nodes.FirstOrDefault(node=>node.Location.Identity==selected) is not {} node)return;
overview=false;camera=camera.Focus(node.Position);Invalidate();
}
public void Zoom(double steps){overview=false;camera=camera.Zoom(steps);Invalidate();}
private void EnsureGraphics()
{
if(graphics is not null||GraphicsError.Length>0)return;
try
{
compositor=ElementCompositionPreview.GetElementVisual(host).Compositor;graphics=new(compositor);
visual=compositor.CreateContainerVisual();pixelsVisual=compositor.CreateSpriteVisual();pixelsVisual.RelativeSizeAdjustment=Vector2.One;
var surface=compositor.CreateSurfaceBrush(graphics.Surface);surface.Stretch=CompositionStretch.Fill;pixelsVisual.Brush=surface;visual.Children.InsertAtTop(pixelsVisual);
ElementCompositionPreview.SetElementChildVisual(host,visual);
ResizePresentation();
}
catch(Exception error)
{
GraphicsError=error.Message;empty.Text="3D渲染暂不可用,可从左侧列表选择地点。";
App.Runtime.ReportDiagnostic("current-system.graphics","星系场景",error,ActivitySeverity.Error,"3D场景初始化失败,地点列表仍可使用。");
}
}
private void ResizePresentation()
{
// The relative-size sprite fills this container, not the XAML host directly.
// An unsized ContainerVisual stays 0x0 even while GPU readback has valid pixels.
if(visual is not null)visual.Size=new((float)Math.Max(0,host.ActualWidth),(float)Math.Max(0,host.ActualHeight));
}
private void Invalidate(){if(!loaded||scheduled)return;scheduled=true;CompositionTarget.Rendering+=RenderFrame;}
private void StopScheduling(){if(!scheduled)return;CompositionTarget.Rendering-=RenderFrame;scheduled=false;}
private void RenderFrame(object? sender,object e){StopScheduling();if(loaded)Draw();}
internal void RenderNowForProbe(){StopScheduling();EnsureGraphics();Draw();}
private static Vector4 Colour(SystemLocationCategory kind)=>kind switch
{
SystemLocationCategory.Star=>new(1,.41f,.14f,1),
SystemLocationCategory.Planet=>new(.38f,.53f,.69f,1),
SystemLocationCategory.LandablePlanet=>new(.63f,.49f,.34f,1),
SystemLocationCategory.Station or SystemLocationCategory.Carrier=>new(.73f,.82f,.89f,1),
SystemLocationCategory.Signal=>new(.42f,.82f,.76f,1),_=>new(.63f,.71f,.8f,1)
};
private void Draw()
{
ResizePresentation();
if(ActualWidth<2||ActualHeight<2)return;
var width=ProjectionWidth;var height=ActualHeight;
if(overview)camera=OverviewCameraForProbe;
projections=scene?.Project(camera,CameraProjectionWidth,height,selected).Select(point=>point with{X=point.X+overlayLeft}).ToArray()??[];
markerGroups=LocalSystemMarkerLayout.Build(projections,MarkerLocations);
if(GraphicsError.Length==0)empty.Text=scene is null?"等待读取真实地点坐标":"当前筛选没有已定位地点,可调整搜索或地点类型。";
empty.Visibility=scene is null||scene.Nodes.Count==0||GraphicsError.Length>0?Visibility.Visible:Visibility.Collapsed;
Canvas.SetLeft(empty,24);Canvas.SetTop(empty,60);
var bodies=new List<LocalSystemBody>();var lines=new List<LocalSystemLine>();
void Line(Vector3d from,Vector3d to,Vector4 colour)
{
if(Project(from) is not {} a||Project(to) is not {} b)return;
lines.Add(new(new((float)a.X,(float)a.Y,0,0),colour));lines.Add(new(new((float)b.X,(float)b.Y,0,0),colour));
}
for(var index=-12;index<=12;index++)
{
var at=index*.5;var alpha=index==0?.28f:.09f;
Line(new(at,-.08,-6),new(at,-.08,6),new(.2f,.43f,.59f,alpha));
Line(new(-6,-.08,at),new(6,-.08,at),new(.2f,.43f,.59f,alpha));
}
if(scene is not null)foreach(var path in scene.Orbits.OrderBy(path=>path.Child.Identity==selected))
{
var colour=path.Child.Identity==selected?new Vector4(1,.68f,.28f,.95f):new Vector4(.32f,.58f,.78f,.4f);
for(var index=1;index<path.Points.Count;index++)Line(path.Points[index-1],path.Points[index],colour);
}
foreach(var point in projections)
{
var kind=point.Node.Location.Category;
if(kind is not (SystemLocationCategory.Star or SystemLocationCategory.Planet or SystemLocationCategory.LandablePlanet))continue;
var style=kind==SystemLocationCategory.Star?0:1;
var colour=Colour(kind);
if(kind==SystemLocationCategory.Star){var spectral=LocalSystemStellarStyle.Colour(point.Node.Location.NativeStarType);colour=new(spectral.R/255f,spectral.G/255f,spectral.B/255f,1);}
bodies.Add(new(new((float)point.X,(float)point.Y,(float)point.Radius,0),colour,
new((float)point.Light.X,(float)point.Light.Y,(float)point.Light.Z,0),new(style,point.Selected?1:0,point.Node.Location.Key%31,(float)point.Radius)));
}
if(scene?.PlayerPosition is {} ship&&Project(ship) is {} player)
{
var angle=0d;
if(scene.PlayerForward is {} forward){var facing=camera.RotateVector(forward);angle=-Math.Atan2(facing.X,facing.Y);}
bodies.Add(new(new((float)player.X,(float)player.Y,9,0),new(.33f,.87f,.78f,1),default,new(4,0,0,(float)angle)));
if(scene.Nodes.FirstOrDefault(node=>node.Location.Identity==selected) is {} destination)
Line(ship,destination.Position,new(.94f,.63f,.24f,.38f));
}
if(graphics is not null)
{
try{graphics.Draw(bodies.ToArray(),lines.ToArray(),ActualWidth,height,XamlRoot?.RasterizationScale??1);}
catch(Exception error)
{
GraphicsError=error.Message;empty.Text="3D渲染暂不可用,可从左侧列表选择地点。";empty.Visibility=Visibility.Visible;
App.Runtime.ReportDiagnostic("current-system.graphics","星系场景",error,ActivitySeverity.Error,"3D场景绘制失败。");
}
}
PlaceIcons();PlaceLabels(width,height);
OrbitStatusChanged?.Invoke(scene?.OrbitStatus??"轨道参数待接入",scene?.OrbitDescription(selected)??"等待真实轨道参数。");
SceneStatusChanged?.Invoke(scene is null?"等待真实地点坐标":displayOnly?$"公开目录示意 · {scene.Nodes.Count} 个地点":$"已定位 {scene.Nodes.Count} 个地点 · 显示 {MarkerLocations.Count} 个标记");
}
private (double X,double Y,double Depth)? Project(Vector3d point)
=>camera.Project(point,CameraProjectionWidth,ActualHeight) is {} value?(value.X+overlayLeft,value.Y,value.Depth):null;
private void PlaceIcons()
{
var count=0;
foreach(var group in markerGroups)
{
var point=group.Anchor;
// Celestial bodies retain their shaded 3D surface. All other
// destinations use exactly the same asset resolver as the sidebar.
if(point.Node.Location.Category is SystemLocationCategory.Star or SystemLocationCategory.Planet or SystemLocationCategory.LandablePlanet)continue;
if(count==iconPool.Count)
{
var icon=new GameIcon();var container=new Grid{IsHitTestVisible=false};
container.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse{Stroke=Brush(255,174,59),StrokeThickness=1,Opacity=.8});
container.Children.Add(icon);
var number=new TextBlock{FontSize=10,Foreground=Brush(229,238,247),Margin=new(3,0,3,0)};
var badge=new Border{Child=number,Background=Brush(18,34,48),CornerRadius=new(7),HorizontalAlignment=HorizontalAlignment.Right,VerticalAlignment=VerticalAlignment.Bottom,Margin=new(0,0,-7,-5)};
container.Children.Add(badge);iconPool.Add((container,icon,badge,number));labels.Children.Insert(0,container);
}
var marker=iconPool[count++];var size=22d;
marker.Icon.IconKey=GameUiIcons.ForLocation(point.Node.Location);marker.Icon.Width=size;marker.Icon.Height=size;
marker.Count.Text=group.Members.Count.ToString();marker.Badge.Visibility=group.Members.Count>1?Visibility.Visible:Visibility.Collapsed;
marker.Host.Width=size+8;marker.Host.Height=size+8;marker.Host.Visibility=Visibility.Visible;
marker.Host.Children[0].Visibility=point.Selected?Visibility.Visible:Visibility.Collapsed;
Canvas.SetLeft(marker.Host,point.X-(size+8)/2);Canvas.SetTop(marker.Host,point.Y-(size+8)/2);
}
for(var index=count;index<iconPool.Count;index++)iconPool[index].Host.Visibility=Visibility.Collapsed;
}
internal object[] IconsForProbe=>markerGroups.Select(group=>(object)new{group.Anchor.Node.Location.Identity,icon=GameUiIcons.ForLocation(group.Anchor.Node.Location).ToString(),
asset=GameUiIcons.Asset(GameUiIcons.ForLocation(group.Anchor.Node.Location)),group.Anchor.X,group.Anchor.Y,members=group.Members.Select(point=>point.Node.Location.Identity)}).ToArray();
private void PlaceLabels(double width,double height)
{
var boxes=new List<Rect>();var count=0;
var candidates=MarkerLocations.Where(point=>LocalSystemMarkerLayout.Celestial(point.Node.Location)).Concat(markerGroups.Select(group=>group.Anchor))
.OrderByDescending(point=>point.Selected).ThenBy(point=>point.Node.Location.Category==SystemLocationCategory.Star?0:1)
.ThenBy(point=>point.Node.Location.DistanceMetres??double.MaxValue);
foreach(var point in candidates)
{
if(count>=28)break;
var text=point.Node.Location.DisplayName;var labelWidth=Math.Clamp(text.Length*7+14,90,180);var labelHeight=point.Selected?44d:26d;
var choices=new[]{new Rect(point.X+point.Radius+8,point.Y-10,labelWidth,labelHeight),new Rect(point.X-point.Radius-labelWidth-8,point.Y-10,labelWidth,labelHeight),
new Rect(point.X-labelWidth/2,point.Y+point.Radius+8,labelWidth,labelHeight),new Rect(point.X-labelWidth/2,point.Y-point.Radius-labelHeight-8,labelWidth,labelHeight)};
var place=choices.FirstOrDefault(box=>box.X>=overlayLeft+12&&box.Y>=64&&box.Right<=width-overlayRight-12&&box.Bottom<=height-64
&&!projections.Where(body=>LocalSystemMarkerLayout.Celestial(body.Node.Location)).Any(body=>
Math.Pow(body.X-Math.Clamp(body.X,box.X,box.Right),2)+Math.Pow(body.Y-Math.Clamp(body.Y,box.Y,box.Bottom),2)<Math.Pow(body.Radius+5,2))
&&!boxes.Any(other=>other.X<box.Right&&other.Right>box.X&&other.Y<box.Bottom&&other.Bottom>box.Y));
if(place.Width==0)continue;
if(count==labelPool.Count)
{
var block=new TextBlock{FontSize=12,TextTrimming=TextTrimming.CharacterEllipsis,MaxLines=2};
var border=new Border{Child=block,Background=new SolidColorBrush(Windows.UI.Color.FromArgb(185,8,18,29)),Padding=new(5,3,5,3),CornerRadius=new(4)};
labelPool.Add((border,block));labels.Children.Add(border);
}
var label=labelPool[count++];label.Text.Text=point.Selected?text+"\n"+point.Node.Location.DistanceText:text;
label.Text.Foreground=point.Selected?Brush(255,179,71):Brush(187,207,224);label.Text.FontWeight=point.Selected?Microsoft.UI.Text.FontWeights.SemiBold:Microsoft.UI.Text.FontWeights.Normal;
label.Border.Width=place.Width;label.Border.Height=place.Height;label.Border.Visibility=Visibility.Visible;
Canvas.SetLeft(label.Border,place.X);Canvas.SetTop(label.Border,place.Y);boxes.Add(place);
}
for(var i=count;i<labelPool.Count;i++)labelPool[i].Border.Visibility=Visibility.Collapsed;
}
private void Pressed(object sender,PointerRoutedEventArgs e)
{
var point=e.GetCurrentPoint(this);if(!point.Properties.IsLeftButtonPressed&&!point.Properties.IsRightButtonPressed)return;
Focus(FocusState.Pointer);dragging=true;moved=false;panning=point.Properties.IsRightButtonPressed;previous=pressed=point.Position;CapturePointer(e.Pointer);e.Handled=true;
}
private void Moved(object sender,PointerRoutedEventArgs e)
{
if(!dragging)return;var point=e.GetCurrentPoint(this).Position;var dx=point.X-previous.X;var dy=point.Y-previous.Y;
if(Math.Abs(point.X-pressed.X)+Math.Abs(point.Y-pressed.Y)>4)moved=true;
overview=false;camera=panning?camera.Pan(dx,dy,CameraProjectionWidth,ActualHeight):camera.Rotate(dx,dy);previous=point;Invalidate();e.Handled=true;
}
private void Released(object sender,PointerRoutedEventArgs e)
{
if(!dragging)return;dragging=false;ReleasePointerCapture(e.Pointer);
if(!moved&&!panning)SelectAt(e.GetCurrentPoint(this).Position);e.Handled=true;
}
private void Select(SystemLocation location){selected=location.Identity;LocationSelected?.Invoke(this,location);Invalidate();}
private void SelectAt(Point point)
{
var hits=LocalSystemScene.HitTest(InteractiveLocations,point.X,point.Y);
if(hits.Count==0)return;
if(hits.Count==1){Select(hits[0].Node.Location);return;}
var picker=new ListView{ItemsSource=hits.Select(hit=>hit.Node.Location.DisplayName+" · "+hit.Node.Location.KindText).ToArray(),MaxHeight=340,MinWidth=260,SelectionMode=ListViewSelectionMode.Single};
var flyout=new Flyout{Content=picker};
picker.SelectionChanged+=(_,_)=>{if(picker.SelectedIndex is var index&&index>=0&&index<hits.Count){Select(hits[index].Node.Location);flyout.Hide();}};
flyout.ShowAt(this,new Microsoft.UI.Xaml.Controls.Primitives.FlyoutShowOptions{Position=point});
}
internal void SelectAtForProbe(Point point)
{
// Same production hit testing; a deterministic explicit first hit avoids opening a test-only flyout.
if(LocalSystemScene.HitTest(InteractiveLocations,point.X,point.Y).FirstOrDefault() is {} hit)Select(hit.Node.Location);
}
internal void RotateForProbe(double dx,double dy){overview=false;camera=camera.Rotate(dx,dy);RenderNowForProbe();}
internal void PanForProbe(double dx,double dy){overview=false;camera=camera.Pan(dx,dy,CameraProjectionWidth,ActualHeight);RenderNowForProbe();}
internal (int Width,int Height,byte[] Pixels) CapturePixelsForProbe()=>graphics?.ReadPixels()??throw new InvalidOperationException(GraphicsError);
internal async Task<IDisposable> FreezeForXamlProbeAsync()
{
RenderNowForProbe();var frame=graphics?.ReadPixels()??throw new InvalidOperationException(GraphicsError);
var bitmap=new Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap(frame.Width,frame.Height);
using(var stream=bitmap.PixelBuffer.AsStream())await stream.WriteAsync(frame.Pixels);bitmap.Invalidate();
var snapshot=new Image{Source=bitmap,Stretch=Stretch.Fill,IsHitTestVisible=false};ElementCompositionPreview.SetElementChildVisual(host,null);host.Children.Add(snapshot);
return new SnapshotScope(()=>{host.Children.Remove(snapshot);ElementCompositionPreview.SetElementChildVisual(host,visual);Invalidate();});
}
private sealed class SnapshotScope(Action restore):IDisposable{public void Dispose()=>restore();}
private static SolidColorBrush Brush(byte r,byte g,byte b)=>new(Windows.UI.Color.FromArgb(255,r,g,b));
}