diff --git a/data/wiki/guanghan_topology_demo.html b/data/wiki/guanghan_topology_demo.html index 57ecd5d..bd6ffc9 100644 --- a/data/wiki/guanghan_topology_demo.html +++ b/data/wiki/guanghan_topology_demo.html @@ -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 @@ -
- 结构直连 - 加压走道 - 燃料 / 供电 - 大型折叠散热面板 -
+
正在从 API 加载拓扑数据...
@@ -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 = diff --git a/tests/test_guanghan_topology_demo.py b/tests/test_guanghan_topology_demo.py index 765bf3e..c365392 100644 --- a/tests/test_guanghan_topology_demo.py +++ b/tests/test_guanghan_topology_demo.py @@ -41,7 +41,8 @@ class GuanghanTopologySourceTests(unittest.TestCase): self.assertNotIn("http://localhost:5000", self.html) 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("edgeStyles", self.html) self.assertIn("decorationStyles", self.html)