Files
ZeroSpace/ZeroSpace/ZeroSpace.Web/Pages/Galaxy.razor
T
JonathanMcCaffrey beb22d097f AI: Galaxy page faction bonus drawer replaces faction filter
- Removed the faction <select> filter from the Galaxy filter bar.
- Added a right-side drawer; exactly one of the three factions is
  always selected (Eden Accord default), user picks via picker.
- Drawer aggregates occupational bonuses across the selected
  faction's planets: buffs grouped by type with magnitudes summed
  (e.g. two +15% cooldown planets total +30%), plus planet counts.
- Drawer also lists the faction's planets linking to detail pages,
  and collapses via the header toggle.
2026-09-29 00:23:49 +00:00

710 lines
29 KiB
Plaintext

@page "/galaxy"
@using System.Globalization
@using System.Text.RegularExpressions
@inject NavigationManager Navigation
<PageTitle>Galaxy Map</PageTitle>
<div class="galaxy-page">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-3">
<div>
<h1 class="h2 mb-1 text-light fw-bold">Galaxy Map</h1>
<p class="text-secondary mb-0 small">Interactive tactical star chart displaying all planetary systems and hyperlane routes.</p>
</div>
<div class="d-flex flex-wrap align-items-center gap-3">
<div class="map-legend d-flex flex-wrap align-items-center gap-3 px-3 py-2 rounded">
<div class="legend-group d-flex align-items-center gap-2">
<span class="legend-header text-dim fs-8 text-uppercase">Faction:</span>
<div class="legend-item d-flex align-items-center gap-1" title="The Eden Accord (Green)">
<span class="legend-dot dot-eden"></span>
<span class="legend-text">Eden Accord</span>
</div>
<div class="legend-item d-flex align-items-center gap-1" title="The Keeper Council (Blue)">
<span class="legend-dot dot-keeper"></span>
<span class="legend-text">Keeper Council</span>
</div>
<div class="legend-item d-flex align-items-center gap-1" title="The Obsidian Order (Red)">
<span class="legend-dot dot-obsidian"></span>
<span class="legend-text">Obsidian Order</span>
</div>
</div>
</div>
<div class="map-stats text-secondary small px-3 py-2 rounded">
<span><strong>@_matchingCount</strong> / <strong>@_systems.Count</strong> Systems</span>
<span class="mx-1">•</span>
<span><strong>@_connections.Count</strong> Hyperlanes</span>
</div>
</div>
</div>
<!-- Filter Control Bar -->
<div class="filter-bar zs-glass-card p-3 mb-3 d-flex flex-wrap align-items-center gap-3">
<div class="d-flex align-items-center gap-2 filter-item">
<label class="filter-label text-dim small fw-semibold text-uppercase">System Type:</label>
<select class="form-select form-select-sm scifi-select" @bind="_selectedCategory" @bind:after="ApplyFilters">
<option value="">All Types</option>
<option value="@StarSystemCategory.HomeworldSystem">Homeworld System</option>
<option value="@StarSystemCategory.CoreSystem">Core System</option>
<option value="@StarSystemCategory.SideSystem">Side System</option>
</select>
</div>
<div class="d-flex align-items-center gap-2 filter-item">
<label class="filter-label text-dim small fw-semibold text-uppercase">Mastery:</label>
<select class="form-select form-select-sm scifi-select" @bind="_selectedMastery" @bind:after="ApplyFilters">
<option value="">All Masteries</option>
<option value="__ANY__">Any Mastery</option>
@foreach (var mastery in _availableMasteries)
{
<option value="@mastery">@mastery</option>
}
</select>
</div>
<div class="d-flex align-items-center gap-2 filter-item">
<label class="filter-label text-dim small fw-semibold text-uppercase">Special Yields:</label>
<select class="form-select form-select-sm scifi-select" @bind="_selectedYield" @bind:after="ApplyFilters">
<option value="">All Yields</option>
<option value="ANY">Any Special Yield</option>
<option value="Influence">Higher Influence</option>
<option value="Credits">Higher Credits</option>
<option value="Energy">Higher Energy</option>
<option value="Items">Higher Items</option>
</select>
</div>
@if (HasActiveFilters)
{
<button class="btn btn-sm btn-scifi-outline py-1 px-3 ms-auto" @onclick="ResetFilters">
<span>Reset Filters</span>
</button>
}
</div>
<div class="galaxy-layout">
<div class="galaxy-map-container" @onmouseleave="OnContainerMouseLeave">
<svg viewBox="0 0 1000 1000" class="galaxy-svg" preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Background Grid Pattern -->
<pattern id="space-grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(56, 189, 248, 0.04)" stroke-width="1"/>
</pattern>
<!-- Center Core Glow -->
<radialGradient id="galaxy-core-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(56, 189, 248, 0.08)"/>
<stop offset="60%" stop-color="rgba(16, 185, 129, 0.03)"/>
<stop offset="100%" stop-color="rgba(13, 17, 23, 0)"/>
</radialGradient>
<!-- Glow filters for Owners / Factions -->
<filter id="glow-eden" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="glow-keeper" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="glow-obsidian" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Map Background & Grid -->
<rect width="1000" height="1000" class="map-bg"/>
<rect width="1000" height="1000" fill="url(#space-grid)"/>
<rect width="1000" height="1000" fill="url(#galaxy-core-glow)"/>
<!-- Connection Lines (Hyperlanes) -->
<g class="hyperlanes">
@foreach (var edge in _connections)
{
var isFromMatch = _matchingSystemIds.Contains(edge.From.Id);
var isToMatch = _matchingSystemIds.Contains(edge.To.Id);
var isFilterDimmed = HasActiveFilters && (!isFromMatch || !isToMatch);
var isHoverHighlighted = _hoveredSystem is not null &&
(string.Equals(edge.From.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(edge.To.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase));
var isHoverDimmed = _hoveredSystem is not null && !isHoverHighlighted;
var lineClass = isHoverHighlighted ? "hyperlane-line hyperlane-highlighted" :
(isHoverDimmed || isFilterDimmed) ? "hyperlane-line hyperlane-dimmed" : "hyperlane-line";
<line x1="@SvgNumber(edge.From.X)"
y1="@SvgNumber(edge.From.Y)"
x2="@SvgNumber(edge.To.X)"
y2="@SvgNumber(edge.To.Y)"
class="@lineClass"/>
}
</g>
<!-- Planetary Nodes -->
<g class="planetary-nodes">
@foreach (var system in _systems)
{
var ownerClass = GetOwnerCssClass(system.Owner);
var radius = GetNodeRadius(system.SystemCategory);
var isMatch = _matchingSystemIds.Contains(system.Id);
var isHovered = _hoveredSystem is not null && string.Equals(system.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase);
var isNeighbor = _hoveredConnectedIds.Contains(system.Id);
var isDimmed = (_hoveredSystem is not null && !isHovered && !isNeighbor) || (HasActiveFilters && !isMatch);
var stateClass = isHovered ? "is-hovered" :
isNeighbor ? "is-neighbor" :
isDimmed ? "is-dimmed" :
(HasActiveFilters && isMatch ? "is-filter-matched" : "");
<g class="node-group @ownerClass @stateClass"
transform="translate(@SvgNumber(system.X), @SvgNumber(system.Y))"
@onclick="() => OnNodeClick(system.Id)"
@onmouseenter="() => OnNodeMouseEnter(system)"
@onmouseleave="() => OnNodeMouseLeave(system)"
@onfocus="() => OnNodeFocus(system)"
@onblur="OnNodeBlur"
@onkeydown="e => OnNodeKeyDown(e, system.Id)"
tabindex="0"
role="button"
aria-label="@system.Name (@system.SystemCategory.ToDisplayName(), @system.Owner)">
<!-- Outer aura/glow circle -->
<circle r="@SvgNumber(radius + 4)" class="node-aura"/>
<!-- Main Node Circle -->
<circle r="@SvgNumber(radius)" class="node-circle"/>
<!-- Center core dot for homeworlds and core systems -->
@if (system.SystemCategory != StarSystemCategory.SideSystem)
{
<circle r="@SvgNumber(radius * 0.4)" class="node-core-dot"/>
}
<!-- System Name Label -->
<text y="@SvgNumber(radius + 10)" class="node-label" text-anchor="middle">@system.Name</text>
</g>
}
</g>
</svg>
<!-- Floating Glassmorphism Hover Card -->
@if (_hoveredSystem is not null)
{
var ownerClass = GetOwnerCssClass(_hoveredSystem.Owner);
var higherYields = GetHigherYields(_hoveredSystem);
<div class="planet-hover-card @ownerClass"
style="@GetTooltipStyle(_hoveredSystem)"
role="tooltip"
aria-live="polite">
<div class="card-header-row d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="system-owner-indicator"></span>
<span class="system-title fw-bold text-light">@_hoveredSystem.Name</span>
</div>
<span class="badge @CategoryBadgeClass(_hoveredSystem.SystemCategory)">@_hoveredSystem.SystemCategory.ToDisplayName()</span>
</div>
<div class="card-details-grid">
<div class="detail-row">
<span class="detail-label">Faction Owner</span>
<span class="badge @OwnerBadgeClass(_hoveredSystem.Owner) mt-1">@_hoveredSystem.Owner</span>
</div>
<div class="detail-row">
<span class="detail-label">Occupation Buff</span>
<span class="detail-value @(_hoveredSystem.OccupationBuff != null ? "text-buff" : "text-secondary")">
@(_hoveredSystem.OccupationBuff ?? "—")
</span>
</div>
<div class="detail-row">
<span class="detail-label">System Mastery</span>
<span class="detail-value @(_hoveredSystem.SystemMastery != null ? "text-mastery" : "text-secondary")">
@(_hoveredSystem.SystemMastery ?? "—")
</span>
</div>
<div class="detail-row">
<span class="detail-label">Yield Bonuses</span>
<div class="yield-badges d-flex flex-wrap gap-1 mt-1">
@if (higherYields.Count == 0)
{
<span class="yield-badge yield-standard">Standard</span>
}
else
{
@foreach (var y in higherYields)
{
<span class="yield-badge yield-@y.ToLowerInvariant()">+@y</span>
}
}
</div>
</div>
<div class="detail-row">
<div class="d-flex align-items-center justify-content-between">
<span class="detail-label">Connected Systems</span>
<span class="badge bg-dark text-secondary border border-secondary border-opacity-25">@_hoveredSystem.ConnectedSystems.Count</span>
</div>
<div class="connected-systems-list text-secondary small mt-1">
@string.Join(" • ", _hoveredSystem.ConnectedSystems)
</div>
</div>
</div>
<div class="card-footer-hint mt-2 pt-2 border-top border-secondary border-opacity-25 text-center">
<span class="hint-text small">Click system to view full details →</span>
</div>
</div>
}
</div>
<aside class="faction-drawer zs-glass-card @(_drawerCollapsed ? "is-collapsed" : "")" aria-label="Faction occupational bonuses">
<div class="drawer-header d-flex align-items-center justify-content-between gap-2">
<div>
<h2 class="drawer-title text-light fw-bold mb-0">Faction Bonuses</h2>
<p class="drawer-subtitle text-secondary mb-0">Occupation buffs by faction</p>
</div>
<button class="btn btn-sm btn-scifi-outline drawer-toggle" @onclick="ToggleDrawer"
aria-expanded="@(!_drawerCollapsed)"
title="@(_drawerCollapsed ? "Expand bonus panel" : "Collapse bonus panel")">
@(_drawerCollapsed ? "»" : "«")
</button>
</div>
@if (!_drawerCollapsed)
{
<div class="drawer-body">
<div class="faction-picker" role="radiogroup" aria-label="Select faction">
@foreach (var owner in StarSystemOwners.All)
{
var isActive = string.Equals(owner, _selectedFaction, StringComparison.Ordinal);
<button role="radio" aria-checked="@isActive"
class="faction-pick @(isActive ? "is-active " + GetOwnerCssClass(owner) : "")"
@onclick="() => SelectFaction(owner)">
<span class="legend-dot @FactionDotClass(owner)"></span>
<span class="faction-pick-name">@ShortFactionName(owner)</span>
<span class="faction-pick-count">@FactionSystemCount(owner)</span>
</button>
}
</div>
<div class="drawer-section">
<h3 class="drawer-section-title">Occupational bonuses</h3>
<p class="drawer-summary text-secondary">
<strong class="text-light">@FactionSystems.Count</strong> systems owned by
<strong class="text-light">@ShortFactionName(_selectedFaction)</strong>
</p>
@if (FactionBonuses.Count == 0)
{
<p class="text-secondary small mb-0">No occupation buffs recorded for this faction's systems.</p>
}
else
{
<ul class="bonus-list list-unstyled mb-0">
@foreach (var bonus in FactionBonuses)
{
<li class="bonus-row">
<span class="bonus-total">@FormatBonusTotal(bonus)</span>
<span class="bonus-label">@bonus.Label</span>
<span class="bonus-count">@bonus.PlanetCount @(bonus.PlanetCount == 1 ? "planet" : "planets")</span>
</li>
}
</ul>
}
</div>
<div class="drawer-section">
<h3 class="drawer-section-title">Planets (@FactionSystems.Count)</h3>
<ul class="planet-list list-unstyled mb-0">
@foreach (var system in FactionSystems)
{
<li>
<button class="planet-row" @onclick="() => OnNodeClick(system.Id)" title="View @system.Name details">
<span class="planet-name">@system.Name</span>
<span class="planet-buff">@(system.OccupationBuff ?? "—")</span>
</button>
</li>
}
</ul>
</div>
</div>
}
</aside>
</div>
</div>
@code {
public sealed record ConnectionEdge(StarSystemData From, StarSystemData To);
private IReadOnlyList<StarSystemData> _systems = [];
private IReadOnlyList<ConnectionEdge> _connections = [];
private IReadOnlyList<string> _availableMasteries = [];
private StarSystemData? _hoveredSystem;
private readonly HashSet<string> _hoveredConnectedIds = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _matchingSystemIds = new(StringComparer.OrdinalIgnoreCase);
private StarSystemCategory? _selectedCategory;
private string _selectedMastery = string.Empty;
private string _selectedYield = string.Empty;
private int _matchingCount;
// Faction picker: one of the three factions is always selected.
private string _selectedFaction = StarSystemOwners.EdenAccord;
private bool _drawerCollapsed;
private bool HasActiveFilters =>
_selectedCategory.HasValue ||
!string.IsNullOrEmpty(_selectedMastery) ||
!string.IsNullOrEmpty(_selectedYield);
protected override void OnInitialized()
{
_systems = StarSystems.All;
_connections = BuildUniqueConnections(_systems);
_availableMasteries = SystemMasteries.All.Select(m => m.Name).Distinct().OrderBy(m => m).ToList();
ApplyFilters();
}
private void ApplyFilters()
{
_matchingSystemIds.Clear();
foreach (var system in _systems)
{
if (MatchesFilters(system))
{
_matchingSystemIds.Add(system.Id);
}
}
_matchingCount = _matchingSystemIds.Count;
}
private bool MatchesFilters(StarSystemData system)
{
if (_selectedCategory.HasValue && system.SystemCategory != _selectedCategory.Value)
{
return false;
}
if (!string.IsNullOrEmpty(_selectedMastery))
{
if (_selectedMastery == "__ANY__")
{
if (string.IsNullOrWhiteSpace(system.SystemMastery)) return false;
}
else if (!string.Equals(system.SystemMastery, _selectedMastery, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (!string.IsNullOrEmpty(_selectedYield))
{
if (_selectedYield == "ANY")
{
if (!system.HigherInfluenceYield && !system.HigherCreditYield && !system.HigherEnergyYield && !system.HigherChanceForItems)
{
return false;
}
}
else if (_selectedYield == "Influence" && !system.HigherInfluenceYield) return false;
else if (_selectedYield == "Credits" && !system.HigherCreditYield) return false;
else if (_selectedYield == "Energy" && !system.HigherEnergyYield) return false;
else if (_selectedYield == "Items" && !system.HigherChanceForItems) return false;
}
return true;
}
private void ResetFilters()
{
_selectedCategory = null;
_selectedMastery = string.Empty;
_selectedYield = string.Empty;
ApplyFilters();
}
// ----- faction bonus drawer -----
public sealed record FactionBonus(string Label, bool IsPercent, double Total, int PlanetCount);
private void SelectFaction(string owner)
{
if (string.Equals(_selectedFaction, owner, StringComparison.Ordinal))
{
return;
}
_selectedFaction = owner;
}
private void ToggleDrawer() => _drawerCollapsed = !_drawerCollapsed;
private int FactionSystemCount(string owner) =>
_systems.Count(s => string.Equals(s.Owner, owner, StringComparison.OrdinalIgnoreCase));
private IReadOnlyList<StarSystemData> FactionSystems =>
_systems
.Where(s => string.Equals(s.Owner, _selectedFaction, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.Name)
.ToList();
/// <summary>
/// Groups the selected faction's occupation buffs by bonus type and sums
/// the magnitudes, so e.g. two "+15% cooldown reduction" planets total +30%.
/// </summary>
private IReadOnlyList<FactionBonus> FactionBonuses
{
get
{
var groups = new Dictionary<(string Label, bool IsPercent), (double Total, int Count)>();
foreach (var system in _systems)
{
if (!string.Equals(system.Owner, _selectedFaction, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrWhiteSpace(system.OccupationBuff))
{
continue;
}
var (amount, isPercent, label) = ParseOccupationBuff(system.OccupationBuff);
var key = (label, isPercent);
groups.TryGetValue(key, out var current);
groups[key] = (current.Total + amount, current.Count + 1);
}
return groups
.Select(g => new FactionBonus(g.Key.Label, g.Key.IsPercent, g.Value.Total, g.Value.Count))
.OrderByDescending(b => b.PlanetCount)
.ThenBy(b => b.Label)
.ToList();
}
}
/// <summary>Parses buffs shaped like "+50 Starting Hexite" or "+15% cooldown reduction".</summary>
private static (double Amount, bool IsPercent, string Label) ParseOccupationBuff(string buff)
{
var match = Regex.Match(buff.Trim(), @"^\+\s*([0-9]*\.?[0-9]+)\s*(%?)\s*(.+)$");
if (match.Success &&
double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var amount))
{
return (amount, match.Groups[2].Value == "%", match.Groups[3].Value.Trim());
}
return (0, false, buff.Trim());
}
private static string FormatBonusTotal(FactionBonus bonus) =>
$"+{bonus.Total.ToString("0.##", CultureInfo.InvariantCulture)}{(bonus.IsPercent ? "%" : string.Empty)}";
private static string ShortFactionName(string owner) => owner switch
{
StarSystemOwners.EdenAccord => "Eden Accord",
StarSystemOwners.KeeperCouncil => "Keeper Council",
StarSystemOwners.ObsidianOrder => "Obsidian Order",
_ => owner
};
private static string FactionDotClass(string owner) => owner switch
{
StarSystemOwners.EdenAccord => "dot-eden",
StarSystemOwners.KeeperCouncil => "dot-keeper",
StarSystemOwners.ObsidianOrder => "dot-obsidian",
_ => string.Empty
};
private static IReadOnlyList<ConnectionEdge> BuildUniqueConnections(IReadOnlyList<StarSystemData> allSystems)
{
var edges = new List<ConnectionEdge>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var system in allSystems)
{
foreach (var targetName in system.ConnectedSystems)
{
if (!StarSystems.ByName.TryGetValue(targetName, out var target))
{
continue;
}
var edgeKey = string.Compare(system.Id, target.Id, StringComparison.OrdinalIgnoreCase) < 0
? $"{system.Id}::{target.Id}"
: $"{target.Id}::{system.Id}";
if (seen.Add(edgeKey))
{
edges.Add(new ConnectionEdge(system, target));
}
}
}
return edges;
}
private void SetHoveredSystem(StarSystemData? system)
{
_hoveredSystem = system;
_hoveredConnectedIds.Clear();
if (system is not null)
{
foreach (var connName in system.ConnectedSystems)
{
if (StarSystems.ByName.TryGetValue(connName, out var target))
{
_hoveredConnectedIds.Add(target.Id);
}
}
}
}
private void OnNodeMouseEnter(StarSystemData system)
{
SetHoveredSystem(system);
}
private void OnNodeMouseLeave(StarSystemData system)
{
if (_hoveredSystem?.Id == system.Id)
{
SetHoveredSystem(null);
}
}
private void OnNodeFocus(StarSystemData system)
{
SetHoveredSystem(system);
}
private void OnNodeBlur()
{
SetHoveredSystem(null);
}
private void OnContainerMouseLeave()
{
SetHoveredSystem(null);
}
private void OnNodeKeyDown(KeyboardEventArgs e, string systemId)
{
if (e.Key is "Enter" or " ")
{
OnNodeClick(systemId);
}
}
private void OnNodeClick(string systemId)
{
Navigation.NavigateTo($"/StarSystem/{systemId}");
}
private static IReadOnlyList<string> GetHigherYields(StarSystemData system)
{
var yields = new List<string>(4);
if (system.HigherInfluenceYield) yields.Add("Influence");
if (system.HigherCreditYield) yields.Add("Credits");
if (system.HigherEnergyYield) yields.Add("Energy");
if (system.HigherChanceForItems) yields.Add("Items");
return yields;
}
private static string GetTooltipStyle(StarSystemData system)
{
var posX = system.X / 10.0;
var posY = system.Y / 10.0;
string transform;
if (posX > 65.0)
{
if (posY < 20.0)
{
transform = "translate(calc(-100% - 16px), 0%)";
}
else if (posY > 80.0)
{
transform = "translate(calc(-100% - 16px), -100%)";
}
else
{
transform = "translate(calc(-100% - 16px), -50%)";
}
}
else if (posX < 35.0)
{
if (posY < 20.0)
{
transform = "translate(16px, 0%)";
}
else if (posY > 80.0)
{
transform = "translate(16px, -100%)";
}
else
{
transform = "translate(16px, -50%)";
}
}
else
{
if (posY > 50.0)
{
transform = "translate(-50%, calc(-100% - 16px))";
}
else
{
transform = "translate(-50%, 16px)";
}
}
return $"left: {SvgNumber(posX)}%; top: {SvgNumber(posY)}%; transform: {transform};";
}
private static string GetOwnerCssClass(string owner) => owner switch
{
StarSystemOwners.EdenAccord => "owner-eden",
StarSystemOwners.KeeperCouncil => "owner-keeper",
StarSystemOwners.ObsidianOrder => "owner-obsidian",
_ => "owner-eden"
};
private static string CategoryBadgeClass(StarSystemCategory category) => category switch
{
StarSystemCategory.HomeworldSystem => "badge-homeworld",
StarSystemCategory.CoreSystem => "badge-core",
StarSystemCategory.SideSystem => "badge-side",
_ => "badge-side"
};
private static string OwnerBadgeClass(string owner) => owner switch
{
StarSystemOwners.EdenAccord => "badge-eden",
StarSystemOwners.KeeperCouncil => "badge-keeper",
StarSystemOwners.ObsidianOrder => "badge-obsidian",
_ => "badge-side"
};
private static double GetNodeRadius(StarSystemCategory category) => category switch
{
StarSystemCategory.HomeworldSystem => 7.0,
StarSystemCategory.CoreSystem => 6.0,
_ => 4.5
};
private static string SvgNumber(double value) => value.ToString(CultureInfo.InvariantCulture);
}