From b1b0c798b3a31325d35fa0a77e9f41097ac1320b Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 15:36:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20GPS-Track-Statistiken=20automatisch=20i?= =?UTF-8?q?ns=20Logbuch=20=C3=BCbernehmen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strecke, Max- und Durchschnittsgeschwindigkeit werden beim Track-Upload berechnet, gespeichert und in PDF/CSV exportiert. Test-GPX für die Kieler Förde (5 sm) hinzugefügt. Co-authored-by: Cursor --- client/src/App.css | 5 + client/src/components/LogEntryEditor.tsx | 92 ++ client/src/i18n/locales/de.json | 3 + client/src/i18n/locales/en.json | 3 + client/src/services/csvExport.ts | 6 + client/src/services/pdfExport.ts | 17 +- client/src/utils/trackStats.ts | 107 ++ testdata/tracks/kieler-foerde-5sm.gpx | 1274 ++++++++++++++++++++++ 8 files changed, 1503 insertions(+), 4 deletions(-) create mode 100644 client/src/utils/trackStats.ts create mode 100644 testdata/tracks/kieler-foerde-5sm.gpx diff --git a/client/src/App.css b/client/src/App.css index ba929a4..e8d4590 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1980,6 +1980,11 @@ body:has(.theme-cupertino) { margin-bottom: 12px; } +.track-stats-grid { + margin-top: 16px; + align-items: start; +} + .signature-grid { align-items: start; } diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 75f4f63..5bd1a03 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -19,6 +19,7 @@ import { parseTrackFile, type SavedTrack } from '../services/trackUpload.js' +import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' interface LogEntryEditorProps { entryId: string @@ -86,6 +87,11 @@ export default function LogEntryEditor({ const [signSkipper, setSignSkipper] = useState('') const [signCrew, setSignCrew] = useState('') + // GPS track stats (from uploaded track) + const [trackDistanceNm, setTrackDistanceNm] = useState('') + const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') + const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') + // Events list state const [events, setEvents] = useState([]) @@ -121,6 +127,27 @@ export default function LogEntryEditor({ const [uploadError, setUploadError] = useState(null) const fileInputRef = useRef(null) + const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { + const stats = computeTrackStats(waypoints) + if (!stats) return + const formatted = formatTrackStats(stats) + setTrackDistanceNm(formatted.distanceNm) + setTrackSpeedMaxKn(formatted.speedMaxKn) + setTrackSpeedAvgKn(formatted.speedAvgKn) + } + + const loadTrackStatsFromEntry = (entry: any) => { + if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') { + setTrackDistanceNm(String(entry.trackDistanceNm)) + } + if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') { + setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn)) + } + if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { + setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) + } + } + // Auto-calculate Freshwater Consumption useEffect(() => { const morning = parseFloat(fwMorning) || 0 @@ -189,6 +216,7 @@ export default function LogEntryEditor({ setSignSkipper(preloadedEntry.signSkipper || '') setSignCrew(preloadedEntry.signCrew || '') + loadTrackStatsFromEntry(preloadedEntry) setEvents(preloadedEntry.events || []) return } @@ -218,6 +246,7 @@ export default function LogEntryEditor({ setSignSkipper(decrypted.signSkipper || '') setSignCrew(decrypted.signCrew || '') + loadTrackStatsFromEntry(decrypted) setEvents(decrypted.events || []) } } @@ -249,6 +278,12 @@ export default function LogEntryEditor({ loadTrack() }, [entryId, preloadedTrack]) + useEffect(() => { + if (!savedTrack || savedTrack.waypoints.length < 2) return + if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return + applyTrackStats(savedTrack.waypoints) + }, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn]) + // Track file upload handlers const handleFileUpload = async (file: File) => { if (readOnly) return @@ -268,6 +303,7 @@ export default function LogEntryEditor({ } await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType) + applyTrackStats(parsedWps) await loadTrack() } catch (err: any) { console.error('File parsing failed:', err) @@ -311,6 +347,9 @@ export default function LogEntryEditor({ try { await deleteTrack(logbookId, entryId) setSavedTrack(null) + setTrackDistanceNm('') + setTrackSpeedMaxKn('') + setTrackSpeedAvgKn('') setUploadError(null) } catch (err: any) { showAlert(err.message || 'Failed to delete track') @@ -570,6 +609,9 @@ export default function LogEntryEditor({ }, signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(), signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(), + trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, + trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, + trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, events } @@ -1178,6 +1220,15 @@ export default function LogEntryEditor({ {savedTrack.waypoints.length > 0 && ( <> · {savedTrack.waypoints.length} {t('logs.track_upload_points')} )} + {trackDistanceNm && ( + <> · {trackDistanceNm} sm + )} + {trackSpeedMaxKn && ( + <> · max {trackSpeedMaxKn} kn + )} + {trackSpeedAvgKn && ( + <> · Ø {trackSpeedAvgKn} kn + )}
@@ -1204,6 +1255,47 @@ export default function LogEntryEditor({
)} + + {(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && ( +
+
+ + setTrackDistanceNm(e.target.value)} + disabled={saving || readOnly} + /> +
+
+ + setTrackSpeedMaxKn(e.target.value)} + disabled={saving || readOnly} + /> +
+
+ + setTrackSpeedAvgKn(e.target.value)} + disabled={saving || readOnly} + /> +
+
+ )} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4d735af..f1a2ac2 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -166,6 +166,9 @@ "gps_track_upload_btn": "GPS-Track hochladen", "gps_track_delete": "Track-Datei löschen", "gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?", + "track_distance": "GPS-Strecke (sm)", + "track_speed_max": "Max. Geschwindigkeit (kn)", + "track_speed_avg": "Ø Geschwindigkeit (kn)", "exporting": "Exportiere...", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.", "invite_crew": "Crew einladen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index f0319bb..eccb6a3 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -166,6 +166,9 @@ "gps_track_upload_btn": "Upload GPS Track File", "gps_track_delete": "Delete Track File", "gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?", + "track_distance": "GPS distance (nm)", + "track_speed_max": "Max speed (kn)", + "track_speed_avg": "Avg speed (kn)", "exporting": "Exporting...", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead.", "invite_crew": "Invite Crew", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 4fd99ae..deacbfa 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -78,6 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const headers = [ 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Skipper Signature', 'Crew Signature', + 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Event Time', 'MgK Course', 'RwK Course', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', @@ -96,6 +97,9 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const dest = entry.destination || ''; const signS = formatSignatureForExport(entry.signSkipper); const signC = formatSignatureForExport(entry.signCrew); + const trackDist = entry.trackDistanceNm ?? ''; + const trackMax = entry.trackSpeedMaxKn ?? ''; + const trackAvg = entry.trackSpeedAvgKn ?? ''; const fwM = entry.freshwater?.morning ?? ''; const fwR = entry.freshwater?.refilled ?? ''; const fwE = entry.freshwater?.evening ?? ''; @@ -111,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya rows.push([ dateVal, travelDay, dep, dest, signS, signC, + trackDist, trackMax, trackAvg, '', '', '', '', '', '', '', '', '', '', '', '', @@ -126,6 +131,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya rows.push([ dateVal, travelDay, dep, dest, signS, signC, + trackDist, trackMax, trackAvg, ev.time || '', ev.mgk || '', ev.rwk || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 4ea2e89..70c80a2 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -78,10 +78,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); - doc.text(`Datum: ${entry.date || '—'}`, 10, 26); - doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26); - doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26); - doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26); + doc.text(`Datum: ${entry.date || '—'}`, 10, 23); + doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23); + doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23); + doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23); + + if (entry.trackDistanceNm) { + doc.setFont('Helvetica', 'normal'); + doc.text( + `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, + 10, + 27 + ); + } // Divider line doc.setLineWidth(0.3); diff --git a/client/src/utils/trackStats.ts b/client/src/utils/trackStats.ts new file mode 100644 index 0000000..4194d3b --- /dev/null +++ b/client/src/utils/trackStats.ts @@ -0,0 +1,107 @@ +import type { TrackWaypoint } from '../services/trackUpload.js' + +const NM_IN_METERS = 1852 +const MAX_PLAUSIBLE_KNOTS = 50 + +export interface TrackStats { + distanceNm: number + speedMaxKn: number + speedAvgKn: number + durationMinutes: number +} + +function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000 + const p1 = (lat1 * Math.PI) / 180 + const p2 = (lat2 * Math.PI) / 180 + const dLat = ((lat2 - lat1) * Math.PI) / 180 + const dLon = ((lon2 - lon1) * Math.PI) / 180 + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.asin(Math.sqrt(a)) +} + +function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean { + if (waypoints.length < 2) return false + const first = waypoints[0].timestamp + const last = waypoints[waypoints.length - 1].timestamp + return last > first + 60_000 +} + +export function computeTrackStats(waypoints: TrackWaypoint[]): TrackStats | null { + if (waypoints.length < 2) return null + + let totalMeters = 0 + let maxSegmentKn = 0 + let maxTaggedKn = 0 + let hasTaggedSpeed = false + + const timed = hasMeaningfulTimestamps(waypoints) + const firstTs = waypoints[0].timestamp + const lastTs = waypoints[waypoints.length - 1].timestamp + + for (let i = 1; i < waypoints.length; i++) { + const prev = waypoints[i - 1] + const curr = waypoints[i] + const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng) + totalMeters += segmentM + + if (timed) { + const dtMs = curr.timestamp - prev.timestamp + if (dtMs > 0 && segmentM > 0) { + const segmentKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000) + if (segmentKn <= MAX_PLAUSIBLE_KNOTS) { + maxSegmentKn = Math.max(maxSegmentKn, segmentKn) + } + } + } + + if (curr.speedKnots != null && curr.speedKnots > 0) { + hasTaggedSpeed = true + maxTaggedKn = Math.max(maxTaggedKn, curr.speedKnots) + } + } + + const distanceNm = totalMeters / NM_IN_METERS + if (distanceNm <= 0) return null + + let speedMaxKn = 0 + let speedAvgKn = 0 + let durationMinutes = 0 + + if (timed) { + const durationHours = (lastTs - firstTs) / 3_600_000 + durationMinutes = Math.round((lastTs - firstTs) / 60_000) + speedAvgKn = durationHours > 0 ? distanceNm / durationHours : 0 + speedMaxKn = Math.max(maxSegmentKn, hasTaggedSpeed ? maxTaggedKn : 0) + } else if (hasTaggedSpeed) { + const taggedSpeeds = waypoints + .map((wp) => wp.speedKnots) + .filter((speed): speed is number => speed != null && speed > 0) + speedMaxKn = maxTaggedKn + speedAvgKn = + taggedSpeeds.length > 0 + ? taggedSpeeds.reduce((sum, speed) => sum + speed, 0) / taggedSpeeds.length + : 0 + } + + return { + distanceNm: Number(distanceNm.toFixed(2)), + speedMaxKn: Number(speedMaxKn.toFixed(1)), + speedAvgKn: Number(speedAvgKn.toFixed(1)), + durationMinutes + } +} + +export function formatTrackStats(stats: TrackStats): { + distanceNm: string + speedMaxKn: string + speedAvgKn: string +} { + return { + distanceNm: stats.distanceNm.toFixed(2), + speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '', + speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : '' + } +} diff --git a/testdata/tracks/kieler-foerde-5sm.gpx b/testdata/tracks/kieler-foerde-5sm.gpx new file mode 100644 index 0000000..92b5cc3 --- /dev/null +++ b/testdata/tracks/kieler-foerde-5sm.gpx @@ -0,0 +1,1274 @@ + + + + Kieler Förde Testfahrt 5 sm + Testtrack Kiellinie nach Laboe, 5.00 Seemeilen. Erwartet: Durchschnitt 4.6 kn, Max 7.8 kn. + + + + Kieler Förde Kiellinie → Laboe + sailing + + + + 1.0 + 1.286 + 42.2 + + + + 1.0 + 1.348 + 42.2 + + + + 1.0 + 1.409 + 42.2 + + + + 1.0 + 1.471 + 42.2 + + + + 1.0 + 1.532 + 42.2 + + + + 1.0 + 1.594 + 42.2 + + + + 1.0 + 1.655 + 42.2 + + + + 1.0 + 1.717 + 42.2 + + + + 1.0 + 1.778 + 42.2 + + + + 1.0 + 1.840 + 42.2 + + + + 1.0 + 1.901 + 42.2 + + + + 1.0 + 1.963 + 42.2 + + + + 1.0 + 2.025 + 42.2 + + + + 1.0 + 2.086 + 42.2 + + + + 1.0 + 2.148 + 42.2 + + + + 1.0 + 2.209 + 42.2 + + + + 1.0 + 2.271 + 42.2 + + + + 1.0 + 2.557 + 42.2 + + + + 1.0 + 2.681 + 42.2 + + + + 1.0 + 2.804 + 42.2 + + + + 1.0 + 2.912 + 42.2 + + + + 1.0 + 2.990 + 42.2 + + + + 1.0 + 3.031 + 42.2 + + + + 1.0 + 3.028 + 42.2 + + + + 1.0 + 2.983 + 42.2 + + + + 1.0 + 2.900 + 42.2 + + + + 1.0 + 2.790 + 42.2 + + + + 1.0 + 2.666 + 42.2 + + + + 1.0 + 2.543 + 42.2 + + + + 1.0 + 2.436 + 42.2 + + + + 1.0 + 2.358 + 42.2 + + + + 1.0 + 2.319 + 42.2 + + + + 1.0 + 2.323 + 42.2 + + + + 1.0 + 2.369 + 42.2 + + + + 1.0 + 2.453 + 42.2 + + + + 1.0 + 2.563 + 42.2 + + + + 1.0 + 2.687 + 42.2 + + + + 1.0 + 2.810 + 42.2 + + + + 1.0 + 2.916 + 42.2 + + + + 1.0 + 2.993 + 42.2 + + + + 1.0 + 3.032 + 42.2 + + + + 1.0 + 3.027 + 42.2 + + + + 1.0 + 2.980 + 42.2 + + + + 1.0 + 2.895 + 42.2 + + + + 1.0 + 2.784 + 42.2 + + + + 1.0 + 2.660 + 42.2 + + + + 1.0 + 2.538 + 42.2 + + + + 1.0 + 2.432 + 42.2 + + + + 1.0 + 2.355 + 42.2 + + + + 1.0 + 2.318 + 42.2 + + + + 1.0 + 2.324 + 32.8 + + + + 1.0 + 2.372 + 32.8 + + + + 1.0 + 2.457 + 32.8 + + + + 1.0 + 2.569 + 32.8 + + + + 1.0 + 2.693 + 32.8 + + + + 1.0 + 2.815 + 32.8 + + + + 1.0 + 2.921 + 32.8 + + + + 1.0 + 2.996 + 32.8 + + + + 1.0 + 3.033 + 32.8 + + + + 1.0 + 3.026 + 32.8 + + + + 1.0 + 2.976 + 32.8 + + + + 1.0 + 2.891 + 32.8 + + + + 1.0 + 2.778 + 32.8 + + + + 1.0 + 2.654 + 32.8 + + + + 1.0 + 2.532 + 32.8 + + + + 1.0 + 2.427 + 32.8 + + + + 1.0 + 2.353 + 32.8 + + + + 1.0 + 2.317 + 32.8 + + + + 1.0 + 2.325 + 32.8 + + + + 1.0 + 2.375 + 32.8 + + + + 1.0 + 2.462 + 32.8 + + + + 1.0 + 2.575 + 32.8 + + + + 1.0 + 2.699 + 32.8 + + + + 1.0 + 2.821 + 32.8 + + + + 1.0 + 2.925 + 32.8 + + + + 1.0 + 2.999 + 32.8 + + + + 1.0 + 3.033 + 32.8 + + + + 1.0 + 3.024 + 32.8 + + + + 1.0 + 2.973 + 32.8 + + + + 1.0 + 2.886 + 32.8 + + + + 1.0 + 2.773 + 32.8 + + + + 1.0 + 2.648 + 32.8 + + + + 1.0 + 2.526 + 32.8 + + + + 1.0 + 2.423 + 32.8 + + + + 1.0 + 2.350 + 32.8 + + + + 1.0 + 2.317 + 32.8 + + + + 1.0 + 2.327 + 32.8 + + + + 1.0 + 2.379 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 4.013 + 32.8 + + + + 1.0 + 2.419 + 32.8 + + + + 1.0 + 2.348 + 32.8 + + + + 1.0 + 2.316 + 32.8 + + + + 1.0 + 2.328 + 32.8 + + + + 1.0 + 2.382 + 30.2 + + + + 1.0 + 2.472 + 30.2 + + + + 1.0 + 2.586 + 30.2 + + + + 1.0 + 2.711 + 30.2 + + + + 1.0 + 2.832 + 30.2 + + + + 1.0 + 2.934 + 30.2 + + + + 1.0 + 3.004 + 30.2 + + + + 1.0 + 3.034 + 30.2 + + + + 1.0 + 3.021 + 30.2 + + + + 1.0 + 2.966 + 30.2 + + + + 1.0 + 2.876 + 30.2 + + + + 1.0 + 2.761 + 30.2 + + + + 1.0 + 2.636 + 30.2 + + + + 1.0 + 2.515 + 30.2 + + + + 1.0 + 2.414 + 30.2 + + + + 1.0 + 2.345 + 30.2 + + + + 1.0 + 2.316 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 3.550 + 30.2 + + + + 1.0 + 2.962 + 30.2 + + + + 1.0 + 2.871 + 30.2 + + + + 1.0 + 2.755 + 30.2 + + + + 1.0 + 2.630 + 30.2 + + + + 1.0 + 2.510 + 30.2 + + + + 1.0 + 2.410 + 30.2 + + + + 1.0 + 2.343 + 30.2 + + + + 1.0 + 2.315 + 30.2 + + + + 1.0 + 2.332 + 30.2 + + + + 1.0 + 2.390 + 30.2 + + + + 1.0 + 2.482 + 30.2 + + + + 1.0 + 2.598 + 30.2 + + + + 1.0 + 2.723 + 30.2 + + + + 1.0 + 2.843 + 30.2 + + + + 1.0 + 2.942 + 30.2 + + + + 1.0 + 3.009 + 30.2 + + + + 1.0 + 3.035 + 30.2 + + + + 1.0 + 3.018 + 30.2 + + + + 1.0 + 2.959 + 30.2 + + + + 1.0 + 2.866 + 30.2 + + + + 1.0 + 2.749 + 30.2 + + + + 1.0 + 2.624 + 29.3 + + + + 1.0 + 2.505 + 29.3 + + + + 1.0 + 2.406 + 29.3 + + + + 1.0 + 2.340 + 29.3 + + + + 1.0 + 2.821 + 29.3 + + + + 1.0 + 2.786 + 29.3 + + + + 1.0 + 2.752 + 29.3 + + + + 1.0 + 2.717 + 29.3 + + + + 1.0 + 2.683 + 29.3 + + + + 1.0 + 2.649 + 29.3 + + + + 1.0 + 2.614 + 29.3 + + + + 1.0 + 2.580 + 29.3 + + + + 1.0 + 2.545 + 29.3 + + + + 1.0 + 2.511 + 29.3 + + + + 1.0 + 2.476 + 29.3 + + + + 1.0 + 2.442 + 29.3 + + + + 1.0 + 2.407 + 29.3 + + + + 1.0 + 2.373 + 29.3 + + + + 1.0 + 2.338 + 29.3 + + + + 1.0 + 2.304 + 29.3 + + + + 1.0 + 2.269 + 29.3 + + + + 1.0 + 2.235 + 29.3 + + + + 1.0 + 2.201 + 29.3 + + + + 1.0 + 2.166 + 29.3 + + + + 1.0 + 2.132 + 29.3 + + + + 1.0 + 2.097 + 29.3 + + + + 1.0 + 2.063 + 29.3 + + + + 1.0 + 2.028 + 29.3 + + + + 1.0 + 1.994 + 29.3 + + + + 1.0 + 1.959 + 29.3 + + + + 1.0 + 1.925 + 29.3 + + + + 1.0 + 1.890 + 29.3 + + + + 1.0 + 1.856 + 29.3 + + + + 1.0 + 1.821 + 29.3 + + + + 1.0 + 1.787 + 29.3 + + + + 1.0 + 1.753 + 29.3 + + + + 1.0 + 1.718 + 29.3 + + + + 1.0 + 1.684 + 29.3 + + + + 1.0 + 1.649 + 29.3 + + + + 1.0 + 1.615 + 29.7 + + + + 1.0 + 1.580 + 29.7 + + + + 1.0 + 1.546 + 29.7 + + + + 1.0 + 1.511 + 29.7 + + + + 1.0 + 1.477 + 29.7 + + + + 1.0 + 1.442 + 29.7 + + + + 1.0 + 1.408 + 29.7 + + + + 1.0 + 1.373 + 29.7 + + + + 1.0 + 1.339 + 29.7 + + + + 1.0 + 1.305 + 29.7 + + + + 1.0 + 1.270 + 29.7 + + + + 1.0 + 1.236 + 29.7 + + + + 1.0 + 1.201 + 29.7 + + + + 1.0 + 1.167 + 29.7 + + + + 1.0 + 1.132 + 29.7 + + + + 1.0 + 1.098 + 29.7 + + + + 1.0 + 1.063 + 29.7 + + + + 1.0 + 1.029 + 29.7 + + + +