Author SHA1 Message Date
dekim2 56b1f2cb9f test: verify review highlight controls (#6) 2026-10-28 09:51:28 +09:00
dekim2 9332b1a95c test: verify review highlight controls (#6) 2026-10-28 09:51:27 +09:00
dekim2 324e5e0c77 feat: add review highlight controls (#6) 2026-10-28 09:51:27 +09:00
dekim2 49e8ccbcff feat: add review highlight controls (#6) 2026-10-28 09:51:26 +09:00
dekim2 3ed6b9def2 feat: add review highlight controls (#6) 2026-10-28 09:51:25 +09:00
dekim2 3a1069f08e feat: add review highlight controls (#6) 2026-10-28 09:51:25 +09:00
dekim2 a0d01aa41e feat: add review highlight controls (#6) 2026-10-28 09:51:24 +09:00
dekim2 98f3563b3d docs: clarify server startup and port-specific access 2026-10-28 09:39:18 +09:00
dekim2 2e0460d464 Merge pull request '원본 SWMM 자료와 화면 제원 자동 검증 추가 (#1)' (#2) from codex/issue-1-validation into main
Reviewed-on: #2
2026-10-27 17:58:35 +09:00
dekim2 240dd9ff64 test: record issue #3 and #4 verification results 2026-10-27 17:58:05 +09:00
dekim2 69cdd2c1b2 fix: record browser startup failures (#4) 2026-10-27 17:58:04 +09:00
dekim2 7bd2148243 test: reproduce stale browser validation result (#4) 2026-10-27 17:58:03 +09:00
dekim2 bc9bb05900 fix: record browser startup failures (#4) 2026-10-27 17:55:28 +09:00
dekim2 793cbe5aae fix: apply numeric tolerance to nested coordinates (#3) 2026-10-27 17:51:40 +09:00
dekim2 d9109dda1d fix: apply numeric tolerance to nested coordinates (#3) 2026-10-27 17:51:39 +09:00
dekim2 dd3f461d80 docs: add verification commands for issue #1 2026-10-27 16:15:50 +09:00
dekim2 240e077b0d test: add validation/issue-1-result.json for issue #1 2026-10-27 16:15:48 +09:00
dekim2 9786956218 test: add package.json for issue #1 2026-10-27 16:15:46 +09:00
dekim2 4707561278 test: add tests/viewer.cjs for issue #1 2026-10-27 16:15:45 +09:00
dekim2 7fbe2fb7e5 test: add tests/test_validate_network.py for issue #1 2026-10-27 16:15:41 +09:00
dekim2 fbff81d189 test: add tools/validate_network.py for issue #1 2026-10-27 16:15:38 +09:00
13 changed files with 757 additions and 11 deletions

No files matched your search

+26 -3
View File
@@ -8,12 +8,14 @@ Python 3.10 이상과 WebGL을 지원하는 최신 Chrome 또는 Edge가 필요
```bash
cd /home/user/hwacheon-network-viewer
python3 start.py
python3 start.py --port 8081
```
브라우저에서 **http://127.0.0.1:8080/** 를 열면 화천 관망도가 바로 표시됩니다. 실행 터미널을 유지하고 종료할 때 `Ctrl+C`를 누릅니다. 포트가 사용 중이면 `python3 start.py --port 8081`로 실행합니다. 다른 위치에 복사한 경우 해당 프로젝트 폴더에서 실행하면 됩니다.
위 명령을 이 프로젝트 폴더에서 실행한 뒤, 터미널에 표시된 주소로 접속하세요. 위 예제의 주소는 **http://127.0.0.1:8081/** 이며 화천 관망도 화면이 표시됩니다. 주소를 여는 것만으로 서버가 실행되지는 않습니다. 다른 위치에 복사했다면 해당 프로젝트 폴더에서 실행하세요.
내부망 공유는 `python3 start.py --lan`으로 실행합니다. WSL 환경에서는 Windows 포트 전달·방화벽 설정이 별도로 필요합니다. 공유 모드에는 로그인이 없습니다. 글꼴·스크립트·데이터는 모두 프로젝트 안에서 읽습니다.
이 예제는 다른 서버와의 포트 충돌을 피하기 위해 8081을 사용합니다. `--port`를 생략하면 기본 포트는 8080입니다. 해당 주소에 다른 화면이 보이면 이미 그 포트에서 다른 서버가 실행 중일 수 있으므로, 이 프로젝트의 실행 결과와 포트 번호를 확인하세요. 실행 터미널을 유지하고 종료할 때 `Ctrl+C`를 누릅니다.
내부망 공유는 `python3 start.py --lan --port 8081`로 실행합니다. WSL 환경에서는 Windows 포트 전달·방화벽 설정이 별도로 필요합니다. 공유 모드에는 로그인이 없습니다. 글꼴·스크립트·데이터는 모두 프로젝트 안에서 읽습니다.
## 파일 구성
@@ -38,3 +40,24 @@ python3 start.py
- 맨홀 내경 1.2m는 표현용 가정이며 실제 벽 두께·시공 선형·측량 지반고는 복원하지 않습니다.
- 좌표 누락·겹침, 입력 길이와 좌표 거리 차이, 입력 방향 기준 역경사 등이 남아 있습니다. 화면의 기존 데이터 점검 항목은 오류 확정이나 검증 완료를 뜻하지 않습니다.
- 표시·계산의 정확성과 브라우저별 동작에 대한 정식 검증은 별도 과제입니다. 개선·검증 계획은 향후 Gitea 이슈로 관리합니다.
## 검증
원본 INP는 저장소에 포함하지 않습니다. 별도로 준비한 파일 경로를 지정합니다.
```bash
python3 tools/validate_network.py --inp "/path/to/HC_A.inp" --output test-results/data-validation.json
python3 -m unittest discover -s tests -p 'test_*.py' -v
```
첫 명령의 종료 코드는 0=일치, 1=불일치, 2=입력 오류입니다. ID·수량·좌표·표고·접속·길이·단면 및 배치 제외 사유를 비교합니다. 수리 계산이나 실측 형상의 정확성을 인증하는 검사는 아닙니다.
화면 검증은 개발용 Playwright를 별도로 설치하고, 다른 터미널에서 `python3 start.py --port 8081`을 실행한 상태로 수행합니다. 웹사이트 실행 자체에는 Node.js가 필요하지 않습니다.
```bash
npm install
npx playwright install chromium
VIEWER_URL=http://127.0.0.1:8081/ npm run test:ui
```
Linux에서 브라우저 실행에 필요한 시스템 패키지가 없으면 Playwright의 설치 안내를 따릅니다. 다른 주소는 `VIEWER_URL`, 설치된 Chrome/Edge는 `PLAYWRIGHT_CHANNEL=chrome` 또는 `msedge` 환경변수로 지정합니다. 결과 JSON과 화면 이미지는 Git에서 제외되는 `test-results/`에 저장합니다. 검사 범위와 실행 결과는 이슈 #1에서 관리합니다.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "hwacheon-network-viewer-validation",
"private": true,
"scripts": { "test:ui": "node tests/viewer.cjs", "test:runner": "node tests/launch-failure.cjs", "test:audit": "node tests/audit-highlight.cjs" },
"devDependencies": { "playwright": "1.62.1" }
}
+60
View File
@@ -0,0 +1,60 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
const path=require('node:path');
const out=path.resolve(__dirname,'../test-results');
(async()=>{
const browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_CHANNEL?{channel:process.env.PLAYWRIGHT_CHANNEL}:{})});
const page=await browser.newPage({viewport:{width:1440,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const report={status:'failed'};
await fs.mkdir(out,{recursive:true});
const state=()=>page.evaluate(async()=>{
const {app}=await import('./app.js');const r=app.renderer;
r.draw();const gl=r.gl,b=new Uint8Array(r.canvas.width*r.canvas.height*4);
gl.readPixels(0,0,r.canvas.width,r.canvas.height,gl.RGBA,gl.UNSIGNED_BYTE,b);
let red=0,hash=0;for(let i=0;i<b.length;i+=4){if(b[i]>220&&b[i+1]<80&&b[i+2]<120)red++;hash=(hash+b[i]*3+b[i+1]*5+b[i+2]*7)>>>0;}
return {phase:r.auditPhase,enabled:r.auditEnabled,timer:r.auditTimer!==null,red,hash,
targetCount:r.auditPickIds.size,targets:r.facilities.filter(f=>r.auditPickIds.has(f.pickId)).map(f=>f.id)};
});
try{
await page.clock.install({time:new Date('2026-10-28T00:00:00Z')});
await page.goto(process.env.VIEWER_URL||'http://127.0.0.1:8081/',{waitUntil:'networkidle'});
await page.waitForFunction(()=>document.querySelector('#loading').hidden);
await page.clock.pauseAt(new Date('2026-10-28T00:01:00Z'));
const before=await state();assert.equal(before.enabled,false);
const data=await (await page.request.get(new URL('data/network.json',page.url()).href)).json();
const ids=new Set(data.issues.filter(i=>['length_mismatch','adverse_slope'].includes(i.code)).flatMap(i=>i.ids));
const expected=data.links.filter(l=>l.type==='conduit'&&l.renderable&&ids.has(l.id)).map(l=>l.id).sort();
assert.deepEqual(before.targets.sort(),expected);
await page.locator('#audit-highlight').check({force:true});
const on=await state();assert.equal(on.phase,1);assert.ok(on.red>before.red+20);
await page.screenshot({path:path.join(out,'audit-red.png'),fullPage:true});
await page.clock.runFor(1000);
const normal=await state();assert.equal(normal.red,before.red);assert.equal(normal.hash,before.hash);
await page.clock.runFor(1000);assert.equal((await state()).phase,1);
await page.locator('#audit-highlight').uncheck({force:true});const stopped=await state();
assert.equal(stopped.timer,false);assert.equal(stopped.hash,before.hash);
await page.locator('#audit-highlight').check({force:true});await page.locator('#audit-steady').check({force:true});
const fixed=await state();assert.equal(fixed.timer,false);assert.equal(fixed.phase,1);
await page.clock.runFor(1150);assert.equal((await state()).phase,1);
await page.locator('#show-conduit').uncheck({force:true});assert.equal((await state()).red,0);await page.locator('#show-conduit').check({force:true});
const target=expected[0];await page.locator('#search').fill(target);await page.locator('#search-results button').first().click({force:true});
assert.ok((await page.locator('#details').textContent()).includes('점검 필요 · 오류 확정 아님'));
await page.locator('#audit-unplaced').click({force:true});
for(const id of ['OUT_PUMP','C_OUT_PUMP','UHR1-01411'])assert.ok((await page.locator('#audit-list').textContent()).includes(id));
await page.locator('#audit-dialog .close-dialog').click({force:true});
await page.locator('#reset').click({force:true});assert.equal((await state()).enabled,false);
await page.emulateMedia({reducedMotion:'reduce'});await page.locator('#audit-highlight').check({force:true});
const reduced=await state();assert.equal(reduced.timer,false);assert.equal(reduced.phase,1);
assert.equal(await page.locator('#audit-steady').isDisabled(),true);
await page.clock.resume();await page.waitForTimeout(150);
await page.locator('#audit-highlight').scrollIntoViewIfNeeded();
await page.screenshot({path:path.join(out,'audit-fixed.png'),fullPage:true});
assert.deepEqual(errors,[]);Object.assign(report,{status:'passed',target_conduits:expected.length,
red_pixels_on:on.red,red_pixels_off:normal.red,original_colors_restored:true,
checks:['target IDs','red pixels','1 second phases','exact original pixel restoration','timer cancellation',
'steady mode','layer visibility','selection reasons','3 unplaced facilities','reset','reduced motion','no page errors']});
}catch(e){report.error=e.stack;process.exitCode=1;await page.screenshot({path:path.join(out,'audit-failure.png'),fullPage:true}).catch(()=>{});}
finally{await fs.writeFile(path.join(out,'audit-highlight-validation.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+20
View File
@@ -0,0 +1,20 @@
// Reproduce an unavailable-browser failure without requiring a browser/server.
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const {spawnSync}=require('node:child_process');
const folder=path.resolve(__dirname,'../test-results/launch-failure-case');
fs.mkdirSync(folder,{recursive:true});
const result=path.join(folder,'ui-validation.json');
fs.writeFileSync(result,JSON.stringify({status:'passed',previous_run:true}));
const run=spawnSync(process.execPath,[path.join(__dirname,'viewer.cjs')],{
env:{...process.env,PLAYWRIGHT_CHANNEL:'intentionally-invalid-channel',VIEWER_RESULTS_DIR:folder},
encoding:'utf8',timeout:20000
});
if(run.error)throw run.error;
assert.equal(run.status,1,run.stderr||run.stdout);
const report=JSON.parse(fs.readFileSync(result,'utf8'));
assert.equal(report.status,'failed');
assert.match(report.error,/intentionally-invalid-channel/);
assert.equal(report.previous_run,undefined);
console.log('PASS: browser launch failure returns 1 and overwrites stale success JSON.');
+139
View File
@@ -0,0 +1,139 @@
"""Synthetic fixtures: no company INP or network data is embedded in tests."""
import hashlib
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
spec=importlib.util.spec_from_file_location('validator',Path(__file__).resolve().parents[1]/'tools/validate_network.py')
validator=importlib.util.module_from_spec(spec)
spec.loader.exec_module(validator)
INP='''[OPTIONS]
FLOW_UNITS CMS
LINK_OFFSETS DEPTH
[JUNCTIONS]
A 10 3 0 0 0
B 9 4 0 0 0
[CONDUITS]
P A B 5 0.015 0.2 0.1 0 0
[XSECTIONS]
P CIRCULAR 0.6 0 0 0 1
[COORDINATES]
A 0 0
B 3 4
'''
def fixture(text=INP):
return {'meta':{'sha256':hashlib.sha256(text.encode()).hexdigest(),'flowUnits':'CMS',
'offsetMode':'DEPTH','physicalConversion':1},
'nodes':[{'id':'A','type':'junction','line':5,'raw':'A 10 3 0 0 0'.split(),
'invert':10,'maxDepth':3,'top':13,'xy':[0,0],'connections':['P']},
{'id':'B','type':'junction','line':6,'raw':'B 9 4 0 0 0'.split(),
'invert':9,'maxDepth':4,'top':13,'xy':[3,4],'connections':['P']}],
'links':[{'id':'P','type':'conduit','line':8,'raw':'P A B 5 0.015 0.2 0.1 0 0'.split(),
'fromNode':'A','toNode':'B','vertices':[],'pathXY':[[0,0],[3,4]],
'planDistance':5,'length':5,'roughness':.015,'inletOffset':.2,'outletOffset':.1,
'invertStart':10.2,'invertEnd':9.1,'centerStart':10.5,'centerEnd':9.4,
'lengthDelta':0,'lengthDifferenceRatio':0,'slopeInput':.22,'slopePlan':.22,
'renderable':True,'section':{'shape':'CIRCULAR','height':.6,'width':.6,
'diameter':.6,'barrels':1,'raw':'P CIRCULAR 0.6 0 0 0 1'.split()}}],
'summary':{'counts':{'junction':2,'outfall':0,'storage':0,'conduit':1,'pump':0,'weir':0},
'coordinateCount':2,'shapes':{'CIRCULAR':1},'renderedConduits':1,'vertexCount':0},'issues':[]}
class ValidationTests(unittest.TestCase):
def run_case(self,data=None,text=INP):
with tempfile.TemporaryDirectory() as folder:
root=Path(folder)
(root/'source.inp').write_bytes(text.encode())
(root/'network.json').write_text(json.dumps(data or fixture(text)),encoding='utf8')
return validator.validate(root/'source.inp',root/'network.json')
def test_matching_fixture(self):
self.assertEqual(self.run_case()['status'],'passed')
def test_changed_diameter_is_caught(self):
data=fixture();data['links'][0]['section']['diameter']=.9
result=self.run_case(data)
self.assertEqual(result['status'],'failed')
self.assertIn('section.diameter',[e['field'] for e in result['mismatches']])
def test_changed_connection_is_caught(self):
data=fixture();data['links'][0]['toNode']='A'
self.assertEqual(self.run_case(data)['status'],'failed')
def test_missing_facility_is_caught(self):
data=fixture();data['nodes'].pop()
self.assertEqual(self.run_case(data)['status'],'failed')
def test_null_is_not_zero(self):
data=fixture();data['nodes'][0]['invert']=None
self.assertEqual(self.run_case(data)['status'],'failed')
def test_duplicate_inp_id_is_caught(self):
text=INP+'A 5 6\n'
self.assertEqual(self.run_case(fixture(text),text)['status'],'failed')
def test_source_hash_is_checked(self):
data=fixture();data['meta']['sha256']='wrong'
self.assertEqual(self.run_case(data)['status'],'failed')
def test_elevation_offsets_are_absolute(self):
text=INP.replace('LINK_OFFSETS DEPTH','LINK_OFFSETS ELEVATION')
data=fixture(text);data['meta']['offsetMode']='ELEVATION'
data['links'][0].update(invertStart=.2,invertEnd=.1,centerStart=.5,centerEnd=.4)
data['links'][0].update(slopeInput=.02,slopePlan=.02)
self.assertEqual(self.run_case(data,text)['status'],'passed')
def test_rectangular_width_and_height_are_distinct(self):
text=INP.replace('P CIRCULAR 0.6 0 0 0 1','P RECT_CLOSED 0.6 1.2 0 0 1')
data=fixture(text)
data['links'][0]['section'].update(shape='RECT_CLOSED',width=1.2,diameter=None,
raw='P RECT_CLOSED 0.6 1.2 0 0 1'.split())
data['summary']['shapes']={'RECT_CLOSED':1}
self.assertEqual(self.run_case(data,text)['status'],'passed')
data['links'][0]['section']['width']=.6
self.assertEqual(self.run_case(data,text)['status'],'failed')
def test_missing_coordinate_requires_documented_exclusion(self):
text=INP.replace('B 3 4\n','')
data=fixture(text);data['nodes'][1]['xy']=None
data['links'][0].update(pathXY=None,planDistance=None,renderable=False,
lengthDelta=None,lengthDifferenceRatio=None,slopePlan=None)
data['summary']['coordinateCount']=1
data['summary']['renderedConduits']=0
self.assertEqual(self.run_case(data,text)['status'],'failed')
data['issues']=[{'code':'missing_coordinate','ids':['B']},{'code':'unplaced_link','ids':['P']}]
self.assertEqual(self.run_case(data,text)['status'],'passed')
def test_unsupported_shape_does_not_pass(self):
text=INP.replace('CIRCULAR','CUSTOM')
with self.assertRaisesRegex(ValueError,'unsupported conduit shape'):
self.run_case(fixture(text),text)
def test_coordinate_roundoff_inside_tolerance_passes(self):
data=fixture();data['nodes'][1]['xy'][0]+=0.0000005
data['links'][0]['pathXY'][1][0]+=0.0000005
self.assertEqual(self.run_case(data)['status'],'passed')
def test_coordinate_change_outside_tolerance_fails(self):
data=fixture();data['nodes'][1]['xy'][0]+=0.000002
self.assertEqual(self.run_case(data)['status'],'failed')
def test_nested_comparison_rejects_nonfinite_boolean_and_null(self):
for bad in (float('nan'),float('inf'),True,None):
with self.subTest(bad=bad):
self.assertFalse(validator.values_match({'xy':[bad,2]}, {'xy':[1,2]},1e-6))
self.assertFalse(validator.values_match({'flag':1},{'flag':True},1e-6))
def test_nested_comparison_keeps_id_and_order(self):
self.assertFalse(validator.values_match(['A','B'],['B','A'],1e-6))
self.assertFalse(validator.values_match('1',1,1e-6))
self.assertTrue(validator.values_match({'xy':[1.0000005,2]}, {'xy':[1,2]},1e-6))
if __name__=='__main__':
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
// Run start.py first. VIEWER_URL defaults to http://127.0.0.1:8080.
const {chromium}=require('playwright');
const fs=require('node:fs/promises');
const path=require('node:path');
const assert=require('node:assert/strict');
const base=process.env.VIEWER_URL||'http://127.0.0.1:8080';
const out=process.env.VIEWER_RESULTS_DIR?path.resolve(process.env.VIEWER_RESULTS_DIR):path.resolve(__dirname,'../test-results');
(async()=>{
await fs.mkdir(out,{recursive:true});
let browser,page;
const errors=[];
let report={status:'failed',url:base};
try{
browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_CHANNEL?{channel:process.env.PLAYWRIGHT_CHANNEL}:{})});
page=await browser.newPage({viewport:{width:1440,height:1000}});
page.on('pageerror',e=>errors.push(String(e)));
await page.goto(base,{waitUntil:'networkidle'});
await page.waitForFunction(()=>document.querySelector('#loading')?.hidden && document.querySelector('#scene'));
const data=await (await page.request.get(new URL('data/network.json',base).href)).json();
assert.equal(await page.locator('#node-count').textContent(),String(data.summary.counts.junction));
assert.equal(await page.locator('#pipe-count').textContent(),String(data.summary.counts.conduit));
// Check every facility through the same selectFacility function used by UI events.
// Separate click/search checks below exercise the actual event wiring.
const inspected=await page.evaluate(async()=>{
const {app,selectFacility}=await import('./app.js');
const failures=[];
const fmt=n=>Number(n).toLocaleString('ko-KR',{minimumFractionDigits:3,maximumFractionDigits:3});
const prop=(label)=>[...document.querySelectorAll('#details .properties > div')]
.find(r=>r.querySelector('dt')?.textContent===label)?.querySelector('dd')?.textContent;
for(const f of app.facilities){
selectFacility(f);
const check=(label,expected)=>{if(prop(label)!==expected)failures.push({id:f.id,label,actual:prop(label),expected});};
if(document.querySelector('#selected-id')?.textContent!==f.id)failures.push({id:f.id,label:'selected-id'});
if(['junction','outfall','storage'].includes(f.type)){
check('바닥 표고',`${fmt(f.invert)} m`);
check('원본 X / Y',f.xy?`${fmt(f.xy[0])}${fmt(f.xy[1])}`:'좌표 없음 · 미배치');
if(f.maxDepth!=null){check('최대 깊이',`${fmt(f.maxDepth)} m`);check('모델상 상단',`${fmt(f.top)} m`);}
}else if(f.type==='conduit'){
check('입력 길이',`${fmt(f.length)} m`);
check('시작 관저고',`${fmt(f.invertStart)} m`);check('도착 관저고',`${fmt(f.invertEnd)} m`);
check('단면',f.section.shape);
if(f.section.shape==='CIRCULAR')check('안지름',`Ø ${fmt(f.section.diameter)} m`);
else check('폭 × 높이',`${fmt(f.section.width)} × ${fmt(f.section.height)} m`);
check('병렬 관 수',String(f.section.barrels));
}
const placed=f.xy!=null||f.renderable;
if(document.querySelector('#focus-selected').disabled===Boolean(placed))failures.push({id:f.id,label:'placement button'});
}
return {count:app.facilities.length,failures};
});
assert.deepEqual(inspected.failures,[]);
const samples=[data.nodes.find(n=>n.type==='junction'&&n.xy),
data.links.find(l=>l.type==='conduit'&&l.renderable&&l.section.shape==='CIRCULAR'),
data.links.find(l=>l.type==='conduit'&&l.renderable&&l.section.shape==='RECT_CLOSED'),
...data.nodes.filter(n=>!n.xy),...data.links.filter(l=>!l.renderable)];
for(let i=0;i<samples.length;i++){
const f=samples[i];
await page.locator('#search').fill(f.id);
const result=page.locator('#search-results button').filter({has:page.locator('span',{hasText:new RegExp('^'+f.id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$')})});
await result.click();
assert.equal(await page.locator('#selected-id').textContent(),f.id);
if(i<3)await page.screenshot({path:path.join(out,`facility-${i}.png`),fullPage:true});
}
await page.locator('#search').fill('__NO_SUCH_FACILITY__');
assert.equal(await page.locator('#search-results button').count(),0);
await page.locator('#actual-scale').click();
assert.equal(await page.locator('#height-scale').inputValue(),'1');
assert.equal(await page.locator('#size-scale').inputValue(),'1');
await page.locator('[data-view="plan"]').click();
await page.waitForFunction(()=>document.querySelector('[data-view="plan"]').getAttribute('aria-pressed')==='true');
await page.screenshot({path:path.join(out,'plan.png'),fullPage:true});
for(const box of await page.locator('[data-layer]').all()){await box.uncheck();await box.check();}
await page.locator('#open-audit').click();
for(const id of [...data.nodes.filter(n=>!n.xy),...data.links.filter(l=>!l.renderable)].map(f=>f.id))
assert.ok((await page.locator('#audit-list').textContent()).includes(id),`audit missing ${id}`);
await page.locator('#audit-dialog .close-dialog').click();
await page.locator('#reset').click();
await page.screenshot({path:path.join(out,'overview.png'),fullPage:true});
assert.deepEqual(errors,[]);
report={status:'passed',facilities_checked:inspected.count,search_click_samples:samples.map(f=>f.id),
checks:['all facility information panels','summary counts','real search/selection clicks','missing-facility explanations',
'unknown ID','scale reset','plan view','layer toggles','audit dialog','reset','no page errors'],
browser:browser.version(),limitations:['Information consistency; screenshot review is separate from dimensional geometry verification.']};
}catch(e){report.error=e.stack;if(page)await page.screenshot({path:path.join(out,'failure.png'),fullPage:true}).catch(()=>{});process.exitCode=1;}
finally{
try{await fs.writeFile(path.join(out,'ui-validation.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));}
finally{if(browser)await browser.close();}
}
})().catch(e=>{console.error(e);process.exitCode=1;});
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Read-only INP/network.json comparison. Exit 0=match, 1=mismatch, 2=input error."""
import argparse
from collections import Counter, defaultdict
import hashlib
import json
import math
from pathlib import Path
import sys
SECTIONS = {'JUNCTIONS':'junction', 'OUTFALLS':'outfall', 'STORAGE':'storage',
'CONDUITS':'conduit', 'PUMPS':'pump', 'WEIRS':'weir'}
def values_match(actual, expected, tolerance):
"""Apply the same numeric contract at every nesting level."""
if isinstance(expected, bool):
return isinstance(actual, bool) and actual == expected
if isinstance(expected, (float, int)):
return (isinstance(actual, (float, int)) and not isinstance(actual, bool)
and math.isfinite(actual) and math.isfinite(expected)
and abs(actual - expected) <= tolerance)
if isinstance(expected, list):
return (isinstance(actual, list) and len(actual) == len(expected)
and all(values_match(a, e, tolerance) for a, e in zip(actual, expected)))
if isinstance(expected, dict):
return (isinstance(actual, dict) and actual.keys() == expected.keys()
and all(values_match(actual[k], v, tolerance) for k, v in expected.items()))
return type(actual) is type(expected) and actual == expected
def parse_inp(path):
raw = Path(path).read_bytes()
for encoding in ('utf-8-sig', 'cp949'):
try:
text = raw.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError('INP encoding must be UTF-8 or CP949')
sections = defaultdict(list)
section = None
for line, value in enumerate(text.splitlines(), 1):
value = value.split(';', 1)[0].strip()
if not value:
continue
if value.startswith('[') and value.endswith(']'):
section = value[1:-1].upper()
elif section:
sections[section].append((line, value.split()))
return raw, sections
def validate(inp, network, tolerance=1e-6):
raw, sections = parse_inp(inp)
data = json.loads(Path(network).read_text(encoding='utf-8-sig'))
errors, exclusions = [], []
checks = 0
def compare(entity, field, actual, expected):
nonlocal checks
checks += 1
if not values_match(actual, expected, tolerance):
errors.append({'id': entity, 'field': field, 'actual': actual, 'expected': expected})
def number(tokens, index, default=None):
if index >= len(tokens):
if default is not None:
return default
raise ValueError(f'missing numeric field: {tokens}')
value = float(tokens[index])
if not math.isfinite(value):
raise ValueError(f'non-finite input: {tokens}')
return value
def indexed(section):
result = {}
for line, tokens in sections[section]:
if tokens[0] in result:
errors.append({'id': tokens[0], 'field': section, 'error': 'duplicate INP ID'})
result[tokens[0]] = (line, tokens)
return result
options = {t[0]: t[1] for _, t in sections['OPTIONS']}
units = options.get('FLOW_UNITS', 'CFS').upper()
if units not in ('CFS', 'GPM', 'MGD', 'CMS', 'LPS', 'MLD'):
raise ValueError('unsupported FLOW_UNITS: ' + units)
factor = .3048 if units in ('CFS', 'GPM', 'MGD') else 1.0
offset = options.get('LINK_OFFSETS', 'DEPTH').upper()
if offset not in ('DEPTH', 'ELEVATION'):
raise ValueError('unsupported LINK_OFFSETS: ' + offset)
compare('meta', 'sha256', data['meta'].get('sha256'), hashlib.sha256(raw).hexdigest())
compare('meta', 'flowUnits', data['meta'].get('flowUnits'), units)
compare('meta', 'offsetMode', data['meta'].get('offsetMode'), offset)
compare('meta', 'physicalConversion', data['meta'].get('physicalConversion'), factor)
coordinates = {id: [number(t, 1), number(t, 2)]
for id, (_, t) in indexed('COORDINATES').items()}
xs = indexed('XSECTIONS')
vertices = defaultdict(list)
for _, t in sections['VERTICES']:
vertices[t[0]].append([number(t, 1), number(t, 2)])
source_nodes, source_links = {}, {}
expected_counts = {}
for section, kind in SECTIONS.items():
source = indexed(section)
expected_counts[kind] = len(source)
destination = source_nodes if kind in ('junction','outfall','storage') else source_links
for id, record in source.items():
if id in destination:
errors.append({'id': id, 'field': 'id', 'error': 'duplicate across facility types'})
destination[id] = (kind, *record)
if not source_nodes or not source_links:
raise ValueError('INP has no supported network nodes or links')
nodes = {n['id']: n for n in data['nodes']}
links = {l['id']: l for l in data['links']}
compare('nodes', 'unique IDs', len(nodes), len(data['nodes']))
compare('links', 'unique IDs', len(links), len(data['links']))
compare('nodes', 'IDs', sorted(nodes), sorted(source_nodes))
compare('links', 'IDs', sorted(links), sorted(source_links))
expected_connections = defaultdict(list)
for id, (_, _, t) in source_links.items():
expected_connections[t[1]].append(id)
expected_connections[t[2]].append(id)
for id, (kind, line, t) in source_nodes.items():
if id not in nodes:
continue
n = nodes[id]
depth = number(t, 2) * factor if kind != 'outfall' else None
inv = number(t, 1) * factor
for field, expected in {'type':kind, 'line':line, 'raw':t, 'invert':inv,
'maxDepth':depth, 'top':None if depth is None else inv+depth,
'xy':coordinates.get(id)}.items():
compare(id, field, n.get(field), expected)
if kind == 'outfall':
has_stage=t[2].upper() not in ('FREE','NORMAL')
gate_index=4 if has_stage else 3
for field, expected in {'boundary':t[2], 'stage':t[3] if has_stage else None,
'flapGate':t[gate_index] if len(t)>gate_index else 'NO'}.items():
compare(id,field,n.get(field),expected)
elif kind == 'storage':
compare(id,'storageShape',n.get('storageShape'),t[4])
compare(id,'storageCurve',n.get('storageCurve'),t[5] if t[4]=='TABULAR' else None)
compare(id, 'connections', sorted(n.get('connections', [])), sorted(expected_connections[id]))
if id not in coordinates:
exclusions.append({'id':id, 'type':kind, 'reason':'missing_coordinate'})
rendered_conduits=0
shape_counts=Counter()
for id, (kind, line, t) in source_links.items():
if id not in links:
continue
link = links[id]
for field, expected in {'type':kind, 'line':line, 'raw':t,
'fromNode':t[1], 'toNode':t[2], 'vertices':vertices[id]}.items():
compare(id, field, link.get(field), expected)
missing = t[1] not in coordinates or t[2] not in coordinates
path = None if missing else [coordinates[t[1]], *vertices[id], coordinates[t[2]]]
compare(id, 'pathXY', link.get('pathXY'), path)
distance = None if missing else sum(math.dist(a,b) for a,b in zip(path,path[1:]))
compare(id, 'planDistance', link.get('planDistance'), distance)
reason = 'missing_coordinate' if missing else None
if kind == 'pump':
for field, expected in {'pumpCurve':t[3],'initialStatus':t[4],
'startupDepth':number(t,5,0)*factor,
'shutoffDepth':number(t,6,0)*factor}.items():
compare(id,field,link.get(field),expected)
elif kind == 'weir':
crest=number(t,4)*factor
elevation=crest if offset=='ELEVATION' else number(source_nodes[t[1]][2],1)*factor+crest
for field, expected in {'weirType':t[3],'crestOffset':crest,'crestElevation':elevation,
'dischargeCoefficient':number(t,5)}.items():
compare(id,field,link.get(field),expected)
if kind == 'conduit':
if id not in xs:
raise ValueError('missing XSECTIONS: ' + id)
_, x = xs[id]
shape = x[1].upper()
shape_counts[shape]+=1
if shape not in ('CIRCULAR', 'RECT_CLOSED', 'RECT_OPEN', 'TRAPEZOIDAL'):
raise ValueError('unsupported conduit shape: ' + shape)
height = number(x,2) * factor
width = height if shape == 'CIRCULAR' else number(x,3) * factor
section = link.get('section') or {}
for field, expected in {'shape':shape,'height':height,'width':width,
'diameter':height if shape=='CIRCULAR' else None,
'barrels':number(x,6,1),'raw':x}.items():
compare(id, 'section.'+field, section.get(field), expected)
if shape == 'TRAPEZOIDAL':
compare(id, 'section.sideSlopes', section.get('sideSlopes'), [number(x,4),number(x,5)])
start = number(t,5,0)*factor
end = number(t,6,0)*factor
inv_start = start if offset=='ELEVATION' else number(source_nodes[t[1]][2],1)*factor+start
inv_end = end if offset=='ELEVATION' else number(source_nodes[t[2]][2],1)*factor+end
for field, expected in {'length':number(t,3)*factor,'roughness':number(t,4),
'inletOffset':start,'outletOffset':end,'invertStart':inv_start,
'invertEnd':inv_end,'centerStart':inv_start+height/2,
'centerEnd':inv_end+height/2}.items():
compare(id, field, link.get(field), expected)
length=number(t,3)*factor
for field, expected in {'lengthDelta':None if distance is None else distance-length,
'lengthDifferenceRatio':None if distance is None or length<=0 else abs(distance-length)/length,
'slopeInput':None if length<=0 else (inv_start-inv_end)/length,
'slopePlan':None if distance is None or distance<=1e-9 else (inv_start-inv_end)/distance}.items():
compare(id,field,link.get(field),expected)
if distance is not None and distance <= 1e-9:
reason = 'coincident_endpoints'
if reason is None:
rendered_conduits+=1
compare(id, 'renderable', link.get('renderable'), reason is None)
if reason:
exclusions.append({'id':id,'type':kind,'reason':reason})
compare('summary','counts',data['summary'].get('counts'),expected_counts)
compare('summary','shapes',data['summary'].get('shapes'),dict(shape_counts))
compare('summary','renderedConduits',data['summary'].get('renderedConduits'),rendered_conduits)
compare('summary','vertexCount',data['summary'].get('vertexCount'),sum(len(v) for v in vertices.values()))
compare('summary','coordinateCount',data['summary'].get('coordinateCount'),
sum(id in coordinates for id in source_nodes))
for excluded in exclusions:
ids = excluded['id']
allowed = ('missing_coordinate',) if ids in nodes else ('unplaced_link','coincident_endpoints')
compare(ids,'exclusion documented',any(ids in i.get('ids',[]) and i.get('code') in allowed
for i in data['issues']),True)
return {'status':'passed' if not errors else 'failed','checks':checks,
'counts':expected_counts,'mismatches':errors,'excluded_facilities':exclusions,
'tolerance_absolute':tolerance,
'scope':'Input/JSON consistency only; no hydraulic or surveyed geometry validation.'}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--inp',required=True,type=Path)
parser.add_argument('--network',type=Path,default=Path(__file__).resolve().parents[1]/'web/data/network.json')
parser.add_argument('--output',type=Path)
args=parser.parse_args()
try:
report=validate(args.inp,args.network)
code=0 if report['status']=='passed' else 1
except (OSError,ValueError,KeyError,IndexError,TypeError) as exc:
report={'status':'input_error','error':str(exc)}
code=2
text=json.dumps(report,ensure_ascii=False,indent=2)
if args.output:
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(text+'\n',encoding='utf-8')
print(text)
return code
if __name__=='__main__':
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
{
"data": {
"status": "passed",
"checks": 29378,
"counts": {
"junction": 805,
"outfall": 9,
"storage": 1,
"conduit": 843,
"pump": 3,
"weir": 1
},
"mismatches": [],
"excluded_facilities": [
{
"id": "OUT_PUMP",
"type": "outfall",
"reason": "missing_coordinate"
},
{
"id": "UHR1-01411",
"type": "conduit",
"reason": "coincident_endpoints"
},
{
"id": "C_OUT_PUMP",
"type": "conduit",
"reason": "missing_coordinate"
}
],
"tolerance_absolute": 0.000001,
"scope": "Input/JSON consistency only; no hydraulic or surveyed geometry validation."
},
"browser": {
"status": "passed",
"facilities_checked": 1662,
"search_click_samples": [
"MH3",
"UHR1-0058",
"UHR1-02901",
"OUT_PUMP",
"UHR1-01411",
"C_OUT_PUMP"
],
"checks": [
"all facility information panels",
"summary counts",
"real search/selection clicks",
"missing-facility explanations",
"unknown ID",
"scale reset",
"plan view",
"layer toggles",
"audit dialog",
"reset",
"no page errors"
],
"browser": "156.0.4314.42",
"limitations": [
"Information consistency; screenshot review is separate from dimensional geometry verification."
]
},
"validator_unit_tests": {
"passed": 15,
"failed": 0
},
"tested_source_sha256": "1919a11dbb7a4b1d6988da1b9a4bda4529655fd89abc7285cf10703a1d7c1843",
"tested_network_blob": "55f77ce41696118dcf1e0c15b7eafb60271ff9d5",
"tested_base_commit": "5e81e9cfe9a67c1589f50ff087e80c4893e01b64",
"environment": "Windows Python 3.12 / Playwright 1.62.1 / headless Edge; WSL execution not yet verified",
"visual_review": [
"overview.png",
"facility-2.png"
],
"application_files_changed": false,
"followup_issues": {
"3": {
"status": "tested",
"change": "Recursive coordinate tolerance; 4 additional regression tests"
},
"4": {
"status": "tested",
"runner_regression": "Invalid channel overwrites prior success JSON; exit 1 verified",
"normal_browser_rerun": "passed; 1662 facility panels"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"status": "passed",
"target_conduits": 207,
"red_pixels_on": 4457,
"red_pixels_off": 0,
"original_colors_restored": true,
"checks": [
"target IDs",
"red pixels",
"1 second phases",
"exact original pixel restoration",
"timer cancellation",
"steady mode",
"layer visibility",
"selection reasons",
"3 unplaced facilities",
"reset",
"reduced motion",
"no page errors"
]
}
+16
View File
@@ -35,6 +35,8 @@ function drawProfile(f){
export function selectFacility(f){
if(!f)return;app.selected=f;app.renderer.set({selected:f.pickId});$('#view-name').textContent=f.id;
let html=`<span class="detail-type ${f.type}">${labels[f.type]}</span><h2 id="selected-id">${esc(f.id)}</h2>`;
const reviewReasons=app.network.issues.filter(i=>['length_mismatch','adverse_slope','missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code)&&i.ids.includes(f.id));
if(reviewReasons.length)html+=section('점검 필요 · 오류 확정 아님',`<ul class="review-reasons">${reviewReasons.map(i=>`<li>${esc(i.message)}</li>`).join('')}</ul>`);
const placed=isPlaced(f),hidden=!app.renderer.state.layers[layerOf(f)];
html+=`<button class="focus-button" id="focus-selected" ${placed?'':'disabled'}>${placed?'선택 시설로 이동 ⌖':'좌표·형상 확인 필요 · 이동 불가'}</button>`;
if(!placed)html+=`<p class="notice warning">${f.xy===null?'좌표가 없어 위치를 배치하지 않았습니다.':f.id==='UHR1-01411'?'양 끝의 평면 좌표가 같아 관로 형상을 생략했습니다.':'연결 노드 좌표 또는 단면이 없어 형상을 배치하지 않았습니다.'}</p>`;
@@ -85,7 +87,15 @@ function updateScales(){
const height=Number($('#height-scale').value),size=Number($('#size-scale').value);app.renderer.set({height,size,section:$('#section-mode').checked});
$('#height-output').value=`×${height}`;$('#size-output').value=`×${size}`;$('#height-badge').textContent=`높이 ×${height}`;$('#size-badge').textContent=`단면·맨홀 폭 ×${size}`;$('#section-badge').hidden=!$('#section-mode').checked;
}
function syncAuditHighlight(){
const enabled=$('#audit-highlight').checked,reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const steady=$('#audit-steady').checked||reduced;
$('#audit-steady').disabled=!enabled||reduced;
$('#audit-highlight-note').textContent=reduced?'동작 줄이기 설정에 따라 고정 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':steady?'고정 빨간색으로 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':'빨간색 1초 · 원래 색 1초 반복. 오류 확정이 아닌 점검 필요 표시입니다.';
app.renderer.setAuditHighlight(enabled,steady);
}
function reset(){
$('#audit-highlight').checked=false;$('#audit-steady').checked=false;syncAuditHighlight();
$('#height-scale').value=3;$('#size-scale').value=3;$('#section-mode').checked=false;$$('[data-layer]').forEach(b=>{b.checked=true;app.renderer.setLayer(b.dataset.layer,true);});updateScales();app.renderer.set({selected:0});app.selected=null;$('#search').value='';updateSearch();clearDetails();$('#view-name').textContent='전체 관망';app.renderer.setView('iso');app.renderer.fit();toast('표시·배율·시점을 초기화했습니다.');
}
function frame(renderer,error){
@@ -109,6 +119,12 @@ async function main(){
app.network=await response.json();const n=app.network,s=n.summary;
app.facilities=[...n.nodes,...n.links].map((f,i)=>({...f,pickId:i+1}));
app.renderer=new NetworkRenderer($('#scene'),n,app.facilities,selectFacility,frame);
const unplaced=app.facilities.filter(f=>!isPlaced(f));
$('#audit-highlight-count').textContent=`표시 가능한 점검 관로 ${app.renderer.auditPickIds.size}개`;
$('#audit-unplaced').textContent=`위치 표시 불가 ${unplaced.length}개 · 사유 보기 ↗`;
$('#audit-unplaced').onclick=()=>openAudit('placement');
$('#audit-highlight').onchange=syncAuditHighlight;$('#audit-steady').onchange=syncAuditHighlight;
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',syncAuditHighlight);syncAuditHighlight();
$('#node-count').textContent=s.counts.junction;$('#pipe-count').textContent=s.counts.conduit;
for(const [layer,count] of Object.entries({junction:s.counts.junction,conduit:s.counts.conduit,outfall:s.counts.outfall,other:s.counts.storage+s.counts.pump+s.counts.weir}))$(`#layer-${layer}-count`).textContent=count;
$('#circle-count').textContent=s.shapes.CIRCULAR;$('#rect-count').textContent=s.shapes.RECT_CLOSED;$('#source-name').textContent=n.meta.sourceName;
+6
View File
@@ -26,6 +26,12 @@
<label><span><i class="legend-dot outfall"></i>방류구 <small id="layer-outfall-count"></small></span><input id="show-outfall" data-layer="outfall" type="checkbox" checked></label>
<label><span><i class="legend-dot other"></i>저류·펌프·위어 <small id="layer-other-count"></small></span><input id="show-other" data-layer="other" type="checkbox" checked></label>
</div></section>
<section class="audit-highlight-controls" aria-label="점검 대상 강조">
<label class="toggle-label"><span>점검 대상 강조<small id="audit-highlight-count">점검 목록 확인 중</small></span><input id="audit-highlight" type="checkbox"></label>
<label class="audit-steady-label"><input id="audit-steady" type="checkbox"> 고정 강조 (깜빡임 없이)</label>
<p class="fine-print" id="audit-highlight-note">빨간색 1초 · 원래 색 1초 반복<br>오류 확정이 아닌 점검 필요 표시입니다.</p>
<button id="audit-unplaced" class="text-button">위치 표시 불가 시설 확인</button>
</section>
<section><div class="section-heading"><h3>높이와 형상</h3><button id="actual-scale" class="text-button">실제 제원 비율</button></div>
<label class="range-heading" for="height-scale">표고·높이 배율 <output id="height-output">×3</output></label><input id="height-scale" type="range" min="1" max="10" step="0.5" value="3"><div class="range-ends"><span>×1</span><span>×10</span></div>
<label class="range-heading" for="size-scale">관로 단면·맨홀 폭 배율 <output id="size-output">×3</output></label><input id="size-scale" type="range" min="1" max="8" step="0.5" value="3"><div class="range-ends"><span>×1</span><span>×8</span></div>
+27 -8
View File
@@ -1,14 +1,14 @@
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;
attribute vec3 aBase;attribute vec3 aOffset;attribute vec3 aNormal;attribute vec3 aColor;attribute float aId;attribute float aKind;attribute float aSlice;attribute float aAudit;
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;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;varying float vAudit;
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;}`;
vNormal=normalize(aNormal/vec3(1.0,1.0,uHeight));vColor=aColor;vId=aId;vKind=aKind;vSlice=aSlice;vOffset=aOffset;vAudit=aAudit;}`;
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;
uniform vec3 uGlowColor;uniform float uGlow;uniform float uSelected;uniform float uPicking;uniform float uPoints;uniform float uSection;uniform vec2 uCameraDir;uniform float uAuditPulse;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;varying float vAudit;
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;}
@@ -17,6 +17,7 @@ void main(){
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);
if(uAuditPulse>.5&&vAudit>.5&&abs(vId-uSelected)>.1)color=vec3(1.0,.10,.22);
gl_FragColor=vec4(color,1.0);
}`;
export class NetworkRenderer {
@@ -29,14 +30,30 @@ export class NetworkRenderer {
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.attributes=Object.fromEntries(['Base','Offset','Normal','Color','Id','Kind','Slice','Audit'].map(n=>[n,gl.getAttribLocation(p,'a'+n)]));
this.uniforms=Object.fromEntries(['Matrix','Height','Size','Dpr','PointSize','Selected','Picking','Points','Section','CameraDir','Glow','GlowColor','AuditPulse'].map(n=>[n,gl.getUniformLocation(p,'u'+n)]));
const reviewIds=new Set(network.issues.filter(i=>['length_mismatch','adverse_slope'].includes(i.code)).flatMap(i=>i.ids));
this.auditPickIds=new Set(facilities.filter(f=>f.type==='conduit'&&f.renderable&&reviewIds.has(f.id)).map(f=>f.pickId));
this.auditEnabled=false;this.auditSteady=false;this.auditPhase=0;this.auditTimer=null;
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};
const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(batch.data),gl.STATIC_DRAW);
const auditBuffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,auditBuffer);gl.bufferData(gl.ARRAY_BUFFER,Float32Array.from({length:batch.count},(_,i)=>this.auditPickIds.has(batch.data[i*15+12])?1:0),gl.STATIC_DRAW);
this.batches[layer][kind]={buffer,auditBuffer,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();
document.addEventListener('visibilitychange',()=>this.setAuditHighlight(this.auditEnabled,this.auditSteady));
}
setAuditHighlight(enabled,steady=false){
clearTimeout(this.auditTimer);this.auditTimer=null;
this.auditEnabled=enabled;this.auditSteady=steady;this.auditPhase=enabled?1:0;
if(enabled&&!steady&&!document.hidden){
const started=performance.now();
const tick=()=>{const elapsed=performance.now()-started;this.auditPhase=Math.floor(elapsed/1000)%2===0?1:0;this.request();this.auditTimer=setTimeout(tick,1000-elapsed%1000);};
this.auditTimer=setTimeout(tick,1000);
}
this.request();
}
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();}
@@ -85,6 +102,7 @@ export class NetworkRenderer {
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));
gl.uniform1f(u.AuditPulse,picking?0:this.auditPhase);
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));
@@ -92,6 +110,7 @@ export class NetworkRenderer {
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.bindBuffer(gl.ARRAY_BUFFER,b.auditBuffer);gl.enableVertexAttribArray(this.attributes.Audit);gl.vertexAttribPointer(this.attributes.Audit,1,gl.FLOAT,false,4,0);
gl.uniform1f(u.Points,kind==='points'?1:0);gl.drawArrays(kind==='mesh'?gl.TRIANGLES:kind==='lines'?gl.LINES:gl.POINTS,0,b.count);
}
}
+8
View File
@@ -12,3 +12,11 @@
.legend-panel{background:#18282be6;border-color:#3b514b}.legend-title{color:#c1d6c9}.legend-panel>div{color:#a9c5b6}.legend-panel>div span{color:#91a79b}.legend-panel small{color:#8aa296}.line-key.circular{background:#64bdb9}.line-key.rectangular{background:#d7b47a}
.axes>span{color:#839d92}.view-toolbar{background:#152126ed;border-color:#3a4a4d;box-shadow:0 8px 30px #0003}.view-toolbar button{color:#a4bcb0}.view-toolbar button:hover{background:#2d403e}.view-toolbar button[aria-pressed=true]{background:#a7e3cb;color:#1b3a2c}.view-toolbar i{background:#354446}
.viewer-footer>span{color:#8aa69b}.viewer .text-button{color:#a5b8b0}.viewer .text-button:hover{color:#a7e3cb}.datum-note{color:#839d92}
.audit-highlight-controls .toggle-label{align-items:center;gap:12px}
.audit-highlight-controls .toggle-label small{display:block;margin-top:5px}
.audit-highlight-controls .audit-steady-label{display:flex;align-items:center;gap:7px;font-size:12px;margin:12px 0;color:#52695d}
.audit-highlight-controls input{accent-color:#e83650}
.audit-highlight-controls .fine-print{line-height:1.65}
.audit-highlight-controls .text-button{font-size:12px;text-align:left;white-space:normal}
.review-reasons{padding-left:18px;color:#a6384b;font-size:12px;line-height:1.7}
.review-reasons li+li{margin-top:6px}