Files
hwacheon-network-viewer/web/renderer.js

133 lines
15 KiB
JavaScript

import {vec,multiply,perspective,lookAt,project} from './math.js';
import {buildGeometry,layerOf,physicalPoint,conduitPath} from './geometry.js';
const vertex=`precision highp float;
attribute vec3 aBase;attribute vec3 aOffset;attribute vec3 aNormal;attribute vec3 aColor;attribute float aId;attribute float aKind;attribute float aSlice;
uniform mat4 uMatrix;uniform float uHeight;uniform float uSize;uniform float uDpr;uniform float uPointSize;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;
void main(){vec3 p=aBase+aOffset*uSize;p.z*=uHeight;gl_Position=uMatrix*vec4(p,1.0);gl_PointSize=uPointSize*uDpr;
vNormal=normalize(aNormal/vec3(1.0,1.0,uHeight));vColor=aColor;vId=aId;vKind=aKind;vSlice=aSlice;vOffset=aOffset;}`;
const fragment=`precision highp float;
uniform vec3 uGlowColor;uniform float uGlow;uniform float uSelected;uniform float uPicking;uniform float uPoints;uniform float uSection;uniform vec2 uCameraDir;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;
void main(){
if(uPoints>.5&&length(gl_PointCoord-vec2(.5))>.5)discard;
if(uSection>.5){if(vKind>.5&&vKind<1.5&&dot(vOffset.xy,uCameraDir)>0.0)discard;if(vKind>1.5&&vKind<2.5&&vSlice>.03)discard;}
if(uPicking>.5){float id=floor(vId+.5);if(id<.5)discard;gl_FragColor=vec4(mod(id,256.0)/255.0,mod(floor(id/256.0),256.0)/255.0,floor(id/65536.0)/255.0,1.0);return;}
vec3 n=normalize(vNormal);if(!gl_FrontFacing)n=-n;float light=.46+.42*max(dot(n,normalize(vec3(-.45,-.65,1.0))),0.0)+.15*max(dot(n,normalize(vec3(.6,.3,.5))),0.0);
vec3 color=vColor;if(vKind<3.5&&uPoints<.5)color*=light;
if(abs(vId-uSelected)<.1&&uSelected>.5)color=mix(vec3(.97,.41,.14),vec3(1.0,.78,.3),.32*max(n.z,0.0));
if(uGlow>0.0&&abs(vId-uSelected)>.1)color=mix(color,min(vec3(1.0),uGlowColor*1.25+vec3(.08)),uGlow);
gl_FragColor=vec4(color,1.0);
}`;
export class NetworkRenderer {
constructor(canvas,network,facilities,onSelect,onFrame){
this.canvas=canvas;this.network=network;this.facilities=facilities;this.onSelect=onSelect;this.onFrame=onFrame;
this.state={height:3,size:3,section:false,selected:0,layers:{junction:true,conduit:true,outfall:true,other:true}};
this.camera={azimuth:-.92,elevation:.73,distance:2400,target:[0,0,30]};this.view='iso';this.autoFit=true;this.frame=0;this.matrix=null;this.lastRenderMs=0;
this.gl=canvas.getContext('webgl',{alpha:true,antialias:true,preserveDrawingBuffer:true});
if(!this.gl)throw Error('WebGL을 시작할 수 없습니다. 브라우저의 하드웨어 가속 설정을 확인해 주세요.');
const gl=this.gl;
const shader=(type,source)=>{const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(s));return s;};
const p=gl.createProgram();gl.attachShader(p,shader(gl.VERTEX_SHADER,vertex));gl.attachShader(p,shader(gl.FRAGMENT_SHADER,fragment));gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw Error(gl.getProgramInfoLog(p));this.program=p;
this.attributes=Object.fromEntries(['Base','Offset','Normal','Color','Id','Kind','Slice'].map(n=>[n,gl.getAttribLocation(p,'a'+n)]));
this.uniforms=Object.fromEntries(['Matrix','Height','Size','Dpr','PointSize','Selected','Picking','Points','Section','CameraDir','Glow','GlowColor'].map(n=>[n,gl.getUniformLocation(p,'u'+n)]));
this.batches={};const geometry=buildGeometry(network,facilities);
for(const [layer,groups] of Object.entries(geometry)){this.batches[layer]={};for(const [kind,batch] of Object.entries(groups)){
const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(batch.data),gl.STATIC_DRAW);this.batches[layer][kind]={buffer,count:batch.count};
}}
this.picking={fbo:gl.createFramebuffer(),texture:gl.createTexture(),depth:gl.createRenderbuffer()};
this.interactions();this.observer=new ResizeObserver(()=>this.resize());this.observer.observe(canvas);this.resize();this.fit();
}
visible(f){return this.state.layers[layerOf(f)]&&(f.xy!=null||f.renderable);}
set(patch){const oldHeight=this.state.height;Object.assign(this.state,patch);if(patch.height||patch.size){if(this.autoFit)this.fit();else if(patch.height)this.camera.target[2]*=patch.height/oldHeight;}this.request();}
setLayer(layer,value){this.state.layers[layer]=value;this.request();}
request(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.draw();});}
resize(){
const previousWidth=this.width,previousHeight=this.height;const r=this.canvas.getBoundingClientRect();this.width=Math.max(1,r.width);this.height=Math.max(1,r.height);this.dpr=Math.min(devicePixelRatio||1,2);
this.canvas.width=Math.round(this.width*this.dpr);this.canvas.height=Math.round(this.height*this.dpr);const gl=this.gl;
gl.bindTexture(gl.TEXTURE_2D,this.picking.texture);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,this.canvas.width,this.canvas.height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.bindRenderbuffer(gl.RENDERBUFFER,this.picking.depth);gl.renderbufferStorage(gl.RENDERBUFFER,gl.DEPTH_COMPONENT16,this.canvas.width,this.canvas.height);gl.bindFramebuffer(gl.FRAMEBUFFER,this.picking.fbo);gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,this.picking.texture,0);gl.framebufferRenderbuffer(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.RENDERBUFFER,this.picking.depth);if(gl.checkFramebufferStatus(gl.FRAMEBUFFER)!==gl.FRAMEBUFFER_COMPLETE)throw Error('시설 선택용 렌더 버퍼를 만들 수 없습니다.');gl.bindFramebuffer(gl.FRAMEBUFFER,null);if(this.autoFit&&previousWidth&&(previousWidth!==this.width||previousHeight!==this.height))this.fit();this.request();
}
pointsFor(f){
if(f.xy){const p=physicalPoint(f,this.network.meta.origin);return[p,[p[0],p[1],p[2]+(f.maxDepth??1)]];}
if(f.type==='conduit'&&f.renderable)return conduitPath(f,this.network.meta.origin).flatMap(p=>[p,[p[0],p[1],p[2]+f.section.height*this.state.size]]);
return f.symbolPoint?[f.symbolPoint]:[];
}
fit(){
this.autoFit=true;const pts=this.facilities.filter(f=>this.visible(f)).flatMap(f=>this.pointsFor(f));if(!pts.length){this.request();return;}
const min=[0,1,2].map(i=>Math.min(...pts.map(p=>p[i]*(i===2?this.state.height:1)))),max=[0,1,2].map(i=>Math.max(...pts.map(p=>p[i]*(i===2?this.state.height:1))));
this.camera.target=min.map((n,i)=>(n+max[i])/2);const span=Math.max(max[0]-min[0],max[1]-min[1],(max[2]-min[2]));
this.camera.distance=Math.max(30,span*1.65/Math.min(this.width/this.height,.95));
// Fit the actual projected envelope into the unobstructed area, rather than
// placing an asymmetric network behind the header or view toolbar.
const world=pts.map(p=>[p[0],p[1],p[2]*this.state.height]);
for(let pass=0;pass<3;pass++){
const c=this.camera,eye=c.target.map((v,i)=>v+c.distance*[Math.cos(c.elevation)*Math.cos(c.azimuth),Math.cos(c.elevation)*Math.sin(c.azimuth),Math.sin(c.elevation)][i]);
const m=multiply(perspective(this.width/this.height,.03,100000),lookAt(eye,c.target));
const screen=world.map(p=>project(p,m,this.width,this.height));
const minX=Math.min(...screen.map(p=>p.x)),maxX=Math.max(...screen.map(p=>p.x)),minY=Math.min(...screen.map(p=>p.y)),maxY=Math.max(...screen.map(p=>p.y));
const desiredX=this.width/2,desiredY=(190+this.height-125)/2;
this.pan(desiredX-(minX+maxX)/2,desiredY-(minY+maxY)/2);
if(pass<2){const ratio=Math.max((maxX-minX)/Math.max(100,this.width-100),(maxY-minY)/Math.max(100,this.height-330));this.camera.distance*=Math.max(.6,ratio*1.08);}
}
this.request();
}
focus(f){
const points=this.pointsFor(f);if(!points.length)return false;this.autoFit=false;
const min=[0,1,2].map(i=>Math.min(...points.map(p=>p[i]*(i===2?this.state.height:1)))),max=[0,1,2].map(i=>Math.max(...points.map(p=>p[i]*(i===2?this.state.height:1))));
this.camera.target=min.map((v,i)=>(v+max[i])/2);
const span=Math.hypot(...min.map((n,i)=>max[i]-n));this.camera.distance=Math.max(f.type==='conduit'?18:28,span*2.2/Math.min(this.width/this.height,.95));this.request();return true;
}
setView(view){this.view=view;if(view==='plan')Object.assign(this.camera,{azimuth:-Math.PI/2,elevation:Math.PI/2-.0001});else if(view==='side')Object.assign(this.camera,{azimuth:-Math.PI/2,elevation:0});else Object.assign(this.camera,{azimuth:-.92,elevation:.73});if(this.autoFit)this.fit();this.request();}
draw(picking=false){
const started=performance.now(),gl=this.gl,u=this.uniforms,c=this.camera;
const eye=[c.target[0]+c.distance*Math.cos(c.elevation)*Math.cos(c.azimuth),c.target[1]+c.distance*Math.cos(c.elevation)*Math.sin(c.azimuth),c.target[2]+c.distance*Math.sin(c.elevation)];
this.matrix=multiply(perspective(this.width/this.height,Math.max(.03,c.distance/15000),Math.max(30000,c.distance*5)),lookAt(eye,c.target));
gl.bindFramebuffer(gl.FRAMEBUFFER,picking?this.picking.fbo:null);gl.viewport(0,0,this.canvas.width,this.canvas.height);gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);gl.disable(gl.BLEND);gl.disable(gl.DITHER);gl.disable(gl.CULL_FACE);gl.useProgram(this.program);
gl.uniformMatrix4fv(u.Matrix,false,this.matrix);gl.uniform1f(u.Height,this.state.height);gl.uniform1f(u.Size,this.state.size);gl.uniform1f(u.Dpr,this.dpr);gl.uniform1f(u.PointSize,picking?9:5);gl.uniform1f(u.Selected,this.state.selected);gl.uniform1f(u.Picking,picking?1:0);gl.uniform1f(u.Section,this.state.section?1:0);gl.uniform2f(u.CameraDir,Math.cos(c.azimuth),Math.sin(c.azimuth));
for(const [layer,groups] of Object.entries(this.batches)){
if(layer==='grid'&&picking)continue;if(layer!=='grid'&&!this.state.layers[layer])continue;
gl.uniform1f(u.Glow,picking?0:(this.glowForLayer?.(layer)||0));
gl.uniform3fv(u.GlowColor,this.glowColorForLayer?.(layer)||[0,0,0]);
for(const [kind,b] of Object.entries(groups)){
if(!b.count)continue;gl.bindBuffer(gl.ARRAY_BUFFER,b.buffer);
let offset=0;for(const [name,size] of [['Base',3],['Offset',3],['Normal',3],['Color',3],['Id',1],['Kind',1],['Slice',1]]){const a=this.attributes[name];gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,size,gl.FLOAT,false,60,offset*4);offset+=size;}
gl.uniform1f(u.Points,kind==='points'?1:0);gl.drawArrays(kind==='mesh'?gl.TRIANGLES:kind==='lines'?gl.LINES:gl.POINTS,0,b.count);
}
}
if(!picking){this.drawAxes();this.lastRenderMs=performance.now()-started;this.onFrame?.(this);}
}
pick(clientX,clientY){
const r=this.canvas.getBoundingClientRect(),gl=this.gl,x=Math.round((clientX-r.left)*this.dpr),y=Math.round((r.bottom-clientY)*this.dpr),radius=Math.ceil(4*this.dpr),x0=Math.max(0,x-radius),y0=Math.max(0,y-radius),w=Math.min(this.canvas.width-x0,2*radius+1),h=Math.min(this.canvas.height-y0,2*radius+1);
if(w<=0||h<=0)return;this.draw(true);const bytes=new Uint8Array(w*h*4);gl.readPixels(x0,y0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,bytes);let best=null;
for(let j=0;j<h;j++)for(let i=0;i<w;i++){const k=(j*w+i)*4,id=bytes[k]+bytes[k+1]*256+bytes[k+2]*65536;if(!id)continue;const distance=(i+x0-x)**2+(j+y0-y)**2;if(!best||distance<best.distance)best={id,distance};}
gl.bindFramebuffer(gl.FRAMEBUFFER,null);this.request();if(best)this.onSelect(this.facilities.find(f=>f.pickId===best.id));
}
projectFacility(f){const pts=this.pointsFor(f);if(!pts.length||!this.matrix)return null;const p=f.type==='conduit'?[0,1,2].map(i=>pts.reduce((sum,v)=>sum+v[i],0)/pts.length):pts.at(-1).slice();p[2]*=this.state.height;return project(p,this.matrix,this.width,this.height);}
drawAxes(){
const canvas=document.querySelector('#axes'),ctx=canvas.getContext('2d'),c=this.camera;ctx.clearRect(0,0,110,100);const cx=54,cy=54;
const right=[-Math.sin(c.azimuth),Math.cos(c.azimuth),0],up=[-Math.cos(c.azimuth)*Math.sin(c.elevation),-Math.sin(c.azimuth)*Math.sin(c.elevation),Math.cos(c.elevation)];
for(const [v,name,color] of [[[1,0,0],'X','#d2a18d'],[[0,1,0],'Y','#8fbea3'],[[0,0,1],'Z','#9ebed4']]){
const dx=vec.dot(v,right)*27,dy=-vec.dot(v,up)*27;ctx.strokeStyle=color;ctx.lineWidth=1.4;ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke();ctx.fillStyle=color;ctx.font='10px system-ui';ctx.textAlign='center';ctx.fillText(name,cx+dx*1.3,cy+dy*1.3+3);
}ctx.fillStyle='#adc8b9';ctx.beginPath();ctx.arc(cx,cy,2,0,Math.PI*2);ctx.fill();
}
pan(dx,dy,manual=false){
if(manual)this.autoFit=false;const c=this.camera,units=c.distance*.73/this.height,right=[-Math.sin(c.azimuth),Math.cos(c.azimuth),0],up=[-Math.cos(c.azimuth)*Math.sin(c.elevation),-Math.sin(c.azimuth)*Math.sin(c.elevation),Math.cos(c.elevation)];
c.target=c.target.map((v,i)=>v-dx*units*right[i]+dy*units*up[i]);
}
interactions(){
const el=this.canvas,pointers=new Map();let moved=0,start=null,previous=null;
el.addEventListener('contextmenu',e=>e.preventDefault());
el.addEventListener('pointerdown',e=>{el.focus({preventScroll:true});el.setPointerCapture(e.pointerId);pointers.set(e.pointerId,[e.clientX,e.clientY]);if(pointers.size===1){start=[e.clientX,e.clientY];moved=0;}else moved=100;previous=[...pointers.values()];});
el.addEventListener('pointermove',e=>{if(!pointers.has(e.pointerId))return;pointers.set(e.pointerId,[e.clientX,e.clientY]);const now=[...pointers.values()];if(now.length===1&&previous?.length===1){const dx=now[0][0]-previous[0][0],dy=now[0][1]-previous[0][1];moved+=Math.hypot(dx,dy);if(e.shiftKey||e.buttons===2||e.buttons===4)this.pan(dx,dy,true);else{this.autoFit=false;this.camera.azimuth-=dx*.007;this.camera.elevation=Math.max(-.15,Math.min(Math.PI/2-.0001,this.camera.elevation+dy*.006));this.view='custom';}}
else if(now.length===2&&previous?.length===2){const mid=p=>[(p[0][0]+p[1][0])/2,(p[0][1]+p[1][1])/2],n=mid(now),p=mid(previous);this.pan(n[0]-p[0],n[1]-p[1],true);const d=p=>Math.hypot(p[0][0]-p[1][0],p[0][1]-p[1][1]);this.camera.distance=Math.max(2,Math.min(15000,this.camera.distance*d(previous)/Math.max(1,d(now))));}
previous=now;this.request();});
el.addEventListener('pointerup',e=>{if(pointers.size===1&&moved<5&&start&&e.button===0)this.pick(e.clientX,e.clientY);pointers.delete(e.pointerId);previous=[...pointers.values()];});
el.addEventListener('pointercancel',e=>{pointers.delete(e.pointerId);previous=[...pointers.values()];moved=100;});
el.addEventListener('wheel',e=>{e.preventDefault();this.autoFit=false;this.camera.distance=Math.max(2,Math.min(15000,this.camera.distance*Math.exp(Math.max(-1,Math.min(1,e.deltaY*.001)))));this.request();},{passive:false});
el.addEventListener('keydown',e=>{this.autoFit=false;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();this.camera.azimuth+=e.key==='ArrowLeft'?.07:e.key==='ArrowRight'?-.07:0;this.camera.elevation=Math.max(-.15,Math.min(Math.PI/2-.0001,this.camera.elevation+(e.key==='ArrowUp'?.06:e.key==='ArrowDown'?-.06:0)));this.view='custom';}else if(['+','=','-','_'].includes(e.key)){e.preventDefault();this.camera.distance*=e.key==='-'||e.key==='_'?1.15:.87;}this.request();});
el.addEventListener('webglcontextlost',e=>{e.preventDefault();this.onFrame?.(this,'contextlost');});
el.addEventListener('webglcontextrestored',()=>location.reload());
}
}