RayMelius Claude Opus 4.6 commited on
Commit
e801152
·
1 Parent(s): af7b74e

Expand city to 34 locations with new buildings, pan controls, and 2.5D web UI

Browse files

- Add school, office tower, factory, hospital, 3 apartment blocks, bakery,
cinema, church, town square, sports field, east/west avenues
- New building sprites: factory with chimney smoke, hospital with red cross,
church with steeple, cinema with marquee lights, sports field with track
- Pan system: drag canvas or use sliders to scroll larger world (1.6x1.4)
- Sidebar narrowed 380->260px, agent clustering improved for large crowds
- Occupation-aware work locations (lawyer->tower, mechanic->factory, etc.)
- Routines use new locations for lunch, leisure, and evening entertainment

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

config/city.yaml CHANGED
@@ -2,7 +2,7 @@ name: Soci City
2
 
3
  locations:
4
  # ===================
5
- # RESIDENTIAL — 10 individual houses
6
  # ===================
7
  - id: house_elena
8
  name: "Elena & Lila's Apartment"
@@ -37,14 +37,14 @@ locations:
37
  zone: residential
38
  description: A small studio apartment crammed with musical instruments, vinyl records, and empty coffee cups.
39
  capacity: 2
40
- connected_to: [street_north, cafe, park]
41
 
42
  - id: house_priya
43
  name: "Priya & Nina's Flat"
44
  zone: residential
45
  description: A modern flat kept immaculately clean. Family photos on one side, property listings on the other.
46
  capacity: 3
47
- connected_to: [street_north, office, park]
48
 
49
  - id: house_james
50
  name: "James & Theo's House"
@@ -74,6 +74,27 @@ locations:
74
  capacity: 4
75
  connected_to: [street_south, bar, library]
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  # ===================
78
  # COMMERCIAL
79
  # ===================
@@ -82,28 +103,42 @@ locations:
82
  zone: commercial
83
  description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
84
  capacity: 15
85
- connected_to: [house_elena, house_helen, house_diana, house_kai, street_north, office]
86
 
87
  - id: grocery
88
  name: Green Basket Market
89
  zone: commercial
90
  description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
91
  capacity: 12
92
- connected_to: [house_diana, house_rosa, street_north, street_south]
93
 
94
  - id: bar
95
  name: The Rusty Anchor
96
  zone: commercial
97
  description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
98
  capacity: 15
99
- connected_to: [house_james, house_frank, restaurant, street_south]
100
 
101
  - id: restaurant
102
  name: Mama Rosa's Kitchen
103
  zone: commercial
104
  description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
105
  capacity: 12
106
- connected_to: [house_rosa, bar, office, street_south]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  # ===================
109
  # WORK
@@ -112,8 +147,36 @@ locations:
112
  name: The Hive Coworking
113
  zone: work
114
  description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  capacity: 20
116
- connected_to: [house_priya, cafe, restaurant, street_north]
117
 
118
  # ===================
119
  # PUBLIC
@@ -123,14 +186,14 @@ locations:
123
  zone: public
124
  description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
125
  capacity: 30
126
- connected_to: [house_elena, house_marcus, house_kai, house_priya, gym, library, street_north]
127
 
128
  - id: gym
129
  name: Iron & Grit Gym
130
  zone: public
131
  description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back.
132
- capacity: 12
133
- connected_to: [house_marcus, house_james, house_yuki, park, street_south]
134
 
135
  - id: library
136
  name: Soci Public Library
@@ -139,19 +202,54 @@ locations:
139
  capacity: 15
140
  connected_to: [house_helen, house_yuki, house_frank, park, street_south]
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  # ===================
143
- # STREETS — connectors
144
  # ===================
145
  - id: street_north
146
  name: North Main Street
147
  zone: public
148
  description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
149
- capacity: 40
150
- connected_to: [house_elena, house_marcus, house_helen, house_diana, house_kai, house_priya, cafe, grocery, office, park, street_south]
151
 
152
  - id: street_south
153
  name: South Main Street
154
  zone: public
155
  description: The southern stretch of main street. More residential, with the bar and gym nearby.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  capacity: 40
157
- connected_to: [house_james, house_rosa, house_yuki, house_frank, bar, grocery, gym, library, restaurant, street_north]
 
2
 
3
  locations:
4
  # ===================
5
+ # RESIDENTIAL — 10 individual houses + 3 apartment blocks
6
  # ===================
7
  - id: house_elena
8
  name: "Elena & Lila's Apartment"
 
37
  zone: residential
38
  description: A small studio apartment crammed with musical instruments, vinyl records, and empty coffee cups.
39
  capacity: 2
40
+ connected_to: [street_west, cafe, park]
41
 
42
  - id: house_priya
43
  name: "Priya & Nina's Flat"
44
  zone: residential
45
  description: A modern flat kept immaculately clean. Family photos on one side, property listings on the other.
46
  capacity: 3
47
+ connected_to: [street_east, office, park]
48
 
49
  - id: house_james
50
  name: "James & Theo's House"
 
74
  capacity: 4
75
  connected_to: [street_south, bar, library]
76
 
77
+ - id: apartment_block_1
78
+ name: Northside Apartments
79
+ zone: residential
80
+ description: A modern four-story apartment building with a rooftop terrace and shared laundry room.
81
+ capacity: 8
82
+ connected_to: [street_north, cafe, park, church]
83
+
84
+ - id: apartment_block_2
85
+ name: Southside Apartments
86
+ zone: residential
87
+ description: A brick apartment building with a courtyard garden and friendly neighbors who watch out for each other.
88
+ capacity: 8
89
+ connected_to: [street_south, bar, gym, cinema]
90
+
91
+ - id: apartment_block_3
92
+ name: Central Apartments
93
+ zone: residential
94
+ description: A newly renovated building right off the town square. Convenient but noisy on weekends.
95
+ capacity: 8
96
+ connected_to: [town_square, grocery, street_north, street_south]
97
+
98
  # ===================
99
  # COMMERCIAL
100
  # ===================
 
103
  zone: commercial
104
  description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
105
  capacity: 15
106
+ connected_to: [house_elena, house_helen, house_diana, house_kai, street_north, office, bakery]
107
 
108
  - id: grocery
109
  name: Green Basket Market
110
  zone: commercial
111
  description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
112
  capacity: 12
113
+ connected_to: [house_diana, house_rosa, apartment_block_3, street_north, street_south]
114
 
115
  - id: bar
116
  name: The Rusty Anchor
117
  zone: commercial
118
  description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
119
  capacity: 15
120
+ connected_to: [house_james, house_frank, apartment_block_2, restaurant, street_south, cinema]
121
 
122
  - id: restaurant
123
  name: Mama Rosa's Kitchen
124
  zone: commercial
125
  description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
126
  capacity: 12
127
+ connected_to: [house_rosa, bar, office, street_south, town_square]
128
+
129
+ - id: bakery
130
+ name: Golden Crust Bakery
131
+ zone: commercial
132
+ description: A charming bakery with a glass display of fresh pastries. The smell of bread draws people in from the street.
133
+ capacity: 10
134
+ connected_to: [street_north, cafe, apartment_block_1]
135
+
136
+ - id: cinema
137
+ name: Starlight Cinema
138
+ zone: commercial
139
+ description: A vintage two-screen cinema with red velvet seats and a popcorn machine that never stops.
140
+ capacity: 15
141
+ connected_to: [street_south, bar, town_square, apartment_block_2]
142
 
143
  # ===================
144
  # WORK
 
147
  name: The Hive Coworking
148
  zone: work
149
  description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
150
+ capacity: 25
151
+ connected_to: [house_priya, cafe, restaurant, street_north, town_square]
152
+
153
+ - id: office_tower
154
+ name: Pinnacle Tower
155
+ zone: work
156
+ description: A sleek glass office building housing law firms, tech startups, and financial advisors across six floors.
157
+ capacity: 30
158
+ connected_to: [street_north, street_east, cafe, town_square]
159
+
160
+ - id: factory
161
+ name: Ironworks Factory
162
+ zone: work
163
+ description: A large industrial building with a loading dock, welding bays, and the constant hum of machinery.
164
+ capacity: 25
165
+ connected_to: [street_south, street_east, sports_field]
166
+
167
+ - id: school
168
+ name: Soci Elementary & High
169
+ zone: work
170
+ description: A two-story school building with a playground, science lab, and the sound of a bell every hour.
171
+ capacity: 25
172
+ connected_to: [street_north, street_west, park, church]
173
+
174
+ - id: hospital
175
+ name: Soci City Hospital
176
+ zone: work
177
+ description: A small community hospital with an ER, a few wards, and a cafeteria that serves surprisingly good soup.
178
  capacity: 20
179
+ connected_to: [street_north, street_east, street_south]
180
 
181
  # ===================
182
  # PUBLIC
 
186
  zone: public
187
  description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
188
  capacity: 30
189
+ connected_to: [house_elena, house_marcus, house_kai, house_priya, gym, library, street_north, church, school, sports_field]
190
 
191
  - id: gym
192
  name: Iron & Grit Gym
193
  zone: public
194
  description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back.
195
+ capacity: 15
196
+ connected_to: [house_marcus, house_james, house_yuki, apartment_block_2, park, street_south, sports_field]
197
 
198
  - id: library
199
  name: Soci Public Library
 
202
  capacity: 15
203
  connected_to: [house_helen, house_yuki, house_frank, park, street_south]
204
 
205
+ - id: church
206
+ name: "St. Mary's Church"
207
+ zone: public
208
+ description: A stone church with stained glass windows, a quiet garden, and community gatherings every Sunday.
209
+ capacity: 15
210
+ connected_to: [street_north, street_west, park, apartment_block_1, school]
211
+
212
+ - id: town_square
213
+ name: Town Square
214
+ zone: public
215
+ description: The heart of Soci City. A cobblestone plaza with a fountain, benches, a notice board, and a weekend farmers market.
216
+ capacity: 40
217
+ connected_to: [street_north, street_south, office, office_tower, restaurant, cinema, apartment_block_3]
218
+
219
+ - id: sports_field
220
+ name: Community Sports Field
221
+ zone: public
222
+ description: A large grassy field with soccer goals, a running track, and bleachers. Busy on evenings and weekends.
223
+ capacity: 30
224
+ connected_to: [gym, park, street_south, street_west, factory]
225
+
226
  # ===================
227
+ # STREETS — connectors (grid)
228
  # ===================
229
  - id: street_north
230
  name: North Main Street
231
  zone: public
232
  description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
233
+ capacity: 50
234
+ connected_to: [house_elena, house_marcus, house_helen, house_diana, apartment_block_1, apartment_block_3, cafe, grocery, office, office_tower, bakery, park, school, hospital, church, town_square, street_south, street_east, street_west]
235
 
236
  - id: street_south
237
  name: South Main Street
238
  zone: public
239
  description: The southern stretch of main street. More residential, with the bar and gym nearby.
240
+ capacity: 50
241
+ connected_to: [house_james, house_rosa, house_yuki, house_frank, apartment_block_2, apartment_block_3, bar, grocery, gym, library, restaurant, cinema, factory, sports_field, town_square, hospital, street_north, street_east, street_west]
242
+
243
+ - id: street_east
244
+ name: East Avenue
245
+ zone: public
246
+ description: A quieter avenue on the east side of town, near the hospital and office towers.
247
+ capacity: 40
248
+ connected_to: [house_priya, office_tower, hospital, factory, street_north, street_south]
249
+
250
+ - id: street_west
251
+ name: West Avenue
252
+ zone: public
253
+ description: A tree-lined avenue on the west side, near the school and sports fields.
254
  capacity: 40
255
+ connected_to: [house_kai, school, church, sports_field, street_north, street_south]
src/soci/agents/generator.py CHANGED
@@ -69,26 +69,26 @@ LAST_NAMES = [
69
  ]
70
 
71
  OCCUPATIONS = [
72
- # White collar
73
- ("software engineer", "work"), ("accountant", "work"), ("marketing manager", "work"),
74
- ("architect", "work"), ("data analyst", "work"), ("project manager", "work"),
75
- ("graphic designer", "work"), ("lawyer", "work"), ("consultant", "work"),
76
- ("financial advisor", "work"),
77
- # Blue collar / service
78
- ("mechanic", "work"), ("electrician", "work"), ("plumber", "work"),
79
- ("construction worker", "work"),
80
- # Service / hospitality (evening shifts)
81
- ("bartender", "commercial"), ("chef", "commercial"), ("waiter", "commercial"),
82
- ("barista", "commercial"),
83
- # Creative
84
- ("writer", "work"), ("musician", "work"), ("photographer", "work"),
85
- ("artist", "work"),
86
- # Education
87
- ("teacher", "work"), ("professor", "work"), ("tutor", "work"),
88
- # Health
89
- ("nurse", "work"), ("personal trainer", "public"), ("therapist", "work"),
90
  # Student / retired
91
- ("college student", "work"), ("retired", None),
92
  ]
93
 
94
  VALUES_POOL = [
@@ -149,11 +149,11 @@ def _pick_name(gender: str, used_names: set[str]) -> str:
149
 
150
 
151
  def _pick_occupation(age: int) -> tuple[str, str | None]:
152
- """Pick occupation based on age. Returns (title, work_zone)."""
153
  if age >= 65 and random.random() < 0.7:
154
  return "retired", None
155
  if 18 <= age <= 22 and random.random() < 0.6:
156
- return "college student", "work"
157
  return random.choice(OCCUPATIONS)
158
 
159
 
@@ -293,22 +293,22 @@ def _llm_temperature(openness: int) -> float:
293
 
294
 
295
  def _assign_locations(
296
- persona_data: dict,
297
  occupation: str,
298
- work_zone: str | None,
299
  residential_ids: list[str],
300
- work_ids: list[str],
301
- commercial_ids: list[str],
302
  res_index: int,
303
  ) -> tuple[str, str]:
304
  """Assign home and work locations. Returns (home_id, work_id)."""
305
  home_id = residential_ids[res_index % len(residential_ids)]
306
 
307
- if occupation in RETIRED_OCCUPATIONS or work_zone is None:
308
- work_id = home_id # Retired folks "work" from home (leisure)
309
- elif occupation in EVENING_SHIFT_JOBS:
310
- work_id = random.choice(commercial_ids) if commercial_ids else home_id
311
  else:
 
 
312
  work_id = random.choice(work_ids) if work_ids else home_id
313
 
314
  return home_id, work_id
@@ -318,8 +318,6 @@ def generate_personas(count: int, city: City) -> list[Persona]:
318
  """Generate `count` unique personas with assigned home/work locations."""
319
  # Gather location pools
320
  residential_ids = [lid for lid, loc in city.locations.items() if loc.zone == "residential"]
321
- work_ids = [lid for lid, loc in city.locations.items() if loc.zone == "work"]
322
- commercial_ids = [lid for lid, loc in city.locations.items() if loc.zone == "commercial"]
323
 
324
  if not residential_ids:
325
  raise ValueError("City has no residential locations — cannot assign homes.")
@@ -331,7 +329,7 @@ def generate_personas(count: int, city: City) -> list[Persona]:
331
  gender = _pick_gender()
332
  name = _pick_name(gender, used_names)
333
  age = random.randint(18, 75)
334
- occupation, work_zone = _pick_occupation(age)
335
  traits = _generate_traits()
336
  values = _pick_values(traits)
337
  quirks = _pick_quirks()
@@ -340,12 +338,10 @@ def generate_personas(count: int, city: City) -> list[Persona]:
340
  temperature = _llm_temperature(traits["openness"])
341
 
342
  home_id, work_id = _assign_locations(
343
- {},
344
  occupation,
345
- work_zone,
346
  residential_ids,
347
- work_ids,
348
- commercial_ids,
349
  i,
350
  )
351
 
 
69
  ]
70
 
71
  OCCUPATIONS = [
72
+ # White collar → office, office_tower
73
+ ("software engineer", "office"), ("accountant", "office"), ("marketing manager", "office"),
74
+ ("architect", "office"), ("data analyst", "office"), ("project manager", "office"),
75
+ ("graphic designer", "office"), ("lawyer", "office_tower"), ("consultant", "office_tower"),
76
+ ("financial advisor", "office_tower"),
77
+ # Blue collar factory
78
+ ("mechanic", "factory"), ("electrician", "factory"), ("plumber", "factory"),
79
+ ("construction worker", "factory"),
80
+ # Service / hospitality (evening shifts) → commercial
81
+ ("bartender", "bar"), ("chef", "restaurant"), ("waiter", "restaurant"),
82
+ ("barista", "cafe"),
83
+ # Creative → office
84
+ ("writer", "office"), ("musician", "office"), ("photographer", "office"),
85
+ ("artist", "office"),
86
+ # Education → school
87
+ ("teacher", "school"), ("professor", "school"), ("tutor", "school"),
88
+ # Health → hospital
89
+ ("nurse", "hospital"), ("personal trainer", "gym"), ("therapist", "hospital"),
90
  # Student / retired
91
+ ("college student", "school"), ("retired", None),
92
  ]
93
 
94
  VALUES_POOL = [
 
149
 
150
 
151
  def _pick_occupation(age: int) -> tuple[str, str | None]:
152
+ """Pick occupation based on age. Returns (title, work_location_id)."""
153
  if age >= 65 and random.random() < 0.7:
154
  return "retired", None
155
  if 18 <= age <= 22 and random.random() < 0.6:
156
+ return "college student", "school"
157
  return random.choice(OCCUPATIONS)
158
 
159
 
 
293
 
294
 
295
  def _assign_locations(
 
296
  occupation: str,
297
+ work_location_id: str | None,
298
  residential_ids: list[str],
299
+ city_locations: dict,
 
300
  res_index: int,
301
  ) -> tuple[str, str]:
302
  """Assign home and work locations. Returns (home_id, work_id)."""
303
  home_id = residential_ids[res_index % len(residential_ids)]
304
 
305
+ if occupation in RETIRED_OCCUPATIONS or work_location_id is None:
306
+ work_id = home_id # Retired folks stay home
307
+ elif work_location_id in city_locations:
308
+ work_id = work_location_id
309
  else:
310
+ # Fallback: find any work-zone location
311
+ work_ids = [lid for lid, loc in city_locations.items() if loc.zone == "work"]
312
  work_id = random.choice(work_ids) if work_ids else home_id
313
 
314
  return home_id, work_id
 
318
  """Generate `count` unique personas with assigned home/work locations."""
319
  # Gather location pools
320
  residential_ids = [lid for lid, loc in city.locations.items() if loc.zone == "residential"]
 
 
321
 
322
  if not residential_ids:
323
  raise ValueError("City has no residential locations — cannot assign homes.")
 
329
  gender = _pick_gender()
330
  name = _pick_name(gender, used_names)
331
  age = random.randint(18, 75)
332
+ occupation, work_location_id = _pick_occupation(age)
333
  traits = _generate_traits()
334
  values = _pick_values(traits)
335
  quirks = _pick_quirks()
 
338
  temperature = _llm_temperature(traits["openness"])
339
 
340
  home_id, work_id = _assign_locations(
 
341
  occupation,
342
+ work_location_id,
343
  residential_ids,
344
+ city.locations,
 
345
  i,
346
  )
347
 
src/soci/agents/routine.py CHANGED
@@ -153,7 +153,7 @@ class DailyRoutine:
153
  {"purpose": 0.3})
154
 
155
  # Lunch — pick a food place or stay at work
156
- food_places = ["cafe", "restaurant", "grocery"]
157
  lunch_spot = self._rng.choice(food_places)
158
  h, m = t // 60, t % 60
159
  t = self._add(h, m, "move", lunch_spot, 1, f"Walking to lunch at {lunch_spot}",
@@ -289,7 +289,7 @@ class DailyRoutine:
289
  t = self._add(h, m, "relax", home, gap_ticks, "Chilling at home",
290
  {"comfort": 0.1, "fun": 0.05})
291
 
292
- lunch_spot = self._rng.choice(["cafe", "restaurant", "park"])
293
  h, m = t // 60, t % 60
294
  t = self._add(h, m, "move", lunch_spot, 1, f"Heading to {lunch_spot}",
295
  {})
@@ -301,7 +301,8 @@ class DailyRoutine:
301
  if e >= 6:
302
  # Social afternoon: visit multiple places
303
  afternoon_places = self._rng.sample(
304
- ["park", "cafe", "bar", "gym", "library"],
 
305
  k=min(2, self._rng.randint(1, 2)),
306
  )
307
  for place in afternoon_places:
@@ -364,7 +365,7 @@ class DailyRoutine:
364
 
365
  if e >= 6:
366
  # Extroverts go out
367
- venue = self._rng.choice(["bar", "restaurant", "park"])
368
  h, m = t // 60, t % 60
369
  t = self._add(h, m, "move", venue, 1, f"Heading to {venue}",
370
  {})
@@ -393,13 +394,13 @@ class DailyRoutine:
393
  """Fill a leisure period with activities based on personality."""
394
  activities = []
395
  if persona.extraversion >= 6:
396
- activities.extend(["park", "cafe", "gym"])
397
  else:
398
- activities.extend(["library", "park"])
399
  if persona.conscientiousness >= 6:
400
  activities.append("gym")
401
  if persona.openness >= 6:
402
- activities.extend(["library", "park"])
403
 
404
  dest = self._rng.choice(activities)
405
  available_ticks = max(0, (end_t - t) // 15)
@@ -422,12 +423,20 @@ class DailyRoutine:
422
  "cafe": "Hanging out at the cafe",
423
  "gym": "Working out at the gym",
424
  "library": "Reading at the library",
 
 
 
 
425
  }.get(dest, f"Spending time at {dest}")
426
  needs = {
427
  "park": {"fun": 0.2, "comfort": 0.1},
428
  "cafe": {"social": 0.2, "fun": 0.1},
429
  "gym": {"energy": -0.1, "fun": 0.2},
430
  "library": {"fun": 0.15, "comfort": 0.1},
 
 
 
 
431
  }.get(dest, {"fun": 0.1})
432
  h, m = t // 60, t % 60
433
  t = self._add(h, m, act_type, dest, act_ticks, act_detail, needs)
 
153
  {"purpose": 0.3})
154
 
155
  # Lunch — pick a food place or stay at work
156
+ food_places = ["cafe", "restaurant", "grocery", "bakery"]
157
  lunch_spot = self._rng.choice(food_places)
158
  h, m = t // 60, t % 60
159
  t = self._add(h, m, "move", lunch_spot, 1, f"Walking to lunch at {lunch_spot}",
 
289
  t = self._add(h, m, "relax", home, gap_ticks, "Chilling at home",
290
  {"comfort": 0.1, "fun": 0.05})
291
 
292
+ lunch_spot = self._rng.choice(["cafe", "restaurant", "park", "bakery", "town_square"])
293
  h, m = t // 60, t % 60
294
  t = self._add(h, m, "move", lunch_spot, 1, f"Heading to {lunch_spot}",
295
  {})
 
301
  if e >= 6:
302
  # Social afternoon: visit multiple places
303
  afternoon_places = self._rng.sample(
304
+ ["park", "cafe", "bar", "gym", "library", "cinema",
305
+ "town_square", "sports_field"],
306
  k=min(2, self._rng.randint(1, 2)),
307
  )
308
  for place in afternoon_places:
 
365
 
366
  if e >= 6:
367
  # Extroverts go out
368
+ venue = self._rng.choice(["bar", "restaurant", "park", "cinema", "town_square"])
369
  h, m = t // 60, t % 60
370
  t = self._add(h, m, "move", venue, 1, f"Heading to {venue}",
371
  {})
 
394
  """Fill a leisure period with activities based on personality."""
395
  activities = []
396
  if persona.extraversion >= 6:
397
+ activities.extend(["park", "cafe", "gym", "town_square", "sports_field"])
398
  else:
399
+ activities.extend(["library", "park", "church"])
400
  if persona.conscientiousness >= 6:
401
  activities.append("gym")
402
  if persona.openness >= 6:
403
+ activities.extend(["library", "park", "cinema"])
404
 
405
  dest = self._rng.choice(activities)
406
  available_ticks = max(0, (end_t - t) // 15)
 
423
  "cafe": "Hanging out at the cafe",
424
  "gym": "Working out at the gym",
425
  "library": "Reading at the library",
426
+ "cinema": "Watching a movie",
427
+ "town_square": "People-watching at the square",
428
+ "sports_field": "Playing sports at the field",
429
+ "church": "Quiet time at the church",
430
  }.get(dest, f"Spending time at {dest}")
431
  needs = {
432
  "park": {"fun": 0.2, "comfort": 0.1},
433
  "cafe": {"social": 0.2, "fun": 0.1},
434
  "gym": {"energy": -0.1, "fun": 0.2},
435
  "library": {"fun": 0.15, "comfort": 0.1},
436
+ "cinema": {"fun": 0.3, "social": 0.1},
437
+ "town_square": {"social": 0.2, "fun": 0.15},
438
+ "sports_field": {"fun": 0.25, "energy": -0.1},
439
+ "church": {"comfort": 0.2, "purpose": 0.1},
440
  }.get(dest, {"fun": 0.1})
441
  h, m = t // 60, t % 60
442
  t = self._add(h, m, act_type, dest, act_ticks, act_detail, needs)
web/index.html CHANGED
@@ -33,7 +33,7 @@
33
 
34
  /* SIDEBAR */
35
  #sidebar {
36
- width: 380px; background: #16213e; border-left: 2px solid #0f3460;
37
  display: flex; flex-direction: column; overflow: hidden;
38
  }
39
  .sidebar-tabs {
@@ -175,6 +175,13 @@
175
  <canvas id="cityCanvas"></canvas>
176
  <div id="tooltip"></div>
177
  <div id="toast-container"></div>
 
 
 
 
 
 
 
178
  </div>
179
  <div id="sidebar">
180
  <div class="sidebar-tabs">
@@ -202,52 +209,81 @@ const POLL_INTERVAL = 2000;
202
  const HORIZON = 0.14;
203
 
204
  // --- CITY LAYOUT ---
205
- // Road network: main horizontal roads and connecting vertical roads
 
 
 
 
206
  const ROADS = [
207
  // Horizontal roads
208
- { x1: 0.04, y1: 0.30, x2: 0.96, y2: 0.30, width: 14, name: 'North Road' },
209
- { x1: 0.04, y1: 0.65, x2: 0.96, y2: 0.65, width: 14, name: 'South Road' },
210
  // Main vertical street
211
- { x1: 0.50, y1: 0.17, x2: 0.50, y2: 0.92, width: 16, name: 'Main Street' },
212
- // West lane
213
- { x1: 0.18, y1: 0.24, x2: 0.18, y2: 0.85, width: 8 },
214
- // East lane
215
- { x1: 0.82, y1: 0.24, x2: 0.82, y2: 0.85, width: 8 },
216
- // Small connector roads
217
- { x1: 0.18, y1: 0.48, x2: 0.50, y2: 0.48, width: 6 },
218
- { x1: 0.50, y1: 0.48, x2: 0.82, y2: 0.48, width: 6 },
219
- { x1: 0.18, y1: 0.80, x2: 0.50, y2: 0.80, width: 6 },
220
- { x1: 0.50, y1: 0.80, x2: 0.82, y2: 0.80, width: 6 },
 
 
 
 
 
221
  ];
222
 
223
- // Building positions — placed along streets
224
  const LOCATION_POSITIONS = {
225
- // North-side houses (above North Road)
226
- house_elena: { x: 0.10, y: 0.21, type: 'house', label: 'Elena & Lila' },
227
- house_marcus: { x: 0.30, y: 0.21, type: 'house', label: 'Marcus & Zoe' },
228
- house_helen: { x: 0.70, y: 0.21, type: 'house', label: 'Helen & Alice' },
229
- house_diana: { x: 0.90, y: 0.21, type: 'house', label: 'Diana & Marco' },
230
- // Middle houses
231
- house_kai: { x: 0.10, y: 0.40, type: 'house', label: "Kai's Studio" },
232
- house_priya: { x: 0.90, y: 0.40, type: 'house', label: 'Priya & Nina' },
233
- // South-side houses (below South Road)
234
- house_james: { x: 0.10, y: 0.72, type: 'house', label: 'James & Theo' },
235
- house_rosa: { x: 0.30, y: 0.72, type: 'house', label: 'Rosa & Omar' },
236
- house_yuki: { x: 0.70, y: 0.72, type: 'house', label: 'Yuki & Devon' },
237
- house_frank: { x: 0.90, y: 0.72, type: 'house', label: 'Frank+George+Sam' },
238
- // Commercial — along main street
239
- cafe: { x: 0.38, y: 0.35, type: 'shop', label: 'The Daily Grind' },
240
- grocery: { x: 0.62, y: 0.35, type: 'shop', label: 'Green Basket' },
241
- office: { x: 0.50, y: 0.54, type: 'office', label: 'The Hive' },
242
- restaurant: { x: 0.38, y: 0.58, type: 'shop', label: "Mama Rosa's" },
243
- bar: { x: 0.62, y: 0.58, type: 'shop', label: 'Rusty Anchor' },
244
- // Public
245
- park: { x: 0.50, y: 0.22, type: 'park', label: 'Willow Park' },
246
- gym: { x: 0.30, y: 0.54, type: 'public', label: 'Iron & Grit' },
247
- library: { x: 0.70, y: 0.54, type: 'public', label: 'Public Library' },
248
- // Streets
249
- street_north: { x: 0.50, y: 0.30, type: 'street', label: 'N. Main St' },
250
- street_south: { x: 0.50, y: 0.65, type: 'street', label: 'S. Main St' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  };
252
 
253
  const AGENT_COLORS = [
@@ -300,6 +336,10 @@ let stars = [];
300
  let activeTab = 'agents';
301
  let agentIdxMap = {};
302
 
 
 
 
 
303
  // Tree / decorations cache
304
  let trees = [];
305
  let streetLamps = [];
@@ -313,10 +353,11 @@ function initParticles() {
313
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
314
  // Scatter trees in green spaces between buildings
315
  const treeZones = [
316
- {cx:0.50, cy:0.22, rx:0.12, ry:0.04, count:8}, // park
317
- {cx:0.10, cy:0.55, rx:0.04, ry:0.06, count:3}, // west green
318
- {cx:0.90, cy:0.55, rx:0.04, ry:0.06, count:3}, // east green
319
- {cx:0.50, cy:0.88, rx:0.15, ry:0.03, count:5}, // south park
 
320
  ];
321
  for (const z of treeZones) {
322
  for (let i = 0; i < z.count; i++) {
@@ -329,9 +370,11 @@ function initParticles() {
329
  }
330
  }
331
  // Street lamps along roads
332
- for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.20 + i*0.09 });
333
- for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.10 + i*0.26, y: 0.30 });
334
- for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.10 + i*0.26, y: 0.65 });
 
 
335
  }
336
 
337
  // ============================================================
@@ -354,6 +397,10 @@ function initCanvas() {
354
  window.addEventListener('resize', resizeCanvas);
355
  canvas.addEventListener('click', onCanvasClick);
356
  canvas.addEventListener('mousemove', onCanvasMouseMove);
 
 
 
 
357
  initParticles();
358
  requestAnimationFrame(animate);
359
  }
@@ -363,6 +410,40 @@ function resizeCanvas() {
363
  canvas.height = c.clientHeight;
364
  }
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  function animate() {
367
  animFrame++;
368
  for (const [id, target] of Object.entries(agentTargets)) {
@@ -380,17 +461,24 @@ function animate() {
380
  // ============================================================
381
  function draw() {
382
  if (!ctx) return;
383
- const W = canvas.width, H = canvas.height;
 
 
 
 
 
 
 
384
 
385
- drawSky(W, H);
386
  drawGround(W, H);
387
  drawWeather(W, H);
388
  drawRoads(W, H);
389
  drawSidewalks(W, H);
390
  drawTrees(W, H);
391
 
392
- // Draw buildings (non-street, non-park locations)
393
- for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
 
394
  if (pos.type !== 'street') drawBuilding(id, pos, W, H);
395
  }
396
 
@@ -418,6 +506,35 @@ function draw() {
418
  ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
419
  ctx.fillRect(0, 0, W, H);
420
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  }
422
 
423
  function getAgentIdx(id) {
@@ -429,6 +546,7 @@ function getAgentIdx(id) {
429
  // SKY
430
  // ============================================================
431
  function drawSky(W, H) {
 
432
  const s = SKY[currentTimeOfDay] || SKY.morning;
433
  const hLine = H * HORIZON;
434
  const grad = ctx.createLinearGradient(0, 0, 0, hLine);
@@ -474,7 +592,7 @@ function drawMoon(x, y, r) {
474
  // GROUND
475
  // ============================================================
476
  function drawGround(W, H) {
477
- const hLine = H * HORIZON;
478
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
479
  const grad = ctx.createLinearGradient(0, hLine, 0, H);
480
  const bc = hexToRgb(gt.base);
@@ -670,22 +788,32 @@ function drawBuilding(id, pos, W, H) {
670
  if (pos.type === 'house') drawHouse(x, y, isDark, id);
671
  else if (pos.type === 'shop') drawShop(x, y, isDark);
672
  else if (pos.type === 'office') drawOffice(x, y, isDark);
 
673
  else if (pos.type === 'park') drawPark(x, y, isDark);
674
  else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
 
 
 
 
 
 
 
 
675
 
676
  // Label
677
- if (pos.type !== 'park') {
678
  const label = pos.label || id;
679
  const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
680
  ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
681
- const ly = pos.type === 'house' ? y + 18 : y + 25;
682
  ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
683
  ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
684
  }
685
 
686
  // Occupant count badge
687
  if (occ > 0) {
688
- const bx = x + (pos.type === 'house' ? 22 : 28), by = y - (pos.type === 'house' ? 14 : 18);
 
689
  ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
690
  ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
691
  ctx.fillText(occ.toString(), bx, by);
@@ -835,6 +963,257 @@ function drawPark(x, y, dk) {
835
  ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
836
  }
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  function dim(hex, f) {
839
  if (hex.startsWith('rgb')) return hex; // already rgb
840
  const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
@@ -907,7 +1286,8 @@ function drawConversationBubbles(W, H) {
907
  // ============================================================
908
  function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
909
  const loc = agent.location || 'house_elena';
910
- const pos = LOCATION_POSITIONS[loc];
 
911
  if (!pos) return;
912
 
913
  const atLoc = byLoc[loc] || [];
@@ -916,22 +1296,29 @@ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
916
 
917
  // For streets, spread agents along the road
918
  if (pos.type === 'street') {
 
 
919
  const spread = Math.min(count, 10);
920
- const step = 0.04;
921
  const startX = pos.x - (spread-1)*step/2;
922
  agentTargets[id] = {
923
- x: (startX + localIdx * step) * W,
924
- y: pos.y * H + 16 + (localIdx % 2) * 12
925
  };
926
  } else {
927
- // Cluster around building
928
- const radius = pos.type === 'house' ? 18 + Math.floor(count/3)*8 : 28 + Math.floor(count/5)*10;
929
- const step = Math.PI / Math.max(count+1, 2);
930
- const angle = step * (localIdx+1);
931
- const ox = Math.cos(angle)*radius - radius/3;
932
- const oy = Math.sin(angle)*radius*0.5 + (pos.type === 'house' ? 22 : 30);
933
-
934
- agentTargets[id] = { x: pos.x*W + ox, y: pos.y*H + oy };
 
 
 
 
 
935
  }
936
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
937
  }
@@ -1054,8 +1441,9 @@ function drawPerson(id, agent, globalIdx, W, H) {
1054
  // INTERACTION
1055
  // ============================================================
1056
  function onCanvasClick(e) {
 
1057
  const rect=canvas.getBoundingClientRect();
1058
- const mx=e.clientX-rect.left, my=e.clientY-rect.top;
1059
  let clicked=null, minD=24;
1060
  for (const [id,pos] of Object.entries(agentPositions)) {
1061
  const d=Math.hypot(mx-pos.x,my-pos.y);
@@ -1073,10 +1461,12 @@ function onCanvasClick(e) {
1073
 
1074
  function onCanvasMouseMove(e) {
1075
  const rect=canvas.getBoundingClientRect();
1076
- const mx=e.clientX-rect.left, my=e.clientY-rect.top;
1077
- const W=canvas.width, H=canvas.height;
1078
  const tt=document.getElementById('tooltip');
1079
 
 
 
1080
  let foundAgent=null;
1081
  for (const [id,pos] of Object.entries(agentPositions)) {
1082
  if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
@@ -1084,7 +1474,8 @@ function onCanvasMouseMove(e) {
1084
 
1085
  let foundLoc=null;
1086
  if (!foundAgent) {
1087
- for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
 
1088
  const lx=pos.x*W, ly=pos.y*H;
1089
  const hitR = pos.type === 'house' ? 25 : 35;
1090
  if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}
 
33
 
34
  /* SIDEBAR */
35
  #sidebar {
36
+ width: 260px; background: #16213e; border-left: 2px solid #0f3460;
37
  display: flex; flex-direction: column; overflow: hidden;
38
  }
39
  .sidebar-tabs {
 
175
  <canvas id="cityCanvas"></canvas>
176
  <div id="tooltip"></div>
177
  <div id="toast-container"></div>
178
+ <input type="range" id="pan-x" min="0" max="100" value="0"
179
+ style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;"
180
+ oninput="onPanSlider()">
181
+ <input type="range" id="pan-y" min="0" max="100" value="0"
182
+ orient="vertical"
183
+ style="position:absolute;right:4px;top:10px;bottom:24px;width:14px;height:calc(100% - 34px);opacity:0.5;z-index:50;writing-mode:vertical-lr;direction:ltr;-webkit-appearance:slider-vertical;"
184
+ oninput="onPanSlider()">
185
  </div>
186
  <div id="sidebar">
187
  <div class="sidebar-tabs">
 
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 = [
218
  // Horizontal roads
219
+ { x1: 0.03, y1: 0.28, x2: 0.97, y2: 0.28, width: 14, name: 'North Road' },
220
+ { x1: 0.03, y1: 0.58, x2: 0.97, y2: 0.58, width: 14, name: 'South Road' },
221
  // Main vertical street
222
+ { x1: 0.50, y1: 0.13, x2: 0.50, y2: 0.90, width: 16, name: 'Main Street' },
223
+ // West avenue
224
+ { x1: 0.15, y1: 0.20, x2: 0.15, y2: 0.82, width: 10 },
225
+ // East avenue
226
+ { x1: 0.85, y1: 0.20, x2: 0.85, y2: 0.82, width: 10 },
227
+ // Mid connectors
228
+ { x1: 0.15, y1: 0.43, x2: 0.50, y2: 0.43, width: 6 },
229
+ { x1: 0.50, y1: 0.43, x2: 0.85, y2: 0.43, width: 6 },
230
+ { x1: 0.15, y1: 0.72, x2: 0.50, y2: 0.72, width: 6 },
231
+ { x1: 0.50, y1: 0.72, x2: 0.85, y2: 0.72, width: 6 },
232
+ // Extra connectors
233
+ { x1: 0.30, y1: 0.28, x2: 0.30, y2: 0.43, width: 6 },
234
+ { x1: 0.70, y1: 0.28, x2: 0.70, y2: 0.43, width: 6 },
235
+ { x1: 0.30, y1: 0.58, x2: 0.30, y2: 0.72, width: 6 },
236
+ { x1: 0.70, y1: 0.58, x2: 0.70, y2: 0.72, width: 6 },
237
  ];
238
 
239
+ // Building positions — spread across larger grid
240
  const LOCATION_POSITIONS = {
241
+ // === RESIDENTIAL Row 1: North houses ===
242
+ house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' },
243
+ house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' },
244
+ house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' },
245
+ house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' },
246
+ // Row 2: Middle houses
247
+ house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" },
248
+ house_priya: { x: 0.92, y: 0.36, type: 'house', label: 'Priya & Nina' },
249
+ // Row 3: South houses
250
+ house_james: { x: 0.08, y: 0.65, type: 'house', label: 'James & Theo' },
251
+ house_rosa: { x: 0.24, y: 0.65, type: 'house', label: 'Rosa & Omar' },
252
+ house_yuki: { x: 0.62, y: 0.65, type: 'house', label: 'Yuki & Devon' },
253
+ house_frank: { x: 0.78, y: 0.65, type: 'house', label: 'Frank+George+Sam' },
254
+ // Apartment blocks
255
+ apartment_block_1: { x: 0.40, y: 0.19, type: 'apartment', label: 'Northside Apts' },
256
+ apartment_block_2: { x: 0.40, y: 0.65, type: 'apartment', label: 'Southside Apts' },
257
+ apartment_block_3: { x: 0.56, y: 0.50, type: 'apartment', label: 'Central Apts' },
258
+
259
+ // === COMMERCIAL ===
260
+ cafe: { x: 0.35, y: 0.34, type: 'shop', label: 'The Daily Grind' },
261
+ grocery: { x: 0.65, y: 0.34, type: 'shop', label: 'Green Basket' },
262
+ bakery: { x: 0.22, y: 0.34, type: 'shop', label: 'Golden Crust' },
263
+ restaurant: { x: 0.35, y: 0.50, type: 'shop', label: "Mama Rosa's" },
264
+ bar: { x: 0.65, y: 0.50, type: 'shop', label: 'Rusty Anchor' },
265
+ cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' },
266
+
267
+ // === WORK ===
268
+ office: { x: 0.50, y: 0.34, type: 'office', label: 'The Hive' },
269
+ office_tower: { x: 0.80, y: 0.34, type: 'tower', label: 'Pinnacle Tower' },
270
+ factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' },
271
+ school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' },
272
+ hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' },
273
+
274
+ // === PUBLIC ===
275
+ park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' },
276
+ gym: { x: 0.22, y: 0.50, type: 'public', label: 'Iron & Grit' },
277
+ library: { x: 0.78, y: 0.78, type: 'public', label: 'Public Library' },
278
+ church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" },
279
+ town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
280
+ sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
281
+
282
+ // === STREETS ===
283
+ street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' },
284
+ street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' },
285
+ street_east: { x: 0.85, y: 0.43, type: 'street', label: 'East Ave' },
286
+ street_west: { x: 0.15, y: 0.43, type: 'street', label: 'West Ave' },
287
  };
288
 
289
  const AGENT_COLORS = [
 
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 = [];
345
  let streetLamps = [];
 
353
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
354
  // Scatter trees in green spaces between buildings
355
  const treeZones = [
356
+ {cx:0.50, cy:0.19, rx:0.10, ry:0.03, count:8}, // park
357
+ {cx:0.22, cy:0.78, rx:0.06, ry:0.03, count:5}, // sports field
358
+ {cx:0.50, cy:0.50, rx:0.04, ry:0.03, count:4}, // town square
359
+ {cx:0.08, cy:0.78, rx:0.03, ry:0.02, count:3}, // church garden
360
+ {cx:0.50, cy:0.85, rx:0.15, ry:0.03, count:5}, // south green
361
  ];
362
  for (const z of treeZones) {
363
  for (let i = 0; i < z.count; i++) {
 
370
  }
371
  }
372
  // Street lamps along roads
373
+ for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.16 + i*0.10 });
374
+ for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.28 });
375
+ for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.58 });
376
+ for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.15, y: 0.25 + i*0.18 });
377
+ for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.85, y: 0.25 + i*0.18 });
378
  }
379
 
380
  // ============================================================
 
397
  window.addEventListener('resize', resizeCanvas);
398
  canvas.addEventListener('click', onCanvasClick);
399
  canvas.addEventListener('mousemove', onCanvasMouseMove);
400
+ canvas.addEventListener('mousedown', onCanvasDragStart);
401
+ canvas.addEventListener('mousemove', onCanvasDrag);
402
+ canvas.addEventListener('mouseup', onCanvasDragEnd);
403
+ canvas.addEventListener('mouseleave', onCanvasDragEnd);
404
  initParticles();
405
  requestAnimationFrame(animate);
406
  }
 
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');
421
+ const sy = document.getElementById('pan-y');
422
+ panX = (sx.value / 100) * maxPanX();
423
+ panY = (sy.value / 100) * maxPanY();
424
+ }
425
+ function syncSliders() {
426
+ const sx = document.getElementById('pan-x');
427
+ const sy = document.getElementById('pan-y');
428
+ if (sx) sx.value = maxPanX() > 0 ? (panX / maxPanX()) * 100 : 0;
429
+ if (sy) sy.value = maxPanY() > 0 ? (panY / maxPanY()) * 100 : 0;
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++;
449
  for (const [id, target] of Object.entries(agentTargets)) {
 
461
  // ============================================================
462
  function draw() {
463
  if (!ctx) return;
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);
477
  drawTrees(W, H);
478
 
479
+ // Draw buildings known positions + auto-generated houses
480
+ const allPositions = getEffectivePositions();
481
+ for (const [id, pos] of Object.entries(allPositions)) {
482
  if (pos.type !== 'street') drawBuilding(id, pos, W, H);
483
  }
484
 
 
506
  ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
507
  ctx.fillRect(0, 0, W, H);
508
  }
509
+
510
+ ctx.restore();
511
+ }
512
+
513
+ // Auto-compute positions for generated houses not in LOCATION_POSITIONS
514
+ let _genPosCache = {};
515
+ function getEffectivePositions() {
516
+ const result = {...LOCATION_POSITIONS};
517
+ // Add any locations from server data that we don't have positions for
518
+ for (const locId of Object.keys(locations)) {
519
+ if (!result[locId]) {
520
+ if (!_genPosCache[locId]) {
521
+ // Hash-based position in residential rows
522
+ let h = 0;
523
+ for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
524
+ const col = ((h >>> 0) % 12) / 12;
525
+ const row = ((h >>> 4) % 3);
526
+ const rowY = [0.82, 0.86, 0.90][row];
527
+ _genPosCache[locId] = {
528
+ x: 0.06 + col * 0.88,
529
+ y: rowY + ((h >>> 8) % 3) * 0.01,
530
+ type: 'house',
531
+ label: (locations[locId]?.name || locId).slice(0, 14),
532
+ };
533
+ }
534
+ result[locId] = _genPosCache[locId];
535
+ }
536
+ }
537
+ return result;
538
  }
539
 
540
  function getAgentIdx(id) {
 
546
  // SKY
547
  // ============================================================
548
  function drawSky(W, H) {
549
+ // Sky is always drawn at canvas size (before pan transform)
550
  const s = SKY[currentTimeOfDay] || SKY.morning;
551
  const hLine = H * HORIZON;
552
  const grad = ctx.createLinearGradient(0, 0, 0, hLine);
 
592
  // GROUND
593
  // ============================================================
594
  function drawGround(W, H) {
595
+ const hLine = (canvas.height * HORIZON);
596
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
597
  const grad = ctx.createLinearGradient(0, hLine, 0, H);
598
  const bc = hexToRgb(gt.base);
 
788
  if (pos.type === 'house') drawHouse(x, y, isDark, id);
789
  else if (pos.type === 'shop') drawShop(x, y, isDark);
790
  else if (pos.type === 'office') drawOffice(x, y, isDark);
791
+ else if (pos.type === 'tower') drawTower(x, y, isDark);
792
  else if (pos.type === 'park') drawPark(x, y, isDark);
793
  else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
794
+ else if (pos.type === 'factory') drawFactory(x, y, isDark);
795
+ else if (pos.type === 'school') drawSchool(x, y, isDark);
796
+ else if (pos.type === 'hospital') drawHospital(x, y, isDark);
797
+ else if (pos.type === 'church') drawChurch(x, y, isDark);
798
+ else if (pos.type === 'cinema') drawCinema(x, y, isDark);
799
+ else if (pos.type === 'apartment') drawApartment(x, y, isDark);
800
+ else if (pos.type === 'square') drawSquare(x, y, isDark);
801
+ else if (pos.type === 'sports') drawSportsField(x, y, isDark);
802
 
803
  // Label
804
+ if (pos.type !== 'park' && pos.type !== 'square' && pos.type !== 'sports') {
805
  const label = pos.label || id;
806
  const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
807
  ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
808
+ const ly = pos.type === 'house' ? y + 18 : (pos.type === 'tower' ? y + 35 : y + 25);
809
  ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
810
  ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
811
  }
812
 
813
  // Occupant count badge
814
  if (occ > 0) {
815
+ const byOff = pos.type === 'house' ? 14 : (pos.type === 'tower' ? 32 : (pos.type === 'church' ? 40 : 18));
816
+ const bx = x + (pos.type === 'house' ? 22 : 28), by = y - byOff;
817
  ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
818
  ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
819
  ctx.fillText(occ.toString(), bx, by);
 
963
  ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
964
  }
965
 
966
+ function drawTower(x, y, dk) {
967
+ const w = 36, h = 56;
968
+ // Glass facade
969
+ ctx.fillStyle = dk ? '#1e2038' : '#6880a0';
970
+ ctx.fillRect(x-w/2, y-h/2, w, h);
971
+ // Top cap
972
+ ctx.fillStyle = dk ? '#141828' : '#506880';
973
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
974
+ // Antenna
975
+ ctx.fillStyle = '#888'; ctx.fillRect(x-1, y-h/2-12, 2, 10);
976
+ ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(x, y-h/2-12, 2, 0, 6.28); ctx.fill();
977
+ // Door
978
+ ctx.fillStyle = dk ? '#0a0e18' : '#384858';
979
+ ctx.fillRect(x-5, y+h/2-14, 10, 14);
980
+ // Window grid (5 rows x 4 cols)
981
+ const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.5)';
982
+ ctx.fillStyle = wc;
983
+ for (let r = 0; r < 5; r++)
984
+ for (let c = 0; c < 4; c++)
985
+ ctx.fillRect(x-w/2+3+c*8.5, y-h/2+5+r*10, 6, 6);
986
+ }
987
+
988
+ function drawFactory(x, y, dk) {
989
+ const w = 56, h = 34;
990
+ // Main building
991
+ ctx.fillStyle = dk ? '#2a2520' : '#8a7a6a';
992
+ ctx.fillRect(x-w/2, y-h/2, w, h);
993
+ // Saw-tooth roof
994
+ ctx.fillStyle = dk ? '#1a1815' : '#6a5a4a';
995
+ for (let i = 0; i < 3; i++) {
996
+ const rx = x - w/2 + i*(w/3);
997
+ ctx.beginPath();
998
+ ctx.moveTo(rx, y-h/2);
999
+ ctx.lineTo(rx + w/6, y-h/2-10);
1000
+ ctx.lineTo(rx + w/3, y-h/2);
1001
+ ctx.closePath(); ctx.fill();
1002
+ }
1003
+ // Chimney with smoke
1004
+ ctx.fillStyle = dk ? '#3a3020' : '#706050';
1005
+ ctx.fillRect(x+w/2-10, y-h/2-18, 8, 14);
1006
+ // Smoke
1007
+ ctx.fillStyle = `rgba(180,180,180,${dk?0.15:0.25})`;
1008
+ for (let i = 0; i < 3; i++) {
1009
+ const sy = y-h/2-22-i*8 + Math.sin(animFrame*0.03+i)*3;
1010
+ ctx.beginPath(); ctx.arc(x+w/2-6+i*3, sy, 4+i*2, 0, 6.28); ctx.fill();
1011
+ }
1012
+ // Loading door
1013
+ ctx.fillStyle = dk ? '#1a1815' : '#5a4a3a';
1014
+ ctx.fillRect(x-8, y+h/2-16, 16, 16);
1015
+ // Windows
1016
+ const wc = dk ? 'rgba(255,180,80,0.5)' : 'rgba(200,220,240,0.4)';
1017
+ ctx.fillStyle = wc;
1018
+ for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*14, y-h/2+6, 10, 8);
1019
+ }
1020
+
1021
+ function drawSchool(x, y, dk) {
1022
+ const w = 52, h = 36;
1023
+ // Main building
1024
+ ctx.fillStyle = dk ? '#2a2225' : '#c4a088';
1025
+ ctx.fillRect(x-w/2, y-h/2, w, h);
1026
+ // Roof
1027
+ ctx.fillStyle = dk ? '#1a1518' : '#8a5a3a';
1028
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
1029
+ // Flagpole
1030
+ ctx.fillStyle = '#888'; ctx.fillRect(x+w/2-6, y-h/2-20, 2, 18);
1031
+ // Flag
1032
+ ctx.fillStyle = '#e94560';
1033
+ ctx.beginPath();
1034
+ ctx.moveTo(x+w/2-4, y-h/2-20);
1035
+ ctx.lineTo(x+w/2+8, y-h/2-16);
1036
+ ctx.lineTo(x+w/2-4, y-h/2-12);
1037
+ ctx.closePath(); ctx.fill();
1038
+ // Door
1039
+ ctx.fillStyle = dk ? '#1a1218' : '#6a4a3a';
1040
+ ctx.fillRect(x-5, y+h/2-14, 10, 14);
1041
+ // Windows (2 rows)
1042
+ const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(200,230,255,0.5)';
1043
+ ctx.fillStyle = wc;
1044
+ for (let r = 0; r < 2; r++)
1045
+ for (let c = 0; c < 4; c++)
1046
+ ctx.fillRect(x-w/2+4+c*12, y-h/2+5+r*12, 8, 8);
1047
+ }
1048
+
1049
+ function drawHospital(x, y, dk) {
1050
+ const w = 50, h = 40;
1051
+ // Main building
1052
+ ctx.fillStyle = dk ? '#1e2228' : '#c8ccd0';
1053
+ ctx.fillRect(x-w/2, y-h/2, w, h);
1054
+ // Flat roof
1055
+ ctx.fillStyle = dk ? '#141820' : '#a0a4a8';
1056
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
1057
+ // Red cross
1058
+ ctx.fillStyle = '#e94560';
1059
+ ctx.fillRect(x-3, y-h/2+4, 6, 14);
1060
+ ctx.fillRect(x-7, y-h/2+8, 14, 6);
1061
+ // Door
1062
+ ctx.fillStyle = dk ? '#0a1018' : '#4a5a6a';
1063
+ ctx.fillRect(x-5, y+h/2-12, 10, 12);
1064
+ // Windows
1065
+ const wc = dk ? 'rgba(200,240,255,0.55)' : 'rgba(200,230,255,0.5)';
1066
+ ctx.fillStyle = wc;
1067
+ for (let r = 0; r < 2; r++)
1068
+ for (let c = 0; c < 4; c++)
1069
+ ctx.fillRect(x-w/2+4+c*12, y-h/2+20+r*8, 8, 5);
1070
+ }
1071
+
1072
+ function drawChurch(x, y, dk) {
1073
+ const w = 40, h = 36;
1074
+ // Main building
1075
+ ctx.fillStyle = dk ? '#2a2828' : '#c0b8a8';
1076
+ ctx.fillRect(x-w/2, y-h/2, w, h);
1077
+ // Steeple
1078
+ ctx.fillStyle = dk ? '#1a1818' : '#908880';
1079
+ ctx.fillRect(x-6, y-h/2-18, 12, 18);
1080
+ // Steeple top
1081
+ ctx.beginPath();
1082
+ ctx.moveTo(x-8, y-h/2-18);
1083
+ ctx.lineTo(x, y-h/2-30);
1084
+ ctx.lineTo(x+8, y-h/2-18);
1085
+ ctx.closePath(); ctx.fill();
1086
+ // Cross
1087
+ ctx.fillStyle = dk ? '#888' : '#d4c8a0';
1088
+ ctx.fillRect(x-1.5, y-h/2-38, 3, 10);
1089
+ ctx.fillRect(x-4, y-h/2-36, 8, 3);
1090
+ // Stained glass window
1091
+ ctx.fillStyle = dk ? 'rgba(100,150,255,0.5)' : 'rgba(80,120,200,0.4)';
1092
+ ctx.beginPath(); ctx.arc(x, y-h/2-8, 4, 0, 6.28); ctx.fill();
1093
+ // Door (arched)
1094
+ ctx.fillStyle = dk ? '#1a1518' : '#5a4a3a';
1095
+ ctx.fillRect(x-5, y+h/2-14, 10, 14);
1096
+ ctx.beginPath(); ctx.arc(x, y+h/2-14, 5, Math.PI, 0); ctx.fill();
1097
+ // Side windows
1098
+ const wc = dk ? 'rgba(255,200,100,0.5)' : 'rgba(200,180,120,0.45)';
1099
+ ctx.fillStyle = wc;
1100
+ ctx.fillRect(x-w/2+4, y-h/2+6, 6, 10);
1101
+ ctx.fillRect(x+w/2-10, y-h/2+6, 6, 10);
1102
+ // Label
1103
+ ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
1104
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText("St. Mary's", x+1, y+h/2+3);
1105
+ ctx.fillStyle = dk ? '#a0a8c0' : '#fff'; ctx.fillText("St. Mary's", x, y+h/2+2);
1106
+ }
1107
+
1108
+ function drawCinema(x, y, dk) {
1109
+ const w = 48, h = 34;
1110
+ // Main building
1111
+ ctx.fillStyle = dk ? '#2a1828' : '#5a3060';
1112
+ ctx.fillRect(x-w/2, y-h/2, w, h);
1113
+ // Marquee sign
1114
+ ctx.fillStyle = dk ? '#4a2040' : '#8a4080';
1115
+ ctx.fillRect(x-w/2+4, y-h/2-8, w-8, 10);
1116
+ // Marquee lights
1117
+ ctx.fillStyle = dk ? '#f0c040' : '#ffe880';
1118
+ for (let i = 0; i < 8; i++) {
1119
+ const lx = x-w/2+8+i*5;
1120
+ const flicker = Math.sin(animFrame*0.1+i*0.8) > 0;
1121
+ if (flicker || !dk) {
1122
+ ctx.beginPath(); ctx.arc(lx, y-h/2-3, 1.5, 0, 6.28); ctx.fill();
1123
+ }
1124
+ }
1125
+ // "CINEMA" text
1126
+ ctx.fillStyle = dk ? '#f0c040' : '#fff';
1127
+ ctx.font = 'bold 7px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1128
+ ctx.fillText('CINEMA', x, y-h/2-2);
1129
+ // Door
1130
+ ctx.fillStyle = dk ? '#1a0818' : '#3a1830';
1131
+ ctx.fillRect(x-5, y+h/2-12, 10, 12);
1132
+ // Poster frames
1133
+ const wc = dk ? 'rgba(255,200,100,0.4)' : 'rgba(200,180,240,0.5)';
1134
+ ctx.fillStyle = wc;
1135
+ ctx.fillRect(x-w/2+4, y-h/2+8, 12, 16);
1136
+ ctx.fillRect(x+w/2-16, y-h/2+8, 12, 16);
1137
+ }
1138
+
1139
+ function drawApartment(x, y, dk) {
1140
+ const w = 38, h = 48;
1141
+ // Main building (tall)
1142
+ ctx.fillStyle = dk ? '#2a2830' : '#a09890';
1143
+ ctx.fillRect(x-w/2, y-h/2, w, h);
1144
+ // Flat roof
1145
+ ctx.fillStyle = dk ? '#1a1820' : '#807870';
1146
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
1147
+ // Door
1148
+ ctx.fillStyle = dk ? '#1a1520' : '#605850';
1149
+ ctx.fillRect(x-4, y+h/2-10, 8, 10);
1150
+ // Windows (4 rows x 3 cols)
1151
+ const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.45)';
1152
+ ctx.fillStyle = wc;
1153
+ for (let r = 0; r < 4; r++)
1154
+ for (let c = 0; c < 3; c++) {
1155
+ // Some windows dark at night
1156
+ if (dk && ((r*3+c+animFrame) % 7 < 2)) continue;
1157
+ ctx.fillRect(x-w/2+4+c*12, y-h/2+5+r*11, 8, 7);
1158
+ }
1159
+ }
1160
+
1161
+ function drawSquare(x, y, dk) {
1162
+ // Cobblestone plaza
1163
+ ctx.fillStyle = dk ? '#2a2820' : '#b0a890';
1164
+ ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.fill();
1165
+ ctx.strokeStyle = dk ? '#3a3828' : '#c0b898';
1166
+ ctx.lineWidth = 2; ctx.stroke();
1167
+ // Fountain
1168
+ ctx.fillStyle = dk ? '#1a2a3a' : '#708898';
1169
+ ctx.beginPath(); ctx.ellipse(x, y, 12, 7, 0, 0, 6.28); ctx.fill();
1170
+ ctx.fillStyle = dk ? '#2a3a5a' : '#88b0d0';
1171
+ ctx.beginPath(); ctx.ellipse(x, y-2, 6, 4, 0, 0, 6.28); ctx.fill();
1172
+ // Water spray
1173
+ if (!dk || animFrame % 3 < 2) {
1174
+ ctx.fillStyle = `rgba(100,180,220,${dk?0.3:0.5})`;
1175
+ ctx.beginPath(); ctx.arc(x, y-8+Math.sin(animFrame*0.06)*2, 3, 0, 6.28); ctx.fill();
1176
+ }
1177
+ // Benches
1178
+ ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30';
1179
+ ctx.fillRect(x-30, y+12, 12, 3);
1180
+ ctx.fillRect(x+18, y+12, 12, 3);
1181
+ // Notice board
1182
+ ctx.fillStyle = dk ? '#2a2520' : '#8a7a60';
1183
+ ctx.fillRect(x+30, y-8, 8, 12);
1184
+ // Label
1185
+ ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
1186
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Town Square', x+1, y+21);
1187
+ ctx.fillStyle = dk ? '#a0a8b0' : '#fff'; ctx.fillText('Town Square', x, y+20);
1188
+ }
1189
+
1190
+ function drawSportsField(x, y, dk) {
1191
+ // Large green field
1192
+ ctx.fillStyle = dk ? '#1a3018' : '#4a9a38';
1193
+ ctx.beginPath(); ctx.ellipse(x, y, 48, 24, 0, 0, 6.28); ctx.fill();
1194
+ // Field lines
1195
+ ctx.strokeStyle = dk ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)';
1196
+ ctx.lineWidth = 1.5;
1197
+ ctx.beginPath(); ctx.ellipse(x, y, 44, 20, 0, 0, 6.28); ctx.stroke();
1198
+ ctx.beginPath(); ctx.moveTo(x, y-20); ctx.lineTo(x, y+20); ctx.stroke();
1199
+ ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.28); ctx.stroke();
1200
+ // Goals
1201
+ ctx.strokeStyle = dk ? '#555' : '#ddd'; ctx.lineWidth = 2;
1202
+ ctx.strokeRect(x-46, y-6, 4, 12);
1203
+ ctx.strokeRect(x+42, y-6, 4, 12);
1204
+ // Running track
1205
+ ctx.strokeStyle = dk ? '#3a2828' : '#c87850';
1206
+ ctx.lineWidth = 3;
1207
+ ctx.beginPath(); ctx.ellipse(x, y, 52, 28, 0, 0, 6.28); ctx.stroke();
1208
+ // Bleachers
1209
+ ctx.fillStyle = dk ? '#2a2828' : '#888';
1210
+ ctx.fillRect(x-20, y+26, 40, 5);
1211
+ // Label
1212
+ ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
1213
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Sports Field', x+1, y+33);
1214
+ ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Sports Field', x, y+32);
1215
+ }
1216
+
1217
  function dim(hex, f) {
1218
  if (hex.startsWith('rgb')) return hex; // already rgb
1219
  const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
 
1286
  // ============================================================
1287
  function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
1288
  const loc = agent.location || 'house_elena';
1289
+ const allPos = getEffectivePositions();
1290
+ const pos = allPos[loc];
1291
  if (!pos) return;
1292
 
1293
  const atLoc = byLoc[loc] || [];
 
1296
 
1297
  // For streets, spread agents along the road
1298
  if (pos.type === 'street') {
1299
+ const row = Math.floor(localIdx / 10);
1300
+ const col = localIdx % 10;
1301
  const spread = Math.min(count, 10);
1302
+ const step = 0.025;
1303
  const startX = pos.x - (spread-1)*step/2;
1304
  agentTargets[id] = {
1305
+ x: (startX + col * step) * W,
1306
+ y: pos.y * H + 16 + row * 18 + (col % 2) * 10
1307
  };
1308
  } else {
1309
+ // Multi-row arrangement for large crowds
1310
+ const baseRadius = pos.type === 'house' ? 16 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 35 : 24);
1311
+ const maxPerRow = pos.type === 'house' ? 4 : 8;
1312
+ const row = Math.floor(localIdx / maxPerRow);
1313
+ const colIdx = localIdx % maxPerRow;
1314
+ const rowCount = Math.min(count - row * maxPerRow, maxPerRow);
1315
+ const radius = baseRadius + row * 14;
1316
+ const step = Math.PI / Math.max(rowCount + 1, 2);
1317
+ const angle = step * (colIdx + 1);
1318
+ const ox = Math.cos(angle) * radius - radius / 3;
1319
+ const oy = Math.sin(angle) * radius * 0.45 + (pos.type === 'house' ? 20 : 28) + row * 6;
1320
+
1321
+ agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
1322
  }
1323
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
1324
  }
 
1441
  // INTERACTION
1442
  // ============================================================
1443
  function onCanvasClick(e) {
1444
+ if (isDragging) return;
1445
  const rect=canvas.getBoundingClientRect();
1446
+ const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
1447
  let clicked=null, minD=24;
1448
  for (const [id,pos] of Object.entries(agentPositions)) {
1449
  const d=Math.hypot(mx-pos.x,my-pos.y);
 
1461
 
1462
  function onCanvasMouseMove(e) {
1463
  const rect=canvas.getBoundingClientRect();
1464
+ const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
1465
+ const W=worldW(), H=worldH();
1466
  const tt=document.getElementById('tooltip');
1467
 
1468
+ if (isDragging) return;
1469
+
1470
  let foundAgent=null;
1471
  for (const [id,pos] of Object.entries(agentPositions)) {
1472
  if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
 
1474
 
1475
  let foundLoc=null;
1476
  if (!foundAgent) {
1477
+ const allPos = getEffectivePositions();
1478
+ for (const [id, pos] of Object.entries(allPos)) {
1479
  const lx=pos.x*W, ly=pos.y*H;
1480
  const hitR = pos.type === 'house' ? 25 : 35;
1481
  if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}