-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1257 lines (1186 loc) · 58.2 KB
/
Copy pathindex.html
File metadata and controls
1257 lines (1186 loc) · 58.2 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
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>世界の窓 — A Window to the World</title>
<style>
:root{
--bg:#e9e2d3; /* クリーム */
--ink:#2c2a24;
--sub:#6f685a;
--line:#cfc6b2;
--accent:#8a6d3b; /* 金茶 */
--accent2:#3b5e5a;
--panel:rgba(233,226,211,.86);
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;overflow:hidden;background:var(--bg);
font-family:-apple-system,"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif;
color:var(--ink);-webkit-font-smoothing:antialiased;}
#pano{position:absolute;inset:0;background:#111;}
/* うっすら周辺減光で「眺める」感を出す */
#vignette{position:absolute;inset:0;pointer-events:none;
box-shadow:inset 0 0 220px 40px rgba(20,16,8,.45);z-index:2;}
/* --- 上部:旅のしおり --- */
#shiori{position:absolute;top:0;left:0;right:0;z-index:5;
padding:18px 22px 26px;pointer-events:none;
background:linear-gradient(180deg,rgba(20,16,8,.55),rgba(20,16,8,0));
color:#f4eeddff;}
#trip-title{font-size:20px;font-weight:700;letter-spacing:.04em;
text-shadow:0 1px 6px rgba(0,0,0,.5);opacity:0;transition:opacity .8s;}
#trip-desc{font-size:13px;margin-top:5px;line-height:1.6;color:#e6dcc4;
max-width:640px;text-shadow:0 1px 4px rgba(0,0,0,.5);opacity:0;transition:opacity .8s;}
#trip-place{font-size:11px;margin-top:7px;color:#c9bd9e;letter-spacing:.08em;
text-shadow:0 1px 4px rgba(0,0,0,.5);opacity:0;transition:opacity .8s;}
.show{opacity:1 !important;}
/* --- ミニ地図(右下コーナー・1つの箱にまとめる) --- */
#mapwrap{position:absolute;right:14px;bottom:14px;z-index:9;
width:180px;height:180px;}
#minimap{position:absolute;inset:0;border-radius:12px;overflow:hidden;
border:2px solid rgba(255,252,245,.85);
box-shadow:0 6px 20px rgba(20,16,8,.4);background:#ddd;}
#minizoom{position:absolute;top:8px;right:8px;z-index:10;
display:flex;flex-direction:column;gap:4px;}
#minizoom button{width:28px;height:28px;border:none;border-radius:7px;
background:rgba(255,252,245,.9);color:#2c2a24;font-size:16px;font-weight:700;
cursor:pointer;box-shadow:0 2px 6px rgba(20,16,8,.3);line-height:1;}
#minizoom button:active{background:#fff;}
#minilabel{position:absolute;left:8px;right:8px;bottom:8px;z-index:10;
font-size:10px;color:#2c2a24;background:rgba(255,252,245,.82);
padding:2px 7px;border-radius:8px;pointer-events:none;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
@media (max-width:520px){
#mapwrap{width:130px;height:130px;}
}
/* --- 歩数カウンター(右上・課金の目安) --- */
#counter{position:absolute;top:16px;right:16px;z-index:6;
font-size:11px;color:#f4eedd;background:rgba(20,16,8,.4);
padding:5px 11px;border-radius:14px;letter-spacing:.03em;
text-shadow:0 1px 3px rgba(0,0,0,.4);backdrop-filter:blur(4px);
text-align:right;line-height:1.5;}
#counter small{opacity:.7;font-size:9.5px;}
/* --- ラジオ局名(右上・カウンターの下) --- */
#station{position:absolute;top:60px;right:16px;z-index:6;
font-size:10px;color:#e6dcc4;background:rgba(20,16,8,.35);
padding:3px 9px;border-radius:12px;text-shadow:0 1px 3px rgba(0,0,0,.4);
backdrop-filter:blur(4px);display:none;max-width:200px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#station #stName{cursor:pointer;}
#station a{color:#f4eedd;text-decoration:none;}
/* --- お知らせトースト(上限到達など) --- */
#notice{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(.96);
z-index:12;background:var(--panel);color:var(--ink);
border:1px solid var(--line);border-radius:16px;padding:22px 26px;
text-align:center;max-width:300px;box-shadow:0 12px 40px rgba(20,16,8,.35);
opacity:0;pointer-events:none;transition:.3s;backdrop-filter:blur(8px);}
#notice.show{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1);}
#notice p{font-size:14px;line-height:1.7;}
#notice button{margin-top:16px;border:none;background:var(--accent);color:#fff8ec;
padding:9px 18px;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer;}
/* --- 旅ログ パネル --- */
#logpanel{position:absolute;top:0;right:0;bottom:0;width:min(320px,82vw);z-index:14;
background:var(--panel);backdrop-filter:blur(10px);
border-left:1px solid var(--line);box-shadow:-8px 0 30px rgba(20,16,8,.3);
transform:translateX(100%);transition:transform .3s;display:flex;flex-direction:column;}
#logpanel.open{transform:translateX(0);}
#logpanel h2{font-size:15px;padding:18px 18px 12px;letter-spacing:.05em;
display:flex;justify-content:space-between;align-items:center;}
#logpanel h2 span{font-size:18px;cursor:pointer;color:var(--sub);}
#logList{flex:1;overflow-y:auto;padding:0 18px 18px;}
.logItem{padding:11px 0;border-bottom:1px solid var(--line);}
.logItem .lt{font-size:13px;font-weight:600;color:var(--ink);}
.logItem .lp{font-size:11px;color:var(--sub);margin-top:2px;}
.logItem .ld{font-size:10px;color:var(--accent);margin-top:3px;letter-spacing:.05em;}
.logItem.clickable{cursor:pointer;border-radius:8px;margin:0 -8px;padding-left:8px;padding-right:8px;transition:background .15s;}
.logItem.clickable:hover{background:rgba(138,109,59,.08);}
#logEmpty{color:var(--sub);font-size:12px;padding:20px 0;text-align:center;}
#logClear{margin:0 18px 18px;padding:8px;border:1px solid var(--line);
background:rgba(44,42,36,.05);border-radius:10px;font-size:11px;color:var(--sub);
cursor:pointer;text-align:center;}
/* --- 夜モードの色被せ(SVは昼撮影なので夕闇を演出) --- */
#nighttint{position:absolute;inset:0;z-index:3;pointer-events:none;opacity:0;
transition:opacity 1.4s;
background:radial-gradient(ellipse at 50% 38%,rgba(20,26,60,.18),rgba(6,8,26,.72));}
body.night #nighttint{opacity:1;}
body.night #pano{filter:brightness(.5) contrast(1.06) saturate(.72);}
#pano{transition:filter 1.4s;}
/* --- 設定・フルスクリーンボタン(左上) --- */
#btnSettings,#btnFull{position:absolute;top:14px;z-index:8;
width:38px;height:38px;border:none;border-radius:50%;cursor:pointer;
background:rgba(20,16,8,.4);color:#f4eedd;font-size:18px;
backdrop-filter:blur(4px);box-shadow:0 2px 8px rgba(20,16,8,.3);}
#btnSettings{left:14px;}
#btnFull{left:60px;font-size:17px;}
/* --- 設定パネル(左からスライド) --- */
#setpanel{position:absolute;top:0;left:0;bottom:0;width:min(300px,80vw);z-index:15;
background:var(--panel);backdrop-filter:blur(10px);
border-right:1px solid var(--line);box-shadow:8px 0 30px rgba(20,16,8,.3);
transform:translateX(-100%);transition:transform .3s;padding:18px;}
#setpanel.open{transform:translateX(0);}
#setpanel h2{font-size:15px;letter-spacing:.05em;margin-bottom:16px;
display:flex;justify-content:space-between;align-items:center;}
#setpanel h2 span{font-size:18px;cursor:pointer;color:var(--sub);}
.setrow{margin-bottom:22px;}
.setrow>label{font-size:12px;color:var(--sub);display:block;margin-bottom:8px;}
.seg{display:flex;gap:6px;}
.seg button{flex:1;padding:9px 0;border:1px solid var(--line);border-radius:10px;
background:rgba(255,252,245,.5);color:var(--sub);font-size:13px;cursor:pointer;transition:.15s;}
.seg button.on{background:var(--accent);color:#fff8ec;border-color:var(--accent);}
/* 無操作で自動的にUIを隠す(没入モード)/ミニ地図は残す */
#dock,#counter,#station,#btnSettings,#btnFull{transition:opacity .9s;}
body.idle #dock, body.idle #counter, body.idle #station,
body.idle #btnSettings, body.idle #btnFull{opacity:0;pointer-events:none;}
/* --- パネルのタブ(旅ログ/お気に入り) --- */
.tabs{display:flex;gap:6px;padding:0 18px 10px;}
.tabs button{flex:1;padding:7px 0;border:1px solid var(--line);border-radius:9px;
background:rgba(255,252,245,.5);color:var(--sub);font-size:12px;cursor:pointer;}
.tabs button.on{background:var(--accent);color:#fff8ec;border-color:var(--accent);}
.logItem .lbtns{margin-top:6px;display:flex;gap:8px;}
.logItem .lbtns a{font-size:10.5px;color:var(--accent2);text-decoration:none;
border:1px solid var(--line);border-radius:8px;padding:3px 8px;cursor:pointer;}
/* --- 下部:操作 --- */
#dock{position:absolute;bottom:0;left:0;right:0;z-index:6;
display:flex;flex-direction:column;gap:10px;
padding:16px 210px calc(16px + env(safe-area-inset-bottom)) 18px;
background:linear-gradient(0deg,var(--panel),rgba(233,226,211,0));}
@media (max-width:520px){
#dock{padding-right:150px;}
}
#startbar{display:flex;flex-direction:column;gap:8px;}
#btnRandom{width:100%;font-size:16px;padding:15px 20px;letter-spacing:.08em;
background:var(--accent);color:#fff8ec;
box-shadow:0 4px 14px rgba(138,109,59,.4);}
#searchrow{display:flex;gap:8px;align-items:center;}
#searchInput{flex:1;min-width:120px;padding:10px 14px;border:1px solid var(--line);
border-radius:20px;background:rgba(255,252,245,.7);color:var(--ink);font-size:13px;}
#themes{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;
scrollbar-width:none;-webkit-overflow-scrolling:touch;}
#themes::-webkit-scrollbar{display:none;}
.theme{flex:0 0 auto;padding:7px 13px;border:1px solid var(--line);
border-radius:20px;background:rgba(255,252,245,.55);color:var(--sub);
font-size:12.5px;white-space:nowrap;cursor:pointer;transition:.2s;user-select:none;}
.theme:hover{background:#fff;}
.theme.active{background:var(--accent);color:#fff8ec;border-color:var(--accent);}
#controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
button.ctl{border:none;background:var(--ink);color:var(--bg);
padding:11px 18px;border-radius:24px;font-size:14px;font-weight:600;
cursor:pointer;letter-spacing:.03em;transition:.15s;white-space:nowrap;}
button.ghost.on{background:var(--accent);color:#fff8ec;border-color:var(--accent);}
button.ctl:active{transform:scale(.96);}
button.ghost{background:rgba(44,42,36,.08);color:var(--ink);
border:1px solid var(--line);}
#speedwrap{display:flex;align-items:center;gap:8px;flex:1;min-width:0;}
#speedwrap label{font-size:11px;color:var(--sub);white-space:nowrap;}
input[type=range]{flex:1;accent-color:var(--accent);min-width:60px;}
/* --- 起動オーバーレイ --- */
#boot{position:absolute;inset:0;z-index:20;display:flex;
flex-direction:column;align-items:center;justify-content:center;
text-align:center;padding:32px;background:var(--bg);}
#boot h1{font-size:30px;letter-spacing:.1em;font-weight:700;
display:flex;flex-direction:column;align-items:center;gap:6px;}
#boot h1 small{font-size:12px;letter-spacing:.22em;font-weight:400;
color:var(--sub);text-transform:uppercase;}
#boot p{font-size:13px;color:var(--sub);margin-top:12px;line-height:1.8;max-width:440px;}
#boot .go{margin-top:26px;}
#keywarn{margin-top:20px;font-size:11.5px;color:#a2571f;line-height:1.7;
max-width:460px;background:rgba(162,87,31,.08);border:1px solid rgba(162,87,31,.25);
border-radius:10px;padding:12px 14px;display:none;}
.spinner{width:26px;height:26px;border:3px solid var(--line);
border-top-color:var(--accent);border-radius:50%;
animation:spin 1s linear infinite;margin-top:20px;display:none;}
@keyframes spin{to{transform:rotate(360deg);}}
#hint{position:absolute;left:0;right:0;bottom:120px;z-index:4;text-align:center;
font-size:11px;color:#e6dcc4;text-shadow:0 1px 4px rgba(0,0,0,.6);
opacity:0;transition:opacity 1s;pointer-events:none;}
#keyInput{margin-top:22px;width:min(420px,86vw);padding:11px 14px;
border:1px solid var(--line);border-radius:10px;background:#fffdf8;color:var(--ink);
font-size:13px;text-align:center;font-family:ui-monospace,monospace;}
#keySaved{margin-top:16px;font-size:13px;color:var(--ink);display:none;
background:rgba(58,94,90,.1);border:1px solid var(--line);border-radius:10px;
padding:10px 14px;}
#keySaved #keyChange{color:var(--accent2);text-decoration:underline;cursor:pointer;margin-left:6px;}
#keyHelp{margin-top:10px;font-size:12px;color:var(--accent2);cursor:pointer;
text-decoration:underline;}
#keySteps{margin-top:10px;font-size:11.5px;color:var(--sub);line-height:1.8;
max-width:440px;text-align:left;background:rgba(44,42,36,.05);
border:1px solid var(--line);border-radius:10px;padding:12px 14px;display:none;}
#keySteps a{color:var(--accent2);}
.setrow button.link{background:none;border:1px solid var(--line);border-radius:9px;
padding:8px 12px;font-size:12px;color:var(--sub);cursor:pointer;width:100%;}
</style>
</head>
<body>
<div id="pano"></div>
<div id="nighttint"></div>
<div id="vignette"></div>
<button id="btnSettings">⚙</button>
<button id="btnFull" title="フルスクリーン">⛶</button>
<div id="setpanel">
<h2>設定 <span id="setClose">✕</span></h2>
<div class="setrow">
<label>時間帯</label>
<div class="seg" id="todSeg">
<button data-tod="auto">自動</button>
<button data-tod="day">昼</button>
<button data-tod="night">夜</button>
</div>
</div>
<div class="setrow">
<label>没入モード(しばらく操作しないとUIが消えます)</label>
<div class="seg" id="immSeg">
<button data-imm="on">ON</button>
<button data-imm="off">OFF</button>
</div>
</div>
<div class="setrow">
<label>APIキー</label>
<button class="link" id="btnKeyReset">キーを入れ直す(再読み込み)</button>
</div>
</div>
<div id="shiori">
<div id="trip-title"></div>
<div id="trip-desc"></div>
<div id="trip-place"></div>
</div>
<div id="counter">🚶 0歩</div>
<div id="station"></div>
<div id="mapwrap">
<div id="minimap"></div>
<div id="minizoom">
<button id="zoomIn">+</button>
<button id="zoomOut">−</button>
</div>
<div id="minilabel"></div>
</div>
<div id="hint">道なりに、ひとりでに歩いていきます</div>
<div id="notice"><p id="noticeMsg"></p><button id="noticeBtn"></button></div>
<div id="logpanel">
<h2>旅のしおり <span id="logClose">✕</span></h2>
<div class="tabs" id="logTabs">
<button data-tab="log" class="on">旅ログ</button>
<button data-tab="mark">★ お気に入り</button>
</div>
<div id="logList"></div>
<div id="logClear">記録を消す</div>
</div>
<div id="dock">
<div id="startbar">
<button class="ctl" id="btnRandom">🎲 世界のどこかへ</button>
<div id="searchrow">
<input id="searchInput" type="text" spellcheck="false"
placeholder="行きたい町を検索(例:Kyoto, Paris)">
<button class="ctl ghost" id="btnSearch">🔍</button>
</div>
</div>
<div id="themes"></div>
<div id="controls">
<button class="ctl" id="btnPlay">⏸ 止める</button>
<button class="ctl ghost" id="btnStep">⏭ 先へ</button>
<button class="ctl ghost" id="btnNext">↻ 次の旅へ</button>
<button class="ctl ghost" id="btnAudio">♪ 音</button>
<button class="ctl ghost" id="btnMark">☆ 保存</button>
<button class="ctl ghost" id="btnLog">☰ ログ</button>
</div>
<div id="speedwrap">
<label>移動の間隔 ゆっくり</label>
<input type="range" id="speed" min="0" max="100" value="65">
<label>速い</label>
</div>
</div>
<div id="boot">
<h1>世界の窓<small>A Window to the World</small></h1>
<p>世界のどこかを、ただ覗くための窓です。<br>
行き先はこちらで見繕います。コーヒーでも飲みながらどうぞ。</p>
<input id="keyInput" type="password" spellcheck="false" autocomplete="off"
placeholder="Google Maps APIキー(AIza… )">
<div id="keySaved"></div>
<div id="keyHelp">キーの取り方(無料)を見る ▾</div>
<div id="keySteps">
<b>① </b><a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a> でプロジェクトを作成<br>
<b>② </b>お支払い(Billing)をリンク ※無料枠内なら課金されません<br>
<b>③ </b>「Maps JavaScript API」を有効化<br>
<b>④ </b>「認証情報」→ APIキーを発行 → 上の欄に貼る<br>
<b>⑤ </b>安全のためキーは「Maps JavaScript API のみ」に制限を推奨<br>
<span style="color:var(--accent)">※ キーはあなたのブラウザだけに保存され、外部に送信されません。</span>
</div>
<button class="ctl go" id="btnBoot">旅に出る</button>
<div class="spinner" id="spin"></div>
<div id="keywarn"></div>
</div>
<script>
/* ============================================================
世界の窓 (A Window to the World) — あてもない自動散歩 StreetView
単一HTML。Google Maps JavaScript API の StreetViewPanorama を使う。
▼ BYOK方式:各自の Google Maps APIキーを起動画面で入力(この端末にだけ保存)。
キーはソースに埋め込みません(公開しても漏れない設計)。
============================================================ */
const API_KEY = ""; // 公開版はここは空。キーはユーザーが入力→localStorageに保存。
/* ---- 旅のコーディネート:テーマごとの出発点候補 ----
lat/lng は「その辺りの道」。実際の徒歩はそこから自動で伸びる。 */
const TRIPS = [
{ theme:"北の海辺", title:"北欧、海沿いの無人の道",
desc:"風の強い岬。雲は速く、家々は色を潜めている。誰ともすれ違わない朝を歩く。",
spots:[
{name:"ロフォーテン諸島, ノルウェー", lat:68.0880, lng:13.5410},
{name:"レイキャヴィク近郊, アイスランド", lat:64.1200, lng:-21.8200},
{name:"シェトランド諸島, スコットランド", lat:60.1550, lng:-1.1450},
]},
{ theme:"日本の路地", title:"どこかの町の、名もない路地",
desc:"生活の匂いだけがある細道。自転車、鉢植え、電線の空。曲がるたびに知らない町になる。",
spots:[
{name:"尾道, 広島", lat:34.4090, lng:133.2050},
{name:"谷中, 東京", lat:35.7270, lng:139.7660},
{name:"金沢 東山, 石川", lat:36.5720, lng:136.6690},
]},
{ theme:"地中海の島", title:"陽の白い、石畳の島",
desc:"壁は白く、扉は青い。坂と階段ばかりの町を、影を選びながら下っていく。",
spots:[
{name:"サントリーニ, ギリシャ", lat:36.4180, lng:25.4310},
{name:"シャウエン, モロッコ", lat:35.1710, lng:-5.2680},
{name:"ドゥブロヴニク, クロアチア", lat:42.6410, lng:18.1100},
]},
{ theme:"果ての一本道", title:"地平線しかない、まっすぐな道",
desc:"どこまでも同じ景色。速度の感覚がなくなる。ラジオでも流したくなる長い直線。",
spots:[
{name:"ルート66, アリゾナ, アメリカ", lat:35.0500, lng:-113.7200},
{name:"アウトバック, オーストラリア", lat:-23.7000, lng:133.8800},
{name:"パタゴニア, アルゼンチン", lat:-50.3400, lng:-72.2700},
]},
{ theme:"山あいの村", title:"雪をかぶった、山の集落",
desc:"斜面に張りつく家々。空気は薄く、音が遠い。峠道はどこへ続くのか分からない。",
spots:[
{name:"ツェルマット近郊, スイス", lat:46.0210, lng:7.7490},
{name:"ハルシュタット, オーストリア", lat:47.5620, lng:13.6490},
{name:"ドロミテ, イタリア", lat:46.4100, lng:11.8440},
]},
{ theme:"アジアの雑踏", title:"熱気と看板の、路地の奥へ",
desc:"屋台、原色の看板、匂いまで見えそうな道。人波を縫うように進んでいく。",
spots:[
{name:"ハノイ旧市街, ベトナム", lat:21.0340, lng:105.8510},
{name:"ジョージタウン, マレーシア", lat:5.4160, lng:100.3350},
{name:"高雄, 台湾", lat:22.6250, lng:120.2870},
]},
{ theme:"砂と遺跡", title:"砂の色をした、古い町",
desc:"日干し煉瓦、細い影、乾いた風。時間の止まった路地をゆっくりと。",
spots:[
{name:"マラケシュ, モロッコ", lat:31.6250, lng:-7.9890},
{name:"カッパドキア, トルコ", lat:38.6430, lng:34.8290},
{name:"ペトラ近郊, ヨルダン", lat:30.3290, lng:35.4440},
]},
];
/* ---- テーマ別の“土地の空気”に寄せたネットラジオ(SomaFM=無料・停波しにくい)
※ mount = SomaFM のチャンネルID。実URLは HOSTS と組み合わせて生成(サーバ障害時に自動フォールバック)
※ 真の現地ラジオに差し替えたいときは mount を配信URLに変え、下の再生処理を直せばOK ---- */
const STREAMS = {
"北の海辺": { name:"SomaFM · Drone Zone", mount:"dronezone" },
"日本の路地":{ name:"SomaFM · Fluid", mount:"fluid" },
"地中海の島":{ name:"SomaFM · Beat Blender", mount:"beatblender" },
"果ての一本道":{ name:"SomaFM · Boot Liquor", mount:"bootliquor" },
"山あいの村":{ name:"SomaFM · Deep Space One", mount:"deepspaceone" },
"アジアの雑踏":{ name:"SomaFM · Suburbs of Goa", mount:"suburbsofgoa" },
"砂と遺跡": { name:"SomaFM · Dub Step Beyond", mount:"dubstep" },
};
// SomaFM の配信サーバ(1つ落ちても次を試す)
const SOMA_HOSTS = ["ice2.somafm.com","ice6.somafm.com","ice4.somafm.com","ice5.somafm.com"];
// 音は“モードに関係なく”鳴らせる独立のチャンネル一覧(局名クリックで次の曲へ)
const CHANNELS = [
{name:"Drone Zone", mount:"dronezone"},
{name:"Groove Salad", mount:"groovesalad"},
{name:"Fluid", mount:"fluid"},
{name:"Space Station", mount:"spacestation"},
{name:"Deep Space One", mount:"deepspaceone"},
{name:"Lush", mount:"lush"},
{name:"Beat Blender", mount:"beatblender"},
{name:"Boot Liquor", mount:"bootliquor"},
{name:"Suburbs of Goa", mount:"suburbsofgoa"},
{name:"Dub Step Beyond", mount:"dubstep"},
];
let panorama, svService, geocoder;
let running = true;
let heading = Math.random()*360; // 進行方向(旅の向き)
let viewHeading = heading; // いま実際にカメラが向いている方向
let recent = []; // 直近に通ったpano(ループ防止)
let activeThemeIdx = null;
let swayT = Math.random()*1000;
let minimap, marker;
let moving = false; // ワープ中フラグ(多重発火防止)
let lastPano = null; // 直前のpano id
let markerHdgLast = -999; // マーカー向きの更新間引き用
const JUMP_M = 45; // 1回の移動で進む距離(約5歩ぶん・m)
const ROT_DEG_PER_MS = 0.0006; // 視界回転の速さ(移動間隔とは独立)=約10分で1周(ごくゆっくり)
// この滞在中の“見回しプログラム”(毎回変える)
let lookDir = 1; // +1=右回り / -1=左回り
let lookSky = 0; // 空を見上げる量(度・0なら見上げない)
let spinAngle = Math.random()*360; // いまの視界の向き(常にゆっくり回り続ける)
let trailDots = []; // ミニ地図に残す軌跡の点
// 設定・状態
let tod = "auto"; // 時間帯: auto / day / night
let immOn = true; // 没入モード(無操作でUIを隠す)
let idleTimer = null;
let activeTab = "log"; // パネル: log / mark
let curPos = null; // いまの緯度経度(ブックマーク用)
let curSpotName = ""; // いまの地域名
// 歩数・上限
const SESSION_CAP = 50; // 1回の散歩あたり
const MONTHLY_CAP = 4000; // 今月あたり
let sessionSteps = 0;
let monthCount = 0;
let monthLocked = false;
// 1歩ごとの滞在(=ゆっくり360°見回す時間)
let dwellElapsed = 0; // このパノラマに来てからの経過ms
let lastFrame = null;
let audioEl = null, audioOn = false;
let curMount = null, hostIdx = 0; // いまのラジオ局と、使っている配信サーバの番号
let channelIdx = 0; // CHANNELS の何番を鳴らしているか
// 手動で視点を回している間は自動の見回しを止める
let userControl = false;
let userReleaseTimer = null;
const $ = s=>document.querySelector(s);
/* ---------- 月次カウンタの永続化(localStorage) ---------- */
function monthKey(){ const d=new Date(); return d.getFullYear()+"-"+(d.getMonth()+1); }
function loadMonth(){
try{
const saved = JSON.parse(localStorage.getItem("sw_month")||"{}");
if(saved.key === monthKey()){ monthCount = saved.count||0; }
else { monthCount = 0; saveMonth(); }
}catch(e){ monthCount = 0; }
monthLocked = monthCount >= MONTHLY_CAP;
}
function saveMonth(){
try{ localStorage.setItem("sw_month", JSON.stringify({key:monthKey(), count:monthCount})); }catch(e){}
}
/* ---------- Google Maps API 動的ロード(BYOK) ---------- */
function getKey(){
const entered = ($("#keyInput").value||"").trim();
return entered || localStorage.getItem("sw_apikey") || API_KEY;
}
function loadMaps(){
const key = getKey();
if(!key){ showKeyWarning(); return; }
localStorage.setItem("sw_apikey", key); // この端末にだけ保存
$("#spin").style.display="block";
const s=document.createElement("script");
s.src=`https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&v=weekly&callback=onMapsReady`;
s.async=true;
s.onerror=()=>showKeyWarning("APIの読み込みに失敗しました。キーやネットワーク接続を確認してください。");
document.head.appendChild(s);
}
function showKeyWarning(msg){
$("#spin").style.display="none";
const w=$("#keywarn");
w.style.display="block";
w.innerHTML = (msg||"") + `
<b>APIキーを入力してください。</b><br>
上の欄に Google Maps の APIキー(AIza… )を貼ってから「旅に出る」を押してください。<br>
取り方は「キーの取り方(無料)を見る」を開いてね。`;
}
window.onMapsReady = function(){
svService = new google.maps.StreetViewService();
geocoder = new google.maps.Geocoder();
panorama = new google.maps.StreetViewPanorama($("#pano"), {
pov:{heading, pitch:0}, zoom:0.9,
disableDefaultUI:true, showRoadLabels:false,
motionTracking:false, clickToGo:false, scrollwheel:false,
linksControl:false, panControl:false, addressControl:false,
});
// ミニ地図(1回だけ作る=地図の課金はほぼこの1回きり。以降はマーカーが動くだけ)
minimap = new google.maps.Map($("#minimap"), {
zoom:15, center:{lat:0,lng:0},
disableDefaultUI:true, gestureHandling:"none", keyboardShortcuts:false,
clickableIcons:false, mapId:null,
});
// 向いている方向がひと目で分かる矢印マーカー(前方に尖った視線マーク)
marker = new google.maps.Marker({
map:minimap,
icon:{ path:"M 0,-13 L 8,9 L 0,4 L -8,9 Z", scale:1.1,
rotation:heading, fillColor:"#c0392b", fillOpacity:1,
strokeColor:"#fff8ec", strokeWeight:1.6, anchor:new google.maps.Point(0,0) },
});
// パノラマが動いたらミニ地図も追従(歩くたびに呼ばれる)
panorama.addListener("position_changed", ()=>{
const p = panorama.getPosition();
if(!p || !minimap) return;
minimap.setCenter(p);
marker.setPosition(p);
});
// ミニ地図の拡大・縮小ボタン
$("#zoomIn").onclick = ()=>minimap.setZoom(Math.min(minimap.getZoom()+1, 21));
$("#zoomOut").onclick = ()=>minimap.setZoom(Math.max(minimap.getZoom()-1, 3));
// 手動で視点を回している間は自動の見回しを止める(回したら元に戻らない)
const panoEl = $("#pano");
const beginUserControl = ()=>{ userControl = true; clearTimeout(userReleaseTimer); };
const endUserControl = ()=>{
if(!userControl) return; // パノラマを触っていた時だけ(他のボタン操作では効かせない)
clearTimeout(userReleaseTimer);
userReleaseTimer = setTimeout(()=>{
// 再開時:いま見ている方向から回転を続け、進む向きもそこに合わせる(ガクッとしない)
const pov = panorama.getPov();
spinAngle = pov.heading; // 視界回転をこの向きから継続
heading = pov.heading; // 次に進む向きも“見ている方”へ
userControl = false;
}, 2500); // 手を離して2.5秒たったら自動見回しに戻る
};
panoEl.addEventListener("mousedown", beginUserControl);
panoEl.addEventListener("touchstart", beginUserControl, {passive:true});
window.addEventListener("mouseup", endUserControl);
window.addEventListener("touchend", endUserControl);
loadMonth();
initAudio();
loadSettings();
applyTod();
renderPanel();
updateCounter();
resetIdle();
$("#boot").style.display="none";
startTrip(0);
requestAnimationFrame(tick);
};
// マーカーの向きを更新(引数なしなら進行方向)
function updateMarkerHeading(angle){
if(!marker) return;
const a = (angle==null?heading:angle);
const ic = marker.getIcon();
ic.rotation = a;
marker.setIcon(ic);
}
// このパノラマでの見回し方を毎回変える(右回り/左回り/たまに空)
function newLookProgram(){
lookDir = Math.random()<0.5 ? 1 : -1;
lookSky = Math.random()<0.4 ? (14 + Math.random()*18) : 0; // 4割の確率で空を見上げる
}
// ミニ地図に軌跡の点を残す
function addTrailDot(latLng){
if(!minimap || !latLng) return;
const dot = new google.maps.Marker({
map:minimap, position:latLng, clickable:false, zIndex:1,
icon:{ path:google.maps.SymbolPath.CIRCLE, scale:2.6,
fillColor:"#8a6d3b", fillOpacity:.85, strokeColor:"#fff8ec", strokeWeight:.8 },
});
trailDots.push(dot);
if(trailDots.length>400){ const old=trailDots.shift(); old.setMap(null); }
}
function clearTrail(){
trailDots.forEach(d=>d.setMap(null));
trailDots = [];
}
/* ---------- 歩数カウント(=コストの目安)+上限管理 ---------- */
function updateCounter(){
$("#counter").innerHTML =
"🚶 "+sessionSteps+" / "+SESSION_CAP+
"<br><small>今月 "+monthCount+" / "+MONTHLY_CAP+"</small>";
}
// 1パノラマ読み込むごと(=1歩/旅の出発)に呼ぶ。上限に達したら止める。
function countLoad(){
sessionSteps++;
monthCount++;
saveMonth();
updateCounter();
if(monthCount >= MONTHLY_CAP){
monthLocked = true;
stopWalking("今月の上限("+MONTHLY_CAP+"歩)に達しました 🌙<br>また来月ゆっくり歩きましょう", null);
return false;
}
if(sessionSteps >= SESSION_CAP){
stopWalking("今回の散歩はここまで 🍵<br>("+SESSION_CAP+"歩)おつかれさまでした", "もう"+SESSION_CAP+"歩あるく");
return false;
}
return true; // まだ歩いてよい
}
/* ---------- 到着処理(全モード共通) ---------- */
function arrive(data, displayName, themeName){
recent = [];
heading = Math.random()*360;
dwellElapsed = 0;
lastPano = data.location.pano;
recent.push(lastPano);
clearTrail(); // 新しい旅なので軌跡をリセット
newLookProgram();
panorama.setPano(lastPano);
panorama.setPov({heading, pitch:0});
updateMarkerHeading();
addTrailDot(data.location.latLng);
curPos = data.location.latLng;
curSpotName = displayName;
$("#minilabel").textContent = displayName;
if(themeName) setStation(themeName); // ラジオを土地に合わせる
running = true;
$("#btnPlay").textContent = "⏸ 止める";
countLoad(); // 出発地の1枚もコスト
}
/* ---------- 旅を始める(テーマ) ---------- */
function startTrip(themeIdx){
if(monthLocked){
stopWalking("今月の上限("+MONTHLY_CAP+"歩)に達しました 🌙<br>また来月ゆっくり歩きましょう", null);
return;
}
activeThemeIdx = (themeIdx==null)
? Math.floor(Math.random()*TRIPS.length) : themeIdx;
renderThemes();
applyTod();
const trip = TRIPS[activeThemeIdx];
const spot = pickSpot(trip); // 時間帯に合わせて行き先を選ぶ
hideShiori();
svService.getPanorama(
{ location:{lat:spot.lat, lng:spot.lng}, radius:6000,
source:google.maps.StreetViewSource.OUTDOOR },
(data,status)=>{
if(status==="OK"){
showShiori(trip.title, trip.desc, spot.name);
arrive(data, spot.name, trip.theme);
addLog(trip, spot);
}else{
startTrip(); // 見つからなければ別の旅へ
}
});
}
/* ---------- 町の名前で検索して出発 ---------- */
function startSearch(q){
q = (q||"").trim();
if(!q) return;
if(monthLocked){ stopWalking("今月の上限("+MONTHLY_CAP+"歩)に達しました 🌙<br>また来月ゆっくり歩きましょう", null); return; }
if(!geocoder){ showNotice("検索の準備ができていません。もう一度お試しください。", null); return; }
geocoder.geocode({address:q}, (res,status)=>{
if(status==="OK" && res && res[0]){
const loc = res[0].geometry.location;
const name = res[0].formatted_address || q;
svService.getPanorama({location:loc, radius:10000, source:google.maps.StreetViewSource.OUTDOOR},
(data,st)=>{
if(st==="OK"){
activeThemeIdx = null; renderThemes();
hideShiori();
showShiori("検索の旅", q+" のあたりを歩きます", name);
arrive(data, name, null);
addLog({theme:"検索", title:"検索の旅"}, {name});
}else{
showNotice("「"+esc(q)+"」の近くにストリートビューが見つかりませんでした。", null);
}
});
}else if(status==="ZERO_RESULTS"){
showNotice("「"+esc(q)+"」という地名が見つかりませんでした。", null);
}else{
showNotice("検索に失敗しました。<br>キーで <b>Geocoding API</b> が有効か確認してください。", null);
}
});
}
/* ---------- 座標→地名(逆ジオコーディング。市区町村+都道府県/州+国に短縮)
※ 検索と同じ「コールバック形式」で統一(Promise形式だと環境により解決しないことがある) ---------- */
function reverseGeocodeName(latLng, cb){
if(!geocoder || !latLng){ cb("どこかの場所"); return; }
const loc = { lat: (typeof latLng.lat==="function"?latLng.lat():latLng.lat),
lng: (typeof latLng.lng==="function"?latLng.lng():latLng.lng) };
geocoder.geocode({ location: loc }, (results, status)=>{
if(status==="OK" && results && results.length){
cb(shortPlaceName(results) || "どこかの場所");
}else{
if(status!=="OK") console.warn("[世界の窓] 逆ジオコーディング:", status);
cb("どこかの場所");
}
});
}
// 住所コンポーネントから、番地・郵便番号を捨てて「市区町村, 都道府県/州, 国」だけを組む
function shortPlaceName(results){
const want = {};
const keep = ["locality","postal_town","sublocality","administrative_area_level_2",
"administrative_area_level_1","country","natural_feature"];
for(const r of results){
for(const c of (r.address_components||[])){
for(const t of (c.types||[])){
if(keep.includes(t) && !want[t]) want[t] = c.long_name;
}
}
}
const city = want["locality"] || want["postal_town"] || want["sublocality"]
|| want["administrative_area_level_2"] || want["natural_feature"];
const parts = [city, want["administrative_area_level_1"], want["country"]].filter(Boolean);
const uniq = parts.filter((p,i)=>parts.indexOf(p)===i); // 市名=県名などの重複を除く
return uniq.length ? uniq.join(", ") : null;
}
/* ---------- 完全ランダムな“陸地”から出発 ---------- */
// 陸地に偏らせるための大まかな範囲(海に落ちて空振りするのを減らす)
const LAND_BOXES = [
[36,60,-9,30], [50,58,-6,1], [37,43,-9,-1], [36,42,20,40], [55,66,8,25],
[31,43,130,142], [34,38,126,129], [22,25,120,122], [-8,20,96,122], [8,30,70,88],
[30,45,-95,-70], [33,48,-123,-100],[16,30,-105,-97],[43,50,-80,-73],
[-33,-2,-60,-40],[-40,-30,-66,-58],[-40,-30,-73,-70],
[-38,-20,115,153],[-46,-36,167,178],[-34,-25,18,30],
];
function startRandom(tries){
tries = tries||0;
if(monthLocked){ stopWalking("今月の上限("+MONTHLY_CAP+"歩)に達しました 🌙<br>また来月ゆっくり歩きましょう", null); return; }
if(tries===0){ showShiori("あてのない旅", "世界のどこか、名前も知らない場所を探しています…", "🎲 ランダム"); }
if(tries>=16){ startTrip(); return; } // 諦めてテーマ旅へ
const b = LAND_BOXES[Math.floor(Math.random()*LAND_BOXES.length)];
const lat = b[0] + Math.random()*(b[1]-b[0]);
const lng = b[2] + Math.random()*(b[3]-b[2]);
svService.getPanorama({location:{lat,lng}, radius:50000, source:google.maps.StreetViewSource.OUTDOOR},
(data,st)=>{
if(st==="OK"){
activeThemeIdx = null; renderThemes();
arrive(data, "現在地を調べています…", null);
// 座標→地名を引いて、表示・ログに本当の地名を残す(あとで追える)
reverseGeocodeName(data.location.latLng, (name)=>{
curSpotName = name;
$("#minilabel").textContent = name;
showShiori("あてのない旅", "世界のどこか、"+name+" のあたりから。", name);
addLog({theme:"ランダム", title:"あてのない旅"}, {name});
});
}else{
startRandom(tries+1);
}
});
}
/* ---------- しおり表示 ---------- */
function hideShiori(){
["#trip-title","#trip-desc","#trip-place"].forEach(s=>$(s).classList.remove("show"));
}
function showShiori(title, desc, place){
$("#trip-title").textContent = title;
$("#trip-desc").textContent = desc;
$("#trip-place").textContent = "◇ "+place;
setTimeout(()=>{
["#trip-title","#trip-desc","#trip-place"].forEach(s=>$(s).classList.add("show"));
$("#hint").classList.add("show");
setTimeout(()=>$("#hint").classList.remove("show"),4000);
},120);
}
/* ---------- 次の場所へ移動するまでの間隔(視界の回転速度とは独立) ----------
スライダー 0(ゆっくり)〜100(速い) を 300秒〜40秒 に対応させる。
※ 視界の回転は ROT_DEG_PER_MS の一定速度で、この間隔とは連動しない。 */
function dwellMs(){
const v = +$("#speed").value; // 0..100
return 300000 - v*2600; // v=0→300s(5分), v=65→131s(≈2.2分), v=100→40s
}
/* ---------- 緯度経度を heading 方向へ meters だけ進めた点 ---------- */
function projectLatLng(lat, lng, headingDeg, meters){
const R=6378137, d=meters/R, brng=headingDeg*Math.PI/180;
const la1=lat*Math.PI/180, lo1=lng*Math.PI/180;
const la2=Math.asin(Math.sin(la1)*Math.cos(d)+Math.cos(la1)*Math.sin(d)*Math.cos(brng));
const lo2=lo1+Math.atan2(Math.sin(brng)*Math.sin(d)*Math.cos(la1),
Math.cos(d)-Math.sin(la1)*Math.sin(la2));
return {lat:la2*180/Math.PI, lng:lo2*180/Math.PI};
}
/* ---------- 着地点で、進行方向に一番沿った道を選ぶ(=基本まっすぐ進む) ---------- */
function chooseHeadingFromLinks(links, cur){
if(!links || !links.length) return cur;
// 進行方向に一番近いリンク=道なりに直進。ランダムには曲がらない(一方通行感)
let scored = links.map(l=>({l, diff:angleDiff(cur, l.heading)})).sort((a,b)=>a.diff-b.diff);
return scored[0].l.heading;
}
/* ---------- 次の移動:進行方向へ約5歩ぶんワープ(読み込みは1回だけ) ---------- */
function walkStep(){
if(moving || !panorama) return;
const pos = panorama.getPosition();
if(!pos){ startTrip(); return; }
moving = true;
const target = projectLatLng(pos.lat(), pos.lng(), heading, JUMP_M);
svService.getPanorama(
{ location:target, radius:70, source:google.maps.StreetViewSource.OUTDOOR },
(data,status)=>{
moving = false;
if(status==="OK"){
const pano = data.location.pano;
if(pano===lastPano || recent.includes(pano)){
// 来た所へ戻りそう → 大きく振らず、少しだけ向きをずらして前方の別ルートを探す
heading = (heading + (Math.random()<0.5?-1:1)*(18+Math.random()*22)) % 360;
dwellElapsed = Math.max(0, dwellMs()-2500);
return;
}
lastPano = pano;
recent.push(pano); if(recent.length>40) recent.shift(); // 戻り防止のため長めに記憶
panorama.setPano(pano);
heading = chooseHeadingFromLinks(data.links, heading); // 道なりに直進
newLookProgram(); // 回る向き等を新しく
addTrailDot(data.location.latLng); // 軌跡の点を残す
curPos = data.location.latLng; // ブックマーク用に現在地を控える
countLoad();
}else{
// その方向に道が無い → まず小さく振って前方を探す(行き止まりなら自然に反転する)
heading = (heading + (Math.random()<0.5?-1:1)*(25+Math.random()*30)) % 360;
dwellElapsed = Math.max(0, dwellMs()-2500);
}
});
}
function angleDiff(a,b){
return Math.abs(((a-b)%360+540)%360-180);
}
/* ---------- メインループ:その場でゆっくり左右を見回し、時間が来たら移動 ---------- */
function tick(ts){
requestAnimationFrame(tick);
if(!panorama){ lastFrame=ts; return; }
const dt = lastFrame ? Math.min(ts-lastFrame, 100) : 16;
lastFrame = ts;
// 手動で回している間は、自動の見回しも自動移動も止めて手に委ねる
if(userControl){
const pov = panorama.getPov();
viewHeading = pov.heading;
if(marker && Math.abs(viewHeading - markerHdgLast) > 2){
markerHdgLast = viewHeading;
updateMarkerHeading(viewHeading);
}
return;
}
// 微細な揺らぎ(1/fっぽく)=機械的な等速回転に見えないように
swayT += dt*0.00028;
const sway = Math.sin(swayT*1.7)*1.2 + Math.sin(swayT*0.6+0.4)*0.8;
// 移動の間隔(視界の回転とは独立)
if(running){
dwellElapsed += dt;
if(dwellElapsed >= dwellMs()){ dwellElapsed = 0; walkStep(); }
}
// 視界は“移動間隔とは無関係に”常に一定速度でゆっくり回り続ける
spinAngle += lookDir * ROT_DEG_PER_MS * dt;
// ピッチはゆっくり上下に漂う。lookSky が立っている滞在では少し多めに空を仰ぐ
const pitchAmp = 2.5 + lookSky*0.35;
const targetPitch = Math.sin(swayT*0.5+0.7) * pitchAmp;
viewHeading = spinAngle + sway;
// ピッチ(上下)は現在値から目標へ滑らかに寄せる → 手動で見上げた後の復帰でガクッとしない
const pov = panorama.getPov();
const np = pov.pitch + (targetPitch - pov.pitch) * 0.05;
panorama.setPov({ heading: viewHeading, pitch: np });
// ミニ地図の矢印も“いま見ている方向”に(少し間引いて更新)
if(marker && Math.abs(viewHeading - markerHdgLast) > 2){
markerHdgLast = viewHeading;
updateMarkerHeading(viewHeading);
}
}
/* ---------- UI ---------- */
function renderThemes(){
const box=$("#themes"); box.innerHTML="";
TRIPS.forEach((t,i)=>{
const el=document.createElement("div");
el.className="theme"+(i===activeThemeIdx?" active":"");
el.textContent=t.theme;
el.onclick=()=>startTrip(i);
box.appendChild(el);
});
}
$("#btnPlay").onclick=()=>{
if(monthLocked){
showNotice("今月の上限("+MONTHLY_CAP+"歩)に達しました 🌙<br>また来月ゆっくり歩きましょう", null);
return;
}
running=!running;
if(running && sessionSteps >= SESSION_CAP){ sessionSteps = 0; updateCounter(); } // 再開時はセッションをリセット
$("#btnPlay").textContent = running?"⏸ 止める":"▶ 歩く";
};
// いまの旅の中で、少し先へ進む(止まっていても押せば一歩進む)
$("#btnStep").onclick=()=>{
if(monthLocked || moving) return;
userControl = false; clearTimeout(userReleaseTimer);
dwellElapsed = 0;
walkStep();
};
$("#btnNext").onclick=()=>startTrip(); // ランダムな別の旅へ
$("#btnBoot").onclick=loadMaps;
$("#btnSearch").onclick=()=>startSearch($("#searchInput").value);
$("#searchInput").addEventListener("keydown",e=>{ if(e.key==="Enter") startSearch($("#searchInput").value); });
$("#btnRandom").onclick=()=>startRandom();
/* ---------- 歩くのを止める+お知らせ ---------- */
function stopWalking(msg, resumeLabel){
running = false;
$("#btnPlay").textContent = "▶ 歩く";
showNotice(msg, resumeLabel);
}
function showNotice(msg, resumeLabel){
$("#noticeMsg").innerHTML = msg;
const btn = $("#noticeBtn");
if(resumeLabel){
btn.textContent = resumeLabel;
btn.style.display = "";
btn.onclick = ()=>{
hideNotice();
sessionSteps = 0; updateCounter();
running = true; $("#btnPlay").textContent = "⏸ 止める";
};
}else{
btn.textContent = "とじる";
btn.style.display = "";
btn.onclick = hideNotice;
}
$("#notice").classList.add("show");
}
function hideNotice(){ $("#notice").classList.remove("show"); }
/* ---------- 地域ラジオ ---------- */
function initAudio(){
audioEl = new Audio();
audioEl.volume = 0.55;
// crossOrigin は付けない:付けると CORS ヘッダの無い配信(SomaFM等)が
// HTTPS上で読み込み失敗して無音になる。ただ流すだけなら不要。
audioEl.addEventListener("error", ()=>{
if(!audioOn || !curMount) return;
hostIdx++; // このサーバがダメなら次のサーバへ
if(hostIdx < SOMA_HOSTS.length){ tryPlayHost(); }
else {
const ch = CHANNELS[channelIdx];
$("#station").innerHTML = '<span id="stName" title="クリックで別の曲へ">📻 '+esc(ch.name)+'(接続不可)⏭</span>';
$("#stName").onclick = cycleChannel;
}
});
}
function tryPlayHost(){
if(!curMount) return;
audioEl.src = "https://"+SOMA_HOSTS[hostIdx]+"/"+curMount+"-128-mp3";
audioEl.play().catch(()=>{});
}
// 局名(クリックで曲替え)+ ↗(局ページを開く)を表示
function updateStationLabel(){
const ch = CHANNELS[channelIdx];
$("#station").innerHTML =
'<span id="stName" title="クリックで曲を変える">📻 SomaFM · '+esc(ch.name)+' ⏭</span>'+
'<a id="stLink" href="https://somafm.com/'+ch.mount+'/" target="_blank" rel="noopener" title="局のページを開く"> ↗</a>';
$("#stName").onclick = cycleChannel;
}
function playChannel(idx){
channelIdx = ((idx % CHANNELS.length) + CHANNELS.length) % CHANNELS.length;
curMount = CHANNELS[channelIdx].mount; hostIdx = 0;
updateStationLabel();
tryPlayHost();
}