@jjlmoya/utils-astronomy 1.28.0 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +29 -29
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +144 -143
  7. package/src/tool/meteorShowerObservingPlanner/bibliography.astro +14 -0
  8. package/src/tool/meteorShowerObservingPlanner/bibliography.ts +12 -0
  9. package/src/tool/meteorShowerObservingPlanner/component.astro +165 -0
  10. package/src/tool/meteorShowerObservingPlanner/controller.ts +178 -0
  11. package/src/tool/meteorShowerObservingPlanner/dom-views.ts +187 -0
  12. package/src/tool/meteorShowerObservingPlanner/entry.ts +29 -0
  13. package/src/tool/meteorShowerObservingPlanner/evaluator.ts +20 -0
  14. package/src/tool/meteorShowerObservingPlanner/i18n/de.ts +219 -0
  15. package/src/tool/meteorShowerObservingPlanner/i18n/en.ts +219 -0
  16. package/src/tool/meteorShowerObservingPlanner/i18n/es.ts +219 -0
  17. package/src/tool/meteorShowerObservingPlanner/i18n/fr.ts +219 -0
  18. package/src/tool/meteorShowerObservingPlanner/i18n/id.ts +223 -0
  19. package/src/tool/meteorShowerObservingPlanner/i18n/it.ts +223 -0
  20. package/src/tool/meteorShowerObservingPlanner/i18n/ja.ts +219 -0
  21. package/src/tool/meteorShowerObservingPlanner/i18n/ko.ts +219 -0
  22. package/src/tool/meteorShowerObservingPlanner/i18n/nl.ts +223 -0
  23. package/src/tool/meteorShowerObservingPlanner/i18n/pl.ts +223 -0
  24. package/src/tool/meteorShowerObservingPlanner/i18n/pt.ts +223 -0
  25. package/src/tool/meteorShowerObservingPlanner/i18n/ru.ts +219 -0
  26. package/src/tool/meteorShowerObservingPlanner/i18n/sv.ts +223 -0
  27. package/src/tool/meteorShowerObservingPlanner/i18n/tr.ts +223 -0
  28. package/src/tool/meteorShowerObservingPlanner/i18n/zh.ts +219 -0
  29. package/src/tool/meteorShowerObservingPlanner/index.ts +11 -0
  30. package/src/tool/meteorShowerObservingPlanner/logic.test.ts +117 -0
  31. package/src/tool/meteorShowerObservingPlanner/logic.ts +263 -0
  32. package/src/tool/meteorShowerObservingPlanner/meteor-shower-observing-planner.css +404 -0
  33. package/src/tool/meteorShowerObservingPlanner/seo.astro +14 -0
  34. package/src/tool/meteorShowerObservingPlanner/storage.ts +41 -0
  35. package/src/tool/meteorShowerObservingPlanner/ui.ts +87 -0
  36. package/src/tool/starExposureCalculator/index.ts +3 -0
  37. package/src/tool/telescopeResolution/index.ts +3 -0
  38. package/src/tools.ts +4 -0
@@ -0,0 +1,263 @@
1
+ import type { ShowerPreset, ObservingPlannerInput, HourlyCalculation, EvaluationResult, MeteorShowerObservingPlannerUI } from './ui';
2
+
3
+ export const METEOR_SHOWERS: ShowerPreset[] = [
4
+ {
5
+ id: 'perseids',
6
+ name: 'Perseids',
7
+ peakMonth: 8,
8
+ peakDay: 12,
9
+ zhr: 100,
10
+ radiantConstellation: 'Perseus',
11
+ velocityKms: 59,
12
+ parentBody: '109P/Swift-Tuttle',
13
+ },
14
+ {
15
+ id: 'geminids',
16
+ name: 'Geminids',
17
+ peakMonth: 12,
18
+ peakDay: 14,
19
+ zhr: 120,
20
+ radiantConstellation: 'Gemini',
21
+ velocityKms: 35,
22
+ parentBody: '3200 Phaethon',
23
+ },
24
+ {
25
+ id: 'quadrantids',
26
+ name: 'Quadrantids',
27
+ peakMonth: 1,
28
+ peakDay: 4,
29
+ zhr: 110,
30
+ radiantConstellation: 'Bootes',
31
+ velocityKms: 41,
32
+ parentBody: '2003 EH1',
33
+ },
34
+ {
35
+ id: 'lyrids',
36
+ name: 'Lyrids',
37
+ peakMonth: 4,
38
+ peakDay: 22,
39
+ zhr: 18,
40
+ radiantConstellation: 'Lyra',
41
+ velocityKms: 49,
42
+ parentBody: 'C/1861 G1 Thatcher',
43
+ },
44
+ {
45
+ id: 'orionids',
46
+ name: 'Orionids',
47
+ peakMonth: 10,
48
+ peakDay: 21,
49
+ zhr: 20,
50
+ radiantConstellation: 'Orion',
51
+ velocityKms: 66,
52
+ parentBody: '1P/Halley',
53
+ },
54
+ {
55
+ id: 'eta-aquariids',
56
+ name: 'Eta Aquariids',
57
+ peakMonth: 5,
58
+ peakDay: 6,
59
+ zhr: 50,
60
+ radiantConstellation: 'Aquarius',
61
+ velocityKms: 66,
62
+ parentBody: '1P/Halley',
63
+ },
64
+ {
65
+ id: 'leonids',
66
+ name: 'Leonids',
67
+ peakMonth: 11,
68
+ peakDay: 17,
69
+ zhr: 15,
70
+ radiantConstellation: 'Leo',
71
+ velocityKms: 71,
72
+ parentBody: '55P/Tempel-Tuttle',
73
+ },
74
+ {
75
+ id: 'custom',
76
+ name: 'Custom Shower',
77
+ peakMonth: 1,
78
+ peakDay: 1,
79
+ zhr: 50,
80
+ radiantConstellation: 'Zenith',
81
+ velocityKms: 50,
82
+ parentBody: 'Unknown Comet',
83
+ },
84
+ ];
85
+
86
+ export function getShowerById(id: string): ShowerPreset {
87
+ const found = METEOR_SHOWERS.find((s) => s.id === id);
88
+ return found || METEOR_SHOWERS[0];
89
+ }
90
+
91
+ export function calculateRadiantElevation(hour: number, latitude: number, showerId: string): number {
92
+ const shower = getShowerById(showerId);
93
+ const hourNormalized = (hour + 24) % 24;
94
+ const isNorthern = latitude >= 0;
95
+
96
+ let peakHour = 3;
97
+ if (shower.id === 'eta-aquariids') {
98
+ peakHour = 4;
99
+ } else if (shower.id === 'lyrids' || shower.id === 'orionids') {
100
+ peakHour = 2;
101
+ }
102
+
103
+ const hoursFromPeak = Math.abs(((hourNormalized - peakHour + 12) % 24) - 12);
104
+ const basePeakAltitude = isNorthern ? 65 : 35;
105
+ const latitudeFactor = Math.cos(((latitude - 35) * Math.PI) / 180);
106
+ const rawAltitude = basePeakAltitude * latitudeFactor - hoursFromPeak * 8;
107
+
108
+ return Math.max(0, Math.min(90, Math.round(rawAltitude)));
109
+ }
110
+
111
+ export function calculateMoonInterference(moonPhase: number): number {
112
+ const normalizedPhase = Math.min(1, Math.max(0, moonPhase));
113
+ const sineFactor = Math.sin(normalizedPhase * Math.PI);
114
+ return Math.round(sineFactor * 100);
115
+ }
116
+
117
+ export function calculateEffectiveZHR(
118
+ zhr: number,
119
+ radiantElevation: number,
120
+ bortleClass: number,
121
+ moonPhase: number
122
+ ): number {
123
+ if (radiantElevation <= 0) {
124
+ return 0;
125
+ }
126
+
127
+ const elevationFactor = Math.sin((radiantElevation * Math.PI) / 180);
128
+ const bortleFactor = Math.max(0.1, 1 - (bortleClass - 1) * 0.1);
129
+ const moonInterference = calculateMoonInterference(moonPhase);
130
+ const moonFactor = Math.max(0.2, 1 - (moonInterference / 100) * 0.6);
131
+
132
+ const effective = zhr * elevationFactor * bortleFactor * moonFactor;
133
+ return Math.max(0, Math.round(effective * 10) / 10);
134
+ }
135
+
136
+ export function formatHourLabel(hour: number): string {
137
+ const normalized = (hour + 24) % 24;
138
+ return `${String(normalized).padStart(2, '0')}:00`;
139
+ }
140
+
141
+ function markPeakHours(list: HourlyCalculation[]): void {
142
+ const maxZhr = Math.max(...list.map((item) => item.effectiveZhr), -1);
143
+ if (maxZhr > 0) {
144
+ list.forEach((item) => {
145
+ if (item.effectiveZhr === maxZhr) {
146
+ item.isPeakHour = true;
147
+ }
148
+ });
149
+ }
150
+ }
151
+
152
+ export function calculateHourlyBreakdown(input: ObservingPlannerInput): HourlyCalculation[] {
153
+ const shower = getShowerById(input.showerId);
154
+ const baseZhr = input.showerId === 'custom' ? input.customZhr : shower.zhr;
155
+ const list: HourlyCalculation[] = [];
156
+
157
+ const start = input.sessionStartHour;
158
+ const end = input.sessionStartHour > input.sessionEndHour ? input.sessionEndHour + 24 : input.sessionEndHour;
159
+
160
+ for (let current = start; current <= end; current++) {
161
+ const actualHour = current % 24;
162
+ const elevation = calculateRadiantElevation(actualHour, input.latitude, input.showerId);
163
+ const effZhr = calculateEffectiveZHR(baseZhr, elevation, input.bortleClass, input.moonPhase);
164
+ const moonPercent = calculateMoonInterference(input.moonPhase);
165
+
166
+ list.push({
167
+ hour: actualHour,
168
+ formattedTime: formatHourLabel(actualHour),
169
+ radiantElevationDeg: elevation,
170
+ effectiveZhr: effZhr,
171
+ qualityScore: Math.min(100, Math.round((effZhr / Math.max(1, baseZhr)) * 100)),
172
+ moonInterferencePercent: moonPercent,
173
+ isPeakHour: false,
174
+ });
175
+ }
176
+
177
+ markPeakHours(list);
178
+ return list;
179
+ }
180
+
181
+ function evaluateDarknessRating(bortle: number, ui: MeteorShowerObservingPlannerUI): string {
182
+ if (bortle <= 3) {
183
+ return ui.bortleDescriptions[2] || 'Dark Sky';
184
+ }
185
+ if (bortle <= 6) {
186
+ return ui.bortleDescriptions[5] || 'Suburban Sky';
187
+ }
188
+ return ui.bortleDescriptions[8] || 'Urban Light Pollution';
189
+ }
190
+
191
+ function evaluateMoonRating(moonPhase: number, ui: MeteorShowerObservingPlannerUI): string {
192
+ const percent = calculateMoonInterference(moonPhase);
193
+ if (percent < 25) {
194
+ return ui.moonPhaseNames['new'] || 'Minimal Moon Impact';
195
+ }
196
+ if (percent < 70) {
197
+ return ui.moonPhaseNames['quarter'] || 'Moderate Moon Glow';
198
+ }
199
+ return ui.moonPhaseNames['full'] || 'Severe Moon Interference';
200
+ }
201
+
202
+ function getBortleBadge(bortleClass: number, bLabels: Record<string, string>): EvaluationResult['badges'][number] | null {
203
+ if (bortleClass <= 3) {
204
+ return { label: bLabels.darkSky || 'Pristine Dark Sky', type: 'success' };
205
+ }
206
+ if (bortleClass >= 7) {
207
+ return { label: bLabels.lightPollution || 'High Light Pollution', type: 'warning' };
208
+ }
209
+ return null;
210
+ }
211
+
212
+ function getMoonBadge(moonPhase: number, bLabels: Record<string, string>): EvaluationResult['badges'][number] {
213
+ const isLow = calculateMoonInterference(moonPhase) < 30;
214
+ return {
215
+ label: isLow ? bLabels.favorableMoon || 'Favorable Moon' : bLabels.moonWashout || 'Moon Washout Risk',
216
+ type: isLow ? 'success' : 'info',
217
+ };
218
+ }
219
+
220
+ function buildSessionBadges(
221
+ input: ObservingPlannerInput,
222
+ ui: MeteorShowerObservingPlannerUI,
223
+ maxRate: number
224
+ ): EvaluationResult['badges'] {
225
+ const badges: EvaluationResult['badges'] = [];
226
+ const bLabels = ui.badgeLabels || {};
227
+
228
+ const bBadge = getBortleBadge(input.bortleClass, bLabels);
229
+ if (bBadge) badges.push(bBadge);
230
+
231
+ badges.push(getMoonBadge(input.moonPhase, bLabels));
232
+
233
+ if (maxRate >= 20) {
234
+ badges.push({ label: bLabels.primeWindow || 'Prime Activity Window', type: 'success' });
235
+ }
236
+
237
+ return badges;
238
+ }
239
+
240
+ export function evaluateObservingSession(
241
+ input: ObservingPlannerInput,
242
+ ui: MeteorShowerObservingPlannerUI
243
+ ): EvaluationResult {
244
+ const hourly = calculateHourlyBreakdown(input);
245
+ const maxRate = Math.max(...hourly.map((h) => h.effectiveZhr), 0);
246
+ const peakItem = hourly.find((h) => h.effectiveZhr === maxRate) || null;
247
+ const totalScore = hourly.reduce((sum, h) => sum + h.qualityScore, 0);
248
+
249
+ const avgScore = hourly.length > 0 ? Math.round(totalScore / hourly.length) : 0;
250
+ const peakTime = peakItem ? peakItem.formattedTime : '23:00';
251
+ const bestEnd = formatHourLabel(peakItem ? (peakItem.hour + 2) % 24 : 1);
252
+
253
+ return {
254
+ overallScore: avgScore,
255
+ bestWindowStart: peakTime,
256
+ bestWindowEnd: bestEnd,
257
+ maxEffectiveRate: maxRate,
258
+ skyDarknessRating: evaluateDarknessRating(input.bortleClass, ui),
259
+ moonImpactRating: evaluateMoonRating(input.moonPhase, ui),
260
+ badges: buildSessionBadges(input, ui, maxRate),
261
+ hourlyBreakdown: hourly,
262
+ };
263
+ }
@@ -0,0 +1,404 @@
1
+ :root {
2
+ --n-card-bg: #ffffff;
3
+ --n-card-border: #e2e8f0;
4
+ --n-text-main: #0f172a;
5
+ --n-text-muted: #475569;
6
+ --n-accent: #4f46e5;
7
+ --n-accent-hover: #4338ca;
8
+ --n-accent-dim: rgba(79, 70, 229, 0.12);
9
+ --n-surface-sub: #f8fafc;
10
+ --n-surface-dark: #0f172a;
11
+ --n-badge-success-bg: #dcfce7;
12
+ --n-badge-success-fg: #14532d;
13
+ --n-badge-warning-bg: #fef3c7;
14
+ --n-badge-warning-fg: #78350f;
15
+ --n-badge-info-bg: #e0e7ff;
16
+ --n-badge-info-fg: #312e81;
17
+ --n-bar-bg: #cbd5e1;
18
+ --n-bar-fill: #4f46e5;
19
+ --n-bar-peak: #e11d48;
20
+ }
21
+
22
+ .theme-dark .meteor-shower-planner-card,
23
+ .meteor-shower-planner-card.theme-dark {
24
+ --n-card-bg: #0f172a;
25
+ --n-card-border: #1e293b;
26
+ --n-text-main: #f8fafc;
27
+ --n-text-muted: #94a3b8;
28
+ --n-accent: #6366f1;
29
+ --n-accent-hover: #818cf8;
30
+ --n-accent-dim: rgba(99, 102, 241, 0.2);
31
+ --n-surface-sub: #1e293b;
32
+ --n-surface-dark: #020617;
33
+ --n-badge-success-bg: rgba(22, 101, 52, 0.35);
34
+ --n-badge-success-fg: #4ade80;
35
+ --n-badge-warning-bg: rgba(146, 64, 14, 0.35);
36
+ --n-badge-warning-fg: #fbbf24;
37
+ --n-badge-info-bg: rgba(55, 48, 163, 0.35);
38
+ --n-badge-info-fg: #a5b4fc;
39
+ --n-bar-bg: #334155;
40
+ --n-bar-fill: #6366f1;
41
+ --n-bar-peak: #fb7185;
42
+ }
43
+
44
+ .meteor-shower-planner-card {
45
+ background: var(--n-card-bg);
46
+ border: 1px solid var(--n-card-border);
47
+ border-radius: 20px;
48
+ padding: 24px;
49
+ color: var(--n-text-main);
50
+ font-family: system-ui, -apple-system, sans-serif;
51
+ max-width: 900px;
52
+ margin: 0 auto;
53
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
54
+ }
55
+
56
+ .planner-layout-grid {
57
+ display: grid;
58
+ grid-template-columns: 1fr 1fr;
59
+ gap: 24px;
60
+ }
61
+
62
+ @media (max-width: 768px) {
63
+ .planner-layout-grid {
64
+ grid-template-columns: 1fr;
65
+ }
66
+ }
67
+
68
+ .planner-control-section {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: 20px;
72
+ }
73
+
74
+ .presets-chip-grid {
75
+ display: flex;
76
+ flex-wrap: wrap;
77
+ gap: 8px;
78
+ }
79
+
80
+ .preset-chip-btn {
81
+ background: var(--n-surface-sub);
82
+ border: 1px solid var(--n-card-border);
83
+ color: var(--n-text-main);
84
+ border-radius: 9999px;
85
+ padding: 6px 14px;
86
+ font-size: 13px;
87
+ font-weight: 500;
88
+ cursor: pointer;
89
+ transition: all 0.2s ease;
90
+ }
91
+
92
+ .preset-chip-btn:hover {
93
+ border-color: var(--n-accent);
94
+ }
95
+
96
+ .preset-chip-btn.active {
97
+ background: var(--n-accent);
98
+ color: #fff;
99
+ border-color: var(--n-accent);
100
+ }
101
+
102
+ .input-control-group {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 6px;
106
+ }
107
+
108
+ .input-label-row {
109
+ display: flex;
110
+ justify-content: space-between;
111
+ align-items: center;
112
+ font-size: 14px;
113
+ font-weight: 600;
114
+ color: var(--n-text-main);
115
+ }
116
+
117
+ .input-val-badge {
118
+ color: var(--n-accent);
119
+ font-weight: 700;
120
+ }
121
+
122
+ .planner-slider-range {
123
+ width: 100%;
124
+ accent-color: var(--n-accent);
125
+ height: 6px;
126
+ border-radius: 4px;
127
+ background: var(--n-bar-bg);
128
+ }
129
+
130
+ .hours-select-row {
131
+ display: flex;
132
+ gap: 12px;
133
+ align-items: center;
134
+ }
135
+
136
+ .hours-sep-txt {
137
+ font-size: 13px;
138
+ font-weight: 500;
139
+ color: var(--n-text-muted);
140
+ }
141
+
142
+ /* Custom Select Dropdown UI */
143
+ .custom-select-wrap {
144
+ position: relative;
145
+ flex: 1;
146
+ }
147
+
148
+ .custom-select-trigger {
149
+ width: 100%;
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: space-between;
153
+ background: var(--n-surface-sub);
154
+ border: 1px solid var(--n-card-border);
155
+ color: var(--n-text-main);
156
+ border-radius: 10px;
157
+ padding: 8px 14px;
158
+ font-size: 14px;
159
+ font-weight: 600;
160
+ cursor: pointer;
161
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
162
+ }
163
+
164
+ .custom-select-trigger:hover,
165
+ .custom-select-trigger[aria-expanded="true"] {
166
+ border-color: var(--n-accent);
167
+ outline: none;
168
+ }
169
+
170
+ .select-arrow-icon {
171
+ color: var(--n-text-muted);
172
+ transition: transform 0.2s ease;
173
+ }
174
+
175
+ .custom-select-trigger[aria-expanded="true"] .select-arrow-icon {
176
+ transform: rotate(180deg);
177
+ }
178
+
179
+ .custom-select-menu {
180
+ position: absolute;
181
+ top: calc(100% + 4px);
182
+ left: 0;
183
+ right: 0;
184
+ z-index: 50;
185
+ background: var(--n-card-bg);
186
+ border: 1px solid var(--n-card-border);
187
+ border-radius: 10px;
188
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15);
189
+ max-height: 200px;
190
+ overflow-y: auto;
191
+ display: flex;
192
+ flex-direction: column;
193
+ padding: 4px;
194
+ }
195
+
196
+ .custom-select-menu[hidden] {
197
+ display: none !important;
198
+ }
199
+
200
+ .custom-select-opt {
201
+ width: 100%;
202
+ text-align: left;
203
+ background: transparent;
204
+ border: none;
205
+ color: var(--n-text-main);
206
+ padding: 8px 12px;
207
+ font-size: 13px;
208
+ font-weight: 500;
209
+ border-radius: 6px;
210
+ cursor: pointer;
211
+ transition: background 0.15s ease;
212
+ }
213
+
214
+ .custom-select-opt:hover {
215
+ background: var(--n-accent-dim);
216
+ color: var(--n-accent);
217
+ }
218
+
219
+ .custom-select-opt.active {
220
+ background: var(--n-accent);
221
+ color: #ffffff;
222
+ }
223
+
224
+ .planner-results-section {
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 20px;
228
+ }
229
+
230
+ .results-metrics-grid {
231
+ display: grid;
232
+ grid-template-columns: repeat(3, 1fr);
233
+ gap: 12px;
234
+ }
235
+
236
+ .metric-card-box {
237
+ background: var(--n-surface-sub);
238
+ border: 1px solid var(--n-card-border);
239
+ border-radius: 12px;
240
+ padding: 12px;
241
+ text-align: center;
242
+ }
243
+
244
+ .metric-lbl-txt {
245
+ font-size: 12px;
246
+ color: var(--n-text-muted);
247
+ font-weight: 500;
248
+ margin-bottom: 4px;
249
+ }
250
+
251
+ .metric-val-txt {
252
+ font-size: 16px;
253
+ font-weight: 700;
254
+ color: var(--n-text-main);
255
+ }
256
+
257
+ .res-score-val.badge-excellent {
258
+ color: #059669;
259
+ }
260
+
261
+ .res-score-val.badge-moderate {
262
+ color: #d97706;
263
+ }
264
+
265
+ .res-score-val.badge-poor {
266
+ color: #dc2626;
267
+ }
268
+
269
+ .res-badges-list {
270
+ display: flex;
271
+ flex-wrap: wrap;
272
+ gap: 8px;
273
+ }
274
+
275
+ .badge {
276
+ padding: 5px 12px;
277
+ border-radius: 9999px;
278
+ font-size: 12px;
279
+ font-weight: 600;
280
+ }
281
+
282
+ .badge-success {
283
+ background: var(--n-badge-success-bg);
284
+ color: var(--n-badge-success-fg);
285
+ }
286
+
287
+ .badge-warning {
288
+ background: var(--n-badge-warning-bg);
289
+ color: var(--n-badge-warning-fg);
290
+ }
291
+
292
+ .badge-info {
293
+ background: var(--n-badge-info-bg);
294
+ color: var(--n-badge-info-fg);
295
+ }
296
+
297
+ .sky-dome-container {
298
+ border-radius: 16px;
299
+ overflow: hidden;
300
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
301
+ }
302
+
303
+ .sky-dome-svg {
304
+ width: 100%;
305
+ height: auto;
306
+ display: block;
307
+ }
308
+
309
+ .hourly-timeline-panel {
310
+ background: var(--n-surface-sub);
311
+ border: 1px solid var(--n-card-border);
312
+ border-radius: 14px;
313
+ padding: 16px;
314
+ }
315
+
316
+ .panel-title-lbl {
317
+ font-size: 14px;
318
+ font-weight: 600;
319
+ color: var(--n-text-main);
320
+ margin-bottom: 12px;
321
+ }
322
+
323
+ .hourly-bars-track {
324
+ display: flex;
325
+ gap: 8px;
326
+ align-items: flex-end;
327
+ height: 120px;
328
+ padding-top: 16px;
329
+ }
330
+
331
+ .hourly-bar-col {
332
+ flex: 1;
333
+ display: flex;
334
+ flex-direction: column;
335
+ align-items: center;
336
+ gap: 4px;
337
+ height: 100%;
338
+ }
339
+
340
+ .bar-val {
341
+ font-size: 11px;
342
+ font-weight: 600;
343
+ color: var(--n-text-main);
344
+ }
345
+
346
+ .bar-track {
347
+ flex: 1;
348
+ width: 100%;
349
+ max-width: 18px;
350
+ background: var(--n-bar-bg);
351
+ border-radius: 4px;
352
+ display: flex;
353
+ align-items: flex-end;
354
+ overflow: hidden;
355
+ }
356
+
357
+ .bar-fill {
358
+ width: 100%;
359
+ background: var(--n-bar-fill);
360
+ border-radius: 4px;
361
+ transition: height 0.3s ease;
362
+ }
363
+
364
+ .hourly-bar-col.is-peak .bar-fill {
365
+ background: var(--n-bar-peak);
366
+ }
367
+
368
+ .bar-label {
369
+ font-size: 10px;
370
+ font-weight: 600;
371
+ color: var(--n-text-muted);
372
+ }
373
+
374
+ .checklist-card-panel {
375
+ background: var(--n-surface-sub);
376
+ border: 1px solid var(--n-card-border);
377
+ border-radius: 14px;
378
+ padding: 16px;
379
+ margin-top: 20px;
380
+ }
381
+
382
+ .checklist-ul {
383
+ list-style: none;
384
+ padding: 0;
385
+ margin: 8px 0 0 0;
386
+ display: flex;
387
+ flex-direction: column;
388
+ gap: 8px;
389
+ }
390
+
391
+ .checklist-li {
392
+ display: flex;
393
+ align-items: center;
394
+ gap: 8px;
395
+ font-size: 13px;
396
+ color: var(--n-text-main);
397
+ }
398
+
399
+ .checklist-icon-bullet {
400
+ width: 8px;
401
+ height: 8px;
402
+ border-radius: 50%;
403
+ background: var(--n-accent);
404
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { meteorShowerObservingPlanner } from './index';
4
+
5
+ interface Props {
6
+ locale: string;
7
+ }
8
+
9
+ const { locale } = Astro.props;
10
+ const entry = await meteorShowerObservingPlanner.i18n[locale as keyof typeof meteorShowerObservingPlanner.i18n]();
11
+ const content = entry();
12
+ ---
13
+
14
+ <SEORenderer locale={locale} sections={content.seo} />
@@ -0,0 +1,41 @@
1
+ import type { ObservingPlannerInput } from './ui';
2
+
3
+ const STORAGE_KEY = 'jjlmoya_astronomy_meteor_planner_v1';
4
+
5
+ export const DEFAULT_INPUT: ObservingPlannerInput = {
6
+ showerId: 'perseids',
7
+ customZhr: 100,
8
+ latitude: 40,
9
+ bortleClass: 4,
10
+ moonPhase: 0.15,
11
+ sessionStartHour: 22,
12
+ sessionEndHour: 5,
13
+ };
14
+
15
+ export function loadSavedInput(): ObservingPlannerInput {
16
+ if (typeof window === 'undefined') {
17
+ return DEFAULT_INPUT;
18
+ }
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY);
21
+ if (!raw) {
22
+ return DEFAULT_INPUT;
23
+ }
24
+ const parsed = JSON.parse(raw);
25
+ return {
26
+ ...DEFAULT_INPUT,
27
+ ...parsed,
28
+ };
29
+ } catch {
30
+ return DEFAULT_INPUT;
31
+ }
32
+ }
33
+
34
+ export function saveInput(input: ObservingPlannerInput): void {
35
+ if (typeof window === 'undefined') {
36
+ return;
37
+ }
38
+ try {
39
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(input));
40
+ } catch {}
41
+ }