-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
623 lines (550 loc) · 59.4 KB
/
index.html
File metadata and controls
623 lines (550 loc) · 59.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>The Chaperone's Ledger</title>
<!-- PWA Links -->
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link rel="apple-touch-icon" href="icon.svg">
<meta name="theme-color" content="#2c3e50">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Leaflet CSS & JS for Mapping -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 50: '#f8fafc', 100: '#f1f5f9', 800: '#1e293b', 900: '#0f172a' }
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'],
serif: ['Georgia', 'Cambria', 'Times New Roman', 'Times', 'serif']
}
}
}
}
</script>
<style>
body { -webkit-tap-highlight-color: transparent; }
.tab-active { border-bottom: 2px solid #0f172a; font-weight: 600; color: #0f172a; }
.tab-inactive { color: #64748b; }
#map { height: calc(100vh - 220px); width: 100%; border-radius: 0.5rem; z-index: 10; }
/* Custom map markers */
.custom-marker svg { drop-shadow: 0px 2px 4px rgba(0,0,0,0.3); transition: transform 0.2s; }
.custom-marker:hover svg { transform: scale(1.1); }
/* Hide scrollbar for clean UI but allow scroll */
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Touch friendly tap target utilities */
.tap-target { min-height: 44px; min-width: 44px; }
</style>
</head>
<body class="bg-brand-50 text-brand-900 font-sans antialiased min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-5xl mx-auto px-4 py-4 flex justify-between items-center">
<div>
<h1 class="text-2xl font-serif font-bold text-gray-900 tracking-tight">The Ledger</h1>
<p class="text-[10px] sm:text-xs text-gray-500 font-medium uppercase tracking-widest mt-1">Chaperone Survival Guide</p>
</div>
<nav class="flex space-x-2 sm:space-x-4 text-sm font-medium" id="nav-tabs">
<button onclick="switchTab('wizard')" id="tab-wizard" class="tab-active px-2 py-2 tap-target transition-colors">Wizard</button>
<button onclick="switchTab('map')" id="tab-map" class="tab-inactive px-2 py-2 tap-target transition-colors">Map</button>
<button onclick="switchTab('directory')" id="tab-directory" class="tab-inactive px-2 py-2 tap-target transition-colors">Directory</button>
</nav>
</div>
</header>
<!-- Main Content Area -->
<main class="flex-grow max-w-5xl mx-auto w-full px-4 py-6">
<!-- SECTION: Wizard -->
<section id="view-wizard" class="space-y-6">
<div class="bg-white p-5 md:p-8 rounded-xl shadow-sm border border-gray-100">
<h2 class="text-xl font-serif font-semibold mb-2">The Recommendation Engine</h2>
<p class="text-gray-600 text-sm mb-6 leading-relaxed">Please stipulate the operational parameters of today’s excursion. Multi-selection is encouraged; we shall attempt to filter out the most egregious mistakes.</p>
<form id="wizard-form" class="space-y-8" onsubmit="handleWizard(event)">
<!-- Q1: Age -->
<div class="space-y-3">
<label class="block font-medium text-gray-800">1. Child's Age</label>
<div class="flex items-center space-x-4">
<button type="button" onclick="adjustAge(-1)" class="w-12 h-12 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600 transition shadow-sm tap-target">−</button>
<input type="number" id="wiz-age" min="0" max="17" step="1" value="4" class="w-20 text-center text-xl font-semibold bg-transparent border-b-2 border-slate-800 focus:outline-none focus:ring-0" readonly>
<button type="button" onclick="adjustAge(1)" class="w-12 h-12 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600 transition shadow-sm tap-target">+</button>
</div>
</div>
<!-- Q2: Weather -->
<div class="space-y-3">
<label class="block font-medium text-gray-800">2. Environment (Select one or both)</label>
<div class="flex flex-wrap gap-3">
<label class="cursor-pointer flex-1 sm:flex-none">
<input type="checkbox" value="indoor" class="peer sr-only wiz-env-cb" checked>
<span class="tap-target w-full flex items-center justify-center px-4 py-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white peer-checked:border-slate-800 transition shadow-sm text-sm font-medium">Indoor / Rain-Safe</span>
</label>
<label class="cursor-pointer flex-1 sm:flex-none">
<input type="checkbox" value="outdoor" class="peer sr-only wiz-env-cb" checked>
<span class="tap-target w-full flex items-center justify-center px-4 py-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white peer-checked:border-slate-800 transition shadow-sm text-sm font-medium">Outdoor / Dry</span>
</label>
</div>
</div>
<!-- Q3: Adult Exertion -->
<div class="space-y-3">
<label class="block font-medium text-gray-800">3. Adult Exertion Tolerance</label>
<p class="text-xs text-gray-500 mb-2">How much marching are the chaperones prepared to endure?</p>
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<label class="cursor-pointer">
<input type="checkbox" value="Low" class="peer sr-only wiz-adult-ex-cb" checked>
<span class="tap-target flex flex-col items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-center">
<span class="font-medium text-sm">Low</span>
<span class="text-[10px] opacity-75 hidden sm:block mt-1">Requires Seating</span>
</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" value="Medium" class="peer sr-only wiz-adult-ex-cb" checked>
<span class="tap-target flex flex-col items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-center">
<span class="font-medium text-sm">Medium</span>
<span class="text-[10px] opacity-75 hidden sm:block mt-1">Gentle Stroll</span>
</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" value="High" class="peer sr-only wiz-adult-ex-cb">
<span class="tap-target flex flex-col items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-center">
<span class="font-medium text-sm">High</span>
<span class="text-[10px] opacity-75 hidden sm:block mt-1">Vast Distances</span>
</span>
</label>
</div>
</div>
<!-- Q4: Child Exertion -->
<div class="space-y-3">
<label class="block font-medium text-gray-800">4. Child Energy Burn</label>
<p class="text-xs text-gray-500 mb-2">How thoroughly must the child be exhausted?</p>
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<label class="cursor-pointer">
<input type="checkbox" value="Low" class="peer sr-only wiz-child-ex-cb" checked>
<span class="tap-target flex items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">Low</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" value="Medium" class="peer sr-only wiz-child-ex-cb" checked>
<span class="tap-target flex items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">Medium</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" value="High" class="peer sr-only wiz-child-ex-cb" checked>
<span class="tap-target flex items-center justify-center p-3 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">High</span>
</label>
</div>
</div>
<!-- Q5: Logistics -->
<div class="space-y-3">
<label class="block font-medium text-gray-800">5. Logistical Necessities</label>
<div class="flex flex-wrap gap-3">
<label class="cursor-pointer">
<input type="checkbox" id="wiz-cafe" class="peer sr-only">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">☕ On-Site Café Mandatory</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" id="wiz-free" class="peer sr-only">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">🎟️ Free Child Entry</span>
</label>
<label class="cursor-pointer">
<input type="checkbox" id="wiz-athome" class="peer sr-only">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition shadow-sm text-sm font-medium">🏠 Include 'At Home'</span>
</label>
</div>
</div>
<button type="submit" class="w-full sm:w-auto tap-target px-8 py-4 bg-slate-900 hover:bg-slate-800 active:bg-slate-950 text-white font-medium rounded-xl shadow-md transition transform active:scale-95">
Determine Our Fate
</button>
</form>
</div>
<div id="wizard-results" class="hidden space-y-4">
<h3 class="text-lg font-serif font-semibold border-b border-gray-200 pb-2">What We're Left With (<span id="result-count">0</span>)</h3>
<div id="wizard-cards" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Cards injected here -->
</div>
</div>
</section>
<!-- SECTION: Map -->
<section id="view-map" class="hidden flex-col h-full space-y-4">
<!-- Map Touch Filters -->
<div class="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex flex-col gap-4">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500">Filters</div>
<div class="flex overflow-x-auto hide-scrollbar pb-2 gap-2" id="map-filter-container">
<!-- Environment -->
<label class="shrink-0 cursor-pointer">
<input type="checkbox" value="indoor" class="peer sr-only map-filter-env" checked>
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-brand-900 peer-checked:text-white transition text-xs font-medium">Indoor</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="checkbox" value="outdoor" class="peer sr-only map-filter-env" checked>
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-brand-900 peer-checked:text-white transition text-xs font-medium">Outdoor</span>
</label>
<div class="w-px bg-gray-300 shrink-0 mx-1"></div>
<!-- Adult Effort -->
<label class="shrink-0 cursor-pointer">
<input type="checkbox" value="Low" class="peer sr-only map-filter-adult" checked>
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-blue-600 peer-checked:text-white transition text-xs font-medium">Adult: Low</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="checkbox" value="Medium" class="peer sr-only map-filter-adult" checked>
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-blue-600 peer-checked:text-white transition text-xs font-medium">Adult: Med</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="checkbox" value="High" class="peer sr-only map-filter-adult" checked>
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-blue-600 peer-checked:text-white transition text-xs font-medium">Adult: High</span>
</label>
<div class="w-px bg-gray-300 shrink-0 mx-1"></div>
<!-- Logistics -->
<label class="shrink-0 cursor-pointer">
<input type="checkbox" id="map-filter-cafe" class="peer sr-only map-filter-logistics">
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-emerald-600 peer-checked:text-white transition text-xs font-medium">☕ Café</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="checkbox" id="map-filter-free" class="peer sr-only map-filter-logistics">
<span class="tap-target flex items-center px-3 py-2 border border-gray-200 rounded-full peer-checked:bg-emerald-600 peer-checked:text-white transition text-xs font-medium">🎟️ Free</span>
</label>
</div>
</div>
<div id="map-wrapper" class="relative bg-gray-200 rounded-xl shadow-sm border border-gray-300 overflow-hidden">
<div id="map"></div>
<!-- Legend overlay -->
<div class="absolute bottom-4 left-4 z-[400] bg-white/90 backdrop-blur text-[10px] p-2 rounded shadow border border-gray-200 pointer-events-none">
<div class="font-bold mb-1">Key</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-emerald-500 mr-1"></span>Park/Nature</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1"></span>Museum/Culture</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-rose-500 mr-1"></span>Play/Action</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-purple-500 mr-1"></span>Theatre</div>
</div>
</div>
</section>
<!-- SECTION: Directory -->
<section id="view-directory" class="hidden flex-col h-full space-y-4">
<!-- Directory Search & Filters -->
<div class="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
<input type="text" id="dir-search" placeholder="Search activities, postcodes, or notes..." class="w-full tap-target p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-slate-800 text-base" oninput="renderDirectory()">
<div class="flex overflow-x-auto hide-scrollbar gap-2">
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="all" class="peer sr-only" checked onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-slate-800 peer-checked:text-white transition text-sm font-medium">All</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="museum" class="peer sr-only" onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-blue-600 peer-checked:text-white transition text-sm font-medium">Museums</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="park" class="peer sr-only" onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-emerald-600 peer-checked:text-white transition text-sm font-medium">Parks</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="play" class="peer sr-only" onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-rose-600 peer-checked:text-white transition text-sm font-medium">Play</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="theatre" class="peer sr-only" onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-purple-600 peer-checked:text-white transition text-sm font-medium">Theatre</span>
</label>
<label class="shrink-0 cursor-pointer">
<input type="radio" name="dir-category" value="home" class="peer sr-only" onchange="renderDirectory()">
<span class="tap-target flex items-center px-4 py-2 border border-gray-200 rounded-lg peer-checked:bg-slate-500 peer-checked:text-white transition text-sm font-medium">At Home</span>
</label>
</div>
</div>
<div class="text-sm font-medium text-gray-500 px-1">Showing <span id="dir-count">0</span> activities</div>
<!-- Directory Cards -->
<div id="directory-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pb-10">
<!-- Cards injected here -->
</div>
</section>
</main>
<script>
const activitiesData = [
{ name: "Young V&A", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "E2 9PA", category: "Museum", type: "museum", lat: 51.5296, lng: -0.0551, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Free, buggy-friendly, plenty to look at without rushing." },
{ name: "Science Museum – Pattern Pod", minAge: 0, maxAge: 8, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SW7 2DD", category: "Museum", type: "museum", lat: 51.4975, lng: -0.1749, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Contained early-years space; easy to do in short bursts." },
{ name: "London Transport Museum", minAge: 0, maxAge: 17, costChild: 0, costAdult: 24.5, indoor: true, outdoor: false, cafe: true, postcode: "WC2E 7BB", category: "Museum", type: "museum", lat: 51.5121, lng: -0.1202, exerciseAdult: "Low", exerciseChild: "Low", notes: "Under-18s free, lots of seating, easy 'one exhibit at a time' pacing." },
{ name: "Mudchute Park & Farm", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "E14 3HP", category: "City farm", type: "park", lat: 51.4908, lng: -0.0146, exerciseAdult: "Medium", exerciseChild: "High", notes: "Animals + café; easy to leave early if tired." },
{ name: "Battersea Park Children’s Zoo", minAge: 0, maxAge: 15, costChild: 12.95, costAdult: 15.95, indoor: false, outdoor: true, cafe: true, postcode: "SW11 4NJ", category: "Zoo", type: "park", lat: 51.4820, lng: -0.1580, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Small, manageable zoo; good benches and a clear route." },
{ name: "WWT London Wetland Centre", minAge: 0, maxAge: 17, costChild: 11.65, costAdult: 17.95, indoor: true, outdoor: true, cafe: true, postcode: "SW13 9WT", category: "Nature", type: "park", lat: 51.4825, lng: -0.2394, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Flat, buggy-friendly; indoor hides/café for breaks." },
{ name: "Horniman Museum & Gardens", minAge: 0, maxAge: 12, costChild: "Varies", costAdult: 0, indoor: true, outdoor: true, cafe: true, postcode: "SE23 3PQ", category: "Museum/Gardens", type: "museum", lat: 51.4411, lng: -0.0611, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Mix-and-match indoor/outdoor depending on weather." },
{ name: "Uber Boat by Thames Clippers", minAge: 0, maxAge: 15, costChild: 0, costAdult: "Varies", indoor: true, outdoor: false, cafe: false, postcode: "SW1A 2JH", category: "Transport", type: "play", lat: 51.5015, lng: -0.1228, exerciseAdult: "Low", exerciseChild: "Low", notes: "All seated, novelty factor high; choose off-peak." },
{ name: "National Maritime Museum – AHOY!", minAge: 0, maxAge: 7, costChild: "Varies", costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SE10 9NF", category: "Museum", type: "museum", lat: 51.4811, lng: -0.0055, exerciseAdult: "Low", exerciseChild: "High", notes: "Purpose-built for little ones; good facilities." },
{ name: "RAF Museum London", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: true, cafe: true, postcode: "NW9 5LL", category: "Museum", type: "museum", lat: 51.5954, lng: -0.2398, exerciseAdult: "Medium", exerciseChild: "High", notes: "Huge indoor space, lifts, seats; outdoor playground for energy burn." },
{ name: "Walthamstow Wetlands", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N17 9NH", category: "Nature", type: "park", lat: 51.5855, lng: -0.0468, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Wide, flat paths and café; easy loop options." },
{ name: "Woodberry Wetlands", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N16 5HQ", category: "Nature", type: "park", lat: 51.5714, lng: -0.0898, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Short, easy circuit, good for a quick nature dose." },
{ name: "Queen Elizabeth Olympic Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "E20 2ST", category: "Park", type: "park", lat: 51.5431, lng: -0.0135, exerciseAdult: "Medium", exerciseChild: "High", notes: "Big playground choice; pick one and keep it simple." },
{ name: "Trent Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "EN4 0PS", category: "Park", type: "park", lat: 51.6521, lng: -0.1384, exerciseAdult: "Medium", exerciseChild: "High", notes: "Wide paths and café, easy to tailor distance." },
{ name: "Forty Hall Estate", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: true, cafe: true, postcode: "EN2 9HA", category: "Estate", type: "park", lat: 51.6668, lng: -0.0682, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Gentle gardens and open space; good for slow pacing." },
{ name: "Capel Manor Gardens", minAge: 0, maxAge: 12, costChild: "Varies", costAdult: "Varies", indoor: false, outdoor: true, cafe: true, postcode: "EN1 4RQ", category: "Gardens", type: "park", lat: 51.6676, lng: -0.0441, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Plenty of benches and calm corners." },
{ name: "Hainault Forest Country Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "IG7 4QN", category: "Forest", type: "park", lat: 51.6160, lng: 0.1190, exerciseAdult: "Medium", exerciseChild: "High", notes: "Choose a short loop; woodland is great for slower wandering." },
{ name: "Fairlop Waters Country Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "IG6 3HN", category: "Park", type: "park", lat: 51.6015, lng: 0.1006, exerciseAdult: "Medium", exerciseChild: "High", notes: "Wide paths and facilities; easy for scooters/balance bikes." },
{ name: "Epping Forest – High Beach", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "IG10 4AF", category: "Forest", type: "park", lat: 51.6641, lng: 0.0254, exerciseAdult: "Medium", exerciseChild: "High", notes: "Pick a short, flat-ish route; bring snacks." },
{ name: "Alexandra Palace Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N22 7AY", category: "Park", type: "park", lat: 51.5942, lng: -0.1308, exerciseAdult: "Medium", exerciseChild: "High", notes: "Lots of benches and views. Easy parking on quiet days." },
{ name: "Hackney City Farm", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: true, cafe: true, postcode: "E2 8QA", category: "Farm", type: "park", lat: 51.5306, lng: -0.0673, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Short, contained visit; good for quick animal fix." },
{ name: "William Morris Gallery", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "E17 4PP", category: "Gallery", type: "museum", lat: 51.5902, lng: -0.0202, exerciseAdult: "Low", exerciseChild: "Low", notes: "Calm indoor option with café; good if everyone’s tired." },
{ name: "Leytonstone Soft Play", minAge: 0, maxAge: 3, costChild: "Varies", costAdult: "Varies", indoor: true, outdoor: false, cafe: true, postcode: "E11 4LA", category: "Soft play", type: "play", lat: 51.5583, lng: 0.0051, exerciseAdult: "Low", exerciseChild: "High", notes: "Specifically suitable for under-3s. You can sit." },
{ name: "Play Central (CRATE)", minAge: 0, maxAge: 8, costChild: "Varies", costAdult: "Varies", indoor: true, outdoor: false, cafe: true, postcode: "E17 7JR", category: "Soft play", type: "play", lat: 51.5831, lng: -0.0200, exerciseAdult: "Low", exerciseChild: "High", notes: "Good seating/food nearby; easy to combine with lunch." },
{ name: "Grovelands Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N14 6RA", category: "Park", type: "park", lat: 51.6318, lng: -0.1136, exerciseAdult: "Medium", exerciseChild: "High", notes: "Lake loop is gentle; café and toilets make it easy with grandparents." },
{ name: "Arnos Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: false, postcode: "N11 1AP", category: "Park", type: "park", lat: 51.6253, lng: -0.1360, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Flat-ish stroll; good for a short playground stop." },
{ name: "Oakwood Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: false, postcode: "N14 6QB", category: "Park", type: "park", lat: 51.6416, lng: -0.1246, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Simple park outing, minimal planning." },
{ name: "Myddelton House Gardens", minAge: 0, maxAge: 12, costChild: "Varies", costAdult: "Varies", indoor: false, outdoor: true, cafe: true, postcode: "EN2 9HG", category: "Gardens", type: "park", lat: 51.6708, lng: -0.0614, exerciseAdult: "Low", exerciseChild: "Low", notes: "Calmer ‘stroll and look’ option, good for grandparents." },
{ name: "Rainbow Soft Play & Cafe", minAge: 0.4, maxAge: 11, costChild: 8.5, costAdult: 2.5, indoor: true, outdoor: false, cafe: true, postcode: "N14 4PE", category: "Soft play", type: "play", lat: 51.6322, lng: -0.1288, exerciseAdult: "Low", exerciseChild: "High", notes: "Excellent bad-weather option; lots of seating for adults." },
{ name: "Chickenshed Theatre", minAge: 0, maxAge: 6, costChild: "Varies", costAdult: "Varies", indoor: true, outdoor: false, cafe: true, postcode: "N14 4PE", category: "Theatre", type: "theatre", lat: 51.6322, lng: -0.1288, exerciseAdult: "Low", exerciseChild: "Low", notes: "Seated, structured, inclusive show, good pacing for grandparents." },
{ name: "AirHop Trampoline Park", minAge: 4, maxAge: 15, costChild: "Varies", costAdult: "Varies", indoor: true, outdoor: false, cafe: true, postcode: "EN1 1FS", category: "Trampoline", type: "play", lat: 51.6560, lng: -0.0514, exerciseAdult: "Low", exerciseChild: "High", notes: "Great energy-burn; grandparents can supervise from seating/café area." },
{ name: "Willows Activity Farm", minAge: 0, maxAge: 12, costChild: 24, costAdult: 24, indoor: true, outdoor: true, cafe: true, postcode: "AL4 0PF", category: "Farm", type: "play", lat: 51.7241, lng: -0.2644, exerciseAdult: "Medium", exerciseChild: "High", notes: "Expansive but offers plenty of seating; tractor rides are excellent." },
{ name: "St James's Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "SW1A 2BJ", category: "Park", type: "park", lat: 51.5025, lng: -0.1345, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Flat, paved walkways with the reliable spectacle of pelicans." },
{ name: "The Regent's Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "NW1 4NR", category: "Park", type: "park", lat: 51.5313, lng: -0.1569, exerciseAdult: "Medium", exerciseChild: "High", notes: "Superb broad avenues; easy to navigate with buggies." },
{ name: "Hyde Park Boating", minAge: 3, maxAge: 12, costChild: 6, costAdult: 12, indoor: false, outdoor: true, cafe: true, postcode: "W2 2UH", category: "Activity", type: "play", lat: 51.5054, lng: -0.1654, exerciseAdult: "High", exerciseChild: "Medium", notes: "Pedalo operation requires knee cartilage; opt for the ferry if tired." },
{ name: "Kew Gardens", minAge: 2, maxAge: 12, costChild: 6, costAdult: 22, indoor: true, outdoor: true, cafe: true, postcode: "TW9 3AE", category: "Gardens", type: "park", lat: 51.4789, lng: -0.2956, exerciseAdult: "High", exerciseChild: "High", notes: "Aesthetic triumph; plenty of perimeter seating near play areas." },
{ name: "London Zoo", minAge: 0, maxAge: 12, costChild: 20, costAdult: 30, indoor: true, outdoor: true, cafe: true, postcode: "NW1 4RY", category: "Zoo", type: "park", lat: 51.5353, lng: -0.1534, exerciseAdult: "High", exerciseChild: "High", notes: "Substantial walking required; tactical café stops are advised." },
{ name: "Spitalfields City Farm", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: false, postcode: "E1 5AR", category: "Farm", type: "park", lat: 51.5218, lng: -0.0638, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Compact footprint; one cannot get lost." },
{ name: "Novelty Automation", minAge: 5, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: false, postcode: "WC1R 4PZ", category: "Arcade", type: "museum", lat: 51.5204, lng: -0.1158, exerciseAdult: "Low", exerciseChild: "Low", notes: "Wry mechanical satire; entertaining for adults while children pull levers." },
{ name: "God's Own Junkyard", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "E17 9HQ", category: "Gallery", type: "museum", lat: 51.5830, lng: -0.0058, exerciseAdult: "Low", exerciseChild: "Low", notes: "Visually overwhelming but physically static; excellent for a sit-down." },
{ name: "Natural History Museum", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SW7 5BD", category: "Museum", type: "museum", lat: 51.4967, lng: -0.1764, exerciseAdult: "High", exerciseChild: "High", notes: "Scale is punishing; target a single gallery and retreat." },
{ name: "Wonderlab (Science Museum)", minAge: 4, maxAge: 14, costChild: 9, costAdult: 11, indoor: true, outdoor: false, cafe: true, postcode: "SW7 2DD", category: "Museum", type: "museum", lat: 51.4975, lng: -0.1749, exerciseAdult: "Low", exerciseChild: "High", notes: "Highly interactive; grandparents can anchor at a bench while children orbit." },
{ name: "Museum of London Docklands", minAge: 0, maxAge: 8, costChild: 3, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "E14 4AL", category: "Museum", type: "museum", lat: 51.5074, lng: -0.0232, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Hermetically sealed play zone; excellent visibility from seated positions." },
{ name: "National Army Museum (Play Base)", minAge: 1, maxAge: 8, costChild: 6.25, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SW3 4HT", category: "Play", type: "play", lat: 51.4852, lng: -0.1594, exerciseAdult: "Low", exerciseChild: "High", notes: "Pristine soft play with an adjacent café; mercifully small capacity." },
{ name: "Postal Museum (Mail Rail)", minAge: 0, maxAge: 8, costChild: 6, costAdult: 17, indoor: true, outdoor: false, cafe: true, postcode: "WC1X 0DA", category: "Museum", type: "museum", lat: 51.5244, lng: -0.1147, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Mail Rail requires merely sitting; the Sorted! play area is contained." },
{ name: "Camley Street Natural Park", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N1C 4PW", category: "Nature", type: "park", lat: 51.5356, lng: -0.1290, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Tranquil, flat wooden walkways beside the canal; an unexpected refuge." },
{ name: "Puppet Theatre Barge", minAge: 3, maxAge: 12, costChild: 11, costAdult: 14, indoor: true, outdoor: false, cafe: false, postcode: "W9 2PF", category: "Theatre", type: "theatre", lat: 51.5215, lng: -0.1830, exerciseAdult: "Low", exerciseChild: "Low", notes: "Charming, seated, and sedate; ideal for a rainy afternoon." },
{ name: "Tate Modern", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SE1 9TG", category: "Gallery", type: "museum", lat: 51.5076, lng: -0.0994, exerciseAdult: "Medium", exerciseChild: "High", notes: "Vast ramps for burning energy; frequent free drawing stations." },
{ name: "London Aquatics Centre", minAge: 0, maxAge: 15, costChild: 3, costAdult: 6, indoor: true, outdoor: false, cafe: true, postcode: "E20 2ZQ", category: "Swimming", type: "play", lat: 51.5398, lng: -0.0118, exerciseAdult: "Low", exerciseChild: "High", notes: "Spectator seating available if the adult refuses to swim." },
{ name: "Little Angel Theatre", minAge: 2, maxAge: 10, costChild: 12, costAdult: 14, indoor: true, outdoor: false, cafe: false, postcode: "N1 2DN", category: "Theatre", type: "theatre", lat: 51.5385, lng: -0.0998, exerciseAdult: "Low", exerciseChild: "Low", notes: "Intimate marionette theatre; demands very little physical exertion." },
{ name: "Granary Square Fountains", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "N1C 4BH", category: "Play", type: "play", lat: 51.5354, lng: -0.1257, exerciseAdult: "Low", exerciseChild: "High", notes: "Café seating directly overlooks the fountains; outsourced entertainment." },
{ name: "Chislehurst Caves", minAge: 0, maxAge: 15, costChild: 6, costAdult: 8, indoor: true, outdoor: false, cafe: true, postcode: "BR7 5NL", category: "Attraction", type: "play", lat: 51.4087, lng: 0.0558, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Dark and uneven underfoot; excellent for mild peril but hard on the knees." },
{ name: "Creekside Discovery Centre", minAge: 8, maxAge: 15, costChild: 15, costAdult: 15, indoor: false, outdoor: true, cafe: false, postcode: "SE8 4SA", category: "Outdoor", type: "park", lat: 51.4782, lng: -0.0208, exerciseAdult: "High", exerciseChild: "High", notes: "Wading through tidal mud is bracing; perhaps an activity better delegated." },
{ name: "The Milk Float (Canoes)", minAge: 8, maxAge: 15, costChild: 15, costAdult: 25, indoor: false, outdoor: true, cafe: true, postcode: "E9 5EN", category: "Outdoor", type: "play", lat: 51.5428, lng: -0.0227, exerciseAdult: "High", exerciseChild: "High", notes: "Canoeing demands sustained strength; the floating bar is a safer harbour." },
{ name: "East Dulwich Tavern", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SE22 8EW", category: "Pub", type: "home", lat: 51.4608, lng: -0.0766, exerciseAdult: "Low", exerciseChild: "Low", notes: "A pub that happily tolerates children with board games; an essential refuge." },
{ name: "Crystal Palace Dinosaurs", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "SE19 2GA", category: "Park", type: "park", lat: 51.4215, lng: -0.0674, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Victorian inaccuracies rendered in concrete; ample flat paths." },
{ name: "LEGOLAND Windsor", minAge: 2, maxAge: 12, costChild: 35, costAdult: 35, indoor: false, outdoor: true, cafe: true, postcode: "SL4 4AY", category: "Theme Park", type: "play", lat: 51.4635, lng: -0.6511, exerciseAdult: "High", exerciseChild: "High", notes: "A sprawling vortex of plastic; requires immense stamina and deep pockets." },
{ name: "HMS Belfast", minAge: 5, maxAge: 15, costChild: 12, costAdult: 24, indoor: true, outdoor: true, cafe: true, postcode: "SE1 2JH", category: "Museum", type: "museum", lat: 51.5065, lng: -0.0813, exerciseAdult: "High", exerciseChild: "Medium", notes: "Steep ladders, cramped decks; not recommended for those with arthritic joints." },
{ name: "The Lightroom", minAge: 0, maxAge: 15, costChild: 15, costAdult: 25, indoor: true, outdoor: false, cafe: true, postcode: "N1C 4DY", category: "Gallery", type: "museum", lat: 51.5375, lng: -0.1251, exerciseAdult: "Low", exerciseChild: "Low", notes: "Immersive projections where one simply sits in the dark." },
{ name: "Brockwell Lido", minAge: 0, maxAge: 15, costChild: 5, costAdult: 8, indoor: false, outdoor: true, cafe: true, postcode: "SE24 0PA", category: "Swimming", type: "play", lat: 51.4518, lng: -0.1065, exerciseAdult: "Low", exerciseChild: "High", notes: "Unheated water builds character; observing from the café builds contentment." },
{ name: "London Fields Lido", minAge: 0, maxAge: 15, costChild: 3, costAdult: 6, indoor: false, outdoor: true, cafe: true, postcode: "E8 3EU", category: "Swimming", type: "play", lat: 51.5404, lng: -0.0601, exerciseAdult: "Low", exerciseChild: "High", notes: "A heated outdoor pool; vastly superior to freezing in Brockwell." },
{ name: "Alexandra Palace Boating", minAge: 3, maxAge: 15, costChild: 6, costAdult: 9, indoor: false, outdoor: true, cafe: true, postcode: "N22 7AY", category: "Outdoor", type: "play", lat: 51.5956, lng: -0.1265, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Pedalo excursions offer a captive audience; requires functioning knee cartilage." },
{ name: "Brockwell Miniature Railway", minAge: 0, maxAge: 12, costChild: 1, costAdult: 1, indoor: false, outdoor: true, cafe: false, postcode: "SE24 0NG", category: "Attraction", type: "play", lat: 51.4501, lng: -0.1068, exerciseAdult: "Low", exerciseChild: "Low", notes: "A gloriously brief and inexpensive locomotive experience." },
{ name: "Imperial War Museum", minAge: 5, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SE1 6HZ", category: "Museum", type: "museum", lat: 51.4958, lng: -0.1086, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Spacious and sombre; the vast atrium offers ample seating." },
{ name: "British Museum", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "WC1B 3DG", category: "Museum", type: "museum", lat: 51.5194, lng: -0.1269, exerciseAdult: "High", exerciseChild: "Medium", notes: "Crowd management is a tactical exercise; aim directly for the Amaravati sculptures." },
{ name: "V&A Museum", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "SW7 2RL", category: "Museum", type: "museum", lat: 51.4966, lng: -0.1722, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Vast, echoing halls with generous benches; perfect for a quiet sit." },
{ name: "Guildhall Art Gallery", minAge: 5, maxAge: 15, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: false, postcode: "EC2V 5AE", category: "Museum", type: "museum", lat: 51.5153, lng: -0.0919, exerciseAdult: "Low", exerciseChild: "Low", notes: "Subterranean Roman ruins; cool, quiet, and mercifully uncrowded." },
{ name: "SEA LIFE London Aquarium", minAge: 0, maxAge: 15, costChild: 25, costAdult: 35, indoor: true, outdoor: false, cafe: false, postcode: "SE1 7PB", category: "Aquarium", type: "play", lat: 51.5010, lng: -0.1195, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Hypnotic but heavily trafficked; shuffle through at a glacial pace." },
{ name: "Unicorn Theatre", minAge: 2, maxAge: 12, costChild: 10, costAdult: 18, indoor: true, outdoor: false, cafe: true, postcode: "SE1 2HZ", category: "Theatre", type: "theatre", lat: 51.5050, lng: -0.0811, exerciseAdult: "Low", exerciseChild: "Low", notes: "Purpose-built children's theatre; guaranteed a comfortable seat." },
{ name: "Polka Theatre", minAge: 0, maxAge: 12, costChild: 10, costAdult: 15, indoor: true, outdoor: false, cafe: true, postcode: "SW19 1SB", category: "Theatre", type: "theatre", lat: 51.4196, lng: -0.1982, exerciseAdult: "Low", exerciseChild: "Low", notes: "Excellent facilities and a contained indoor play area." },
{ name: "Lee Valley White Water Centre", minAge: 8, maxAge: 15, costChild: 30, costAdult: 50, indoor: false, outdoor: true, cafe: true, postcode: "EN9 1AB", category: "Outdoor", type: "play", lat: 51.6881, lng: -0.0016, exerciseAdult: "Low", exerciseChild: "High", notes: "Extreme aquatic peril; ideally, the grandparents remain on the terrace." },
{ name: "Waterfront Leisure Centre", minAge: 0, maxAge: 15, costChild: 3, costAdult: 5, indoor: true, outdoor: false, cafe: true, postcode: "SE18 6DL", category: "Swimming", type: "play", lat: 51.4936, lng: 0.0632, exerciseAdult: "Low", exerciseChild: "High", notes: "Standard municipal swimming infrastructure; purely functional." },
{ name: "Stepney City Farm", minAge: 0, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "E1 3DG", category: "Farm", type: "park", lat: 51.5186, lng: -0.0469, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Compact and manageable footprint; one cannot easily lose a child here." },
{ name: "Token Studio", minAge: 4, maxAge: 15, costChild: 15, costAdult: 15, indoor: true, outdoor: false, cafe: false, postcode: "SE1 4YG", category: "Workshop", type: "play", lat: 51.5009, lng: -0.0709, exerciseAdult: "Low", exerciseChild: "Low", notes: "Captive, seated focus; a rare opportunity to enforce stillness." },
{ name: "Richmond Park", minAge: 0, maxAge: 15, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "TW10 5HS", category: "Park", type: "park", lat: 51.4442, lng: -0.2797, exerciseAdult: "High", exerciseChild: "High", notes: "Spotting deer is an excellent, unarguable excuse to stand completely still." },
{ name: "West End Theatre", minAge: 5, maxAge: 15, costChild: 30, costAdult: 50, indoor: true, outdoor: false, cafe: false, postcode: "WC2", category: "Theatre", type: "theatre", lat: 51.5115, lng: -0.1311, exerciseAdult: "Low", exerciseChild: "Low", notes: "The darkness of the stalls kindly hides any accidental dozing." },
{ name: "Putt in the Park (Battersea)", minAge: 4, maxAge: 15, costChild: 9, costAdult: 11, indoor: false, outdoor: true, cafe: true, postcode: "SW11 4NJ", category: "Mini Golf", type: "play", lat: 51.4815, lng: -0.1600, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Requires gentle stooping; the adjacent pizzeria is the true draw." },
// At Home activities
{ name: "Magnet tiles", minAge: 2, maxAge: 10, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "Home", category: "At-home", type: "home", lat: null, lng: null, exerciseAdult: "Low", exerciseChild: "Low", notes: "Good for shared play with adults." },
{ name: "Role play (shop/doctor)", minAge: 2, maxAge: 8, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "Home", category: "At-home", type: "home", lat: null, lng: null, exerciseAdult: "Low", exerciseChild: "Medium", notes: "Adults can ‘be the customer’ and rest." },
{ name: "Board games", minAge: 3, maxAge: 10, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "Home", category: "At-home", type: "home", lat: null, lng: null, exerciseAdult: "Low", exerciseChild: "Low", notes: "Turn-taking and counting; grandparents usually enjoy these." },
{ name: "Gardening (together)", minAge: 2, maxAge: 12, costChild: 0, costAdult: 0, indoor: false, outdoor: true, cafe: true, postcode: "Home", category: "At-home", type: "home", lat: null, lng: null, exerciseAdult: "Medium", exerciseChild: "Medium", notes: "Watering, digging, planting; natural rhythm, low pressure." },
{ name: "Dance party", minAge: 1, maxAge: 8, costChild: 0, costAdult: 0, indoor: true, outdoor: false, cafe: true, postcode: "Home", category: "At-home", type: "home", lat: null, lng: null, exerciseAdult: "Medium", exerciseChild: "High", notes: "Big energy burn in 10 minutes." }
];
// UI State
let currentTab = 'wizard';
let mapInstance = null;
let markersGroup = null;
// Utility: Parse cost
function parseCost(val) {
if (val === 0 || val === '0') return 'Free';
if (val === 'Varies') return 'Varies';
return '£' + parseFloat(val).toFixed(2);
}
// Adjust Age (Wizard)
function adjustAge(delta) {
const input = document.getElementById('wiz-age');
let val = parseInt(input.value) + delta;
if (val < 0) val = 0;
if (val > 17) val = 17;
input.value = val;
}
// Utility: Get checked values from a NodeList
function getCheckedValues(selector) {
return Array.from(document.querySelectorAll(selector))
.filter(cb => cb.checked)
.map(cb => cb.value);
}
// Tab Switching
function switchTab(tabId) {
currentTab = tabId;
['wizard', 'map', 'directory'].forEach(t => {
const el = document.getElementById(`view-${t}`);
const btn = document.getElementById(`tab-${t}`);
if(t === tabId) {
el.classList.remove('hidden');
if(t==='map' || t==='directory') el.classList.add('flex');
btn.className = 'tab-active px-2 py-2 tap-target transition-colors';
} else {
el.classList.add('hidden');
if(t==='map' || t==='directory') el.classList.remove('flex');
btn.className = 'tab-inactive px-2 py-2 tap-target transition-colors hover:text-gray-900';
}
});
if(tabId === 'map') {
if(!mapInstance) initMap();
else mapInstance.invalidateSize();
renderMapMarkers();
}
if(tabId === 'directory') renderDirectory();
}
// Card Renderer (Shared between Wizard and Directory)
function createCardHtml(item) {
let iconStr = item.type === 'home' ? '🏠' : (item.indoor && !item.outdoor) ? '🏛️' : '🌳';
return `
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow flex flex-col justify-between h-full">
<div>
<div class="flex justify-between items-start mb-2 gap-2">
<h4 class="font-serif font-bold text-gray-900 leading-tight">${item.name}</h4>
<span class="text-lg shrink-0" title="Environment">${iconStr}</span>
</div>
<p class="text-[13px] text-gray-700 italic mb-4">"${item.notes}"</p>
</div>
<div class="grid grid-cols-2 gap-y-2 gap-x-1 text-xs text-gray-600 bg-gray-50 p-2 rounded">
<p><span class="font-semibold text-gray-800">Ages:</span> ${item.minAge}-${item.maxAge}</p>
<p><span class="font-semibold text-gray-800">Cost:</span> ${parseCost(item.costChild)}</p>
<p><span class="font-semibold text-blue-800">Adult:</span> ${item.exerciseAdult}</p>
<p><span class="font-semibold text-rose-800">Child:</span> ${item.exerciseChild}</p>
</div>
</div>
`;
}
// Wizard Logic
function handleWizard(e) {
e.preventDefault();
const age = parseFloat(document.getElementById('wiz-age').value);
const envs = getCheckedValues('.wiz-env-cb');
const adultEx = getCheckedValues('.wiz-adult-ex-cb');
const childEx = getCheckedValues('.wiz-child-ex-cb');
const needCafe = document.getElementById('wiz-cafe').checked;
const needFree = document.getElementById('wiz-free').checked;
const includeHome = document.getElementById('wiz-athome').checked;
const filtered = activitiesData.filter(item => {
if (age < item.minAge || age > item.maxAge) return false;
if (!includeHome && item.postcode === 'Home') return false;
// Environment
const matchesEnv = (envs.includes('indoor') && item.indoor) || (envs.includes('outdoor') && item.outdoor);
if (envs.length > 0 && !matchesEnv && item.postcode !== 'Home') return false;
// Exertion Check
if (adultEx.length > 0 && !adultEx.includes(item.exerciseAdult)) return false;
if (childEx.length > 0 && !childEx.includes(item.exerciseChild)) return false;
// Absolute logistical requirements
if (needCafe && !item.cafe) return false;
if (needFree && item.costChild !== 0 && item.costChild !== '0') return false;
return true;
});
renderWizardResults(filtered);
}
function renderWizardResults(results) {
const container = document.getElementById('wizard-cards');
const resultCount = document.getElementById('result-count');
const resultsSection = document.getElementById('wizard-results');
container.innerHTML = '';
resultCount.innerText = results.length;
resultsSection.classList.remove('hidden');
if (results.length === 0) {
container.innerHTML = `<p class="text-gray-500 italic py-4">It appears your criteria are too stringent for this mortal coil. I suggest relaxing the parameters, or perhaps resigning yourself to television.</p>`;
return;
}
// Shuffle and pick top 10
const shuffled = results.sort(() => 0.5 - Math.random()).slice(0, 10);
shuffled.forEach(item => {
const div = document.createElement('div');
div.innerHTML = createCardHtml(item);
container.appendChild(div.firstElementChild);
});
}
// Map Logic
function initMap() {
mapInstance = L.map('map').setView([51.505, -0.125], 11);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap & CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(mapInstance);
markersGroup = L.layerGroup().addTo(mapInstance);
// Bind filter events for Map View
document.querySelectorAll('#map-filter-container input').forEach(chk => {
chk.addEventListener('change', () => renderMapMarkers());
});
}
function getCategoryColor(type) {
const map = { 'museum': '#3b82f6', 'park': '#10b981', 'play': '#f43f5e', 'theatre': '#a855f7' };
return map[type] || '#64748b';
}
function createCustomIcon(color) {
const svg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/></svg>`;
return L.divIcon({ className: 'custom-marker', html: svg, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32] });
}
function renderMapMarkers() {
if(!markersGroup) return;
markersGroup.clearLayers();
const envs = getCheckedValues('.map-filter-env');
const adultEx = getCheckedValues('.map-filter-adult');
const cafeOnly = document.getElementById('map-filter-cafe').checked;
const freeOnly = document.getElementById('map-filter-free').checked;
activitiesData.forEach(item => {
if(!item.lat || !item.lng || item.postcode === 'Home') return;
// Env Filter
const matchesEnv = (envs.includes('indoor') && item.indoor) || (envs.includes('outdoor') && item.outdoor);
if (envs.length > 0 && !matchesEnv) return;
// Adult Effort Filter
if (adultEx.length > 0 && !adultEx.includes(item.exerciseAdult)) return;
// Logistics
if(cafeOnly && !item.cafe) return;
if(freeOnly && item.costChild !== 0 && item.costChild !== '0') return;
const lat = item.lat + (Math.random() - 0.5) * 0.0001;
const lng = item.lng + (Math.random() - 0.5) * 0.0001;
const marker = L.marker([lat, lng], { icon: createCustomIcon(getCategoryColor(item.type)) });
const popupHtml = `
<div class="font-sans text-sm min-w-[200px]">
<h4 class="font-bold text-slate-900 border-b pb-1 mb-2">${item.name}</h4>
<div class="grid grid-cols-2 gap-1 text-[11px] mb-2">
<span class="font-semibold text-blue-800">Adult: ${item.exerciseAdult}</span>
<span class="font-semibold text-rose-800">Child: ${item.exerciseChild}</span>
</div>
<p class="italic text-gray-700 leading-tight">"${item.notes}"</p>
</div>
`;
marker.bindPopup(popupHtml);
markersGroup.addLayer(marker);
});
}
// Directory Logic
function renderDirectory() {
const container = document.getElementById('directory-grid');
const search = document.getElementById('dir-search').value.toLowerCase();
const catNode = document.querySelector('input[name="dir-category"]:checked');
const cat = catNode ? catNode.value : 'all';
container.innerHTML = '';
let count = 0;
activitiesData.forEach(item => {
if (cat !== 'all' && item.type !== cat) return;
const searchStr = `${item.name} ${item.notes} ${item.postcode} ${item.category}`.toLowerCase();
if (search && !searchStr.includes(search)) return;
count++;
const div = document.createElement('div');
div.innerHTML = createCardHtml(item);
container.appendChild(div.firstElementChild);
});
document.getElementById('dir-count').innerText = count;
}
// Register Service Worker
document.addEventListener('DOMContentLoaded', () => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js')
.catch(err => console.log('Service Worker failed:', err));
});
}
renderDirectory();
});
</script>
</body>
</html>