@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.
- package/package.json +1 -1
- package/src/category/index.ts +29 -29
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +144 -143
- package/src/tool/meteorShowerObservingPlanner/bibliography.astro +14 -0
- package/src/tool/meteorShowerObservingPlanner/bibliography.ts +12 -0
- package/src/tool/meteorShowerObservingPlanner/component.astro +165 -0
- package/src/tool/meteorShowerObservingPlanner/controller.ts +178 -0
- package/src/tool/meteorShowerObservingPlanner/dom-views.ts +187 -0
- package/src/tool/meteorShowerObservingPlanner/entry.ts +29 -0
- package/src/tool/meteorShowerObservingPlanner/evaluator.ts +20 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/de.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/en.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/es.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/fr.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/id.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/it.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/ja.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/ko.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/nl.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/pl.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/pt.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/ru.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/sv.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/tr.ts +223 -0
- package/src/tool/meteorShowerObservingPlanner/i18n/zh.ts +219 -0
- package/src/tool/meteorShowerObservingPlanner/index.ts +11 -0
- package/src/tool/meteorShowerObservingPlanner/logic.test.ts +117 -0
- package/src/tool/meteorShowerObservingPlanner/logic.ts +263 -0
- package/src/tool/meteorShowerObservingPlanner/meteor-shower-observing-planner.css +404 -0
- package/src/tool/meteorShowerObservingPlanner/seo.astro +14 -0
- package/src/tool/meteorShowerObservingPlanner/storage.ts +41 -0
- package/src/tool/meteorShowerObservingPlanner/ui.ts +87 -0
- package/src/tool/starExposureCalculator/index.ts +3 -0
- package/src/tool/telescopeResolution/index.ts +3 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { ObservingPlannerInput, MeteorShowerObservingPlannerUI, EvaluationResult } from './ui';
|
|
2
|
+
import { evaluateObservingSession } from './logic';
|
|
3
|
+
import { renderSkyDomeSvg, renderHourlyBars } from './dom-views';
|
|
4
|
+
import { getQualityBadgeClass, formatZHRDisplay } from './evaluator';
|
|
5
|
+
import { saveInput } from './storage';
|
|
6
|
+
|
|
7
|
+
interface MenuOptParams {
|
|
8
|
+
opt: HTMLButtonElement;
|
|
9
|
+
label: HTMLElement;
|
|
10
|
+
opts: NodeListOf<HTMLButtonElement>;
|
|
11
|
+
trigger: HTMLButtonElement;
|
|
12
|
+
menu: HTMLElement;
|
|
13
|
+
onSelect: (val: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MeteorShowerPlannerController {
|
|
17
|
+
private input: ObservingPlannerInput;
|
|
18
|
+
private ui: MeteorShowerObservingPlannerUI;
|
|
19
|
+
private rootEl: HTMLElement;
|
|
20
|
+
|
|
21
|
+
constructor(rootEl: HTMLElement, initialInput: ObservingPlannerInput, ui: MeteorShowerObservingPlannerUI) {
|
|
22
|
+
this.rootEl = rootEl;
|
|
23
|
+
this.input = { ...initialInput };
|
|
24
|
+
this.ui = ui;
|
|
25
|
+
this.init();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private init(): void {
|
|
29
|
+
this.bindEvents();
|
|
30
|
+
this.update();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private bindMenuOpt(params: MenuOptParams): void {
|
|
34
|
+
params.opt.addEventListener('click', (e) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
const val = parseInt(params.opt.getAttribute('data-val') || '0', 10);
|
|
37
|
+
params.label.textContent = `${String(val).padStart(2, '0')}:00`;
|
|
38
|
+
params.opts.forEach((o) => o.classList.remove('active'));
|
|
39
|
+
params.opt.classList.add('active');
|
|
40
|
+
params.trigger.setAttribute('aria-expanded', 'false');
|
|
41
|
+
params.menu.setAttribute('hidden', '');
|
|
42
|
+
params.onSelect(val);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private bindCustomSelect(triggerId: string, menuId: string, labelId: string, onSelect: (val: number) => void): void {
|
|
47
|
+
const trigger = this.rootEl.querySelector<HTMLButtonElement>(`#${triggerId}`);
|
|
48
|
+
const menu = this.rootEl.querySelector<HTMLElement>(`#${menuId}`);
|
|
49
|
+
const label = this.rootEl.querySelector<HTMLElement>(`#${labelId}`);
|
|
50
|
+
if (!trigger || !menu || !label) return;
|
|
51
|
+
|
|
52
|
+
trigger.addEventListener('click', (e) => {
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
|
55
|
+
this.closeAllCustomSelects();
|
|
56
|
+
if (!isExpanded) {
|
|
57
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
58
|
+
menu.removeAttribute('hidden');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const opts = menu.querySelectorAll<HTMLButtonElement>('.custom-select-opt');
|
|
63
|
+
opts.forEach((opt) => this.bindMenuOpt({ opt, label, opts, trigger, menu, onSelect }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private closeAllCustomSelects(): void {
|
|
67
|
+
const triggers = this.rootEl.querySelectorAll<HTMLButtonElement>('.custom-select-trigger');
|
|
68
|
+
const menus = this.rootEl.querySelectorAll<HTMLElement>('.custom-select-menu');
|
|
69
|
+
triggers.forEach((t) => t.setAttribute('aria-expanded', 'false'));
|
|
70
|
+
menus.forEach((m) => m.setAttribute('hidden', ''));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private bindPresets(): void {
|
|
74
|
+
const presetBtns = this.rootEl.querySelectorAll<HTMLButtonElement>('.preset-chip-btn');
|
|
75
|
+
presetBtns.forEach((btn) => {
|
|
76
|
+
btn.addEventListener('click', () => {
|
|
77
|
+
const id = btn.getAttribute('data-shower-id');
|
|
78
|
+
if (id) {
|
|
79
|
+
this.input.showerId = id;
|
|
80
|
+
presetBtns.forEach((b) => b.classList.remove('active'));
|
|
81
|
+
btn.classList.add('active');
|
|
82
|
+
const customGroup = this.rootEl.querySelector<HTMLElement>('.custom-zhr-group');
|
|
83
|
+
if (customGroup) customGroup.style.display = id === 'custom' ? 'block' : 'none';
|
|
84
|
+
this.update();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private bindSliders(): void {
|
|
91
|
+
const lat = this.rootEl.querySelector<HTMLInputElement>('#planner-latitude-input');
|
|
92
|
+
if (lat) {
|
|
93
|
+
lat.addEventListener('input', (e) => {
|
|
94
|
+
this.input.latitude = parseFloat((e.target as HTMLInputElement).value);
|
|
95
|
+
const disp = this.rootEl.querySelector('#planner-latitude-val');
|
|
96
|
+
if (disp) disp.textContent = `${this.input.latitude > 0 ? '+' : ''}${this.input.latitude}°`;
|
|
97
|
+
this.update();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const bortle = this.rootEl.querySelector<HTMLInputElement>('#planner-bortle-input');
|
|
102
|
+
if (bortle) {
|
|
103
|
+
bortle.addEventListener('input', (e) => {
|
|
104
|
+
this.input.bortleClass = parseInt((e.target as HTMLInputElement).value, 10);
|
|
105
|
+
const disp = this.rootEl.querySelector('#planner-bortle-val');
|
|
106
|
+
if (disp) disp.textContent = `${this.ui.classLabel || 'Class'} ${this.input.bortleClass}`;
|
|
107
|
+
this.update();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const moon = this.rootEl.querySelector<HTMLInputElement>('#planner-moon-input');
|
|
112
|
+
if (moon) {
|
|
113
|
+
moon.addEventListener('input', (e) => {
|
|
114
|
+
this.input.moonPhase = parseFloat((e.target as HTMLInputElement).value);
|
|
115
|
+
const disp = this.rootEl.querySelector('#planner-moon-val');
|
|
116
|
+
if (disp) disp.textContent = `${Math.round(this.input.moonPhase * 100)}%`;
|
|
117
|
+
this.update();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private bindEvents(): void {
|
|
123
|
+
document.addEventListener('click', () => this.closeAllCustomSelects());
|
|
124
|
+
this.bindPresets();
|
|
125
|
+
this.bindSliders();
|
|
126
|
+
|
|
127
|
+
const customZhr = this.rootEl.querySelector<HTMLInputElement>('#planner-custom-zhr-input');
|
|
128
|
+
if (customZhr) {
|
|
129
|
+
customZhr.addEventListener('input', (e) => {
|
|
130
|
+
this.input.customZhr = Math.max(1, parseInt((e.target as HTMLInputElement).value, 10) || 50);
|
|
131
|
+
this.update();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.bindCustomSelect('planner-start-hour-btn', 'planner-start-hour-menu', 'planner-start-hour-label', (val) => {
|
|
136
|
+
this.input.sessionStartHour = val;
|
|
137
|
+
this.update();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this.bindCustomSelect('planner-end-hour-btn', 'planner-end-hour-menu', 'planner-end-hour-label', (val) => {
|
|
141
|
+
this.input.sessionEndHour = val;
|
|
142
|
+
this.update();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private updateMetrics(evaluation: EvaluationResult): void {
|
|
147
|
+
const bestWindowEl = this.rootEl.querySelector('.res-best-window');
|
|
148
|
+
if (bestWindowEl) bestWindowEl.textContent = `${evaluation.bestWindowStart} - ${evaluation.bestWindowEnd}`;
|
|
149
|
+
|
|
150
|
+
const maxRateEl = this.rootEl.querySelector('.res-max-rate');
|
|
151
|
+
if (maxRateEl) maxRateEl.textContent = formatZHRDisplay(evaluation.maxEffectiveRate, this.ui);
|
|
152
|
+
|
|
153
|
+
const scoreValEl = this.rootEl.querySelector('.res-score-val');
|
|
154
|
+
if (scoreValEl) {
|
|
155
|
+
scoreValEl.textContent = `${evaluation.overallScore}/100`;
|
|
156
|
+
scoreValEl.className = `res-score-val ${getQualityBadgeClass(evaluation.overallScore)}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public update(): void {
|
|
161
|
+
saveInput(this.input);
|
|
162
|
+
const evaluation = evaluateObservingSession(this.input, this.ui);
|
|
163
|
+
this.updateMetrics(evaluation);
|
|
164
|
+
|
|
165
|
+
const badgesContainer = this.rootEl.querySelector('.res-badges-list');
|
|
166
|
+
if (badgesContainer) {
|
|
167
|
+
badgesContainer.innerHTML = evaluation.badges
|
|
168
|
+
.map((b) => `<span class="badge badge-${b.type}">${b.label}</span>`)
|
|
169
|
+
.join('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const domeContainer = this.rootEl.querySelector('.sky-dome-container');
|
|
173
|
+
if (domeContainer) domeContainer.innerHTML = renderSkyDomeSvg(this.input, evaluation, this.ui);
|
|
174
|
+
|
|
175
|
+
const barsContainer = this.rootEl.querySelector('.hourly-bars-track');
|
|
176
|
+
if (barsContainer) barsContainer.innerHTML = renderHourlyBars(evaluation.hourlyBreakdown);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { EvaluationResult, HourlyCalculation, ObservingPlannerInput, MeteorShowerObservingPlannerUI } from './ui';
|
|
2
|
+
import { getShowerById, calculateRadiantElevation } from './logic';
|
|
3
|
+
|
|
4
|
+
interface MoonGraphicParams {
|
|
5
|
+
moonPhase: number;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
moonTxt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function renderSkyDomeBackground(moonGlowOpacity: string): string {
|
|
12
|
+
return `
|
|
13
|
+
<defs>
|
|
14
|
+
<radialGradient id="skyBgGrad" cx="50%" cy="100%" r="90%">
|
|
15
|
+
<stop offset="0%" stop-color="#1e293b"/>
|
|
16
|
+
<stop offset="100%" stop-color="#090d16"/>
|
|
17
|
+
</radialGradient>
|
|
18
|
+
<radialGradient id="moonGlowGrad" cx="50%" cy="50%" r="50%">
|
|
19
|
+
<stop offset="0%" stop-color="#fff8e7" stop-opacity="${moonGlowOpacity}"/>
|
|
20
|
+
<stop offset="100%" stop-color="#fff8e7" stop-opacity="0"/>
|
|
21
|
+
</radialGradient>
|
|
22
|
+
</defs>
|
|
23
|
+
<rect width="400" height="230" rx="16" fill="url(#skyBgGrad)"/>
|
|
24
|
+
<circle cx="50" cy="35" r="1" fill="#fff" opacity="0.8"/>
|
|
25
|
+
<circle cx="130" cy="55" r="1.5" fill="#fff" opacity="0.9"/>
|
|
26
|
+
<circle cx="270" cy="25" r="1.2" fill="#fff" opacity="0.7"/>
|
|
27
|
+
<circle cx="350" cy="65" r="1" fill="#fff" opacity="0.8"/>
|
|
28
|
+
<circle cx="190" cy="40" r="1" fill="#fff" opacity="0.6"/>
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderSkyDomeGrid(): string {
|
|
33
|
+
return `
|
|
34
|
+
<line x1="30" y1="130" x2="370" y2="130" stroke="rgba(255,255,255,0.08)" stroke-dasharray="3,3"/>
|
|
35
|
+
<text x="375" y="133" fill="#64748b" font-size="9">30°</text>
|
|
36
|
+
<line x1="30" y1="80" x2="370" y2="80" stroke="rgba(255,255,255,0.08)" stroke-dasharray="3,3"/>
|
|
37
|
+
<text x="375" y="83" fill="#64748b" font-size="9">60°</text>
|
|
38
|
+
<line x1="20" y1="180" x2="380" y2="180" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>
|
|
39
|
+
<text x="30" y="200" fill="#cbd5e1" font-size="11" font-weight="600">E</text>
|
|
40
|
+
<text x="200" y="200" text-anchor="middle" fill="#cbd5e1" font-size="11" font-weight="600">S</text>
|
|
41
|
+
<text x="370" y="200" text-anchor="end" fill="#cbd5e1" font-size="11" font-weight="600">W</text>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderTelemetryCard(title: string, constName: string, statusTxt: string, badgeColor: string): string {
|
|
46
|
+
return `
|
|
47
|
+
<g transform="translate(14, 20)">
|
|
48
|
+
<rect width="145" height="34" rx="6" fill="rgba(15, 23, 42, 0.75)" stroke="rgba(255,255,255,0.12)" stroke-width="1"/>
|
|
49
|
+
<text x="8" y="14" fill="#94a3b8" font-size="9" font-weight="600">${title}</text>
|
|
50
|
+
<text x="8" y="27" fill="${badgeColor}" font-size="11" font-weight="700">${constName} (${statusTxt})</text>
|
|
51
|
+
</g>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderMoonGraphic(params: MoonGraphicParams): string {
|
|
56
|
+
if (params.moonPhase <= 0.05) return '';
|
|
57
|
+
const percent = Math.round(params.moonPhase * 100);
|
|
58
|
+
const pathCurv = (10 * (1 - params.moonPhase * 2)).toFixed(1);
|
|
59
|
+
return `
|
|
60
|
+
<circle cx="${params.x}" cy="${params.y}" r="28" fill="url(#moonGlowGrad)"/>
|
|
61
|
+
<circle cx="${params.x}" cy="${params.y}" r="10" fill="#e2e8f0"/>
|
|
62
|
+
<path d="M ${params.x} ${params.y - 10} A 10 10 0 0 1 ${params.x} ${params.y + 10} A ${pathCurv} 10 0 0 1 ${params.x} ${params.y - 10}" fill="#090d16"/>
|
|
63
|
+
<text x="${params.x}" y="${params.y + 20}" text-anchor="middle" fill="#94a3b8" font-size="9">${percent}% ${params.moonTxt}</text>
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderMeteorStreaks(isAbove: boolean, zhr: number, rx: number, ry: number): string {
|
|
68
|
+
if (!isAbove || zhr <= 2) return '';
|
|
69
|
+
const streakCount = Math.min(7, Math.max(2, Math.floor(zhr / 8)));
|
|
70
|
+
const angles = [35, 70, 120, 145, 210, 250, 310];
|
|
71
|
+
const lengths = [35, 55, 40, 60, 45, 50, 65];
|
|
72
|
+
|
|
73
|
+
return angles
|
|
74
|
+
.slice(0, streakCount)
|
|
75
|
+
.map((ang, idx) => {
|
|
76
|
+
const rad = (ang * Math.PI) / 180;
|
|
77
|
+
const len = lengths[idx % lengths.length];
|
|
78
|
+
const x2 = Math.round(rx + Math.cos(rad) * len);
|
|
79
|
+
const y2 = Math.round(ry + Math.sin(rad) * len);
|
|
80
|
+
const opacity = (0.5 + (idx % 3) * 0.2).toFixed(2);
|
|
81
|
+
return `<line x1="${rx}" y1="${ry}" x2="${x2}" y2="${y2}" stroke="#a5b4fc" stroke-width="1.8" stroke-dasharray="80" stroke-linecap="round" opacity="${opacity}"/>`;
|
|
82
|
+
})
|
|
83
|
+
.join('');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderRadiantMarker(isAbove: boolean, rx: number, ry: number, belowTxt: string): string {
|
|
87
|
+
if (isAbove) {
|
|
88
|
+
return `
|
|
89
|
+
<path d="M 20 180 Q ${rx} ${ry - 15} 380 180" fill="none" stroke="rgba(129, 140, 248, 0.35)" stroke-dasharray="4,4" stroke-width="1.5"/>
|
|
90
|
+
<g transform="translate(${rx}, ${ry})">
|
|
91
|
+
<circle r="15" fill="none" stroke="#6366f1" stroke-width="2" opacity="0.9"/>
|
|
92
|
+
<circle r="5" fill="#818cf8"/>
|
|
93
|
+
<line x1="-20" y1="0" x2="20" y2="0" stroke="#818cf8" stroke-width="1.5"/>
|
|
94
|
+
<line x1="0" y1="-20" x2="0" y2="20" stroke="#818cf8" stroke-width="1.5"/>
|
|
95
|
+
</g>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
return `
|
|
99
|
+
<g transform="translate(${rx}, 195)">
|
|
100
|
+
<circle r="10" fill="none" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="2,2"/>
|
|
101
|
+
<text x="0" y="16" text-anchor="middle" fill="#f43f5e" font-size="9" font-weight="600">${belowTxt}</text>
|
|
102
|
+
</g>
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface DomeCoords {
|
|
107
|
+
peakHour: number;
|
|
108
|
+
elev: number;
|
|
109
|
+
isAbove: boolean;
|
|
110
|
+
radiantX: number;
|
|
111
|
+
radiantY: number;
|
|
112
|
+
moonX: number;
|
|
113
|
+
moonY: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function calculateDomeCoords(input: ObservingPlannerInput, evaluation: EvaluationResult): DomeCoords {
|
|
117
|
+
const peakItem = evaluation.hourlyBreakdown.find((h) => h.isPeakHour) || evaluation.hourlyBreakdown[0];
|
|
118
|
+
const peakHour = peakItem ? peakItem.hour : 2;
|
|
119
|
+
|
|
120
|
+
const elev = Math.round(calculateRadiantElevation(peakHour, input.latitude, input.showerId));
|
|
121
|
+
const isAbove = elev > 0;
|
|
122
|
+
const clampedElev = Math.max(-15, Math.min(88, elev));
|
|
123
|
+
const radiantY = Math.round(180 - (Math.max(0, clampedElev) / 90) * 135);
|
|
124
|
+
const radiantX = Math.round(200 + (((135 + ((peakHour % 12) / 12) * 90) - 180) / 90) * 140);
|
|
125
|
+
|
|
126
|
+
const moonX = Math.round(80 + ((peakHour % 10) / 10) * 240);
|
|
127
|
+
const moonY = Math.round(70 + Math.sin(peakHour * 0.5) * 30);
|
|
128
|
+
|
|
129
|
+
return { peakHour, elev, isAbove, radiantX, radiantY, moonX, moonY };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getConstellationName(constKey: string, ui?: MeteorShowerObservingPlannerUI): string {
|
|
133
|
+
if (!ui || !ui.constellations) return constKey;
|
|
134
|
+
return ui.constellations[constKey] || constKey;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getStatusText(isAbove: boolean, elev: number, ui?: MeteorShowerObservingPlannerUI): string {
|
|
138
|
+
const alt = ui && ui.altLabel ? ui.altLabel : 'Alt';
|
|
139
|
+
const below = ui && ui.belowHorizonLabel ? ui.belowHorizonLabel : 'Below Horizon';
|
|
140
|
+
return isAbove ? `+${elev}° ${alt}` : below;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function renderSkyDomeSvg(input: ObservingPlannerInput, evaluation: EvaluationResult, ui?: MeteorShowerObservingPlannerUI): string {
|
|
144
|
+
const shower = getShowerById(input.showerId);
|
|
145
|
+
const coords = calculateDomeCoords(input, evaluation);
|
|
146
|
+
const peakItem = evaluation.hourlyBreakdown.find((h) => h.isPeakHour) || evaluation.hourlyBreakdown[0];
|
|
147
|
+
|
|
148
|
+
const moonOpacity = (input.moonPhase * 0.5).toFixed(2);
|
|
149
|
+
const constName = getConstellationName(shower.radiantConstellation, ui);
|
|
150
|
+
const statusTxt = getStatusText(coords.isAbove, coords.elev, ui);
|
|
151
|
+
const moonLabelTxt = ui && ui.moonLabel ? ui.moonLabel : 'Moon';
|
|
152
|
+
const belowHorizonTxt = ui && ui.belowHorizonLabel ? ui.belowHorizonLabel : 'Below Horizon';
|
|
153
|
+
const telemetryTitle = ui && ui.radiantTelemetryLabel ? ui.radiantTelemetryLabel : 'RADIANT TELEMETRY';
|
|
154
|
+
|
|
155
|
+
const moonParams: MoonGraphicParams = { moonPhase: input.moonPhase, x: coords.moonX, y: coords.moonY, moonTxt: moonLabelTxt };
|
|
156
|
+
|
|
157
|
+
return `
|
|
158
|
+
<svg class="sky-dome-svg" viewBox="0 0 400 230" role="img" aria-label="Celestial Sky Dome">
|
|
159
|
+
${renderSkyDomeBackground(moonOpacity)}
|
|
160
|
+
${renderSkyDomeGrid()}
|
|
161
|
+
${renderTelemetryCard(telemetryTitle, constName, statusTxt, coords.isAbove ? '#10b981' : '#f43f5e')}
|
|
162
|
+
${renderMoonGraphic(moonParams)}
|
|
163
|
+
${renderMeteorStreaks(coords.isAbove, peakItem ? peakItem.effectiveZhr : 0, coords.radiantX, coords.radiantY)}
|
|
164
|
+
${renderRadiantMarker(coords.isAbove, coords.radiantX, coords.radiantY, belowHorizonTxt)}
|
|
165
|
+
</svg>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function renderHourlyBars(breakdown: HourlyCalculation[]): string {
|
|
170
|
+
if (breakdown.length === 0) return '';
|
|
171
|
+
const maxZhr = Math.max(...breakdown.map((b) => b.effectiveZhr), 1);
|
|
172
|
+
|
|
173
|
+
return breakdown
|
|
174
|
+
.map((item) => {
|
|
175
|
+
const heightPercent = Math.max(8, Math.round((item.effectiveZhr / maxZhr) * 100));
|
|
176
|
+
return `
|
|
177
|
+
<div class="hourly-bar-col ${item.isPeakHour ? 'is-peak' : ''}">
|
|
178
|
+
<span class="bar-val">${item.effectiveZhr.toFixed(0)}</span>
|
|
179
|
+
<div class="bar-track">
|
|
180
|
+
<div class="bar-fill" style="height: ${heightPercent}%"></div>
|
|
181
|
+
</div>
|
|
182
|
+
<span class="bar-label">${item.formattedTime}</span>
|
|
183
|
+
</div>
|
|
184
|
+
`;
|
|
185
|
+
})
|
|
186
|
+
.join('');
|
|
187
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AstronomyToolEntry } from '../../types';
|
|
2
|
+
import type { MeteorShowerObservingPlannerUI, MeteorShowerObservingPlannerLocaleContent } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { MeteorShowerObservingPlannerUI, MeteorShowerObservingPlannerLocaleContent };
|
|
5
|
+
|
|
6
|
+
export const meteorShowerObservingPlanner: AstronomyToolEntry<MeteorShowerObservingPlannerUI> = {
|
|
7
|
+
id: 'meteor-shower-observing-planner',
|
|
8
|
+
icons: {
|
|
9
|
+
bg: 'mdi:weather-night',
|
|
10
|
+
fg: 'mdi:star-shooting',
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
14
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
15
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
16
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
17
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
18
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
19
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
20
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
21
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
22
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
23
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
26
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EvaluationResult, MeteorShowerObservingPlannerUI } from './ui';
|
|
2
|
+
|
|
3
|
+
export function getQualityBadgeClass(score: number): string {
|
|
4
|
+
if (score >= 70) {
|
|
5
|
+
return 'badge-excellent';
|
|
6
|
+
}
|
|
7
|
+
if (score >= 40) {
|
|
8
|
+
return 'badge-moderate';
|
|
9
|
+
}
|
|
10
|
+
return 'badge-poor';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatZHRDisplay(val: number, ui?: MeteorShowerObservingPlannerUI): string {
|
|
14
|
+
const unit = ui?.hrUnit || '/ hr';
|
|
15
|
+
return `${val.toFixed(1)} ${unit}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function calculateSummaryBadges(evaluation: EvaluationResult): Array<{ label: string; type: string }> {
|
|
19
|
+
return evaluation.badges;
|
|
20
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { MeteorShowerObservingPlannerLocaleContent } from '../ui';
|
|
2
|
+
import { METEOR_PLANNER_BIBLIOGRAPHY } from '../bibliography';
|
|
3
|
+
|
|
4
|
+
const slug = 'planer-fuer-meteorstrom-beobachtung';
|
|
5
|
+
|
|
6
|
+
export const content: MeteorShowerObservingPlannerLocaleContent = {
|
|
7
|
+
slug,
|
|
8
|
+
title: 'Meteorstrom Beobachtungsfenster Planer',
|
|
9
|
+
description: 'Planen Sie optimale Beobachtungszeiten für Sternschnuppen mit Berechnung der effektiven stündlichen ZHR Rate und Mondlichtdämpfung.',
|
|
10
|
+
ui: {
|
|
11
|
+
title: 'Meteorstrom Beobachtungsplaner',
|
|
12
|
+
subtitle: 'Berechnen Sie optimale Fenster und vorhergesagte Raten pro Stunde',
|
|
13
|
+
presetLabel: 'Hauptmeteorstrom auswählen',
|
|
14
|
+
customZhrLabel: 'Benutzerdefinierte ZHR Rate',
|
|
15
|
+
latitudeLabel: 'Breitengrad des Beobachters',
|
|
16
|
+
bortleLabel: 'Bortle Skala für Himmelsdunkelheit',
|
|
17
|
+
moonPhaseLabel: 'Mondphase und Beleuchtung',
|
|
18
|
+
sessionHoursLabel: 'Geplante Beobachtungsstunden',
|
|
19
|
+
toLabel: 'bis',
|
|
20
|
+
classLabel: 'Klasse',
|
|
21
|
+
radiantTelemetryLabel: 'RADIANTE-TELEMETRIE',
|
|
22
|
+
belowHorizonLabel: 'Unter dem Horizont',
|
|
23
|
+
altLabel: 'Höhe',
|
|
24
|
+
moonLabel: 'Mond',
|
|
25
|
+
hrUnit: '/ Std',
|
|
26
|
+
presets: {
|
|
27
|
+
perseids: 'Perseiden (12 Aug)',
|
|
28
|
+
geminids: 'Geminiden (14 Dez)',
|
|
29
|
+
quadrantids: 'Quadranten (4 Jan)',
|
|
30
|
+
lyrids: 'Lyriden (22 Apr)',
|
|
31
|
+
orionids: 'Orioniden (21 Okt)',
|
|
32
|
+
'eta-aquariids': 'Eta Aquariiden (6 Mai)',
|
|
33
|
+
leonids: 'Leoniden (17 Nov)',
|
|
34
|
+
custom: 'Benutzerdefinierter Strom',
|
|
35
|
+
},
|
|
36
|
+
constellations: {
|
|
37
|
+
Perseus: 'Perseus',
|
|
38
|
+
Gemini: 'Zwillinge',
|
|
39
|
+
Bootes: 'Bärenhüter',
|
|
40
|
+
Lyra: 'Leier',
|
|
41
|
+
Orion: 'Orion',
|
|
42
|
+
Aquarius: 'Wassermann',
|
|
43
|
+
Leo: 'Löwe',
|
|
44
|
+
Zenith: 'Zenit',
|
|
45
|
+
},
|
|
46
|
+
badgeLabels: {
|
|
47
|
+
darkSky: 'Exzellenter Dunkler Himmel',
|
|
48
|
+
lightPollution: 'Hohe Lichtverschmutzung',
|
|
49
|
+
favorableMoon: 'Günstiger Mond',
|
|
50
|
+
moonWashout: 'Risiko durch Mondschein',
|
|
51
|
+
primeWindow: 'Hauptaktivitätsfenster',
|
|
52
|
+
},
|
|
53
|
+
bortleDescriptions: {
|
|
54
|
+
2: 'Exzellenter dunkler Himmel',
|
|
55
|
+
5: 'Vorstädtischer Himmel',
|
|
56
|
+
8: 'Städtische Lichtverschmutzung',
|
|
57
|
+
},
|
|
58
|
+
moonPhaseNames: {
|
|
59
|
+
new: 'Minimaler Mondschein',
|
|
60
|
+
quarter: 'Moderate Mondstörung',
|
|
61
|
+
full: 'Starke Mondüberstrahlung',
|
|
62
|
+
},
|
|
63
|
+
resultsTitle: 'Analyse der Beobachtungssitzung',
|
|
64
|
+
bestWindowLabel: 'Bestes Beobachtungsfenster',
|
|
65
|
+
maxRateLabel: 'Maximale sichtbare Rate',
|
|
66
|
+
skyQualityLabel: 'Himmelsqualitätsindex',
|
|
67
|
+
hourlyChartTitle: 'Stündliche Vorhersage und Radiantenhöhe',
|
|
68
|
+
checklistTitle: 'Vorbereitung auf die Beobachtung',
|
|
69
|
+
checklistItems: [
|
|
70
|
+
'Gönnen Sie Ihren Augen mindestens 20 Minuten Dunkeladaption vor dem Zählen',
|
|
71
|
+
'Verwenden Sie Rotlichtlampen um die Nachtsicht zu bewahren',
|
|
72
|
+
'Nutzen Sie einen bequemen Liegestuhl mit Blick 45 Grad über dem Horizont',
|
|
73
|
+
'Vermeiden Sie den Blick auf helle Smartphone Displayanzeigen',
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
seo: [
|
|
77
|
+
{
|
|
78
|
+
type: 'title',
|
|
79
|
+
text: 'Bedeutung der Zenitrate ZHR und der sichtbaren Meteore',
|
|
80
|
+
level: 2,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'paragraph',
|
|
84
|
+
html: 'Die Zenitrate ZHR gibt die theoretische Höchstzahl an Sternschnuppen an die ein Beobachter unter idealem dunklem Himmel mit dem Radianten direkt im Zenit sehen würde. In der Praxis liegt die tatsächlich sichtbare Zahl aufgrund von Lichtverschmutzung und Radiantenhöhe deutlich niedriger. Atmosphärische Extinktion dämpft das Licht schwacher Meteorspuren zusätzlich ab.',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: 'paragraph',
|
|
88
|
+
html: 'Die Planung optimaler Beobachtungsfenster erfordert die Berücksichtigung geometrischer Umweltfaktoren. Steht der Radiant nah am Horizont verlaufen viele Meteorbahnen außerhalb des Blickfeldes. Beobachter in städtischen Gebieten müssen mit weiteren Verlusten durch Straßenlaternen und Gebäudebelichtung rechnen.',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'list',
|
|
92
|
+
items: [
|
|
93
|
+
'Der Höheneinfluss des Radianten reduziert die Rate proportional zum Sinus der Höhe',
|
|
94
|
+
'Lichtverschmutzung nach Bortle dämpft schwache Sternschnuppenspuren',
|
|
95
|
+
'Mondlicht erzeugt eine Aufhellung des Hintergrundhimmels',
|
|
96
|
+
'Das eigene Sehfeld begrenzt den sichtbaren Bereich',
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: 'tip',
|
|
101
|
+
title: 'Empfehlung für Dunklen Himmel',
|
|
102
|
+
html: 'Beobachtungen von Standorten der Bortle Klasse 2 oder 3 steigern die Zahl sichtbarer schwacher Meteore erheblich im Vergleich zu Balkonen in Großstädten.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'title',
|
|
106
|
+
text: 'Einfluss von Mondphase und Lichtverschmutzung',
|
|
107
|
+
level: 2,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'paragraph',
|
|
111
|
+
html: 'Mondlicht ist der stärkste natürliche Störfaktor während eines Meteorstroms. Ein heller Halbmond oder Vollmond überstrahlt schwache Spuren und verringert die sichtbare Rate um über 70 Prozent selbst bei feinstem Landhimmel. Eine dunkle Neumondnacht ist daher die allerbeste Voraussetzung für astronomische Beobachtungen.',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: 'paragraph',
|
|
115
|
+
html: 'Beobachtungsstunden nach Monduntergang oder vor Mondaufgang bieten den besten Kontrast zum Nachthimmel. Beobachtungen um die astronomische Mitternacht versprechen die höchste Dunkelheit und beste Chancen auf helle Feuerbälle.',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: 'title',
|
|
119
|
+
text: 'Tipps für eine Erfolgreiche Beobachtungsnacht',
|
|
120
|
+
level: 2,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: 'paragraph',
|
|
124
|
+
html: 'Erfolgreiche Sternschnuppenbeobachtung erfordert Geduld und gute Kleidung. Da Meteore überraschend auftreten bietet das freie Auge ein wesentlich breiteres Sehfeld als Teleskope oder Ferngläser.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: 'list',
|
|
128
|
+
items: [
|
|
129
|
+
'Tragen Sie warme Kleidung in Schichten auch in Sommernächten',
|
|
130
|
+
'Bringen Sie einen Schlafsack oder eine Decke für Liegestühle mit',
|
|
131
|
+
'Halten Sie warme Getränke bereit um die Körperwärme zu halten',
|
|
132
|
+
'Notieren Sie Uhrzeit und Richtung sehr heller Feuerbälle',
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: 'title',
|
|
137
|
+
text: 'Technische Berechungsmethodik',
|
|
138
|
+
level: 2,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: 'paragraph',
|
|
142
|
+
html: 'Dieser Planer berechnet die effektive Rate durch Verknüpfung der Spitzen ZHR mit geometrischen Höhenfaktoren Himmelsdunkelheit und Mondphasenkorrekturen für realistische Erwartungen am Beobachtungsort. Dies hilft Amateurastronomen bei der Planung.',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
faq: [
|
|
146
|
+
{
|
|
147
|
+
question: 'Was bedeutet Zenitrate ZHR?',
|
|
148
|
+
answer: 'Die ZHR ist die theoretische Maximalzahl an Meteoren pro Stunde unter komplett dunklem Himmel mit dem Radianten im Zenit.',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
question: 'Warum sehe ich weniger Sternschnuppen als angegeben?',
|
|
152
|
+
answer: 'Angegebene ZHR Werte setzen perfekten dunklen Himmel voraus. Lichtverschmutzung und Mondschein reduzieren die sichtbare Zahl.',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
question: 'Benötige ich ein Teleskop für Sternschnuppen?',
|
|
156
|
+
answer: 'Nein Teleskope verengen das Blickfeld zu stark. Die Beobachtung mit bloßem Auge ist am besten geeignet.',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
howTo: [
|
|
160
|
+
{
|
|
161
|
+
name: 'Meteorstrom auswählen',
|
|
162
|
+
text: 'Wählen Sie einen vorgegebenen Meteorstrom wie die Perseiden oder geben Sie eigene Werte ein.',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'Breitengrad einstellen',
|
|
166
|
+
text: 'Passen Sie den Regler an Ihren aktuellen Standort an.',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'Dunkelheit und Mondphase konfigurieren',
|
|
170
|
+
text: 'Stellen Sie die lokale Bortle Klasse und die aktuelle Mondbeleuchtung ein.',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'Vorhersage prüfen',
|
|
174
|
+
text: 'Analysieren Sie das Diagramm für Ihr optimales Fenster.',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
bibliography: METEOR_PLANNER_BIBLIOGRAPHY,
|
|
178
|
+
schemas: [
|
|
179
|
+
{
|
|
180
|
+
'@context': 'https://schema.org',
|
|
181
|
+
'@type': 'SoftwareApplication',
|
|
182
|
+
name: 'Meteorstrom Beobachtungsfenster Planer',
|
|
183
|
+
operatingSystem: 'Any',
|
|
184
|
+
applicationCategory: 'EducationalApplication',
|
|
185
|
+
offers: {
|
|
186
|
+
'@type': 'Offer',
|
|
187
|
+
price: '0',
|
|
188
|
+
priceCurrency: 'EUR',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
'@context': 'https://schema.org',
|
|
193
|
+
'@type': 'FAQPage',
|
|
194
|
+
mainEntity: [
|
|
195
|
+
{
|
|
196
|
+
'@type': 'Question',
|
|
197
|
+
name: 'Was bedeutet Zenitrate ZHR?',
|
|
198
|
+
acceptedAnswer: {
|
|
199
|
+
'@type': 'Answer',
|
|
200
|
+
text: 'Die ZHR ist die theoretische Maximalzahl an Meteoren pro Stunde unter komplett dunklem Himmel mit dem Radianten im Zenit.',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
'@context': 'https://schema.org',
|
|
207
|
+
'@type': 'HowTo',
|
|
208
|
+
name: 'So nutzen Sie den Meteorstrom Beobachtungsplaner',
|
|
209
|
+
description: 'Anleitung zur Ermittlung des besten Beobachtungsfensters für Sternschnuppen.',
|
|
210
|
+
step: [
|
|
211
|
+
{
|
|
212
|
+
'@type': 'HowToStep',
|
|
213
|
+
name: 'Meteorstrom auswählen',
|
|
214
|
+
text: 'Wählen Sie einen vorgegebenen Meteorstrom wie die Perseiden oder geben Sie eigene Werte ein.',
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|