Files
KSP_project/data/wiki/guanghan_topology_demo.html
T
2026-07-15 12:21:34 +08:00

709 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<title>广寒基地拓扑结构</title>
<style>
:root {
color-scheme: light;
--bg: #f4f7fb; --panel: #ffffff; --panel-2: #eef3f8;
--text: #152033; --muted: #5e6f85; --border: #b8c5d4;
--structural: #66788e; --pressurized: #168a55; --utility: #7a4fb3;
--core: #c76b00; --habitat: #1677b8; --power: #9b7900;
--vehicle: #8755a4; --radiator: #9b7900;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 30px;
background: radial-gradient(circle at 50% 0%, rgba(89,132,181,0.14), transparent 36rem), var(--bg);
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
}
.page { width: min(100%, 1500px); margin: 0 auto; }
.page-header { text-align: center; margin-bottom: 22px; }
h1 { margin: 0 0 7px; font-size: clamp(1.7rem, 3vw, 2.45rem); font-weight: 650; letter-spacing: 0.08em; }
.subtitle { margin: 0; color: var(--muted); }
.toolbar { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 12px; margin: 16px 0; }
.toolbar label { color: var(--muted); font-size: 0.88rem; }
.toolbar select, .toolbar input { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font-size: 0.88rem; }
.toolbar button { padding: 6px 16px; border: 1px solid var(--pressurized); border-radius: 6px; background: #e7f7ee; color: #0e6840; cursor: pointer; font-size: 0.88rem; font-weight: 600; }
.toolbar button:hover { background: #d6f0e1; }
.loading-msg { text-align: center; color: var(--muted); padding: 40px; }
.error-msg { text-align: center; color: #b42318; padding: 40px; }
.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 13px 24px; margin: 20px 0; padding: 13px 18px; border: 1px solid var(--border); border-radius: 12px; background: rgba(255,255,255,0.88); }
.legend-item { display: inline-flex; align-items: center; gap: 9px; color: var(--text); font-size: 0.86rem; }
.legend-line { display: inline-block; width: 46px; border-top: 4px solid; }
.legend-chip { width: 19px; height: 13px; border: 2px solid var(--radiator); background: #fff0a8; border-radius: 3px; }
.map-shell { overflow: auto; border: 1px solid var(--border); border-radius: 18px; background: #f8fbff; box-shadow: 0 18px 45px rgba(50,72,96,0.16); }
.topology-map { display: block; width: 100%; min-width: 1080px; height: auto; background: linear-gradient(rgba(78,105,135,0.12) 1px, transparent 1px), linear-gradient(90deg, rgba(78,105,135,0.12) 1px, transparent 1px), #f8fbff; background-size: 48px 48px; }
.connection { fill: none; vector-effect: non-scaling-stroke; stroke-linecap: square; }
.topology-node { cursor: pointer; outline: none; }
.topology-node rect { vector-effect: non-scaling-stroke; transition: filter 150ms ease, stroke-width 150ms ease; }
.topology-node:hover rect, .topology-node:focus-visible rect { filter: brightness(0.97) drop-shadow(0 6px 8px rgba(45,66,88,0.18)); stroke-width: 5; }
.node-title { fill: var(--text); font-size: 25px; font-weight: 700; text-anchor: middle; }
.node-meta { fill: #435a72; font-size: 17px; text-anchor: middle; }
.node-hint { fill: #6f8296; font-size: 14px; text-anchor: middle; }
.radiator { pointer-events: none; }
.radiator rect { vector-effect: non-scaling-stroke; }
.radiator line { stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.radiator-label { font-size: 15px; font-weight: 650; text-anchor: middle; }
.click-note { margin: 13px 0 0; color: var(--muted); font-size: 0.84rem; text-align: center; }
body.modal-open { overflow: hidden; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(21,32,51,0.38); backdrop-filter: blur(8px); }
.modal-backdrop[aria-hidden="false"] { display: flex; }
.detail-dialog { position: relative; width: min(760px, 100%); max-height: min(820px, calc(100vh - 48px)); overflow: auto; padding: 30px; border: 1px solid #a8b7c8; border-radius: 18px; outline: none; background: linear-gradient(145deg, #ffffff, #f2f6fa 72%); box-shadow: 0 30px 80px rgba(38,55,74,0.28); }
.detail-dialog:focus-visible { box-shadow: 0 0 0 3px var(--pressurized), 0 30px 80px rgba(38,55,74,0.28); }
.modal-close { position: absolute; top: 14px; right: 14px; width: 40px; height: 40px; border: 1px solid #9aabbd; border-radius: 50%; background: #edf2f7; color: var(--text); font: 500 1.65rem/1 system-ui, sans-serif; cursor: pointer; }
.modal-close:hover, .modal-close:focus-visible { border-color: var(--pressurized); outline: none; background: #e2eaf2; }
.detail-kicker { margin: 0 46px 6px 0; color: var(--pressurized); font-size: 0.77rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
#detail-title { margin: 0 50px 10px 0; font-size: 1.85rem; }
#detail-role { margin: 0 0 22px; color: #42566e; line-height: 1.7; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; }
.detail-grid > div { min-width: 0; padding: 14px; border: 1px solid #c7d2de; border-radius: 10px; background: rgba(247,249,252,0.9); }
.detail-grid dt { margin-bottom: 7px; color: #61758c; font-size: 0.75rem; }
.detail-grid dd { margin: 0; color: #1e2d40; line-height: 1.55; overflow-wrap: anywhere; }
.detail-grid dd span { display: block; }
.detail-grid dd span + span { margin-top: 4px; }
@media (max-width: 760px) {
body { padding: 14px; } .topology-map { min-width: 980px; } .legend { justify-content: flex-start; }
.modal-backdrop { padding: 12px; } .detail-dialog { padding: 25px 18px 20px; max-height: calc(100vh - 24px); }
.detail-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<header class="page-header">
<h1>广寒基地拓扑结构</h1>
<p class="subtitle" id="page-subtitle">加载中...</p>
</header>
<div class="toolbar">
<label for="mission-select">任务:</label>
<select id="mission-select">
<option value="">(最新)</option>
</select>
<label for="time-input">时间:</label>
<input type="text" id="time-input" placeholder="ISO 8601,如 2024-09-01T00:00:00Z" size="28">
<button id="go-btn">查询</button>
<span class="snapshot-info" id="snapshot-info"></span>
</div>
<div class="legend" id="legend" aria-label="图例"></div>
<div id="loading-msg" class="loading-msg">正在从 API 加载拓扑数据...</div>
<div id="error-msg" class="error-msg" style="display:none;"></div>
<div class="map-shell" id="map-shell" style="display:none;">
<svg class="topology-map" viewBox="0 0 1900 1900" role="img" aria-labelledby="topology-title" id="topology-svg">
<title id="topology-title">广寒基地拓扑</title>
<desc id="topology-desc">根据所选任务或时间展示广寒基地当时的工程拓扑。</desc>
<!-- FUTURE_EXPANSION_ANCHOR:north -->
<!-- FUTURE_EXPANSION_ANCHOR:east -->
<!-- FUTURE_EXPANSION_ANCHOR:west -->
<g id="connection-layer"></g>
<g id="decoration-layer"></g>
<g id="node-layer"></g>
</svg>
</div>
<p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p>
</main>
<div class="modal-backdrop" id="detail-modal" aria-hidden="true">
<section class="detail-dialog" role="dialog" aria-modal="true" aria-labelledby="detail-title" tabindex="-1">
<button class="modal-close" type="button" aria-label="关闭详情">×</button>
<p class="detail-kicker" id="detail-type"></p>
<h2 id="detail-title"></h2>
<p id="detail-role"></p>
<dl class="detail-grid">
<div><dt>安装位置</dt><dd id="detail-location"></dd></div>
<div><dt>运行状态</dt><dd id="detail-status"></dd></div>
<div><dt>关键参数</dt><dd id="detail-metrics"></dd></div>
<div><dt>尺寸</dt><dd id="detail-dimensions"></dd></div>
<div><dt>连接关系</dt><dd id="detail-connections"></dd></div>
<div><dt>任务节点</dt><dd id="detail-mission"></dd></div>
</dl>
</section>
</div>
<script>
// Keep API calls beside the HTML document so the app works both at the
// service root and behind a reverse-proxy prefix such as /topology/.
const API_BASE = new URL('.', window.location.href).pathname.replace(/\/$/, '');
const SVG_NS = 'http://www.w3.org/2000/svg';
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' };
const ANCHOR_LABELS = {
explicit_time: '明确时间',
mission_latest_event: '任务末条记录',
latest_recorded_state: '最新已记录状态',
no_events: '暂无记录',
};
const TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
let currentData = null;
let lastModalTrigger = null;
function svgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NS, name);
Object.entries(attributes).forEach(([key, value]) => {
if (value !== null && value !== undefined) element.setAttribute(key, value);
});
return element;
}
async function fetchSnapshot(mission, at) {
const params = new URLSearchParams();
if (at) params.set('at', at);
if (mission) params.set('mission', mission);
const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`);
if (!response.ok) {
let error;
try {
error = await response.json();
} catch {
error = { message: `HTTP ${response.status}` };
}
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
async function loadMissions() {
const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/missions`);
if (!response.ok) throw new Error(`任务列表加载失败(HTTP ${response.status}`);
return (await response.json()).missions;
}
function formatSnapshotTime(value) {
return value ? TIME_FORMATTER.format(new Date(value)) : '—';
}
function isValidTimeInput(value) {
if (!value) return true;
if (!/(Z|[+-]\d{2}:\d{2})$/.test(value)) return false;
return !Number.isNaN(Date.parse(value));
}
function setError(message) {
const error = document.getElementById('error-msg');
error.textContent = message;
error.style.display = message ? 'block' : 'none';
}
function setLines(elementId, lines, fallback) {
const container = document.getElementById(elementId);
const values = lines.length ? lines : [fallback];
container.replaceChildren(...values.map(value => {
const line = document.createElement('span');
line.textContent = value;
return line;
}));
}
function splitEndpoint(endpoint) {
const separator = endpoint.indexOf('.');
return separator === -1 ? endpoint : endpoint.slice(0, separator);
}
function getPresentation() {
const presentation = currentData?.layout?.presentation;
if (!presentation) throw new Error('拓扑表现层资料未记录');
return presentation;
}
function requireStyle(collection, key, label) {
const style = collection?.[key];
if (!style) throw new Error(`${label} 未记录: ${key}`);
return style;
}
function setOptionalAttr(element, name, value) {
if (value !== null && value !== undefined && value !== '') {
element.setAttribute(name, value);
}
}
function applyNodeStyle(rect, nodeStyle) {
rect.setAttribute('fill', nodeStyle.fillColor);
rect.setAttribute('stroke', nodeStyle.strokeColor);
rect.setAttribute('stroke-width', nodeStyle.strokeWidth);
rect.setAttribute('rx', nodeStyle.cornerRadius);
}
function applyTextStyle(textElement, color) {
if (color) textElement.setAttribute('fill', color);
}
function applyEdgeStyle(line, edgeStyle) {
line.setAttribute('stroke', edgeStyle.strokeColor);
line.setAttribute('stroke-width', edgeStyle.strokeWidth);
setOptionalAttr(line, 'stroke-dasharray', edgeStyle.dashArray);
setOptionalAttr(line, 'stroke-linecap', edgeStyle.lineCap);
}
function edgePoint(source, target) {
const sourceCenter = {
x: source.x + source.width / 2,
y: source.y + source.height / 2,
};
const targetCenter = {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
};
const dx = targetCenter.x - sourceCenter.x;
const dy = targetCenter.y - sourceCenter.y;
if (dx === 0 && dy === 0) return sourceCenter;
const xScale = dx === 0 ? Infinity : (source.width / 2) / Math.abs(dx);
const yScale = dy === 0 ? Infinity : (source.height / 2) / Math.abs(dy);
const scale = Math.min(xScale, yScale);
return {
x: sourceCenter.x + dx * scale,
y: sourceCenter.y + dy * scale,
};
}
function fitSvgText(textElement, fullText, maxWidth) {
textElement.textContent = fullText || '';
if (!fullText) return;
if (textElement.getComputedTextLength() <= maxWidth) return;
let fitted = fullText;
while (fitted.length > 1) {
fitted = fitted.slice(0, -1);
textElement.textContent = `${fitted}…`;
if (textElement.getComputedTextLength() <= maxWidth) break;
}
}
function appendNodeText(group, node, className, text, y, color) {
if (!text) return;
const element = svgElement('text', {
class: className,
x: node.x + node.width / 2,
y,
});
applyTextStyle(element, color);
group.appendChild(element);
fitSvgText(element, text, node.width - 24);
}
function createRadiator(node, style) {
const group = svgElement('g', { class: 'radiator', 'aria-label': style.displayName });
const params = style.renderParams || {};
const widthRatio = Number(params.widthRatio ?? 0.55);
const maxWidth = Number(params.maxWidth ?? 130);
const height = Number(params.height ?? 16);
const offsetXRatio = Number(params.offsetXRatio ?? 0.55);
const offsetY = Number(params.offsetY ?? -18);
const ribCount = Number(params.ribCount ?? 3);
const width = Math.min(maxWidth, node.width * widthRatio);
const x = node.x + node.width * offsetXRatio;
const y = node.y + offsetY;
group.appendChild(svgElement('rect', {
x, y, width, height, rx: 3,
fill: style.fillColor,
stroke: style.strokeColor,
'stroke-width': style.strokeWidth,
}));
for (let index = 1; index <= ribCount; index += 1) {
const lineX = x + width * index / (ribCount + 1);
group.appendChild(svgElement('line', {
x1: lineX, y1: y + 1, x2: lineX, y2: y + height - 1,
stroke: style.labelColor || style.strokeColor,
'stroke-width': 1.5,
}));
}
if (style.label) {
const label = svgElement('text', {
class: 'radiator-label',
x: x + width / 2,
y: y - 8,
fill: style.labelColor || style.strokeColor,
});
label.textContent = style.label;
group.appendChild(label);
}
return group;
}
function renderLegend() {
const legend = document.getElementById('legend');
const presentation = getPresentation();
const items = [...(presentation.legendItems || [])]
.sort((left, right) => left.displayOrder - right.displayOrder);
const elements = items.map(item => {
const wrapper = document.createElement('span');
wrapper.className = 'legend-item';
if (item.itemType === 'edge') {
const style = requireStyle(
presentation.edgeStyles,
item.styleRef,
'连接样式',
);
const sample = document.createElement('i');
sample.className = 'legend-line';
sample.style.borderColor = style.strokeColor;
sample.style.borderTopWidth = `${Math.max(2, Number(style.strokeWidth || 4) / 2)}px`;
if (style.dashArray) sample.style.borderTopStyle = 'dashed';
wrapper.appendChild(sample);
} else if (item.itemType === 'decoration') {
const style = requireStyle(
presentation.decorationStyles,
item.styleRef,
'装饰样式',
);
const sample = document.createElement('i');
sample.className = 'legend-chip';
sample.style.background = style.fillColor;
sample.style.borderColor = style.strokeColor;
wrapper.appendChild(sample);
} else {
const style = requireStyle(
presentation.nodeStyles,
item.styleRef,
'节点样式',
);
const sample = document.createElement('i');
sample.className = 'legend-chip';
sample.style.background = style.fillColor;
sample.style.borderColor = style.strokeColor;
wrapper.appendChild(sample);
}
wrapper.append(document.createTextNode(item.label));
return wrapper;
});
legend.replaceChildren(...elements);
}
function renderTopology() {
const payload = currentData.layout;
if (!payload || !payload.layout) throw new Error('拓扑布局资料未记录');
const presentation = getPresentation();
const svg = document.getElementById('topology-svg');
svg.setAttribute('viewBox', `0 0 ${payload.layout.canvasWidth} ${payload.layout.canvasHeight}`);
const connectionLayer = document.getElementById('connection-layer');
const decorationLayer = document.getElementById('decoration-layer');
const nodeLayer = document.getElementById('node-layer');
connectionLayer.replaceChildren();
decorationLayer.replaceChildren();
nodeLayer.replaceChildren();
const componentMap = new Map(currentData.components.map(component => [component.key, component]));
const visibleNodes = payload.nodes
.filter(node => node.memberKeys.some(key => componentMap.has(key)));
const nodeByKey = new Map(visibleNodes.map(node => [node.nodeKey, node]));
const nodeByComponent = new Map();
visibleNodes.forEach(node => {
node.memberKeys.forEach(key => {
if (componentMap.has(key)) nodeByComponent.set(key, node);
});
});
const visualConnections = new Map();
currentData.connections.forEach(connection => {
const fromComponent = splitEndpoint(connection.fromPort);
const toComponent = splitEndpoint(connection.toPort);
const fromNode = nodeByComponent.get(fromComponent);
const toNode = nodeByComponent.get(toComponent);
if (!fromNode || !toNode || fromNode.nodeKey === toNode.nodeKey) return;
const edgeStyle = requireStyle(
presentation.edgeStyles,
connection.type,
'连接样式',
);
const pair = [fromNode.nodeKey, toNode.nodeKey].sort();
const key = `${pair[0]}|${pair[1]}|${connection.type}`;
if (!visualConnections.has(key)) {
visualConnections.set(key, { fromNode, toNode, edgeStyle, connection });
}
});
visualConnections.forEach(({ fromNode, toNode, edgeStyle, connection }) => {
const start = edgePoint(fromNode, toNode);
const end = edgePoint(toNode, fromNode);
const line = svgElement('line', {
class: 'connection',
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
'data-type': connection.type,
'data-from': fromNode.nodeKey,
'data-to': toNode.nodeKey,
});
applyEdgeStyle(line, edgeStyle);
connectionLayer.appendChild(line);
});
visibleNodes.forEach(node => {
if (node.decorations && node.decorations.radiator) {
const radiatorStyle = requireStyle(
presentation.decorationStyles,
'radiator',
'装饰样式',
);
decorationLayer.appendChild(createRadiator(node, radiatorStyle));
}
});
[...visibleNodes]
.sort((left, right) => left.zIndex - right.zIndex)
.forEach(node => {
const components = node.memberKeys.map(key => componentMap.get(key)).filter(Boolean);
const primary = components[0];
const title = node.labelOverride || primary.name;
const nodeStyle = requireStyle(
presentation.nodeStyles,
node.styleKey,
'节点样式',
);
const group = svgElement('g', {
class: 'topology-node',
'data-node-key': node.nodeKey,
'data-comp': node.nodeKey,
role: 'button',
tabindex: 0,
});
const fullTitle = svgElement('title');
fullTitle.textContent = title;
group.appendChild(fullTitle);
const rect = svgElement('rect', {
class: 'node-shape',
x: node.x,
y: node.y,
width: node.width,
height: node.height,
});
applyNodeStyle(rect, nodeStyle);
group.appendChild(rect);
const hasHint = Boolean(node.displayHint);
appendNodeText(
group, node, 'node-title', title,
node.y + node.height * (hasHint ? 0.34 : 0.42),
nodeStyle.titleColor || presentation.theme.tokens.textColor,
);
appendNodeText(
group, node, 'node-meta', node.displayMeta,
node.y + node.height * (hasHint ? 0.60 : 0.72),
nodeStyle.metaColor || presentation.theme.tokens.metaColor,
);
appendNodeText(
group, node, 'node-hint', node.displayHint,
node.y + node.height * 0.82,
nodeStyle.hintColor || presentation.theme.tokens.hintColor,
);
group.addEventListener('click', () => openModal(node.nodeKey, group));
group.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openModal(node.nodeKey, group);
}
});
nodeLayer.appendChild(group);
});
}
function getNodeComponents(nodeKey) {
if (!currentData || !currentData.layout) return [];
const node = currentData.layout.nodes.find(item => item.nodeKey === nodeKey);
if (!node) return [];
const componentMap = new Map(currentData.components.map(component => [component.key, component]));
return node.memberKeys.map(key => componentMap.get(key)).filter(Boolean);
}
function getLayoutNode(nodeKey) {
return currentData?.layout?.nodes.find(node => node.nodeKey === nodeKey);
}
function buildConnectionsForComponent(componentKey) {
const result = [];
currentData.connections.forEach(connection => {
const fromComponent = splitEndpoint(connection.fromPort);
const toComponent = splitEndpoint(connection.toPort);
if (fromComponent !== componentKey && toComponent !== componentKey) return;
const labels = {
structural_mount: '结构直连',
pressurized_passage: '加压走道',
fuel: '燃料管线',
power: '供电管线',
docking: '对接',
cooling: '冷却回路',
};
const otherKey = fromComponent === componentKey ? toComponent : fromComponent;
const other = currentData.components.find(component => component.key === otherKey);
result.push(`${other?.name || otherKey}${labels[connection.type] || connection.type}`);
});
return result;
}
function openModal(nodeKey, trigger = null) {
const components = getNodeComponents(nodeKey);
if (components.length === 0) return;
const primary = components[0];
const layoutNode = getLayoutNode(nodeKey);
document.getElementById('detail-type').textContent =
components.length > 1 ? '组合模块' : (TYPE_LABELS[primary.type] || primary.type);
document.getElementById('detail-title').textContent =
layoutNode?.labelOverride || primary.name;
document.getElementById('detail-role').textContent =
components.map(component => component.description || component.summary || component.name).join('');
const ports = components.flatMap(component =>
(component.ports || []).map(port => `${component.name}${port.displayName}${port.direction || '—'}`)
);
document.getElementById('detail-location').textContent = ports.join(' · ') || '资料未记录';
const statusParts = [];
components.forEach(component => {
const states = component.states || {};
const values = [];
if (states.lifecycle) values.push(states.lifecycle === 'installed' ? '已安装' : states.lifecycle);
if (states.operation) {
values.push({ operational: '运行中', standby: '待机', offline: '离线' }[states.operation] || states.operation);
if (states.operationDetails?.powerPercent) values.push(`功率 ${states.operationDetails.powerPercent}%`);
}
if (values.length) statusParts.push(`${component.name}${values.join(' · ')}`);
});
document.getElementById('detail-status').textContent = statusParts.join('') || '资料未记录';
const metrics = [];
components.forEach(component => {
const dimensions = component.dimensions || {};
const properties = component.properties || {};
const values = [];
if (dimensions.massT) values.push(`质量 ${dimensions.massT} t`);
if (properties.capacity) values.push(`铺位 ${properties.capacity}`);
if (properties.sleepBags) values.push(`睡袋 ${properties.sleepBags}`);
if (properties.zones) values.push(`温区 ${properties.zones}`);
if (properties.foldingArms) values.push(`折叠臂 ${properties.foldingArms}`);
if (properties.block) values.push(`Block ${properties.block}`);
if (values.length) metrics.push(`${component.name}${values.join('')}`);
else if (components.length > 1 && component.summary) metrics.push(`${component.name}${component.summary}`);
});
setLines('detail-metrics', metrics, '资料未记录');
const dimensionLines = [];
components.forEach(component => {
const dimensions = component.dimensions || {};
const values = [];
if (dimensions.lengthM) values.push(`长 ${dimensions.lengthM} m`);
if (dimensions.widthM) values.push(`宽 ${dimensions.widthM} m`);
if (dimensions.heightM) values.push(`高 ${dimensions.heightM} m`);
if (dimensions.diameterM) values.push(`直径 ${dimensions.diameterM} m`);
if (values.length) dimensionLines.push(`${component.name}${values.join('')}`);
});
setLines('detail-dimensions', dimensionLines, '资料未记录');
const connections = [];
components.forEach(component => {
buildConnectionsForComponent(component.key).forEach(value => {
if (!connections.includes(value)) connections.push(value);
});
});
setLines('detail-connections', connections, '无连接记录');
const missions = components.map(component =>
`${component.name}${component.introducedMission} · ${formatSnapshotTime(component.introducedAt)}`
);
setLines('detail-mission', missions, '资料未记录');
const modal = document.getElementById('detail-modal');
lastModalTrigger = trigger;
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
modal.querySelector('.detail-dialog').focus();
}
function closeModal() {
document.getElementById('detail-modal').setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
if (lastModalTrigger?.isConnected) lastModalTrigger.focus();
lastModalTrigger = null;
}
async function load(mission, at) {
document.getElementById('loading-msg').style.display = 'block';
setError('');
document.getElementById('map-shell').style.display = 'none';
try {
currentData = await fetchSnapshot(mission, at);
renderTopology();
renderLegend();
const query = currentData.query;
const missionLabel = query.requestedMission || '(最新)';
document.getElementById('page-subtitle').textContent =
`${missionLabel} · 快照时间: ${formatSnapshotTime(query.snapshotAt)} · ${currentData.components.length} 组件`;
document.getElementById('snapshot-info').textContent =
`快照: ${formatSnapshotTime(query.snapshotAt)} · ${ANCHOR_LABELS[query.anchorSource] || query.anchorSource}`;
document.getElementById('loading-msg').style.display = 'none';
document.getElementById('map-shell').style.display = 'block';
} catch (error) {
document.getElementById('loading-msg').style.display = 'none';
setError(`加载失败: ${error.message}`);
}
}
(async () => {
const missionSelect = document.getElementById('mission-select');
const missions = await loadMissions();
missions.forEach(mission => {
const option = document.createElement('option');
option.value = mission.code;
option.textContent = mission.code;
missionSelect.appendChild(option);
});
async function applyUrlState() {
const urlParams = new URLSearchParams(window.location.search);
const mission = urlParams.get('mission') || '';
const at = urlParams.get('at') || '';
missionSelect.value = mission;
document.getElementById('time-input').value = at;
await load(mission || null, at || null);
}
await applyUrlState();
document.getElementById('go-btn').addEventListener('click', () => {
const mission = missionSelect.value || null;
const at = document.getElementById('time-input').value || null;
if (!isValidTimeInput(at)) {
setError('请输入带时区的 ISO 8601 时间,例如 2025-01-02T08:00:00+08:00。');
return;
}
const params = new URLSearchParams();
if (mission) params.set('mission', mission);
if (at) params.set('at', at);
window.history.pushState({}, '', `${window.location.pathname}${params.size ? `?${params}` : ''}`);
load(mission, at);
});
window.addEventListener('popstate', applyUrlState);
document.getElementById('detail-modal').addEventListener('click', event => {
if (event.target === event.currentTarget) closeModal();
});
document.querySelector('.modal-close').addEventListener('click', closeModal);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && document.getElementById('detail-modal').getAttribute('aria-hidden') === 'false') {
closeModal();
}
});
})();
</script>
</body>
</html>