knosky 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -114,7 +114,7 @@
114
114
  <div id="boot">Building the city…</div>
115
115
  <header></header>
116
116
  <aside id="nav">
117
- <div class="navbrand"><svg class="logo" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-label="Sathia">
117
+ <div class="navbrand"><svg class="logo" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-label="KnoSky">
118
118
  <defs><radialGradient id="og" cx="50%" cy="50%" r="50%">
119
119
  <stop offset="0%" stop-color="#f0b454" stop-opacity=".55"/><stop offset="55%" stop-color="#e89a2e" stop-opacity=".14"/><stop offset="100%" stop-color="#e89a2e" stop-opacity="0"/>
120
120
  </radialGradient></defs>
@@ -171,6 +171,27 @@
171
171
  let CITY={nodes:[],generated_at:""};
172
172
  let GD="";
173
173
  function hashCode(s){let h=2166136261;s=String(s);for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return (h>>>0);}
174
+
175
+ // ---------- LOD / clustering / viewport-culling / streaming (SAT-447) ----------
176
+ // Tier constants (mirror core/lod.mjs — kept inline so the renderer stays self-contained)
177
+ const LOD_FULL=0,LOD_SIMPLE=1,LOD_DOT=2,LOD_CLUSTER=3;
178
+ // Zoom thresholds: below each boundary the next LOD tier kicks in
179
+ const LOD_FULL_THRESH=0.35,LOD_SIMPLE_THRESH=0.12,LOD_DOT_THRESH=0.06;
180
+ // Streaming: repos with this many nodes get progressive OBJ population across rAF frames
181
+ const STREAM_THRESH=400,STREAM_BATCH=80;
182
+ function lodForZoom(z){if(z>=LOD_FULL_THRESH)return LOD_FULL;if(z>=LOD_SIMPLE_THRESH)return LOD_SIMPLE;if(z>=LOD_DOT_THRESH)return LOD_DOT;return LOD_CLUSTER;}
183
+ // Current-frame LOD tier + axis-aligned world-space clip rect (updated once per render)
184
+ let _lod=LOD_FULL,_clip={wx0:-1e9,wy0:-1e9,wx1:1e9,wy1:1e9};
185
+ function updateLodAndClip(){
186
+ _lod=lodForZoom(cam.zoom);
187
+ const pad=128; // world-unit margin so tiles at the edge aren't clipped
188
+ _clip={wx0:(0-cam.x)/cam.zoom-pad,wy0:(0-cam.y)/cam.zoom-pad,
189
+ wx1:(innerWidth-cam.x)/cam.zoom+pad,wy1:(innerHeight-cam.y)/cam.zoom+pad};}
190
+ function inView(wx,wy){return wx>=_clip.wx0&&wx<=_clip.wx1&&wy>=_clip.wy0&&wy<=_clip.wy1;}
191
+ // District cluster descriptors — one entry per district, built at ingest time
192
+ let CLUSTERS=[];
193
+ function buildClustersNow(){CLUSTERS=[];const acc={};for(const n of NODES){const k=n.d;if(!acc[k])acc[k]={sumX:0,sumY:0,count:0};acc[k].sumX+=n.bx;acc[k].sumY+=n.by;acc[k].count++;}Object.keys(acc).forEach(k=>{const a=acc[k];const cfg=DCFG[k]||{name:k,color:'#7c83a3'};CLUSTERS.push({d:k,label:cfg.name,color:cfg.color,count:a.count,wx:a.sumX/a.count,wy:a.sumY/a.count});});}
194
+
174
195
  const TW=64,TH=32;
175
196
  // central relative-size system (multiples of a tile) — tune here to keep everything proportional
176
197
  const SCALE={building:1.5,tree:0.7,car:0.6,fountain:0.5,bench:0.34,lamp:0.62};
@@ -229,19 +250,43 @@
229
250
  const TOWER=[0,1,2,21,23],HOUSE=[16,18,17,19,20];
230
251
  let OBJ=[];
231
252
  let ROADS=[],roadSet=new Set();
253
+ // Streaming geometry build state: when the node count is large, OBJ is populated
254
+ // incrementally across animation frames so the page stays responsive.
255
+ let _streamPending=null,_streamDone=false;
256
+ function _applyNodeGeom(n){const h=hashCode(n.id);n.gx=n._gx;n.gy=n._gy;
257
+ n.scale=0.62+((h>>2)%36)/100;n.h=1+((h>>7)%6)+(((h>>11)%5===0)?3:0);n.bright=0.9+((h>>17)%20)/100;
258
+ const w=isoToWorld(n.gx,n.gy);n.bx=w.x;n.by=w.y;
259
+ const pool=n.kind==='decision'?TOWER:HOUSE;n.cell=pool[(h>>5)%pool.length];n.bscale=0.97+((h>>9)%10)/100;n.bv=(n.kind==='decision'?(h%6):6+(h%10));}
232
260
  function buildGeometry(){
233
261
  LAY=buildLayout(NODES);IB=LAY.IB;
234
- NODES.forEach(n=>{const h=hashCode(n.id);n.gx=n._gx;n.gy=n._gy;
235
- n.scale=0.62+((h>>2)%36)/100;n.h=1+((h>>7)%6)+(((h>>11)%5===0)?3:0);n.bright=0.9+((h>>17)%20)/100;
236
- const w=isoToWorld(n.gx,n.gy);n.bx=w.x;n.by=w.y;
237
- const pool=n.kind==='decision'?TOWER:HOUSE;n.cell=pool[(h>>5)%pool.length];n.bscale=0.97+((h>>9)%10)/100;n.bv=(n.kind==='decision'?(h%6):6+(h%10));});
238
- OBJ=[];
239
- NODES.forEach(n=>{if(n.gx!=null)OBJ.push({k:'b',gx:n.gx,gy:n.gy,n});});
240
- TILES.forEach(t=>{if(t.type==='tree')OBJ.push({k:'t',gx:t.gx,gy:t.gy,seed:hashCode('t'+t.gx+'_'+t.gy)});});
241
- PROPS.forEach(p=>OBJ.push({k:'p',gx:p.gx,gy:p.gy,type:p.type}));
242
- OBJ.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
243
262
  ROADS=TILES.filter(t=>t.type==='road');roadSet=new Set(ROADS.map(t=>t.gx+","+t.gy));
244
- }
263
+ // Always apply geometry attributes synchronously so bx/by are ready for clustering
264
+ NODES.forEach(_applyNodeGeom);
265
+ if(NODES.length>=STREAM_THRESH){
266
+ // Large repo: populate OBJ skeleton synchronously (buildings only) so first frames
267
+ // can render clusters + initial buildings, then stream trees/props over rAF batches.
268
+ OBJ=[];
269
+ NODES.forEach(n=>{if(n.gx!=null)OBJ.push({k:'b',gx:n.gx,gy:n.gy,n});});
270
+ OBJ.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
271
+ // Queue tree/prop batches for streaming
272
+ const extras=[];
273
+ TILES.forEach(t=>{if(t.type==='tree')extras.push({k:'t',gx:t.gx,gy:t.gy,seed:hashCode('t'+t.gx+'_'+t.gy)});});
274
+ PROPS.forEach(p=>extras.push({k:'p',gx:p.gx,gy:p.gy,type:p.type}));
275
+ _streamPending=extras;_streamDone=false;
276
+ _scheduleStream();
277
+ }else{
278
+ OBJ=[];
279
+ NODES.forEach(n=>{if(n.gx!=null)OBJ.push({k:'b',gx:n.gx,gy:n.gy,n});});
280
+ TILES.forEach(t=>{if(t.type==='tree')OBJ.push({k:'t',gx:t.gx,gy:t.gy,seed:hashCode('t'+t.gx+'_'+t.gy)});});
281
+ PROPS.forEach(p=>OBJ.push({k:'p',gx:p.gx,gy:p.gy,type:p.type}));
282
+ OBJ.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
283
+ _streamPending=null;_streamDone=true;}}
284
+ function _scheduleStream(){if(!_streamPending||!_streamPending.length){_streamDone=true;return;}
285
+ requestAnimationFrame(()=>{if(!_streamPending)return;
286
+ const batch=_streamPending.splice(0,STREAM_BATCH);
287
+ for(const o of batch)OBJ.push(o);
288
+ OBJ.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
289
+ if(_streamPending.length)_scheduleStream();else{_streamDone=true;_streamPending=null;}});}
245
290
  function roadNbrs(gx,gy){const o=[];[[1,0],[-1,0],[0,1],[0,-1]].forEach(d=>{if(roadSet.has((gx+d[0])+","+(gy+d[1])))o.push(d);});return o;}
246
291
 
247
292
  // ---------- tree sprite-sheet constants (legacy 7.5MB sheet fallback removed; dedicated trees.png is the live path) ----------
@@ -412,7 +457,7 @@
412
457
 
413
458
  // ---------- ground ----------
414
459
  const GRASS="#3c6a3a";
415
- function drawGround(){for(const t of TILES){const w=isoToWorld(t.gx,t.gy);diamondPath(w.x,w.y,TW/2,TH/2);let col;
460
+ function drawGround(){for(const t of TILES){const w=isoToWorld(t.gx,t.gy);if(!inView(w.x,w.y))continue;diamondPath(w.x,w.y,TW/2,TH/2);let col;
416
461
  if(t.type==='road')col=PAL.night?[26,27,38]:[64,66,76];
417
462
  else if(t.type==='park')col=PAL.night?[54,60,44]:[150,156,118];
418
463
  else if(t.type==='plaza')col=PAL.night?[64,61,51]:[120,114,92];
@@ -426,7 +471,7 @@
426
471
  if(vr){ctx.moveTo(w.x-TW/4,w.y+TH/4);ctx.lineTo(w.x+TW/4,w.y-TH/4);}
427
472
  ctx.stroke();ctx.setLineDash([]);}
428
473
  }}
429
- function drawShadows(){ctx.fillStyle="rgba(0,0,0,.22)";for(const o of OBJ){if(o.k!=='b')continue;const n=o.n;if(filteredHidden.has(n.id))continue;const w=isoToWorld(n.gx,n.gy);diamondPath(w.x+8,w.y+5,TW*0.46*n.bscale,TH*0.46*n.bscale);ctx.fill();}}
474
+ function drawShadows(){if(_lod>=LOD_SIMPLE)return;ctx.fillStyle="rgba(0,0,0,.22)";for(const o of OBJ){if(o.k!=='b')continue;const n=o.n;if(filteredHidden.has(n.id))continue;const w=isoToWorld(n.gx,n.gy);if(!inView(w.x,w.y))continue;diamondPath(w.x+8,w.y+5,TW*0.46*n.bscale,TH*0.46*n.bscale);ctx.fill();}}
430
475
 
431
476
  // ---------- buildings (sprite blit, day/night/dim, + vector fallback) ----------
432
477
  // Legacy raster-sprite path (unused in the default Kenney CC0 / --vector modes); bottom-anchored to the tile.
@@ -492,10 +537,28 @@
492
537
  ctx.fillStyle="#5b3d1f";ctx.fillRect(w.x-1.5,w.y-6,3,7);ctx.fillStyle=(seed%3?"#3e7a3c":"#4f8a44");
493
538
  ctx.beginPath();ctx.arc(w.x,w.y-10,s,0,7);ctx.arc(w.x-s*0.5,w.y-6,s*0.7,0,7);ctx.arc(w.x+s*0.5,w.y-6,s*0.7,0,7);ctx.fill();}
494
539
 
495
- // ---------- flat dotted connections ----------
540
+ // ---------- flat dotted connections (same-repo) + arced cross-repo boundary edges ----------
541
+ // CROSS_REPO_SET holds "fromId\x00toId" pairs populated during ingest() for O(1) lookup.
542
+ let CROSS_REPO_SET=new Set();
543
+ function repoOf(n){const p=n&&n.prov;if(!p)return null;if(typeof p.repo==='string'&&p.repo.length)return p.repo;if(typeof p.store==='string'&&p.store.length)return p.store;return null;}
544
+ function buildCrossRepoSet(nodes){CROSS_REPO_SET=new Set();const ob=Object.fromEntries(nodes.map(n=>[n.id,n]));for(const n of nodes){const r1=repoOf(n);if(!r1)continue;for(const tid of(n.links||[])){const m=ob[tid];if(!m)continue;const r2=repoOf(m);if(r2&&r1!==r2)CROSS_REPO_SET.add(n.id+'\x00'+tid);}}}
545
+ function isCrossRepoLink(fromId,toId){return CROSS_REPO_SET.has(fromId+'\x00'+toId);}
546
+ // Draw a cross-repo edge as a raised arc with a portal glow (visually distinct from same-repo dotted lines).
547
+ function drawCrossRepoEdge(A,B){const mx=(A[0]+B[0])/2,my=(A[1]+B[1])/2,lift=Math.hypot(B[0]-A[0],B[1]-A[1])*0.28;
548
+ // shadow halo
549
+ ctx.save();ctx.globalAlpha=0.18;ctx.strokeStyle="#c680ff";ctx.lineWidth=7;ctx.setLineDash([]);
550
+ ctx.beginPath();ctx.moveTo(A[0],A[1]);ctx.quadraticCurveTo(mx,my-lift,B[0],B[1]);ctx.stroke();ctx.restore();
551
+ // main arc
552
+ ctx.strokeStyle="rgba(198,128,255,.92)";ctx.lineWidth=2;ctx.setLineDash([5,4]);
553
+ ctx.beginPath();ctx.moveTo(A[0],A[1]);ctx.quadraticCurveTo(mx,my-lift,B[0],B[1]);ctx.stroke();
554
+ // destination portal ring
555
+ ctx.setLineDash([]);ctx.fillStyle="rgba(220,180,255,.28)";ctx.beginPath();ctx.arc(B[0],B[1],8,0,7);ctx.fill();
556
+ ctx.strokeStyle="rgba(198,128,255,.9)";ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(B[0],B[1],4.2,0,7);ctx.stroke();
557
+ ctx.fillStyle="#e8c0ff";ctx.beginPath();ctx.arc(B[0],B[1],2.0,0,7);ctx.fill();}
496
558
  function drawConnections(id){const n=byId[id];if(!n)return;const A=[n.bx,n.by];
497
559
  ctx.save();ctx.setLineDash([2,6]);ctx.lineCap="round";ctx.lineWidth=2;
498
560
  n.links.forEach(tid=>{const m=byId[tid];if(!m)return;const B=[m.bx,m.by];
561
+ if(isCrossRepoLink(id,tid)){drawCrossRepoEdge(A,B);ctx.setLineDash([2,6]);return;}
499
562
  ctx.strokeStyle="rgba(255,240,180,.9)";ctx.beginPath();ctx.moveTo(A[0],A[1]);ctx.lineTo(B[0],B[1]);ctx.stroke();
500
563
  ctx.setLineDash([]);ctx.fillStyle="#ffe9a6";ctx.beginPath();ctx.arc(B[0],B[1],3.2,0,7);ctx.fill();
501
564
  ctx.fillStyle="rgba(255,233,166,.28)";ctx.beginPath();ctx.arc(B[0],B[1],7,0,7);ctx.fill();ctx.setLineDash([2,6]);});
@@ -503,7 +566,9 @@
503
566
 
504
567
  // ---------- churn heat: ground glow under recently-changed files (D-155, file-level metadata only) ----------
505
568
  function drawChurn(){
569
+ if(_lod>=LOD_SIMPLE)return; // skip at low zoom — churn glow is too small to read
506
570
  for(const n of NODES){ const ch=n.churn; if(!ch||!ch.b||ch.b<2||filteredHidden.has(n.id)) continue;
571
+ if(!inView(n.bx,n.by))continue;
507
572
  const col = ch.b>=3 ? 'rgba(242,104,63,' : 'rgba(232,178,74,';
508
573
  ctx.fillStyle=col+'0.20)'; diamondPath(n.bx,n.by,TW*0.62,TH*0.62); ctx.fill();
509
574
  ctx.fillStyle=col+'0.32)'; diamondPath(n.bx,n.by,TW*0.40,TH*0.40); ctx.fill();
@@ -573,24 +638,67 @@
573
638
  if(flip){ctx.save();ctx.translate(x*2,0);ctx.scale(-1,1);ctx.drawImage(VEH,sx,sy,vCW,vCH,dx,dy,dW,dH);ctx.restore();}
574
639
  else ctx.drawImage(VEH,sx,sy,vCW,vCH,dx,dy,dW,dH);}
575
640
 
641
+ // ---------- cluster badges (LOD_CLUSTER tier — entire districts collapsed to one badge) ----------
642
+ function drawClusterBadge(cl){
643
+ const x=cl.wx,y=cl.wy;
644
+ if(!inView(x,y))return;
645
+ if(focusK&&cl.d!==focusK)return;
646
+ const R=28,FONT_C="'Plus Jakarta Sans',system-ui,sans-serif";
647
+ // outer glow
648
+ ctx.save();
649
+ ctx.globalAlpha=0.22;
650
+ ctx.beginPath();ctx.arc(x,y,R*1.6,0,7);ctx.fillStyle=cl.color;ctx.fill();
651
+ ctx.globalAlpha=1;
652
+ // filled circle
653
+ ctx.beginPath();ctx.arc(x,y,R,0,7);ctx.fillStyle=cl.color;ctx.fill();
654
+ // border ring
655
+ ctx.lineWidth=2;ctx.strokeStyle="rgba(255,255,255,.45)";ctx.stroke();
656
+ // count label
657
+ const label=cl.count>=1000?(Math.round(cl.count/100)/10)+'k':String(cl.count);
658
+ ctx.fillStyle="#0a0a12";ctx.textAlign="center";ctx.textBaseline="middle";ctx.font="700 13px "+FONT_C;
659
+ ctx.fillText(label,x,y);
660
+ // district name beneath
661
+ ctx.globalAlpha=0.75;ctx.fillStyle=cl.color;ctx.font="600 10px "+FONT_C;
662
+ ctx.fillText(cl.label,x,y+R+12);
663
+ ctx.restore();}
664
+
576
665
  // ---------- render ----------
577
666
  let focusK=null,filteredHidden=new Set(),selectedId=null,connSet=new Set();
578
- function render(){NOW=performance.now()-T0;moveSky();ctx.setTransform(DPR,0,0,DPR,0,0);drawBackdrop();
667
+ function render(){NOW=performance.now()-T0;updateLodAndClip();moveSky();ctx.setTransform(DPR,0,0,DPR,0,0);drawBackdrop();
579
668
  ctx.save();ctx.translate(cam.x,cam.y);ctx.scale(cam.zoom,cam.zoom);
580
669
  ctx.globalAlpha=Math.min(1,NOW/450);
670
+
671
+ // LOD_CLUSTER: render one badge per district, skip all expensive geometry
672
+ if(_lod===LOD_CLUSTER){
673
+ drawIslandShadow();drawSlab();
674
+ CLUSTERS.forEach(drawClusterBadge);
675
+ ctx.globalAlpha=1;ctx.restore();return;}
676
+
581
677
  drawIslandShadow();drawSlab();drawGround();drawCloudShadows();drawShadows();drawChurn();
582
- const list=OBJ.slice();drawCars(list);
678
+
679
+ // At LOD_SIMPLE / LOD_DOT we skip cars, trees, props to keep frame-time low
680
+ const showFull=(_lod===LOD_FULL);
681
+ const list=OBJ.slice();
682
+ if(showFull)drawCars(list);
583
683
  list.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
584
684
  for(const o of list){
585
- if(o.k==='t'){drawTree(o.gx,o.gy,o.seed);continue;}
586
- if(o.k==='p'){drawProp(o.type,o.gx,o.gy);continue;}
587
- if(o.k==='c'){if(window.__KC_KENNEY__&&KEN.ok)drawKenCar(o.wx.x,o.wx.y,o.car);else if(VEH_OK)drawCarSprite(o.wx.x,o.wx.y,o.car.m,o.car.dir);else drawCar(o.wx.x,o.wx.y,o.car.col,o.car.dir);continue;}
685
+ // Viewport cull: skip objects outside the clip rect
686
+ const {gx,gy}=o;const wc=isoToWorld(gx,gy);if(!inView(wc.x,wc.y))continue;
687
+ if(o.k==='t'){if(showFull)drawTree(gx,gy,o.seed);continue;}
688
+ if(o.k==='p'){if(showFull)drawProp(o.type,gx,gy);continue;}
689
+ if(o.k==='c'){if(showFull){if(window.__KC_KENNEY__&&KEN.ok)drawKenCar(o.wx.x,o.wx.y,o.car);else if(VEH_OK)drawCarSprite(o.wx.x,o.wx.y,o.car.m,o.car.dir);else drawCar(o.wx.x,o.wx.y,o.car.col,o.car.dir);}continue;}
588
690
  const n=o.n;const hidden=filteredHidden.has(n.id);
589
691
  if(n.id===selectedId)continue;
590
692
  const dim=hidden||(selectedId&&!connSet.has(n.id))||(focusK&&n.d!==focusK);
693
+ // LOD_DOT: render buildings as coloured dots instead of full models
694
+ if(_lod===LOD_DOT){
695
+ if(dim)continue;
696
+ ctx.fillStyle=DCFG[n.d]?DCFG[n.d].color:'#7c83a3';
697
+ ctx.beginPath();ctx.arc(wc.x,wc.y,4,0,7);ctx.fill();
698
+ continue;}
591
699
  drawBuilding(n,false,dim);}
592
700
  if(selectedId){drawConnections(selectedId);const sn=byId[selectedId];if(sn&&!filteredHidden.has(sn.id))drawBuilding(sn,true,false);}
593
- drawClouds();drawSkyLife();
701
+ if(showFull){drawClouds();drawSkyLife();}
594
702
  ctx.globalAlpha=1;ctx.restore();}
595
703
  function loop(){requestAnimationFrame(loop);cam.x+=(cam.tx-cam.x)*0.12;cam.y+=(cam.ty-cam.y)*0.12;cam.zoom+=(cam.tz-cam.zoom)*0.12;render();}
596
704
 
@@ -681,6 +789,7 @@
681
789
  byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
682
790
  NODES.forEach(n=>n.links=n.links.filter(id=>byId[id]&&id!==n.id));
683
791
  buildGeometry();
792
+ buildCrossRepoSet(NODES);
684
793
  buildSkyLife();
685
794
  buildCars();
686
795
  // reset view/selection state for the fresh graph