Refactor modular startup and viewer ship debugging
This commit is contained in:
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||
import type {
|
||||
FactionSnapshot,
|
||||
InventoryEntry,
|
||||
PlanetSnapshot,
|
||||
ResourceNodeDelta,
|
||||
ResourceNodeSnapshot,
|
||||
@@ -149,6 +150,16 @@ interface SystemSummaryVisual {
|
||||
anchor: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface HistoryWindowState {
|
||||
id: string;
|
||||
target: Selectable;
|
||||
root: HTMLElement;
|
||||
titleEl: HTMLHeadingElement;
|
||||
bodyEl: HTMLDivElement;
|
||||
copyButtonEl: HTMLButtonElement;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||
local: 900,
|
||||
system: 3200,
|
||||
@@ -204,6 +215,7 @@ export class GameViewer {
|
||||
private readonly networkPanelEl: HTMLDivElement;
|
||||
private readonly performancePanelEl: HTMLDivElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private readonly historyLayerEl: HTMLDivElement;
|
||||
private readonly marqueeEl: HTMLDivElement;
|
||||
private readonly hoverLabelEl: HTMLDivElement;
|
||||
|
||||
@@ -241,6 +253,12 @@ export class GameViewer {
|
||||
private suppressClickSelection = false;
|
||||
private activeSystemId?: string;
|
||||
private followedShipId?: string;
|
||||
private readonly historyWindows: HistoryWindowState[] = [];
|
||||
private historyWindowCounter = 0;
|
||||
private historyWindowZCounter = 10;
|
||||
private historyWindowDragId?: string;
|
||||
private historyWindowDragPointerId?: number;
|
||||
private historyWindowDragOffset = new THREE.Vector2();
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
@@ -286,7 +304,8 @@ export class GameViewer {
|
||||
</aside>
|
||||
<div class="error-strip" hidden></div>
|
||||
</div>
|
||||
<section class="faction-strip"></section>
|
||||
<div class="history-layer"></div>
|
||||
<section class="ship-strip"></section>
|
||||
<div class="marquee-box"></div>
|
||||
<div class="hover-label" hidden></div>
|
||||
`;
|
||||
@@ -297,10 +316,11 @@ export class GameViewer {
|
||||
this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement;
|
||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||
this.factionStripEl = hud.querySelector(".ship-strip") as HTMLDivElement;
|
||||
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
||||
this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement;
|
||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||
this.historyLayerEl = hud.querySelector(".history-layer") as HTMLDivElement;
|
||||
this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement;
|
||||
this.hoverLabelEl = hud.querySelector(".hover-label") as HTMLDivElement;
|
||||
|
||||
@@ -313,6 +333,11 @@ export class GameViewer {
|
||||
this.renderer.domElement.addEventListener("click", this.onClick);
|
||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||
this.factionStripEl.addEventListener("click", this.onShipStripClick);
|
||||
this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick);
|
||||
this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown);
|
||||
window.addEventListener("pointermove", this.onHistoryWindowPointerMove);
|
||||
window.addEventListener("pointerup", this.onHistoryWindowPointerUp);
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
@@ -671,17 +696,34 @@ export class GameViewer {
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildFactions(factions: FactionSnapshot[]) {
|
||||
this.factionStripEl.innerHTML = factions
|
||||
.map((faction) => `
|
||||
<article class="faction-card">
|
||||
<div class="swatch" style="background:${faction.color}"></div>
|
||||
<div>
|
||||
<h3>${faction.label}</h3>
|
||||
<p>Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}</p>
|
||||
</div>
|
||||
</article>
|
||||
`)
|
||||
private rebuildFactions(_factions: FactionSnapshot[]) {
|
||||
if (!this.world) {
|
||||
this.factionStripEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const ships = [...this.world.ships.values()]
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
|
||||
this.factionStripEl.innerHTML = ships
|
||||
.map((ship) => {
|
||||
const fuel = this.inventoryAmount(ship.inventory, "gas");
|
||||
const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id;
|
||||
const isFollowed = this.followedShipId === ship.id;
|
||||
return `
|
||||
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
|
||||
<div class="ship-card-header">
|
||||
<h3>${ship.label}</h3>
|
||||
<span class="ship-card-badge">${ship.shipClass}</span>
|
||||
</div>
|
||||
<p>${ship.systemId}</p>
|
||||
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
|
||||
<p>State ${ship.state}</p>
|
||||
<p>Order ${ship.orderKind ?? "none"}</p>
|
||||
<button type="button" class="ship-card-history-button" data-history-ship-id="${ship.id}">Open History</button>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -690,6 +732,7 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshHistoryWindows();
|
||||
this.updateSystemPanel();
|
||||
|
||||
if (this.selectedItems.length === 0) {
|
||||
@@ -722,14 +765,16 @@ export class GameViewer {
|
||||
}
|
||||
const parent = this.describeSelectionParent(selected);
|
||||
this.detailTitleEl.textContent = ship.label;
|
||||
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
||||
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
|
||||
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
|
||||
<p>Inventory ${this.formatInventory(ship.inventory)}</p>
|
||||
<p>Velocity ${this.formatVector(ship.localVelocity)}</p>
|
||||
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
|
||||
<p class="history">${ship.history.join("<br>")}</p>
|
||||
<p>History available from the ship card list.</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
@@ -744,8 +789,9 @@ export class GameViewer {
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${station.category} · ${station.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
||||
<p class="history">${this.renderRecentEvents("station", station.id)}</p>
|
||||
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
||||
<p>Inventory ${this.formatInventory(station.inventory)}</p>
|
||||
<p>History available in the separate history window.</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
@@ -795,6 +841,20 @@ export class GameViewer {
|
||||
`;
|
||||
}
|
||||
|
||||
private formatInventory(entries: InventoryEntry[]): string {
|
||||
if (entries.length === 0) {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`)
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
private inventoryAmount(entries: InventoryEntry[], itemId: string): number {
|
||||
return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0;
|
||||
}
|
||||
|
||||
private render() {
|
||||
const frameStartedAtMs = performance.now();
|
||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
||||
@@ -1925,6 +1985,269 @@ export class GameViewer {
|
||||
this.updatePanels();
|
||||
};
|
||||
|
||||
private onShipStripClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
|
||||
const historyShipId = historyButton?.dataset.historyShipId;
|
||||
if (historyShipId) {
|
||||
this.openHistoryWindow({ kind: "ship", id: historyShipId });
|
||||
return;
|
||||
}
|
||||
|
||||
const card = target.closest<HTMLElement>("[data-ship-id]");
|
||||
const shipId = card?.dataset.shipId;
|
||||
if (!shipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedItems = [{ kind: "ship", id: shipId }];
|
||||
this.syncFollowStateFromSelection();
|
||||
this.updatePanels();
|
||||
};
|
||||
|
||||
private openHistoryWindow(target: Selectable) {
|
||||
const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
|
||||
if (existing) {
|
||||
this.bringHistoryWindowToFront(existing);
|
||||
this.refreshHistoryWindows();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `history-${++this.historyWindowCounter}`;
|
||||
const root = document.createElement("aside");
|
||||
root.className = "history-window";
|
||||
root.dataset.historyWindowId = id;
|
||||
root.innerHTML = `
|
||||
<div class="history-window-header">
|
||||
<h2 class="history-window-title">History</h2>
|
||||
<div class="history-window-actions">
|
||||
<button type="button" class="history-window-copy">Copy</button>
|
||||
<button type="button" class="history-window-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-window-body">No history selected.</div>
|
||||
`;
|
||||
|
||||
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
|
||||
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
|
||||
root.style.left = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerWidth - 580)))}px`;
|
||||
root.style.top = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerHeight - 420)))}px`;
|
||||
|
||||
const windowState: HistoryWindowState = {
|
||||
id,
|
||||
target,
|
||||
root,
|
||||
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
|
||||
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
|
||||
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
|
||||
text: "",
|
||||
};
|
||||
|
||||
this.historyWindows.push(windowState);
|
||||
this.historyLayerEl.append(root);
|
||||
this.bringHistoryWindowToFront(windowState);
|
||||
this.refreshHistoryWindows();
|
||||
}
|
||||
|
||||
private refreshHistoryWindows() {
|
||||
if (!this.world) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const windowState of [...this.historyWindows]) {
|
||||
if (windowState.target.kind === "ship") {
|
||||
const ship = this.world.ships.get(windowState.target.id);
|
||||
if (!ship) {
|
||||
this.destroyHistoryWindow(windowState.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
windowState.titleEl.textContent = `${ship.label} History`;
|
||||
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
|
||||
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (windowState.target.kind === "station") {
|
||||
const station = this.world.stations.get(windowState.target.id);
|
||||
if (!station) {
|
||||
this.destroyHistoryWindow(windowState.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
windowState.titleEl.textContent = `${station.label} History`;
|
||||
windowState.text = this.renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
|
||||
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
this.destroyHistoryWindow(windowState.id);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyHistoryWindow(id: string) {
|
||||
const index = this.historyWindows.findIndex((windowState) => windowState.id === id);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [removed] = this.historyWindows.splice(index, 1);
|
||||
removed.root.remove();
|
||||
if (this.historyWindowDragId === id) {
|
||||
this.historyWindowDragId = undefined;
|
||||
this.historyWindowDragPointerId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private onHistoryLayerClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
|
||||
const windowId = windowEl?.dataset.historyWindowId;
|
||||
if (!windowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const copyButton = target.closest(".history-window-copy");
|
||||
if (copyButton) {
|
||||
void this.copyHistoryWindowContent(windowId);
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = target.closest(".history-window-close");
|
||||
if (closeButton) {
|
||||
this.destroyHistoryWindow(windowId);
|
||||
return;
|
||||
}
|
||||
|
||||
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||
if (windowState) {
|
||||
this.bringHistoryWindowToFront(windowState);
|
||||
}
|
||||
};
|
||||
|
||||
private onHistoryLayerPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
|
||||
const windowId = windowEl?.dataset.historyWindowId;
|
||||
if (!windowEl || !windowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||
if (!windowState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bringHistoryWindowToFront(windowState);
|
||||
if (!target.closest(".history-window-header") || target.closest("button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = windowState.root.getBoundingClientRect();
|
||||
this.historyWindowDragId = windowId;
|
||||
this.historyWindowDragPointerId = event.pointerId;
|
||||
this.historyWindowDragOffset.set(event.clientX - bounds.left, event.clientY - bounds.top);
|
||||
windowState.root.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
private onHistoryWindowPointerMove = (event: PointerEvent) => {
|
||||
if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId);
|
||||
if (!windowState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = windowState.root.offsetWidth;
|
||||
const height = windowState.root.offsetHeight;
|
||||
const left = THREE.MathUtils.clamp(event.clientX - this.historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
|
||||
const top = THREE.MathUtils.clamp(event.clientY - this.historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
|
||||
|
||||
windowState.root.style.left = `${left}px`;
|
||||
windowState.root.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
private onHistoryWindowPointerUp = (event: PointerEvent) => {
|
||||
if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId);
|
||||
this.historyWindowDragPointerId = undefined;
|
||||
this.historyWindowDragId = undefined;
|
||||
windowState?.root.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
private async copyHistoryWindowContent(windowId: string) {
|
||||
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||
if (!windowState?.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.copyTextToClipboard(windowState.text);
|
||||
windowState.copyButtonEl.textContent = "Copied";
|
||||
window.setTimeout(() => {
|
||||
windowState.copyButtonEl.textContent = "Copy";
|
||||
}, 1200);
|
||||
} catch {
|
||||
windowState.copyButtonEl.textContent = "Failed";
|
||||
window.setTimeout(() => {
|
||||
windowState.copyButtonEl.textContent = "Copy";
|
||||
}, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand("copy");
|
||||
if (!copied) {
|
||||
throw new Error("execCommand copy failed");
|
||||
}
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private bringHistoryWindowToFront(windowState: HistoryWindowState) {
|
||||
windowState.root.style.zIndex = `${++this.historyWindowZCounter}`;
|
||||
}
|
||||
|
||||
private updateHoverLabel(event: PointerEvent) {
|
||||
if (this.dragMode) {
|
||||
this.hoverLabelEl.hidden = true;
|
||||
|
||||
@@ -77,6 +77,11 @@ export interface ResourceNodeSnapshot {
|
||||
|
||||
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
|
||||
|
||||
export interface InventoryEntry {
|
||||
itemId: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface StationSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -85,8 +90,8 @@ export interface StationSnapshot {
|
||||
localPosition: Vector3Dto;
|
||||
color: string;
|
||||
dockedShips: number;
|
||||
oreStored: number;
|
||||
refinedStock: number;
|
||||
energyStored: number;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
}
|
||||
|
||||
@@ -105,9 +110,9 @@ export interface ShipSnapshot {
|
||||
orderKind: string | null;
|
||||
defaultBehaviorKind: string;
|
||||
controllerTaskKind: string;
|
||||
cargo: number;
|
||||
cargoCapacity: number;
|
||||
cargoItemId: string | null;
|
||||
energyStored: number;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
health: number;
|
||||
history: string[];
|
||||
|
||||
@@ -87,7 +87,7 @@ canvas {
|
||||
.info-panel,
|
||||
.network-panel,
|
||||
.performance-panel,
|
||||
.faction-strip {
|
||||
.ship-strip {
|
||||
backdrop-filter: blur(18px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
@@ -112,7 +112,7 @@ canvas {
|
||||
.topbar h2,
|
||||
.info-panel h2,
|
||||
.info-panel h3,
|
||||
.faction-card h3 {
|
||||
.ship-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,86 @@ canvas {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.history-window {
|
||||
position: absolute;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
width: min(520px, calc(100vw - 40px));
|
||||
height: min(360px, 56vh);
|
||||
min-width: 320px;
|
||||
min-height: 220px;
|
||||
max-width: calc(100vw - 40px);
|
||||
max-height: calc(100vh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(6, 12, 24, 0.9);
|
||||
border: 1px solid rgba(127, 214, 255, 0.2);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.42);
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.history-window[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(127, 214, 255, 0.12);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.history-window-title {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-window-close,
|
||||
.ship-card-history-button {
|
||||
border: 1px solid rgba(127, 214, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 214, 255, 0.08);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-window-close {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.history-window-copy {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.history-window-body {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.error-strip {
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
@@ -238,35 +318,88 @@ canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faction-strip {
|
||||
.history-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ship-strip {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
width: min(920px, calc(100vw - 440px));
|
||||
min-height: 110px;
|
||||
min-height: 140px;
|
||||
border-radius: 24px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
pointer-events: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.faction-card {
|
||||
.ship-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
|
||||
padding: 14px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.faction-card p {
|
||||
.ship-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(127, 214, 255, 0.38);
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.ship-card.is-selected {
|
||||
border-color: rgba(255, 191, 105, 0.82);
|
||||
background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
|
||||
}
|
||||
|
||||
.ship-card.is-followed {
|
||||
box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
|
||||
}
|
||||
|
||||
.ship-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ship-card-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 214, 255, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ship-card p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.ship-card-history-button {
|
||||
margin-top: auto;
|
||||
padding: 8px 12px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
@@ -277,7 +410,7 @@ canvas {
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.faction-strip {
|
||||
.ship-strip {
|
||||
right: 20px;
|
||||
width: auto;
|
||||
}
|
||||
@@ -318,12 +451,19 @@ canvas {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.faction-strip {
|
||||
.ship-strip {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: auto;
|
||||
min-height: 100px;
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 126px;
|
||||
}
|
||||
|
||||
.history-window {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: calc(100vw - 40px);
|
||||
max-height: calc(100vh - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user