feat(topology): render styles from presentation payload
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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-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.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; }
|
||||
.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; }
|
||||
.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 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; }
|
||||
.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-meta { fill: #a6b5c9; font-size: 17px; text-anchor: middle; }
|
||||
.node-hint { fill: #71849e; font-size: 14px; text-anchor: middle; }
|
||||
.radiator { pointer-events: none; }
|
||||
.radiator rect { fill: #6b5814; stroke: var(--radiator); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.radiator line { stroke: #f8e798; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
||||
.radiator-label { fill: #f4df7b; font-size: 15px; font-weight: 650; text-anchor: middle; }
|
||||
.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(2,7,14,0.82); backdrop-filter: blur(8px); }
|
||||
@@ -101,12 +90,7 @@
|
||||
<span class="snapshot-info" id="snapshot-info"></span>
|
||||
</div>
|
||||
|
||||
<div class="legend" aria-label="图例">
|
||||
<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 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>
|
||||
@@ -228,10 +212,40 @@ function splitEndpoint(endpoint) {
|
||||
return separator === -1 ? endpoint : endpoint.slice(0, separator);
|
||||
}
|
||||
|
||||
function connectionClass(type) {
|
||||
if (type === 'structural_mount') return 'structural';
|
||||
if (type === 'pressurized_passage') return 'pressurized';
|
||||
return 'utility';
|
||||
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) {
|
||||
@@ -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;
|
||||
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) {
|
||||
const group = svgElement('g', { class: 'radiator', 'aria-label': '大型折叠散热面板' });
|
||||
const width = Math.min(130, node.width * 0.55);
|
||||
const x = node.x + node.width * 0.55;
|
||||
const y = node.y - 18;
|
||||
group.appendChild(svgElement('rect', { x, y, width, height: 16, rx: 3 }));
|
||||
for (let index = 1; index < 4; index += 1) {
|
||||
const lineX = x + width * index / 4;
|
||||
group.appendChild(svgElement('line', { x1: lineX, y1: y + 1, x2: lineX, y2: y + 15 }));
|
||||
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);
|
||||
}
|
||||
const label = svgElement('text', {
|
||||
class: 'radiator-label',
|
||||
x: x + width / 2,
|
||||
y: y - 8,
|
||||
});
|
||||
label.textContent = '散热器';
|
||||
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}`);
|
||||
@@ -329,19 +417,23 @@ function renderTopology() {
|
||||
const fromNode = nodeByComponent.get(fromComponent);
|
||||
const toNode = nodeByComponent.get(toComponent);
|
||||
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 key = `${pair[0]}|${pair[1]}|${style}`;
|
||||
const key = `${pair[0]}|${pair[1]}|${connection.type}`;
|
||||
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 end = edgePoint(toNode, fromNode);
|
||||
connectionLayer.appendChild(svgElement('line', {
|
||||
class: `connection ${style}`,
|
||||
const line = svgElement('line', {
|
||||
class: 'connection',
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: end.x,
|
||||
@@ -349,12 +441,19 @@ function renderTopology() {
|
||||
'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) {
|
||||
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 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,
|
||||
@@ -374,19 +478,32 @@ function renderTopology() {
|
||||
const fullTitle = svgElement('title');
|
||||
fullTitle.textContent = title;
|
||||
group.appendChild(fullTitle);
|
||||
group.appendChild(svgElement('rect', {
|
||||
class: node.styleKey,
|
||||
const rect = svgElement('rect', {
|
||||
class: 'node-shape',
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
rx: 16,
|
||||
}));
|
||||
});
|
||||
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));
|
||||
appendNodeText(group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72));
|
||||
appendNodeText(group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82);
|
||||
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 === ' ') {
|
||||
@@ -522,6 +639,7 @@ async function load(mission, at) {
|
||||
try {
|
||||
currentData = await fetchSnapshot(mission, at);
|
||||
renderTopology();
|
||||
renderLegend();
|
||||
const query = currentData.query;
|
||||
const missionLabel = query.requestedMission || '(最新)';
|
||||
document.getElementById('page-subtitle').textContent =
|
||||
|
||||
Reference in New Issue
Block a user