Skip to content

Commit 3d15415

Browse files
bgrozevclaude
andauthored
Add courses (#13)
* Add Courses feature with Distance Course support - New /courses page in left nav (below Wind, not in mobile bottom nav) - Courses page has a dropdown to select a single course (or none) - Selected course is persisted in localStorage and saved/restored with presets - Course elements rendered on map: buoys (concentric circles), lines, distance markers - DistanceCourse abstraction: makeDistanceCourse(id, name, lat, lng, direction) builds entry/exit gates, yellow course lines, and distance markers - First course: Skydive City Distance (p=28.2187921,-82.1515655, d=200°) - POM and hover highlight circles resized to match buoy diameter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add target positioning relative to course in Courses panel - Course type gains optional center (LatLng) and direction (degrees) fields - makeDistanceCourse populates these from its parameters - New getTargetRelativeToCourse / fromCourseRelative geometry helpers in courses.ts - Courses panel shows a Target section when a course is selected: - Depth (m): distance along d+180 axis from course center, positive = away from course - Offset (m): lateral distance, positive = right of course direction - Approach Angle (°): finalHeading relative to course direction (courseDir - finalHeading); step 0.5°, rotating the flight path around the fixed landing point Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add Zone Accuracy course type and Skydive City ZA course - Add makeZACourse() with G1-G4 water gates, Z1-Z10 landing zones, and centre zone with transverse 46/48/50/48/46 scoring - Add 'Skydive City: Zone Accuracy' course to COURSES - Render course markers as centered label boxes (no circle dot) - Use marker.color for label text (supports red centre-zone '50') - Gate score labels placed on gate cross-lines; zone names outside rail Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Hide course labels at low zoom levels, fix zone widths - Track map zoom via onZoomChanged; hide course markers (score/name labels) below zoom 20 - Fix ZA course zone widths to match diagram - Remove z1Offset parameter from makeZACourse (hardcoded to 8m) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add custom courses with localStorage persistence and map edit handles - Custom courses stored in localStorage as CourseParams (type, lat, lng, direction) - Course selector groups custom courses above built-in, both with duplicate button - New button creates a course at current target location - Target section (depth/offset/approach angle) now shown for all courses - Edit accordion (collapsed by default) exposes name/type/lat/lng/direction/delete - When Edit is expanded: cyan drag handle moves course center, orange rotation handle (15m out) updates bearing live; both update the course immediately - App resolves enabled courses from both custom and built-in pools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add Speed course type with arc rails and inside gate labels - New CourseType 'speed' with carveDirection (left/right) - Five 10m-wide gates on a 53.48m radius circle around focal point - Arc rails approximated with 8 segments per section instead of straight chords - Gate labels (G1-G5) placed 3m inside the channel toward focal point - Inside buoys orange, outside white - CoursesComponent: Speed option in type selector with Carve Direction field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add Skydive City: Speed built-in course Derived from focal point and G4 GPS coordinates: - p = G1 center (28.2187600, -82.1514781) - direction = 235°, left carve Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add more courses. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f026550 commit 3d15415

File tree

12 files changed

+1251
-13
lines changed

12 files changed

+1251
-13
lines changed

scripts/avg-gps.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env node
2+
/**
3+
* avg-gps.js — average lat/lng from a FlySight/FliP CSV file
4+
*
5+
* Usage: node scripts/avg-gps.js <file.csv>
6+
*
7+
* Reads all rows that have parseable lat/lon values and prints their
8+
* arithmetic mean. Useful for deriving precise coordinates from a
9+
* stationary GPS log.
10+
*/
11+
12+
const fs = require('fs');
13+
14+
const file = process.argv[2];
15+
if (!file) {
16+
console.error('Usage: node scripts/avg-gps.js <file.csv>');
17+
process.exit(1);
18+
}
19+
20+
const content = fs.readFileSync(file, 'utf8');
21+
const lines = content.trim().split('\n');
22+
23+
if (lines.length < 2) {
24+
console.error('File has no data rows');
25+
process.exit(1);
26+
}
27+
28+
// First line is column headers
29+
const headers = lines[0].split(',').map(h => h.trim());
30+
const latIdx = headers.indexOf('lat');
31+
const lonIdx = headers.indexOf('lon');
32+
33+
if (latIdx === -1 || lonIdx === -1) {
34+
console.error(`Could not find 'lat' and 'lon' columns. Found: ${headers.join(', ')}`);
35+
process.exit(1);
36+
}
37+
38+
// Skip any row where lat/lon don't parse as numbers (e.g. the units row)
39+
let sumLat = 0;
40+
let sumLon = 0;
41+
let count = 0;
42+
43+
for (let i = 1; i < lines.length; i++) {
44+
const line = lines[i].trim();
45+
if (!line) continue;
46+
const fields = line.split(',');
47+
const lat = parseFloat(fields[latIdx]);
48+
const lon = parseFloat(fields[lonIdx]);
49+
if (!isNaN(lat) && !isNaN(lon)) {
50+
sumLat += lat;
51+
sumLon += lon;
52+
count++;
53+
}
54+
}
55+
56+
if (count === 0) {
57+
console.error('No valid lat/lon rows found');
58+
process.exit(1);
59+
}
60+
61+
const avgLat = sumLat / count;
62+
const avgLon = sumLon / count;
63+
64+
//process.stderr.write(`Averaged ${count} points\n`);
65+
console.log(`${file}=${avgLat.toFixed(7)},${avgLon.toFixed(7)}`);

src/App.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Air as AirIcon,
55
Crop as CropIcon,
66
FavoriteSharp as FavoriteIcon,
7+
Flag as FlagIcon,
78
Info as InfoIcon,
89
RotateLeft as RotateLeftIcon,
910
Settings as SettingsIcon
@@ -25,6 +26,7 @@ import React, { useCallback, useMemo, useState } from 'react';
2526

2627
import {
2728
AboutComponent,
29+
CoursesComponent,
2830
FlipIcon,
2931
ManoeuvreComponent,
3032
MapComponent,
@@ -35,17 +37,20 @@ import {
3537
WindSummary,
3638
WindsComponent
3739
} from './components';
40+
import { CourseEditTarget } from './components/MapComponent';
3841
import { SOURCE_DZ, SOURCE_MANUAL } from './forecast/forecast';
3942
import {
4043
AppStateProvider,
4144
DEFAULT_TARGET,
4245
useAppState,
46+
useCustomCourses,
4347
useFetchForecast,
4448
useMapClickHandler,
4549
usePresets
4650
} from './hooks';
47-
import { Target, WindSummaryData } from './types';
51+
import { Course, LatLng, Target, WindSummaryData } from './types';
4852
import { addWind, hasTargetMovedTooFar } from './util/geo';
53+
import { COURSES } from './util/courses';
4954
import { averageWind, reposition, straightenLegs } from './util/util';
5055
import { WindRow } from './util/wind';
5156

@@ -70,6 +75,11 @@ const NAVIGATION: Navigation = [
7075
title: 'Wind',
7176
icon: <AirIcon />
7277
},
78+
{
79+
segment: 'courses',
80+
title: 'Courses',
81+
icon: <FlagIcon />
82+
},
7383
{
7484
kind: 'divider'
7585
},
@@ -154,10 +164,13 @@ function DashboardContent() {
154164
patternParams,
155165
setPatternParams,
156166
settings,
157-
setSettings
167+
setSettings,
168+
selectedCourseId,
169+
setSelectedCourseId
158170
} = useAppState();
159171

160172
const [forecastTime, setForecastTime] = useState<Date | null>(null);
173+
const [courseEditOpen, setCourseEditOpen] = useState(false);
161174

162175
const { winds, fetching, fetchWinds, setWinds, resetWinds } = useFetchForecast({
163176
target: target.target,
@@ -197,9 +210,11 @@ function DashboardContent() {
197210
target,
198211
patternParams,
199212
manoeuvreConfig,
213+
selectedCourseId,
200214
setTarget,
201215
setPatternParams,
202-
setManoeuvreConfig
216+
setManoeuvreConfig,
217+
setSelectedCourseId
203218
});
204219

205220
const handlePresetSave = (name?: string) => {
@@ -294,6 +309,17 @@ function DashboardContent() {
294309
onForecastTimeChange={setForecastTime}
295310
/>
296311
);
312+
} else if (router.pathname === '/courses') {
313+
p = (
314+
<CoursesComponent
315+
selectedCourseId={selectedCourseId}
316+
onSelect={setSelectedCourseId}
317+
target={target}
318+
onTargetChange={setTarget}
319+
editOpen={courseEditOpen}
320+
onEditOpenChange={setCourseEditOpen}
321+
/>
322+
);
297323
} else if (router.pathname === '/about') {
298324
p = <AboutComponent />;
299325
} else if (router.pathname === '/settings') {
@@ -318,6 +344,22 @@ function DashboardContent() {
318344
</Box>
319345
);
320346
}
347+
const { customCourses, customParams, updateCourse } = useCustomCourses();
348+
const allCourses: Course[] = [...customCourses, ...COURSES];
349+
const selectedCourse = selectedCourseId ? allCourses.find(c => c.id === selectedCourseId) : undefined;
350+
const enabledCourses: Course[] = selectedCourse ? [selectedCourse] : [];
351+
352+
const selectedCustomParam = customParams.find(c => c.id === selectedCourseId) ?? null;
353+
const courseEditTarget: CourseEditTarget | undefined =
354+
courseEditOpen && selectedCustomParam && router.pathname === '/courses'
355+
? {
356+
center: { lat: selectedCustomParam.lat, lng: selectedCustomParam.lng } as LatLng,
357+
direction: selectedCustomParam.direction,
358+
onMove: (newCenter: LatLng) => updateCourse(selectedCustomParam.id, { lat: newCenter.lat, lng: newCenter.lng }),
359+
onRotate: (newDir: number) => updateCourse(selectedCustomParam.id, { direction: newDir })
360+
}
361+
: undefined;
362+
321363
const map = (
322364
<MapComponent
323365
center={target.target}
@@ -328,6 +370,8 @@ function DashboardContent() {
328370
windDirection={averageWind_?.direction ?? 0}
329371
windSpeed={averageWind_?.speedKts ?? 0}
330372
waitingForClick={isWaitingForClick}
373+
courses={enabledCourses}
374+
courseEditTarget={courseEditTarget}
331375
/>
332376
);
333377
const dashboard = (

0 commit comments

Comments
 (0)