@jjlmoya/utils-nautical 1.2.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 (74) hide show
  1. package/package.json +75 -0
  2. package/src/category/i18n/en.ts +79 -0
  3. package/src/category/i18n/es.ts +79 -0
  4. package/src/category/i18n/fr.ts +79 -0
  5. package/src/category/index.ts +13 -0
  6. package/src/components/PreviewNavSidebar.astro +116 -0
  7. package/src/components/PreviewToolbar.astro +143 -0
  8. package/src/data.ts +12 -0
  9. package/src/env.d.ts +5 -0
  10. package/src/index.ts +22 -0
  11. package/src/layouts/PreviewLayout.astro +117 -0
  12. package/src/pages/[locale]/[slug].astro +146 -0
  13. package/src/pages/[locale].astro +249 -0
  14. package/src/pages/index.astro +4 -0
  15. package/src/tests/faq_count.test.ts +30 -0
  16. package/src/tests/mocks/astro_mock.js +2 -0
  17. package/src/tests/seo_length.test.ts +29 -0
  18. package/src/tests/tool_validation.test.ts +51 -0
  19. package/src/tool/endurance/bibliography.astro +14 -0
  20. package/src/tool/endurance/component.astro +310 -0
  21. package/src/tool/endurance/i18n/en.ts +243 -0
  22. package/src/tool/endurance/i18n/es.ts +249 -0
  23. package/src/tool/endurance/i18n/fr.ts +217 -0
  24. package/src/tool/endurance/index.ts +56 -0
  25. package/src/tool/endurance/logic.ts +36 -0
  26. package/src/tool/endurance/seo.astro +55 -0
  27. package/src/tool/endurance/style.css +333 -0
  28. package/src/tool/nauticalConverter/bibliography.astro +14 -0
  29. package/src/tool/nauticalConverter/component.astro +481 -0
  30. package/src/tool/nauticalConverter/i18n/en.ts +239 -0
  31. package/src/tool/nauticalConverter/i18n/es.ts +239 -0
  32. package/src/tool/nauticalConverter/i18n/fr.ts +239 -0
  33. package/src/tool/nauticalConverter/index.ts +57 -0
  34. package/src/tool/nauticalConverter/logic.ts +52 -0
  35. package/src/tool/nauticalConverter/seo.astro +52 -0
  36. package/src/tool/nauticalConverter/style.css +205 -0
  37. package/src/tool/sailArea/bibliography.astro +14 -0
  38. package/src/tool/sailArea/component.astro +418 -0
  39. package/src/tool/sailArea/i18n/en.ts +275 -0
  40. package/src/tool/sailArea/i18n/es.ts +275 -0
  41. package/src/tool/sailArea/i18n/fr.ts +275 -0
  42. package/src/tool/sailArea/index.ts +53 -0
  43. package/src/tool/sailArea/logic.ts +55 -0
  44. package/src/tool/sailArea/seo.astro +52 -0
  45. package/src/tool/sailArea/style.css +351 -0
  46. package/src/tool/speedConverter/bibliography.astro +14 -0
  47. package/src/tool/speedConverter/component.astro +125 -0
  48. package/src/tool/speedConverter/i18n/en.ts +271 -0
  49. package/src/tool/speedConverter/i18n/es.ts +271 -0
  50. package/src/tool/speedConverter/i18n/fr.ts +271 -0
  51. package/src/tool/speedConverter/index.ts +46 -0
  52. package/src/tool/speedConverter/logic.ts +48 -0
  53. package/src/tool/speedConverter/seo.astro +43 -0
  54. package/src/tool/speedConverter/style.css +239 -0
  55. package/src/tool/tideCalculator/bibliography.astro +14 -0
  56. package/src/tool/tideCalculator/component.astro +314 -0
  57. package/src/tool/tideCalculator/i18n/en.ts +203 -0
  58. package/src/tool/tideCalculator/i18n/es.ts +225 -0
  59. package/src/tool/tideCalculator/i18n/fr.ts +190 -0
  60. package/src/tool/tideCalculator/index.ts +50 -0
  61. package/src/tool/tideCalculator/logic.ts +73 -0
  62. package/src/tool/tideCalculator/seo.astro +61 -0
  63. package/src/tool/tideCalculator/style.css +310 -0
  64. package/src/tool/underKeel/bibliography.astro +14 -0
  65. package/src/tool/underKeel/component.astro +240 -0
  66. package/src/tool/underKeel/i18n/en.ts +193 -0
  67. package/src/tool/underKeel/i18n/es.ts +206 -0
  68. package/src/tool/underKeel/i18n/fr.ts +193 -0
  69. package/src/tool/underKeel/index.ts +49 -0
  70. package/src/tool/underKeel/logic.ts +83 -0
  71. package/src/tool/underKeel/seo.astro +46 -0
  72. package/src/tool/underKeel/style.css +312 -0
  73. package/src/tools.ts +25 -0
  74. package/src/types.ts +69 -0
@@ -0,0 +1,83 @@
1
+ export interface TideParams {
2
+ highTime: number;
3
+ highHeight: number;
4
+ lowTime: number;
5
+ lowHeight: number;
6
+ }
7
+
8
+ export interface UnderKeelParams {
9
+ draft: number;
10
+ chartDepth: number;
11
+ safetyMargin: number;
12
+ tide: TideParams;
13
+ }
14
+
15
+ export type PassType = 'always' | 'never' | 'partial';
16
+
17
+ export interface PassWindow {
18
+ type: PassType;
19
+ start: number;
20
+ end: number;
21
+ neededDepth: number;
22
+ tideRequired: number;
23
+ }
24
+
25
+ interface CycleData {
26
+ flooding: boolean;
27
+ sT: number;
28
+ dur: number;
29
+ sH: number;
30
+ amp: number;
31
+ mult: number;
32
+ tH: number;
33
+ }
34
+
35
+ function getCycleData(tide: TideParams): CycleData {
36
+ const { highTime: tH, highHeight: hH, lowTime: tL, lowHeight: hL } = tide;
37
+ const flooding = tH > tL;
38
+ return {
39
+ flooding,
40
+ sT: flooding ? tL : tH,
41
+ dur: Math.abs(tH - tL),
42
+ sH: flooding ? hL : hH,
43
+ amp: Math.abs(hH - hL),
44
+ mult: flooding ? 1 : -1,
45
+ tH,
46
+ };
47
+ }
48
+
49
+ function findPassOffset(target: number, dur: number): number {
50
+ const doz = [1 / 12, 2 / 12, 3 / 12, 3 / 12, 2 / 12, 1 / 12];
51
+ const step = dur / 6;
52
+ let cum = 0;
53
+ for (let i = 0; i < 6; i++) {
54
+ const d = doz[i] ?? 0;
55
+ if (cum + d >= target) return (i + (target - cum) / d) * step;
56
+ cum += d;
57
+ }
58
+ return dur;
59
+ }
60
+
61
+ function calculatePartialWindow(tide: TideParams, tideRequired: number, neededDepth: number): PassWindow {
62
+ const { flooding, sT, dur, sH, amp, mult, tH } = getCycleData(tide);
63
+ const targetInt = (tideRequired - sH) / (mult * amp);
64
+ const tOff = findPassOffset(targetInt, dur);
65
+ const pT = sT + tOff;
66
+ if (flooding) return { type: 'partial', start: pT, end: tH + (tH - pT), neededDepth, tideRequired };
67
+ return { type: 'partial', start: tH - (pT - tH), end: pT, neededDepth, tideRequired };
68
+ }
69
+
70
+ export function calculatePassWindow(params: UnderKeelParams): PassWindow {
71
+ const { draft, chartDepth, safetyMargin, tide } = params;
72
+ const neededDepth = draft + safetyMargin;
73
+ const tideRequired = Math.max(0, neededDepth - chartDepth);
74
+ const maxTide = Math.max(tide.highHeight, tide.lowHeight);
75
+ const minTide = Math.min(tide.highHeight, tide.lowHeight);
76
+
77
+ if (tideRequired > maxTide) return { type: 'never', start: 0, end: 0, neededDepth, tideRequired };
78
+ if (tideRequired <= minTide) {
79
+ const { sT, dur } = getCycleData(tide);
80
+ return { type: 'always', start: sT, end: sT + dur, neededDepth, tideRequired };
81
+ }
82
+ return calculatePartialWindow(tide, tideRequired, neededDepth);
83
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ import {
3
+ SEOTitle,
4
+ SEOTable,
5
+ SEOTip,
6
+ SEOGlossary,
7
+ SEOArticle,
8
+ } from '@jjlmoya/utils-shared';
9
+ import { underKeel } from './index';
10
+ import type { KnownLocale } from '../../types';
11
+
12
+ interface Props {
13
+ locale?: KnownLocale;
14
+ }
15
+
16
+ const { locale = 'es' } = Astro.props;
17
+ const content = await underKeel.i18n[locale]?.();
18
+ if (!content) return null;
19
+
20
+ const { seo } = content;
21
+ ---
22
+
23
+ <SEOArticle>
24
+ {seo.map((section: any) => {
25
+ switch (section.type) {
26
+ case 'title':
27
+ return <SEOTitle title={section.text} level={section.level || 2} />;
28
+ case 'paragraph':
29
+ return <p set:html={section.html} />;
30
+ case 'tip':
31
+ return <SEOTip title={section.title}><Fragment set:html={section.html} /></SEOTip>;
32
+ case 'table':
33
+ return (
34
+ <SEOTable headers={section.headers}>
35
+ {section.rows.map((row: string[]) => (
36
+ <tr>{row.map((cell: string) => <td set:html={cell} />)}</tr>
37
+ ))}
38
+ </SEOTable>
39
+ );
40
+ case 'glossary':
41
+ return <SEOGlossary items={section.items} />;
42
+ default:
43
+ return null;
44
+ }
45
+ })}
46
+ </SEOArticle>
@@ -0,0 +1,312 @@
1
+ .under-keel-calculator {
2
+ --n-bg: #fff;
3
+ --n-bg-muted: #f1f5f9;
4
+ --n-text: #0f172a;
5
+ --n-text-muted: #475569;
6
+ --n-text-dim: #64748b;
7
+ --n-border: #e2e8f0;
8
+ --n-shadow: rgba(0, 0, 0, 0.05);
9
+ --n-primary: #3b82f6;
10
+ --n-primary-on: #fff;
11
+ --n-accent: #6366f1;
12
+ --n-cyan: #0891b2;
13
+ --n-success: #10b981;
14
+ --n-warning: #f59e0b;
15
+ --n-error: #f43f5e;
16
+
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: 1.5rem;
20
+ max-width: 1000px;
21
+ margin: 0 auto;
22
+ }
23
+
24
+ .theme-dark .under-keel-calculator {
25
+ --n-bg: #0f172a;
26
+ --n-bg-muted: #1e293b;
27
+ --n-text: #f8fafc;
28
+ --n-text-muted: #94a3b8;
29
+ --n-text-dim: #64748b;
30
+ --n-border: #334155;
31
+ --n-shadow: rgba(0, 0, 0, 0.3);
32
+ --n-primary: #60a5fa;
33
+ --n-primary-on: #fff;
34
+ --n-accent: #818cf8;
35
+ --n-cyan: #22d3ee;
36
+ --n-success: #34d399;
37
+ --n-warning: #fbbf24;
38
+ --n-error: #fb7185;
39
+ }
40
+
41
+ .ukc-main-card {
42
+ background: var(--n-bg);
43
+ border: 1px solid var(--n-border);
44
+ border-radius: 2rem;
45
+ display: grid;
46
+ grid-template-columns: 320px 1fr;
47
+ overflow: hidden;
48
+ box-shadow: 0 20px 40px var(--n-shadow);
49
+ }
50
+
51
+ @media (max-width: 850px) {
52
+ .ukc-main-card {
53
+ grid-template-columns: 1fr;
54
+ }
55
+ }
56
+
57
+ .ukc-calc-sidebar {
58
+ background: var(--n-primary);
59
+ color: var(--n-primary-on);
60
+ padding: 2rem;
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 2rem;
64
+ }
65
+
66
+ .ukc-sidebar-header h3 {
67
+ font-size: 1.1rem;
68
+ margin: 0;
69
+ font-weight: 700;
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.05em;
72
+ opacity: 0.9;
73
+ }
74
+
75
+ .ukc-input-set {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 1.2rem;
79
+ }
80
+
81
+ .ukc-input-block {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 0.4rem;
85
+ }
86
+
87
+ .ukc-input-block label {
88
+ font-size: 0.8rem;
89
+ font-weight: 600;
90
+ opacity: 0.7;
91
+ }
92
+
93
+ .ukc-unit-input {
94
+ position: relative;
95
+ display: flex;
96
+ align-items: center;
97
+ }
98
+
99
+ .ukc-unit-input input {
100
+ width: 100%;
101
+ background: rgba(255, 255, 255, 0.1);
102
+ border: 1px solid rgba(255, 255, 255, 0.2);
103
+ border-radius: 0.8rem;
104
+ padding: 0.7rem 2.5rem 0.7rem 1rem;
105
+ color: var(--n-primary-on);
106
+ font-size: 1rem;
107
+ outline: none;
108
+ }
109
+
110
+ .ukc-unit-input span {
111
+ position: absolute;
112
+ right: 12px;
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ opacity: 0.4;
116
+ }
117
+
118
+ .ukc-hr-divider {
119
+ height: 1px;
120
+ background: rgba(255, 255, 255, 0.1);
121
+ margin: 0.5rem 0;
122
+ }
123
+
124
+ .ukc-row {
125
+ display: flex;
126
+ gap: 0.5rem;
127
+ }
128
+
129
+ .under-keel-calculator input[type='time'],
130
+ .under-keel-calculator input[type='number'] {
131
+ background: rgba(255, 255, 255, 0.1);
132
+ border: 1px solid rgba(255, 255, 255, 0.2);
133
+ border-radius: 0.8rem;
134
+ padding: 0.7rem;
135
+ color: var(--n-primary-on);
136
+ font-size: 1rem;
137
+ outline: none;
138
+ }
139
+
140
+ .ukc-mini-num {
141
+ width: 80px;
142
+ }
143
+
144
+ .ukc-calc-content {
145
+ padding: 2.5rem;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 2rem;
149
+ background: var(--n-bg-muted);
150
+ }
151
+
152
+ .ukc-top-row {
153
+ display: flex;
154
+ justify-content: space-between;
155
+ align-items: flex-start;
156
+ }
157
+
158
+ .ukc-result-display {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 0.5rem;
162
+ }
163
+
164
+ .ukc-result-display .label {
165
+ font-size: 0.85rem;
166
+ font-weight: 600;
167
+ color: var(--n-text-muted);
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.05em;
170
+ }
171
+
172
+ .ukc-time-range {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 1.5rem;
176
+ font-size: 3.5rem;
177
+ font-weight: 800;
178
+ color: var(--n-text);
179
+ line-height: 1;
180
+ }
181
+
182
+ .ukc-arrow-sep {
183
+ color: var(--n-cyan);
184
+ opacity: 0.4;
185
+ }
186
+
187
+ .ukc-status-indicator {
188
+ padding: 0.4rem 1.2rem;
189
+ border-radius: 2rem;
190
+ font-size: 0.9rem;
191
+ font-weight: 700;
192
+ width: fit-content;
193
+ background: var(--n-bg-muted);
194
+ color: var(--n-text-dim);
195
+ }
196
+
197
+ .ukc-status-indicator.success {
198
+ background: color-mix(in srgb, var(--n-success), transparent 80%);
199
+ color: var(--n-success);
200
+ }
201
+
202
+ .ukc-status-indicator.warning {
203
+ background: color-mix(in srgb, var(--n-warning), transparent 80%);
204
+ color: var(--n-warning);
205
+ }
206
+
207
+ .ukc-status-indicator.danger {
208
+ background: color-mix(in srgb, var(--n-error), transparent 80%);
209
+ color: var(--n-error);
210
+ }
211
+
212
+ .ukc-data-group {
213
+ display: flex;
214
+ flex-direction: column;
215
+ gap: 0.8rem;
216
+ }
217
+
218
+ .ukc-stat-pill {
219
+ background: var(--n-bg);
220
+ padding: 0.5rem 1rem;
221
+ border-radius: 0.8rem;
222
+ border: 1px solid var(--n-border);
223
+ display: flex;
224
+ justify-content: space-between;
225
+ gap: 2rem;
226
+ font-size: 0.9rem;
227
+ }
228
+
229
+ .ukc-stat-pill span {
230
+ color: var(--n-text-muted);
231
+ font-weight: 500;
232
+ }
233
+
234
+ .ukc-stat-pill strong {
235
+ color: var(--n-text);
236
+ font-weight: 700;
237
+ }
238
+
239
+ .ukc-visual-container {
240
+ background: var(--n-bg);
241
+ border: 1px solid var(--n-border);
242
+ border-radius: 1.5rem;
243
+ padding: 2rem;
244
+ height: 300px;
245
+ display: flex;
246
+ justify-content: center;
247
+ position: relative;
248
+ }
249
+
250
+ .ukc-ocean-frame {
251
+ width: 200px;
252
+ height: 100%;
253
+ background: var(--n-bg-muted);
254
+ border: 4px solid var(--n-border);
255
+ border-radius: 1rem;
256
+ position: relative;
257
+ overflow: hidden;
258
+ }
259
+
260
+ .ukc-water-volume {
261
+ position: absolute;
262
+ bottom: 0;
263
+ width: 100%;
264
+ background: linear-gradient(to top, var(--n-primary), var(--n-cyan));
265
+ opacity: 0.6;
266
+ transition: height 0.6s ease;
267
+ }
268
+
269
+ .ukc-surface-line {
270
+ position: absolute;
271
+ top: 0;
272
+ width: 100%;
273
+ height: 3px;
274
+ background: var(--n-cyan);
275
+ }
276
+
277
+ .ukc-ship-silhouette {
278
+ position: absolute;
279
+ left: 20px;
280
+ right: 20px;
281
+ height: 30px;
282
+ color: var(--n-text);
283
+ z-index: 10;
284
+ transition: bottom 0.6s ease;
285
+ }
286
+
287
+ .ukc-sand-bottom {
288
+ position: absolute;
289
+ bottom: 0;
290
+ width: 100%;
291
+ height: 40px;
292
+ background: color-mix(in srgb, var(--n-warning), transparent 40%);
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ font-size: 0.7rem;
297
+ font-weight: 800;
298
+ color: var(--n-text);
299
+ letter-spacing: 0.2em;
300
+ z-index: 5;
301
+ }
302
+
303
+ @media (max-width: 640px) {
304
+ .ukc-time-range {
305
+ font-size: 2.2rem;
306
+ }
307
+
308
+ .ukc-top-row {
309
+ flex-direction: column;
310
+ gap: 2rem;
311
+ }
312
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { TIDE_CALCULATOR_TOOL } from './tool/tideCalculator';
2
+ import { UNDER_KEEL_TOOL } from './tool/underKeel';
3
+ import { NAUTICAL_CONVERTER_TOOL } from './tool/nauticalConverter';
4
+ import { SAIL_AREA_TOOL } from './tool/sailArea';
5
+ import { SPEED_CONVERTER_TOOL } from './tool/speedConverter';
6
+ import { ENDURANCE_TOOL } from './tool/endurance';
7
+ import type { ToolDefinition } from './types';
8
+
9
+ export const ALL_TOOLS: ToolDefinition[] = [
10
+ TIDE_CALCULATOR_TOOL,
11
+ UNDER_KEEL_TOOL,
12
+ NAUTICAL_CONVERTER_TOOL,
13
+ SAIL_AREA_TOOL,
14
+ SPEED_CONVERTER_TOOL,
15
+ ENDURANCE_TOOL,
16
+ ];
17
+
18
+ export {
19
+ TIDE_CALCULATOR_TOOL,
20
+ UNDER_KEEL_TOOL,
21
+ NAUTICAL_CONVERTER_TOOL,
22
+ SAIL_AREA_TOOL,
23
+ SPEED_CONVERTER_TOOL,
24
+ ENDURANCE_TOOL,
25
+ };
package/src/types.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { SEOSection } from '@jjlmoya/utils-shared';
2
+ import type { WithContext, Thing } from 'schema-dts';
3
+
4
+ export type { SEOSection };
5
+
6
+ export type KnownLocale =
7
+ | 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
8
+ | 'fr' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
9
+ | 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
10
+
11
+ export interface FAQItem {
12
+ question: string;
13
+ answer: string;
14
+ }
15
+
16
+ export interface BibliographyEntry {
17
+ name: string;
18
+ url: string;
19
+ }
20
+
21
+ export interface HowToStep {
22
+ name: string;
23
+ text: string;
24
+ }
25
+
26
+ export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
27
+ slug: string;
28
+ title: string;
29
+ description: string;
30
+ ui: TUI;
31
+ seo: SEOSection[];
32
+ faq: FAQItem[];
33
+ bibliography: BibliographyEntry[];
34
+ howTo: HowToStep[];
35
+ schemas: WithContext<Thing>[];
36
+ }
37
+
38
+ export interface CategoryLocaleContent {
39
+ slug: string;
40
+ title: string;
41
+ description: string;
42
+ seo: SEOSection[];
43
+ }
44
+
45
+ export type LocaleLoader<T> = () => Promise<T>;
46
+
47
+ export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
48
+
49
+ export interface NauticalToolEntry<TUI extends Record<string, string> = Record<string, string>> {
50
+ id: string;
51
+ icons: {
52
+ bg: string;
53
+ fg: string;
54
+ };
55
+ i18n: LocaleMap<ToolLocaleContent<TUI>>;
56
+ }
57
+
58
+ export interface NauticalCategoryEntry {
59
+ icon: string;
60
+ tools: NauticalToolEntry[];
61
+ i18n: LocaleMap<CategoryLocaleContent>;
62
+ }
63
+
64
+ export interface ToolDefinition {
65
+ entry: NauticalToolEntry<Record<string, string>>;
66
+ Component: unknown;
67
+ SEOComponent: unknown;
68
+ BibliographyComponent: unknown;
69
+ }