Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1622eaa2da |
@@ -17006,6 +17006,16 @@
|
|||||||
"map_roads",
|
"map_roads",
|
||||||
"map_pump_shell"
|
"map_pump_shell"
|
||||||
],
|
],
|
||||||
|
"networkContext": [
|
||||||
|
"detail_roof",
|
||||||
|
"detail_facade",
|
||||||
|
"detail_interior",
|
||||||
|
"detail_ceiling",
|
||||||
|
"map_platforms",
|
||||||
|
"map_railway",
|
||||||
|
"map_roads",
|
||||||
|
"map_pump_shell"
|
||||||
|
],
|
||||||
"detailBase": [
|
"detailBase": [
|
||||||
"map_roof",
|
"map_roof",
|
||||||
"map_facade",
|
"map_facade",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export function attachNetworkStyle(group,model){
|
|||||||
model.pipeInstances.forEach((p,i)=>{if(!partsById.has(p.inpId))partsById.set(p.inpId,[]);partsById.get(p.inpId).push(i);});
|
model.pipeInstances.forEach((p,i)=>{if(!partsById.has(p.inpId))partsById.set(p.inpId,[]);partsById.get(p.inpId).push(i);});
|
||||||
const owned=[];for(const mesh of [pipes,nodes,bends].filter(Boolean)){const material=new THREE.MeshPhysicalMaterial({color:0xffffff,roughness:.3,metalness:.22,clearcoat:.22,clearcoatRoughness:.38});mesh.material=material;mesh.castShadow=true;mesh.receiveShadow=true;owned.push(material);}
|
const owned=[];for(const mesh of [pipes,nodes,bends].filter(Boolean)){const material=new THREE.MeshPhysicalMaterial({color:0xffffff,roughness:.3,metalness:.22,clearcoat:.22,clearcoatRoughness:.38});mesh.material=material;mesh.castShadow=true;mesh.receiveShadow=true;owned.push(material);}
|
||||||
const geo=new THREE.ConeGeometry(1,2,12),mat=new THREE.MeshBasicMaterial({color:style.arrowColor}),arrows=new THREE.InstancedMesh(geo,mat,model.links.length);arrows.name='WaterFlowArrows';arrows.userData.instances=model.links.map(l=>({assetId:'inp:link:'+l.id,inpId:l.id,kind:l.kind}));group.add(arrows);
|
const geo=new THREE.ConeGeometry(1,2,12),mat=new THREE.MeshBasicMaterial({color:style.arrowColor}),arrows=new THREE.InstancedMesh(geo,mat,model.links.length);arrows.name='WaterFlowArrows';arrows.userData.instances=model.links.map(l=>({assetId:'inp:link:'+l.id,inpId:l.id,kind:l.kind}));group.add(arrows);
|
||||||
|
const markerGeo=new THREE.OctahedronGeometry(.72,0),markerMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.96,depthTest:false,depthWrite:false}),scadaMarkers=new THREE.InstancedMesh(markerGeo,markerMat,model.nodeInstances.length);scadaMarkers.name='ScadaSensorMarkers';scadaMarkers.userData.instances=model.nodeInstances.map(n=>({assetId:'inp:node:'+n.inpId,inpId:n.inpId,kind:'SCADA'}));scadaMarkers.renderOrder=6;group.add(scadaMarkers);
|
||||||
|
const ringGeo=new THREE.TorusGeometry(1.15,.09,8,28);ringGeo.rotateX(Math.PI/2);const ringMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.76,depthTest:false,depthWrite:false}),scadaRings=new THREE.InstancedMesh(ringGeo,ringMat,model.nodeInstances.length);scadaRings.name='ScadaSensorRings';scadaRings.userData.instances=scadaMarkers.userData.instances;scadaRings.renderOrder=5;group.add(scadaRings);
|
||||||
const matrix=new THREE.Matrix4(),dummy=new THREE.Object3D(),pos=new THREE.Vector3(),quat=new THREE.Quaternion(),scale=new THREE.Vector3();let summary={};
|
const matrix=new THREE.Matrix4(),dummy=new THREE.Object3D(),pos=new THREE.Vector3(),quat=new THREE.Quaternion(),scale=new THREE.Vector3();let summary={};
|
||||||
function apply(){
|
function apply(){
|
||||||
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。');
|
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。');
|
||||||
@@ -57,16 +59,25 @@ export function attachNetworkStyle(group,model){
|
|||||||
group.traverse(o=>{if(o.userData.cadAttachment){const r=model.cadAttachments.find(r=>r.assetId===o.userData.assetId);o.traverse(mesh=>{if(!mesh.isMesh)return;if(mesh.userData.cadRadialScale)mesh.scale.set(style.scale,1,style.scale);if(mesh.userData.cadTerminalScale)mesh.scale.setScalar(style.scale);mesh.material.color.copy(color(style.mode==='pressure'?results?.nodes?.[r.hostNodeId]?.pressure:null));mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;});}});
|
group.traverse(o=>{if(o.userData.cadAttachment){const r=model.cadAttachments.find(r=>r.assetId===o.userData.assetId);o.traverse(mesh=>{if(!mesh.isMesh)return;if(mesh.userData.cadRadialScale)mesh.scale.set(style.scale,1,style.scale);if(mesh.userData.cadTerminalScale)mesh.scale.setScalar(style.scale);mesh.material.color.copy(color(style.mode==='pressure'?results?.nodes?.[r.hostNodeId]?.pressure:null));mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;});}});
|
||||||
if(focusScope){const zero=new THREE.Matrix4().makeScale(0,0,0);for(const [mesh,recs,isNode] of [[pipes,model.pipeInstances,false],[nodes,model.nodeInstances,true],[bends,model.bendInstances,false]]){if(!mesh)continue;recs.forEach((r,i)=>{if(!(isNode?scopeNodes:scopeIds).has(r.inpId))mesh.setMatrixAt(i,zero);else if(isNode&&displayMode==='coordinated'){mesh.getMatrixAt(i,matrix);matrix.decompose(pos,quat,scale);const radius=Math.max(.075,...model.links.filter(l=>scopeIds.has(l.id)&&[l.from,l.to].includes(r.inpId)).map(l=>l.diameterMm/2000));matrix.compose(pos,quat,new THREE.Vector3(radius,radius,radius));mesh.setMatrixAt(i,matrix);}});mesh.instanceMatrix.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}}
|
if(focusScope){const zero=new THREE.Matrix4().makeScale(0,0,0);for(const [mesh,recs,isNode] of [[pipes,model.pipeInstances,false],[nodes,model.nodeInstances,true],[bends,model.bendInstances,false]]){if(!mesh)continue;recs.forEach((r,i)=>{if(!(isNode?scopeNodes:scopeIds).has(r.inpId))mesh.setMatrixAt(i,zero);else if(isNode&&displayMode==='coordinated'){mesh.getMatrixAt(i,matrix);matrix.decompose(pos,quat,scale);const radius=Math.max(.075,...model.links.filter(l=>scopeIds.has(l.id)&&[l.from,l.to].includes(r.inpId)).map(l=>l.diameterMm/2000));matrix.compose(pos,quat,new THREE.Vector3(radius,radius,radius));mesh.setMatrixAt(i,matrix);}});mesh.instanceMatrix.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}}
|
||||||
group.traverse(o=>{if(o.userData.cadAttachment)o.visible=!focusScope;if(o.userData.layoutMode&&focusScope)o.visible=false;});
|
group.traverse(o=>{if(o.userData.cadAttachment)o.visible=!focusScope;if(o.userData.layoutMode&&focusScope)o.visible=false;});
|
||||||
nodes.visible=style.nodes;let arrowCount=0;
|
nodes.visible=style.nodes;let scadaCount=0;
|
||||||
|
model.nodeInstances.forEach((record,index)=>{
|
||||||
|
const visible=results?.nodes?.[record.inpId]?.source==='scada'&&(!focusScope||scopeNodes.has(record.inpId));
|
||||||
|
if(!visible){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);scadaRings.setMatrixAt(index,dummy.matrix);return;}
|
||||||
|
nodes.getMatrixAt(index,matrix);matrix.decompose(pos,quat,scale);
|
||||||
|
dummy.position.copy(pos);dummy.position.y+=1.18;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);
|
||||||
|
dummy.position.copy(pos);dummy.position.y+=.2;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaRings.setMatrixAt(index,dummy.matrix);scadaCount++;
|
||||||
|
});
|
||||||
|
for(const marker of [scadaMarkers,scadaRings]){marker.instanceMatrix.needsUpdate=true;marker.visible=scadaCount>0;marker.computeBoundingBox();marker.computeBoundingSphere();}
|
||||||
|
let arrowCount=0;
|
||||||
model.links.forEach((l,i)=>{
|
model.links.forEach((l,i)=>{
|
||||||
const ids=partsById.get(l.id)??[],idx=ids[Math.floor(ids.length/2)];let direction=style.direction==='topology'?1:style.direction==='results'?metricValue('direction',l,results):0;
|
const ids=partsById.get(l.id)??[],idx=ids[Math.floor(ids.length/2)];let direction=style.direction==='topology'?1:style.direction==='results'?metricValue('direction',l,results):0;
|
||||||
if(!direction||idx===undefined||(focusScope&&!scopeIds.has(l.id))){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();}
|
if(!direction||idx===undefined||(focusScope&&!scopeIds.has(l.id))){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();}
|
||||||
else{pipes.getMatrixAt(idx,matrix);matrix.decompose(pos,quat,scale);dummy.position.copy(pos);dummy.quaternion.copy(quat);if(direction<0)dummy.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),Math.PI));const r=Math.max(.22,scale.x*1.7);dummy.scale.set(r,r*1.5,r);arrowCount++;}
|
else{pipes.getMatrixAt(idx,matrix);matrix.decompose(pos,quat,scale);dummy.position.copy(pos);dummy.quaternion.copy(quat);if(direction<0)dummy.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),Math.PI));const r=Math.max(.22,scale.x*1.7);dummy.scale.set(r,r*1.5,r);arrowCount++;}
|
||||||
dummy.updateMatrix();arrows.setMatrixAt(i,dummy.matrix);
|
dummy.updateMatrix();arrows.setMatrixAt(i,dummy.matrix);
|
||||||
});arrows.instanceMatrix.needsUpdate=true;arrows.visible=style.direction!=='none';arrows.material.color.set(style.arrowColor);arrows.computeBoundingBox();arrows.computeBoundingSphere();
|
});arrows.instanceMatrix.needsUpdate=true;arrows.visible=style.direction!=='none';arrows.material.color.set(style.arrowColor);arrows.computeBoundingBox();arrows.computeBoundingSphere();
|
||||||
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
|
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,scadaMarkers:scadaCount,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
|
||||||
}
|
}
|
||||||
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows};
|
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();markerGeo.dispose();markerMat.dispose();ringGeo.dispose();ringMat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows,scadaMarkers,scadaRings};
|
||||||
apply();return api;
|
apply();return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
||||||
<output id="qa" hidden></output>
|
<output id="qa" hidden></output>
|
||||||
<script type="module" src="./preview.mjs?v=32-context-opacity"></script>
|
<script type="module" src="./preview.mjs?v=34-detailed-network-context"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as THREE from 'three';
|
|||||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||||
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
||||||
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
||||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale';
|
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
|
||||||
import {cadToGltf} from './integration.mjs';
|
import {cadToGltf} from './integration.mjs';
|
||||||
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
|
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ async function load(id){
|
|||||||
}
|
}
|
||||||
async function show(next,{frame=true}={}){
|
async function show(next,{frame=true}={}){
|
||||||
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
|
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
|
||||||
const token=++generation;mode=next;let ids=manifest.mapDefault;
|
const token=++generation;mode=next;let ids=next==='network'?manifest.networkContext:manifest.mapDefault;
|
||||||
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
|
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
|
||||||
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
|
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
|
||||||
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
|
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
type SceneRuntimeState,
|
type SceneRuntimeState,
|
||||||
} from "./sceneProtocol";
|
} from "./sceneProtocol";
|
||||||
|
|
||||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
|
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=34-detailed-network-context";
|
||||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const initialRuntimeState: SceneRuntimeState = {
|
const initialRuntimeState: SceneRuntimeState = {
|
||||||
|
|||||||
@@ -225,14 +225,14 @@ export function ThreeDimensionalTimeline({
|
|||||||
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
<div className="timeline-drag-handle relative flex h-9 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/35 bg-white/[0.08]">
|
||||||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
|
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/55 transition-colors hover:bg-slate-500/70" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="收起时间轴"
|
aria-label="收起时间轴"
|
||||||
title="收起时间轴"
|
title="收起时间轴"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-white/20 text-base text-slate-600 ring-1 ring-white/55 [box-shadow:inset_0_1px_0_rgba(255,255,255,0.72),0_4px_12px_rgba(15,43,69,0.08)] after:absolute after:-inset-1 after:content-[''] transition-[transform,color,background-color,box-shadow] [@media(hover:hover)]:hover:bg-white/50 [@media(hover:hover)]:hover:text-slate-900 [@media(hover:hover)]:hover:[box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),0_6px_16px_rgba(15,43,69,0.14)] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45"
|
||||||
>
|
>
|
||||||
<FiX />
|
<FiX />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
packageId: string;
|
packageId: string;
|
||||||
renderRevision: number;
|
renderRevision: number;
|
||||||
assets: Array<{ file: string; sha256: string }>;
|
assets: Array<{ file: string; sha256: string }>;
|
||||||
|
networkContext: string[];
|
||||||
networkModel: { file: string; metadata: string; sha256: string };
|
networkModel: { file: string; metadata: string; sha256: string };
|
||||||
sourceCatalogFile?: string;
|
sourceCatalogFile?: string;
|
||||||
bindingsFile?: string;
|
bindingsFile?: string;
|
||||||
@@ -28,6 +29,16 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
expect(manifest.packageId).toBe("zjb-web-v29");
|
expect(manifest.packageId).toBe("zjb-web-v29");
|
||||||
expect(manifest.renderRevision).toBe(29);
|
expect(manifest.renderRevision).toBe(29);
|
||||||
expect(manifest.assets).toHaveLength(24);
|
expect(manifest.assets).toHaveLength(24);
|
||||||
|
expect(manifest.networkContext).toEqual([
|
||||||
|
"detail_roof",
|
||||||
|
"detail_facade",
|
||||||
|
"detail_interior",
|
||||||
|
"detail_ceiling",
|
||||||
|
"map_platforms",
|
||||||
|
"map_railway",
|
||||||
|
"map_roads",
|
||||||
|
"map_pump_shell",
|
||||||
|
]);
|
||||||
manifest.assets.forEach((asset) => {
|
manifest.assets.forEach((asset) => {
|
||||||
const assetPath = join(sceneRoot, asset.file);
|
const assetPath = join(sceneRoot, asset.file);
|
||||||
expect(existsSync(assetPath)).toBe(true);
|
expect(existsSync(assetPath)).toBe(true);
|
||||||
@@ -73,6 +84,10 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
join(sceneRoot, "asset-inspector.mjs"),
|
join(sceneRoot, "asset-inspector.mjs"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const networkStyle = readFileSync(
|
||||||
|
join(sceneRoot, "network-style.mjs"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
expect(preview).toContain("tjwater:zjb-scene");
|
expect(preview).toContain("tjwater:zjb-scene");
|
||||||
expect(preview).toContain("HOST_VERSION=2");
|
expect(preview).toContain("HOST_VERSION=2");
|
||||||
@@ -83,10 +98,13 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
expect(preview).toContain("data.type==='clear-results'");
|
expect(preview).toContain("data.type==='clear-results'");
|
||||||
expect(preview).toContain("data.type!=='command'");
|
expect(preview).toContain("data.type!=='command'");
|
||||||
expect(preview).toContain("postHost('selection-changed'");
|
expect(preview).toContain("postHost('selection-changed'");
|
||||||
|
expect(preview).toContain("next==='network'?manifest.networkContext:manifest.mapDefault");
|
||||||
expect(preview).not.toContain("window.zjbNetwork");
|
expect(preview).not.toContain("window.zjbNetwork");
|
||||||
expect(preview).not.toContain("resultsFile");
|
expect(preview).not.toContain("resultsFile");
|
||||||
expect(html).not.toContain("绑定运行结果");
|
expect(html).not.toContain("绑定运行结果");
|
||||||
expect(html).not.toContain("resultsFile");
|
expect(html).not.toContain("resultsFile");
|
||||||
expect(inspector).not.toContain("document.getElementById");
|
expect(inspector).not.toContain("document.getElementById");
|
||||||
|
expect(networkStyle).toContain("ScadaSensorMarkers");
|
||||||
|
expect(networkStyle).toContain("source==='scada'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,6 +149,42 @@ describe("buildSceneFrame", () => {
|
|||||||
expect(frame.stats.missingLinks).toBe(2);
|
expect(frame.stats.missingLinks).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds a SCADA-only frame when the matching simulation frame is absent", () => {
|
||||||
|
const frame = buildSceneFrame({
|
||||||
|
queryTime: new Date("2026-09-14T09:30:00.000Z"),
|
||||||
|
model,
|
||||||
|
nodeRows: [],
|
||||||
|
linkRows: [],
|
||||||
|
pressureDevices: [
|
||||||
|
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||||
|
],
|
||||||
|
scadaRows: [
|
||||||
|
{
|
||||||
|
time: "2026-09-14T09:30:00.000Z",
|
||||||
|
device_id: "S-1",
|
||||||
|
monitored_value: 30.8,
|
||||||
|
cleaned_value: 30.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(frame.resultTime).toBe("2026-09-14T09:30:00.000Z");
|
||||||
|
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||||
|
pressure: 30.9,
|
||||||
|
source: "scada",
|
||||||
|
deviceId: "S-1",
|
||||||
|
});
|
||||||
|
expect(frame.payload?.links).toEqual({});
|
||||||
|
expect(frame.stats).toMatchObject({
|
||||||
|
simulationNodes: 0,
|
||||||
|
simulationLinks: 0,
|
||||||
|
scadaOverrides: 1,
|
||||||
|
});
|
||||||
|
expect(frame.warnings).toEqual([
|
||||||
|
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
|
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
|
||||||
const frame = buildSceneFrame({
|
const frame = buildSceneFrame({
|
||||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||||
@@ -261,4 +297,39 @@ describe("buildSceneFrame", () => {
|
|||||||
"SCADA 数据暂不可用,当前仅显示模拟结果。",
|
"SCADA 数据暂不可用,当前仅显示模拟结果。",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the SCADA-only warning returned by the frame builder", async () => {
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [
|
||||||
|
{
|
||||||
|
time: "2026-09-14T09:30:00.000Z",
|
||||||
|
device_id: "S-1",
|
||||||
|
monitored_value: 30.8,
|
||||||
|
cleaned_value: 30.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame = await fetchSceneFrame({
|
||||||
|
queryTime: new Date("2026-09-14T09:30:00.000Z"),
|
||||||
|
model,
|
||||||
|
pressureDevices: [
|
||||||
|
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||||
|
],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(frame.stats.scadaOverrides).toBe(1);
|
||||||
|
expect(frame.warnings).toEqual([
|
||||||
|
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,6 +157,28 @@ const resolveCommonFrameTime = (
|
|||||||
)[0];
|
)[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveNearestRowTime = <T extends { time: string }>(
|
||||||
|
queryTime: Date,
|
||||||
|
rows: T[],
|
||||||
|
) => {
|
||||||
|
const target = queryTime.getTime();
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set(
|
||||||
|
rows
|
||||||
|
.map((row) => toTimestamp(row.time))
|
||||||
|
.filter(
|
||||||
|
(time): time is number =>
|
||||||
|
time !== null &&
|
||||||
|
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates.sort(
|
||||||
|
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
||||||
|
)[0];
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeLinkStatus = (
|
const normalizeLinkStatus = (
|
||||||
value: number | null,
|
value: number | null,
|
||||||
): SceneLinkResult["status"] => {
|
): SceneLinkResult["status"] => {
|
||||||
@@ -210,7 +232,13 @@ export const buildSceneFrame = ({
|
|||||||
simulationUnits?: NetworkResultUnits;
|
simulationUnits?: NetworkResultUnits;
|
||||||
}): SceneFrame => {
|
}): SceneFrame => {
|
||||||
const selectedTime = queryTime.toISOString();
|
const selectedTime = queryTime.toISOString();
|
||||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
const simulationFrameTime = resolveCommonFrameTime(
|
||||||
|
queryTime,
|
||||||
|
nodeRows,
|
||||||
|
linkRows,
|
||||||
|
);
|
||||||
|
const frameTime =
|
||||||
|
simulationFrameTime ?? resolveNearestRowTime(queryTime, scadaRows);
|
||||||
if (frameTime === null) {
|
if (frameTime === null) {
|
||||||
return {
|
return {
|
||||||
selectedTime,
|
selectedTime,
|
||||||
@@ -313,7 +341,10 @@ export const buildSceneFrame = ({
|
|||||||
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
||||||
ignoredElements,
|
ignoredElements,
|
||||||
},
|
},
|
||||||
warnings: [],
|
warnings:
|
||||||
|
simulationFrameTime === null
|
||||||
|
? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -404,5 +435,5 @@ export const fetchSceneFrame = async ({
|
|||||||
scadaRows,
|
scadaRows,
|
||||||
simulationUnits: networkOptions,
|
simulationUnits: networkOptions,
|
||||||
});
|
});
|
||||||
return { ...frame, warnings };
|
return { ...frame, warnings: [...frame.warnings, ...warnings] };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user