@jjlmoya/utils-drones 1.18.0 → 1.20.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 +4 -2
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/antenna-length-calculator/component.astro +2 -2
- package/src/tool/drone-flight-time/drone-flight-time-calculator.css +7 -0
- package/src/tool/drone-power-analyzer/components/MotorConfig.astro +1 -0
- package/src/tool/drone-power-analyzer/components/WeightConfig.astro +1 -0
- package/src/tool/gps-coordinates-converter/components/GpsInputs.astro +10 -10
- package/src/tool/gsd-flight-planner/InputsColumn.astro +12 -12
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-drones",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/seo.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -31,7 +31,7 @@ const materials = [
|
|
|
31
31
|
|
|
32
32
|
<div class="input-group">
|
|
33
33
|
<div class="input-with-unit">
|
|
34
|
-
<input type="number" id="freqInput" value="868" step="1" min="1" max="10000" />
|
|
34
|
+
<input type="number" id="freqInput" value="868" step="1" min="1" max="10000" aria-label={ui.signalParameters} />
|
|
35
35
|
<span class="unit">MHz</span>
|
|
36
36
|
</div>
|
|
37
37
|
</div>
|
|
@@ -76,7 +76,7 @@ const materials = [
|
|
|
76
76
|
<h2>{ui.conductorMedium}</h2>
|
|
77
77
|
</div>
|
|
78
78
|
<div class="input-group">
|
|
79
|
-
<select id="vfInput" class="custom-select-premium">
|
|
79
|
+
<select id="vfInput" class="custom-select-premium" aria-label={ui.conductorMedium}>
|
|
80
80
|
{materials.map(m => (
|
|
81
81
|
<option value={m.vf}>{m.label}</option>
|
|
82
82
|
))}
|
|
@@ -72,6 +72,8 @@
|
|
|
72
72
|
display: grid;
|
|
73
73
|
grid-template-columns: 400px 1fr;
|
|
74
74
|
min-height: 700px;
|
|
75
|
+
width: 100%;
|
|
76
|
+
min-width: 0;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
.flight-calculator-ui .config-sidebar {
|
|
@@ -81,6 +83,7 @@
|
|
|
81
83
|
display: flex;
|
|
82
84
|
flex-direction: column;
|
|
83
85
|
gap: 3rem;
|
|
86
|
+
min-width: 0;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
.flight-calculator-ui .main-display {
|
|
@@ -89,6 +92,8 @@
|
|
|
89
92
|
flex-direction: column;
|
|
90
93
|
gap: 3rem;
|
|
91
94
|
background: var(--dft-bg);
|
|
95
|
+
min-width: 0;
|
|
96
|
+
overflow: hidden;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
.flight-calculator-ui .divider {
|
|
@@ -348,6 +353,7 @@
|
|
|
348
353
|
position: relative;
|
|
349
354
|
height: 350px;
|
|
350
355
|
width: 100%;
|
|
356
|
+
min-width: 0;
|
|
351
357
|
background: var(--dft-bg-surface);
|
|
352
358
|
border-radius: 32px;
|
|
353
359
|
padding: 1.5rem;
|
|
@@ -357,6 +363,7 @@
|
|
|
357
363
|
@media (max-width: 1200px) {
|
|
358
364
|
.flight-calculator-ui .card-grid {
|
|
359
365
|
grid-template-columns: 1fr;
|
|
366
|
+
min-height: auto;
|
|
360
367
|
}
|
|
361
368
|
.flight-calculator-ui .config-sidebar {
|
|
362
369
|
border-right: none;
|
|
@@ -40,6 +40,7 @@ const { ui } = Astro.props;
|
|
|
40
40
|
step="10"
|
|
41
41
|
value="600"
|
|
42
42
|
class="thrust-slider"
|
|
43
|
+
aria-label={ui.thrustPerMotor || "Thrust per Motor"}
|
|
43
44
|
onchange="document.getElementById('thrustPerMotor').value = this.value; document.getElementById('thrustPerMotor').dispatchEvent(new Event('input'));"
|
|
44
45
|
/>
|
|
45
46
|
</div>
|
|
@@ -31,6 +31,7 @@ const { ui } = Astro.props;
|
|
|
31
31
|
step="10"
|
|
32
32
|
value="800"
|
|
33
33
|
class="auw-slider"
|
|
34
|
+
aria-label={ui.auwLabel || "All-Up Weight (AUW)"}
|
|
34
35
|
onchange="document.getElementById('auw').value = this.value; document.getElementById('auw').dispatchEvent(new Event('input'));"
|
|
35
36
|
/>
|
|
36
37
|
</div>
|
|
@@ -16,11 +16,11 @@ import { Icon } from "astro-icon/components";
|
|
|
16
16
|
|
|
17
17
|
<div id="dd-inputs" class="compact-inputs">
|
|
18
18
|
<div class="input-block">
|
|
19
|
-
<label class="field-label">{ui.lat}</label>
|
|
19
|
+
<label class="field-label" for="latDD">{ui.lat}</label>
|
|
20
20
|
<input type="text" id="latDD" class="tech-input" placeholder="40.4168" />
|
|
21
21
|
</div>
|
|
22
22
|
<div class="input-block">
|
|
23
|
-
<label class="field-label">{ui.lng}</label>
|
|
23
|
+
<label class="field-label" for="lngDD">{ui.lng}</label>
|
|
24
24
|
<input type="text" id="lngDD" class="tech-input" placeholder="-3.7038" />
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
@@ -29,10 +29,10 @@ import { Icon } from "astro-icon/components";
|
|
|
29
29
|
<div class="gms-group">
|
|
30
30
|
<label class="field-label">{ui.latGMS}</label>
|
|
31
31
|
<div class="compact-inputs">
|
|
32
|
-
<input type="number" id="latG" class="tech-input" placeholder="G" />
|
|
33
|
-
<input type="number" id="latM" class="tech-input" placeholder="M" />
|
|
34
|
-
<input type="number" id="latS" class="tech-input" placeholder="S" step="any" />
|
|
35
|
-
<select id="latH" class="tech-input">
|
|
32
|
+
<input type="number" id="latG" class="tech-input" placeholder="G" aria-label={`${ui.latGMS} - Degrees`} />
|
|
33
|
+
<input type="number" id="latM" class="tech-input" placeholder="M" aria-label={`${ui.latGMS} - Minutes`} />
|
|
34
|
+
<input type="number" id="latS" class="tech-input" placeholder="S" step="any" aria-label={`${ui.latGMS} - Seconds`} />
|
|
35
|
+
<select id="latH" class="tech-input" aria-label={`${ui.latGMS} - Hemisphere`}>
|
|
36
36
|
<option value="N">N</option>
|
|
37
37
|
<option value="S">S</option>
|
|
38
38
|
</select>
|
|
@@ -41,10 +41,10 @@ import { Icon } from "astro-icon/components";
|
|
|
41
41
|
<div class="gms-group">
|
|
42
42
|
<label class="field-label">{ui.lngGMS}</label>
|
|
43
43
|
<div class="compact-inputs">
|
|
44
|
-
<input type="number" id="lngG" class="tech-input" placeholder="G" />
|
|
45
|
-
<input type="number" id="lngM" class="tech-input" placeholder="M" />
|
|
46
|
-
<input type="number" id="lngS" class="tech-input" placeholder="S" step="any" />
|
|
47
|
-
<select id="lngH" class="tech-input">
|
|
44
|
+
<input type="number" id="lngG" class="tech-input" placeholder="G" aria-label={`${ui.lngGMS} - Degrees`} />
|
|
45
|
+
<input type="number" id="lngM" class="tech-input" placeholder="M" aria-label={`${ui.lngGMS} - Minutes`} />
|
|
46
|
+
<input type="number" id="lngS" class="tech-input" placeholder="S" step="any" aria-label={`${ui.lngGMS} - Seconds`} />
|
|
47
|
+
<select id="lngH" class="tech-input" aria-label={`${ui.lngGMS} - Hemisphere`}>
|
|
48
48
|
<option value="E">E</option>
|
|
49
49
|
<option value="W">W</option>
|
|
50
50
|
</select>
|
|
@@ -6,7 +6,7 @@ const { ui } = Astro.props;
|
|
|
6
6
|
<h2 class="column-title">{ui.configuration || "Configuration"}</h2>
|
|
7
7
|
|
|
8
8
|
<div class="section">
|
|
9
|
-
<label class="section-label">{ui.cameraSelection || "Camera Selection"}</label>
|
|
9
|
+
<label for="cameraPreset" class="section-label">{ui.cameraSelection || "Camera Selection"}</label>
|
|
10
10
|
<select id="cameraPreset" class="input-field">
|
|
11
11
|
<option value="">{ui.manualMode || "Manual Mode"}</option>
|
|
12
12
|
<option value="mavic3e">{ui.presetDjiMavic3e || "DJI Mavic 3E"}</option>
|
|
@@ -20,12 +20,12 @@ const { ui } = Astro.props;
|
|
|
20
20
|
<label class="section-label">{ui.sensorConfig || "Sensor Configuration"}</label>
|
|
21
21
|
<div class="input-row">
|
|
22
22
|
<div class="input-item">
|
|
23
|
-
<
|
|
23
|
+
<label for="sensorWidth" class="input-label">{ui.width || "Width"}</label>
|
|
24
24
|
<input id="sensorWidth" type="number" min="1" max="100" step="0.1" value="23.5" class="input-field" />
|
|
25
25
|
<span class="unit-hint">mm</span>
|
|
26
26
|
</div>
|
|
27
27
|
<div class="input-item">
|
|
28
|
-
<
|
|
28
|
+
<label for="sensorHeight" class="input-label">{ui.height || "Height"}</label>
|
|
29
29
|
<input id="sensorHeight" type="number" min="1" max="100" step="0.1" value="15.6" class="input-field" />
|
|
30
30
|
<span class="unit-hint">mm</span>
|
|
31
31
|
</div>
|
|
@@ -33,7 +33,7 @@ const { ui } = Astro.props;
|
|
|
33
33
|
</div>
|
|
34
34
|
|
|
35
35
|
<div class="section">
|
|
36
|
-
<label class="section-label">{ui.focalLength || "Focal Length"}</label>
|
|
36
|
+
<label for="focalLength" class="section-label">{ui.focalLength || "Focal Length"}</label>
|
|
37
37
|
<div class="input-item">
|
|
38
38
|
<input id="focalLength" type="number" min="1" max="1000" step="0.1" value="35" class="input-field" />
|
|
39
39
|
<span class="unit-hint" id="focalLengthUnit">mm</span>
|
|
@@ -44,12 +44,12 @@ const { ui } = Astro.props;
|
|
|
44
44
|
<label class="section-label">{ui.imageResolution || "Image Resolution"}</label>
|
|
45
45
|
<div class="input-row">
|
|
46
46
|
<div class="input-item">
|
|
47
|
-
<
|
|
47
|
+
<label for="imageWidth" class="input-label">{ui.w || "W"}</label>
|
|
48
48
|
<input id="imageWidth" type="number" min="100" max="50000" step="1" value="5472" class="input-field" />
|
|
49
49
|
<span class="unit-hint">{ui.px || "px"}</span>
|
|
50
50
|
</div>
|
|
51
51
|
<div class="input-item">
|
|
52
|
-
<
|
|
52
|
+
<label for="imageHeight" class="input-label">{ui.h || "H"}</label>
|
|
53
53
|
<input id="imageHeight" type="number" min="100" max="50000" step="1" value="3648" class="input-field" />
|
|
54
54
|
<span class="unit-hint">{ui.px || "px"}</span>
|
|
55
55
|
</div>
|
|
@@ -57,24 +57,24 @@ const { ui } = Astro.props;
|
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
59
|
<div class="section">
|
|
60
|
-
<label class="section-label">{ui.altitudeAgl || "Altitude (AGL)"}</label>
|
|
60
|
+
<label for="altitudeAgl" class="section-label">{ui.altitudeAgl || "Altitude (AGL)"}</label>
|
|
61
61
|
<div class="input-item">
|
|
62
62
|
<input id="altitudeAgl" type="number" min="10" max="500" step="5" value="100" class="input-field" />
|
|
63
63
|
<span class="unit-hint" id="altUnit">m</span>
|
|
64
64
|
</div>
|
|
65
|
-
<input id="altitudeSlider" type="range" min="10" max="500" step="5" value="100" class="slider" />
|
|
65
|
+
<input id="altitudeSlider" type="range" min="10" max="500" step="5" value="100" class="slider" aria-label={ui.altitudeAgl || "Altitude (AGL)"} />
|
|
66
66
|
</div>
|
|
67
67
|
|
|
68
68
|
<div class="section">
|
|
69
69
|
<label class="section-label">{ui.overlapSettings || "Overlap Configuration"}</label>
|
|
70
70
|
<div class="input-row">
|
|
71
71
|
<div class="input-item">
|
|
72
|
-
<
|
|
72
|
+
<label for="forwardOverlap" class="input-label">{ui.forward || "Forward"}</label>
|
|
73
73
|
<input id="forwardOverlap" type="number" min="0" max="95" step="5" value="75" class="input-field" />
|
|
74
74
|
<span class="unit-hint">%</span>
|
|
75
75
|
</div>
|
|
76
76
|
<div class="input-item">
|
|
77
|
-
<
|
|
77
|
+
<label for="lateralOverlap" class="input-label">{ui.lateral || "Lateral"}</label>
|
|
78
78
|
<input id="lateralOverlap" type="number" min="0" max="95" step="5" value="65" class="input-field" />
|
|
79
79
|
<span class="unit-hint">%</span>
|
|
80
80
|
</div>
|
|
@@ -84,7 +84,7 @@ const { ui } = Astro.props;
|
|
|
84
84
|
<div class="section">
|
|
85
85
|
<label class="section-label">{ui.missionArea || "Mission Area"}</label>
|
|
86
86
|
<div class="input-item">
|
|
87
|
-
<
|
|
87
|
+
<label for="missionArea" class="input-label">{ui.totalAreaToSurvey || "Total Area to Survey"}</label>
|
|
88
88
|
<input id="missionArea" type="number" min="0.1" max="10000000" step="0.1" value="100" class="input-field" />
|
|
89
89
|
<span class="unit-hint" id="areaInputUnit">m²</span>
|
|
90
90
|
<span class="unit-hint" style="font-size: 0.65rem; margin-top: 0.25rem;">{ui.hectareHint || "1 ha = 10,000 m²"}</span>
|
|
@@ -94,7 +94,7 @@ const { ui } = Astro.props;
|
|
|
94
94
|
<div class="section">
|
|
95
95
|
<label class="section-label">{ui.inverseCalc || "Reverse Calculation"}</label>
|
|
96
96
|
<div class="input-item">
|
|
97
|
-
<
|
|
97
|
+
<label for="desiredGsd" class="input-label">{ui.targetGsd || "Target GSD"}</label>
|
|
98
98
|
<input id="desiredGsd" type="number" min="0.1" max="50" step="0.1" value="2" class="input-field" />
|
|
99
99
|
<span class="unit-hint" id="desiredGsdUnit">cm/px</span>
|
|
100
100
|
</div>
|