@jjlmoya/utils-nautical 1.22.0 → 1.24.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 (59) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/en.ts +1 -1
  3. package/src/category/index.ts +2 -1
  4. package/src/data.ts +1 -0
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +1 -0
  7. package/src/layouts/PreviewLayout.astro +118 -118
  8. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  9. package/src/tests/category_seo_quality.test.ts +74 -0
  10. package/src/tests/faq_count.test.ts +30 -30
  11. package/src/tests/i18n_coverage.test.ts +6 -1
  12. package/src/tests/locale_completeness.test.ts +2 -2
  13. package/src/tests/no_h1_in_components.test.ts +1 -1
  14. package/src/tests/seo_length.test.ts +29 -29
  15. package/src/tests/seo_parity.test.ts +2 -2
  16. package/src/tests/seo_wellformed_export.test.ts +65 -0
  17. package/src/tests/spanish_leakage.test.ts +175 -175
  18. package/src/tests/tool_validation.test.ts +54 -51
  19. package/src/tool/endurance/i18n/fr.ts +205 -205
  20. package/src/tool/endurance/seo.astro +15 -15
  21. package/src/tool/hullSpeed/bibliography.astro +14 -0
  22. package/src/tool/hullSpeed/bibliography.ts +12 -0
  23. package/src/tool/hullSpeed/component.astro +116 -0
  24. package/src/tool/hullSpeed/controller.ts +204 -0
  25. package/src/tool/hullSpeed/dom-views.ts +141 -0
  26. package/src/tool/hullSpeed/entry.ts +32 -0
  27. package/src/tool/hullSpeed/evaluator.ts +20 -0
  28. package/src/tool/hullSpeed/hull-draw.ts +185 -0
  29. package/src/tool/hullSpeed/i18n/de.ts +199 -0
  30. package/src/tool/hullSpeed/i18n/en.ts +199 -0
  31. package/src/tool/hullSpeed/i18n/es.ts +199 -0
  32. package/src/tool/hullSpeed/i18n/fr.ts +199 -0
  33. package/src/tool/hullSpeed/i18n/id.ts +199 -0
  34. package/src/tool/hullSpeed/i18n/it.ts +199 -0
  35. package/src/tool/hullSpeed/i18n/ja.ts +199 -0
  36. package/src/tool/hullSpeed/i18n/ko.ts +199 -0
  37. package/src/tool/hullSpeed/i18n/nl.ts +199 -0
  38. package/src/tool/hullSpeed/i18n/pl.ts +199 -0
  39. package/src/tool/hullSpeed/i18n/pt.ts +199 -0
  40. package/src/tool/hullSpeed/i18n/ru.ts +199 -0
  41. package/src/tool/hullSpeed/i18n/sv.ts +199 -0
  42. package/src/tool/hullSpeed/i18n/tr.ts +199 -0
  43. package/src/tool/hullSpeed/i18n/zh.ts +199 -0
  44. package/src/tool/hullSpeed/index.ts +11 -0
  45. package/src/tool/hullSpeed/logic.test.ts +105 -0
  46. package/src/tool/hullSpeed/logic.ts +116 -0
  47. package/src/tool/hullSpeed/presets.ts +17 -0
  48. package/src/tool/hullSpeed/sailboat-hull-speed-calculator.css +504 -0
  49. package/src/tool/hullSpeed/seo.astro +15 -0
  50. package/src/tool/hullSpeed/storage.test.ts +23 -0
  51. package/src/tool/hullSpeed/storage.ts +65 -0
  52. package/src/tool/hullSpeed/ui.ts +43 -0
  53. package/src/tool/nauticalConverter/seo.astro +15 -15
  54. package/src/tool/sailArea/seo.astro +15 -15
  55. package/src/tool/speedConverter/i18n/fr.ts +259 -259
  56. package/src/tool/speedConverter/seo.astro +15 -15
  57. package/src/tool/tideCalculator/seo.astro +15 -15
  58. package/src/tool/underKeel/seo.astro +15 -15
  59. package/src/tools.ts +3 -0
@@ -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
+ }
@@ -0,0 +1,185 @@
1
+ import type { HullKind } from './logic';
2
+
3
+ interface Frame {
4
+ s: number;
5
+ b: number;
6
+ w: number;
7
+ L: number;
8
+ kind: HullKind;
9
+ }
10
+
11
+ export interface BoatDraw {
12
+ stern: number;
13
+ bow: number;
14
+ wl: number;
15
+ lift: number;
16
+ kind: HullKind;
17
+ }
18
+
19
+ function frameOf(draw: BoatDraw): Frame {
20
+ return {
21
+ s: draw.stern,
22
+ b: draw.bow,
23
+ w: draw.wl + draw.lift,
24
+ L: draw.bow - draw.stern,
25
+ kind: draw.kind,
26
+ };
27
+ }
28
+
29
+ function fb(kind: HullKind, length: number): number {
30
+ if (kind === 'planing') return 54 + length * 0.03;
31
+ if (kind === 'semi') return 62 + length * 0.035;
32
+ return 74 + length * 0.045;
33
+ }
34
+
35
+ function sheer(frame: Frame): { ds: number; db: number; ox: number; oy: number } {
36
+ if (frame.kind === 'planing') {
37
+ return {
38
+ ds: frame.w - 50,
39
+ db: frame.w - 58,
40
+ ox: Math.min(18, frame.L * 0.04),
41
+ oy: 12,
42
+ };
43
+ }
44
+ const rise = fb(frame.kind, frame.L);
45
+ return {
46
+ ds: frame.w - rise * 0.78,
47
+ db: frame.w - rise * 1.22,
48
+ ox: Math.min(24, frame.L * 0.05),
49
+ oy: 16,
50
+ };
51
+ }
52
+
53
+ function deckPlane(frame: Frame): string {
54
+ const { ds, db, ox, oy } = sheer(frame);
55
+ return `M ${frame.s} ${ds} L ${frame.b} ${db} L ${frame.b + ox} ${db - oy} L ${frame.s + ox} ${ds - oy} Z`;
56
+ }
57
+
58
+ function topsides(frame: Frame): string {
59
+ const { ds, db } = sheer(frame);
60
+ if (frame.kind === 'planing') {
61
+ const aft = frame.s - 18;
62
+ return `M ${frame.s} ${frame.w} L ${aft} ${ds} L ${frame.b - 8} ${db} L ${frame.b + 6} ${frame.w - 6} L ${frame.b} ${frame.w} Z`;
63
+ }
64
+ const stem = frame.kind === 'displacement' ? 18 : 9;
65
+ return `M ${frame.s} ${frame.w} L ${frame.s - 10} ${ds} L ${frame.b} ${db} L ${frame.b + stem} ${frame.w - 10} L ${frame.b} ${frame.w} Z`;
66
+ }
67
+
68
+ function gunwale(frame: Frame): string {
69
+ const { ds, db } = sheer(frame);
70
+ return `M ${frame.s} ${ds} L ${frame.b} ${db} L ${frame.b} ${db + 7} L ${frame.s} ${ds + 7} Z`;
71
+ }
72
+
73
+ function transom(frame: Frame): string {
74
+ const { ds, ox, oy } = sheer(frame);
75
+ if (frame.kind === 'planing') {
76
+ const aft = frame.s - 18;
77
+ return `M ${frame.s} ${frame.w} L ${aft} ${ds} L ${aft + ox} ${ds - oy} L ${frame.s + ox} ${frame.w - 4} Z`;
78
+ }
79
+ return `M ${frame.s} ${frame.w} L ${frame.s} ${ds} L ${frame.s + ox} ${ds - oy} L ${frame.s + ox} ${frame.w - 8} Z`;
80
+ }
81
+
82
+ function underbody(frame: Frame): string {
83
+ const s = frame.s;
84
+ const L = frame.L;
85
+ const w = frame.w;
86
+ if (frame.kind === 'planing') {
87
+ return `M ${s} ${w} L ${s + L * 0.18} ${w + 10} L ${s + L * 0.72} ${w + 11} L ${s + L * 0.92} ${w + 6} L ${frame.b} ${w} Z`;
88
+ }
89
+ return `M ${s} ${w} C ${s + L * 0.14} ${w + 32} ${s + L * 0.38} ${w + 48} ${s + L * 0.52} ${w + 46} C ${s + L * 0.72} ${w + 40} ${s + L * 0.9} ${w + 24} ${frame.b} ${w} Z`;
90
+ }
91
+
92
+ function keelPath(frame: Frame): string {
93
+ const s = frame.s;
94
+ const L = frame.L;
95
+ const w = frame.w;
96
+ if (frame.kind === 'planing') {
97
+ return `M ${s + L * 0.16} ${w} L ${s + L * 0.6} ${w} L ${s + L * 0.4} ${w + 22} Z`;
98
+ }
99
+ if (frame.kind === 'semi') {
100
+ return `M ${s + L * 0.22} ${w} L ${s + L * 0.7} ${w} L ${s + L * 0.5} ${w + 52} L ${s + L * 0.32} ${w + 46} Z`;
101
+ }
102
+ return `M ${s + L * 0.18} ${w} L ${s + L * 0.66} ${w} L ${s + L * 0.54} ${w + 78} L ${s + L * 0.28} ${w + 70} Z`;
103
+ }
104
+
105
+ function cabinPath(frame: Frame): string {
106
+ const { ds } = sheer(frame);
107
+ if (frame.kind === 'planing') return planingCockpit(frame, ds);
108
+ const x0 = frame.s + frame.L * 0.27;
109
+ const x1 = frame.s + frame.L * 0.6;
110
+ return `<path class="n-hull-cabin" d="M ${x0} ${ds} L ${x0 + 10} ${ds - 46} L ${x1 - 4} ${ds - 44} L ${x1 + 12} ${ds} Z" />`;
111
+ }
112
+
113
+ function planingCockpit(frame: Frame, ds: number): string {
114
+ const x0 = frame.s + frame.L * 0.22;
115
+ const x1 = frame.s + frame.L * 0.48;
116
+ const x2 = frame.s + frame.L * 0.7;
117
+ const screen = `M ${x1} ${ds} L ${x1 + 10} ${ds - 32} L ${x2} ${ds - 30} L ${x2 + 8} ${ds} Z`;
118
+ const console = `M ${x0} ${ds} L ${x0} ${ds - 16} L ${x1 - 4} ${ds - 16} L ${x1 - 4} ${ds} Z`;
119
+ return `<path class="n-hull-cabin" d="${console}" /><path class="n-hull-glass" d="${screen}" />`;
120
+ }
121
+
122
+ function ports(frame: Frame): string {
123
+ if (frame.kind === 'planing') return '';
124
+ const { ds } = sheer(frame);
125
+ const y = ds - 22;
126
+ const x1 = frame.s + frame.L * 0.36;
127
+ const x2 = frame.s + frame.L * 0.48;
128
+ return `<circle class="n-hull-glass" cx="${x1.toFixed(1)}" cy="${y.toFixed(1)}" r="5" /><circle class="n-hull-glass" cx="${x2.toFixed(1)}" cy="${y.toFixed(1)}" r="5" />`;
129
+ }
130
+
131
+ function rig(frame: Frame): string {
132
+ if (frame.kind === 'planing') return '';
133
+ const { ds } = sheer(frame);
134
+ const x = frame.s + frame.L * 0.42;
135
+ const boom = frame.L * 0.28;
136
+ return `<path class="n-hull-spar" d="M ${x} ${ds - 44} L ${x} ${ds - 168}" /><path class="n-hull-spar" d="M ${x} ${ds - 70} L ${x + boom} ${ds - 58}" />`;
137
+ }
138
+
139
+ function rudder(frame: Frame): string {
140
+ const s = frame.s;
141
+ const w = frame.w;
142
+ return `M ${s + 7} ${w} L ${s - 6} ${w + 40} L ${s + 12} ${w + 40} L ${s + 16} ${w} Z`;
143
+ }
144
+
145
+ function drive(frame: Frame): string {
146
+ if (frame.kind === 'planing') return outboard(frame);
147
+ return `<path class="n-hull-keel" d="${rudder(frame)}" />`;
148
+ }
149
+
150
+ function outboard(frame: Frame): string {
151
+ const { ds } = sheer(frame);
152
+ const x = frame.s - 14;
153
+ const cowling = `M ${x - 7} ${ds - 4} L ${x + 9} ${ds - 4} L ${x + 8} ${ds + 18} L ${x - 6} ${ds + 18} Z`;
154
+ const shaft = `M ${x} ${ds + 18} L ${x} ${frame.w + 28}`;
155
+ const foot = `M ${x - 5} ${frame.w + 24} L ${x + 14} ${frame.w + 26} L ${x + 12} ${frame.w + 34} L ${x - 4} ${frame.w + 32} Z`;
156
+ return `<path class="n-hull-keel" d="${cowling}" /><path class="n-hull-spar" d="${shaft}" /><path class="n-hull-keel" d="${foot}" />`;
157
+ }
158
+
159
+ function chine(frame: Frame): string {
160
+ if (frame.kind !== 'planing') return '';
161
+ const y = frame.w - 16;
162
+ return `<path class="n-hull-chine" d="M ${frame.s + 6} ${y} L ${frame.b - 10} ${y - 8}" />`;
163
+ }
164
+
165
+ function boot(frame: Frame): string {
166
+ return `M ${frame.s} ${frame.w - 4} L ${frame.b} ${frame.w - 4} L ${frame.b} ${frame.w + 5} L ${frame.s} ${frame.w + 5} Z`;
167
+ }
168
+
169
+ export function boatMarkup(draw: BoatDraw): string {
170
+ const frame = frameOf(draw);
171
+ return [
172
+ `<path class="n-hull-keel" d="${keelPath(frame)}" />`,
173
+ drive(frame),
174
+ `<path class="n-hull-body" d="${underbody(frame)}" />`,
175
+ `<path class="n-hull-topside" d="${topsides(frame)}" />`,
176
+ `<path class="n-hull-transom" d="${transom(frame)}" />`,
177
+ `<path class="n-hull-deck" d="${deckPlane(frame)}" />`,
178
+ `<path class="n-hull-gunwale" d="${gunwale(frame)}" />`,
179
+ `<path class="n-hull-bootfill" d="${boot(frame)}" />`,
180
+ chine(frame),
181
+ cabinPath(frame),
182
+ ports(frame),
183
+ rig(frame),
184
+ ].join('');
185
+ }