@jjlmoya/utils-astronomy 1.35.1 → 1.36.0

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 (55) hide show
  1. package/package.json +66 -66
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -2
  4. package/src/index.ts +2 -1
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +4 -3
  7. package/src/tool/astrophotographyExposureStackPlanner/astrophotography-exposure-stacking-planner.css +496 -0
  8. package/src/tool/astrophotographyExposureStackPlanner/bibliography.astro +14 -0
  9. package/src/tool/astrophotographyExposureStackPlanner/bibliography.ts +12 -0
  10. package/src/tool/astrophotographyExposureStackPlanner/component.astro +149 -0
  11. package/src/tool/astrophotographyExposureStackPlanner/controller.ts +125 -0
  12. package/src/tool/astrophotographyExposureStackPlanner/dom-views.ts +41 -0
  13. package/src/tool/astrophotographyExposureStackPlanner/entry.ts +29 -0
  14. package/src/tool/astrophotographyExposureStackPlanner/evaluator.ts +44 -0
  15. package/src/tool/astrophotographyExposureStackPlanner/i18n/de.ts +40 -0
  16. package/src/tool/astrophotographyExposureStackPlanner/i18n/en.ts +232 -0
  17. package/src/tool/astrophotographyExposureStackPlanner/i18n/es.ts +52 -0
  18. package/src/tool/astrophotographyExposureStackPlanner/i18n/fr.ts +40 -0
  19. package/src/tool/astrophotographyExposureStackPlanner/i18n/id.ts +40 -0
  20. package/src/tool/astrophotographyExposureStackPlanner/i18n/it.ts +40 -0
  21. package/src/tool/astrophotographyExposureStackPlanner/i18n/ja.ts +40 -0
  22. package/src/tool/astrophotographyExposureStackPlanner/i18n/ko.ts +40 -0
  23. package/src/tool/astrophotographyExposureStackPlanner/i18n/nl.ts +40 -0
  24. package/src/tool/astrophotographyExposureStackPlanner/i18n/pl.ts +40 -0
  25. package/src/tool/astrophotographyExposureStackPlanner/i18n/pt.ts +40 -0
  26. package/src/tool/astrophotographyExposureStackPlanner/i18n/ru.ts +40 -0
  27. package/src/tool/astrophotographyExposureStackPlanner/i18n/sv.ts +40 -0
  28. package/src/tool/astrophotographyExposureStackPlanner/i18n/tr.ts +40 -0
  29. package/src/tool/astrophotographyExposureStackPlanner/i18n/zh.ts +40 -0
  30. package/src/tool/astrophotographyExposureStackPlanner/index.ts +11 -0
  31. package/src/tool/astrophotographyExposureStackPlanner/localized.ts +59 -0
  32. package/src/tool/astrophotographyExposureStackPlanner/logic.test.ts +74 -0
  33. package/src/tool/astrophotographyExposureStackPlanner/logic.ts +95 -0
  34. package/src/tool/astrophotographyExposureStackPlanner/seo.astro +14 -0
  35. package/src/tool/astrophotographyExposureStackPlanner/storage.ts +22 -0
  36. package/src/tool/astrophotographyExposureStackPlanner/ui.ts +102 -0
  37. package/src/tool/meteorShowerObservingPlanner/dom-views.ts +38 -16
  38. package/src/tool/meteorShowerObservingPlanner/i18n/de.ts +219 -219
  39. package/src/tool/meteorShowerObservingPlanner/i18n/en.ts +219 -219
  40. package/src/tool/meteorShowerObservingPlanner/i18n/es.ts +219 -219
  41. package/src/tool/meteorShowerObservingPlanner/i18n/fr.ts +219 -219
  42. package/src/tool/meteorShowerObservingPlanner/i18n/id.ts +223 -223
  43. package/src/tool/meteorShowerObservingPlanner/i18n/it.ts +223 -223
  44. package/src/tool/meteorShowerObservingPlanner/i18n/ja.ts +219 -219
  45. package/src/tool/meteorShowerObservingPlanner/i18n/ko.ts +219 -219
  46. package/src/tool/meteorShowerObservingPlanner/i18n/nl.ts +223 -223
  47. package/src/tool/meteorShowerObservingPlanner/i18n/pl.ts +223 -223
  48. package/src/tool/meteorShowerObservingPlanner/i18n/pt.ts +223 -223
  49. package/src/tool/meteorShowerObservingPlanner/i18n/ru.ts +219 -219
  50. package/src/tool/meteorShowerObservingPlanner/i18n/sv.ts +223 -223
  51. package/src/tool/meteorShowerObservingPlanner/i18n/tr.ts +223 -223
  52. package/src/tool/meteorShowerObservingPlanner/i18n/zh.ts +219 -219
  53. package/src/tool/meteorShowerObservingPlanner/meteor-shower-observing-planner.css +16 -9
  54. package/src/tool/meteorShowerObservingPlanner/ui.ts +87 -87
  55. package/src/tools.ts +3 -1
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { astrophotographyExposureStackPlanner } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await astrophotographyExposureStackPlanner.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const ASTROPHOTOGRAPHY_EXPOSURE_BIBLIOGRAPHY: BibliographyEntry[] = [
4
+ {
5
+ name: 'Societe Astronomique du Havre, La Regle NPF',
6
+ url: 'https://sahavre.fr/wp/regle-npf-rule/',
7
+ },
8
+ {
9
+ name: 'NASA Science, Astrophotography With Your Smartphone',
10
+ url: 'https://science.nasa.gov/solar-system/skywatching/night-sky-network/astrophotography-with-your-smartphone/',
11
+ },
12
+ ];
@@ -0,0 +1,149 @@
1
+ ---
2
+ import type { ExposurePlannerUI } from './ui';
3
+ import { DEFAULT_INPUT } from './logic';
4
+
5
+ interface Props {
6
+ ui: ExposurePlannerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const config = JSON.stringify({ defaults: DEFAULT_INPUT, ui });
11
+ ---
12
+
13
+ <div class="astrophotography-exposure-stack-planner" data-exposure-planner>
14
+ <form class="exposure-planner-form" onsubmit="return false">
15
+ <section class="control-rail" aria-label={ui.cameraLegend}>
16
+ <fieldset class="camera-fields">
17
+ <legend>{ui.cameraLegend}</legend>
18
+ <label class="field-control">
19
+ <span>{ui.focalLabel}</span>
20
+ <input data-field="focalLength" type="number" min="5" max="600" step="1" value={DEFAULT_INPUT.focalLength} inputmode="decimal" />
21
+ <small>{ui.focalHelp}</small>
22
+ </label>
23
+ <label class="field-control">
24
+ <span>{ui.apertureLabel}</span>
25
+ <input data-field="aperture" type="number" min="1" max="32" step="0.1" value={DEFAULT_INPUT.aperture} inputmode="decimal" />
26
+ <small>{ui.apertureHelp}</small>
27
+ </label>
28
+ <label class="field-control">
29
+ <span>{ui.pixelPitchLabel}</span>
30
+ <input data-field="pixelPitch" type="number" min="0.5" max="30" step="0.1" value={DEFAULT_INPUT.pixelPitch} inputmode="decimal" />
31
+ <small>{ui.pixelPitchHelp}</small>
32
+ </label>
33
+ <label class="field-control">
34
+ <span>{ui.declinationLabel}</span>
35
+ <input data-field="declination" type="number" min="0" max="89" step="1" value={DEFAULT_INPUT.declination} inputmode="decimal" />
36
+ <small>{ui.declinationHelp}</small>
37
+ </label>
38
+ </fieldset>
39
+
40
+ <fieldset class="choice-fieldset">
41
+ <legend>{ui.sensorLabel}</legend>
42
+ <div class="choice-grid sensor-grid">
43
+ {Object.entries(ui.sensorOptions).map(([value, label]) => (
44
+ <button type="button" data-sensor data-value={value} aria-pressed="false">{label}</button>
45
+ ))}
46
+ </div>
47
+ </fieldset>
48
+
49
+ <fieldset class="choice-fieldset">
50
+ <legend>{ui.ruleLabel}</legend>
51
+ <div class="choice-grid two-up">
52
+ <button type="button" data-rule data-value="npf" aria-pressed="false">{ui.ruleNpf}</button>
53
+ <button type="button" data-rule data-value="rule500" aria-pressed="false">{ui.rule500}</button>
54
+ </div>
55
+ </fieldset>
56
+
57
+ <fieldset class="choice-fieldset">
58
+ <legend>{ui.trackingLabel}</legend>
59
+ <div class="choice-grid two-up">
60
+ <button type="button" data-tracking data-value="untracked" aria-pressed="false">{ui.untracked}</button>
61
+ <button type="button" data-tracking data-value="tracked" aria-pressed="false">{ui.tracked}</button>
62
+ </div>
63
+ </fieldset>
64
+
65
+ <fieldset class="timing-fields">
66
+ <legend>{ui.trackingExposureLabel}</legend>
67
+ <label class="field-control">
68
+ <span>{ui.trackingExposureLabel}</span>
69
+ <input data-field="trackingExposureSeconds" type="number" min="5" max="900" step="5" value={DEFAULT_INPUT.trackingExposureSeconds} inputmode="decimal" />
70
+ <small>{ui.trackingExposureHelp}</small>
71
+ </label>
72
+ <label class="field-control">
73
+ <span>{ui.deadTimeLabel}</span>
74
+ <input data-field="deadTimeSeconds" type="number" min="0" max="120" step="1" value={DEFAULT_INPUT.deadTimeSeconds} inputmode="decimal" />
75
+ <small>{ui.deadTimeHelp}</small>
76
+ </label>
77
+ </fieldset>
78
+
79
+ <label class="field-control integration-field">
80
+ <span>{ui.totalIntegrationLabel}</span>
81
+ <input data-field="totalIntegrationMinutes" type="number" min="1" max="1440" step="5" value={DEFAULT_INPUT.totalIntegrationMinutes} inputmode="decimal" />
82
+ <small>{ui.totalIntegrationHelp}</small>
83
+ </label>
84
+ </section>
85
+ </form>
86
+
87
+ <section class="exposure-result" aria-live="polite">
88
+ <div class="result-topline">
89
+ <span class="result-kicker">{ui.resultKicker}</span>
90
+ <span class="status-pill" data-status data-tone="attention">{ui.startingStatus}</span>
91
+ </div>
92
+ <div class="result-heading">
93
+ <div>
94
+ <h2>{ui.resultTitle}</h2>
95
+ <p data-note></p>
96
+ </div>
97
+ <div class="hero-output">
98
+ <strong data-output="ceiling">0.0 s</strong>
99
+ <span>{ui.secondsUnit}</span>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="timeline-panel">
104
+ <div class="timeline-header">
105
+ <h3>{ui.timelineTitle}</h3>
106
+ <span><span data-output="frames">0</span> {ui.framesLabel}</span>
107
+ </div>
108
+ <div class="timeline" data-timeline></div>
109
+ <div class="timeline-labels"><span data-output="timeline-start">{ui.timelineStart}</span><span data-output="timeline-end">{ui.timelineEnd}</span></div>
110
+ </div>
111
+
112
+ <div class="metric-strip">
113
+ <div><span>{ui.totalTimeLabel}</span><strong data-output="total">0.0 min</strong></div>
114
+ <div><span>{ui.sessionTimeLabel}</span><strong data-output="session">0.0 min</strong></div>
115
+ <div><span>{ui.gainLabel}</span><strong data-output="gain">0.0 stops</strong></div>
116
+ </div>
117
+ <p class="session-detail" data-output="session-detail"></p>
118
+ <aside class="workload-note" data-workload></aside>
119
+ <aside class="sensor-warning" data-sensor-warning hidden></aside>
120
+
121
+ <section class="comparison-panel">
122
+ <h3>{ui.comparisonTitle}</h3>
123
+ <div class="comparison-row"><span>{ui.npfLabel}</span><strong data-output="npf">0.0 s</strong></div>
124
+ <div class="comparison-row"><span>{ui.rule500Label}</span><strong data-output="rule500">0.0 s</strong></div>
125
+ <div class="comparison-row"><span>{ui.deltaLabel}</span><strong data-output="delta">0%</strong></div>
126
+ </section>
127
+
128
+ <details class="method-details">
129
+ <summary>{ui.methodTitle}</summary>
130
+ <p>{ui.methodText}</p>
131
+ <p><strong>{ui.formulaLabel}</strong></p>
132
+ <p>{ui.formulaNpf}</p>
133
+ <p>{ui.formula500}</p>
134
+ </details>
135
+ <aside class="limits-note">
136
+ <strong>{ui.limitsTitle}</strong>
137
+ <p>{ui.limitsText}</p>
138
+ </aside>
139
+ </section>
140
+
141
+ <script is:inline type="application/json" data-planner-config set:html={config}></script>
142
+ </div>
143
+
144
+ <script>
145
+ import { mountExposurePlanner } from './controller';
146
+
147
+ const root = document.querySelector<HTMLElement>('[data-exposure-planner]');
148
+ if (root) mountExposurePlanner(root);
149
+ </script>
@@ -0,0 +1,125 @@
1
+ import { calculateExposurePlan, DEFAULT_INPUT } from './logic';
2
+ import { evaluateExposurePlan } from './evaluator';
3
+ import { renderExposureTimeline, updateResultValues } from './dom-views';
4
+ import { loadExposurePlannerInput, saveExposurePlannerInput } from './storage';
5
+ import type { ExposurePlannerInput, ExposurePlannerUI, ExposureRule, SensorPreset, TrackingMode } from './ui';
6
+
7
+ interface PlannerConfig {
8
+ defaults: ExposurePlannerInput;
9
+ ui: ExposurePlannerUI;
10
+ }
11
+
12
+ function readConfig(root: HTMLElement): PlannerConfig {
13
+ const script = root.querySelector<HTMLScriptElement>('[data-planner-config]');
14
+ return JSON.parse(script?.textContent ?? '{}') as PlannerConfig;
15
+ }
16
+
17
+ function readNumber(root: HTMLElement, field: string, fallback: number): number {
18
+ const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
19
+ return Number(input?.value ?? fallback);
20
+ }
21
+
22
+ function readInput(root: HTMLElement, activeRule: ExposureRule, activeSensor: SensorPreset, activeTracking: TrackingMode): ExposurePlannerInput {
23
+ return {
24
+ exposureRule: activeRule,
25
+ sensor: activeSensor,
26
+ tracking: activeTracking,
27
+ focalLength: readNumber(root, 'focalLength', DEFAULT_INPUT.focalLength),
28
+ aperture: readNumber(root, 'aperture', DEFAULT_INPUT.aperture),
29
+ pixelPitch: readNumber(root, 'pixelPitch', DEFAULT_INPUT.pixelPitch),
30
+ declination: readNumber(root, 'declination', DEFAULT_INPUT.declination),
31
+ trackingExposureSeconds: readNumber(root, 'trackingExposureSeconds', DEFAULT_INPUT.trackingExposureSeconds),
32
+ deadTimeSeconds: readNumber(root, 'deadTimeSeconds', DEFAULT_INPUT.deadTimeSeconds),
33
+ totalIntegrationMinutes: readNumber(root, 'totalIntegrationMinutes', DEFAULT_INPUT.totalIntegrationMinutes),
34
+ };
35
+ }
36
+
37
+ function setActive(root: HTMLElement, selector: string, value: string): void {
38
+ root.querySelectorAll<HTMLElement>(selector).forEach((button) => {
39
+ button.classList.toggle('is-active', button.dataset.value === value);
40
+ button.setAttribute('aria-pressed', String(button.dataset.value === value));
41
+ });
42
+ }
43
+
44
+ function setStatus(root: HTMLElement, evaluation: ReturnType<typeof evaluateExposurePlan>): void {
45
+ const status = root.querySelector<HTMLElement>('[data-status]');
46
+ const note = root.querySelector<HTMLElement>('[data-note]');
47
+ if (status) {
48
+ status.textContent = evaluation.status;
49
+ status.dataset.tone = evaluation.tone;
50
+ }
51
+ if (note) note.textContent = evaluation.note;
52
+ }
53
+
54
+ function render(root: HTMLElement, state: ExposurePlannerInput, ui: ExposurePlannerUI): void {
55
+ const result = calculateExposurePlan(state);
56
+ setStatus(root, evaluateExposurePlan(state, result, ui));
57
+ updateResultValues(root, result, ui);
58
+ updateContextNotes(root, state, result, ui);
59
+ renderExposureTimeline(root, result);
60
+ saveExposurePlannerInput(state);
61
+ }
62
+
63
+ function updateContextNotes(root: HTMLElement, state: ExposurePlannerInput, result: ReturnType<typeof calculateExposurePlan>, ui: ExposurePlannerUI): void {
64
+ const workload = root.querySelector<HTMLElement>('[data-workload]');
65
+ if (workload) {
66
+ const template = result.frameCount >= 500 ? ui.highWorkloadText : ui.workloadText;
67
+ workload.textContent = template
68
+ .replace('{frames}', result.frameCount.toLocaleString('en-US'))
69
+ .replace('{session}', formatDuration(result.sessionDurationMinutes));
70
+ }
71
+ const sensorWarning = root.querySelector<HTMLElement>('[data-sensor-warning]');
72
+ const hasPixelMismatch = state.sensor === 'apsC' && state.pixelPitch >= 5.5;
73
+ if (sensorWarning) {
74
+ sensorWarning.hidden = !hasPixelMismatch;
75
+ sensorWarning.textContent = hasPixelMismatch
76
+ ? ui.sensorPixelWarning.replace('{pixel}', `${state.pixelPitch.toFixed(1)} µm`)
77
+ : '';
78
+ }
79
+ }
80
+
81
+ function formatDuration(minutes: number): string {
82
+ if (minutes < 60) return `${minutes.toFixed(1)} min`;
83
+ const roundedMinutes = Math.round(minutes);
84
+ const hours = Math.floor(roundedMinutes / 60);
85
+ const remainingMinutes = roundedMinutes % 60;
86
+ return `${hours} h ${remainingMinutes} min`;
87
+ }
88
+
89
+ function bind(root: HTMLElement, ui: ExposurePlannerUI, initial: ExposurePlannerInput): void {
90
+ let activeRule = initial.exposureRule;
91
+ let activeSensor = initial.sensor;
92
+ let activeTracking = initial.tracking;
93
+ const refresh = (): void => render(root, readInput(root, activeRule, activeSensor, activeTracking), ui);
94
+ root.querySelectorAll<HTMLButtonElement>('[data-rule]').forEach((button) => button.addEventListener('click', () => {
95
+ activeRule = button.dataset.value as ExposureRule;
96
+ setActive(root, '[data-rule]', activeRule);
97
+ refresh();
98
+ }));
99
+ root.querySelectorAll<HTMLButtonElement>('[data-sensor]').forEach((button) => button.addEventListener('click', () => {
100
+ activeSensor = button.dataset.value as SensorPreset;
101
+ setActive(root, '[data-sensor]', activeSensor);
102
+ refresh();
103
+ }));
104
+ root.querySelectorAll<HTMLButtonElement>('[data-tracking]').forEach((button) => button.addEventListener('click', () => {
105
+ activeTracking = button.dataset.value as TrackingMode;
106
+ setActive(root, '[data-tracking]', activeTracking);
107
+ refresh();
108
+ }));
109
+ root.querySelectorAll<HTMLInputElement>('[data-field]').forEach((input) => input.addEventListener('input', refresh));
110
+ setActive(root, '[data-rule]', activeRule);
111
+ setActive(root, '[data-sensor]', activeSensor);
112
+ setActive(root, '[data-tracking]', activeTracking);
113
+ refresh();
114
+ }
115
+
116
+ export function mountExposurePlanner(root: HTMLElement): void {
117
+ const config = readConfig(root);
118
+ const initial = loadExposurePlannerInput(config.defaults);
119
+ root.querySelectorAll<HTMLInputElement>('[data-field]').forEach((input) => {
120
+ const field = input.dataset.field as keyof ExposurePlannerInput;
121
+ const value = initial[field];
122
+ if (typeof value === 'number') input.value = String(value);
123
+ });
124
+ bind(root, config.ui, initial);
125
+ }
@@ -0,0 +1,41 @@
1
+ import type { ExposurePlannerUI, ExposurePlannerResult } from './ui';
2
+
3
+ function setText(root: HTMLElement, selector: string, value: string): void {
4
+ const target = root.querySelector<HTMLElement>(selector);
5
+ if (target) target.textContent = value;
6
+ }
7
+
8
+ function formatSeconds(seconds: number): string {
9
+ return seconds < 10 ? `${seconds.toFixed(1)} s` : `${Math.round(seconds)} s`;
10
+ }
11
+
12
+ function formatDuration(minutes: number): string {
13
+ if (minutes < 60) return `${minutes.toFixed(1)} min`;
14
+ const roundedMinutes = Math.round(minutes);
15
+ const hours = Math.floor(roundedMinutes / 60);
16
+ const remainingMinutes = roundedMinutes % 60;
17
+ return `${hours} h ${remainingMinutes} min`;
18
+ }
19
+
20
+ export function updateResultValues(root: HTMLElement, result: ExposurePlannerResult, ui: ExposurePlannerUI): void {
21
+ setText(root, '[data-output="ceiling"]', formatSeconds(result.selectedCeilingSeconds));
22
+ setText(root, '[data-output="frames"]', result.frameCount.toLocaleString('en-US'));
23
+ setText(root, '[data-output="total"]', `${result.plannedIntegrationMinutes.toFixed(1)} min`);
24
+ setText(root, '[data-output="session"]', formatDuration(result.sessionDurationMinutes));
25
+ setText(root, '[data-output="session-detail"]', ui.sessionDetail
26
+ .replace('{dead}', formatSeconds(result.deadTimePerFrameSeconds))
27
+ .replace('{deadTotal}', formatDuration(result.deadTimeMinutes)));
28
+ setText(root, '[data-output="gain"]', `${result.stackingGainStops.toFixed(1)} stops`);
29
+ setText(root, '[data-output="npf"]', formatSeconds(result.npfSeconds));
30
+ setText(root, '[data-output="rule500"]', formatSeconds(result.rule500Seconds));
31
+ setText(root, '[data-output="delta"]', `${result.npfDifferencePercent >= 0 ? '+' : ''}${result.npfDifferencePercent.toFixed(0)}%`);
32
+ setText(root, '[data-output="timeline-start"]', ui.timelineStart);
33
+ setText(root, '[data-output="timeline-end"]', ui.timelineEnd);
34
+ }
35
+
36
+ export function renderExposureTimeline(root: HTMLElement, result: ExposurePlannerResult): void {
37
+ const target = root.querySelector<HTMLElement>('[data-timeline]');
38
+ if (!target) return;
39
+ const slices = result.timeline.map((slice) => `<span class="exposure-slice${slice.isFinal ? ' is-final' : ''}" style="width:${slice.width}%" aria-label="Frame ${slice.index}"></span>`).join('');
40
+ target.innerHTML = `<div class="exposure-track" aria-label="${result.frameCount} planned frames">${slices}</div>`;
41
+ }
@@ -0,0 +1,29 @@
1
+ import type { AstronomyToolEntry } from '../../types';
2
+ import type { ExposurePlannerLocaleContent, ExposurePlannerUI } from './ui';
3
+
4
+ export type { ExposurePlannerLocaleContent, ExposurePlannerUI };
5
+
6
+ export const astrophotographyExposureStackPlanner: AstronomyToolEntry<ExposurePlannerUI> = {
7
+ id: 'astrophotography-exposure-stack-planner',
8
+ icons: {
9
+ bg: 'mdi:camera-iris',
10
+ fg: 'mdi:timer-outline',
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,44 @@
1
+ import type { ExposurePlannerInput, ExposurePlannerUI } from './ui';
2
+ import type { ExposurePlannerResult } from './ui';
3
+
4
+ export type EvaluationTone = 'calm' | 'attention' | 'critical';
5
+
6
+ export interface ExposureEvaluation {
7
+ tone: EvaluationTone;
8
+ status: string;
9
+ note: string;
10
+ }
11
+
12
+ function formatSeconds(seconds: number): string {
13
+ if (seconds < 10) return `${seconds.toFixed(1)} s`;
14
+ return `${Math.round(seconds)} s`;
15
+ }
16
+
17
+ function formatDuration(minutes: number): string {
18
+ if (minutes < 60) return `${minutes.toFixed(1)} min`;
19
+ const roundedMinutes = Math.round(minutes);
20
+ const hours = Math.floor(roundedMinutes / 60);
21
+ const remainingMinutes = roundedMinutes % 60;
22
+ return `${hours} h ${remainingMinutes} min`;
23
+ }
24
+
25
+ export function evaluateExposurePlan(
26
+ input: ExposurePlannerInput,
27
+ result: ExposurePlannerResult,
28
+ ui: ExposurePlannerUI,
29
+ ): ExposureEvaluation {
30
+ if (input.tracking === 'tracked') {
31
+ return {
32
+ tone: 'calm',
33
+ status: ui.tracked,
34
+ note: ui.trackingNote
35
+ .replace('{ceiling}', formatSeconds(result.selectedCeilingSeconds))
36
+ .replace('{frames}', result.frameCount.toLocaleString('en-US'))
37
+ .replace('{session}', formatDuration(result.sessionDurationMinutes)),
38
+ };
39
+ }
40
+ if (result.selectedCeilingSeconds < 2) {
41
+ return { tone: 'critical', status: ui.untracked, note: ui.untrackedNote.replace('{ceiling}', formatSeconds(result.selectedCeilingSeconds)) };
42
+ }
43
+ return { tone: 'attention', status: ui.untracked, note: ui.untrackedNote.replace('{ceiling}', formatSeconds(result.selectedCeilingSeconds)) };
44
+ }
@@ -0,0 +1,40 @@
1
+ import { createLocalizedContent } from '../localized';
2
+
3
+ export const content = createLocalizedContent({
4
+ slug: 'astrophotografie-belichtungsstapel-planer',
5
+ title: 'Planer für Belichtungsstapel in der Astrofotografie',
6
+ description: 'Plane Astrofotografie-Sitzungen mit NPF und der 500er-Regel, Bildzahl, Gesamtintegration und realer Feldzeit.',
7
+ ui: {
8
+ cameraLegend: 'Kamerageometrie', focalLabel: 'Brennweite', focalHelp: 'Brennweite von Objektiv oder Teleskop in mm', apertureLabel: 'Blende', apertureHelp: 'Nutze die Blendenzahl, zum Beispiel 2,8', pixelPitchLabel: 'Pixelgröße', pixelPitchHelp: 'Physische Größe des Fotosites in Mikrometern', declinationLabel: 'Deklination des Ziels', declinationHelp: 'Absolute Deklination in Grad', sensorLabel: 'Sensorformat', sensorOptions: { fullFrame: 'Vollformat', apsC: 'APS C', microFourThirds: 'Micro Four Thirds', phone: 'Smartphone Sensor' },
9
+ ruleLabel: 'Belichtungsgrenze', ruleNpf: 'NPF Formel', rule500: '500er Regel', trackingLabel: 'Montagestatus', untracked: 'Ohne Nachführung', tracked: 'Nachführmontierung', trackingExposureLabel: 'Belichtung pro geführtem Bild', trackingExposureHelp: 'Nur mit Nachführmontierung verwendet. Beginne mit 30 bis 120 s und prüfe Testbilder.', deadTimeLabel: 'Pause zwischen Bildern', deadTimeHelp: 'Beginne mit etwa 2 s und passe Puffer, Speichern und Intervall der Kamera an.', totalIntegrationLabel: 'Gewünschte Gesamtintegration', totalIntegrationHelp: 'Die nutzbare Belichtungszeit, die du sammeln möchtest', resultTitle: 'Dein Aufnahmeplan', resultKicker: 'Ein Ausgangspunkt für die Nacht', secondsUnit: 'pro Bild', framesLabel: 'Bilder', totalTimeLabel: 'Geplante Integration', sessionTimeLabel: 'Geschätzte Feldzeit', gainLabel: 'Idealer Stacking Gewinn', sessionDetail: 'Enthält {dead} zwischen den Bildern: {deadTotal} Intervallzeit.', workloadText: '{frames} Verschlusszyklen. Die Feldschätzung enthält die Pausen zwischen den Bildern.', highWorkloadText: '{frames} Verschlusszyklen. Prüfe elektronischen Verschluss, Akkus und Speicher. Geschätzte Feldzeit: {session}.', sensorPixelWarning: 'Prüfe die Kameradaten: {pixel} ist für APS C eine große Pixelgröße und kann die NPF Grenze verkürzen.', comparisonTitle: 'Zwei Grenzen, eine Entscheidung', npfLabel: 'NPF Grenze', rule500Label: 'Grenze der 500er Regel', deltaLabel: 'NPF Unterschied', timelineTitle: 'Der Stapel als Lichtstreifen', timelineStart: 'erstes Bild', timelineEnd: 'letztes Bild', trackingNote: 'Die Nachführung ist aktiv. Der Plan nutzt {ceiling} pro Bild, also {frames} Bilder und etwa {session} Feldzeit. Prüfe diese Dauer mit Ausrichtung, Guiding und einem Testbild; NPF bleibt die Vergleichsbasis ohne Nachführung.', untrackedNote: 'Nutze {ceiling} als Ausgangsgrenze und prüfe ein Testbild in voller Auflösung auf Sternform und Himmelshelligkeit.', methodTitle: 'So entsteht die Schätzung', methodText: 'Der NPF Weg nutzt Blende, Brennweite, Pixelgröße und Zieldeklination, um sichtbare Sternbewegung auf Pixelebene zu schätzen. Die 500er Regel nutzt nur Brennweite und Sensor Crop. Beide sind geometrische Heuristiken für eine Kamera ohne Nachführung. Mit einer Nachführmontierung verwendet der Plan deine eingestellte Belichtungszeit und zertifiziert die Montierung nicht.', limitsTitle: 'Grenze im Feld', limitsText: 'Nachführung, heller Himmel, ein hochauflösender Ausschnitt, Wind oder Fokusdrift können die praktische Belichtung ändern. Mache ein Testbild und entscheide nach Ecken, Histogramm und Montierungsverhalten.', startingStatus: 'Eingaben werden geprüft', formulaLabel: 'Referenzformeln', formulaNpf: 'NPF: (16,9 x Blende + 0,10 x Brennweite + 13,7 x Pixelgröße) / (Brennweite x cos Deklination).', formula500: '500er Regel: 500 / (Brennweite x Sensor Crop Faktor).', presetsLabel: 'Schnelle Setups', presets: { milkyWay: '24 mm Vollformat', wideAngle: '14 mm Weitwinkel', telephoto: '85 mm Teleobjektiv' },
10
+ },
11
+ seo: [
12
+ { type: 'title', text: 'Warum ein Belichtungsstapel eine Grenze braucht', level: 2 },
13
+ { type: 'paragraph', html: 'Ein Belichtungsstapel besteht aus getrennten Bildern, die später ausgerichtet und kombiniert werden. So sammelst du mehr Signal, ohne jedes Bild unbrauchbar lang zu belichten. Bei einer festen Kamera werden Sterne durch die scheinbare Himmelsbewegung länglich. Zu kurze Bilder machen die Sitzung dagegen durch Auslösepausen und Leserauschen ineffizient.' },
14
+ { type: 'paragraph', html: 'Der Planer macht daraus einen konkreten Aufnahmeablauf. Er zeigt NPF und die 500er Regel, teilt die gewünschte Integration in vollständige Bilder und rechnet die Pause zwischen Dateien in die Feldzeit ein. Das letzte Bild kann die gewünschte Zeit leicht überschreiten, weil keine Teilaufnahme möglich ist.' },
15
+ { type: 'list', items: ['Nutze NPF mit bekannter Blende und Pixelgröße.', 'Vergleiche die 500er Regel als schnelle Sensorberechnung.', 'Behandle die Bildzahl als echte Last für Speicher und Nachbearbeitung.', 'Mache vor dem kompletten Stapel ein Testbild.'] },
16
+ { type: 'tip', title: 'Eine Grenze ist keine Garantie', html: 'Prüfe helle Sterne und Bildecken in voller Auflösung. Wind, Koma, Fokusdrift und Vibration können stärker zählen als eine Formel.' },
17
+ { type: 'title', text: 'NPF und 500er Regel beantworten verschiedene Fragen', level: 2 },
18
+ { type: 'paragraph', html: 'Die 500er Regel ist schnell und bezieht den Crop Faktor des Sensors ein. Sie kennt Pixelgröße, Blende und Deklination nicht. NPF ergänzt diese Werte und ist dadurch stärker an Kamera und Ziel angepasst, bleibt aber eine Heuristik für eine Kamera ohne Nachführung.' },
19
+ { type: 'title', text: 'So wird das Ergebnis zu einer Nachtsitzung', level: 2 },
20
+ { type: 'paragraph', html: 'Trage die echten Werte des verwendeten Objektivs und Sensors ein. Verwende keine äquivalente Brennweite, wenn Brennweite und Sensorformat bereits getrennt erfasst sind. Bei der Pixelgröße sollte eine zuverlässige Modellspezifikation die Grundlage sein.' },
21
+ { type: 'list', items: ['Prüfe ein Testbild an den Ecken mit voller Auflösung.', 'Kontrolliere Histogramm und ausgebrannte Lichter.', 'Halte Fokus und Aufnahmeparameter konstant.', 'Plane Zeit für Dunkelbilder und Kalibrierung ein.'] },
22
+ { type: 'title', text: 'Was sich bei einer Nachführmontierung ändert', level: 2 },
23
+ { type: 'paragraph', html: 'Bei aktivierter Nachführung nutzt das Tool die eingegebene Belichtungsdauer pro Bild. NPF und 500er Regel bleiben als Vergleichswerte ohne Nachführung sichtbar, bestimmen aber nicht mehr die Bildzahl. Polausrichtung, periodischer Fehler, Guiding, Wind, Himmelshelligkeit und Sättigung können die neue Grenze bilden.' },
24
+ { type: 'tip', title: 'Erst testen, dann den ganzen Stapel aufnehmen', html: 'Nimm eine kurze Testserie auf, vergrößere die Ecken und prüfe die Sterne. Passe die geführte Belichtung an, bevor du zwei Stunden dunklen Himmel bindest.' },
25
+ { type: 'paragraph', html: 'Ein Beispiel zeigt den Unterschied: Bei 35 mm, APS C, 5,9 Mikrometern und 5 Grad Deklination liegt die NPF Grenze bei rund 3,8 Sekunden. Ohne Nachführung entstehen für zwei Stunden ungefähr 1.907 Bilder. Mit einer Nachführmontierung und 120 Sekunden pro Bild reichen dagegen 60 Bilder; fünf Sekunden Pause pro Bild verlängern die Feldzeit nur um fünf Minuten.' },
26
+ { type: 'paragraph', html: 'Beurteile die Feldzeit getrennt von der reinen Integrationszeit. Speicherpausen, Fokusprüfung, Dithering, Akkuwechsel und verworfene Bilder gehören in die Nachtplanung, auch wenn sie nicht zum Signal beitragen. Bei vielen kurzen Bildern kann ein elektronischer Verschluss die mechanische Belastung verringern, ersetzt aber keine Kontrolle von Puffer und Speicherkapazität.' },
27
+ { type: 'tip', title: 'Pixelgröße und Sensorformat prüfen', html: 'APS C mit 5,9 Mikrometern ist eine ungewöhnliche Kombination für moderne hochauflösende Kameras. Wenn die echte Pixelgröße kleiner ist, fällt die NPF Grenze kürzer aus. Prüfe deshalb das Kameramodell und mache immer eine Testaufnahme, bevor du die komplette Serie startest.' },
28
+ ],
29
+ faq: [
30
+ { question: 'Soll ich NPF oder die 500er Regel nutzen?', answer: 'Nutze NPF als kameranäheren Ausgangspunkt, wenn Blende und Pixelgröße bekannt sind. Die 500er Regel ist ein schneller Vergleich. Prüfe beide Ergebnisse mit einem Testbild.' },
31
+ { question: 'Was enthält die Bildzahl?', answer: 'Sie zählt vollständige Lichtbilder, die nötig sind, um die gewünschte Integration zu erreichen oder leicht zu überschreiten. Dunkelbilder, Flats, Bias, Pausen und Ausschuss sind nicht enthalten.' },
32
+ { question: 'Berechnet der Nachführmodus die Guiding Genauigkeit?', answer: 'Nein. Er verwendet deine eingegebene Belichtungsdauer. Ausrichtung, Guiding und Himmelshelligkeit müssen mit echten Testbildern geprüft werden.' },
33
+ ],
34
+ howTo: [
35
+ { name: 'Kamerageometrie eingeben', text: 'Trage Brennweite, Blende, Pixelgröße und Deklination des Ziels ein.' },
36
+ { name: 'Methode wählen', text: 'Wähle ohne Nachführung NPF oder 500er Regel, oder aktiviere die Nachführung und setze die Bilddauer.' },
37
+ { name: 'Integration festlegen', text: 'Gib die nutzbare Gesamtzeit ein und prüfe Bildzahl und Feldzeit.' },
38
+ { name: 'Im Feld validieren', text: 'Nimm ein Testbild auf, prüfe Sterne und Histogramm und passe die Serie an.' },
39
+ ],
40
+ });