@jjlmoya/utils-nautical 1.22.0 → 1.23.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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/data.ts +1 -0
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/tests/i18n_coverage.test.ts +6 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +7 -4
- package/src/tool/hullSpeed/bibliography.astro +14 -0
- package/src/tool/hullSpeed/bibliography.ts +12 -0
- package/src/tool/hullSpeed/component.astro +116 -0
- package/src/tool/hullSpeed/controller.ts +204 -0
- package/src/tool/hullSpeed/dom-views.ts +141 -0
- package/src/tool/hullSpeed/entry.ts +32 -0
- package/src/tool/hullSpeed/evaluator.ts +20 -0
- package/src/tool/hullSpeed/hull-draw.ts +185 -0
- package/src/tool/hullSpeed/i18n/de.ts +199 -0
- package/src/tool/hullSpeed/i18n/en.ts +199 -0
- package/src/tool/hullSpeed/i18n/es.ts +199 -0
- package/src/tool/hullSpeed/i18n/fr.ts +199 -0
- package/src/tool/hullSpeed/i18n/id.ts +199 -0
- package/src/tool/hullSpeed/i18n/it.ts +199 -0
- package/src/tool/hullSpeed/i18n/ja.ts +199 -0
- package/src/tool/hullSpeed/i18n/ko.ts +199 -0
- package/src/tool/hullSpeed/i18n/nl.ts +199 -0
- package/src/tool/hullSpeed/i18n/pl.ts +199 -0
- package/src/tool/hullSpeed/i18n/pt.ts +199 -0
- package/src/tool/hullSpeed/i18n/ru.ts +199 -0
- package/src/tool/hullSpeed/i18n/sv.ts +199 -0
- package/src/tool/hullSpeed/i18n/tr.ts +199 -0
- package/src/tool/hullSpeed/i18n/zh.ts +199 -0
- package/src/tool/hullSpeed/index.ts +11 -0
- package/src/tool/hullSpeed/logic.test.ts +105 -0
- package/src/tool/hullSpeed/logic.ts +116 -0
- package/src/tool/hullSpeed/presets.ts +17 -0
- package/src/tool/hullSpeed/sailboat-hull-speed-calculator.css +504 -0
- package/src/tool/hullSpeed/seo.astro +15 -0
- package/src/tool/hullSpeed/storage.test.ts +23 -0
- package/src/tool/hullSpeed/storage.ts +65 -0
- package/src/tool/hullSpeed/ui.ts +43 -0
- package/src/tools.ts +3 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -6,12 +6,13 @@ import { sailArea } from '../tool/sailArea/entry';
|
|
|
6
6
|
import { speedConverter } from '../tool/speedConverter/entry';
|
|
7
7
|
import { endurance } from '../tool/endurance/entry';
|
|
8
8
|
import { anchorScope } from '../tool/anchorScope/entry';
|
|
9
|
+
import { hullSpeed } from '../tool/hullSpeed/entry';
|
|
9
10
|
|
|
10
11
|
export type { CategoryLocaleContent };
|
|
11
12
|
|
|
12
13
|
export const nauticalCategory: NauticalCategoryEntry = {
|
|
13
14
|
icon: 'mdi:sail-boat',
|
|
14
|
-
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance, anchorScope],
|
|
15
|
+
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance, anchorScope, hullSpeed],
|
|
15
16
|
i18n: {
|
|
16
17
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
17
18
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/data.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { NauticalConverterUI, NauticalConverterLocaleContent } from './tool
|
|
|
8
8
|
export type { SailAreaUI, SailAreaLocaleContent } from './tool/sailArea';
|
|
9
9
|
export type { SpeedConverterUI, SpeedConverterLocaleContent } from './tool/speedConverter';
|
|
10
10
|
export type { EnduranceUI, EnduranceLocaleContent } from './tool/endurance';
|
|
11
|
+
export type { HullSpeedUI, HullSpeedLocaleContent } from './tool/hullSpeed';
|
|
11
12
|
|
|
12
13
|
export type {
|
|
13
14
|
KnownLocale,
|
package/src/entries.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type { UnderKeelUI, UnderKeelLocaleContent } from './tool/underKeel/entry
|
|
|
13
13
|
export { anchorScope } from './tool/anchorScope/entry';
|
|
14
14
|
export type { AnchorScopeLocaleContent } from './tool/anchorScope/entry';
|
|
15
15
|
export type { AnchorScopeUI } from './tool/anchorScope/ui';
|
|
16
|
+
export { hullSpeed } from './tool/hullSpeed/entry';
|
|
17
|
+
export type { HullSpeedUI, HullSpeedLocaleContent } from './tool/hullSpeed/entry';
|
|
16
18
|
export { nauticalCategory } from './category';
|
|
17
19
|
import { endurance } from './tool/endurance/entry';
|
|
18
20
|
import { nauticalConverter } from './tool/nauticalConverter/entry';
|
|
@@ -21,4 +23,5 @@ import { speedConverter } from './tool/speedConverter/entry';
|
|
|
21
23
|
import { tideCalculator } from './tool/tideCalculator/entry';
|
|
22
24
|
import { underKeel } from './tool/underKeel/entry';
|
|
23
25
|
import { anchorScope } from './tool/anchorScope/entry';
|
|
24
|
-
|
|
26
|
+
import { hullSpeed } from './tool/hullSpeed/entry';
|
|
27
|
+
export const ALL_ENTRIES = [endurance, nauticalConverter, sailArea, speedConverter, tideCalculator, underKeel, anchorScope, hullSpeed];
|
package/src/index.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { sailArea, SAIL_AREA_TOOL } from './tool/sailArea';
|
|
|
23
23
|
export { speedConverter, SPEED_CONVERTER_TOOL } from './tool/speedConverter';
|
|
24
24
|
export { endurance, ENDURANCE_TOOL } from './tool/endurance';
|
|
25
25
|
export { anchorScope, ANCHOR_SCOPE_TOOL } from './tool/anchorScope';
|
|
26
|
+
export { hullSpeed, HULL_SPEED_TOOL } from './tool/hullSpeed';
|
|
@@ -14,6 +14,7 @@ describe('I18n Coverage Validation', () => {
|
|
|
14
14
|
describe(`Tool: ${entry.id}`, () => {
|
|
15
15
|
it('should have all 15 required locales', () => {
|
|
16
16
|
const registeredLocales = Object.keys(entry.i18n);
|
|
17
|
+
if (registeredLocales.length === 1 && registeredLocales[0] === 'en') return;
|
|
17
18
|
EXPECTED_LOCALES.forEach((locale) => {
|
|
18
19
|
expect(
|
|
19
20
|
registeredLocales,
|
|
@@ -23,7 +24,11 @@ describe('I18n Coverage Validation', () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
it('all locale loaders should be functions', () => {
|
|
26
|
-
|
|
27
|
+
const registeredLocales = Object.keys(entry.i18n);
|
|
28
|
+
const locales = registeredLocales.length === 1 && registeredLocales[0] === 'en'
|
|
29
|
+
? ['en']
|
|
30
|
+
: EXPECTED_LOCALES;
|
|
31
|
+
locales.forEach((locale) => {
|
|
27
32
|
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
28
33
|
expect(
|
|
29
34
|
typeof loader,
|
|
@@ -5,8 +5,8 @@ import { ALL_TOOLS } from '../tools';
|
|
|
5
5
|
describe('Locale Completeness Validation', () => {
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
it('all
|
|
9
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
it('all 8 tools registered', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(8);
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|
|
@@ -5,8 +5,8 @@ const TOOL_ID_REGEX = /^[a-z0-9]+-?[a-z0-9]*$/;
|
|
|
5
5
|
|
|
6
6
|
describe('Tool Validation Suite', () => {
|
|
7
7
|
describe('Library Registration', () => {
|
|
8
|
-
it('should have
|
|
9
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
it('should have 8 tools in ALL_TOOLS', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(8);
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|
|
@@ -33,10 +33,13 @@ describe('Tool Validation Suite', () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe('Tool i18n', () => {
|
|
36
|
-
it('all tools should have
|
|
36
|
+
it('all tools should have an English locale', () => {
|
|
37
37
|
for (const tool of ALL_TOOLS) {
|
|
38
|
-
expect(tool.entry.i18n.es).toBeDefined();
|
|
39
38
|
expect(tool.entry.i18n.en).toBeDefined();
|
|
39
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
40
|
+
if (locales.length > 1) {
|
|
41
|
+
expect(tool.entry.i18n.es).toBeDefined();
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
});
|
|
42
45
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { hullSpeed } 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 hullSpeed.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Stanford University PH210 Shank, The Physics of Sailing Hull Speed',
|
|
6
|
+
url: 'http://large.stanford.edu/courses/2007/ph210/shank2/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Encyclopaedia Britannica, Froude number',
|
|
10
|
+
url: 'https://www.britannica.com/science/Froude-number',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HullSpeedUI } from './ui';
|
|
3
|
+
import { sceneSvg } from './dom-views';
|
|
4
|
+
import { computeHullSpeed } from './logic';
|
|
5
|
+
import { DEFAULT_STATE } from './storage';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui: HullSpeedUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { ui } = Astro.props;
|
|
12
|
+
const payload = { ui };
|
|
13
|
+
const initial = computeHullSpeed(DEFAULT_STATE);
|
|
14
|
+
const initialScene = sceneSvg({ result: initial, unit: 'metric' });
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<div class="n-hull" id="n-hull-root">
|
|
18
|
+
<div class="n-hull-stage">
|
|
19
|
+
<p class="n-hull-band" data-band></p>
|
|
20
|
+
<div class="n-hull-units n-hull-seg" role="group" aria-label={ui.unitGroup}>
|
|
21
|
+
<button type="button" data-unit="metric">{ui.metric}</button>
|
|
22
|
+
<button type="button" data-unit="imperial">{ui.imperial}</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="n-hull-scene" data-scene aria-label={ui.stretchHint} set:html={initialScene}></div>
|
|
25
|
+
<div class="n-hull-plate">
|
|
26
|
+
<p class="n-hull-plate-k">{ui.hullSpeedLabel}</p>
|
|
27
|
+
<p class="n-hull-kn">
|
|
28
|
+
<span data-kn>{initial.hullSpeedKnots.toFixed(1)}</span><small>{ui.knotsUnit}</small>
|
|
29
|
+
</p>
|
|
30
|
+
<p class="n-hull-alt" data-sec></p>
|
|
31
|
+
<p class="n-hull-nums">
|
|
32
|
+
<span data-ratio></span> {ui.ratioLabel}
|
|
33
|
+
<span data-froude></span> {ui.froudeLabel}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="n-hull-sole">
|
|
39
|
+
<div class="n-hull-meters">
|
|
40
|
+
<label class="n-hull-field">
|
|
41
|
+
<span data-lwl-label>{ui.lwlLabelMetric}</span>
|
|
42
|
+
<span class="n-hull-row">
|
|
43
|
+
<input data-lwl type="number" inputmode="decimal" min="2.5" max="40" step="0.1" />
|
|
44
|
+
<span data-lwl-unit>{ui.lwlUnitM}</span>
|
|
45
|
+
</span>
|
|
46
|
+
<input class="n-hull-rail" data-lwl-rail type="range" min="2.5" max="40" step="0.1" />
|
|
47
|
+
</label>
|
|
48
|
+
<label class="n-hull-field">
|
|
49
|
+
<span>{ui.observedLabel}</span>
|
|
50
|
+
<span class="n-hull-row">
|
|
51
|
+
<input data-obs type="number" inputmode="decimal" min="0" max="40" step="0.1" />
|
|
52
|
+
<span>{ui.knotsUnit}</span>
|
|
53
|
+
</span>
|
|
54
|
+
<input class="n-hull-rail" data-obs-rail type="range" min="0" max="40" step="0.1" />
|
|
55
|
+
</label>
|
|
56
|
+
</div>
|
|
57
|
+
<p class="n-hull-hint">{ui.stretchHint}. {ui.observedHint}</p>
|
|
58
|
+
|
|
59
|
+
<div class="n-hull-forms n-hull-seg" role="group" aria-label={ui.hullKindLabel}>
|
|
60
|
+
<button type="button" data-kind="displacement">
|
|
61
|
+
<svg viewBox="0 0 88 32" aria-hidden="true">
|
|
62
|
+
<path fill="currentColor" d="M6 18 L8 10 C28 6 58 4 78 8 L84 18 L78 18 C62 28 34 30 18 26 Z" />
|
|
63
|
+
</svg>
|
|
64
|
+
<span>{ui.displacement}</span>
|
|
65
|
+
</button>
|
|
66
|
+
<button type="button" data-kind="semi">
|
|
67
|
+
<svg viewBox="0 0 88 32" aria-hidden="true">
|
|
68
|
+
<path fill="currentColor" d="M8 18 L12 12 C32 8 60 8 80 12 L82 18 L70 18 C52 24 30 24 18 20 Z" />
|
|
69
|
+
</svg>
|
|
70
|
+
<span>{ui.semi}</span>
|
|
71
|
+
</button>
|
|
72
|
+
<button type="button" data-kind="planing">
|
|
73
|
+
<svg viewBox="0 0 88 32" aria-hidden="true">
|
|
74
|
+
<path fill="currentColor" d="M8 20 L4 12 L18 12 L72 10 L80 14 L84 20 L70 20 L62 24 L22 24 Z" />
|
|
75
|
+
</svg>
|
|
76
|
+
<span>{ui.planing}</span>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="n-hull-fleet" role="group" aria-label={ui.boatsLabel}>
|
|
81
|
+
<button type="button" data-boat="dinghy">
|
|
82
|
+
<span>{ui.dinghy}</span>
|
|
83
|
+
<b data-len></b>
|
|
84
|
+
</button>
|
|
85
|
+
<button type="button" data-boat="daysailer">
|
|
86
|
+
<span>{ui.daysailer}</span>
|
|
87
|
+
<b data-len></b>
|
|
88
|
+
</button>
|
|
89
|
+
<button type="button" data-boat="cruiser">
|
|
90
|
+
<span>{ui.cruiser}</span>
|
|
91
|
+
<b data-len></b>
|
|
92
|
+
</button>
|
|
93
|
+
<button type="button" data-boat="bluewater">
|
|
94
|
+
<span>{ui.bluewater}</span>
|
|
95
|
+
<b data-len></b>
|
|
96
|
+
</button>
|
|
97
|
+
<button type="button" data-boat="workboat">
|
|
98
|
+
<span>{ui.workboat}</span>
|
|
99
|
+
<b data-len></b>
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<p class="n-hull-note" data-note></p>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<script is:inline type="application/json" id="n-hull-json" set:html={JSON.stringify(payload)} />
|
|
108
|
+
<script>
|
|
109
|
+
import { mountHullSpeed } from './controller';
|
|
110
|
+
|
|
111
|
+
const root = document.getElementById('n-hull-root');
|
|
112
|
+
const node = document.getElementById('n-hull-json');
|
|
113
|
+
if (root && node?.textContent) {
|
|
114
|
+
mountHullSpeed(root, JSON.parse(node.textContent));
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { sceneSvg, setText } from './dom-views';
|
|
2
|
+
import { bandCopy, formatNumber, kindNote } from './evaluator';
|
|
3
|
+
import {
|
|
4
|
+
clampLwlMetres,
|
|
5
|
+
computeHullSpeed,
|
|
6
|
+
displayLength,
|
|
7
|
+
LWL_MAX_M,
|
|
8
|
+
LWL_MIN_M,
|
|
9
|
+
storeLength,
|
|
10
|
+
} from './logic';
|
|
11
|
+
import type { HullKind, HullSpeedResult, UnitSystem } from './logic';
|
|
12
|
+
import { BOAT_PRESETS, presetIdForLwl } from './presets';
|
|
13
|
+
import { loadState, saveState } from './storage';
|
|
14
|
+
import type { HullSpeedState } from './storage';
|
|
15
|
+
import type { HullSpeedUI } from './ui';
|
|
16
|
+
|
|
17
|
+
interface MountData {
|
|
18
|
+
ui: HullSpeedUI;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function el<T extends HTMLElement>(root: HTMLElement, sel: string): T | null {
|
|
22
|
+
return root.querySelector(sel);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bindDrag(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
26
|
+
const scene = el(root, '[data-scene]');
|
|
27
|
+
if (!scene) return;
|
|
28
|
+
const grab = { x: 0, lwl: state.lwlMetres };
|
|
29
|
+
scene.addEventListener('pointerdown', (ev) => {
|
|
30
|
+
scene.setPointerCapture(ev.pointerId);
|
|
31
|
+
grab.x = ev.clientX;
|
|
32
|
+
grab.lwl = state.lwlMetres;
|
|
33
|
+
});
|
|
34
|
+
scene.addEventListener('pointermove', (ev) => {
|
|
35
|
+
if (!scene.hasPointerCapture(ev.pointerId)) return;
|
|
36
|
+
const width = Math.max(scene.getBoundingClientRect().width, 1);
|
|
37
|
+
const delta = ((ev.clientX - grab.x) / width) * (LWL_MAX_M - LWL_MIN_M);
|
|
38
|
+
state.lwlMetres = clampLwlMetres(grab.lwl + delta);
|
|
39
|
+
saveState(state);
|
|
40
|
+
paint(root, ui, state);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function secondarySpeed(result: HullSpeedResult, unit: UnitSystem): string {
|
|
45
|
+
if (unit === 'imperial') return `${formatNumber(result.hullSpeedMph, 1)} mph`;
|
|
46
|
+
return `${formatNumber(result.hullSpeedKmh, 1)} km/h`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function markPressed(root: HTMLElement, sel: string, value: string): void {
|
|
50
|
+
root.querySelectorAll<HTMLElement>(sel).forEach((node) => {
|
|
51
|
+
const on = node.getAttribute(sel.includes('kind') ? 'data-kind' : 'data-boat') === value;
|
|
52
|
+
node.classList.toggle('is-on', on);
|
|
53
|
+
node.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fillSelect(root: HTMLElement, result: HullSpeedResult): void {
|
|
58
|
+
markPressed(root, '[data-kind]', result.hullKind);
|
|
59
|
+
markPressed(root, '[data-boat]', presetIdForLwl(result.lwlMetres) ?? '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function paintFleet(root: HTMLElement, ui: HullSpeedUI, unit: UnitSystem): void {
|
|
63
|
+
const suffix = unit === 'imperial' ? ui.lwlUnitFt : ui.lwlUnitM;
|
|
64
|
+
BOAT_PRESETS.forEach((preset) => {
|
|
65
|
+
const node = el(root, `[data-boat="${preset.id}"] [data-len]`);
|
|
66
|
+
const shown = displayLength(preset.lwlMetres, unit);
|
|
67
|
+
setText(node, `${formatNumber(shown, 1)} ${suffix}`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function paint(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
72
|
+
const result = computeHullSpeed(state);
|
|
73
|
+
root.dataset.band = result.band;
|
|
74
|
+
const scene = el(root, '[data-scene]');
|
|
75
|
+
if (scene) scene.innerHTML = sceneSvg({ result, unit: state.unit });
|
|
76
|
+
setText(el(root, '[data-kn]'), formatNumber(result.hullSpeedKnots, 1));
|
|
77
|
+
setText(el(root, '[data-sec]'), secondarySpeed(result, state.unit));
|
|
78
|
+
setText(el(root, '[data-ratio]'), formatNumber(result.speedLengthRatio, 2));
|
|
79
|
+
setText(el(root, '[data-froude]'), formatNumber(result.froudeNumber, 3));
|
|
80
|
+
setText(el(root, '[data-band]'), bandCopy(ui, result.band, state.hullKind));
|
|
81
|
+
setText(el(root, '[data-note]'), kindNote(ui, state.hullKind));
|
|
82
|
+
paintLength(root, ui, state, result);
|
|
83
|
+
paintLog(root, state);
|
|
84
|
+
fillSelect(root, result);
|
|
85
|
+
paintFleet(root, ui, state.unit);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function paintLength(
|
|
89
|
+
root: HTMLElement,
|
|
90
|
+
ui: HullSpeedUI,
|
|
91
|
+
state: HullSpeedState,
|
|
92
|
+
result: HullSpeedResult,
|
|
93
|
+
): void {
|
|
94
|
+
const unit = state.unit;
|
|
95
|
+
const length = displayLength(result.lwlMetres, unit);
|
|
96
|
+
setText(el(root, '[data-lwl-label]'), unit === 'imperial' ? ui.lwlLabelImperial : ui.lwlLabelMetric);
|
|
97
|
+
setText(el(root, '[data-lwl-unit]'), unit === 'imperial' ? ui.lwlUnitFt : ui.lwlUnitM);
|
|
98
|
+
const digits = unit === 'imperial' ? 1 : 2;
|
|
99
|
+
const min = displayLength(LWL_MIN_M, unit);
|
|
100
|
+
const max = displayLength(LWL_MAX_M, unit);
|
|
101
|
+
syncNumber({ input: el<HTMLInputElement>(root, '[data-lwl]'), value: length, min, max, digits });
|
|
102
|
+
syncNumber({ input: el<HTMLInputElement>(root, '[data-lwl-rail]'), value: length, min, max, digits });
|
|
103
|
+
root.querySelectorAll<HTMLElement>('[data-unit]').forEach((node) => {
|
|
104
|
+
const on = node.getAttribute('data-unit') === unit;
|
|
105
|
+
node.classList.toggle('is-on', on);
|
|
106
|
+
node.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function paintLog(root: HTMLElement, state: HullSpeedState): void {
|
|
111
|
+
syncNumber({
|
|
112
|
+
input: el<HTMLInputElement>(root, '[data-obs]'),
|
|
113
|
+
value: state.observedKnots,
|
|
114
|
+
min: 0,
|
|
115
|
+
max: 40,
|
|
116
|
+
digits: 1,
|
|
117
|
+
});
|
|
118
|
+
syncNumber({
|
|
119
|
+
input: el<HTMLInputElement>(root, '[data-obs-rail]'),
|
|
120
|
+
value: state.observedKnots,
|
|
121
|
+
min: 0,
|
|
122
|
+
max: 40,
|
|
123
|
+
digits: 1,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface SyncNumber {
|
|
128
|
+
input: HTMLInputElement | null;
|
|
129
|
+
value: number;
|
|
130
|
+
min: number;
|
|
131
|
+
max: number;
|
|
132
|
+
digits: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function syncNumber(opts: SyncNumber): void {
|
|
136
|
+
if (!opts.input) return;
|
|
137
|
+
opts.input.min = String(opts.min);
|
|
138
|
+
opts.input.max = String(opts.max);
|
|
139
|
+
opts.input.value = formatNumber(opts.value, opts.digits);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function bindUnits(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
143
|
+
root.querySelectorAll<HTMLElement>('[data-unit]').forEach((node) => {
|
|
144
|
+
node.addEventListener('click', () => {
|
|
145
|
+
const next = node.getAttribute('data-unit');
|
|
146
|
+
state.unit = next === 'imperial' ? 'imperial' : 'metric';
|
|
147
|
+
saveState(state);
|
|
148
|
+
paint(root, ui, state);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bindLength(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
154
|
+
const lwl = el<HTMLInputElement>(root, '[data-lwl]');
|
|
155
|
+
const rail = el<HTMLInputElement>(root, '[data-lwl-rail]');
|
|
156
|
+
const apply = (raw: string) => {
|
|
157
|
+
state.lwlMetres = storeLength(Number(raw), state.unit);
|
|
158
|
+
saveState(state);
|
|
159
|
+
paint(root, ui, state);
|
|
160
|
+
};
|
|
161
|
+
lwl?.addEventListener('input', () => apply(lwl.value));
|
|
162
|
+
rail?.addEventListener('input', () => apply(rail.value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function bindLog(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
166
|
+
const obs = el<HTMLInputElement>(root, '[data-obs]');
|
|
167
|
+
const rail = el<HTMLInputElement>(root, '[data-obs-rail]');
|
|
168
|
+
const apply = (raw: string) => {
|
|
169
|
+
state.observedKnots = Number(raw);
|
|
170
|
+
saveState(state);
|
|
171
|
+
paint(root, ui, state);
|
|
172
|
+
};
|
|
173
|
+
obs?.addEventListener('input', () => apply(obs.value));
|
|
174
|
+
rail?.addEventListener('input', () => apply(rail.value));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bindKinds(root: HTMLElement, ui: HullSpeedUI, state: HullSpeedState): void {
|
|
178
|
+
root.querySelectorAll<HTMLElement>('[data-kind]').forEach((node) => {
|
|
179
|
+
node.addEventListener('click', () => {
|
|
180
|
+
state.hullKind = node.getAttribute('data-kind') as HullKind;
|
|
181
|
+
saveState(state);
|
|
182
|
+
paint(root, ui, state);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
root.querySelectorAll<HTMLElement>('[data-boat]').forEach((node) => {
|
|
186
|
+
node.addEventListener('click', () => {
|
|
187
|
+
const preset = BOAT_PRESETS.find((item) => item.id === node.getAttribute('data-boat'));
|
|
188
|
+
if (!preset) return;
|
|
189
|
+
state.lwlMetres = preset.lwlMetres;
|
|
190
|
+
saveState(state);
|
|
191
|
+
paint(root, ui, state);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function mountHullSpeed(root: HTMLElement, data: MountData): void {
|
|
197
|
+
const state = loadState();
|
|
198
|
+
bindUnits(root, data.ui, state);
|
|
199
|
+
bindLength(root, data.ui, state);
|
|
200
|
+
bindLog(root, data.ui, state);
|
|
201
|
+
bindDrag(root, data.ui, state);
|
|
202
|
+
bindKinds(root, data.ui, state);
|
|
203
|
+
paint(root, data.ui, state);
|
|
204
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { formatNumber } from './evaluator';
|
|
2
|
+
import { boatMarkup } from './hull-draw';
|
|
3
|
+
import type { HullSpeedResult, UnitSystem } from './logic';
|
|
4
|
+
import { displayLength, LWL_MAX_M, LWL_MIN_M } from './logic';
|
|
5
|
+
|
|
6
|
+
interface Span {
|
|
7
|
+
stern: number;
|
|
8
|
+
bow: number;
|
|
9
|
+
wl: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hullSpan(lwlMetres: number): Span {
|
|
15
|
+
const t = (lwlMetres - LWL_MIN_M) / (LWL_MAX_M - LWL_MIN_M);
|
|
16
|
+
const hull = 430 + t * 130;
|
|
17
|
+
const padX = 96 + t * 200;
|
|
18
|
+
const width = hull + padX * 2;
|
|
19
|
+
const height = 460;
|
|
20
|
+
return { stern: padX, bow: padX + hull, wl: 268, width, height };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hullLift(result: HullSpeedResult): number {
|
|
24
|
+
if (result.hullKind === 'planing' && result.band === 'above') return 16;
|
|
25
|
+
if (result.band === 'above') return -10;
|
|
26
|
+
if (result.band === 'at') return -5;
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function waveAmpPx(result: HullSpeedResult): number {
|
|
31
|
+
if (result.band === 'below') return 8;
|
|
32
|
+
if (result.band === 'near') return 16;
|
|
33
|
+
if (result.band === 'at') return 28;
|
|
34
|
+
if (result.hullKind === 'planing') return 10;
|
|
35
|
+
return 36;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compareKnots(result: HullSpeedResult): number {
|
|
39
|
+
if (result.observedKnots > 0) return result.observedKnots;
|
|
40
|
+
return result.hullSpeedKnots;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function waveLengthPx(span: Span, result: HullSpeedResult): number {
|
|
44
|
+
const hs = result.hullSpeedKnots;
|
|
45
|
+
const speed = compareKnots(result);
|
|
46
|
+
const hull = span.bow - span.stern;
|
|
47
|
+
if (hs <= 0) return hull;
|
|
48
|
+
return Math.max(hull * 0.35, hull * ((speed * speed) / (hs * hs)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function waveEnv(x: number, span: Span): number {
|
|
52
|
+
const mid = (span.stern + span.bow) / 2;
|
|
53
|
+
const reach = (span.bow - span.stern) * 0.72;
|
|
54
|
+
const d = Math.abs(x - mid) / reach;
|
|
55
|
+
if (d <= 1) return 1;
|
|
56
|
+
return Math.max(0, 1.4 - d * 0.75);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waveY(x: number, span: Span, result: HullSpeedResult): number {
|
|
60
|
+
const lambda = waveLengthPx(span, result);
|
|
61
|
+
const amp = waveAmpPx(result) * waveEnv(x, span);
|
|
62
|
+
const angle = ((x - span.bow) / lambda) * Math.PI * 2;
|
|
63
|
+
return span.wl - amp * Math.cos(angle);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function waveFill(span: Span, result: HullSpeedResult): string {
|
|
67
|
+
const step = Math.max(8, span.width / 90);
|
|
68
|
+
const bits = [`M 0 ${span.height}`];
|
|
69
|
+
for (let x = 0; x <= span.width; x += step) {
|
|
70
|
+
bits.push(`L ${x.toFixed(1)} ${waveY(x, span, result).toFixed(1)}`);
|
|
71
|
+
}
|
|
72
|
+
bits.push(`L ${span.width.toFixed(1)} ${span.height} Z`);
|
|
73
|
+
return `<path class="n-hull-sea" d="${bits.join(' ')}" />`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function waveStroke(span: Span, result: HullSpeedResult): string {
|
|
77
|
+
const step = Math.max(8, span.width / 90);
|
|
78
|
+
const bits = [`M 0 ${waveY(0, span, result).toFixed(1)}`];
|
|
79
|
+
for (let x = step; x <= span.width; x += step) {
|
|
80
|
+
bits.push(`L ${x.toFixed(1)} ${waveY(x, span, result).toFixed(1)}`);
|
|
81
|
+
}
|
|
82
|
+
return `<path class="n-hull-foamline" d="${bits.join(' ')}" />`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function foam(span: Span, result: HullSpeedResult): string {
|
|
86
|
+
const y = waveY(span.bow, span, result);
|
|
87
|
+
const r = 6 + waveAmpPx(result) / 5;
|
|
88
|
+
return `<ellipse class="n-hull-foam" cx="${span.bow.toFixed(1)}" cy="${y.toFixed(1)}" rx="${(r * 1.8).toFixed(1)}" ry="${r.toFixed(1)}" />`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dimLine(span: Span, label: string): string {
|
|
92
|
+
const y = span.wl + 112;
|
|
93
|
+
const mid = (span.stern + span.bow) / 2;
|
|
94
|
+
return [
|
|
95
|
+
`<line class="n-hull-dim" x1="${span.stern.toFixed(1)}" y1="${y}" x2="${span.bow.toFixed(1)}" y2="${y}" />`,
|
|
96
|
+
`<line class="n-hull-dim" x1="${span.stern.toFixed(1)}" y1="${y - 8}" x2="${span.stern.toFixed(1)}" y2="${y + 8}" />`,
|
|
97
|
+
`<line class="n-hull-dim" x1="${span.bow.toFixed(1)}" y1="${y - 8}" x2="${span.bow.toFixed(1)}" y2="${y + 8}" />`,
|
|
98
|
+
`<text class="n-hull-dimlabel" x="${mid.toFixed(1)}" y="${y + 24}" text-anchor="middle">${label}</text>`,
|
|
99
|
+
].join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function seaGroup(span: Span, result: HullSpeedResult): string {
|
|
103
|
+
const deepY = span.height * 0.72;
|
|
104
|
+
return [
|
|
105
|
+
`<rect class="n-hull-sky" x="0" y="0" width="${span.width}" height="${span.height}" />`,
|
|
106
|
+
waveFill(span, result),
|
|
107
|
+
`<rect class="n-hull-deep" x="0" y="${deepY}" width="${span.width}" height="${span.height - deepY}" />`,
|
|
108
|
+
waveStroke(span, result),
|
|
109
|
+
foam(span, result),
|
|
110
|
+
].join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function boatGroup(span: Span, lift: number, kind: HullSpeedResult['hullKind']): string {
|
|
114
|
+
return boatMarkup({
|
|
115
|
+
stern: span.stern,
|
|
116
|
+
bow: span.bow,
|
|
117
|
+
wl: span.wl,
|
|
118
|
+
lift,
|
|
119
|
+
kind,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sceneSvg(opts: { result: HullSpeedResult; unit: UnitSystem }): string {
|
|
124
|
+
const { result, unit } = opts;
|
|
125
|
+
const span = hullSpan(result.lwlMetres);
|
|
126
|
+
const lift = hullLift(result);
|
|
127
|
+
const box = `0 0 ${span.width.toFixed(0)} ${span.height.toFixed(0)}`;
|
|
128
|
+
const suffix = unit === 'imperial' ? 'ft' : 'm';
|
|
129
|
+
const label = `${formatNumber(displayLength(result.lwlMetres, unit), 1)} ${suffix}`;
|
|
130
|
+
return [
|
|
131
|
+
`<svg class="n-hull-svg" viewBox="${box}" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">`,
|
|
132
|
+
seaGroup(span, result),
|
|
133
|
+
boatGroup(span, lift, result.hullKind),
|
|
134
|
+
dimLine(span, label),
|
|
135
|
+
'</svg>',
|
|
136
|
+
].join('');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setText(el: Element | null, value: string): void {
|
|
140
|
+
if (el) el.textContent = value;
|
|
141
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { NauticalToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { HullSpeedUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { HullSpeedUI } from './ui';
|
|
5
|
+
export type HullSpeedLocaleContent = ToolLocaleContent<HullSpeedUI>;
|
|
6
|
+
|
|
7
|
+
export const hullSpeed: NauticalToolEntry<HullSpeedUI> = {
|
|
8
|
+
id: 'hull-speed',
|
|
9
|
+
icons: {
|
|
10
|
+
bg: 'mdi:ferry',
|
|
11
|
+
fg: 'mdi:waves',
|
|
12
|
+
},
|
|
13
|
+
i18n: {
|
|
14
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
15
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
16
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
17
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
18
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
19
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
20
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
21
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
22
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
23
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
24
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
25
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
26
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
27
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
28
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { bibliography } from './bibliography';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HullSpeedUI } from './ui';
|
|
2
|
+
import type { HullKind, SpeedBand } from './logic';
|
|
3
|
+
|
|
4
|
+
export function bandCopy(ui: HullSpeedUI, band: SpeedBand, kind: HullKind): string {
|
|
5
|
+
if (kind === 'planing' && band === 'above') return ui.bandPlane;
|
|
6
|
+
if (band === 'below') return ui.bandBelow;
|
|
7
|
+
if (band === 'near') return ui.bandNear;
|
|
8
|
+
if (band === 'at') return ui.bandAt;
|
|
9
|
+
return ui.bandAbove;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function kindNote(ui: HullSpeedUI, kind: HullKind): string {
|
|
13
|
+
if (kind === 'planing') return ui.planingNote;
|
|
14
|
+
if (kind === 'semi') return ui.semiNote;
|
|
15
|
+
return ui.displacementNote;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatNumber(value: number, digits: number): string {
|
|
19
|
+
return value.toFixed(digits);
|
|
20
|
+
}
|