Skip to content

Repository files navigation

GlideComp logo GlideComp

GlideComp scores hanggliding and paragliding competitions — and analyses the whole field's flights, so pilots can learn from the people they flew the day with.

What it does:

  • Pilots (or competition organizers) load IGC track log files and XCTask task files in the browser
  • The app analyzes flights: task completion, scoring explanations, glide performance, thermal/climb analysis
  • Full CIVL GAP scoring implementation (FAI Sporting Code Section 7F) — distance, time, leading, and arrival points for both PG and HG
  • Field analysis — every track on a task measured against every other one across climbing, gliding, decision-making, gaggle and race craft, ranked by how strongly each behaviour separated the field, and read against the day's weather
  • Includes 2D (Mapbox) and 3D (Three.js globe) map visualization of flight tracks
  • Competition management features: pilot registration, task setup, scoring, penalties, with a full audit log for transparency

How it's built:

  • Client-side first — IGC parsing and flight analysis happen entirely in the browser, no server required for core functionality
  • Frontend: React SPA hosted on Cloudflare Pages, built with react-aria-components (the app's only component kit — see docs/2026-07-18-rac-adoption-guide.md) and Tailwind CSS. The public competition pages are server-rendered by Pages Functions, and the content pages (home, about, scoring guides) are prerendered static HTML
  • Backend: Cloudflare Workers API for competition management, with D1 (SQLite) database and R2 storage
  • Engine: A dedicated @glidecomp/engine package handles geo calculations (WGS84 ellipsoid math, Vincenty formulas) and flight analysis

Key design principles:

  • No server-side storage needed for basic use — drag-and-drop files and analyze locally
  • All scoring decisions are explainable and auditable
  • Every mutation affecting competition scores is audit-logged

The app is live at glidecomp.com.

Features

  • Flight event detection — automatic detection of takeoff, landing, thermals, glides, start/goal crossings, and turnpoint tagging
  • Thermal analysis — entry/exit times, altitude gain, average climb rate
  • Glide analysis — distance, altitude lost, L/D ratio, plus sink detection for poor glides
  • GAP scoring — CIVL GAP scoring with distance, time, leading, and arrival points, including stopped tasks (S7F §12.3)
  • Open-distance scoring — for tasks flown downwind from a launch cylinder rather than around a route
  • Report card — a per-pilot page explaining exactly how their score was arrived at, with the substituted arithmetic, each component's scoring curve drawn with the field on it, and links into the scoring guides
  • Field analysis — 26 cross-pilot behavioural metrics per task, ranked by Spearman correlation against finishing position, with per-comp consistency across tasks
  • Task weather — modelled wind, climb and timing for the task's day from an outside provider, plus the organizer's own weather notes, read against what the field actually flew
  • Track quality checks — every tracklog assessed against its task (FAI S7A §4.4.2); hard findings withhold a track from scoring, and every verdict is overridable by the organizer
  • Competition management — create competitions, register pilots, upload IGC tracks, manage tasks, apply penalties, with full audit logging
  • Scores export — the full standings as a pivot-ready CSV, downloadable or opened live in Google Sheets
  • Waypoints — a per-competition waypoint list, editable and exportable to flight instruments
  • Authentication — sign in with Google, or passwordless email one-time codes
  • Task editor — create and edit tasks with drag-to-reorder turnpoints, waypoint database search, and click-on-map placement
  • Multiple data sources — drag & drop IGC/XCTSK files, import from XContest by task code, or import from AirScore by URL
  • Interactive map — 2D (Mapbox) and 3D views with track overlay, task cylinders, and map annotations
  • 3D flight replay — a Three.js replay of a whole task's field, with gaggle detection
  • Altitude sparkline — clickable time-series chart linked to events and map position
  • Configurable units & detection — speed, altitude, distance, climb rate units; adjustable thermal/glide detection thresholds

Web Development

Prerequisites

  • Bun (also requires Node.js 20+)
  • A MapBox access token (for the map). If your token has URL restrictions, ensure glidecomp.com and localhost:3000 are in the allowed URLs list.

Setup

bun install

# Copy .env.example and add your MapBox token
cp .env.example .env
# Edit .env and set VITE_MAPBOX_TOKEN=your_token_here

Running locally

Start the frontend and all API workers together:

bun run dev

This starts two processes:

  • Frontend — Vite dev server at http://localhost:3000
  • API Workers — auth-api, competition-api and airscore-api, all in ONE wrangler dev session, reached through the dev-router Worker at http://localhost:8790

Every worker shares a single Miniflare instance, so the D1 database has one writer — running them as separate processes on separate ports raced on the shared SQLite file (issue #477). Only the router's port is exposed; it dispatches /api/auth/*, /api/comp/*, /api/airscore/* and friends to the right worker over service bindings, exactly as the Pages Functions do in production. Vite proxies all of /api there, so the browser only ever talks to :3000. To start the workers on their own: bun run dev:workers.

The auth worker requires a .dev.vars file in web/workers/auth-api/ — see docs/auth.md for setup.

To start only the frontend (without any workers):

bun run dev:frontend

AirScore features are served by the same router; nothing extra to start. If you'd rather use the production AirScore API instead of the local worker:

VITE_AIRSCORE_URL=https://glidecomp.com/api/airscore bun run dev

Previewing through the real Pages runtime

bun run preview builds the site and serves it through wrangler pages dev, so you exercise the SSR Functions, the sitemap and the Astro pages exactly as production does — no HMR, so a code change needs a re-run.

bun run preview          # http://localhost:3000

Running isolated, in a container

bun run preview binds ports on your Mac (pages dev, the Workers' dev-router, a workerd inspector) and keeps D1 + R2 in web/.wrangler/state, so a second copy collides with the first. To run one that can't, put it in a container — the stock ports stay inside the VM and only one is published:

bun run preview:container            # http://localhost:3200
PORT=3201 bun run preview:container  # a second, wholly independent instance

Requires Apple's container (macOS 26+, Apple silicon): brew install container. Each PORT gets its own named volume for D1 + R2, so instances never see each other's data — or your host checkout's.

Your checkout is bind-mounted read-only and staged into the container on start, so a source edit needs a restart (Ctrl-C, re-run), not an image rebuild. There's no HMR — preview serves a production build, same as on the host. Use bun run dev for a fast edit loop.

The image holds only the dependency tree, and the script rebuilds it for you when it needs to: it hashes the files that layer is built from (bun.lock, the workspace manifests, patches/) and compares against a stamp beside the image, printing the reason when it rebuilds. Editing source never triggers one. Even a missed rebuild is only ever slower, not wrong — the entrypoint runs bun install --frozen-lockfile after staging, which reconciles node_modules to whatever lockfile the mount brought in.

variable default when you need it
PORT 3200 run more than one instance
SKIP_SEED 0 keep an instance's existing data instead of reseeding
REBUILD 0 force an image rebuild — normally detected for you
CPUS / MEMORY 6 / 8G vite build is OOM-killed below ~4G

The default port is 3200 because 3000 (vite), 3100 (SSR e2e), 4321 (astro), 8790 (the Workers' dev-router) and 9232 (its inspector) are all already spoken for — and because a native bun run dev binds Vite to [::1]:3000 while a published container port binds 127.0.0.1:3000. macOS treats those as different sockets, so both bind happily and localhost quietly serves you whichever one the resolver picked. The script refuses a port anything already holds, on either stack, rather than let that happen.

The browser only ever talks to the published port. The one thing that doesn't work in the container is the analysis page's AirScore import — it calls same-origin /api/airscore, and there's no Pages Function proxying that, so it 404s here exactly as it does in production.

Tests and type checking

bun run test             # bun test sweep (root, engine, airscore-api, dev-router, scripts) + type check
bun run test:all         # the above, then the three vitest suites concurrently
bun run test:comp        # Competition API tests only
bun run test:auth        # Auth API tests only
bun run test:frontend    # Frontend tests only
bun run test:e2e         # Playwright end-to-end tests
bun run test:e2e:ssr     # Playwright suite against the server-rendered build
bun run db:migrate       # Apply D1 migrations to the local state
bun run typecheck        # Type check the root project
bun run typecheck:all    # Type check everything (frontend + engine + all four workers)

The e2e suite writes to the persistent local D1 state in web/.wrangler/state. Specs clean up after themselves and each run sweeps anything a killed run left behind, so this should stay tidy on its own — but if local e2e results ever look inexplicable, bun run kill-state resets the database to empty (then bun run seed to put the sample comps back).

Chrome MCP server set up

Read https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session

Deployment

  • Push to master → deploys to production
  • Push to other branches → deploys to preview URL
bun run deploy           # Manual deploy to Cloudflare Pages
bun run deploy:worker    # Manual deploy AirScore API Worker
bun run deploy:comp      # Manual deploy Competition API Worker
bun run deploy:auth      # Manual deploy Auth API Worker
bun run deploy:all       # Deploy Pages + AirScore & Competition Workers (auth-api is separate: deploy:auth)

URLs:

CLI Scripts

detect-events - Detect flight events from an IGC file, outputting CSV:

bun run detect-events -- <flight.igc> [task.xctsk]

# Example with sample data
bun run detect-events -- \
  web/frontend/public/data/tracks/2025-01-05-Tushar-Corryong.igc \
  web/frontend/public/data/tasks/buje.xctsk

get-xcontest-task - Download a task from XContest by code:

bun run get-xcontest-task -- face
bun run get-xcontest-task -- --file task.json

score-task - Score multiple pilots against a task using CIVL GAP (FAI Sporting Code Section 7F):

bun run score-task -- <task.xctsk> <igc-file-or-folder>... [options]

# Scores identically to the web app. --wing (HG or PG) is REQUIRED for GAP
# scoring — the CLI has no comp record and won't guess. Given it, the run starts
# from the official per-category FAI/S7F defaults and each flag overrides one
# parameter. Flag names are the kebab-case of the gap_params keys the UI saves;
# units are the engine's (metres / seconds / 0-1 ratios). Grouped by function:
#
# Wing (required for GAP):
#   --wing <HG|PG>             Competition wing (`scoring`)
# Task mode:
#   --open-distance            Score as open distance (GAP options ignored)
#   --comp <slug-or-dir>       Score a whole bundled comp (every task per class,
#                              plus a per-class cross-task aggregate); wing comes
#                              from the comp.json manifest. Implies --field-analysis.
# Field analysis:
#   --field-analysis           After the scores, print the behavioural field
#                              analysis — per-pilot metrics (climbing, gliding,
#                              decisions, gaggle, race craft, day profile/wind)
#                              led by the metric-separation ranking (Spearman ρ
#                              vs GAP rank). See docs/2026-07-18-field-analysis-plan.md.
# Nominal parameters:
#   --nominal-distance <m>     `nominalDistance` (default: 70% of task distance)
#   --nominal-distance-pct <%> That percentage, when you'd rather not do the sum
#   --nominal-goal <ratio>     `nominalGoal` 0-1 (default: 0.3)
#   --nominal-time <s>         `nominalTime` in seconds (default: 5400)
#   --minimum-distance <m>     `minimumDistance` in metres (default: 5000)
# Scoring terms (per-wing defaults; each has a --use-… form to turn it back on):
#   --no-use-leading           Disable leading (departure) points (`useLeading`)
#   --no-use-arrival           Disable arrival points (`useArrival`)
#   --no-use-distance-difficulty   Disable difficulty points (`useDistanceDifficulty`)
# Stopped tasks (S7F §12.3):
#   --stop-time <iso-datetime> Announce a stop, e.g. 2026-01-15T03:45:00Z
#   --score-back-time <s>      PG score-back window (default: 300)
# Formula & advanced:
#   --leading-formula <weighted|classic>  `leadingFormula` (default: classic HG / weighted PG)
#   --leading-weight-formula <gap2020|s7f2024>  PG leading weight (default: gap2020)
#   --time-points-exponent <5/6|2/3>       `timePointsExponent` (default: 5/6)
#   --ess-not-goal-factor <ratio>          `essNotGoalFactor` (default: 0.8 HG / 0 PG)
#   (see --help for the full list, incl. nominal-launch, distance-origin,
#    jump-the-gun-factor, jump-the-gun-max-seconds, leading-time-ratio)
# Output:
#   --json                     Output as JSON

# Example: score Corryong Cup 2026 Open Task 1 as HG with the official defaults
bun run score-task \
  web/samples/comps/corryong-cup-2026-open-t1/task.xctsk \
  web/samples/comps/corryong-cup-2026-open-t1/ \
  --wing HG

# Example: score the whole bundled comp, with field analysis per task and a
# per-class cross-task aggregate
bun run score-task -- --comp corryong-cup-2026

Example output:

=== Task Scoring Results (CIVL GAP) ===

Scoring config:
  Sport:          HG
  Leading:        on (classic)
  Time exponent:  5/6
  Arrival:        on
  Difficulty:     on
  Nominal:        dist 55.2 km / time 90 min / goal 30% / launch 96%

Task distance:    78.8 km
Pilots:           32 flying / 32 present
In goal:          12 (37.5%)
Best time:        1:37:55

Task Validity:    100.0%
Available Points: 1000 (dist: 486, time: 360, lead: 90, arr: 64)

   #  Pilot                            Dist     SS Time   Dist Pts   Diff Pts   Time Pts   Lead Pts    Arr Pts    Total
-----------------------------------------------------------------------------------------------------------------------
   1  Rohan Holtkamp                78.8 km     1:47:55      485.6      242.8      294.1       90.0       64.3      934
   2  Jon Durand                    78.8 km     1:37:55      485.6      242.8      360.1       21.2       44.2      911
   3  Peter  Burkitt                78.8 km     1:50:13      485.6      242.8      281.7       89.4       53.4      910
  ...
  13  Rich Reinauer                 76.9 km          LO      479.7      242.8        0.0        0.0        0.0      480

The full HG table also shows a leading-coefficient (LC) column; it's elided here for width.

seed - Seed the bundled sample competitions into local D1 + R2 (idempotent; --remote targets production):

bun run seed                    # every bundled comp
bun run seed big-chip kosci-loop  # just these slugs

civl-rankings - Import the FAI/CIVL monthly world pilot rankings into D1 (all ten disciplines; idempotent, and a no-op on a day CIVL hasn't published anything new). Runs daily in CI — see docs/civl-rankings.md:

bun run civl-rankings

build-3dvis - Pack a task's tracks into the asset the 3D replay loads:

bun run build-3dvis

bench-task / bench-analysis - Benchmark the scoring and analysis paths (see the benchmark-engine skill).

Project Structure

functions/               - Cloudflare Pages Functions (SSR for the public /comp pages,
                           /api/* proxies to the Workers, sitemap, middleware)
web/
  frontend/              - Cloudflare Pages frontend (Vite + TypeScript)
    static/              - Astro app: the prerendered content pages (home, about,
                           legal, the scoring guides)
  engine/                - Shared analysis library (IGC parsing, event detection, GAP scoring,
                           field analysis, track quality, weather)
    cli/                 - CLI utilities (detect-events, get-xcontest-task, score-task, build-3dvis, benchmarks)
  workers/
    auth-api/            - Authentication API (Cloudflare Worker + D1)
    competition-api/     - Competition management API (Cloudflare Worker + D1 + R2)
    airscore-api/        - AirScore caching proxy (Cloudflare Worker)
    dev-router/          - Dev-only router: owns :8790 and dispatches to the three above
  db/
    migrations/          - D1 schema migrations (shared by auth-api and competition-api)
  samples/               - Bundled sample competitions, CIVL ranking fixtures, reference data
  scripts/               - Operational scripts (comp seeding/download/generation, dev helpers, secrets)
e2e/                     - Playwright end-to-end tests
docs/                    - Feature and architecture specifications
patches/                 - Patched dependencies (applied by bun)

About

Hang Gliding and Paragliding competition task score and flight analysis

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages