Files
ZeroSpace/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs
T

793 lines
23 KiB
C#

using System.Globalization;
using CommunityToolkit.Maui.Storage;
using ZeroSpace.MapEditor.Models;
using ZeroSpace.MapEditor.Services;
namespace ZeroSpace.MapEditor.Views;
public partial class MapEditorPage : ContentPage
{
private readonly MapDrawable _drawable = new();
private List<MapNode> _nodes = new();
private Dictionary<string, MapNode> _byName = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<MapNode> _selectedNodes = new();
private MapNode? _selected;
private MapNode? _pressNode;
private Point _pressPoint;
private Point _lastPoint;
private Point _dragStartWorldPoint;
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
private readonly HashSet<MapNode> _preMarqueeSelected = new();
private bool _draggingNode;
private bool _marqueeSelecting;
private bool _panning;
private bool _panModeEnabled;
private bool _wasAlreadySelectedInGroup;
private bool _viewFitted;
private bool _updatingInspector;
private string? _folder;
private readonly SemaphoreSlim _saveLock = new(1, 1);
private CancellationTokenSource? _autoSaveCts;
public MapEditorPage()
{
InitializeComponent();
_drawable.FindNode = name => _byName.TryGetValue(name, out var node) ? node : null;
MapView.Drawable = _drawable;
MapView.SizeChanged += OnMapViewSizeChanged;
TryAutoLoad();
}
// ----- view transform -----
private (double X, double Y) ToWorld(Point screen) =>
((screen.X - _drawable.OffsetX) / _drawable.Scale,
(screen.Y - _drawable.OffsetY) / _drawable.Scale);
private void FitView()
{
var width = MapView.Width;
var height = MapView.Height;
if (width <= 0 || height <= 0)
{
return;
}
const double margin = 60;
double minX = 0, minY = 0, maxX = StarSystemDocStore.WorldSize, maxY = StarSystemDocStore.WorldSize;
if (_nodes.Count > 0)
{
minX = Math.Min(0, _nodes.Min(static n => n.X));
minY = Math.Min(0, _nodes.Min(static n => n.Y));
maxX = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.X));
maxY = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.Y));
}
var worldW = Math.Max(maxX - minX, 100);
var worldH = Math.Max(maxY - minY, 100);
_drawable.Scale = Math.Min((width - margin * 2) / worldW, (height - margin * 2) / worldH);
_drawable.OffsetX = width / 2 - (minX + worldW / 2) * _drawable.Scale;
_drawable.OffsetY = height / 2 - (minY + worldH / 2) * _drawable.Scale;
_viewFitted = true;
MapView.Invalidate();
}
private void OnMapViewSizeChanged(object? sender, EventArgs e)
{
if (!_viewFitted)
{
FitView();
}
}
private void Zoom(double factor)
{
var centerX = MapView.Width / 2;
var centerY = MapView.Height / 2;
var (worldX, worldY) = ToWorld(new Point(centerX, centerY));
_drawable.Scale = Math.Clamp(_drawable.Scale * factor, 0.05, 20);
_drawable.OffsetX = centerX - worldX * _drawable.Scale;
_drawable.OffsetY = centerY - worldY * _drawable.Scale;
MapView.Invalidate();
}
private void OnZoomInClicked(object? sender, EventArgs e) => Zoom(1.25);
private void OnZoomOutClicked(object? sender, EventArgs e) => Zoom(1 / 1.25);
private void OnResetViewClicked(object? sender, EventArgs e)
{
_viewFitted = false;
FitView();
}
private void OnPanModeClicked(object? sender, EventArgs e)
{
_panModeEnabled = !_panModeEnabled;
PanModeButton.Text = _panModeEnabled ? "Pan: ON" : "Pan: Off";
StatusLabel.Text = _panModeEnabled
? "Pan mode enabled. Drag anywhere to move the view."
: "Select mode enabled. Drag empty space to multi-select.";
}
private void CenterOnNode(MapNode node)
{
var width = MapView.Width;
var height = MapView.Height;
if (width > 0 && height > 0)
{
_drawable.OffsetX = width / 2 - node.X * _drawable.Scale;
_drawable.OffsetY = height / 2 - node.Y * _drawable.Scale;
MapView.Invalidate();
}
}
// ----- search -----
private void OnSearchTextChanged(object? sender, TextChangedEventArgs e) => ApplySearch(e.NewTextValue);
private void OnSearchButtonPressed(object? sender, EventArgs e)
{
ApplySearch(SearchControl.Text);
FocusFirstMatch();
}
private void ApplySearch(string? query)
{
var trimmed = query?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
foreach (var node in _nodes)
{
node.IsHighlighted = false;
}
if (_folder is not null)
{
StatusLabel.Text = $"{_nodes.Count} systems loaded from {_folder}.";
}
else
{
StatusLabel.Text = "No folder loaded.";
}
MapView.Invalidate();
return;
}
var matchCount = 0;
foreach (var node in _nodes)
{
var isMatch = node.Doc.Name.Contains(trimmed, StringComparison.OrdinalIgnoreCase);
node.IsHighlighted = isMatch;
if (isMatch)
{
matchCount++;
}
}
if (matchCount == 0)
{
StatusLabel.Text = $"No systems matching '{trimmed}'.";
}
else
{
StatusLabel.Text = $"Found {matchCount} matching system{(matchCount == 1 ? string.Empty : "s")}.";
}
MapView.Invalidate();
}
private void FocusFirstMatch()
{
var match = _nodes.FirstOrDefault(static n => n.IsHighlighted);
if (match is not null)
{
Select(match);
CenterOnNode(match);
}
}
// ----- pointer interaction -----
private MapNode? HitTest(Point screen)
{
var (worldX, worldY) = ToWorld(screen);
var radius = MapDrawable.NodeRadius + 4;
foreach (var node in _nodes)
{
var dx = node.X - worldX;
var dy = node.Y - worldY;
if (dx * dx + dy * dy <= radius * radius)
{
return node;
}
}
return null;
}
private static double Distance(Point a, Point b)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
private void OnPointerPressed(object? sender, PointerEventArgs e)
{
var position = e.GetPosition(MapView);
if (position is null)
{
return;
}
_pressPoint = position.Value;
_lastPoint = position.Value;
_draggingNode = false;
_marqueeSelecting = false;
_panning = false;
_wasAlreadySelectedInGroup = false;
var isRightOrMiddle = false;
var isShiftOrCtrl = false;
var isAltPressed = false;
#if WINDOWS
if (e.PlatformArgs?.PointerRoutedEventArgs is { } winArgs)
{
var pt = winArgs.GetCurrentPoint(null);
isRightOrMiddle = pt.Properties.IsRightButtonPressed || pt.Properties.IsMiddleButtonPressed;
isShiftOrCtrl = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Shift) ||
winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Control);
isAltPressed = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Menu);
}
#endif
if (_panModeEnabled || isRightOrMiddle || isAltPressed)
{
_panning = true;
_pressNode = null;
return;
}
_pressNode = HitTest(position.Value);
if (_pressNode is not null)
{
if (isShiftOrCtrl)
{
if (_selectedNodes.Contains(_pressNode))
{
_selectedNodes.Remove(_pressNode);
_pressNode.IsSelected = false;
_selected = _selectedNodes.FirstOrDefault();
if (_selected is not null)
{
PopulateInspector(_selected);
}
else
{
InspectorScroll.IsVisible = false;
}
}
else
{
_selectedNodes.Add(_pressNode);
_pressNode.IsSelected = true;
_selected = _pressNode;
InspectorScroll.IsVisible = true;
PopulateInspector(_selected);
}
MapView.Invalidate();
}
else
{
if (_selectedNodes.Contains(_pressNode))
{
_wasAlreadySelectedInGroup = _selectedNodes.Count > 1;
_selected = _pressNode;
PopulateInspector(_selected);
}
else
{
Select(_pressNode);
}
}
_dragStartWorldPositions.Clear();
var (clickWorldX, clickWorldY) = ToWorld(position.Value);
_dragStartWorldPoint = new Point(clickWorldX, clickWorldY);
foreach (var node in _selectedNodes)
{
_dragStartWorldPositions[node] = (node.X, node.Y);
}
}
else
{
_marqueeSelecting = true;
_preMarqueeSelected.Clear();
if (isShiftOrCtrl)
{
foreach (var n in _selectedNodes)
{
_preMarqueeSelected.Add(n);
}
}
_drawable.SelectionBox = null;
}
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
var position = e.GetPosition(MapView);
if (position is null)
{
return;
}
if (_panning)
{
_drawable.OffsetX += position.Value.X - _lastPoint.X;
_drawable.OffsetY += position.Value.Y - _lastPoint.Y;
_lastPoint = position.Value;
MapView.Invalidate();
return;
}
if (_pressNode is not null)
{
if (!_draggingNode && Distance(position.Value, _pressPoint) > 4)
{
_draggingNode = true;
}
if (_draggingNode)
{
var (currentWorldX, currentWorldY) = ToWorld(position.Value);
var deltaX = currentWorldX - _dragStartWorldPoint.X;
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
foreach (var node in _selectedNodes)
{
if (_dragStartWorldPositions.TryGetValue(node, out var startPos))
{
node.X = startPos.X + deltaX;
node.Y = startPos.Y + deltaY;
node.Doc.IsDirty = true;
}
}
UpdateDirtyState();
SyncPositionEntries();
MapView.Invalidate();
}
}
else if (_marqueeSelecting)
{
var minX = (float)Math.Min(_pressPoint.X, position.Value.X);
var minY = (float)Math.Min(_pressPoint.Y, position.Value.Y);
var width = (float)Math.Abs(_pressPoint.X - position.Value.X);
var height = (float)Math.Abs(_pressPoint.Y - position.Value.Y);
var rect = new RectF(minX, minY, width, height);
_drawable.SelectionBox = rect;
foreach (var node in _nodes)
{
var screenX = (float)(node.X * _drawable.Scale + _drawable.OffsetX);
var screenY = (float)(node.Y * _drawable.Scale + _drawable.OffsetY);
var inBox = screenX >= minX && screenX <= minX + width &&
screenY >= minY && screenY <= minY + height;
node.IsSelected = inBox || _preMarqueeSelected.Contains(node);
}
MapView.Invalidate();
}
}
private void OnPointerReleased(object? sender, PointerEventArgs e)
{
var position = e.GetPosition(MapView);
if (_panning)
{
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
{
var hit = HitTest(position.Value);
Select(hit);
}
_panning = false;
}
else if (_pressNode is not null)
{
var wasDragging = _draggingNode;
if (!_draggingNode && _wasAlreadySelectedInGroup)
{
Select(_pressNode);
}
_draggingNode = false;
_pressNode = null;
if (wasDragging)
{
_ = AutoSaveAsync();
}
}
else if (_marqueeSelecting)
{
var isDrag = position is not null && Distance(position.Value, _pressPoint) > 4;
_drawable.SelectionBox = null;
_marqueeSelecting = false;
if (isDrag)
{
var selectedInMarquee = _nodes.Where(static n => n.IsSelected).ToList();
SetSelection(selectedInMarquee);
}
else
{
Select(null);
}
}
else
{
Select(null);
}
_drawable.SelectionBox = null;
MapView.Invalidate();
}
// ----- selection + inspector -----
private void Select(MapNode? node)
{
foreach (var n in _nodes)
{
n.IsSelected = false;
}
_selectedNodes.Clear();
_selected = node;
if (_selected is not null)
{
_selected.IsSelected = true;
_selectedNodes.Add(_selected);
}
InspectorScroll.IsVisible = node is not null;
if (node is not null)
{
PopulateInspector(node);
}
MapView.Invalidate();
}
private void SetSelection(IEnumerable<MapNode> nodes, MapNode? primary = null)
{
foreach (var n in _nodes)
{
n.IsSelected = false;
}
_selectedNodes.Clear();
foreach (var n in nodes)
{
n.IsSelected = true;
_selectedNodes.Add(n);
}
if (_selectedNodes.Count == 0)
{
_selected = null;
InspectorScroll.IsVisible = false;
}
else
{
_selected = primary ?? (_selected is not null && _selectedNodes.Contains(_selected)
? _selected
: _selectedNodes.First());
InspectorScroll.IsVisible = true;
PopulateInspector(_selected);
if (_selectedNodes.Count > 1)
{
StatusLabel.Text = $"{_selectedNodes.Count} systems selected.";
}
}
MapView.Invalidate();
}
private void OnConnectionSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_updatingInspector)
{
return;
}
if (e.CurrentSelection.FirstOrDefault() is string name)
{
SelectConnectedSystem(name);
}
}
private void SelectConnectedSystem(string name)
{
if (_byName.TryGetValue(name, out var targetNode))
{
Select(targetNode);
}
}
private void PopulateInspector(MapNode node)
{
_updatingInspector = true;
try
{
var doc = node.Doc;
NameLabel.Text = doc.Name;
KindLabel.Text = string.IsNullOrEmpty(doc.Kind) ? "—" : doc.Kind;
YieldsLabel.Text = YieldText(doc);
BuffLabel.Text = doc.OccupationBuff ?? "—";
MasteryLabel.Text = doc.SystemMastery ?? "—";
XEntry.Text = doc.X.ToString("F1", CultureInfo.InvariantCulture);
YEntry.Text = doc.Y.ToString("F1", CultureInfo.InvariantCulture);
ConnectionsView.SelectedItem = null;
ConnectionsView.ItemsSource = doc.ConnectedSystems.ToList();
}
finally
{
_updatingInspector = false;
}
}
private void SyncPositionEntries()
{
if (_selected is null || _selected != _pressNode)
{
return;
}
_updatingInspector = true;
try
{
XEntry.Text = _selected.X.ToString("F1", CultureInfo.InvariantCulture);
YEntry.Text = _selected.Y.ToString("F1", CultureInfo.InvariantCulture);
}
finally
{
_updatingInspector = false;
}
}
private void OnPositionTextChanged(object? sender, TextChangedEventArgs e)
{
if (_updatingInspector || _selected is null)
{
return;
}
if (double.TryParse(XEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) &&
double.TryParse(YEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
{
if (_selected.X != x || _selected.Y != y)
{
_selected.X = x;
_selected.Y = y;
_selected.Doc.IsDirty = true;
UpdateDirtyState();
MapView.Invalidate();
ScheduleAutoSave();
}
}
}
private void ScheduleAutoSave(int delayMs = 400)
{
_autoSaveCts?.Cancel();
_autoSaveCts?.Dispose();
_autoSaveCts = new CancellationTokenSource();
var token = _autoSaveCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(delayMs, token);
if (!token.IsCancellationRequested)
{
await MainThread.InvokeOnMainThreadAsync(() => AutoSaveAsync());
}
}
catch (OperationCanceledException)
{
// Debounce cancelled by a newer edit or immediate save
}
}, token);
}
private static string YieldText(StarSystemDoc doc)
{
var yields = new List<string>();
if (doc.HigherInfluenceYield)
{
yields.Add("Influence");
}
if (doc.HigherCreditYield)
{
yields.Add("Credits");
}
if (doc.HigherEnergyYield)
{
yields.Add("Energy");
}
if (doc.HigherChanceForItems)
{
yields.Add("Items");
}
return yields.Count == 0 ? "Standard" : "Higher " + string.Join(", ", yields);
}
// ----- folder + save -----
private void TryAutoLoad()
{
// Dev-repo layout: walk up from the app directory looking for zs.docs/StarSystem.
var directory = new DirectoryInfo(AppContext.BaseDirectory);
for (var i = 0; i < 12 && directory is not null; i++, directory = directory.Parent)
{
var candidate = Path.Combine(directory.FullName, "zs.docs", "StarSystem");
if (Directory.Exists(candidate))
{
LoadFolder(candidate);
return;
}
}
var remembered = Preferences.Get("MapEditorFolder", string.Empty);
if (!string.IsNullOrEmpty(remembered) && Directory.Exists(remembered))
{
LoadFolder(remembered);
}
}
private void LoadFolder(string folder)
{
var docs = StarSystemDocStore.LoadFolder(folder);
_folder = folder;
_nodes = docs.Select(static doc => new MapNode(doc)).ToList();
_byName = _nodes.ToDictionary(static node => node.Doc.Name, StringComparer.OrdinalIgnoreCase);
_drawable.Nodes = _nodes;
Select(null);
_viewFitted = false;
FitView();
Preferences.Set("MapEditorFolder", folder);
if (!string.IsNullOrWhiteSpace(SearchControl.Text))
{
ApplySearch(SearchControl.Text);
}
else
{
StatusLabel.Text = $"{_nodes.Count} systems loaded from {folder}.";
}
var dirtyCount = _nodes.Count(static node => node.Doc.IsDirty);
if (dirtyCount > 0)
{
_ = AutoSaveAsync();
}
else
{
UpdateDirtyState();
}
}
private async void OnOpenFolderClicked(object? sender, EventArgs e)
{
try
{
var result = await FolderPicker.Default.PickAsync();
if (result?.Folder is not null && Directory.Exists(result.Folder.Path))
{
LoadFolder(result.Folder.Path);
}
}
catch (Exception ex)
{
StatusLabel.Text = $"Could not open folder: {ex.Message}";
}
}
private void OnSaveClicked(object? sender, EventArgs e)
{
_ = AutoSaveAsync();
}
private async Task AutoSaveAsync()
{
_autoSaveCts?.Cancel();
_autoSaveCts?.Dispose();
_autoSaveCts = null;
var dirtyNodes = _nodes.Where(static n => n.Doc.IsDirty).ToList();
if (dirtyNodes.Count == 0)
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
return;
}
SaveButton.Text = "Saving...";
SaveButton.IsEnabled = false;
await _saveLock.WaitAsync();
try
{
var docsToSave = _nodes.Where(static n => n.Doc.IsDirty).Select(static n => n.Doc).ToList();
if (docsToSave.Count == 0)
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
return;
}
var savedCount = await Task.Run(() => StarSystemDocStore.SavePositions(docsToSave));
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
if (remainingDirty > 0)
{
SaveButton.Text = "Save";
SaveButton.IsEnabled = true;
StatusLabel.Text =
$"{remainingDirty} unsaved position change{(remainingDirty == 1 ? string.Empty : "s")}.";
}
else
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
StatusLabel.Text = savedCount == 1
? "Auto-saved 1 system."
: $"Auto-saved {savedCount} systems.";
}
}
catch (Exception ex)
{
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
SaveButton.Text = "Save";
SaveButton.IsEnabled = remainingDirty > 0;
StatusLabel.Text = $"Auto-save failed: {ex.Message}";
}
finally
{
_saveLock.Release();
}
}
private void UpdateDirtyState()
{
var dirty = _nodes.Count(static node => node.Doc.IsDirty);
if (dirty > 0)
{
SaveButton.Text = "Save";
SaveButton.IsEnabled = true;
StatusLabel.Text = $"{dirty} unsaved position change{(dirty == 1 ? string.Empty : "s")}.";
}
else
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
}
}
}