RayMelius Claude Sonnet 4.6 commited on
Commit
1d60530
Β·
1 Parent(s): 0430992

Add zoom controls, walk animation, and zone background colors

Browse files

- City fits on one screen by default (WORLD_W/H = 1.0, zoom via ctx.scale)
- Scroll wheel zoom centered on cursor; +/- buttons; Fit button
- Rectangle zoom: draw a selection box to zoom into any area
- Walking agents now animate arms (+/-18px) and legs (+/-13px) only when moving
- 11 colored zone overlays (park green, commercial terracotta, office blue, etc.)
- Richer ground gradient with 3-stop depth variation
- Zoom indicator in canvas corner; pan corrected for zoom level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. web/index.html +224 -38
web/index.html CHANGED
@@ -164,6 +164,11 @@
164
  <button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button>
165
  <button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button>
166
  <span class="speed-label" id="speed-label">1x</span>
 
 
 
 
 
167
  </span>
168
  <span id="api-calls">API: 0</span>
169
  <span id="cost">$0.00</span>
@@ -209,9 +214,11 @@ const POLL_INTERVAL = 2000;
209
  const HORIZON = 0.14;
210
 
211
  // --- CITY LAYOUT ---
212
- // World dimensions (normalized) β€” larger than viewport, scrollable
213
- const WORLD_W = 1.6;
214
- const WORLD_H = 1.4;
 
 
215
 
216
  // Road network
217
  const ROADS = [
@@ -336,9 +343,13 @@ let stars = [];
336
  let activeTab = 'agents';
337
  let agentIdxMap = {};
338
 
339
- // Pan state
340
  let panX = 0, panY = 0;
 
341
  let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
 
 
 
342
 
343
  // Tree / decorations cache
344
  let trees = [];
@@ -401,6 +412,7 @@ function initCanvas() {
401
  canvas.addEventListener('mousemove', onCanvasDrag);
402
  canvas.addEventListener('mouseup', onCanvasDragEnd);
403
  canvas.addEventListener('mouseleave', onCanvasDragEnd);
 
404
  initParticles();
405
  requestAnimationFrame(animate);
406
  }
@@ -410,11 +422,11 @@ function resizeCanvas() {
410
  canvas.height = c.clientHeight;
411
  }
412
 
413
- // World size in pixels (larger than canvas)
414
- function worldW() { return canvas.width * WORLD_W; }
415
- function worldH() { return canvas.height * WORLD_H; }
416
- function maxPanX() { return Math.max(0, worldW() - canvas.width); }
417
- function maxPanY() { return Math.max(0, worldH() - canvas.height); }
418
 
419
  function onPanSlider() {
420
  const sx = document.getElementById('pan-x');
@@ -430,19 +442,89 @@ function syncSliders() {
430
  }
431
  function onCanvasDragStart(e) {
432
  if (e.button !== 0) return;
 
 
 
 
 
 
 
433
  isDragging = true;
434
  dragStartX = e.clientX; dragStartY = e.clientY;
435
  dragPanStartX = panX; dragPanStartY = panY;
436
  }
437
  function onCanvasDrag(e) {
 
 
 
 
 
438
  if (!isDragging) return;
439
  const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
440
  if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
441
- panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx));
442
- panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  syncSliders();
444
  }
445
- function onCanvasDragEnd() { isDragging = false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
  function animate() {
448
  animFrame++;
@@ -464,13 +546,20 @@ function draw() {
464
  const W = worldW(), H = worldH();
465
  const cW = canvas.width, cH = canvas.height;
466
 
467
- // Sky and weather drawn without pan (full canvas)
 
 
 
 
 
468
  drawSky(cW, cH);
469
 
470
  ctx.save();
 
471
  ctx.translate(-panX, -panY);
472
 
473
  drawGround(W, H);
 
474
  drawWeather(W, H);
475
  drawRoads(W, H);
476
  drawSidewalks(W, H);
@@ -508,6 +597,29 @@ function draw() {
508
  }
509
 
510
  ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  }
512
 
513
  // Auto-compute positions for generated houses not in LOCATION_POSITIONS
@@ -658,32 +770,50 @@ function drawMoon(x, y, r) {
658
  function drawGround(W, H) {
659
  const hLine = (canvas.height * HORIZON);
660
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
661
- const grad = ctx.createLinearGradient(0, hLine, 0, H);
662
  const bc = hexToRgb(gt.base);
663
- grad.addColorStop(0, `rgb(${bc.r+20},${bc.g+30},${bc.b+10})`);
664
- grad.addColorStop(1, `rgb(${Math.max(0,bc.r-15)},${Math.max(0,bc.g-15)},${Math.max(0,bc.b-10)})`);
 
 
 
 
 
 
665
  ctx.fillStyle = grad;
666
  ctx.fillRect(0, hLine, W, H - hLine);
667
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  if (currentWeather === 'rainy' || currentWeather === 'stormy') {
669
  ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
670
  ctx.fillRect(0, hLine, W, H - hLine);
671
  }
672
 
673
- // Grass texture
674
- ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.12)`;
675
- for (let i = 0; i < 100; i++) {
676
- const gx = (i*37+13)%W;
677
- const gy = hLine + 10 + ((i*53+7)%(H-hLine-15));
678
  ctx.fillRect(gx, gy, 2, 3);
679
  }
680
 
681
- const horizGrad = ctx.createLinearGradient(0, hLine-4, 0, hLine+6);
 
682
  const s = SKY[currentTimeOfDay] || SKY.morning;
683
  horizGrad.addColorStop(0, s.bot);
684
  horizGrad.addColorStop(1, gt.base);
685
  ctx.fillStyle = horizGrad;
686
- ctx.fillRect(0, hLine-4, W, 10);
687
  }
688
 
689
  function hexToRgb(hex) {
@@ -691,6 +821,51 @@ function hexToRgb(hex) {
691
  return {r,g,b};
692
  }
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  // ============================================================
695
  // WEATHER
696
  // ============================================================
@@ -1489,9 +1664,11 @@ function drawPerson(id, agent, globalIdx, W, H) {
1489
 
1490
  if (isSel) { ctx.shadowColor=color; ctx.shadowBlur=12; }
1491
 
1492
- const bounce = (isMoving||moving) ? Math.sin(animFrame*0.18)*1.5 : 0;
1493
- const armSwing = (isMoving||moving) ? Math.sin(animFrame*0.18)*6 : 0;
1494
- const legSwing = (isMoving||moving) ? Math.sin(animFrame*0.18)*4 : 0;
 
 
1495
 
1496
  // Ground shadow (isometric ellipse)
1497
  ctx.fillStyle = 'rgba(0,0,0,0.18)';
@@ -1529,20 +1706,28 @@ function drawPerson(id, agent, globalIdx, W, H) {
1529
  }
1530
 
1531
  // Legs (with perspective offset)
1532
- ctx.strokeStyle = gender==='female' ? skin : dim(color, 0.5);
1533
- ctx.lineWidth = 1.8;
1534
- ctx.beginPath(); ctx.moveTo(-2, 3+bounce); ctx.lineTo(-3, 8+bounce+legSwing); ctx.stroke();
1535
- ctx.beginPath(); ctx.moveTo(2, 3+bounce); ctx.lineTo(3, 8+bounce-legSwing); ctx.stroke();
 
 
 
 
1536
 
1537
  // Feet
1538
  ctx.fillStyle = '#2a2a2a';
1539
- ctx.fillRect(-4.5, 7+bounce+legSwing, 3, 1.5);
1540
- ctx.fillRect(1.5, 7+bounce-legSwing, 3, 1.5);
1541
 
1542
  // Arms
1543
- ctx.strokeStyle = skin; ctx.lineWidth = 1.5;
1544
- ctx.beginPath(); ctx.moveTo(-3.5, -6+bounce); ctx.lineTo(-7, 0+bounce+armSwing); ctx.stroke();
1545
- ctx.beginPath(); ctx.moveTo(3.5, -6+bounce); ctx.lineTo(7, 0+bounce-armSwing); ctx.stroke();
 
 
 
 
1546
 
1547
  // Head (slightly offset for 2.5D)
1548
  ctx.fillStyle = skin;
@@ -1616,8 +1801,9 @@ function drawPerson(id, agent, globalIdx, W, H) {
1616
  // ============================================================
1617
  function onCanvasClick(e) {
1618
  if (isDragging) return;
 
1619
  const rect=canvas.getBoundingClientRect();
1620
- const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
1621
  let clicked=null, minD=24;
1622
  for (const [id,pos] of Object.entries(agentPositions)) {
1623
  const d=Math.hypot(mx-pos.x,my-pos.y);
@@ -1635,11 +1821,11 @@ function onCanvasClick(e) {
1635
 
1636
  function onCanvasMouseMove(e) {
1637
  const rect=canvas.getBoundingClientRect();
1638
- const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
1639
  const W=worldW(), H=worldH();
1640
  const tt=document.getElementById('tooltip');
1641
 
1642
- if (isDragging) return;
1643
 
1644
  let foundAgent=null;
1645
  for (const [id,pos] of Object.entries(agentPositions)) {
 
164
  <button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button>
165
  <button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button>
166
  <span class="speed-label" id="speed-label">1x</span>
167
+ <span style="color:#1a3a6e;margin:0 2px">β”‚</span>
168
+ <button class="ctrl-btn" id="btn-rect-zoom" onclick="toggleRectZoom()" title="Draw a rectangle to zoom into that area (Shift+drag)">⬚</button>
169
+ <button class="ctrl-btn" onclick="zoomBy(1.3)" title="Zoom In (scroll up)">οΌ‹</button>
170
+ <button class="ctrl-btn" onclick="zoomBy(1/1.3)" title="Zoom Out (scroll down)">-</button>
171
+ <button class="ctrl-btn" onclick="zoomFit()" title="Fit entire city on screen">Fit</button>
172
  </span>
173
  <span id="api-calls">API: 0</span>
174
  <span id="cost">$0.00</span>
 
214
  const HORIZON = 0.14;
215
 
216
  // --- CITY LAYOUT ---
217
+ // World dimensions β€” 1:1 with canvas (zoom handled separately)
218
+ const WORLD_W = 1.0;
219
+ const WORLD_H = 1.0;
220
+ const MIN_ZOOM = 0.5;
221
+ const MAX_ZOOM = 10.0;
222
 
223
  // Road network
224
  const ROADS = [
 
343
  let activeTab = 'agents';
344
  let agentIdxMap = {};
345
 
346
+ // Pan & zoom state
347
  let panX = 0, panY = 0;
348
+ let zoom = 1.0;
349
  let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
350
+ // Rectangle-zoom state
351
+ let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null;
352
+ let _blockNextClick = false;
353
 
354
  // Tree / decorations cache
355
  let trees = [];
 
412
  canvas.addEventListener('mousemove', onCanvasDrag);
413
  canvas.addEventListener('mouseup', onCanvasDragEnd);
414
  canvas.addEventListener('mouseleave', onCanvasDragEnd);
415
+ canvas.addEventListener('wheel', onCanvasWheel, {passive: false});
416
  initParticles();
417
  requestAnimationFrame(animate);
418
  }
 
422
  canvas.height = c.clientHeight;
423
  }
424
 
425
+ // World size β€” base is canvas size; zoom applied via ctx.scale
426
+ function worldW() { return canvas.width; }
427
+ function worldH() { return canvas.height; }
428
+ function maxPanX() { return Math.max(0, canvas.width * (1 - 1 / zoom)); }
429
+ function maxPanY() { return Math.max(0, canvas.height * (1 - 1 / zoom)); }
430
 
431
  function onPanSlider() {
432
  const sx = document.getElementById('pan-x');
 
442
  }
443
  function onCanvasDragStart(e) {
444
  if (e.button !== 0) return;
445
+ if (rectZoomMode) {
446
+ const r = canvas.getBoundingClientRect();
447
+ rectStart = {x: e.clientX - r.left, y: e.clientY - r.top};
448
+ rectEnd = {...rectStart};
449
+ isRectDragging = true;
450
+ return;
451
+ }
452
  isDragging = true;
453
  dragStartX = e.clientX; dragStartY = e.clientY;
454
  dragPanStartX = panX; dragPanStartY = panY;
455
  }
456
  function onCanvasDrag(e) {
457
+ if (isRectDragging) {
458
+ const r = canvas.getBoundingClientRect();
459
+ rectEnd = {x: e.clientX - r.left, y: e.clientY - r.top};
460
+ return;
461
+ }
462
  if (!isDragging) return;
463
  const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
464
  if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
465
+ panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx / zoom));
466
+ panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy / zoom));
467
+ syncSliders();
468
+ }
469
+ function onCanvasDragEnd() {
470
+ if (isRectDragging) {
471
+ isRectDragging = false;
472
+ if (rectStart && rectEnd) {
473
+ const rw = Math.abs(rectEnd.x - rectStart.x), rh = Math.abs(rectEnd.y - rectStart.y);
474
+ if (rw > 10 && rh > 10) { applyRectZoom(); _blockNextClick = true; }
475
+ }
476
+ rectStart = null; rectEnd = null;
477
+ rectZoomMode = false;
478
+ const btn = document.getElementById('btn-rect-zoom');
479
+ if (btn) btn.classList.remove('active');
480
+ canvas.style.cursor = 'default';
481
+ return;
482
+ }
483
+ isDragging = false;
484
+ }
485
+
486
+ // ============================================================
487
+ // ZOOM FUNCTIONS
488
+ // ============================================================
489
+ function onCanvasWheel(e) {
490
+ e.preventDefault();
491
+ const r = canvas.getBoundingClientRect();
492
+ const sx = e.clientX - r.left, sy = e.clientY - r.top;
493
+ zoomAround(e.deltaY < 0 ? 1.15 : 1 / 1.15, sx, sy);
494
+ }
495
+ function zoomAround(factor, sx, sy) {
496
+ const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor));
497
+ if (newZoom === zoom) return;
498
+ // World point under screen pixel (sx, sy): wx = sx/zoom + panX
499
+ const wx = sx / zoom + panX, wy = sy / zoom + panY;
500
+ zoom = newZoom;
501
+ panX = Math.max(0, Math.min(maxPanX(), wx - sx / zoom));
502
+ panY = Math.max(0, Math.min(maxPanY(), wy - sy / zoom));
503
  syncSliders();
504
  }
505
+ function zoomBy(factor) { zoomAround(factor, canvas.width / 2, canvas.height / 2); }
506
+ function zoomFit() { zoom = 1.0; panX = 0; panY = 0; syncSliders(); }
507
+ function applyRectZoom() {
508
+ if (!rectStart || !rectEnd) return;
509
+ const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
510
+ const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
511
+ const rw = x2 - x1, rh = y2 - y1;
512
+ if (rw < 10 || rh < 10) return;
513
+ // Convert screen rect corners to world coords
514
+ const wx1 = x1 / zoom + panX, wy1 = y1 / zoom + panY;
515
+ const wx2 = x2 / zoom + panX, wy2 = y2 / zoom + panY;
516
+ const newZoom = Math.min(MAX_ZOOM, Math.min(canvas.width / (wx2 - wx1), canvas.height / (wy2 - wy1)));
517
+ zoom = Math.max(MIN_ZOOM, newZoom);
518
+ panX = Math.max(0, Math.min(maxPanX(), wx1));
519
+ panY = Math.max(0, Math.min(maxPanY(), wy1));
520
+ syncSliders();
521
+ }
522
+ function toggleRectZoom() {
523
+ rectZoomMode = !rectZoomMode;
524
+ const btn = document.getElementById('btn-rect-zoom');
525
+ if (btn) btn.classList.toggle('active', rectZoomMode);
526
+ canvas.style.cursor = rectZoomMode ? 'crosshair' : 'default';
527
+ }
528
 
529
  function animate() {
530
  animFrame++;
 
546
  const W = worldW(), H = worldH();
547
  const cW = canvas.width, cH = canvas.height;
548
 
549
+ // Background fill (covers any gap between sky and zoomed ground)
550
+ const skyCfg = SKY[currentTimeOfDay] || SKY.morning;
551
+ ctx.fillStyle = skyCfg.bot;
552
+ ctx.fillRect(0, 0, cW, cH);
553
+
554
+ // Sky drawn at canvas size (no zoom transform β€” always fills background)
555
  drawSky(cW, cH);
556
 
557
  ctx.save();
558
+ ctx.scale(zoom, zoom);
559
  ctx.translate(-panX, -panY);
560
 
561
  drawGround(W, H);
562
+ drawZones(W, H);
563
  drawWeather(W, H);
564
  drawRoads(W, H);
565
  drawSidewalks(W, H);
 
597
  }
598
 
599
  ctx.restore();
600
+
601
+ // Rect-zoom selection overlay (screen coords, after restore)
602
+ if (isRectDragging && rectStart && rectEnd) {
603
+ const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
604
+ const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
605
+ ctx.fillStyle = 'rgba(78,204,163,0.08)';
606
+ ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
607
+ ctx.strokeStyle = 'rgba(78,204,163,0.9)';
608
+ ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
609
+ ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
610
+ ctx.setLineDash([]);
611
+ }
612
+
613
+ // Zoom level indicator
614
+ if (zoom !== 1.0 || rectZoomMode) {
615
+ ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
616
+ const label = rectZoomMode ? '⬚ draw rect to zoom' : `${zoom.toFixed(1)}x`;
617
+ const tw = ctx.measureText(label).width + 14;
618
+ ctx.fillStyle = 'rgba(15,52,96,0.75)';
619
+ ctx.fillRect(cW - tw - 4, cH - 22, tw + 4, 18);
620
+ ctx.fillStyle = rectZoomMode ? '#f0c040' : '#4ecca3';
621
+ ctx.fillText(label, cW - 6, cH - 6);
622
+ }
623
  }
624
 
625
  // Auto-compute positions for generated houses not in LOCATION_POSITIONS
 
770
  function drawGround(W, H) {
771
  const hLine = (canvas.height * HORIZON);
772
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
 
773
  const bc = hexToRgb(gt.base);
774
+ const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
775
+ const isDawn = currentTimeOfDay === 'dawn';
776
+
777
+ // Base gradient β€” three-band ground for depth
778
+ const grad = ctx.createLinearGradient(0, hLine, 0, H);
779
+ grad.addColorStop(0, `rgb(${bc.r+28},${bc.g+40},${bc.b+12})`);
780
+ grad.addColorStop(0.4, `rgb(${bc.r+10},${bc.g+20},${bc.b+5})`);
781
+ grad.addColorStop(1, `rgb(${Math.max(0,bc.r-20)},${Math.max(0,bc.g-20)},${Math.max(0,bc.b-14)})`);
782
  ctx.fillStyle = grad;
783
  ctx.fillRect(0, hLine, W, H - hLine);
784
 
785
+ // Horizontal band of slightly different shade β€” mid-ground variation
786
+ if (!isDark) {
787
+ const bandY = hLine + (H - hLine) * 0.5;
788
+ const bandGrad = ctx.createLinearGradient(0, bandY - 12, 0, bandY + 12);
789
+ bandGrad.addColorStop(0, 'rgba(0,0,0,0)');
790
+ bandGrad.addColorStop(0.5, isDawn ? 'rgba(180,120,60,0.06)' : 'rgba(80,130,40,0.06)');
791
+ bandGrad.addColorStop(1, 'rgba(0,0,0,0)');
792
+ ctx.fillStyle = bandGrad;
793
+ ctx.fillRect(0, bandY - 12, W, 24);
794
+ }
795
+
796
+ // Rain/storm overlay
797
  if (currentWeather === 'rainy' || currentWeather === 'stormy') {
798
  ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
799
  ctx.fillRect(0, hLine, W, H - hLine);
800
  }
801
 
802
+ // Scattered grass tufts for texture
803
+ ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.14)`;
804
+ for (let i = 0; i < 120; i++) {
805
+ const gx = (i * 37 + 13) % W;
806
+ const gy = hLine + 10 + ((i * 53 + 7) % (H - hLine - 15));
807
  ctx.fillRect(gx, gy, 2, 3);
808
  }
809
 
810
+ // Horizon blend
811
+ const horizGrad = ctx.createLinearGradient(0, hLine - 4, 0, hLine + 8);
812
  const s = SKY[currentTimeOfDay] || SKY.morning;
813
  horizGrad.addColorStop(0, s.bot);
814
  horizGrad.addColorStop(1, gt.base);
815
  ctx.fillStyle = horizGrad;
816
+ ctx.fillRect(0, hLine - 4, W, 12);
817
  }
818
 
819
  function hexToRgb(hex) {
 
821
  return {r,g,b};
822
  }
823
 
824
+ // ============================================================
825
+ // ZONE COLORS β€” colored neighborhood tints
826
+ // ============================================================
827
+ function drawZones(W, H) {
828
+ const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
829
+ const isDawn = currentTimeOfDay === 'dawn';
830
+
831
+ const zones = [
832
+ // Park β€” bright grass green
833
+ { cx: 0.50, cy: 0.19, rx: 0.11, ry: 0.055, c: isDark ? '#1a4a18' : (isDawn ? '#4a7a30' : '#7ecf50'), a: isDark ? 0.22 : 0.35 },
834
+ // Sports field β€” turf green
835
+ { cx: 0.22, cy: 0.78, rx: 0.07, ry: 0.055, c: isDark ? '#1a3818' : '#5ab838', a: isDark ? 0.20 : 0.30 },
836
+ // Town square β€” warm cobblestone gold
837
+ { cx: 0.50, cy: 0.50, rx: 0.07, ry: 0.045, c: isDark ? '#3a3010' : '#e8c86a', a: isDark ? 0.18 : 0.32 },
838
+ // Commercial row β€” warm terracotta orange
839
+ { cx: 0.48, cy: 0.42, rx: 0.32, ry: 0.075, c: isDark ? '#2a1808' : '#e8b880', a: isDark ? 0.15 : 0.20 },
840
+ // North residential β€” soft warm cream
841
+ { cx: 0.48, cy: 0.20, rx: 0.44, ry: 0.060, c: isDark ? '#28201a' : '#eedcb0', a: isDark ? 0.13 : 0.22 },
842
+ // South residential β€” light sandy beige
843
+ { cx: 0.48, cy: 0.66, rx: 0.44, ry: 0.065, c: isDark ? '#28201a' : '#e8d8a0', a: isDark ? 0.13 : 0.22 },
844
+ // Office / business district β€” cool steel blue
845
+ { cx: 0.66, cy: 0.34, rx: 0.22, ry: 0.075, c: isDark ? '#182030' : '#b0cce8', a: isDark ? 0.18 : 0.24 },
846
+ // Industrial β€” brownish rust
847
+ { cx: 0.91, cy: 0.63, rx: 0.07, ry: 0.08, c: isDark ? '#201408' : '#c89860', a: isDark ? 0.20 : 0.28 },
848
+ // Hospital β€” clean pale cyan
849
+ { cx: 0.91, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#182828' : '#b8e8e0', a: isDark ? 0.18 : 0.26 },
850
+ // School β€” warm learning yellow
851
+ { cx: 0.08, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#28200a' : '#f0e890', a: isDark ? 0.16 : 0.26 },
852
+ // Church garden β€” soft lavender-green
853
+ { cx: 0.08, cy: 0.78, rx: 0.06, ry: 0.045, c: isDark ? '#1a2030' : '#d0c8e8', a: isDark ? 0.18 : 0.28 },
854
+ ];
855
+
856
+ for (const z of zones) {
857
+ const grd = ctx.createRadialGradient(z.cx*W, z.cy*H, 0, z.cx*W, z.cy*H, Math.max(z.rx*W, z.ry*H));
858
+ grd.addColorStop(0, z.c);
859
+ grd.addColorStop(1, 'rgba(0,0,0,0)');
860
+ ctx.globalAlpha = z.a;
861
+ ctx.fillStyle = grd;
862
+ ctx.beginPath();
863
+ ctx.ellipse(z.cx*W, z.cy*H, z.rx*W, z.ry*H, 0, 0, 6.28);
864
+ ctx.fill();
865
+ }
866
+ ctx.globalAlpha = 1.0;
867
+ }
868
+
869
  // ============================================================
870
  // WEATHER
871
  // ============================================================
 
1664
 
1665
  if (isSel) { ctx.shadowColor=color; ctx.shadowBlur=12; }
1666
 
1667
+ const walkAnim = isMoving || moving;
1668
+ const walkPhase = animFrame * 0.28;
1669
+ const bounce = walkAnim ? Math.sin(walkPhase) * 2.8 : 0;
1670
+ const armSwing = walkAnim ? Math.sin(walkPhase) * 18 : 0;
1671
+ const legSwing = walkAnim ? Math.sin(walkPhase) * 13 : 0;
1672
 
1673
  // Ground shadow (isometric ellipse)
1674
  ctx.fillStyle = 'rgba(0,0,0,0.18)';
 
1706
  }
1707
 
1708
  // Legs (with perspective offset)
1709
+ const legColor = gender==='female' ? skin : dim(color, 0.5);
1710
+ ctx.strokeStyle = legColor;
1711
+ ctx.lineWidth = walkAnim ? 2.5 : 1.8;
1712
+ const lx1 = walkAnim ? -3 - legSwing * 0.25 : -3;
1713
+ const lx2 = walkAnim ? 3 + legSwing * 0.25 : 3;
1714
+ const ly = walkAnim ? 11 : 8;
1715
+ ctx.beginPath(); ctx.moveTo(-2, 3+bounce); ctx.lineTo(lx1, ly+bounce+legSwing); ctx.stroke();
1716
+ ctx.beginPath(); ctx.moveTo( 2, 3+bounce); ctx.lineTo(lx2, ly+bounce-legSwing); ctx.stroke();
1717
 
1718
  // Feet
1719
  ctx.fillStyle = '#2a2a2a';
1720
+ ctx.fillRect(lx1-2, ly-1+bounce+legSwing, 4, 2);
1721
+ ctx.fillRect(lx2-1, ly-1+bounce-legSwing, 4, 2);
1722
 
1723
  // Arms
1724
+ ctx.strokeStyle = skin;
1725
+ ctx.lineWidth = walkAnim ? 2.0 : 1.5;
1726
+ const armX1 = walkAnim ? -9 : -7;
1727
+ const armX2 = walkAnim ? 9 : 7;
1728
+ const armY = walkAnim ? 2 : 0;
1729
+ ctx.beginPath(); ctx.moveTo(-3.5, -6+bounce); ctx.lineTo(armX1, armY+bounce+armSwing); ctx.stroke();
1730
+ ctx.beginPath(); ctx.moveTo( 3.5, -6+bounce); ctx.lineTo(armX2, armY+bounce-armSwing); ctx.stroke();
1731
 
1732
  // Head (slightly offset for 2.5D)
1733
  ctx.fillStyle = skin;
 
1801
  // ============================================================
1802
  function onCanvasClick(e) {
1803
  if (isDragging) return;
1804
+ if (_blockNextClick) { _blockNextClick = false; return; }
1805
  const rect=canvas.getBoundingClientRect();
1806
+ const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
1807
  let clicked=null, minD=24;
1808
  for (const [id,pos] of Object.entries(agentPositions)) {
1809
  const d=Math.hypot(mx-pos.x,my-pos.y);
 
1821
 
1822
  function onCanvasMouseMove(e) {
1823
  const rect=canvas.getBoundingClientRect();
1824
+ const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
1825
  const W=worldW(), H=worldH();
1826
  const tt=document.getElementById('tooltip');
1827
 
1828
+ if (isDragging || rectZoomMode) return;
1829
 
1830
  let foundAgent=null;
1831
  for (const [id,pos] of Object.entries(agentPositions)) {