Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
1581 lines (1440 loc) · 80.6 KB
/
Copy pathapp.js
File metadata and controls
1581 lines (1440 loc) · 80.6 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
// 114-2學期課程(依學分 desc → 必修優先順序排列)
const currentSemesterCourses = [
{name: "計算機結構", credits: 3, type: "cs_core"},
{name: "演算法", credits: 3, type: "cs_core"},
{name: "離散數學", credits: 3, type: "math_core"},
{name: "資訊專題研究(二):資訊系統", credits: 3, type: "project"},
{name: "數位邏輯", credits: 3, type: "hardware"},
{name: "影像處理", credits: 3, type: "multimedia"},
{name: "計算機圖學", credits: 3, type: "multimedia"},
{name: "資安攻防演練", credits: 3, type: "dept_elective"},
{name: "體育(籃球初級)", credits: 1, type: "pe"}
];
let simulatedCourses = new Set();
let currentEarned = 94;
let currentPercentage = 73.4;
let currentRemaining = 34;
let mobileCoursesOpen = false;
// 滾動閾值(縮小)
const SCROLL_THRESHOLD = 50;
// Scroll監聽
window.addEventListener('scroll', () => {
const stats = document.getElementById('stats');
const treeHeader = document.getElementById('tree-header');
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop > SCROLL_THRESHOLD) {
stats.classList.add('scrolled');
treeHeader.classList.add('floating');
} else {
stats.classList.remove('scrolled');
treeHeader.classList.remove('floating');
}
});
// 快捷鍵
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key.toLowerCase() === 'f') {
e.preventDefault();
toggleFullscreen();
} else if (e.key.toLowerCase() === 'c') {
e.preventDefault();
if (window.innerWidth < 1200) {
toggleMobileCourses();
}
} else if (e.key === 'Escape') {
if (changelogOpen) {
closeChangelog();
return;
}
closeMobileCourses();
const panel = document.getElementById('tree-panel');
if (panel.classList.contains('fullscreen')) {
toggleFullscreen();
}
}
});
function showShortcutHint(text) {
const hint = document.getElementById('shortcut-hint');
hint.textContent = text;
hint.classList.add('show');
setTimeout(() => hint.classList.remove('show'), 1500);
}
// 豎屏選課切換
function toggleMobileCourses() {
if (mobileCoursesOpen) {
closeMobileCourses();
} else {
openMobileCourses();
}
}
function openMobileCourses() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// 滾動到安全位置
if (scrollTop < SCROLL_THRESHOLD + 20) {
window.scrollTo({ top: SCROLL_THRESHOLD + 30, behavior: 'smooth' });
setTimeout(() => {
showPanel();
}, 350);
} else {
showPanel();
}
function showPanel() {
const overlay = document.getElementById('mobile-overlay');
const panel = document.getElementById('mobile-panel');
const btn = document.getElementById('course-btn');
mobileCoursesOpen = true;
overlay.classList.add('active');
panel.classList.add('active');
btn.classList.add('active');
}
}
function closeMobileCourses() {
const overlay = document.getElementById('mobile-overlay');
const panel = document.getElementById('mobile-panel');
const btn = document.getElementById('course-btn');
mobileCoursesOpen = false;
overlay.classList.remove('active');
panel.classList.remove('active');
btn.classList.remove('active');
}
// 全螢幕切換
function toggleFullscreen() {
const panel = document.getElementById('tree-panel');
const btn = document.getElementById('fullscreen-btn');
const canvas = document.getElementById('stellar-bg');
const container = document.querySelector('.container');
if (panel.classList.contains('fullscreen')) {
// 退出全螢幕
panel.classList.remove('fullscreen');
document.body.classList.remove('fullscreen-mode');
btn.innerHTML = '⛶';
// 把 canvas 移回 container
container.insertBefore(canvas, container.firstChild);
showShortcutHint('退出全螢幕');
} else {
// 進入全螢幕
panel.classList.add('fullscreen');
document.body.classList.add('fullscreen-mode');
btn.innerHTML = '✕';
// 把 canvas 移到 tree-panel 內部最前面
panel.insertBefore(canvas, panel.firstChild);
showShortcutHint('全螢幕模式');
}
// v1.13.0: 按下時的 bounce 動畫,視覺更明顯
btn.classList.remove('toggling');
void btn.offsetWidth;
btn.classList.add('toggling');
setTimeout(() => btn.classList.remove('toggling'), 450);
}
// 課程架構數據
function getTreeData() {
let additionalCredits = 0;
let peCredits = 0;
let mathCoreCredits = 0;
let csCoreCredits = 0;
let hardwareCredits = 0;
let multimediaCredits = 0;
let projectCredits = 0;
let deptElectiveCredits = 0;
simulatedCourses.forEach(courseName => {
const course = currentSemesterCourses.find(c => c.name === courseName);
if (course) {
additionalCredits += course.credits;
if (course.type === 'pe') peCredits += course.credits;
if (course.type === 'math_core') mathCoreCredits += course.credits;
if (course.type === 'cs_core') csCoreCredits += course.credits;
if (course.type === 'hardware') hardwareCredits += course.credits;
if (course.type === 'multimedia') multimediaCredits += course.credits;
if (course.type === 'project') projectCredits += course.credits;
if (course.type === 'dept_elective') deptElectiveCredits += course.credits;
}
});
// 114-1 結束 base: 博雅 18 (人文4+社會6+自然6+邏輯運算2)、跨域 4 (運算思維+奈米科技)
const totalPe = 3 + peCredits;
const totalGeneral = 18;
const totalInterdisciplinary = 4;
const totalGeneralEducation = totalGeneral + totalInterdisciplinary;
const extraGeneralCredits = Math.max(0, totalGeneralEducation - 18);
const totalFieldElective = 18 + hardwareCredits + multimediaCredits;
const totalDeptElective = 7 + deptElectiveCredits;
return {
name: "畢業總學分",
credits: 128,
earned: 94 + additionalCredits,
children: [
{
name: "一、校共同必修",
credits: 32,
earned: 31 + peCredits,
children: [
{
name: "中文",
credits: 4,
earned: 4,
courses: [
{name: "中文閱讀與思辨", grade: "A-", semester: "112-1", credits: 2, completed: true},
{name: "中文寫作與表達", grade: "A", semester: "112-2", credits: 2, completed: true}
]
},
{
name: "英文",
credits: 6,
earned: 6,
courses: [
{name: "英文(一)、(二)、(三)", grade: "抵免", credits: 6, completed: true}
]
},
{
name: "通識課程",
credits: 18,
earned: Math.min(18, totalGeneralEducation),
children: [
{
name: "博雅課程",
credits: 14,
earned: Math.min(14, totalGeneral),
courses: [
{name: "亞裔美國文學", grade: "A-", semester: "112-2", credits: 2, completed: true},
{name: "科技與社會", grade: "A-", semester: "112-1", credits: 2, completed: true},
{name: "環境與傳播", grade: "A+", semester: "112-1", credits: 2, completed: true},
{name: "科技與人文的對話", grade: "A-", semester: "113-1", credits: 2, completed: true},
{name: "多元文化", grade: "A+", semester: "113-暑", credits: 2, completed: true},
{name: "資料科學與程式設計", grade: "A+", semester: "113-暑", credits: 2, completed: true},
{name: "個人投資理財", grade: "B+", semester: "111-1", credits: 2, completed: true},
{name: "女性文學、性別平等理論與婦運", grade: "A-", semester: "114-1", credits: 2, completed: true},
{name: "宇宙中的生命與太空環境", grade: "A", semester: "114-1", credits: 2, completed: true}
]
},
{
name: "跨域探索",
credits: 4,
earned: Math.min(4, totalInterdisciplinary),
courses: [
{name: "運算思維與程式設計", grade: "C", semester: "113-2", credits: 2, completed: true},
{name: "奈米科技", grade: "A+", semester: "114-1", credits: 2, completed: true}
]
}
]
},
{
name: "體育",
credits: 4,
earned: totalPe,
courses: [
{name: "體育(現代舞初級)", grade: "A-", semester: "112-1", credits: 1, completed: true},
{name: "體育(籃球初級)", grade: "A+", semester: "113-2", credits: 1, completed: true},
{name: "體育(羽球初級)", grade: "A-", semester: "114-1", credits: 1, completed: true},
simulatedCourses.has("體育(籃球初級)") ? {name: "體育(籃球初級)", credits: 1, completed: true, isNew: true, semester: "114-2"} : {name: "體育(籃球初級)", credits: 1, note: "114-2修課中"},
totalPe >= 4 ? null : {name: "還需修習", credits: 4 - totalPe}
].filter(Boolean)
}
]
},
{
name: "二、系必修(資訊課程)",
credits: 15,
earned: 9 + csCoreCredits,
courses: [
{name: "程式設計(一)", grade: "C-", semester: "112-1", credits: 3, completed: true},
{name: "程式設計(二)", grade: "C+", semester: "112-2", credits: 3, completed: true},
{name: "資料結構", grade: "C", semester: "113-1", credits: 3, completed: true},
simulatedCourses.has("演算法") ? {name: "演算法", credits: 3, completed: true, isNew: true} : {name: "演算法", credits: 3, note: "114-2修課中"},
simulatedCourses.has("計算機結構") ? {name: "計算機結構", credits: 3, completed: true, isNew: true} : {name: "計算機結構", credits: 3, note: "114-2修課中"}
]
},
{
name: "三、系選修",
credits: 54,
earned: 34 + mathCoreCredits + hardwareCredits + multimediaCredits + projectCredits + deptElectiveCredits,
children: [
{
name: "數學必選修",
credits: 12,
earned: 9 + mathCoreCredits,
courses: [
{name: "微積分乙(一)", grade: "C-", semester: "111-1", credits: 3, completed: true},
simulatedCourses.has("離散數學") ? {name: "離散數學", credits: 3, completed: true, isNew: true} : {name: "離散數學", credits: 3, note: "114-2修課中"},
{name: "機率論", grade: "C", semester: "112-1", credits: 3, completed: true},
{name: "線性代數", grade: "C-", semester: "113-1", credits: 3, completed: true}
]
},
{
name: "資訊專題必選修",
credits: 6,
earned: projectCredits,
note: "4選2",
courses: [
{name: "資訊專題研究(一):資訊理論", credits: 3, available: true},
{name: "資訊專題研究(一):資訊系統", credits: 3, note: "114-1 未通過"},
{name: "資訊專題研究(二):資訊理論", credits: 3, available: true},
simulatedCourses.has("資訊專題研究(二):資訊系統") ? {name: "資訊專題研究(二):資訊系統", credits: 3, completed: true, isNew: true} : {name: "資訊專題研究(二):資訊系統", credits: 3, note: "114-2修課中"}
]
},
{
name: "領域選修",
credits: 30,
earned: totalFieldElective,
note: "每領域≥3學分",
children: [
{
name: "資訊理論領域",
credits: 3,
earned: 6,
courses: [
{name: "程式語言結構", grade: "C+", semester: "113-2", credits: 3, completed: true},
{name: "資料庫理論", grade: "C", semester: "113-2", credits: 3, completed: true},
{name: "計算機概論", credits: 3, available: true},
{name: "物件導向分析與設計", credits: 3, available: true},
{name: "自動機理論與正規語言", credits: 3, available: true}
]
},
{
name: "資訊硬體領域",
credits: 3,
earned: 3 + hardwareCredits,
courses: [
{name: "類比數位運算元件(基礎電子學)", grade: "B+", semester: "112-1", credits: 3, completed: true},
simulatedCourses.has("數位邏輯") ? {name: "數位邏輯", credits: 3, completed: true, isNew: true} : {name: "數位邏輯", credits: 3, note: "114-2修課中"},
{name: "組合語言", credits: 3, available: true},
{name: "電腦輔助VLSI設計", credits: 3, available: true}
]
},
{
name: "資訊系統領域",
credits: 3,
earned: 3,
courses: [
{name: "系統程式", grade: "C", semester: "114-1", credits: 3, completed: true},
{name: "軟體工程", credits: 3, available: true},
{name: "資訊安全", credits: 3, available: true},
{name: "作業系統", credits: 3, available: true},
{name: "編譯系統設計", credits: 3, available: true}
]
},
{
name: "電腦網路領域",
credits: 3,
earned: 3,
courses: [
{name: "區域性網路", grade: "C-", semester: "113-1", credits: 3, completed: true},
{name: "計算機網路", credits: 3, available: true},
{name: "資料通訊", credits: 3, available: true},
{name: "無線通訊", credits: 3, available: true}
]
},
{
name: "多媒體處理領域",
credits: 3,
earned: 3 + multimediaCredits,
courses: [
{name: "資料探勘", grade: "C+", semester: "114-1", credits: 3, completed: true},
simulatedCourses.has("計算機圖學") ? {name: "計算機圖學", credits: 3, completed: true, isNew: true} : {name: "計算機圖學", credits: 3, note: "114-2修課中"},
simulatedCourses.has("影像處理") ? {name: "影像處理", credits: 3, completed: true, isNew: true} : {name: "影像處理", credits: 3, note: "114-2修課中"},
{name: "人工智慧", credits: 3, available: true}
]
}
]
},
{
name: "系選修",
credits: 6,
earned: totalDeptElective,
courses: [
{name: "類比數位運算元件實驗", grade: "A-", semester: "112-1", credits: 1, completed: true},
{name: "網路計算與XML", grade: "B", semester: "114-1", credits: 3, completed: true},
{name: "語音處理", grade: "A-", semester: "114-1", credits: 3, completed: true},
simulatedCourses.has("資安攻防演練") ? {name: "資安攻防演練", credits: 3, completed: true, isNew: true} : {name: "資安攻防演練", credits: 3, note: "114-2修課中"},
{name: "數值方法", credits: 3, available: true},
{name: "工程數學", credits: 3, available: true},
{name: "數理統計", credits: 3, available: true},
{name: "函數語言程式設計", credits: 3, available: true},
{name: "進階程式設計", credits: 3, available: true}
]
}
]
},
{
name: "四、自由選修",
credits: 27,
earned: 16 + extraGeneralCredits,
courses: [
{name: "大數據分析導論", grade: "A-", semester: "111-1", credits: 3, completed: true},
{name: "核天文物理介紹", grade: "B", semester: "111-1", credits: 2, completed: true},
{name: "中英筆譯(二)", grade: "B-", semester: "111-1", credits: 3, completed: true},
{name: "組織心理學", grade: "A", semester: "113-暑", credits: 2, completed: true},
{name: "自我覺察與成長", grade: "A", semester: "113-暑", credits: 2, completed: true},
{name: "數學產業實習", grade: "A", semester: "112-2", credits: 2, completed: true},
{name: "英語文職場實習", grade: "A+", semester: "112-2", credits: 2, completed: true},
extraGeneralCredits > 0 ? {name: "通識超修學分", credits: extraGeneralCredits, completed: true, isNew: true, detail: "博雅或跨域超過18學分的部分"} : null,
16 + extraGeneralCredits >= 27 ? null : {name: "還需選修", credits: 27 - 16 - extraGeneralCredits}
].filter(Boolean)
}
]
};
}
// 渲染課程
function renderCurrentCourses() {
const desktopContainer = document.getElementById('current-courses');
const mobileContainer = document.getElementById('mobile-courses');
const createCourseItem = (course, isMobile = false) => {
const div = document.createElement('div');
div.className = 'course-item-sim' + (isMobile ? ' mobile-course-item' : '');
if (simulatedCourses.has(course.name)) {
div.classList.add('selected');
}
div.onclick = (e) => {
if (e.target.type !== 'checkbox') {
toggleCourse(course.name);
}
};
div.innerHTML = `
<input type="checkbox" ${simulatedCourses.has(course.name) ? 'checked' : ''}
onchange="toggleCourse('${course.name}')">
<span class="course-name-sim">${course.name}</span>
<span class="course-credits-sim">${course.credits}學分</span>
`;
return div;
};
desktopContainer.innerHTML = '';
mobileContainer.innerHTML = '';
currentSemesterCourses.forEach(course => {
desktopContainer.appendChild(createCourseItem(course, false));
mobileContainer.appendChild(createCourseItem(course, true));
});
}
// 切換課程選擇
function toggleCourse(courseName) {
if (simulatedCourses.has(courseName)) {
simulatedCourses.delete(courseName);
} else {
simulatedCourses.add(courseName);
}
renderCurrentCourses();
updateSelectAllBtnState();
updateTreeRealtime();
}
function toggleAllCourses() {
const allSelected = simulatedCourses.size === currentSemesterCourses.length;
if (allSelected) {
simulatedCourses.clear();
} else {
currentSemesterCourses.forEach(c => simulatedCourses.add(c.name));
}
renderCurrentCourses();
updateSelectAllBtnState();
updateTreeRealtime();
// v1.12.0: bounce animation pulse on toggle for more obvious feedback
['select-all-btn', 'mobile-select-all-btn'].forEach(id => {
const btn = document.getElementById(id);
if (!btn) return;
btn.classList.remove('toggling');
void btn.offsetWidth; // force reflow so animation restarts
btn.classList.add('toggling');
setTimeout(() => btn.classList.remove('toggling'), 450);
});
}
function updateSelectAllBtnState() {
const allSelected = simulatedCourses.size === currentSemesterCourses.length;
const text = allSelected ? '取消全選' : '全選';
['select-all-btn', 'mobile-select-all-btn'].forEach(id => {
const btn = document.getElementById(id);
if (!btn) return;
btn.textContent = text;
btn.classList.toggle('selected', allSelected);
});
}
// ===== Changelog =====
// 'theme' 標記:只有引入新視覺風格的版本才有切換按鈕
// 後續若有純 CSS 視覺改動的版本,加上 theme: '<theme-id>' 即可
// Markup: **bold**、__underline__、*italic*(renderMarkup 解析)
const CHANGELOG = [
{
version: 'v2.0.3',
date: '2026-05-20',
type: 'patch',
changes: [
'__修正裝成 App 後離線「無法連線」__:Cloudflare Pages 把 `/index.html` 308 重導向到 `/`,service worker `cache.addAll()` 抓到 redirected response 會整批 reject → SW 安裝失敗 → 離線全掛',
'**改用正規 URL**:manifest `start_url` 改 `./`(原 `./index.html`);SW 預快取清單拿掉 `./index.html` 只留 `./`;離線 fallback 也改 `./`',
'**SW 安裝改 `Promise.allSettled`**:單一檔案抓取失敗不再拖垮整個安裝,離線快取更穩固'
]
},
{
version: 'v2.0.2',
date: '2026-05-20',
type: 'patch',
changes: [
'**手機 header 標題縮字**:11 字標題在手機直向會斷兩行 → ≤600px 縮到 *25px*、≤380px 縮到 *22px*,維持單行',
'**滾動 sticky bar 改 2 排式(≤780px)**:標題列(標題 + 字級 slider)+ 數據列(4 格)。__標題與 slider 不再隱藏__ — 直向觀看標題一樣會併入 bar(之前只有橫向會)',
'__修正手機滾動壓縮卡頓__:`.stats` / `.stat-item` / `.stat-number` 的 *font-size / padding* 過渡會逐幀 reflow,手機上(≤780px)關掉這些過渡,狀態切換改瞬間完成',
'字級 slider 在手機直向恢復顯示(v2.0.1 曾因擠不下而隱藏,改 2 排後有空間了)'
]
},
{
version: 'v2.0.1',
date: '2026-05-20',
type: 'patch',
changes: [
'__修正窄螢幕 CJK 文字逐字直排 bug__:`.stat-number` / `.stat-label` 加上 *white-space: nowrap*,文字不再因空間不足而一字一行變直式',
'**修好失效的響應式 media query**:舊的 `@media` 寫 `.stats { grid-template-columns }`,但 `.stats` 自 v1.12 起是 *flex* 不是 grid → 整段失效。改成對真正的 grid 容器 `.stats-grid` 做 RWD',
'**窄螢幕 stats 4 格 → 2 格**(≤640px)、字體分級縮小(≤640px / ≤430px)',
'**滾動 sticky bar 窄螢幕處理**:≤820px 藏標題(header 已有完整標題)、縮字縮距;≤430px 連字體 slider 也收起,只留 4 格數據',
'*style.css* / *app.js* 連結加版本 query(`?v=2.0.1`)做 cache-busting,改版時瀏覽器確實抓新檔'
]
},
{
version: 'v2.0.0',
date: '2026-05-20',
type: 'major',
changes: [
'__架構大改:單檔 → 多檔__。原本 3,382 行的單一 index.html 拆成 *index.html*(markup)+ *style.css*(樣式)+ *app.js*(邏輯)三個檔案,純機械式分離、行為 100% 不變',
'**PWA 支援**:新增 *manifest.webmanifest* + *sw.js*(service worker)+ *icon.svg*,變成可安裝的 Progressive Web App',
'**可安裝**:桌面 / 手機都能「加到主畫面」,開啟後是獨立視窗、無瀏覽器網址列,像原生 App',
'**離線可用**:service worker 用 cache-first 策略快取 index.html / style.css / app.js / 字體,沒網路也能開',
'*app.js* 用一般 `<script>` 載入(非 module),確保 HTML 裡的 inline onclick handler 仍能解析全域函式 — 拆檔不破壞結構的關鍵',
'檔案放在 *app/* 資料夾,根目錄舊版單檔 index.html 保留不動'
]
},
{
version: 'v1.18.0',
date: '2026-05-20',
type: 'minor',
theme: 'current',
changes: [
'**Header bg 改全透明**:跟 footer 一樣 *transparent*;container 上方 95-132px 加 transparent 過渡到 white zone,body 紫色 gradient 從 header 區域顯現',
'**Blur 過渡延伸到 4 stats 上緣**:container 的紫→白 fade 在 *110px (50%)* → *120px (85%)* → *132px (純白)* — 4 stats 上緣 (約 120px) 剛好在過渡尾端',
'**白色卡片邊緣感加強**:*.stat-item*、*.simulator-panel*、*.tree-header*、*.node-content* 的 border *1px → 1.5px*、alpha *0.5→0.7-0.8*、加多層 box-shadow 製造立體感',
'**頁尾縮小**:footer padding *40/25/50 → 18/25/24*(垂直 90px → 42px);font-size *13→12px*;subtitle margin-top *8→5px*、font-size *12→11px*'
]
},
{
version: 'v1.17.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**Course section 真正的 expand/collapse 動畫**:refactor *renderCourseList* — 每個 section(新增課程 / 本學期修課中 / 可選課程 / 已修)包進 *.course-section* wrapper,用 *grid-template-rows 0fr→1fr* 過渡(跟 tree-children 一樣的展開動畫)',
'**walkUpdate diff-update sections**:取代原本 *innerDiv.textContent=\'\'* + rebuild,改用 *syncCourseSections* 比對 section keys。新 section 進場展開、舊 section 退場收合、保留 section 內容 snap 更新',
'__移除 v1.16 fade-in flicker keyframe__(被新展開動畫取代)',
'**Header bg 改回 gradient**:solid *#764ba2* (user 覺得偏藍) → *linear-gradient(135deg, #667eea, #764ba2)*,跟 body 一致',
'**Header 高度回到 v1.7 比例**:padding *35px 30px 45px* → *25px 30px 28px*;h1 字體 *36px w900* → *32px w800*;emoji *38px* → *34px*;mask fade *22px* → *14px*',
'**字體大小 slider 取代 A 按鈕**:footer 的 *A / A+ / A++* cycle button 拿掉,改用 sticky bar 右側 *滑桿*(連續 0.85x–1.35x,step 0.05)。__僅滾動時顯示__;用 *body.style.zoom* 直接控制;localStorage \'font-zoom\' 記住',
'**Slider 樣式**:白色 thumb + 紫色 *#6d28d9* 邊框 + 紫色填充軌道;左右各一個 *A* 字 icon(小→大)標示方向'
]
},
{
version: 'v1.16.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**頁尾漸層 smoother**:container 底部 fade 從 2-stop linear 改用 6-stop ease 過渡,fade zone 拉長到 *280px*(原 220px)',
'**Sticky bar title 字體**改用 *Noto Serif TC w900*,跟 header 同款(原 Microsoft JhengHei)',
'**Header 紫色化**:*#667eea* (偏藍 indigo) → __#764ba2__(更明確的紫色)',
'**Changelog 移除 emojis**,改用 markup parser 解析 **bold**、__底線__、*斜體*',
'**新增課程 / 本學期修課中動畫**:walkUpdate 重建 leaf 內 course list 時,所有 *li* 觸發 *courseEnter* fade-in (translateY -4px → 0)',
'**字體大小切換**:footer 加 *A / A+ / A++* 按鈕,cycle 三種大小(zoom *1 / 1.1 / 1.2*),__localStorage 記住__',
'*Simulator panel* mask 起點 calc(100%-190px) → *calc(100%-250px)*;*tree-panel* 90px → *150px*,對齊新的 280px container fade'
]
},
{
version: 'v1.15.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**Header 重設計**參考 *v1.7*:solid 紫色 *#667eea* 純底色 + 白字(不用漸層)',
'**Header 字體**用 *Noto Serif TC w900 36px*(保留 v1.12 試的好看襯線字)',
'紫色 header 與白色內容的交接處用 *mask-image* fade 出 **blur 效果**(22px 漸層透明,不是整個漸層)',
'**Sticky bar 4 格中間加 | 分隔線**(border-right 1px 淡紫)',
'Sticky bar 4 格位置在 title 以外的空白**置中**(stats-grid *flex:1 + justify-center*)',
'*Simulator panel* mask 漸層起點調整為 *calc(100% - 190px)* 對齊 container 底部 fade 起點',
'*Tree panel* mask 漸層起點調整為 *calc(100% - 90px)* 對齊 container 底部 fade 起點',
'__v1.14.0 theme tag 拿掉__(其視覺已被 v1.15 取代);v1.15.0 接手 *theme: current*'
]
},
{
version: 'v1.14.0',
date: '2026-05-20',
type: 'minor',
changes: [
'__Revert v1.12 fancy serif 實驗__:header h1 改回 *Microsoft JhengHei 32px* white 純文字(拿掉 gradient text / glow / Noto Serif TC)',
'__Revert v1.12 紫色 sticky bar__:scrolled bar 改回 v1.10 風格白玻璃 + 紫色 stat numbers + 灰色 labels',
'__Revert v1.13 「無紫色頂部」__:container 恢復上方透明 zone,body 紫色 gradient 從頂部露出(白字 header on purple body)',
'__Revert v1.12 header padding__:55px→35px (compact,回到 v1.10 高度)',
'**Sticky bar title** 改紫色 (*#4f46e5*)、字體 *Microsoft JhengHei 18px* (配合白玻璃 bar)',
'**移除 progress-bar 殘留 CSS + JS**(v1.13 拿掉 HTML 元素時遺漏的)',
'保留 v1.12 的所有 features:*title morph、stat 對齊修復、star bumps、tree mask、theme transition、toggle bounce*',
'__v1.12.0 theme tag 拿掉__(其視覺實驗已 revert,不再保留為 reachable theme)'
]
},
{
version: 'v1.13.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**拿掉上方紫色漸層**:container 從頂部就是白底,只在 footer 區域才漸層透出紫色',
'**Header h1 gradient** 改用深紫色 (*#4f46e5 → #6d28d9 → #7c3aed*) 配合白底有對比',
'**Sticky bar 加大**:title 字體 *16→21px*、stat-number *17→20px*、stat-label *12→14px*',
'拿掉 sticky bar 內的 cell **垂直分隔線**,4 格融入 bar 為連續整體',
'**移除「已修學分」格下方的小 progress-bar**',
'**全螢幕按鈕加入 bounce 動畫**(含 rotate 變化),按下時視覺更明顯',
'**Theme 拆分**:v1.10.0 → 「v1-10」獨立 theme(透明 header、白玻璃 scrolled bar、無 title morph);v1.12.0 → 「current」(latest)'
]
},
{
version: 'v1.12.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**Header 重設計**:紫色 bar 拿掉、星空當背景、*Noto Serif TC* 學術襯線字體 + 紫色 gradient text + 多層 glow(__v1.14 已 revert__)',
'**Sticky bar 滾動形變**:滾動時標題縮入 bar 靠左、4 stats 靠右、整條 bar 變紫色漸層、字色翻白',
'**修正第一格 stat-item 內容上下對齊**(progress-bar 改 *absolute*、不再擠壓 flex centering)',
'**星空 +10% 數量** (*130→143*) + **+20% 亮度** (canvas opacity *0.7→0.84*)',
'*Tree panel* 與 *simulator panel* 底部 **mask fade**,向下滾動時白色 block 邊界自然溶入紫色頁尾',
'**Theme 切換**加入 0.45s smooth transition(bg、color、border、padding 都會緩動)',
'**「全選 ↔ 取消全選」按鈕**新增 *toggle bounce* 動畫,狀態切換視覺更明顯',
'**Classic theme 同步更新**:h1 在 classic 退回 v1.7 白字樣式、scrolled bar 維持原本白玻璃'
]
},
{
version: 'v1.11.0',
date: '2026-05-20',
type: 'minor',
changes: [
'**Changelog modal** 加入「切換到此樣式」按鈕,可即時在不同視覺版本間切換',
'選擇用 *localStorage* 記住,重新整理後保留設定',
'規則:__只有引入新視覺風格的版本__顯示切換按鈕(v1.10.0 → current,v1.7.0 → classic)'
]
},
{
version: 'v1.10.0',
date: '2026-05-20',
type: 'minor',
theme: 'v1-10',
changes: [
'**按鈕設計改版**:「全選」改 *outline + glass*,「全螢幕」改 *ghost* 風格',
'**Header 與 Footer 改為透明**,融入紫色漸層 + 星空背景',
'**Container 改為上下紫色帶** + 中間白色內容區,消除視覺斷層',
'星空 canvas opacity 從 *0.6* 提升至 *0.7*',
'「取消全選」狀態改用 *amber* 而非 red,視覺更柔和'
]
},
{
version: 'v1.9.0',
date: '2026-05-20',
type: 'minor',
changes: [
'樹狀圖改用 diff-update:保留 DOM 結構不重建,只更新數字、狀態與展開類別',
'勾選或取消課程後,已完成的節點會像手動點箭頭一樣平滑收合(grid-template-rows 過渡)',
'層級式收合:當勾選課程讓「校共同必修」也滿足時,子節點與父節點會一起依層級收上去',
'抽出 renderCourseList 與 buildCourseDetail 兩個 helper,移除原本 inline 的 innerHTML 寫法'
]
},
{
version: 'v1.8.1',
date: '2026-05-19',
type: 'patch',
changes: [
'優化滾動收合動畫:移除 transition: all 並指定具體屬性,避免 display/box-shadow/border 等不可動畫屬性造成的閃跳',
'統一 stat-item 為 flex 布局(column → row),淡出邊框與背景而非瞬間移除',
'樹狀圖更新加入整體淡入淡出過渡,緩解砍掉重建造成的閃跳感'
]
},
{
version: 'v1.8.0',
date: '2026-05-19',
type: 'minor',
changes: [
'更新至 114-2 學期,新增本學期 9 門課程',
'通識分類修正(依 NTNU 官方分類)',
'加入英文 6 學分抵免',
'加入「全選 / 取消全選」按鈕',
'課程列表依學分與必修優先順序排序',
'加入版本變動記錄'
]
},
{
version: 'v1.7.0',
date: '2026-05-19',
type: 'major',
theme: 'classic',
changes: [
'從 React 重構嘗試 pivot 回 v1.7 單檔架構',
'以 v1.7 為新基線重啟開發',
'此版本的視覺風格(solid 紫色 header、實心按鈕、灰底 footer)即為 classic theme 基準'
]
}
];
let changelogOpen = false;
function openChangelog() {
renderChangelog();
document.getElementById('changelog-overlay').classList.add('active');
document.getElementById('changelog-modal').classList.add('active');
changelogOpen = true;
}
function closeChangelog() {
document.getElementById('changelog-overlay').classList.remove('active');
document.getElementById('changelog-modal').classList.remove('active');
changelogOpen = false;
}
// v1.16.0: Markdown-lite parser for changelog entries
// Supports **bold**, __underline__, *italic*. Nested tags not supported.
function renderMarkup(text, container) {
let i = 0;
const markers = ['**', '__', '*'];
while (i < text.length) {
let matched = false;
// Try **bold** first (must come before *italic*)
if (text.startsWith('**', i)) {
const end = text.indexOf('**', i + 2);
if (end !== -1) {
const el = document.createElement('strong');
el.textContent = text.substring(i + 2, end);
container.appendChild(el);
i = end + 2;
matched = true;
}
}
// Try __underline__
if (!matched && text.startsWith('__', i)) {
const end = text.indexOf('__', i + 2);
if (end !== -1) {
const el = document.createElement('u');
el.textContent = text.substring(i + 2, end);
container.appendChild(el);
i = end + 2;
matched = true;
}
}
// Try *italic* (single asterisk, not part of **)
if (!matched && text[i] === '*' && text[i + 1] !== '*') {
const end = text.indexOf('*', i + 1);
if (end !== -1) {
const el = document.createElement('em');
el.textContent = text.substring(i + 1, end);
container.appendChild(el);
i = end + 1;
matched = true;
}
}
if (!matched) {
// Plain text until next marker
let nextMarker = text.length;
for (const m of markers) {
const idx = text.indexOf(m, i + 1);
if (idx !== -1 && idx < nextMarker) nextMarker = idx;
}
if (nextMarker > i) {
container.appendChild(document.createTextNode(text.substring(i, nextMarker)));
i = nextMarker;
} else {
// Defensive: advance one char
container.appendChild(document.createTextNode(text[i]));
i++;
}
}
}
}
function renderChangelog() {
const container = document.getElementById('changelog-content');
container.textContent = '';
const activeTheme = getCurrentTheme();
CHANGELOG.forEach(entry => {
const entryDiv = document.createElement('div');
entryDiv.className = 'changelog-entry';
const versionRow = document.createElement('div');
versionRow.className = 'changelog-version-row';
const verSpan = document.createElement('span');
verSpan.className = 'changelog-version';
verSpan.textContent = entry.version;
const dateSpan = document.createElement('span');
dateSpan.className = 'changelog-date';
dateSpan.textContent = entry.date;
const tagSpan = document.createElement('span');
tagSpan.className = 'changelog-tag ' + entry.type;
tagSpan.textContent = entry.type.toUpperCase();
versionRow.append(verSpan, dateSpan, tagSpan);
const ul = document.createElement('ul');
ul.className = 'changelog-list';
entry.changes.forEach(c => {
const li = document.createElement('li');
renderMarkup(c, li);
ul.appendChild(li);
});
entryDiv.append(versionRow, ul);
// 只有引入新視覺風格的版本顯示切換按鈕
if (entry.theme) {
const btn = document.createElement('button');
btn.className = 'theme-switch-btn';
const isActive = entry.theme === activeTheme;
btn.textContent = isActive ? '📍 已套用此樣式' : '切換到此樣式 →';
if (isActive) btn.classList.add('active');
btn.onclick = () => {
setTheme(entry.theme);
renderChangelog();
};
entryDiv.appendChild(btn);
const clearDiv = document.createElement('div');
clearDiv.style.clear = 'both';
entryDiv.appendChild(clearDiv);
}
container.appendChild(entryDiv);
});
}
// ===== v1.11.0: Theme Switching =====
function getCurrentTheme() {
return localStorage.getItem('course-theme') || 'current';
}
function setTheme(themeName) {
document.body.classList.remove('theme-classic', 'theme-v1-10');
if (themeName === 'classic') {
document.body.classList.add('theme-classic');
} else if (themeName === 'v1-10') {
document.body.classList.add('theme-v1-10');
}
localStorage.setItem('course-theme', themeName);
}
// ===== v1.17.0: Font Size Slider (replaces v1.16 cycle button) =====
const FONT_ZOOM_MIN = 0.85;
const FONT_ZOOM_MAX = 1.35;
function setFontZoom(zoom) {
zoom = Math.max(FONT_ZOOM_MIN, Math.min(FONT_ZOOM_MAX, zoom));
document.body.style.zoom = zoom;
localStorage.setItem('font-zoom', String(zoom));
const slider = document.getElementById('font-size-slider');
if (slider) {
if (parseFloat(slider.value) !== zoom) slider.value = zoom;
const fillPct = ((zoom - FONT_ZOOM_MIN) / (FONT_ZOOM_MAX - FONT_ZOOM_MIN)) * 100;
slider.style.setProperty('--slider-fill', fillPct + '%');
}
}
function initFontSize() {
const saved = parseFloat(localStorage.getItem('font-zoom'));
const zoom = (Number.isFinite(saved) && saved >= FONT_ZOOM_MIN && saved <= FONT_ZOOM_MAX) ? saved : 1.0;
setFontZoom(zoom);
}
function initTheme() {
setTheme(getCurrentTheme());
}
// 實時更新
function updateTreeRealtime() {
let additionalCredits = 0;
simulatedCourses.forEach(courseName => {
const course = currentSemesterCourses.find(c => c.name === courseName);
if (course) additionalCredits += course.credits;
});
const newTotal = 94 + additionalCredits;
const newPercentage = (newTotal / 128 * 100).toFixed(1);
const newRemaining = 128 - newTotal;
document.querySelectorAll('.stat-item').forEach(item => item.classList.add('highlight'));
setTimeout(() => {
document.querySelectorAll('.stat-item').forEach(item => item.classList.remove('highlight'));
}, 500);
animateNumber('total-earned', currentEarned, newTotal, 600);
animateNumber('remaining', currentRemaining, newRemaining, 600);
animatePercentage('completion', currentPercentage, parseFloat(newPercentage), 600);
// v1.13.0: progress-bar 元素已移除(user 不再要這個視覺)
currentEarned = newTotal;
currentRemaining = newRemaining;
currentPercentage = parseFloat(newPercentage);
setTimeout(() => {
const treeRoot = document.getElementById('tree-root');
walkUpdate(treeRoot, getTreeData());
}, 100);
}
function animateNumber(id, start, end, duration) {
const element = document.getElementById(id);
const range = end - start;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(start + range * eased);
element.textContent = current;
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
function animatePercentage(id, start, end, duration) {
const element = document.getElementById(id);
const range = end - start;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = start + range * eased;
element.textContent = current.toFixed(1) + '%';
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
function calculateStatus(node) {
if (node.children) {
node.children.forEach(child => calculateStatus(child));
}
const earned = node.earned || 0;
const required = node.credits || 0;
if (earned === 0) {
node.status = 'incomplete';
} else if (earned >= required) {
node.status = 'completed';
} else {
node.status = 'partial';
}
}
function shouldAutoExpand(node) {
return node.status === 'incomplete' || node.status === 'partial';
}
// 切換已修課程展開
function toggleCompletedCourses(summaryEl) {
const listEl = summaryEl.nextElementSibling;
const toggleIcon = summaryEl.querySelector('.completed-summary-toggle');
listEl.classList.toggle('show');
toggleIcon.classList.toggle('expanded');
}
function buildCourseDetail(course, variant) {
const courseDiv = document.createElement('div');
courseDiv.className = 'course-detail' + (variant === 'completed' ? ' completed'
: variant === 'new' ? ' new'
: variant === 'available' ? ' available' : '');
if (variant === 'incomplete') {
courseDiv.style.background = 'linear-gradient(135deg, #fee2e2, #fecaca)';
courseDiv.style.borderLeftColor = '#ef4444';
}
const nameSpan = document.createElement('span');
nameSpan.textContent = course.name;
courseDiv.appendChild(nameSpan);
if (variant === 'available') {
const creditsSpan = document.createElement('span');
creditsSpan.textContent = `${course.credits}學分`;
courseDiv.appendChild(creditsSpan);
return courseDiv;
}
const info = document.createElement('div');
info.className = 'course-info';
const creditsSpan = document.createElement('span');
creditsSpan.textContent = `${course.credits}學分`;
info.appendChild(creditsSpan);
if (course.grade) {
const gradeTag = document.createElement('span');