feat(topology): render styles from presentation payload

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:49:27 +08:00
co-authored by Claude
parent 2382b5b483
commit 10808fd98b
2 changed files with 176 additions and 57 deletions
+168 -50
View File
@@ -34,31 +34,20 @@
.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(13,23,40,0.84); } .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(13,23,40,0.84); }
.legend-item { display: inline-flex; align-items: center; gap: 9px; color: #c8d5e7; font-size: 0.86rem; } .legend-item { display: inline-flex; align-items: center; gap: 9px; color: #c8d5e7; font-size: 0.86rem; }
.legend-line { display: inline-block; width: 46px; border-top: 4px solid; } .legend-line { display: inline-block; width: 46px; border-top: 4px solid; }
.legend-line.structural { border-color: var(--structural); }
.legend-line.pressurized { border-color: var(--pressurized); }
.legend-line.utility { border-color: var(--utility); border-top-style: dashed; }
.legend-chip { width: 19px; height: 13px; border: 2px solid var(--radiator); background: #665511; border-radius: 3px; } .legend-chip { width: 19px; height: 13px; border: 2px solid var(--radiator); background: #665511; border-radius: 3px; }
.map-shell { overflow: auto; border: 1px solid var(--border); border-radius: 18px; background: #08101e; box-shadow: 0 24px 70px rgba(0,0,0,0.34); } .map-shell { overflow: auto; border: 1px solid var(--border); border-radius: 18px; background: #08101e; box-shadow: 0 24px 70px rgba(0,0,0,0.34); }
.topology-map { display: block; width: 100%; min-width: 1080px; height: auto; background: linear-gradient(rgba(18,36,60,0.21) 1px, transparent 1px), linear-gradient(90deg, rgba(18,36,60,0.21) 1px, transparent 1px), #08101e; background-size: 48px 48px; } .topology-map { display: block; width: 100%; min-width: 1080px; height: auto; background: linear-gradient(rgba(18,36,60,0.21) 1px, transparent 1px), linear-gradient(90deg, rgba(18,36,60,0.21) 1px, transparent 1px), #08101e; background-size: 48px 48px; }
.connection { fill: none; vector-effect: non-scaling-stroke; stroke-linecap: square; } .connection { fill: none; vector-effect: non-scaling-stroke; stroke-linecap: square; }
.connection.structural { stroke: var(--structural); stroke-width: 6; }
.connection.pressurized { stroke: var(--pressurized); stroke-width: 9; }
.connection.utility { stroke: var(--utility); stroke-width: 4; stroke-dasharray: 13 10; }
.topology-node { cursor: pointer; outline: none; } .topology-node { cursor: pointer; outline: none; }
.topology-node rect { vector-effect: non-scaling-stroke; transition: filter 150ms ease, stroke-width 150ms ease; } .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(1.25); stroke-width: 5; } .topology-node:hover rect, .topology-node:focus-visible rect { filter: brightness(1.25); stroke-width: 5; }
.node-card { fill: #10263a; stroke: var(--habitat); stroke-width: 3; }
.core-card { fill: #3b250d; stroke: var(--core); stroke-width: 5; }
.power-card { fill: #2a2810; stroke: var(--power); stroke-width: 3; }
.power-node-card { fill: #393412; stroke: var(--power); stroke-width: 5; }
.vehicle-card { fill: #291c34; stroke: var(--vehicle); stroke-width: 3; }
.node-title { fill: var(--text); font-size: 25px; font-weight: 700; text-anchor: middle; } .node-title { fill: var(--text); font-size: 25px; font-weight: 700; text-anchor: middle; }
.node-meta { fill: #a6b5c9; font-size: 17px; text-anchor: middle; } .node-meta { fill: #a6b5c9; font-size: 17px; text-anchor: middle; }
.node-hint { fill: #71849e; font-size: 14px; text-anchor: middle; } .node-hint { fill: #71849e; font-size: 14px; text-anchor: middle; }
.radiator { pointer-events: none; } .radiator { pointer-events: none; }
.radiator rect { fill: #6b5814; stroke: var(--radiator); stroke-width: 2; vector-effect: non-scaling-stroke; } .radiator rect { vector-effect: non-scaling-stroke; }
.radiator line { stroke: #f8e798; stroke-width: 1.5; vector-effect: non-scaling-stroke; } .radiator line { stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.radiator-label { fill: #f4df7b; font-size: 15px; font-weight: 650; text-anchor: middle; } .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; } .click-note { margin: 13px 0 0; color: var(--muted); font-size: 0.84rem; text-align: center; }
body.modal-open { overflow: hidden; } 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(2,7,14,0.82); backdrop-filter: blur(8px); } .modal-backdrop { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(2,7,14,0.82); backdrop-filter: blur(8px); }
@@ -101,12 +90,7 @@
<span class="snapshot-info" id="snapshot-info"></span> <span class="snapshot-info" id="snapshot-info"></span>
</div> </div>
<div class="legend" aria-label="图例"> <div class="legend" id="legend" aria-label="图例"></div>
<span class="legend-item"><i class="legend-line structural"></i>结构直连</span>
<span class="legend-item"><i class="legend-line pressurized"></i>加压走道</span>
<span class="legend-item"><i class="legend-line utility"></i>燃料 / 供电</span>
<span class="legend-item"><i class="legend-chip"></i>大型折叠散热面板</span>
</div>
<div id="loading-msg" class="loading-msg">正在从 API 加载拓扑数据...</div> <div id="loading-msg" class="loading-msg">正在从 API 加载拓扑数据...</div>
<div id="error-msg" class="error-msg" style="display:none;"></div> <div id="error-msg" class="error-msg" style="display:none;"></div>
@@ -228,10 +212,40 @@ function splitEndpoint(endpoint) {
return separator === -1 ? endpoint : endpoint.slice(0, separator); return separator === -1 ? endpoint : endpoint.slice(0, separator);
} }
function connectionClass(type) { function getPresentation() {
if (type === 'structural_mount') return 'structural'; const presentation = currentData?.layout?.presentation;
if (type === 'pressurized_passage') return 'pressurized'; if (!presentation) throw new Error('拓扑表现层资料未记录');
return 'utility'; 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) { function edgePoint(source, target) {
@@ -267,40 +281,114 @@ function fitSvgText(textElement, fullText, maxWidth) {
} }
} }
function appendNodeText(group, node, className, text, y) { function appendNodeText(group, node, className, text, y, color) {
if (!text) return; if (!text) return;
const element = svgElement('text', { const element = svgElement('text', {
class: className, class: className,
x: node.x + node.width / 2, x: node.x + node.width / 2,
y, y,
}); });
applyTextStyle(element, color);
group.appendChild(element); group.appendChild(element);
fitSvgText(element, text, node.width - 24); fitSvgText(element, text, node.width - 24);
} }
function createRadiator(node) { function createRadiator(node, style) {
const group = svgElement('g', { class: 'radiator', 'aria-label': '大型折叠散热面板' }); const group = svgElement('g', { class: 'radiator', 'aria-label': style.displayName });
const width = Math.min(130, node.width * 0.55); const params = style.renderParams || {};
const x = node.x + node.width * 0.55; const widthRatio = Number(params.widthRatio ?? 0.55);
const y = node.y - 18; const maxWidth = Number(params.maxWidth ?? 130);
group.appendChild(svgElement('rect', { x, y, width, height: 16, rx: 3 })); const height = Number(params.height ?? 16);
for (let index = 1; index < 4; index += 1) { const offsetXRatio = Number(params.offsetXRatio ?? 0.55);
const lineX = x + width * index / 4; const offsetY = Number(params.offsetY ?? -18);
group.appendChild(svgElement('line', { x1: lineX, y1: y + 1, x2: lineX, y2: y + 15 })); 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', { const label = svgElement('text', {
class: 'radiator-label', class: 'radiator-label',
x: x + width / 2, x: x + width / 2,
y: y - 8, y: y - 8,
fill: style.labelColor || style.strokeColor,
}); });
label.textContent = '散热器'; label.textContent = style.label;
group.appendChild(label); group.appendChild(label);
}
return group; 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() { function renderTopology() {
const payload = currentData.layout; const payload = currentData.layout;
if (!payload || !payload.layout) throw new Error('拓扑布局资料未记录'); if (!payload || !payload.layout) throw new Error('拓扑布局资料未记录');
const presentation = getPresentation();
const svg = document.getElementById('topology-svg'); const svg = document.getElementById('topology-svg');
svg.setAttribute('viewBox', `0 0 ${payload.layout.canvasWidth} ${payload.layout.canvasHeight}`); svg.setAttribute('viewBox', `0 0 ${payload.layout.canvasWidth} ${payload.layout.canvasHeight}`);
@@ -329,19 +417,23 @@ function renderTopology() {
const fromNode = nodeByComponent.get(fromComponent); const fromNode = nodeByComponent.get(fromComponent);
const toNode = nodeByComponent.get(toComponent); const toNode = nodeByComponent.get(toComponent);
if (!fromNode || !toNode || fromNode.nodeKey === toNode.nodeKey) return; if (!fromNode || !toNode || fromNode.nodeKey === toNode.nodeKey) return;
const style = connectionClass(connection.type); const edgeStyle = requireStyle(
presentation.edgeStyles,
connection.type,
'连接样式',
);
const pair = [fromNode.nodeKey, toNode.nodeKey].sort(); const pair = [fromNode.nodeKey, toNode.nodeKey].sort();
const key = `${pair[0]}|${pair[1]}|${style}`; const key = `${pair[0]}|${pair[1]}|${connection.type}`;
if (!visualConnections.has(key)) { if (!visualConnections.has(key)) {
visualConnections.set(key, { fromNode, toNode, style, connection }); visualConnections.set(key, { fromNode, toNode, edgeStyle, connection });
} }
}); });
visualConnections.forEach(({ fromNode, toNode, style, connection }) => { visualConnections.forEach(({ fromNode, toNode, edgeStyle, connection }) => {
const start = edgePoint(fromNode, toNode); const start = edgePoint(fromNode, toNode);
const end = edgePoint(toNode, fromNode); const end = edgePoint(toNode, fromNode);
connectionLayer.appendChild(svgElement('line', { const line = svgElement('line', {
class: `connection ${style}`, class: 'connection',
x1: start.x, x1: start.x,
y1: start.y, y1: start.y,
x2: end.x, x2: end.x,
@@ -349,12 +441,19 @@ function renderTopology() {
'data-type': connection.type, 'data-type': connection.type,
'data-from': fromNode.nodeKey, 'data-from': fromNode.nodeKey,
'data-to': toNode.nodeKey, 'data-to': toNode.nodeKey,
})); });
applyEdgeStyle(line, edgeStyle);
connectionLayer.appendChild(line);
}); });
visibleNodes.forEach(node => { visibleNodes.forEach(node => {
if (node.decorations && node.decorations.radiator) { if (node.decorations && node.decorations.radiator) {
decorationLayer.appendChild(createRadiator(node)); const radiatorStyle = requireStyle(
presentation.decorationStyles,
'radiator',
'装饰样式',
);
decorationLayer.appendChild(createRadiator(node, radiatorStyle));
} }
}); });
@@ -364,6 +463,11 @@ function renderTopology() {
const components = node.memberKeys.map(key => componentMap.get(key)).filter(Boolean); const components = node.memberKeys.map(key => componentMap.get(key)).filter(Boolean);
const primary = components[0]; const primary = components[0];
const title = node.labelOverride || primary.name; const title = node.labelOverride || primary.name;
const nodeStyle = requireStyle(
presentation.nodeStyles,
node.styleKey,
'节点样式',
);
const group = svgElement('g', { const group = svgElement('g', {
class: 'topology-node', class: 'topology-node',
'data-node-key': node.nodeKey, 'data-node-key': node.nodeKey,
@@ -374,19 +478,32 @@ function renderTopology() {
const fullTitle = svgElement('title'); const fullTitle = svgElement('title');
fullTitle.textContent = title; fullTitle.textContent = title;
group.appendChild(fullTitle); group.appendChild(fullTitle);
group.appendChild(svgElement('rect', { const rect = svgElement('rect', {
class: node.styleKey, class: 'node-shape',
x: node.x, x: node.x,
y: node.y, y: node.y,
width: node.width, width: node.width,
height: node.height, height: node.height,
rx: 16, });
})); applyNodeStyle(rect, nodeStyle);
group.appendChild(rect);
const hasHint = Boolean(node.displayHint); const hasHint = Boolean(node.displayHint);
appendNodeText(group, node, 'node-title', title, node.y + node.height * (hasHint ? 0.34 : 0.42)); appendNodeText(
appendNodeText(group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72)); group, node, 'node-title', title,
appendNodeText(group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82); 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('click', () => openModal(node.nodeKey, group));
group.addEventListener('keydown', event => { group.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -522,6 +639,7 @@ async function load(mission, at) {
try { try {
currentData = await fetchSnapshot(mission, at); currentData = await fetchSnapshot(mission, at);
renderTopology(); renderTopology();
renderLegend();
const query = currentData.query; const query = currentData.query;
const missionLabel = query.requestedMission || '(最新)'; const missionLabel = query.requestedMission || '(最新)';
document.getElementById('page-subtitle').textContent = document.getElementById('page-subtitle').textContent =
+2 -1
View File
@@ -41,7 +41,8 @@ class GuanghanTopologySourceTests(unittest.TestCase):
self.assertNotIn("http://localhost:5000", self.html) self.assertNotIn("http://localhost:5000", self.html)
def test_page_uses_database_presentation_payload(self) -> None: def test_page_uses_database_presentation_payload(self) -> None:
self.assertIn("currentData.layout.presentation", self.html) self.assertIn("getPresentation", self.html)
self.assertIn("?.presentation", self.html)
self.assertIn("nodeStyles", self.html) self.assertIn("nodeStyles", self.html)
self.assertIn("edgeStyles", self.html) self.assertIn("edgeStyles", self.html)
self.assertIn("decorationStyles", self.html) self.assertIn("decorationStyles", self.html)