diff --git a/dump1090.c b/dump1090.c index d080470c3..affa675c0 100644 --- a/dump1090.c +++ b/dump1090.c @@ -3,18 +3,18 @@ * Copyright (C) 2012 by Salvatore Sanfilippo * * All rights reserved. - * + * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: - * + * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -113,6 +114,7 @@ struct aircraft { uint32_t addr; /* ICAO address */ char hexaddr[7]; /* Printable ICAO address */ char flight[9]; /* Flight number */ + int emitter_category; /* Emitter category */ int altitude; /* Altitude */ int speed; /* Velocity computed from EW and NS components. */ int track; /* Angle of flight. */ @@ -228,7 +230,7 @@ struct modesMessage { int mesub; /* Extended squitter message subtype. */ int heading_is_valid; int heading; - int aircraft_type; + int emitter_category; int fflag; /* 1 = Odd, 0 = Even CPR message. */ int tflag; /* UTC synchronized? */ int raw_latitude; /* Non decoded latitude */ @@ -468,14 +470,6 @@ void readDataFromFile(void) { continue; } - if (Modes.interactive) { - /* When --ifile and --interactive are used together, slow down - * playing at the natural rate of the RTLSDR received. */ - pthread_mutex_unlock(&Modes.data_mutex); - usleep(5000); - pthread_mutex_lock(&Modes.data_mutex); - } - /* Move the last part of the previous buffer, that was not processed, * on the start of the new buffer. */ memcpy(Modes.data, Modes.data+MODES_DATA_LEN, (MODES_FULL_LEN-1)*4); @@ -505,6 +499,24 @@ void readDataFromFile(void) { * no signal. */ memset(p,127,toread); } + if (Modes.interactive || Modes.net) { + /* With --ifile in interactive or network-serving modes, replay + * data at the same rate it would arrive from the SDR: 2 bytes + * per sample at MODES_DEFAULT_RATE samples per second. */ + ssize_t bytes_read = MODES_DATA_LEN - toread; + if (bytes_read > 0) { + uint64_t delay_us = + (((uint64_t)bytes_read * 1000000) / + (MODES_DEFAULT_RATE * 2)); + struct timespec delay = { + .tv_sec = delay_us / 1000000, + .tv_nsec = (delay_us % 1000000) * 1000 + }; + pthread_mutex_unlock(&Modes.data_mutex); + nanosleep(&delay, NULL); + pthread_mutex_lock(&Modes.data_mutex); + } + } Modes.data_ready = 1; /* Signal to the other thread that new data is ready */ pthread_cond_signal(&Modes.data_cond); @@ -968,7 +980,7 @@ int bruteForceAP(unsigned char *msg, struct modesMessage *mm) { aux[lastbyte] ^= crc & 0xff; aux[lastbyte-1] ^= (crc >> 8) & 0xff; aux[lastbyte-2] ^= (crc >> 16) & 0xff; - + /* If the obtained address exists in our cache we consider * the message valid. */ addr = aux[lastbyte] | (aux[lastbyte-1] << 8) | (aux[lastbyte-2] << 16); @@ -1041,6 +1053,73 @@ char *ca_str[8] = { /* 7 */ "Level 7 ???" }; +/* ADS-B emitter category lookup by identification/category message encoding. + * + * The category is encoded as: + * - Category set: metype 1..4 -> A, B, C, D + * - Category code: msg[4] & 7 -> 0..7 within that set + * + * So the logical values are A0..A7, B0..B7, C0..C7, D0..D7. + * Code 0 in each set means "No Emitter Category", which is why that string + * appears at the start of every sub-table. + * + * Public reference: + * FAA AC 20-165B Appendix tables: + * https://www.faa.gov/documentLibrary/media/Advisory_Circular/AC_20-165B.pdf */ +static const char *emitter_category_labels[4][8] = { + { + "No Emitter Category", + "Light Airplane", + "Small Airplane", + "Large Airplane", + "High Vortex Aircraft", + "Heavy Airplane", + "High Performance Aircraft", + "Rotorcraft" + }, + { + "No Emitter Category", + "Glider or sailplane", + "Lighter Than Air", + "Parachute / Sky Diver", + "Ultralight Vehicle", + "UAV", + "Space/Trans-atmospheric Vehicle", + "Reserved" + }, + { + "No Emitter Category", + "Surface Vehicle—Emergency Vehicle", + "Surface Vehicle—Service Vehicle", + "Point Obstacle (Includes Tethered Balloons)", + "Cluster Obstacle", + "Line Obstacle", + "Reserved", + "Reserved" + }, + { + "No Emitter Category", + "Reserved", + "Reserved", + "Reserved", + "Reserved", + "Reserved", + "Reserved", + "Reserved" + } +}; + +const char *getEmitterCategoryLabel(int emitter_category) { + int category_set = emitter_category >> 3; + int category_code = emitter_category & 7; + + if (category_set < 0 || category_set > 3) { + return "Unknown"; + } + + return emitter_category_labels[category_set][category_code]; +} + /* Flight status table. */ char *fs_str[8] = { /* 0 */ "Normal, Airborne", @@ -1223,7 +1302,7 @@ void decodeModesMessage(struct modesMessage *mm, unsigned char *msg) { if (mm->metype >= 1 && mm->metype <= 4) { /* Aircraft Identification and Category */ - mm->aircraft_type = mm->metype-1; + mm->emitter_category = ((4 - mm->metype) << 3) | (msg[4] & 7); mm->flight[0] = ais_charset[msg[5]>>2]; mm->flight[1] = ais_charset[((msg[5]&3)<<4)|(msg[6]>>4)]; mm->flight[2] = ais_charset[((msg[6]&15)<<2)|(msg[7]>>6)]; @@ -1383,14 +1462,8 @@ void displayModesMessage(struct modesMessage *mm) { /* Decode the extended squitter message. */ if (mm->metype >= 1 && mm->metype <= 4) { /* Aircraft identification. */ - char *ac_type_str[4] = { - "Aircraft Type D", - "Aircraft Type C", - "Aircraft Type B", - "Aircraft Type A" - }; - - printf(" Aircraft Type : %s\n", ac_type_str[mm->aircraft_type]); + printf(" Emitter Cat. : %s\n", + getEmitterCategoryLabel(mm->emitter_category)); printf(" Identification : %s\n", mm->flight); } else if (mm->metype >= 5 && mm->metype <= 8) { printf(" F flag : %s\n", mm->fflag ? "odd" : "even"); @@ -1574,7 +1647,7 @@ void detectModeS(uint16_t *m, uint32_t mlen) { * 1.0 - 1.5 usec: second impulse. * 3.5 - 4 usec: third impulse. * 4.5 - 5 usec: last impulse. - * + * * Since we are sampling at 2 Mhz every sample in our magnitude vector * is 0.5 usec, so the preamble will look like this, assuming there is * an impulse at offset 0 in the array: @@ -1695,13 +1768,13 @@ void detectModeS(uint16_t *m, uint32_t mlen) { /* Pack bits into bytes */ for (i = 0; i < MODES_LONG_MSG_BITS; i += 8) { msg[i/8] = - bits[i]<<7 | - bits[i+1]<<6 | - bits[i+2]<<5 | - bits[i+3]<<4 | - bits[i+4]<<3 | - bits[i+5]<<2 | - bits[i+6]<<1 | + bits[i]<<7 | + bits[i+1]<<6 | + bits[i+2]<<5 | + bits[i+3]<<4 | + bits[i+4]<<3 | + bits[i+5]<<2 | + bits[i+6]<<1 | bits[i+7]; } @@ -1829,6 +1902,7 @@ struct aircraft *interactiveCreateAircraft(uint32_t addr) { a->addr = addr; snprintf(a->hexaddr,sizeof(a->hexaddr),"%06x",(int)addr); a->flight[0] = '\0'; + a->emitter_category = 0; a->altitude = 0; a->speed = 0; a->track = 0; @@ -2106,6 +2180,7 @@ struct aircraft *interactiveReceiveData(struct modesMessage *mm) { } else if (mm->msgtype == 17 || mm->msgtype == 18) { if (mm->metype >= 1 && mm->metype <= 4) { memcpy(a->flight, mm->flight, sizeof(a->flight)); + a->emitter_category = mm->emitter_category; } else if (mm->metype >= 9 && mm->metype <= 18) { a->altitude = mm->altitude; if (mm->fflag) { @@ -2176,7 +2251,7 @@ void interactiveShowData(void) { printf("\x1b[H\x1b[2J"); /* Clear the screen */ printf( -"Hex Flight Altitude Speed Lat Lon Track Messages Seen %s\n" +"Hex Flight Altitude GndSpd Lat Lon Track Messages Seen %s\n" "--------------------------------------------------------------------------------\n", progress); @@ -2460,10 +2535,10 @@ int hexDigitVal(int c) { * raw hex format like: *8D4B969699155600E87406F5B69F; * The string is supposed to be at the start of the client buffer * and null-terminated. - * + * * The message is passed to the higher level layers, so it feeds * the selected screen output, the network output and so forth. - * + * * If the message looks invalid is silently discarded. * * The function always returns 0 (success) to the caller as there is @@ -2520,12 +2595,18 @@ char *aircraftsToJson(int *len) { } if (a->lat != 0 && a->lon != 0) { + /* Keep the coarse aircraft_type field for backward + * compatibility while also providing the full A0..D7 + * emitter_category. */ + int aircraft_type = a->emitter_category >> 3; + l = snprintf(p,buflen, "{\"hex\":\"%s\", \"flight\":\"%s\", \"lat\":%f, " "\"lon\":%f, \"altitude\":%d, \"track\":%d, " - "\"speed\":%d},\n", + "\"speed\":%d, \"aircraft_type\":%d, " + "\"emitter_category\":%d},\n", a->hexaddr, a->flight, a->lat, a->lon, altitude, a->track, - speed); + speed, aircraft_type, a->emitter_category); p += l; buflen -= l; /* Resize if needed. */ if (buflen < 256) { @@ -2550,8 +2631,11 @@ char *aircraftsToJson(int *len) { return buf; } -#define MODES_CONTENT_TYPE_HTML "text/html;charset=utf-8" -#define MODES_CONTENT_TYPE_JSON "application/json;charset=utf-8" +#define MODES_CONTENT_TYPE_HTML "text/html; charset=utf-8" +#define MODES_CONTENT_TYPE_JSON "application/json" +#define MODES_CONTENT_TYPE_CSS "text/css; charset=utf-8" +#define MODES_CONTENT_TYPE_JS "application/javascript" +#define MODES_CONTENT_TYPE_TEXT "text/plain; charset=utf-8" /* Get an HTTP request header and write the response to the client. * Again here we assume that the socket buffer is enough without doing @@ -2564,7 +2648,8 @@ int handleHTTPRequest(struct client *c) { int clen, hdrlen; int httpver, keepalive; char *p, *url, *content; - char *ctype; + const char *ctype; + const char *status = "200 OK"; if (Modes.debug & MODES_DEBUG_NET) printf("\nHTTP request: %s\n", c->buf); @@ -2592,18 +2677,54 @@ int handleHTTPRequest(struct client *c) { printf("HTTP requested URL: %s\n\n", url); } - /* Select the content to send, we have just two so far: - * "/" -> Our google map application. - * "/data.json" -> Our ajax request to update planes. */ + /* Select the content to send: + * "/" -> The map application. + * "/web/assets/..." -> Static UI assets. + * "/data.json" -> Aircraft data polled by the UI. */ if (strstr(url, "/data.json")) { content = aircraftsToJson(&clen); ctype = MODES_CONTENT_TYPE_JSON; } else { struct stat sbuf; int fd = -1; + const char *path = NULL; + + if (!strcmp(url, "/")) { + path = "web/index.html"; + ctype = MODES_CONTENT_TYPE_HTML; + } else if (!strcmp(url, "/web/assets/css/style.css")) { + path = "web/assets/css/style.css"; + ctype = MODES_CONTENT_TYPE_CSS; + } else if (!strcmp(url, "/web/assets/js/main.js")) { + path = "web/assets/js/main.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/aircraft.js")) { + path = "web/assets/js/aircraft.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/trail.js")) { + path = "web/assets/js/trail.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/panel.js")) { + path = "web/assets/js/panel.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/lib/storage.js")) { + path = "web/assets/js/lib/storage.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/lib/utils.js")) { + path = "web/assets/js/lib/utils.js"; + ctype = MODES_CONTENT_TYPE_JS; + } else if (!strcmp(url, "/web/assets/js/lib/icons.js")) { + path = "web/assets/js/lib/icons.js"; + ctype = MODES_CONTENT_TYPE_JS; + } - if (stat("gmap.html",&sbuf) != -1 && - (fd = open("gmap.html",O_RDONLY)) != -1) + if (path == NULL) { + content = strdup("Not Found"); + clen = strlen(content); + ctype = MODES_CONTENT_TYPE_TEXT; + status = "404 Not Found"; + } else if (stat(path,&sbuf) != -1 && + (fd = open(path,O_RDONLY)) != -1) { content = malloc(sbuf.st_size); if (read(fd,content,sbuf.st_size) == -1) { @@ -2612,25 +2733,24 @@ int handleHTTPRequest(struct client *c) { } clen = sbuf.st_size; } else { - char buf[128]; - - clen = snprintf(buf,sizeof(buf),"Error opening HTML file: %s", - strerror(errno)); - content = strdup(buf); + content = strdup("Internal Server Error"); + clen = strlen(content); + ctype = MODES_CONTENT_TYPE_TEXT; + status = "500 Internal Server Error"; } if (fd != -1) close(fd); - ctype = MODES_CONTENT_TYPE_HTML; } /* Create the header and send the reply. */ hdrlen = snprintf(hdr, sizeof(hdr), - "HTTP/1.1 200 OK\r\n" + "HTTP/1.1 %s\r\n" "Server: Dump1090\r\n" "Content-Type: %s\r\n" "Connection: %s\r\n" "Content-Length: %d\r\n" "Access-Control-Allow-Origin: *\r\n" "\r\n", + status, ctype, keepalive ? "keep-alive" : "close", clen); @@ -3008,5 +3128,3 @@ int main(int argc, char **argv) { rtlsdr_close(Modes.dev); return 0; } - - diff --git a/gmap.html b/gmap.html deleted file mode 100644 index ad6c30ac5..000000000 --- a/gmap.html +++ /dev/null @@ -1,389 +0,0 @@ - - - - - - - Dump1090 - - - - - - - -
- - -
-
-

Dump1090

-
-
-

-

Click on a plane for info

-
-
- - - - - - - \ No newline at end of file diff --git a/web/assets/css/style.css b/web/assets/css/style.css new file mode 100644 index 000000000..d097e414b --- /dev/null +++ b/web/assets/css/style.css @@ -0,0 +1,319 @@ +body { + margin: 0; + padding: 0; + height: 100dvh; +} + +.is-hidden { + display: none !important; +} + +.map { + width: 100%; + height: 100%; +} + +.leaflet-tile-pane { + filter: brightness(0.6); +} + +.leaflet-control-zoom { + border: none !important; + box-shadow: none !important; + display: flex; + flex-direction: column; + gap: 6px; +} + +.leaflet-control-zoom a, +.leaflet-control-zoom a:active { + user-select: none; + -webkit-user-select: none; +} + +.leaflet-control-zoom a { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08)), + rgba(22, 28, 38, 0.58) !important; + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + color: rgba(255, 255, 255, 0.85) !important; + border: 1px solid rgba(255, 255, 255, 0.24) !important; + border-radius: 10px !important; + width: 36px !important; + height: 36px !important; + line-height: 36px !important; + font-size: 18px !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.22), + 0 18px 40px rgba(0, 0, 0, 0.28); +} + +.leaflet-control-zoom a:hover { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0.14)), + rgba(22, 28, 38, 0.72) !important; + color: #ffffff !important; +} + +.leaflet-control-attribution { + background: rgba(22, 28, 38, 0.58) !important; + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + color: rgba(255, 255, 255, 0.5) !important; + border-radius: 6px; +} + +.leaflet-control-attribution a { + color: rgba(255, 255, 255, 0.7) !important; +} + +.aircraft-icon { + padding: 0; + margin: 0; + cursor: pointer; +} + +.aircraft-icon__symbol { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + cursor: pointer; + color: #ffd700; + filter: drop-shadow(0 0 1px #000000) drop-shadow(0 0 1px #000000); +} + +.aircraft-icon__symbol--selected { + color: #ff6b6b; +} + +.info-panel { + position: absolute; + top: 20px; + right: 20px; + width: 400px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08)), + rgba(22, 28, 38, 0.58); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 12px; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.22), + inset 0 -1px 0 rgba(255, 255, 255, 0.04), + 0 18px 40px rgba(0, 0, 0, 0.28); + font-family: system-ui, sans-serif; + z-index: 1000; + touch-action: none; + user-select: none; + color: #ffffff; + overflow: hidden; +} + +.info-panel::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.2), transparent 42%); + pointer-events: none; +} + +.info-panel__header { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.14); + cursor: move; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.info-panel__heading { + min-width: 0; +} + +.info-panel__title { + margin: 0; + font-size: 16px; + font-weight: 650; + color: #ffffff; + letter-spacing: -0.01em; +} + +.info-panel__clear-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: none; + color: rgba(255, 255, 255, 0.72); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: color 120ms ease; + flex-shrink: 0; +} + +.info-panel__clear-button:hover { + color: #ffffff; +} + +.info-panel__content { + padding: 12px 16px; + margin: 0; +} + +.info-panel__text { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + margin: 0 0 8px 0; + line-height: 1.5; +} + +.info-panel__text:last-child { + margin-bottom: 0; +} + +.info-panel__placeholder { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + margin: 0; +} + +.info-panel__details { + display: none; +} + +.has-selection > .info-panel__placeholder { + display: none; +} + +.has-selection > .info-panel__details { + display: block; +} + +.info-panel__flight { + font-size: 20px; + font-weight: 700; + color: #ffffff; + margin: 0; +} + +.info-panel__headline { + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 16px; +} + +.info-panel__flight-group, +.info-panel__secondary-row, +.info-panel__label-row { + display: flex; + align-items: center; + gap: 6px; +} + +.info-panel__flight-group { + min-width: 0; +} + +.info-panel__subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.info-panel__secondary-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); +} + +.info-panel__secondary-value { + font-size: 11px; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); +} + + +.info-panel__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.info-panel__item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.info-panel__item--wide { + grid-column: span 2; +} + +.info-panel__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); +} + +.info-panel__value { + font-size: 15px; + font-weight: 500; + color: #ffffff; +} + + +.info-panel__copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: none; + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + opacity: 0; + transform: translateY(1px); + transition: opacity 120ms ease, color 120ms ease; +} + +.info-panel__flight-group:hover .info-panel__copy-button, +.info-panel__flight-group:focus-within .info-panel__copy-button, +.info-panel__secondary-row:hover .info-panel__copy-button, +.info-panel__secondary-row:focus-within .info-panel__copy-button, +.info-panel__label-row:hover .info-panel__copy-button, +.info-panel__label-row:focus-within .info-panel__copy-button { + opacity: 1; +} + +.info-panel__copy-button:hover { + color: rgba(255, 255, 255, 0.9); +} + +.info-panel__copy-button:focus-visible { + opacity: 1; + color: #ffffff; + outline: 2px solid rgba(255, 255, 255, 0.35); + outline-offset: 2px; + border-radius: 4px; +} + +.info-panel__copy-button--copied, +.info-panel__copy-button--copied:hover, +.info-panel__copy-button--copied:focus-visible { + opacity: 1; + color: #7dffb3; +} diff --git a/web/assets/js/aircraft.js b/web/assets/js/aircraft.js new file mode 100644 index 000000000..cdd3c6de0 --- /dev/null +++ b/web/assets/js/aircraft.js @@ -0,0 +1,134 @@ +import { + jet, jetHeavy, jetPrivate, propSingle, propTwin, glider, + balloon, helicopter, drone, groundVehicle, obstacle +} from './lib/icons.js'; + +// Marker icons keyed by ADS-B emitter category. Unlisted categories fall back to jet. +const markerIcons = { + 9: groundVehicle, // Surface Vehicle — Emergency + 10: groundVehicle, // Surface Vehicle — Service + 11: obstacle, // Point Obstacle + 12: obstacle, // Cluster Obstacle + 13: obstacle, // Line Obstacle + 17: glider, // Glider or Sailplane + 18: balloon, // Lighter Than Air + 19: obstacle, // Parachute / Sky Diver — no good top-down silhouette + 20: propSingle, // Ultralight Vehicle + 21: drone, // UAV + 25: propSingle, // Light Airplane + 26: propTwin, // Small Airplane + 27: jet, // Large Airplane + 28: jetHeavy, // High Vortex Aircraft + 29: jetHeavy, // Heavy Airplane + 30: jetPrivate, // High Performance Aircraft + 31: helicopter, // Rotorcraft +}; + +function getMarkerIcon(emitterCategory) { + return markerIcons[emitterCategory] ?? jet; +} + +// Categories whose icons are not top-down silhouettes and should not rotate with track. +const nonRotatingCategories = new Set([18, 19, 11, 12, 13]); + +function shouldRotate(emitterCategory) { + return !nonRotatingCategories.has(emitterCategory); +} + +/* ADS-B emitter category labels keyed by the flattened emitter_category value + * produced by the backend. Values 0..31 map directly to A0..A7, B0..B7, + * C0..C7, D0..D7 in order. + * + * Public reference: + * FAA AC 20-165B Appendix tables: + * https://www.faa.gov/documentLibrary/media/Advisory_Circular/AC_20-165B.pdf */ +const emitterCategoryLabels = [ + 'No Emitter Category', + 'Light Airplane', + 'Small Airplane', + 'Large Airplane', + 'High Vortex Aircraft', + 'Heavy Airplane', + 'High Performance Aircraft', + 'Rotorcraft', + 'No Emitter Category', + 'Glider or sailplane', + 'Lighter Than Air', + 'Parachute / Sky Diver', + 'Ultralight Vehicle', + 'UAV', + 'Space/Trans-atmospheric Vehicle', + 'Reserved', + 'No Emitter Category', + 'Surface Vehicle—Emergency Vehicle', + 'Surface Vehicle—Service Vehicle', + 'Point Obstacle (Includes Tethered Balloons)', + 'Cluster Obstacle', + 'Line Obstacle', + 'Reserved', + 'Reserved', + 'No Emitter Category', + 'Reserved', + 'Reserved', + 'Reserved', + 'Reserved', + 'Reserved', + 'Reserved', + 'Reserved' +]; + +/** + * Returns the display label for an emitter category code, or null if invalid. + * + * @param {number | null | undefined} emitterCategory + * @returns {string | null} + */ +export function getEmitterCategoryLabel(emitterCategory) { + if (!Number.isInteger(emitterCategory) || emitterCategory < 0 || + emitterCategory >= emitterCategoryLabels.length) { + return null; + } + return emitterCategoryLabels[emitterCategory]; +} + +/** + * Builds the marker icon for an aircraft based on its emitter category. + * + * @param {{hex: string, emitter_category?: number | null, track?: number | null}} aircraft + * @param {string | null} selectedAircraftHex + * @returns {L.DivIcon} + */ +export function getAircraftIcon(aircraft, selectedAircraftHex) { + const rotation = shouldRotate(aircraft.emitter_category) && aircraft.track || 0; + return L.divIcon({ + html: ` +
+ ${getMarkerIcon(aircraft.emitter_category)} +
+ `, + className: 'aircraft-icon', + iconSize: [32, 32], + iconAnchor: [16, 16] + }); +} + +/** + * Updates the rendered rotation for an aircraft marker without rebuilding its icon. + * + * @param {L.Marker} marker + * @param {number | null | undefined} emitterCategory + * @param {number | null | undefined} track + * @returns {void} + */ +export function updateAircraftMarkerRotation(marker, emitterCategory, track) { + const symbol = marker.getElement()?.querySelector('.aircraft-icon__symbol'); + if (!(symbol instanceof HTMLElement)) return; + + const rotation = shouldRotate(emitterCategory) && track || 0; + symbol.style.transform = `rotate(${rotation}deg)`; +} diff --git a/web/assets/js/lib/icons.js b/web/assets/js/lib/icons.js new file mode 100644 index 000000000..8c3e2d2b6 --- /dev/null +++ b/web/assets/js/lib/icons.js @@ -0,0 +1,122 @@ +// Copy / check icons for the info panel. +export const copyIcon = ` + `; + +export const checkIcon = ` + `; + +// Aircraft silhouette icons for map markers. All use a 64x64 viewBox, rendered at 32x32. + +// Generic single-aisle jet. +export const jet = ` + `; + +// Four-engine heavy jet. +export const jetHeavy = ` + `; + +// Private / business jet. +export const jetPrivate = ` + `; + +// Light single-engine prop. +export const propSingle = ` + `; + +// Small twin turboprop. +export const propTwin = ` + `; + +// Glider. +export const glider = ` + `; + +// Balloon. +export const balloon = ` + `; + +// Helicopter. +export const helicopter = ` + `; + + +// Quadcopter drone / UAV. +export const drone = ` + `; + +// Ground vehicle. +export const groundVehicle = ` + `; + +// Fixed obstacle / point. +export const obstacle = ` + `; + diff --git a/web/assets/js/lib/storage.js b/web/assets/js/lib/storage.js new file mode 100644 index 000000000..dcd804ce7 --- /dev/null +++ b/web/assets/js/lib/storage.js @@ -0,0 +1,88 @@ +// Default map configuration. +const defaultMapView = { lat: 37.0, lng: 13.0, zoom: 8 }; + +// Persisted browser state for the web UI. +const mapViewStorageKey = 'dump1090.mapView'; +const panelPositionStorageKey = 'dump1090.panelPosition'; + +/** + * Loads the persisted map view from local storage. + * + * @returns {{lat: number, lng: number, zoom: number}} + */ +export function loadMapView() { + try { + const storedView = localStorage.getItem(mapViewStorageKey); + if (!storedView) return defaultMapView; + + const parsedView = JSON.parse(storedView); + const lat = Number(parsedView?.lat); + const lng = Number(parsedView?.lng); + const zoom = Number(parsedView?.zoom); + const hasValidCoords = Number.isFinite(lat) && Number.isFinite(lng); + const hasValidZoom = Number.isFinite(zoom); + + if (!hasValidCoords || !hasValidZoom) return defaultMapView; + + return { lat, lng, zoom }; + } catch (error) { + console.warn('Error loading saved map view:', error); + return defaultMapView; + } +} + +/** + * Persists the current map center and zoom level. + * + * @param {L.Map} map + * @returns {void} + */ +export function saveMapView(map) { + try { + const center = map.getCenter(); + localStorage.setItem(mapViewStorageKey, JSON.stringify({ + lat: center.lat, + lng: center.lng, + zoom: map.getZoom() + })); + } catch (error) { + console.warn('Error saving map view:', error); + } +} + +/** + * Loads the persisted panel position from local storage. + * + * @returns {{x: number, y: number}} + */ +export function loadPanelPosition() { + try { + const stored = localStorage.getItem(panelPositionStorageKey); + if (!stored) return { x: 0, y: 0 }; + + const parsed = JSON.parse(stored); + const x = Number(parsed?.x); + const y = Number(parsed?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) return { x: 0, y: 0 }; + + return { x, y }; + } catch (error) { + console.warn('Error loading saved panel position:', error); + return { x: 0, y: 0 }; + } +} + +/** + * Persists the panel position. + * + * @param {number} x + * @param {number} y + * @returns {void} + */ +export function savePanelPosition(x, y) { + try { + localStorage.setItem(panelPositionStorageKey, JSON.stringify({ x, y })); + } catch (error) { + console.warn('Error saving panel position:', error); + } +} diff --git a/web/assets/js/lib/utils.js b/web/assets/js/lib/utils.js new file mode 100644 index 000000000..5775ff5d8 --- /dev/null +++ b/web/assets/js/lib/utils.js @@ -0,0 +1,13 @@ +/** + * Copies text to the clipboard + * + * @param {string} text + * @returns {Promise} + */ +export async function copyText(text) { + if (!navigator.clipboard) { + throw new Error('Clipboard API unavailable'); + } + + await navigator.clipboard.writeText(text); +} diff --git a/web/assets/js/main.js b/web/assets/js/main.js new file mode 100644 index 000000000..a0374c711 --- /dev/null +++ b/web/assets/js/main.js @@ -0,0 +1,226 @@ +import { loadMapView, saveMapView } from './lib/storage.js'; +import { getAircraftIcon, updateAircraftMarkerRotation } from './aircraft.js'; +import { + drawTrail, removeTrail, + distanceNM, angleDifference, + HEADING_CHANGE_DEG, MAX_DISTANCE_NM, MIN_DISTANCE_NM, ALT_CHANGE_FT +} from './trail.js'; +import { + initPanel, + updateAircraftInfo, + updateGeneralInfo, + showSelection, + hideSelection +} from './panel.js'; + +// Application configuration. +const dataRefreshIntervalMs = 100; + +// Runtime state for the current browser session. +let map = null; +let selectedAircraft = null; +const aircraftList = {}; +let aircraftCount = 0; + +/** + * Clears the current aircraft selection and resets the panel. + * + * @returns {void} + */ +function clearSelectedAircraft() { + const previousAircraft = aircraftList[selectedAircraft?.hex]; + if (previousAircraft) { + previousAircraft.marker.setIcon(getAircraftIcon(previousAircraft, null)); + } + selectedAircraft = null; + removeTrail(map); + hideSelection(); +} + +/** + * Selects an aircraft and refreshes its marker and panel state. + * + * @param {{hex: string, marker: L.Marker}} aircraft + * @returns {void} + */ +function selectAircraft(aircraft) { + if (!aircraft) return; + + const previousAircraft = aircraftList[selectedAircraft?.hex]; + selectedAircraft = aircraft; + showSelection(); + + if (previousAircraft) { + previousAircraft.marker.setIcon(getAircraftIcon(previousAircraft, selectedAircraft?.hex)); + } + aircraft.marker.setIcon(getAircraftIcon(aircraft, selectedAircraft?.hex)); + updateAircraftInfo(aircraft); + drawTrail(map, aircraft); +} + +/** + * Removes aircraft that are no longer present in the latest payload + * + * @param {Record} seenAircraftHexes + * @returns {void} + */ +function removeStaleAircraft(seenAircraftHexes) { + for (const aircraft of Object.values(aircraftList)) { + if (seenAircraftHexes[aircraft.hex]) continue; + + map.removeLayer(aircraft.marker); + delete aircraftList[aircraft.hex]; + aircraftCount--; + + if (selectedAircraft?.hex === aircraft.hex) { + clearSelectedAircraft(); + } + } +} + +/** + * Creates or updates a map marker for a single aircraft. + * + * @param {{ + * hex: string, + * flight?: string, + * altitude?: number | null, + * speed?: number | null, + * lat: number, + * lon: number, + * track?: number | null, + * marker?: L.Marker + * }} aircraft + * @returns {void} + */ +function upsertAircraft(aircraft) { + // Normalize the incoming flight identifier. + aircraft.flight = aircraft.flight?.trim() || ''; + + // Register aircraft we have not seen yet. + const existingAircraft = aircraftList[aircraft.hex]; + if (existingAircraft == null) { + aircraft.trail = [{ lat: aircraft.lat, lon: aircraft.lon, altitude: aircraft.altitude, track: aircraft.track ?? null, timestamp: Date.now() }]; + aircraft.marker = L.marker([aircraft.lat, aircraft.lon], { + icon: getAircraftIcon(aircraft, selectedAircraft?.hex), + keyboard: false + }).addTo(map); + aircraft.marker.on('click', (event) => { + L.DomEvent.stopPropagation(event); + selectAircraft(aircraftList[aircraft.hex]); + }); + aircraftList[aircraft.hex] = aircraft; + aircraftCount++; + return; + } + + // Move the existing marker to the latest reported position. + existingAircraft.marker.setLatLng([aircraft.lat, aircraft.lon]); + + // Rebuild the icon only when its structure changes. + if (existingAircraft.emitter_category !== aircraft.emitter_category) { + existingAircraft.marker.setIcon(getAircraftIcon(aircraft, selectedAircraft?.hex)); + } else { + updateAircraftMarkerRotation(existingAircraft.marker, aircraft.emitter_category, aircraft.track); + } + + // Adaptive trail sampling: more dots in turns, fewer on straight legs. + const lastTrailPoint = existingAircraft.trail[existingAircraft.trail.length - 1]; + if (aircraft.lat !== lastTrailPoint.lat || aircraft.lon !== lastTrailPoint.lon) { + const dist = distanceNM(lastTrailPoint.lat, lastTrailPoint.lon, aircraft.lat, aircraft.lon); + + if (dist >= MIN_DISTANCE_NM) { + const headingDelta = angleDifference(lastTrailPoint.track, aircraft.track); + const altDelta = Math.abs((aircraft.altitude ?? 0) - (lastTrailPoint.altitude ?? 0)); + const shouldRecord = + headingDelta >= HEADING_CHANGE_DEG || + dist >= MAX_DISTANCE_NM || + altDelta >= ALT_CHANGE_FT; + + if (shouldRecord) { + existingAircraft.trail.push({ + lat: aircraft.lat, + lon: aircraft.lon, + altitude: aircraft.altitude, + track: aircraft.track ?? null, + timestamp: Date.now() + }); + } + } + } + + // Update the cached aircraft state with the latest values from the feed. + existingAircraft.altitude = aircraft.altitude; + existingAircraft.speed = aircraft.speed; + existingAircraft.lat = aircraft.lat; + existingAircraft.lon = aircraft.lon; + existingAircraft.track = aircraft.track; + existingAircraft.flight = aircraft.flight; + existingAircraft.emitter_category = aircraft.emitter_category; + + // Keep the details panel and trail in sync when this aircraft is currently selected. + if (existingAircraft.hex === selectedAircraft?.hex) { + updateAircraftInfo(existingAircraft); + drawTrail(map, existingAircraft); + } +} + +/** + * Fetches the latest aircraft list and syncs markers and panel state. + * + * @returns {Promise} + */ +async function fetchData() { + try { + const response = await fetch('/data.json'); + if (!response.ok) return; + + const data = await response.json(); + const seenAircraftHexes = {}; + console.log(data); + for (const aircraft of data) { + if (aircraft.hex == null || aircraft.lat == null || aircraft.lon == null) continue; + + seenAircraftHexes[aircraft.hex] = true; + upsertAircraft(aircraft); + } + + removeStaleAircraft(seenAircraftHexes); + } catch (error) { + console.error('Error fetching data:', error); + } +} + +/** + * Fetches the latest aircraft data and updates the general info panel. + * + * @returns {void} + */ +function refreshData() { + fetchData(); + updateGeneralInfo(aircraftCount); +} + +// Initialize the info panel. +initPanel({ onClearSelection: clearSelectedAircraft }); + +// Initialize the map instance. +const initialView = loadMapView(); +map = L.map('canvas').setView([initialView.lat, initialView.lng], initialView.zoom); +L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' +}).addTo(map); + +// Clear selection on Escape. +addEventListener('keydown', (event) => { + if (event.key === 'Escape' && selectedAircraft) clearSelectedAircraft(); +}); + +// Setup map view persistence. +map.on('moveend', () => saveMapView(map)); +addEventListener('beforeunload', () => saveMapView(map)); + +// Run the refresh loop. +refreshData(); +setInterval(refreshData, dataRefreshIntervalMs); diff --git a/web/assets/js/panel.js b/web/assets/js/panel.js new file mode 100644 index 000000000..a28660963 --- /dev/null +++ b/web/assets/js/panel.js @@ -0,0 +1,407 @@ +import { copyText } from './lib/utils.js'; +import { copyIcon, checkIcon } from './lib/icons.js'; +import { getEmitterCategoryLabel } from './aircraft.js'; +import { loadPanelPosition, savePanelPosition } from './lib/storage.js'; + +const defaultAircraftInfoText = 'Select an aircraft to view details'; +const copyButtonFadeDurationMs = 120; + +// Cached DOM references. +const aircraftInfo = document.getElementById('aircraftInfo'); +const generalInfo = document.getElementById('generalInfo'); +const clearSelectionButton = document.getElementById('clearSelectionButton'); +const infoPanel = document.getElementById('info-panel'); + +// Cached field references, keyed by field name. +// Each entry holds { value: HTMLElement, copyButton: HTMLButtonElement | null }. +let fields = {}; + +/** + * Updates a copy button value and availability. + * + * @param {HTMLButtonElement | null} button + * @param {string} displayValue + * @returns {void} + */ +function updateCopyButton(button, displayValue) { + if (!button) return; + + const hasValue = Boolean(displayValue && displayValue !== '—'); + button.dataset.copyValue = hasValue ? displayValue : ''; + button.hidden = !hasValue; +} + +/** + * Sets a field's displayed text and syncs its copy button. + * + * @param {string} name + * @param {string} displayValue + * @returns {void} + */ +function setField(name, displayValue) { + const field = fields[name]; + if (!field) return; + + field.value.textContent = displayValue; + updateCopyButton(field.copyButton, displayValue); +} + +/** + * Shows temporary visual feedback on a copied button. + * + * @param {HTMLButtonElement} button + * @returns {void} + */ +function showCopiedFeedback(button) { + const previousTimer = button._resetTimer; + if (previousTimer !== undefined) { + clearTimeout(previousTimer); + } + + button.classList.add('info-panel__copy-button--copied'); + button.innerHTML = checkIcon; + + button._resetTimer = setTimeout(() => { + button.classList.remove('info-panel__copy-button--copied'); + if (button.closest('.is-hovered') || button.classList.contains('is-focused')) { + button.innerHTML = copyIcon; + } else { + setTimeout(() => { + button.innerHTML = copyIcon; + }, copyButtonFadeDurationMs); + } + button._resetTimer = undefined; + }, 1250); +} + +/** + * Updates the aircraft count shown in the info panel. + * + * @param {number} aircraftCount + * @returns {void} + */ +export function updateGeneralInfo(aircraftCount) { + generalInfo.innerHTML = ` + ${aircraftCount} + aircraft tracked + `; +} + +/** + * Renders the selected aircraft details in the info panel. + * + * @param {{ + * flight?: string, + * hex: string, + * emitter_category?: number | null, + * altitude?: number | null, + * speed?: number | null, + * track?: number | null, + * lat: number, + * lon: number + * }} aircraft + * @returns {void} + */ +export function updateAircraftInfo(aircraft) { + if (!aircraft) return; + + setField('flight', aircraft.flight || '—'); + setField('icao', aircraft.hex); + setField('category', getEmitterCategoryLabel(aircraft.emitter_category) ?? '—'); + setField('altitude', aircraft.altitude != null ? `${aircraft.altitude} ft` : '—'); + setField('speed', aircraft.speed != null ? `${aircraft.speed} kts` : '—'); + setField('track', aircraft.track != null ? `${aircraft.track}°` : '—'); + setField('coords', `${aircraft.lat.toFixed(4)}, ${aircraft.lon.toFixed(4)}`); +} + +/** + * Switches the panel to the selected-aircraft view. + * + * @returns {void} + */ +export function showSelection() { + aircraftInfo.classList.add('has-selection'); + clearSelectionButton.classList.remove('is-hidden'); +} + +/** + * Switches the panel back to the default placeholder view. + * + * @returns {void} + */ +export function hideSelection() { + aircraftInfo.classList.remove('has-selection'); + clearSelectionButton.classList.add('is-hidden'); +} + +/** + * Initializes the info panel: dragging, copy-button interactions, and defaults. + * + * @param {{onClearSelection: () => void}} callbacks + * @returns {void} + */ +export function initPanel({ onClearSelection }) { + // Build the aircraft details template. + aircraftInfo.innerHTML = ` +

${defaultAircraftInfoText}

+
+
+
+
+ +
+
+ Emitter Category: + +
+
+ ICAO 24-bit Address: + + +
+
+
+
+
+ Altitude + +
+ +
+
+
+ Ground Speed + +
+ +
+
+
+ Track + +
+ +
+
+
+ Coordinates + +
+ +
+
+
+ `; + + // Pair each data-field element with its matching data-copy-for button. + for (const el of aircraftInfo.querySelectorAll('[data-field]')) { + const name = el.dataset.field; + fields[name] = { + value: el, + copyButton: aircraftInfo.querySelector(`[data-copy-for="${name}"]`) + }; + } + + // Draggable info panel via pointer events. + // The header acts as the drag handle. Pointer capture ensures moves + // are tracked even when the cursor leaves the header bounds. + // Panel position is tracked via panelX / panelY closure variables + // and applied as a CSS transform so the layout position is preserved. + const header = infoPanel.querySelector('.info-panel__header'); + let dragPointerId = null; + let dragStartX = 0; + let dragStartY = 0; + let panelStartX = 0; + let panelStartY = 0; + let panelX = 0; + let panelY = 0; + + // Cached during drag to avoid repeated getBoundingClientRect() calls. + let clampMinX = 0; + let clampMaxX = 0; + let clampMinY = 0; + let clampMaxY = 0; + + /** + * Computes clamping bounds from the current viewport and panel size. + * + * @returns {void} + */ + function updateClampBounds() { + const rect = infoPanel.getBoundingClientRect(); + const baseLeft = rect.left - panelX; + const baseTop = rect.top - panelY; + clampMinX = -baseLeft; + clampMaxX = window.innerWidth - baseLeft - rect.width; + clampMinY = -baseTop; + clampMaxY = window.innerHeight - baseTop - rect.height; + } + + /** + * Clamps an x/y offset so the panel stays within the viewport. + * + * @param {number} x + * @param {number} y + * @returns {{x: number, y: number}} + */ + function clampPosition(x, y) { + return { + x: Math.max(clampMinX, Math.min(clampMaxX, x)), + y: Math.max(clampMinY, Math.min(clampMaxY, y)) + }; + } + + /** + * Applies a translate offset to the panel. + * + * @param {number} x + * @param {number} y + * @returns {void} + */ + function applyPosition(x, y) { + const clamped = clampPosition(x, y); + panelX = clamped.x; + panelY = clamped.y; + infoPanel.style.transform = `translate(${panelX}px, ${panelY}px)`; + } + + /** + * Ends the current drag, persists position, and restores body styles. + * + * @returns {void} + */ + function endDrag() { + dragPointerId = null; + header.style.cursor = ''; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + savePanelPosition(panelX, panelY); + } + + // Restore persisted panel position. + const savedPosition = loadPanelPosition(); + updateClampBounds(); + applyPosition(savedPosition.x, savedPosition.y); + + // Record the pointer and the panel's current offset when a drag begins. + header.addEventListener('pointerdown', (event) => { + if (dragPointerId != null) return; + if (event.target.closest('button')) return; + dragPointerId = event.pointerId; + header.setPointerCapture(event.pointerId); + dragStartX = event.clientX; + dragStartY = event.clientY; + panelStartX = panelX; + panelStartY = panelY; + updateClampBounds(); + header.style.cursor = 'grabbing'; + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + }); + + // Move the panel by the delta between the current and starting pointer position. + header.addEventListener('pointermove', (event) => { + if (event.pointerId !== dragPointerId) return; + const x = panelStartX + (event.clientX - dragStartX); + const y = panelStartY + (event.clientY - dragStartY); + applyPosition(x, y); + }); + + // Release the drag on pointer up or cancel. + header.addEventListener('pointerup', (event) => { + if (event.pointerId !== dragPointerId) return; + endDrag(); + }); + + header.addEventListener('pointercancel', (event) => { + if (event.pointerId !== dragPointerId) return; + endDrag(); + }); + + // Nudge the panel back into view when the window is resized. + let resizeTimer = null; + addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + updateClampBounds(); + applyPosition(panelX, panelY); + savePanelPosition(panelX, panelY); + }, 100); + }); + + // Handle copy buttons on label hover + aircraftInfo.addEventListener('mouseover', (event) => { + const hoverRow = event.target + .closest('.info-panel__flight-group, .info-panel__secondary-row, .info-panel__label-row'); + if (!hoverRow) return; + hoverRow.classList.add('is-hovered'); + }); + + aircraftInfo.addEventListener('mouseout', (event) => { + const hoverRow = event.target + .closest('.info-panel__flight-group, .info-panel__secondary-row, .info-panel__label-row'); + if (!hoverRow) return; + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && hoverRow.contains(relatedTarget)) return; + hoverRow.classList.remove('is-hovered'); + }); + + // Handle copy buttons on focus (e.g. keyboard navigation) + aircraftInfo.addEventListener('focusin', (event) => { + const copyButton = event.target.closest('.info-panel__copy-button'); + if (!copyButton) return; + copyButton.classList.add('is-focused'); + }); + + aircraftInfo.addEventListener('focusout', (event) => { + const copyButton = event.target.closest('.info-panel__copy-button'); + if (!copyButton) return; + copyButton.classList.remove('is-focused'); + }); + + // Handle clear selection button + clearSelectionButton.addEventListener('click', (event) => { + event.preventDefault(); + onClearSelection(); + }); + + // Handle copy buttons click + aircraftInfo.addEventListener('click', async (event) => { + const copyButton = event.target.closest('[data-copy-value]'); + if (!copyButton) return; + + event.preventDefault(); + const triggeredByMouse = event.detail > 0; + + try { + await copyText(copyButton.dataset.copyValue); + showCopiedFeedback(copyButton); + if (triggeredByMouse) { + copyButton.blur(); + } + } catch (error) { + console.error('Error copying value:', error); + } + }); + + // Set defaults + hideSelection(); +} diff --git a/web/assets/js/trail.js b/web/assets/js/trail.js new file mode 100644 index 000000000..9236c2eff --- /dev/null +++ b/web/assets/js/trail.js @@ -0,0 +1,138 @@ +// Adaptive sampling constants (used by main.js via import). +export const HEADING_CHANGE_DEG = 2; +export const MAX_DISTANCE_NM = 1.5; +export const MIN_DISTANCE_NM = 0.03; +export const ALT_CHANGE_FT = 1000; + +// Color stops: altitude (ft) -> HSL. +const COLOR_STOPS = [ + { alt: 0, h: 0, s: 0, l: 95 }, // white + { alt: 1000, h: 60, s: 95, l: 55 }, // yellow + { alt: 2500, h: 120, s: 80, l: 40 }, // green + { alt: 5000, h: 180, s: 85, l: 45 }, // cyan + { alt: 10000, h: 210, s: 80, l: 60 }, // light blue + { alt: 20000, h: 220, s: 75, l: 45 }, // blue + { alt: 30000, h: 270, s: 60, l: 40 }, // purple + { alt: 45000, h: 280, s: 70, l: 25 }, // dark purple +]; + +/** + * Returns a color for a given altitude using piecewise linear interpolation + * across the COLOR_STOPS table. + * + * @param {number | null | undefined} altitude - altitude in feet + * @returns {string} + */ +export function altitudeColor(altitude) { + const alt = Math.max(0, Math.min(45000, altitude ?? 0)); + + // Find the surrounding stops. + let lo = COLOR_STOPS[0]; + let hi = COLOR_STOPS[COLOR_STOPS.length - 1]; + for (let i = 0; i < COLOR_STOPS.length - 1; i++) { + if (alt >= COLOR_STOPS[i].alt && alt <= COLOR_STOPS[i + 1].alt) { + lo = COLOR_STOPS[i]; + hi = COLOR_STOPS[i + 1]; + break; + } + } + + const t = hi.alt === lo.alt ? 0 : (alt - lo.alt) / (hi.alt - lo.alt); + const h = lo.h + t * (hi.h - lo.h); + const s = lo.s + t * (hi.s - lo.s); + const l = lo.l + t * (hi.l - lo.l); + return `hsl(${h}, ${s}%, ${l}%)`; +} + +/** + * Returns the approximate distance in nautical miles between two lat/lon points + * using the equirectangular approximation. + */ +export function distanceNM(lat1, lon1, lat2, lon2) { + const R_NM = 3440.065; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const cosLat = Math.cos((lat1 + lat2) / 2 * Math.PI / 180); + return R_NM * Math.sqrt(dLat * dLat + dLon * dLon * cosLat * cosLat); +} + +/** + * Returns the absolute angular difference in degrees between two headings, + * correctly handling the 360/0 wrap. Returns 360 if either value is null + * so that a point is always recorded when heading is unknown. + * + * @param {number | null | undefined} a + * @param {number | null | undefined} b + * @returns {number} + */ +export function angleDifference(a, b) { + if (a == null || b == null) return 360; + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} + +/** @type {L.LayerGroup | null} */ +let layer = null; + +/** + * Draws the trail for an aircraft on the given map. + * Every trail point gets a dot; lines connect them. + * + * @param {L.Map} map + * @param {{lat: number, lon: number, altitude: number | null, trail: Array<{lat: number, lon: number, altitude: number | null}>}} aircraft + * @returns {void} + */ +export function drawTrail(map, aircraft) { + removeTrail(map); + if (!aircraft?.trail || aircraft.trail.length < 1) return; + + layer = L.layerGroup().addTo(map); + + const trail = aircraft.trail; + + // Draw connecting line segments. + for (let i = 1; i < trail.length; i++) { + const color = altitudeColor(trail[i].altitude); + L.polyline( + [[trail[i - 1].lat, trail[i - 1].lon], [trail[i].lat, trail[i].lon]], + { color, weight: 2, opacity: 0.6, lineCap: 'butt', lineJoin: 'round' } + ).addTo(layer); + } + + // Connect the last trail point to the aircraft's current position. + const last = trail[trail.length - 1]; + if (aircraft.lat !== last.lat || aircraft.lon !== last.lon) { + const color = altitudeColor(aircraft.altitude); + L.polyline( + [[last.lat, last.lon], [aircraft.lat, aircraft.lon]], + { color, weight: 2, opacity: 0.6, lineCap: 'butt', lineJoin: 'round' } + ).addTo(layer); + } + + // Draw dots on top of lines at every trail point. + for (const point of trail) { + const color = altitudeColor(point.altitude); + const marker = L.circleMarker([point.lat, point.lon], { + radius: 2, + color, + weight: 0, + fillColor: color, + fillOpacity: 1 + }).addTo(layer); + marker.on('mouseover', () => marker.setStyle({ fillColor: '#ffffff', color: '#ffffff' })); + marker.on('mouseout', () => marker.setStyle({ fillColor: color, color })); + } +} + +/** + * Removes the current trail layer from the map. + * + * @param {L.Map} map + * @returns {void} + */ +export function removeTrail(map) { + if (layer) { + map.removeLayer(layer); + layer = null; + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..5edc8f505 --- /dev/null +++ b/web/index.html @@ -0,0 +1,37 @@ + + + + + + Dump1090 + + + + +
+ + + + + + +