Vibe json data export

This commit is contained in:
2026-09-30 01:12:08 -04:00
parent 3d5d1d1acd
commit 25506b4b87
8 changed files with 464 additions and 226 deletions
+123
View File
@@ -0,0 +1,123 @@
---
sessionId: session-260929-233614-1hed
---
# Requirements
### Overview & Goals
Add a "Download JSON" button to the interactive Galaxy Map in `ZeroSpace.Web` that exports all data powering the galaxy map into a structured, downloadable JSON file (`galaxy-map-data.json`). The exported data will include all planetary systems with their full metadata and Markdown bodies, system masteries with their bonuses and notes, and planetary mastery items.
### Scope
- **In Scope**:
- A client-side browser file download utility via Blazor `IJSRuntime`.
- Aggregation and serialization of `StarSystems.All` (including all markdown notes in `Body`), `SystemMasteries.All`, and `PlanetaryMasteryItems.All`.
- A "Download JSON" button styled with the project's Sci-Fi theme in `ZeroSpace.Web/Pages/Galaxy.razor`.
- **Out of Scope**:
- Backend API changes (the export is generated client-side from the static/in-memory data models already present in Blazor WebAssembly).
- Editing or mutating star system data.
### User Stories
- **As a player / community developer**, I want to download all galaxy map data (including star systems, hyperlane connections, occupation buffs, masteries, and markdown notes) in JSON format from the Galaxy Map page so that I can analyze the data offline, create custom tooling, or reference game lore.
### Functional Requirements
1. **Download Action**: Clicking the "Download JSON" button generates and initiates a browser download of `galaxy-map-data.json`.
2. **Star System Data & Markdown**: The JSON output must contain all star systems from `StarSystems.All` including ID, name, category, owner, yield flags, occupation buff, system mastery, connected systems, coordinates, and full markdown text (`Body`).
3. **Mastery Data**: The JSON output must contain all system masteries from `SystemMasteries.All` (levels, available systems, stats, and markdown body) and planetary mastery items from `PlanetaryMasteryItems.All`.
4. **JSON Formatting**: The output JSON must be well-formatted (indented, camelCase property naming, string representations for enums).
5. **UI Integration**: The button must be positioned intuitively in the Galaxy header / control bar matching the existing glassmorphic Sci-Fi styling.
### Non-Functional Requirements
- **Performance**: Export generation and download initiation should take < 50ms without blocking UI interactions.
- **Offline / WebAssembly Compatibility**: The export must function entirely within the client-side Blazor WebAssembly runtime without requiring server endpoints.
# Technical Design
### Current Implementation
- `ZeroSpace.Web/Pages/Galaxy.razor`: The interactive galaxy map page displaying star systems, hyperlanes, and faction occupation details using `StarSystems.All` and `SystemMasteries.All`.
- `ZeroSpace.Model/StarSystemData.cs` & `StarSystems.g.cs`: Contains `StarSystemData` records where `Body` contains the planetary system Markdown notes.
- `ZeroSpace.Model/SystemMasteryData.cs` & `SystemMasteries.g.cs`: Contains `SystemMasteryData` records with mastery levels, system associations, stats, and markdown `Body`.
- `ZeroSpace.Model/PlanetaryMasteryItemData.cs` & `PlanetaryMasteryItems.g.cs`: Contains `PlanetaryMasteryItemData` records for mastery items.
### Key Decisions
1. **Client-Side Export**: Use Blazor WebAssembly's `IJSRuntime` with a lightweight browser Blob URL helper to trigger file download instantly in memory without backend roundtrips.
2. **Unified Data Structure**: Group data under top-level properties (`systems`, `masteries`, `planetaryMasteryItems`, `exportedAt`) for easy consumption by external tools.
3. **Formatting**: Use `System.Text.Json` with `JsonSerializerOptions` configured for `WriteIndented = true`, `PropertyNamingPolicy = JsonNamingPolicy.CamelCase`, and `JsonStringEnumConverter`.
### Proposed Changes
1. **File Download Helper**:
- Add `ZeroSpace.Web/wwwroot/js/fileDownload.js` containing `downloadFileFromText(fileName, contentType, textContent)`.
- Include script reference in `ZeroSpace.Web/wwwroot/index.html`.
2. **Export Model**:
- Define `GalaxyMapExportData` record containing `IReadOnlyList<StarSystemData> Systems`, `IReadOnlyList<SystemMasteryData> Masteries`, `IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems`, and `DateTimeOffset ExportedAt`.
3. **Galaxy Page Update**:
- Add a "Download JSON" button to the header action bar in `Galaxy.razor`.
- Add `DownloadJsonAsync()` method in `@code` section that builds `GalaxyMapExportData`, serializes to JSON, and calls the JS helper.
### Data Models / Contracts
```csharp
public sealed record GalaxyMapExportData(
DateTimeOffset ExportedAt,
int TotalSystems,
int TotalMasteries,
IReadOnlyList<StarSystemData> Systems,
IReadOnlyList<SystemMasteryData> Masteries,
IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems);
```
### Architecture Diagram
```mermaid
graph LR
User[User clicks Download JSON] --> Razor[Galaxy.razor]
Razor --> Aggregator[Aggregate StarSystems, Masteries, Items]
Aggregator --> Serializer[System.Text.Json Serializer]
Serializer --> JSInterop[IJSRuntime downloadFileFromText]
JSInterop --> Browser[Browser saves galaxy-map-data.json]
```
### File Structure
- `ZeroSpace.Web/wwwroot/js/fileDownload.js` *(new)*
- `ZeroSpace.Web/wwwroot/index.html` *(modified to include script tag)*
- `ZeroSpace.Web/Models/GalaxyMapExportData.cs` *(new)*
- `ZeroSpace.Web/Pages/Galaxy.razor` *(modified to add download button and handler)*
- `ZeroSpace.Web/Pages/Galaxy.razor.css` *(optional style tweaks for button layout)*
### Risks
- **Large Payload / Memory**: The total size of all systems and markdown is < 500 KB, which fits easily in browser memory for Blob generation.
# Testing
### Validation Approach
- Verify solution builds with 0 errors via `build_solution_start` and `build_solution_state`.
- Verify the serialized JSON structure against required fields (star system metadata, markdown `Body`, masteries, levels, stats).
- Verify the button UI placement, styling, and JSInterop invocation logic.
### Key Scenarios
1. **Download Button Interaction**: User clicks the "Download JSON" button on `/galaxy`.
- Expected: `downloadFileFromText` JSInterop is called with `galaxy-map-data.json`, `application/json`, and the formatted JSON string.
2. **JSON Content Completeness**:
- Every star system from `StarSystems.All` is present in the `systems` array.
- The markdown `body` field of each star system is included.
- Every mastery from `SystemMasteries.All` is present in the `masteries` array with levels and stats.
- Planetary mastery items from `PlanetaryMasteryItems.All` are included.
### Edge Cases
- Systems with empty vs populated markdown `Body` serialize properly as strings (`""` or markdown text).
- Enum values (`StarSystemCategory`) serialize cleanly as strings (e.g. `"homeworldSystem"`, `"coreSystem"`, `"sideSystem"` or PascalCase).
# Delivery Steps
### ✓ Step 1: Implement file download JSInterop helper and export serialization model
Create the file download JSInterop utility and the galaxy map data export serialization contract.
- Add a JavaScript file download helper (`ZeroSpace.Web/wwwroot/js/fileDownload.js` or equivalent) that accepts filename, content type, and content/bytes, creating an object URL blob and triggering a download anchor click.
- Reference the script in `ZeroSpace.Web/wwwroot/index.html`.
- Define an export container record (e.g., `GalaxyMapExportData`) in `ZeroSpace.Web` / `ZeroSpace.Model` that aggregates star systems (with full metadata and markdown `Body`), system masteries (with levels, stats, available systems, and `Body`), and planetary mastery items.
- Implement a JSON serialization helper/service configured with `JsonSerializerOptions` (`WriteIndented = true`, camelCase naming, `JsonStringEnumConverter`) for clean formatting.
### ✓ Step 2: Integrate Download JSON button and handler into the Galaxy Map page
Add the Download JSON button to the Galaxy Map page and wire up the export trigger.
- Add the "Download JSON" button in the top action bar of `ZeroSpace.Web/Pages/Galaxy.razor` next to the map legend/stats controls.
- Apply consistent Sci-Fi design system styling (`btn-scifi-outline` with appropriate icon/label) in `Galaxy.razor` and `Galaxy.razor.css`.
- Inject `IJSRuntime` in `Galaxy.razor` and implement the `DownloadGalaxyJsonAsync` event handler to generate the export payload and trigger the client-side download named `galaxy-map-data.json`.
- Build the solution and verify clean compilation without errors or warnings.
@@ -0,0 +1,19 @@
namespace ZeroSpace.Model;
public sealed record GalaxyMapExportData(
DateTimeOffset ExportedAt,
int TotalSystems,
int TotalMasteries,
IReadOnlyList<StarSystemData> Systems,
IReadOnlyList<SystemMasteryData> Masteries,
IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems)
{
public static GalaxyMapExportData CreateDefault() =>
new(
DateTimeOffset.UtcNow,
StarSystems.All.Count,
SystemMasteries.All.Count,
StarSystems.All,
SystemMasteries.All,
ZeroSpace.Model.PlanetaryMasteryItems.All);
}
+268 -223
View File
@@ -2,6 +2,8 @@
@using System.Globalization @using System.Globalization
@using System.Text.RegularExpressions @using System.Text.RegularExpressions
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IJSRuntime Js
@inject IGalaxyMapExportSerializer ExportSerializer
<PageTitle>Galaxy Map</PageTitle> <PageTitle>Galaxy Map</PageTitle>
@@ -9,7 +11,8 @@
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-3"> <div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-3">
<div> <div>
<h1 class="h2 mb-1 text-light fw-bold">Galaxy Map</h1> <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> <p class="text-secondary mb-0 small">Interactive tactical star chart displaying all planetary systems and
hyperlane routes.</p>
</div> </div>
<div class="d-flex flex-wrap align-items-center gap-3"> <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="map-legend d-flex flex-wrap align-items-center gap-3 px-3 py-2 rounded">
@@ -34,6 +37,19 @@
<span class="mx-1">•</span> <span class="mx-1">•</span>
<span><strong>@_connections.Count</strong> Hyperlanes</span> <span><strong>@_connections.Count</strong> Hyperlanes</span>
</div> </div>
<button class="btn btn-sm btn-scifi-outline d-inline-flex align-items-center gap-2 px-3 py-2"
@onclick="DownloadGalaxyJsonAsync"
disabled="@_isExporting"
title="Download galaxy map data as JSON">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"
aria-hidden="true">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
<span>@(_isExporting ? "Exporting..." : "Download JSON")</span>
</button>
</div> </div>
</div> </div>
@@ -41,7 +57,8 @@
<div class="filter-bar zs-glass-card p-3 mb-3 d-flex flex-wrap align-items-center gap-3"> <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"> <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> <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"> <select class="form-select form-select-sm scifi-select" @bind="_selectedCategory"
@bind:after="ApplyFilters">
<option value="">All Types</option> <option value="">All Types</option>
<option value="@StarSystemCategory.HomeworldSystem">Homeworld System</option> <option value="@StarSystemCategory.HomeworldSystem">Homeworld System</option>
<option value="@StarSystemCategory.CoreSystem">Core System</option> <option value="@StarSystemCategory.CoreSystem">Core System</option>
@@ -69,7 +86,7 @@
<option value="Influence">Higher Influence</option> <option value="Influence">Higher Influence</option>
<option value="Credits">Higher Credits</option> <option value="Credits">Higher Credits</option>
<option value="Energy">Higher Energy</option> <option value="Energy">Higher Energy</option>
<option value="Items">Higher Items</option> <option value="Items">Higher Items (In current item, items only attainable in shops)</option>
</select> </select>
</div> </div>
@@ -83,263 +100,272 @@
<div class="galaxy-layout"> <div class="galaxy-layout">
<div class="galaxy-map-container" @onmouseleave="OnContainerMouseLeave"> <div class="galaxy-map-container" @onmouseleave="OnContainerMouseLeave">
<svg viewBox="0 0 1000 1000" class="galaxy-svg" preserveAspectRatio="xMidYMid meet" <svg viewBox="0 0 1000 1000" class="galaxy-svg" preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<!-- Background Grid Pattern --> <!-- Background Grid Pattern -->
<pattern id="space-grid" width="50" height="50" patternUnits="userSpaceOnUse"> <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"/> <path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(56, 189, 248, 0.04)" stroke-width="1"/>
</pattern> </pattern>
<!-- Center Core Glow --> <!-- Center Core Glow -->
<radialGradient id="galaxy-core-glow" cx="50%" cy="50%" r="50%"> <radialGradient id="galaxy-core-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(56, 189, 248, 0.08)"/> <stop offset="0%" stop-color="rgba(56, 189, 248, 0.08)"/>
<stop offset="60%" stop-color="rgba(16, 185, 129, 0.03)"/> <stop offset="60%" stop-color="rgba(16, 185, 129, 0.03)"/>
<stop offset="100%" stop-color="rgba(13, 17, 23, 0)"/> <stop offset="100%" stop-color="rgba(13, 17, 23, 0)"/>
</radialGradient> </radialGradient>
<!-- Glow filters for Owners / Factions --> <!-- Glow filters for Owners / Factions -->
<filter id="glow-eden" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow-eden" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/> <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge> <feMerge>
<feMergeNode in="blur"/> <feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/> <feMergeNode in="SourceGraphic"/>
</feMerge> </feMerge>
</filter> </filter>
<filter id="glow-keeper" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow-keeper" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/> <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge> <feMerge>
<feMergeNode in="blur"/> <feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/> <feMergeNode in="SourceGraphic"/>
</feMerge> </feMerge>
</filter> </filter>
<filter id="glow-obsidian" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow-obsidian" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/> <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
<feMerge> <feMerge>
<feMergeNode in="blur"/> <feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/> <feMergeNode in="SourceGraphic"/>
</feMerge> </feMerge>
</filter> </filter>
</defs> </defs>
<!-- Map Background & Grid --> <!-- Map Background & Grid -->
<rect width="1000" height="1000" class="map-bg"/> <rect width="1000" height="1000" class="map-bg"/>
<rect width="1000" height="1000" fill="url(#space-grid)"/> <rect width="1000" height="1000" fill="url(#space-grid)"/>
<rect width="1000" height="1000" fill="url(#galaxy-core-glow)"/> <rect width="1000" height="1000" fill="url(#galaxy-core-glow)"/>
<!-- Connection Lines (Hyperlanes) --> <!-- Connection Lines (Hyperlanes) -->
<g class="hyperlanes"> <g class="hyperlanes">
@foreach (var edge in _connections) @foreach (var edge in _connections)
{ {
var isFromMatch = _matchingSystemIds.Contains(edge.From.Id); var isFromMatch = _matchingSystemIds.Contains(edge.From.Id);
var isToMatch = _matchingSystemIds.Contains(edge.To.Id); var isToMatch = _matchingSystemIds.Contains(edge.To.Id);
var isFilterDimmed = HasActiveFilters && (!isFromMatch || !isToMatch); var isFilterDimmed = HasActiveFilters && (!isFromMatch || !isToMatch);
var isHoverHighlighted = _hoveredSystem is not null && var isHoverHighlighted = _hoveredSystem is not null &&
(string.Equals(edge.From.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase) || (string.Equals(edge.From.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(edge.To.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase)); string.Equals(edge.To.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase));
var isHoverDimmed = _hoveredSystem is not null && !isHoverHighlighted; var isHoverDimmed = _hoveredSystem is not null && !isHoverHighlighted;
var lineClass = isHoverHighlighted ? "hyperlane-line hyperlane-highlighted" : var lineClass = isHoverHighlighted ? "hyperlane-line hyperlane-highlighted" :
(isHoverDimmed || isFilterDimmed) ? "hyperlane-line hyperlane-dimmed" : "hyperlane-line"; (isHoverDimmed || isFilterDimmed) ? "hyperlane-line hyperlane-dimmed" : "hyperlane-line";
<line x1="@SvgNumber(edge.From.X)" <line x1="@SvgNumber(edge.From.X)"
y1="@SvgNumber(edge.From.Y)" y1="@SvgNumber(edge.From.Y)"
x2="@SvgNumber(edge.To.X)" x2="@SvgNumber(edge.To.X)"
y2="@SvgNumber(edge.To.Y)" y2="@SvgNumber(edge.To.Y)"
class="@lineClass"/> class="@lineClass"/>
} }
</g> </g>
<!-- Planetary Nodes --> <!-- Planetary Nodes -->
<g class="planetary-nodes"> <g class="planetary-nodes">
@foreach (var system in _systems) @foreach (var system in _systems)
{ {
var ownerClass = GetOwnerCssClass(system.Owner); var ownerClass = GetOwnerCssClass(system.Owner);
var radius = GetNodeRadius(system.SystemCategory); var radius = GetNodeRadius(system.SystemCategory);
var isMatch = _matchingSystemIds.Contains(system.Id); var isMatch = _matchingSystemIds.Contains(system.Id);
var isHovered = _hoveredSystem is not null && string.Equals(system.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase); var isHovered = _hoveredSystem is not null && string.Equals(system.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase);
var isNeighbor = _hoveredConnectedIds.Contains(system.Id); var isNeighbor = _hoveredConnectedIds.Contains(system.Id);
var isDimmed = (_hoveredSystem is not null && !isHovered && !isNeighbor) || (HasActiveFilters && !isMatch); var isDimmed = (_hoveredSystem is not null && !isHovered && !isNeighbor) || (HasActiveFilters && !isMatch);
var stateClass = isHovered ? "is-hovered" : var stateClass = isHovered ? "is-hovered" :
isNeighbor ? "is-neighbor" : isNeighbor ? "is-neighbor" :
isDimmed ? "is-dimmed" : isDimmed ? "is-dimmed" :
(HasActiveFilters && isMatch ? "is-filter-matched" : ""); (HasActiveFilters && isMatch ? "is-filter-matched" : "");
<g class="node-group @ownerClass @stateClass" <g class="node-group @ownerClass @stateClass"
transform="translate(@SvgNumber(system.X), @SvgNumber(system.Y))" transform="translate(@SvgNumber(system.X), @SvgNumber(system.Y))"
@onclick="() => OnNodeClick(system.Id)" @onclick="() => OnNodeClick(system.Id)"
@onmouseenter="() => OnNodeMouseEnter(system)" @onmouseenter="() => OnNodeMouseEnter(system)"
@onmouseleave="() => OnNodeMouseLeave(system)" @onmouseleave="() => OnNodeMouseLeave(system)"
@onfocus="() => OnNodeFocus(system)" @onfocus="() => OnNodeFocus(system)"
@onblur="OnNodeBlur" @onblur="OnNodeBlur"
@onkeydown="e => OnNodeKeyDown(e, system.Id)" @onkeydown="e => OnNodeKeyDown(e, system.Id)"
tabindex="0" tabindex="0"
role="button" role="button"
aria-label="@system.Name (@system.SystemCategory.ToDisplayName(), @system.Owner)"> aria-label="@system.Name (@system.SystemCategory.ToDisplayName(), @system.Owner)">
<!-- Outer aura/glow circle --> <!-- Outer aura/glow circle -->
<circle r="@SvgNumber(radius + 4)" class="node-aura"/> <circle r="@SvgNumber(radius + 4)" class="node-aura"/>
<!-- Main Node Circle --> <!-- Main Node Circle -->
<circle r="@SvgNumber(radius)" class="node-circle"/> <circle r="@SvgNumber(radius)" class="node-circle"/>
<!-- Center core dot for homeworlds and core systems --> <!-- Center core dot for homeworlds and core systems -->
@if (system.SystemCategory != StarSystemCategory.SideSystem) @if (system.SystemCategory != StarSystemCategory.SideSystem)
{ {
<circle r="@SvgNumber(radius * 0.4)" class="node-core-dot"/> <circle r="@SvgNumber(radius * 0.4)" class="node-core-dot"/>
} }
<!-- System Name Label --> <!-- System Name Label -->
<text y="@SvgNumber(radius + 10)" class="node-label" text-anchor="middle">@system.Name</text> <text y="@SvgNumber(radius + 10)" class="node-label"
</g> text-anchor="middle">@system.Name</text>
} </g>
</g> }
</svg> </g>
</svg>
<!-- Floating Glassmorphism Hover Card --> <!-- Floating Glassmorphism Hover Card -->
@if (_hoveredSystem is not null) @if (_hoveredSystem is not null)
{ {
var ownerClass = GetOwnerCssClass(_hoveredSystem.Owner); var ownerClass = GetOwnerCssClass(_hoveredSystem.Owner);
var higherYields = GetHigherYields(_hoveredSystem); var higherYields = GetHigherYields(_hoveredSystem);
<div class="planet-hover-card @ownerClass" <div class="planet-hover-card @ownerClass"
style="@GetTooltipStyle(_hoveredSystem)" style="@GetTooltipStyle(_hoveredSystem)"
role="tooltip" role="tooltip"
aria-live="polite"> aria-live="polite">
<div class="card-header-row d-flex align-items-center justify-content-between mb-2"> <div class="card-header-row d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="system-owner-indicator"></span> <span class="system-owner-indicator"></span>
<span class="system-title fw-bold text-light">@_hoveredSystem.Name</span> <span class="system-title fw-bold text-light">@_hoveredSystem.Name</span>
</div> </div>
<span class="badge @CategoryBadgeClass(_hoveredSystem.SystemCategory)">@_hoveredSystem.SystemCategory.ToDisplayName()</span> <span
</div> class="badge @CategoryBadgeClass(_hoveredSystem.SystemCategory)">@_hoveredSystem.SystemCategory.ToDisplayName()</span>
<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>
<div class="detail-row"> <div class="card-details-grid">
<span class="detail-label">Occupation Buff</span> <div class="detail-row">
<span class="detail-value @(_hoveredSystem.OccupationBuff != null ? "text-buff" : "text-secondary")"> <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 ?? "—") @(_hoveredSystem.OccupationBuff ?? "—")
</span> </span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">System Mastery</span> <span class="detail-label">System Mastery</span>
<span class="detail-value @(_hoveredSystem.SystemMastery != null ? "text-mastery" : "text-secondary")"> <span
class="detail-value @(_hoveredSystem.SystemMastery != null ? "text-mastery" : "text-secondary")">
@(_hoveredSystem.SystemMastery ?? "—") @(_hoveredSystem.SystemMastery ?? "—")
</span> </span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Yield Bonuses</span> <span class="detail-label">Yield Bonuses</span>
<div class="yield-badges d-flex flex-wrap gap-1 mt-1"> <div class="yield-badges d-flex flex-wrap gap-1 mt-1">
@if (higherYields.Count == 0) @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> <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> </div>
<div class="detail-row"> <div class="card-footer-hint mt-2 pt-2 border-top border-secondary border-opacity-25 text-center">
<div class="d-flex align-items-center justify-content-between"> <span class="hint-text small">Click system to view full details →</span>
<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> </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> </div>
@if (!_drawerCollapsed) <aside class="faction-drawer zs-glass-card @(_drawerCollapsed ? "is-collapsed" : "")"
{ aria-label="Faction occupational bonuses">
<div class="drawer-body"> <div class="drawer-header d-flex align-items-center justify-content-between gap-2">
<div class="faction-picker" role="radiogroup" aria-label="Select faction"> <div>
@foreach (var owner in StarSystemOwners.All) <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>
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>
<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>
<div class="drawer-section"> @if (!_drawerCollapsed)
<h3 class="drawer-section-title">Occupational bonuses</h3> {
<p class="drawer-summary text-secondary"> <div class="drawer-body">
<strong class="text-light">@FactionSystems.Count</strong> systems owned by <div class="faction-picker" role="radiogroup" aria-label="Select faction">
<strong class="text-light">@ShortFactionName(_selectedFaction)</strong> @foreach (var owner in StarSystemOwners.All)
</p> {
@if (FactionBonuses.Count == 0) var isActive = string.Equals(owner, _selectedFaction, StringComparison.Ordinal);
{ <button role="radio" aria-checked="@isActive"
<p class="text-secondary small mb-0">No occupation buffs recorded for this faction's systems.</p> class="faction-pick @(isActive ? "is-active " + GetOwnerCssClass(owner) : "")"
} @onclick="() => SelectFaction(owner)">
else <span class="legend-dot @FactionDotClass(owner)"></span>
{ <span class="faction-pick-name">@ShortFactionName(owner)</span>
<ul class="bonus-list list-unstyled mb-0"> <span class="faction-pick-count">@FactionSystemCount(owner)</span>
@foreach (var bonus in FactionBonuses.OrderBy(b => b.Label)) </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.OrderBy(b => b.Label))
{
<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 class="bonus-row"> <li>
<span class="bonus-total">@FormatBonusTotal(bonus)</span> <button class="planet-row" @onclick="() => OnNodeClick(system.Id)"
<span class="bonus-label">@bonus.Label</span> title="View @system.Name details">
<span class="bonus-count">@bonus.PlanetCount @(bonus.PlanetCount == 1 ? "planet" : "planets")</span> <span class="planet-name">@system.Name</span>
<span class="planet-buff">@(system.OccupationBuff ?? "—")</span>
</button>
</li> </li>
} }
</ul> </ul>
} </div>
</div> </div>
}
<div class="drawer-section"> </aside>
<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>
</div> </div>
@@ -362,6 +388,7 @@
// Faction picker: one of the three factions is always selected. // Faction picker: one of the three factions is always selected.
private string _selectedFaction = StarSystemOwners.EdenAccord; private string _selectedFaction = StarSystemOwners.EdenAccord;
private bool _drawerCollapsed; private bool _drawerCollapsed;
private bool _isExporting;
private bool HasActiveFilters => private bool HasActiveFilters =>
_selectedCategory.HasValue || _selectedCategory.HasValue ||
@@ -386,6 +413,7 @@
_matchingSystemIds.Add(system.Id); _matchingSystemIds.Add(system.Id);
} }
} }
_matchingCount = _matchingSystemIds.Count; _matchingCount = _matchingSystemIds.Count;
} }
@@ -434,6 +462,23 @@
ApplyFilters(); ApplyFilters();
} }
private async Task DownloadGalaxyJsonAsync()
{
if (_isExporting) return;
try
{
_isExporting = true;
var exportData = GalaxyMapExportData.CreateDefault();
var json = ExportSerializer.Serialize(exportData);
await Js.InvokeVoidAsync("downloadFileFromText", "galaxy-map-data.json", "application/json", json);
}
finally
{
_isExporting = false;
}
}
// ----- faction bonus drawer ----- // ----- faction bonus drawer -----
public sealed record FactionBonus(string Label, bool IsPercent, double Total, int PlanetCount); public sealed record FactionBonus(string Label, bool IsPercent, double Total, int PlanetCount);
+5 -3
View File
@@ -12,9 +12,9 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client => builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
{ {
var apiBaseUrl = builder.Configuration["services:apiservice:https:0"] var apiBaseUrl = builder.Configuration["services:apiservice:https:0"]
?? builder.Configuration["services:apiservice:http:0"] ?? builder.Configuration["services:apiservice:http:0"]
?? builder.Configuration["ApiServiceUrl"] ?? builder.Configuration["ApiServiceUrl"]
?? builder.HostEnvironment.BaseAddress; ?? builder.HostEnvironment.BaseAddress;
if (!apiBaseUrl.EndsWith('/')) if (!apiBaseUrl.EndsWith('/'))
{ {
@@ -24,4 +24,6 @@ builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); });
builder.Services.AddSingleton<IGalaxyMapExportSerializer, GalaxyMapExportSerializer>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZeroSpace.Model;
namespace ZeroSpace.Web.Services;
public sealed class GalaxyMapExportSerializer : IGalaxyMapExportSerializer
{
public static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions();
public static JsonSerializerOptions CreateDefaultOptions()
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
public string Serialize(GalaxyMapExportData data) => SerializeData(data);
public static string SerializeData(GalaxyMapExportData data, JsonSerializerOptions? options = null)
{
return JsonSerializer.Serialize(data, options ?? DefaultOptions);
}
}
@@ -0,0 +1,8 @@
using ZeroSpace.Model;
namespace ZeroSpace.Web.Services;
public interface IGalaxyMapExportSerializer
{
string Serialize(GalaxyMapExportData data);
}
@@ -29,6 +29,8 @@
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
<script src="js/fileDownload.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>
@@ -0,0 +1,11 @@
window.downloadFileFromText = function (fileName, contentType, textContent) {
const blob = new Blob([textContent], { type: contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName || 'download.json';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
};