feat: add flight tracking components and hooks
- Introduced FlightCard component for displaying flight information with animations. - Added ScrollArea component for custom scroll behavior. - Implemented StatusBar component to show flight count and loading status. - Created useFlights hook for fetching and managing flight data based on city selection. - Developed useSettings hook for managing user settings with local storage persistence. - Added useTrailHistory hook for managing flight trail data. - Defined City type and CITIES constant for city data management. - Implemented flight utility functions for altitude and speed conversions. - Created map styles for different visual representations. - Added OpenSky API integration for fetching flight data. - Implemented utility functions for class name merging. - Configured TypeScript settings for the project.
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
||||
# Environment Variables
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
|
||||
# ─── OpenSky Network API ──────────────────────────────────────────────────────
|
||||
#
|
||||
# OPTION 1 (Recommended): OAuth2 Client Credentials
|
||||
# For accounts created since mid-March 2025.
|
||||
# Go to https://opensky-network.org → Account → Create API Client
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# OPTION 2: Basic Auth (Legacy accounts only)
|
||||
# Deprecated — will be removed. Only works for accounts created before March 2025.
|
||||
# OPENSKY_USERNAME=
|
||||
# OPENSKY_PASSWORD=
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
.github
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
619
LICENSE
Normal file
619
LICENSE
Normal file
@ -0,0 +1,619 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source code
|
||||
of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This
|
||||
is a different license, not a version of the Affero GPL, but Affero
|
||||
has released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that transaction
|
||||
who receives a copy of the work also receives whatever licenses to the
|
||||
work the party's predecessor in interest had or could give under the
|
||||
previous paragraph, plus a right to possession of the Corresponding
|
||||
Source of the work from the predecessor in interest, if the predecessor
|
||||
has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a consequence
|
||||
of further modification of the contributor version. For purposes of
|
||||
this definition, "control" includes the right to grant patent
|
||||
sublicenses in a manner consistent with the requirements of this
|
||||
License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero
|
||||
General Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
84
README.md
Normal file
84
README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Aeris
|
||||
|
||||
Real-time 3D flight tracking — altitude-aware, visually stunning.
|
||||
|
||||
Aeris renders live air traffic over the world's busiest airspaces on a premium dark-mode map. Flights are separated by altitude in true 3D: low altitudes glow cyan, high altitudes shift to gold. Select a city, and the camera glides to that airspace with spring-eased animation.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, Turbopack) |
|
||||
| Language | TypeScript |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Map | MapLibre GL JS |
|
||||
| WebGL | Deck.gl 9 (IconLayer, PathLayer, MapboxOverlay) |
|
||||
| Animation | Motion (Framer Motion) |
|
||||
| Data | OpenSky Network API |
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# Optionally add OpenSky credentials — see .env.example
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── globals.css Tailwind config, theme vars
|
||||
│ ├── layout.tsx Root layout (Inter font)
|
||||
│ ├── page.tsx Entry — renders <FlightTracker />
|
||||
│ └── api/flights/route.ts OpenSky proxy with rate limiting + auth
|
||||
├── components/
|
||||
│ ├── flight-tracker.tsx Orchestrator — state, camera, layers, UI
|
||||
│ ├── map/
|
||||
│ │ ├── map.tsx MapLibre GL wrapper with React context
|
||||
│ │ └── flight-layers.tsx Deck.gl overlay — icons, trails, shadows, animation
|
||||
│ └── ui/
|
||||
│ ├── altitude-legend.tsx
|
||||
│ ├── control-panel.tsx Tabbed dialog — search, map style, settings
|
||||
│ ├── flight-card.tsx Hover card with flight details
|
||||
│ ├── scroll-area.tsx Custom scrollbar
|
||||
│ └── status-bar.tsx Live status indicator
|
||||
├── hooks/
|
||||
│ ├── use-flights.ts Polling hook for OpenSky API
|
||||
│ ├── use-settings.tsx Settings context with localStorage persistence
|
||||
│ └── use-trail-history.ts Trail accumulation + Catmull-Rom smoothing
|
||||
└── lib/
|
||||
├── cities.ts Curated aviation hub presets
|
||||
├── flight-utils.ts Altitude→color, unit conversions
|
||||
├── map-styles.ts Map style definitions
|
||||
├── opensky.ts OpenSky API client + types
|
||||
└── utils.ts cn() utility
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
- **Dark-first**: CARTO Dark Matter base map, theme-aware UI
|
||||
- **3D depth**: 55° pitch, altitude-based z-displacement via Deck.gl
|
||||
- **Smooth animation**: Catmull-Rom spline trails, per-frame interpolation between polls
|
||||
- **Glassmorphism**: `backdrop-blur-2xl`, `bg-black/60`, `border-white/[0.08]`
|
||||
- **Spring physics**: All UI transitions use spring easing
|
||||
- **Persistence**: Settings + map style in localStorage, `?city=IATA` URL deep links
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------------- | -------- | ------------------------------ |
|
||||
| `OPENSKY_CLIENT_ID` | No | OAuth2 client ID (recommended) |
|
||||
| `OPENSKY_CLIENT_SECRET` | No | OAuth2 client secret |
|
||||
| `OPENSKY_USERNAME` | No | Basic auth username (legacy) |
|
||||
| `OPENSKY_PASSWORD` | No | Basic auth password (legacy) |
|
||||
|
||||
Without credentials, anonymous access is used (~10 requests/minute).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
41
next.config.ts
Normal file
41
next.config.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@deck.gl/core",
|
||||
"@deck.gl/layers",
|
||||
"@deck.gl/geo-layers",
|
||||
"@deck.gl/mesh-layers",
|
||||
"@deck.gl/mapbox",
|
||||
"@deck.gl/react",
|
||||
"@loaders.gl/core",
|
||||
"@loaders.gl/gltf",
|
||||
"@luma.gl/core",
|
||||
"@luma.gl/webgl",
|
||||
],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ hostname: "a.basemaps.cartocdn.com" },
|
||||
{ hostname: "server.arcgisonline.com" },
|
||||
{ hostname: "tile.opentopomap.org" },
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/:path*",
|
||||
headers: [{ key: "Cache-Control", value: "no-store, max-age=0" }],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
45
package.json
Normal file
45
package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "aeris",
|
||||
"version": "0.1.0",
|
||||
"description": "Real-time 3D flight tracking — altitude-aware, visually stunning.",
|
||||
"author": "kewonit",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/geo-layers": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@deck.gl/mesh-layers": "^9.2.7",
|
||||
"@deck.gl/react": "^9.2.7",
|
||||
"@loaders.gl/core": "^4.3.4",
|
||||
"@loaders.gl/gltf": "^4.3.4",
|
||||
"@luma.gl/core": "^9.2.6",
|
||||
"@luma.gl/webgl": "^9.2.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"motion": "^12.34.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5318
pnpm-lock.yaml
generated
Normal file
5318
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
270
src/app/api/flights/route.ts
Normal file
270
src/app/api/flights/route.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const OPENSKY_BASE = "https://opensky-network.org/api";
|
||||
const OPENSKY_TOKEN_URL =
|
||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
||||
|
||||
// OAuth2 token cache
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiresAt = 0; // epoch ms
|
||||
|
||||
async function getAccessToken(): Promise<string | null> {
|
||||
const clientId = process.env.OPENSKY_CLIENT_ID;
|
||||
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
// Reuse token if still valid (with 60s margin)
|
||||
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENSKY_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`[aeris] OAuth2 token request failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
cachedToken = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = data.access_token;
|
||||
tokenExpiresAt = Date.now() + (data.expires_in ?? 1800) * 1000;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.info(
|
||||
`[aeris] OAuth2 token acquired, expires in ${data.expires_in}s`,
|
||||
);
|
||||
}
|
||||
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
console.error("[aeris] OAuth2 token error:", err);
|
||||
cachedToken = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type AuthMode = "oauth2" | "basic" | "anonymous";
|
||||
let authDisabled = false;
|
||||
let authLoggedOnce = false;
|
||||
|
||||
function detectAuthMode(): AuthMode {
|
||||
if (authDisabled) return "anonymous";
|
||||
if (process.env.OPENSKY_CLIENT_ID && process.env.OPENSKY_CLIENT_SECRET)
|
||||
return "oauth2";
|
||||
if (process.env.OPENSKY_USERNAME && process.env.OPENSKY_PASSWORD)
|
||||
return "basic";
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
async function buildAuthHeaders(): Promise<HeadersInit> {
|
||||
const mode = detectAuthMode();
|
||||
|
||||
if (mode === "oauth2") {
|
||||
const token = await getAccessToken();
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
return {}; // token fetch failed — fall through
|
||||
}
|
||||
|
||||
if (mode === "basic") {
|
||||
const user = process.env.OPENSKY_USERNAME!;
|
||||
const pass = process.env.OPENSKY_PASSWORD!;
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function logAuthStatus() {
|
||||
if (authLoggedOnce) return;
|
||||
authLoggedOnce = true;
|
||||
|
||||
const mode = detectAuthMode();
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isDev) {
|
||||
console.info("┌───────────────────────────────────────────────────┐");
|
||||
if (mode === "oauth2") {
|
||||
console.info("│ ✓ OpenSky: OAuth2 client credentials │");
|
||||
console.info(
|
||||
`│ Client: ${(process.env.OPENSKY_CLIENT_ID ?? "").slice(0, 37).padEnd(39)}│`,
|
||||
);
|
||||
} else if (mode === "basic") {
|
||||
console.info("│ ✓ OpenSky: Basic auth (legacy) │");
|
||||
console.info(
|
||||
`│ User: ${(process.env.OPENSKY_USERNAME ?? "").slice(0, 38).padEnd(40)}│`,
|
||||
);
|
||||
} else {
|
||||
console.info("│ ✗ OpenSky: Anonymous mode (rate-limited) │");
|
||||
console.info("│ Set OPENSKY_CLIENT_ID & OPENSKY_CLIENT_SECRET │");
|
||||
console.info("│ in .env.local for authenticated access │");
|
||||
}
|
||||
console.info("└───────────────────────────────────────────────────┘");
|
||||
} else {
|
||||
console.info(`[aeris] Proxy: ${mode} mode`);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-IP rate limiter
|
||||
const requestLog = new Map<string, number[]>();
|
||||
const MAX_REQUESTS_PER_MINUTE = 20;
|
||||
|
||||
function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const timestamps = requestLog.get(ip) ?? [];
|
||||
const recent = timestamps.filter((t) => now - t < windowMs);
|
||||
recent.push(now);
|
||||
requestLog.set(ip, recent);
|
||||
|
||||
// Clean up stale entries periodically
|
||||
if (requestLog.size > 500) {
|
||||
for (const [key, val] of requestLog) {
|
||||
if (val.every((t) => now - t > windowMs)) requestLog.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return recent.length > MAX_REQUESTS_PER_MINUTE;
|
||||
}
|
||||
|
||||
function clamp(val: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, val));
|
||||
}
|
||||
|
||||
async function fetchFromOpenSky(
|
||||
url: string,
|
||||
useAuth: boolean,
|
||||
): Promise<Response> {
|
||||
const headers = useAuth ? await buildAuthHeaders() : {};
|
||||
return fetch(url, { headers, cache: "no-store" });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (isRateLimited(ip)) {
|
||||
return NextResponse.json(
|
||||
{ time: 0, states: null, rateLimited: true },
|
||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const lamin = searchParams.get("lamin");
|
||||
const lamax = searchParams.get("lamax");
|
||||
const lomin = searchParams.get("lomin");
|
||||
const lomax = searchParams.get("lomax");
|
||||
|
||||
if (!lamin || !lamax || !lomin || !lomax) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required bbox parameters" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax };
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (Number.isNaN(val)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid parameter: ${key}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp to valid geographic ranges and limit bbox size
|
||||
const coords = {
|
||||
lamin: clamp(raw.lamin, -90, 90),
|
||||
lamax: clamp(raw.lamax, -90, 90),
|
||||
lomin: clamp(raw.lomin, -180, 180),
|
||||
lomax: clamp(raw.lomax, -180, 180),
|
||||
};
|
||||
|
||||
const latSpan = Math.abs(coords.lamax - coords.lamin);
|
||||
const lonSpan = Math.abs(coords.lomax - coords.lomin);
|
||||
if (latSpan > 20 || lonSpan > 20) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bounding box too large (max 20° per axis)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!authLoggedOnce) logAuthStatus();
|
||||
|
||||
const url = `${OPENSKY_BASE}/states/all?lamin=${coords.lamin}&lamax=${coords.lamax}&lomin=${coords.lomin}&lomax=${coords.lomax}`;
|
||||
const useAuth = detectAuthMode() !== "anonymous";
|
||||
|
||||
try {
|
||||
let res = await fetchFromOpenSky(url, useAuth);
|
||||
|
||||
// On 401, invalidate token/auth and retry anonymously
|
||||
if (res.status === 401 && useAuth) {
|
||||
cachedToken = null;
|
||||
tokenExpiresAt = 0;
|
||||
authDisabled = true;
|
||||
console.warn(
|
||||
"[aeris] Auth rejected (401). Falling back to anonymous. Check credentials in .env.local",
|
||||
);
|
||||
res = await fetchFromOpenSky(url, false);
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get("X-Rate-Limit-Retry-After-Seconds");
|
||||
return NextResponse.json(
|
||||
{
|
||||
time: 0,
|
||||
states: null,
|
||||
rateLimited: true,
|
||||
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
|
||||
},
|
||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[aeris] OpenSky error: ${res.status} ${res.statusText}`);
|
||||
return NextResponse.json(
|
||||
{ error: "Upstream data source error" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Log remaining credits in dev
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const remaining = res.headers.get("X-Rate-Limit-Remaining");
|
||||
if (remaining) {
|
||||
console.info(`[aeris] API credits remaining: ${remaining}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[aeris] OpenSky proxy error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch flight data" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
81
src/app/globals.css
Normal file
81
src/app/globals.css
Normal file
@ -0,0 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 100%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--border: 0 0% 14%;
|
||||
--ring: 0 0% 30%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--radius: var(--radius);
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "SF Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-logo {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-map-theme="dark"] {
|
||||
--ui-fg: 255 255 255;
|
||||
--ui-bg: 0 0 0;
|
||||
}
|
||||
|
||||
[data-map-theme="light"] {
|
||||
--ui-fg: 0 0 0;
|
||||
--ui-bg: 255 255 255;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
29
src/app/layout.tsx
Normal file
29
src/app/layout.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Aeris — Real-Time 3D Flight Tracking",
|
||||
description:
|
||||
"Altitude-aware, visually stunning flight tracking over the world's busiest airspaces.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
src/app/page.tsx
Normal file
5
src/app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { FlightTracker } from "@/components/flight-tracker";
|
||||
|
||||
export default function Home() {
|
||||
return <FlightTracker />;
|
||||
}
|
||||
41
src/components/error-boundary.tsx
Normal file
41
src/components/error-boundary.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
type Props = { children: ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("[aeris] Uncaught error:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-black text-white"
|
||||
>
|
||||
<p className="text-lg font-semibold">Something went wrong</p>
|
||||
<p className="max-w-md text-center text-sm text-white/50">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-white/20"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
299
src/components/flight-tracker.tsx
Normal file
299
src/components/flight-tracker.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { Map, useMap } from "@/components/map/map";
|
||||
import { FlightLayers } from "@/components/map/flight-layers";
|
||||
import { FlightCard } from "@/components/ui/flight-card";
|
||||
import { ControlPanel } from "@/components/ui/control-panel";
|
||||
import { AltitudeLegend } from "@/components/ui/altitude-legend";
|
||||
import { StatusBar } from "@/components/ui/status-bar";
|
||||
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
||||
import { useFlights } from "@/hooks/use-flights";
|
||||
import { useTrailHistory } from "@/hooks/use-trail-history";
|
||||
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { PickingInfo } from "@deck.gl/core";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_CITY_ID = "sfo";
|
||||
const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
||||
|
||||
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
function resolveInitialCity(): City {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("city")?.trim().toUpperCase();
|
||||
if (!code) return DEFAULT_CITY;
|
||||
return (
|
||||
CITIES.find(
|
||||
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
|
||||
) ?? DEFAULT_CITY
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_CITY;
|
||||
}
|
||||
}
|
||||
|
||||
function syncCityToUrl(city: City): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("city", city.iata);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadMapStyle(): MapStyle {
|
||||
try {
|
||||
const id = localStorage.getItem(STYLE_STORAGE_KEY);
|
||||
if (!id) return DEFAULT_STYLE;
|
||||
return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE;
|
||||
} catch {
|
||||
return DEFAULT_STYLE;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMapStyle(style: MapStyle): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STYLE_STORAGE_KEY, style.id);
|
||||
} catch {
|
||||
/* blocked */
|
||||
}
|
||||
}
|
||||
|
||||
function CameraController({ city }: { city: City }) {
|
||||
const { map, isLoaded } = useMap();
|
||||
const { settings } = useSettings();
|
||||
const prevCityRef = useRef<string | null>(null);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const orbitFrameRef = useRef<number | null>(null);
|
||||
const isInteractingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !city) return;
|
||||
if (city.id === prevCityRef.current) return;
|
||||
|
||||
prevCityRef.current = city.id;
|
||||
map.flyTo({
|
||||
center: city.coordinates,
|
||||
zoom: 11,
|
||||
pitch: 49,
|
||||
bearing: 27.4,
|
||||
duration: 2800,
|
||||
essential: true,
|
||||
});
|
||||
}, [map, isLoaded, city]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !city || !settings.autoOrbit) {
|
||||
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion =
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
const directionMultiplier =
|
||||
settings.orbitDirection === "clockwise" ? 1 : -1;
|
||||
const speed = settings.orbitSpeed * directionMultiplier;
|
||||
|
||||
function startOrbit() {
|
||||
if (!map || isInteractingRef.current) return;
|
||||
|
||||
function tick() {
|
||||
if (!map || isInteractingRef.current) return;
|
||||
const bearing = map.getBearing() + speed;
|
||||
map.setBearing(bearing % 360);
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopOrbit() {
|
||||
if (orbitFrameRef.current) {
|
||||
cancelAnimationFrame(orbitFrameRef.current);
|
||||
orbitFrameRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetIdleTimer() {
|
||||
isInteractingRef.current = true;
|
||||
stopOrbit();
|
||||
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
isInteractingRef.current = false;
|
||||
startOrbit();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
const events = ["mousedown", "wheel", "touchstart"] as const;
|
||||
const container = map.getContainer();
|
||||
events.forEach((e) =>
|
||||
container.addEventListener(e, resetIdleTimer, { passive: true }),
|
||||
);
|
||||
|
||||
map.on("movestart", () => {
|
||||
if (isInteractingRef.current) stopOrbit();
|
||||
});
|
||||
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
isInteractingRef.current = false;
|
||||
startOrbit();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
|
||||
return () => {
|
||||
stopOrbit();
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
|
||||
};
|
||||
}, [
|
||||
map,
|
||||
isLoaded,
|
||||
city,
|
||||
settings.autoOrbit,
|
||||
settings.orbitSpeed,
|
||||
settings.orbitDirection,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FlightTrackerInner() {
|
||||
const hydratedCity = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
resolveInitialCity,
|
||||
() => DEFAULT_CITY,
|
||||
);
|
||||
const hydratedStyle = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
loadMapStyle,
|
||||
() => DEFAULT_STYLE,
|
||||
);
|
||||
|
||||
const [cityOverride, setCityOverride] = useState<City | undefined>();
|
||||
const [styleOverride, setStyleOverride] = useState<MapStyle | undefined>();
|
||||
const activeCity = cityOverride ?? hydratedCity;
|
||||
const mapStyle = styleOverride ?? hydratedStyle;
|
||||
const { settings } = useSettings();
|
||||
|
||||
const setActiveCity = useCallback((city: City) => {
|
||||
setCityOverride(city);
|
||||
syncCityToUrl(city);
|
||||
}, []);
|
||||
|
||||
const setMapStyle = useCallback((style: MapStyle) => {
|
||||
setStyleOverride(style);
|
||||
saveMapStyle(style);
|
||||
}, []);
|
||||
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity);
|
||||
const trails = useTrailHistory(flights);
|
||||
const [hoveredFlight, setHoveredFlight] = useState<FlightState | null>(null);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleHover = useCallback((info: PickingInfo<FlightState> | null) => {
|
||||
if (info?.object) {
|
||||
setHoveredFlight(info.object);
|
||||
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
|
||||
} else {
|
||||
setHoveredFlight(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => {
|
||||
if (info?.object) {
|
||||
setHoveredFlight(info.object);
|
||||
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative h-screen w-screen overflow-hidden bg-black">
|
||||
<Map mapStyle={mapStyle.style}>
|
||||
<CameraController city={activeCity} />
|
||||
<FlightLayers
|
||||
flights={flights}
|
||||
trails={trails}
|
||||
onHover={handleHover}
|
||||
onClick={handleClick}
|
||||
showTrails={settings.showTrails}
|
||||
showShadows={settings.showShadows}
|
||||
showAltitudeColors={settings.showAltitudeColors}
|
||||
/>
|
||||
</Map>
|
||||
|
||||
<div
|
||||
data-map-theme={mapStyle.dark ? "dark" : "light"}
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
>
|
||||
<div className="pointer-events-auto absolute left-4 top-4 flex items-center gap-3">
|
||||
<Brand isDark={mapStyle.dark} />
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto absolute right-4 top-4 flex items-center gap-2">
|
||||
<ControlPanel
|
||||
activeCity={activeCity}
|
||||
onSelectCity={setActiveCity}
|
||||
activeStyle={mapStyle}
|
||||
onSelectStyle={setMapStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-4">
|
||||
<StatusBar
|
||||
flightCount={flights.length}
|
||||
cityName={activeCity.name}
|
||||
loading={loading}
|
||||
rateLimited={rateLimited}
|
||||
retryIn={retryIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 right-4">
|
||||
<AltitudeLegend />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlightCard flight={hoveredFlight} x={cursorPos.x} y={cursorPos.y} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlightTracker() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SettingsProvider>
|
||||
<FlightTrackerInner />
|
||||
</SettingsProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Brand({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold tracking-wide ${
|
||||
isDark ? "text-white/70" : "text-black/70"
|
||||
}`}
|
||||
>
|
||||
aeris
|
||||
</span>
|
||||
);
|
||||
}
|
||||
398
src/components/map/flight-layers.tsx
Normal file
398
src/components/map/flight-layers.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
import { IconLayer, PathLayer } from "@deck.gl/layers";
|
||||
import { useMap } from "./map";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import {
|
||||
SAMPLES_PER_SEGMENT,
|
||||
type TrailEntry,
|
||||
} from "@/hooks/use-trail-history";
|
||||
import type { PickingInfo } from "@deck.gl/core";
|
||||
|
||||
const ANIM_DURATION_MS = 15_000;
|
||||
const TELEPORT_THRESHOLD = 0.3; // degrees
|
||||
|
||||
type Snapshot = { lng: number; lat: number; alt: number; track: number };
|
||||
|
||||
function lerpAngle(a: number, b: number, t: number): number {
|
||||
const delta = ((b - a + 540) % 360) - 180;
|
||||
return a + delta * t;
|
||||
}
|
||||
|
||||
function easeOut(t: number): number {
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
}
|
||||
|
||||
function createAircraftAtlas(): HTMLCanvasElement {
|
||||
const size = 128;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(64, 12);
|
||||
ctx.lineTo(72, 48);
|
||||
ctx.lineTo(108, 72);
|
||||
ctx.lineTo(104, 78);
|
||||
ctx.lineTo(72, 66);
|
||||
ctx.lineTo(70, 96);
|
||||
ctx.lineTo(88, 108);
|
||||
ctx.lineTo(86, 114);
|
||||
ctx.lineTo(64, 104);
|
||||
ctx.lineTo(42, 114);
|
||||
ctx.lineTo(40, 108);
|
||||
ctx.lineTo(58, 96);
|
||||
ctx.lineTo(56, 66);
|
||||
ctx.lineTo(24, 78);
|
||||
ctx.lineTo(20, 72);
|
||||
ctx.lineTo(56, 48);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const AIRCRAFT_ICON_MAPPING = {
|
||||
aircraft: { x: 0, y: 0, width: 128, height: 128, mask: true },
|
||||
};
|
||||
|
||||
let _atlasCache: string | undefined;
|
||||
function getAircraftAtlasUrl(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
if (!_atlasCache) _atlasCache = createAircraftAtlas().toDataURL();
|
||||
return _atlasCache;
|
||||
}
|
||||
|
||||
type FlightLayerProps = {
|
||||
flights: FlightState[];
|
||||
trails: TrailEntry[];
|
||||
onHover: (info: PickingInfo<FlightState> | null) => void;
|
||||
onClick: (info: PickingInfo<FlightState> | null) => void;
|
||||
showTrails: boolean;
|
||||
showShadows: boolean;
|
||||
showAltitudeColors: boolean;
|
||||
};
|
||||
|
||||
export function FlightLayers({
|
||||
flights,
|
||||
trails,
|
||||
onHover,
|
||||
onClick,
|
||||
showTrails,
|
||||
showShadows,
|
||||
showAltitudeColors,
|
||||
}: FlightLayerProps) {
|
||||
const { map, isLoaded } = useMap();
|
||||
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||
const atlasUrl = getAircraftAtlasUrl();
|
||||
|
||||
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
||||
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
||||
const dataTimestampRef = useRef(0);
|
||||
const animFrameRef = useRef(0);
|
||||
|
||||
const flightsRef = useRef(flights);
|
||||
const trailsRef = useRef(trails);
|
||||
const showTrailsRef = useRef(showTrails);
|
||||
const showShadowsRef = useRef(showShadows);
|
||||
const showAltColorsRef = useRef(showAltitudeColors);
|
||||
|
||||
useEffect(() => {
|
||||
flightsRef.current = flights;
|
||||
trailsRef.current = trails;
|
||||
showTrailsRef.current = showTrails;
|
||||
showShadowsRef.current = showShadows;
|
||||
showAltColorsRef.current = showAltitudeColors;
|
||||
});
|
||||
|
||||
// Capture current animated position as new "prev" on each data update
|
||||
useEffect(() => {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const oldT = easeOut(Math.min(elapsed / ANIM_DURATION_MS, 1));
|
||||
|
||||
const newPrev = new Map<string, Snapshot>();
|
||||
for (const f of flights) {
|
||||
if (f.longitude == null || f.latitude == null) continue;
|
||||
const id = f.icao24;
|
||||
const oldPrev = prevSnapshotsRef.current.get(id);
|
||||
const oldCurr = currSnapshotsRef.current.get(id);
|
||||
|
||||
if (oldPrev && oldCurr) {
|
||||
const dx = oldCurr.lng - oldPrev.lng;
|
||||
const dy = oldCurr.lat - oldPrev.lat;
|
||||
if (dx * dx + dy * dy <= TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
||||
newPrev.set(id, {
|
||||
lng: oldPrev.lng + dx * oldT,
|
||||
lat: oldPrev.lat + dy * oldT,
|
||||
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldT,
|
||||
track: lerpAngle(oldPrev.track, oldCurr.track, oldT),
|
||||
});
|
||||
} else {
|
||||
newPrev.set(id, oldCurr);
|
||||
}
|
||||
} else if (oldCurr) {
|
||||
newPrev.set(id, oldCurr);
|
||||
}
|
||||
}
|
||||
prevSnapshotsRef.current = newPrev;
|
||||
|
||||
const next = new Map<string, Snapshot>();
|
||||
for (const f of flights) {
|
||||
if (f.longitude != null && f.latitude != null) {
|
||||
next.set(f.icao24, {
|
||||
lng: f.longitude,
|
||||
lat: f.latitude,
|
||||
alt: f.baroAltitude ?? 0,
|
||||
track: f.trueTrack ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
currSnapshotsRef.current = next;
|
||||
dataTimestampRef.current = performance.now();
|
||||
}, [flights]);
|
||||
|
||||
const handleHover = useCallback(
|
||||
(info: PickingInfo<FlightState>) => {
|
||||
onHover(info.object ? info : null);
|
||||
},
|
||||
[onHover],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(info: PickingInfo<FlightState>) => {
|
||||
if (info.object) onClick(info);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
if (!overlayRef.current) {
|
||||
overlayRef.current = new MapboxOverlay({
|
||||
interleaved: false,
|
||||
layers: [],
|
||||
});
|
||||
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (overlayRef.current) {
|
||||
try {
|
||||
map.removeControl(
|
||||
overlayRef.current as unknown as maplibregl.IControl,
|
||||
);
|
||||
} catch {
|
||||
/* unmounted */
|
||||
}
|
||||
overlayRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atlasUrl) return;
|
||||
|
||||
function buildAndPushLayers() {
|
||||
animFrameRef.current = requestAnimationFrame(buildAndPushLayers);
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay) return;
|
||||
|
||||
try {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const rawT = elapsed / ANIM_DURATION_MS;
|
||||
const t = easeOut(Math.min(rawT, 1));
|
||||
|
||||
const currentFlights = flightsRef.current;
|
||||
const currentTrails = trailsRef.current;
|
||||
const altColors = showAltColorsRef.current;
|
||||
const defaultColor: [number, number, number, number] = [
|
||||
180, 220, 255, 200,
|
||||
];
|
||||
|
||||
const interpolated: FlightState[] = currentFlights.map((f) => {
|
||||
if (f.longitude == null || f.latitude == null) return f;
|
||||
|
||||
const curr = currSnapshotsRef.current.get(f.icao24);
|
||||
if (!curr) return f;
|
||||
|
||||
// Synthesize a virtual "prev" for new flights so they slide in
|
||||
let prev = prevSnapshotsRef.current.get(f.icao24);
|
||||
if (!prev) {
|
||||
const rad = (curr.track * Math.PI) / 180;
|
||||
const spd = f.velocity ?? 200;
|
||||
const step = Math.min(
|
||||
(spd * (ANIM_DURATION_MS / 1000)) / 111_320,
|
||||
0.015,
|
||||
);
|
||||
prev = {
|
||||
lng: curr.lng - Math.sin(rad) * step,
|
||||
lat: curr.lat - Math.cos(rad) * step,
|
||||
alt: curr.alt,
|
||||
track: curr.track,
|
||||
};
|
||||
}
|
||||
|
||||
const dx = curr.lng - prev.lng;
|
||||
const dy = curr.lat - prev.lat;
|
||||
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
||||
return f; // teleport — skip interpolation
|
||||
}
|
||||
|
||||
if (rawT <= 1) {
|
||||
return {
|
||||
...f,
|
||||
longitude: prev.lng + dx * t,
|
||||
latitude: prev.lat + dy * t,
|
||||
baroAltitude: prev.alt + (curr.alt - prev.alt) * t,
|
||||
trueTrack: lerpAngle(prev.track, curr.track, t),
|
||||
};
|
||||
}
|
||||
|
||||
// Extrapolate when the next poll is delayed
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed = f.velocity ?? 200;
|
||||
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
return {
|
||||
...f,
|
||||
longitude: curr.lng + Math.sin(heading) * extraDeg,
|
||||
latitude: curr.lat + Math.cos(heading) * extraDeg,
|
||||
baroAltitude: curr.alt,
|
||||
trueTrack: curr.track,
|
||||
};
|
||||
});
|
||||
|
||||
const interpolatedMap = new Map<string, FlightState>();
|
||||
for (const f of interpolated) {
|
||||
interpolatedMap.set(f.icao24, f);
|
||||
}
|
||||
|
||||
const layers = [];
|
||||
|
||||
if (showShadowsRef.current) {
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-shadows",
|
||||
data: interpolated,
|
||||
getPosition: (d) => [d.longitude!, d.latitude!, 0],
|
||||
getIcon: () => "aircraft",
|
||||
getSize: 18,
|
||||
getColor: [0, 0, 0, 60],
|
||||
getAngle: (d) => 360 - (d.trueTrack ?? 0),
|
||||
iconAtlas: atlasUrl,
|
||||
iconMapping: AIRCRAFT_ICON_MAPPING,
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (showTrailsRef.current) {
|
||||
layers.push(
|
||||
new PathLayer<TrailEntry>({
|
||||
id: "flight-trails",
|
||||
data: currentTrails,
|
||||
updateTriggers: { getPath: elapsed },
|
||||
getPath: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
const alt = altitudeToElevation(
|
||||
animFlight?.baroAltitude ?? d.baroAltitude,
|
||||
);
|
||||
const basePath = d.path.map(
|
||||
(p) => [p[0], p[1], alt] as [number, number, number],
|
||||
);
|
||||
// Reveal spline points progressively to match the animated position
|
||||
if (
|
||||
animFlight &&
|
||||
animFlight.longitude != null &&
|
||||
animFlight.latitude != null &&
|
||||
basePath.length > 1
|
||||
) {
|
||||
const ax = animFlight.longitude;
|
||||
const ay = animFlight.latitude;
|
||||
const segLen = Math.min(
|
||||
SAMPLES_PER_SEGMENT,
|
||||
basePath.length - 1,
|
||||
);
|
||||
const reveal = Math.floor(t * segLen);
|
||||
const collapseFrom = basePath.length - segLen + reveal;
|
||||
|
||||
for (let i = collapseFrom; i < basePath.length; i++) {
|
||||
basePath[i] = [ax, ay, alt];
|
||||
}
|
||||
basePath[basePath.length - 1] = [ax, ay, alt];
|
||||
}
|
||||
return basePath;
|
||||
},
|
||||
getColor: (d) => {
|
||||
const len = d.path.length;
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: defaultColor;
|
||||
return Array.from({ length: len }, (_, i) => {
|
||||
const tVal = len > 1 ? i / (len - 1) : 1;
|
||||
return [
|
||||
base[0],
|
||||
base[1],
|
||||
base[2],
|
||||
Math.round(tVal * tVal * 100),
|
||||
];
|
||||
}) as [number, number, number, number][];
|
||||
},
|
||||
getWidth: 2,
|
||||
widthUnits: "pixels",
|
||||
widthMinPixels: 1,
|
||||
widthMaxPixels: 4,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-aircraft",
|
||||
data: interpolated,
|
||||
getPosition: (d) => [
|
||||
d.longitude!,
|
||||
d.latitude!,
|
||||
altitudeToElevation(d.baroAltitude),
|
||||
],
|
||||
getIcon: () => "aircraft",
|
||||
getSize: 22,
|
||||
getColor: (d) =>
|
||||
altColors ? altitudeToColor(d.baroAltitude) : defaultColor,
|
||||
getAngle: (d) => 360 - (d.trueTrack ?? 0),
|
||||
iconAtlas: atlasUrl,
|
||||
iconMapping: AIRCRAFT_ICON_MAPPING,
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
pickable: true,
|
||||
onHover: handleHover,
|
||||
onClick: handleClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
}),
|
||||
);
|
||||
|
||||
overlay.setProps({ layers });
|
||||
} catch (err) {
|
||||
console.error("[aeris] FlightLayers render error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
buildAndPushLayers();
|
||||
return () => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [atlasUrl, handleHover, handleClick]);
|
||||
|
||||
return null;
|
||||
}
|
||||
115
src/components/map/map.tsx
Normal file
115
src/components/map/map.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_STYLE, type MapStyleSpec } from "@/lib/map-styles";
|
||||
|
||||
type MapContextValue = {
|
||||
map: maplibregl.Map | null;
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
const MapContext = createContext<MapContextValue | null>(null);
|
||||
|
||||
export function useMap() {
|
||||
const context = useContext(MapContext);
|
||||
if (!context)
|
||||
throw new Error("useMap must be used within a <Map /> provider");
|
||||
return context;
|
||||
}
|
||||
|
||||
type MapProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
mapStyle?: MapStyleSpec;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
pitch?: number;
|
||||
bearing?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
};
|
||||
|
||||
export type MapRef = maplibregl.Map;
|
||||
|
||||
export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
mapStyle = DEFAULT_STYLE.style,
|
||||
center = [0, 20],
|
||||
zoom = 2.5,
|
||||
pitch = 49,
|
||||
bearing = -20,
|
||||
minZoom = 2,
|
||||
maxZoom = 16,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => mapInstance as maplibregl.Map, [mapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: DEFAULT_STYLE.style as maplibregl.StyleSpecification | string,
|
||||
center,
|
||||
zoom,
|
||||
pitch,
|
||||
bearing,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
maxPitch: 85,
|
||||
attributionControl: false,
|
||||
renderWorldCopies: false,
|
||||
});
|
||||
|
||||
map.on("load", () => setIsLoaded(true));
|
||||
setMapInstance(map);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
setIsLoaded(false);
|
||||
setMapInstance(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance || !isLoaded) return;
|
||||
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
|
||||
}, [mapInstance, isLoaded, mapStyle]);
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({ map: mapInstance, isLoaded }),
|
||||
[mapInstance, isLoaded],
|
||||
);
|
||||
|
||||
return (
|
||||
<MapContext.Provider value={ctx}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative h-full w-full", className)}
|
||||
>
|
||||
{mapInstance && children}
|
||||
</div>
|
||||
</MapContext.Provider>
|
||||
);
|
||||
});
|
||||
80
src/components/ui/altitude-legend.tsx
Normal file
80
src/components/ui/altitude-legend.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function AltitudeLegend() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 24, delay: 0.6 }}
|
||||
className="flex flex-col gap-2 rounded-xl border p-3 backdrop-blur-2xl"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
||||
}}
|
||||
role="img"
|
||||
aria-label="Altitude color scale from 0 feet (green) to 43,000 feet (blue)"
|
||||
>
|
||||
<p
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
>
|
||||
Altitude
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-32 w-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgb(72,210,160), rgb(160,195,80), rgb(235,150,60), rgb(240,110,80), rgb(220,85,130), rgb(180,90,190), rgb(120,110,220), rgb(100,170,240))",
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-32 flex-col justify-between">
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
43,000 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
20,000 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
10,000 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
5,000 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
2,000 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
500 ft
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
>
|
||||
0 ft
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
684
src/components/ui/control-panel.tsx
Normal file
684
src/components/ui/control-panel.tsx
Normal file
@ -0,0 +1,684 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
Search,
|
||||
Map as MapIcon,
|
||||
Settings,
|
||||
X,
|
||||
Check,
|
||||
MapPin,
|
||||
ChevronRight,
|
||||
RotateCw,
|
||||
Route,
|
||||
Layers,
|
||||
Palette,
|
||||
Gauge,
|
||||
ArrowLeftRight,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
|
||||
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
type TabId = "search" | "style" | "settings";
|
||||
|
||||
const TABS: { id: TabId; icon: typeof Search; label: string }[] = [
|
||||
{ id: "search", icon: Search, label: "Search" },
|
||||
{ id: "style", icon: MapIcon, label: "Map Style" },
|
||||
{ id: "settings", icon: Settings, label: "Settings" },
|
||||
];
|
||||
|
||||
type ControlPanelProps = {
|
||||
activeCity: City;
|
||||
onSelectCity: (city: City) => void;
|
||||
activeStyle: MapStyle;
|
||||
onSelectStyle: (style: MapStyle) => void;
|
||||
};
|
||||
|
||||
export function ControlPanel({
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
activeStyle,
|
||||
onSelectStyle,
|
||||
}: ControlPanelProps) {
|
||||
const [openTab, setOpenTab] = useState<TabId | null>(null);
|
||||
|
||||
const open = (tab: TabId) => setOpenTab(tab);
|
||||
const close = () => setOpenTab(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger buttons */}
|
||||
{TABS.map(({ id, icon: Icon, label }) => (
|
||||
<motion.button
|
||||
key={id}
|
||||
onClick={() => open(id)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
|
||||
color: "rgb(var(--ui-fg) / 0.5)",
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
{/* Dialog */}
|
||||
<AnimatePresence>
|
||||
{openTab && (
|
||||
<PanelDialog
|
||||
activeTab={openTab}
|
||||
onTabChange={setOpenTab}
|
||||
onClose={close}
|
||||
activeCity={activeCity}
|
||||
onSelectCity={(c) => {
|
||||
onSelectCity(c);
|
||||
close();
|
||||
}}
|
||||
activeStyle={activeStyle}
|
||||
onSelectStyle={onSelectStyle}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelDialog({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
activeStyle,
|
||||
onSelectStyle,
|
||||
}: {
|
||||
activeTab: TabId;
|
||||
onTabChange: (tab: TabId) => void;
|
||||
onClose: () => void;
|
||||
activeCity: City;
|
||||
onSelectCity: (city: City) => void;
|
||||
activeStyle: MapStyle;
|
||||
onSelectStyle: (style: MapStyle) => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
first.focus();
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const elements = dialog!.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const f = elements[0];
|
||||
const l = elements[elements.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === f) {
|
||||
e.preventDefault();
|
||||
l.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === l) {
|
||||
e.preventDefault();
|
||||
f.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.addEventListener("keydown", trapFocus);
|
||||
return () => dialog.removeEventListener("keydown", trapFocus);
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-80 bg-black/60 backdrop-blur-xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
initial={{ opacity: 0, scale: 0.94, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.94, y: 16 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
}}
|
||||
className="fixed left-1/2 top-1/2 z-90 w-full max-w-180 -translate-x-1/2 -translate-y-1/2 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="panel-dialog-title"
|
||||
>
|
||||
<div className="flex overflow-hidden rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8]">
|
||||
{/* Sidebar */}
|
||||
<div className="flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
|
||||
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
|
||||
Controls
|
||||
</p>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{TABS.map(({ id, icon: Icon, label }) => {
|
||||
const active = id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onTabChange(id)}
|
||||
className={`group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white/90"
|
||||
: "text-white/35 hover:text-white/55 hover:bg-white/4"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="panel-tab-bg"
|
||||
className="absolute inset-0 rounded-xl bg-white/8"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative h-4 w-4 shrink-0" />
|
||||
<span className="relative text-[14px] font-medium">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-4 px-1 flex flex-col gap-3">
|
||||
<a
|
||||
href="https://github.com/kewonit/aeris"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub (opens in new tab)"
|
||||
className="group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors text-white/35 hover:text-white/55 hover:bg-white/4"
|
||||
>
|
||||
<Github
|
||||
className="relative h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="relative text-[14px] font-medium">GitHub</span>
|
||||
</a>
|
||||
<div className="border-t border-white/3 pt-2 px-2.5">
|
||||
<p className="text-[10px] font-medium text-white/10 tracking-wide">
|
||||
v0.1 · OpenSky Network
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col h-120">
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h2
|
||||
id="panel-dialog-title"
|
||||
className="text-[15px] font-semibold tracking-tight text-white/90"
|
||||
>
|
||||
{TABS.find((t) => t.id === activeTab)?.label}
|
||||
</h2>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-white/40" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{activeTab === "search" && (
|
||||
<TabContent key="search">
|
||||
<SearchContent
|
||||
activeCity={activeCity}
|
||||
onSelect={onSelectCity}
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
{activeTab === "style" && (
|
||||
<TabContent key="style">
|
||||
<StyleContent
|
||||
activeStyle={activeStyle}
|
||||
onSelect={onSelectStyle}
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<TabContent key="settings">
|
||||
<SettingsContent />
|
||||
</TabContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchContent({
|
||||
activeCity,
|
||||
onSelect,
|
||||
}: {
|
||||
activeCity: City;
|
||||
onSelect: (city: City) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return CITIES.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.iata.toLowerCase().includes(q) ||
|
||||
c.country.toLowerCase().includes(q),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2.5 border-b border-white/6 mx-5 pb-3">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search airspace..."
|
||||
aria-label="Search cities by name, IATA code, or country"
|
||||
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 focus:outline-none focus-visible:ring-1 focus-visible:ring-white/40 focus-visible:rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-[12px] text-white/25">
|
||||
No cities found
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((city) => (
|
||||
<button
|
||||
key={city.id}
|
||||
onClick={() => onSelect(city)}
|
||||
aria-current={activeCity?.id === city.id ? "true" : undefined}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-white/4 ${
|
||||
activeCity?.id === city.id ? "bg-white/6" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/4">
|
||||
<MapPin className="h-3.5 w-3.5 text-white/40" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-[14px] font-medium text-white/80">
|
||||
{city.name}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-white/25">
|
||||
{city.iata} · {city.country}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyleContent({
|
||||
activeStyle,
|
||||
onSelect,
|
||||
}: {
|
||||
activeStyle: MapStyle;
|
||||
onSelect: (style: MapStyle) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="grid grid-cols-2 gap-3 p-5 pt-2">
|
||||
{MAP_STYLES.map((style, i) => (
|
||||
<StyleTile
|
||||
key={style.id}
|
||||
style={style}
|
||||
isActive={style.id === activeStyle.id}
|
||||
index={i}
|
||||
onSelect={() => onSelect(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-white/4 px-5 py-3">
|
||||
<p className="text-[11px] font-medium text-white/12">
|
||||
Satellite © Esri · Terrain © OpenTopoMap · Base maps ©
|
||||
CARTO
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function StyleTile({
|
||||
style,
|
||||
isActive,
|
||||
index,
|
||||
onSelect,
|
||||
}: {
|
||||
style: MapStyle;
|
||||
isActive: boolean;
|
||||
index: number;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.04 * index, duration: 0.25, ease: "easeOut" }}
|
||||
onClick={onSelect}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`${style.name} map style`}
|
||||
className="group relative flex flex-col gap-2 text-left"
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-16/10 w-full overflow-hidden rounded-xl transition-all duration-200 ${
|
||||
isActive
|
||||
? "ring-2 ring-white/50 ring-offset-2 ring-offset-black/80 shadow-[0_0_20px_rgba(255,255,255,0.06)]"
|
||||
: "ring-1 ring-white/8 group-hover:ring-white/18"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: style.preview }}
|
||||
/>
|
||||
<Image
|
||||
src={style.previewUrl}
|
||||
alt={`${style.name} preview`}
|
||||
fill
|
||||
unoptimized
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
className={`object-cover transition-all duration-500 group-hover:scale-105 ${
|
||||
imgLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-xl shadow-[inset_0_1px_0_rgba(255,255,255,0.06),inset_0_-16px_28px_-10px_rgba(0,0,0,0.4)]" />
|
||||
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 28,
|
||||
}}
|
||||
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-md shadow-black/30"
|
||||
>
|
||||
<Check className="h-3 w-3 text-black" strokeWidth={3} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-0.5">
|
||||
<span
|
||||
className={`text-[12px] font-semibold tracking-tight transition-colors ${
|
||||
isActive
|
||||
? "text-white/90"
|
||||
: "text-white/40 group-hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
{style.name}
|
||||
</span>
|
||||
{style.dark && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-white/20" />
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
const ORBIT_SPEEDS = [
|
||||
{ label: "Slow", value: 0.06 },
|
||||
{ label: "Normal", value: 0.15 },
|
||||
{ label: "Fast", value: 0.35 },
|
||||
];
|
||||
|
||||
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
||||
{ label: "Clockwise", value: "clockwise" },
|
||||
{ label: "Counter", value: "counter-clockwise" },
|
||||
];
|
||||
|
||||
function SettingsContent() {
|
||||
const { settings, update } = useSettings();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-0.5 p-3 pt-1">
|
||||
<SettingRow
|
||||
icon={<RotateCw className="h-4 w-4" />}
|
||||
title="Auto-orbit"
|
||||
description="Camera slowly rotates around the airport"
|
||||
checked={settings.autoOrbit}
|
||||
onChange={(v) => update("autoOrbit", v)}
|
||||
/>
|
||||
|
||||
{settings.autoOrbit && (
|
||||
<>
|
||||
<SegmentRow
|
||||
icon={<Gauge className="h-4 w-4" />}
|
||||
title="Orbit speed"
|
||||
options={ORBIT_SPEEDS}
|
||||
value={settings.orbitSpeed}
|
||||
onChange={(v) => update("orbitSpeed", v)}
|
||||
/>
|
||||
<SegmentRow
|
||||
icon={<ArrowLeftRight className="h-4 w-4" />}
|
||||
title="Direction"
|
||||
options={ORBIT_DIRECTIONS}
|
||||
value={settings.orbitDirection}
|
||||
onChange={(v) => update("orbitDirection", v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-3 my-2 h-px bg-white/4" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Route className="h-4 w-4" />}
|
||||
title="Flight trails"
|
||||
description="Altitude-colored trails behind aircraft"
|
||||
checked={settings.showTrails}
|
||||
onChange={(v) => update("showTrails", v)}
|
||||
/>
|
||||
<SettingRow
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
title="Ground shadows"
|
||||
description="Shadow projections on the map surface"
|
||||
checked={settings.showShadows}
|
||||
onChange={(v) => update("showShadows", v)}
|
||||
/>
|
||||
<SettingRow
|
||||
icon={<Palette className="h-4 w-4" />}
|
||||
title="Altitude colors"
|
||||
description="Color aircraft and trails by altitude"
|
||||
checked={settings.showAltitudeColors}
|
||||
onChange={(v) => update("showAltitudeColors", v)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-white/4 active:bg-white/6"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-white/80">{title}</p>
|
||||
<p className="mt-0.5 text-[11px] font-medium leading-relaxed text-white/22">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={checked} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentRow<T extends string | number>({
|
||||
icon,
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
options: { label: string; value: T }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||
{icon}
|
||||
</div>
|
||||
<p className="flex-1 min-w-0 text-[13px] font-medium text-white/80">
|
||||
{title}
|
||||
</p>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={title}
|
||||
className="flex shrink-0 rounded-md bg-white/4 p-0.5 ring-1 ring-white/6"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`relative rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
isActive ? "text-white/90" : "text-white/30 hover:text-white/50"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId={`seg-${title}`}
|
||||
className="absolute inset-0 rounded-md bg-white/10"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${
|
||||
checked ? "bg-white/20" : "bg-white/6"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: checked ? 17 : 2 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className={`absolute top-0.75 h-3.5 w-3.5 rounded-full shadow-sm transition-colors duration-200 ${
|
||||
checked ? "bg-white" : "bg-white/25"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/ui/flight-card.tsx
Normal file
131
src/components/ui/flight-card.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plane, ArrowUp, ArrowDown, Gauge, Compass, Globe } from "lucide-react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import {
|
||||
metersToFeet,
|
||||
msToKnots,
|
||||
formatCallsign,
|
||||
headingToCardinal,
|
||||
} from "@/lib/flight-utils";
|
||||
|
||||
type FlightCardProps = {
|
||||
flight: FlightState | null;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function FlightCard({ flight, x, y }: FlightCardProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{flight && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 28,
|
||||
mass: 0.8,
|
||||
}}
|
||||
className="pointer-events-none fixed z-50 w-72"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
left: `min(${x + 16}px, calc(100vw - 304px))`,
|
||||
top: `min(${y - 8}px, calc(100vh - 280px))`,
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/6">
|
||||
<Plane className="h-4 w-4 text-white/80" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-wide text-white">
|
||||
{formatCallsign(flight.callsign)}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium tracking-wider text-white/40 uppercase">
|
||||
{flight.icao24}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 uppercase">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
|
||||
|
||||
<div className="mt-3.5 grid grid-cols-2 gap-3">
|
||||
<Metric
|
||||
icon={<ArrowUp className="h-3 w-3" />}
|
||||
label="Altitude"
|
||||
value={metersToFeet(flight.baroAltitude)}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Gauge className="h-3 w-3" />}
|
||||
label="Speed"
|
||||
value={msToKnots(flight.velocity)}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Compass className="h-3 w-3" />}
|
||||
label="Heading"
|
||||
value={
|
||||
flight.trueTrack !== null
|
||||
? `${Math.round(flight.trueTrack)}° ${headingToCardinal(flight.trueTrack)}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
icon={<ArrowDown className="h-3 w-3" />}
|
||||
label="V/S"
|
||||
value={
|
||||
flight.verticalRate !== null
|
||||
? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)} m/s`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
|
||||
|
||||
<div className="mt-3 flex items-center gap-1.5">
|
||||
<Globe className="h-3 w-3 text-white/30" />
|
||||
<p className="text-[11px] font-medium tracking-wide text-white/40">
|
||||
{flight.originCountry}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 text-white/30">
|
||||
{icon}
|
||||
<span className="text-[10px] font-medium tracking-wider uppercase">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold tracking-tight text-white/90">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/ui/scroll-area.tsx
Normal file
95
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type HTMLAttributes,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ScrollAreaProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbHeight, setThumbHeight] = useState(0);
|
||||
const [thumbTop, setThumbTop] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const updateThumb = useCallback(() => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const ratio = vp.clientHeight / vp.scrollHeight;
|
||||
if (ratio >= 1) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setThumbHeight(Math.max(ratio * vp.clientHeight, 24));
|
||||
setThumbTop(
|
||||
(vp.scrollTop / (vp.scrollHeight - vp.clientHeight)) *
|
||||
(vp.clientHeight - Math.max(ratio * vp.clientHeight, 24)),
|
||||
);
|
||||
setVisible(true);
|
||||
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setVisible(false), 1200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const onScroll = () => updateThumb();
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const observer = new ResizeObserver(() => updateThumb());
|
||||
observer.observe(vp);
|
||||
|
||||
return () => {
|
||||
vp.removeEventListener("scroll", onScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [updateThumb]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="h-full w-full overflow-y-auto overflow-x-hidden scrollbar-none"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
onMouseEnter={updateThumb}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0.5 top-0 bottom-0 w-1.5 transition-opacity duration-300",
|
||||
visible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className="absolute w-full rounded-full bg-white/15 transition-[background-color] duration-150 hover:bg-white/25"
|
||||
style={{
|
||||
height: thumbHeight,
|
||||
transform: `translateY(${thumbTop}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScrollArea.displayName = "ScrollArea";
|
||||
111
src/components/ui/status-bar.tsx
Normal file
111
src/components/ui/status-bar.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plane, Radio, ShieldAlert } from "lucide-react";
|
||||
|
||||
type StatusBarProps = {
|
||||
flightCount: number;
|
||||
cityName: string;
|
||||
loading: boolean;
|
||||
rateLimited?: boolean;
|
||||
retryIn?: number;
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
flightCount,
|
||||
cityName,
|
||||
loading,
|
||||
rateLimited = false,
|
||||
retryIn = 0,
|
||||
}: StatusBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<AnimatePresence>
|
||||
{rateLimited && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 28 }}
|
||||
className="flex items-center gap-2.5 rounded-xl border border-amber-500/15 bg-amber-500/6 px-3.5 py-2 backdrop-blur-2xl"
|
||||
role="alert"
|
||||
>
|
||||
<ShieldAlert className="h-3.5 w-3.5 text-amber-400/80" />
|
||||
<span className="text-[11px] font-medium tracking-wide text-amber-300/70">
|
||||
Rate limited
|
||||
</span>
|
||||
{retryIn > 0 && (
|
||||
<>
|
||||
<div className="h-3 w-px bg-amber-400/10" />
|
||||
<span className="font-mono text-[11px] font-semibold tabular-nums text-amber-400/60">
|
||||
{retryIn}s
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
delay: 0.4,
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Radio
|
||||
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-3 w-px"
|
||||
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Plane
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-[11px] font-semibold tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.6)" }}
|
||||
>
|
||||
{flightCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-3 w-px"
|
||||
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{cityName}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/hooks/use-flights.ts
Normal file
119
src/hooks/use-flights.ts
Normal file
@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchFlightsByBbox,
|
||||
bboxFromCenter,
|
||||
type FlightState,
|
||||
} from "@/lib/opensky";
|
||||
import type { City } from "@/lib/cities";
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
const RATE_LIMIT_BACKOFF_MS = 30_000;
|
||||
|
||||
export function useFlights(city: City | null) {
|
||||
const [flights, setFlights] = useState<FlightState[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const [retryIn, setRetryIn] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const clearCountdown = useCallback(() => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setRetryIn(0);
|
||||
}, []);
|
||||
|
||||
const startCountdown = useCallback(
|
||||
(ms: number) => {
|
||||
clearCountdown();
|
||||
const endTime = Date.now() + ms;
|
||||
setRetryIn(Math.ceil(ms / 1000));
|
||||
countdownRef.current = setInterval(() => {
|
||||
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
||||
setRetryIn(remaining);
|
||||
if (remaining <= 0) clearCountdown();
|
||||
}, 1000);
|
||||
},
|
||||
[clearCountdown],
|
||||
);
|
||||
|
||||
const scheduleNext = useCallback(
|
||||
(target: City, delayMs: number) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => fetchData(target), delayMs);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (target: City) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const bbox = bboxFromCenter(
|
||||
target.coordinates[0],
|
||||
target.coordinates[1],
|
||||
target.radius,
|
||||
);
|
||||
const result = await fetchFlightsByBbox(...bbox, controller.signal);
|
||||
|
||||
if (result.rateLimited) {
|
||||
setRateLimited(true);
|
||||
startCountdown(RATE_LIMIT_BACKOFF_MS);
|
||||
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
setRateLimited(false);
|
||||
clearCountdown();
|
||||
setFlights(result.flights);
|
||||
scheduleNext(target, POLL_INTERVAL_MS);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setFlights([]);
|
||||
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[scheduleNext, startCountdown, clearCountdown],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (!city) {
|
||||
setFlights([]);
|
||||
setRateLimited(false);
|
||||
clearCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setRateLimited(false);
|
||||
clearCountdown();
|
||||
fetchData(city);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
abortRef.current?.abort();
|
||||
clearCountdown();
|
||||
};
|
||||
}, [city, fetchData, clearCountdown]);
|
||||
|
||||
return { flights, loading, error, rateLimited, retryIn };
|
||||
}
|
||||
152
src/hooks/use-settings.tsx
Normal file
152
src/hooks/use-settings.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type OrbitDirection = "clockwise" | "counter-clockwise";
|
||||
|
||||
export type Settings = {
|
||||
autoOrbit: boolean;
|
||||
orbitSpeed: number;
|
||||
orbitDirection: OrbitDirection;
|
||||
showTrails: boolean;
|
||||
showShadows: boolean;
|
||||
showAltitudeColors: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
autoOrbit: true,
|
||||
orbitSpeed: 0.15,
|
||||
orbitDirection: "clockwise",
|
||||
showTrails: true,
|
||||
showShadows: true,
|
||||
showAltitudeColors: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "aeris:settings";
|
||||
const STORAGE_VERSION = 1;
|
||||
const WRITE_DEBOUNCE_MS = 300;
|
||||
|
||||
type StorageEnvelope = {
|
||||
v: number;
|
||||
data: Settings;
|
||||
};
|
||||
|
||||
/** Validate that a parsed value matches the Settings shape. */
|
||||
function isValidSettings(obj: unknown): obj is Settings {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
const s = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof s.autoOrbit === "boolean" &&
|
||||
typeof s.orbitSpeed === "number" &&
|
||||
(s.orbitDirection === "clockwise" ||
|
||||
s.orbitDirection === "counter-clockwise") &&
|
||||
typeof s.showTrails === "boolean" &&
|
||||
typeof s.showShadows === "boolean" &&
|
||||
typeof s.showAltitudeColors === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function loadSettings(): Settings {
|
||||
if (typeof window === "undefined") return DEFAULT_SETTINGS;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_SETTINGS;
|
||||
const envelope: StorageEnvelope = JSON.parse(raw);
|
||||
if (envelope.v !== STORAGE_VERSION || !isValidSettings(envelope.data)) {
|
||||
// Merge salvageable keys with defaults
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
if (typeof envelope.data === "object" && envelope.data !== null) {
|
||||
const d = envelope.data as Record<string, unknown>;
|
||||
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
|
||||
if (key in d && typeof d[key] === typeof DEFAULT_SETTINGS[key]) {
|
||||
(merged as Record<string, unknown>)[key] = d[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...envelope.data };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings: Settings): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const envelope: StorageEnvelope = { v: STORAGE_VERSION, data: settings };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
/* quota exceeded or blocked */
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsContextValue = {
|
||||
settings: Settings;
|
||||
update: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
let settingsCache: Settings | undefined;
|
||||
|
||||
function getSettingsSnapshot(): Settings {
|
||||
if (!settingsCache) settingsCache = loadSettings();
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
const ctx = useContext(SettingsContext);
|
||||
if (!ctx) throw new Error("useSettings must be used within SettingsProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const hydrated = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
getSettingsSnapshot,
|
||||
() => DEFAULT_SETTINGS,
|
||||
);
|
||||
|
||||
const [override, setOverride] = useState<Settings | undefined>();
|
||||
const settings = override ?? hydrated;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!override) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(
|
||||
() => saveSettings(override),
|
||||
WRITE_DEBOUNCE_MS,
|
||||
);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [override]);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||
setOverride((prev) => {
|
||||
const base = prev ?? getSettingsSnapshot();
|
||||
return { ...base, [key]: value };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, update }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
148
src/hooks/use-trail-history.ts
Normal file
148
src/hooks/use-trail-history.ts
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
type Position = [lng: number, lat: number];
|
||||
|
||||
export type TrailEntry = {
|
||||
icao24: string;
|
||||
path: Position[];
|
||||
baroAltitude: number | null;
|
||||
};
|
||||
|
||||
const MAX_POINTS = 40;
|
||||
const SYNTHETIC_COUNT = 12;
|
||||
const JUMP_THRESHOLD_DEG = 0.3;
|
||||
export const SAMPLES_PER_SEGMENT = 8;
|
||||
|
||||
// Centripetal Catmull-Rom spline (Barry-Goldman algorithm, α = 0.5).
|
||||
// Produces smooth C1 curves that pass through every control point.
|
||||
function catmullRomSmooth(
|
||||
points: Position[],
|
||||
samplesPerSegment: number = SAMPLES_PER_SEGMENT,
|
||||
): Position[] {
|
||||
if (points.length < 3) return [...points];
|
||||
|
||||
const result: Position[] = [points[0]];
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(0, i - 1)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||
|
||||
// Knot intervals
|
||||
const d01 = Math.pow(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), 0.5) || 1e-6;
|
||||
const d12 = Math.pow(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]), 0.5) || 1e-6;
|
||||
const d23 = Math.pow(Math.hypot(p3[0] - p2[0], p3[1] - p2[1]), 0.5) || 1e-6;
|
||||
|
||||
const t0 = 0;
|
||||
const t1 = d01;
|
||||
const t2 = t1 + d12;
|
||||
const t3 = t2 + d23;
|
||||
|
||||
for (let s = 1; s <= samplesPerSegment; s++) {
|
||||
const t = t1 + (t2 - t1) * (s / samplesPerSegment);
|
||||
|
||||
// Barry-Goldman interpolation
|
||||
const a1x =
|
||||
((t1 - t) / (t1 - t0)) * p0[0] + ((t - t0) / (t1 - t0)) * p1[0];
|
||||
const a1y =
|
||||
((t1 - t) / (t1 - t0)) * p0[1] + ((t - t0) / (t1 - t0)) * p1[1];
|
||||
const a2x =
|
||||
((t2 - t) / (t2 - t1)) * p1[0] + ((t - t1) / (t2 - t1)) * p2[0];
|
||||
const a2y =
|
||||
((t2 - t) / (t2 - t1)) * p1[1] + ((t - t1) / (t2 - t1)) * p2[1];
|
||||
const a3x =
|
||||
((t3 - t) / (t3 - t2)) * p2[0] + ((t - t2) / (t3 - t2)) * p3[0];
|
||||
const a3y =
|
||||
((t3 - t) / (t3 - t2)) * p2[1] + ((t - t2) / (t3 - t2)) * p3[1];
|
||||
|
||||
const b1x = ((t2 - t) / (t2 - t0)) * a1x + ((t - t0) / (t2 - t0)) * a2x;
|
||||
const b1y = ((t2 - t) / (t2 - t0)) * a1y + ((t - t0) / (t2 - t0)) * a2y;
|
||||
const b2x = ((t3 - t) / (t3 - t1)) * a2x + ((t - t1) / (t3 - t1)) * a3x;
|
||||
const b2y = ((t3 - t) / (t3 - t1)) * a2y + ((t - t1) / (t3 - t1)) * a3y;
|
||||
|
||||
const cx = ((t2 - t) / (t2 - t1)) * b1x + ((t - t1) / (t2 - t1)) * b2x;
|
||||
const cy = ((t2 - t) / (t2 - t1)) * b1y + ((t - t1) / (t2 - t1)) * b2y;
|
||||
|
||||
result.push([cx, cy]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function synthesizeTail(f: FlightState): Position[] {
|
||||
const lng = f.longitude!;
|
||||
const lat = f.latitude!;
|
||||
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180;
|
||||
const speed = f.velocity ?? 200;
|
||||
const step = Math.min((speed * 10) / 111_320, 0.02);
|
||||
|
||||
const pts: Position[] = [];
|
||||
for (let i = SYNTHETIC_COUNT; i >= 1; i--) {
|
||||
const d = step * i;
|
||||
pts.push([lng - Math.sin(heading) * d, lat - Math.cos(heading) * d]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
class TrailStore {
|
||||
private trails = new Map<string, Position[]>();
|
||||
private seen = new Set<string>();
|
||||
|
||||
update(flights: FlightState[]): TrailEntry[] {
|
||||
const current = new Set<string>();
|
||||
|
||||
for (const f of flights) {
|
||||
if (f.longitude === null || f.latitude === null) continue;
|
||||
const id = f.icao24;
|
||||
current.add(id);
|
||||
|
||||
const pos: Position = [f.longitude, f.latitude];
|
||||
let trail = this.trails.get(id);
|
||||
|
||||
if (!trail) {
|
||||
trail = synthesizeTail(f);
|
||||
this.trails.set(id, trail);
|
||||
}
|
||||
|
||||
const last = trail[trail.length - 1];
|
||||
const dx = pos[0] - last[0];
|
||||
const dy = pos[1] - last[1];
|
||||
if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) {
|
||||
trail.length = 0;
|
||||
}
|
||||
|
||||
trail.push(pos);
|
||||
if (trail.length > MAX_POINTS) {
|
||||
trail.splice(0, trail.length - MAX_POINTS);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of this.seen) {
|
||||
if (!current.has(id)) this.trails.delete(id);
|
||||
}
|
||||
this.seen = current;
|
||||
|
||||
const result: TrailEntry[] = [];
|
||||
for (const f of flights) {
|
||||
const trail = this.trails.get(f.icao24);
|
||||
if (trail && trail.length >= 2) {
|
||||
result.push({
|
||||
icao24: f.icao24,
|
||||
path: trail.length >= 3 ? catmullRomSmooth(trail) : [...trail],
|
||||
baroAltitude: f.baroAltitude,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
|
||||
const [store] = useState(() => new TrailStore());
|
||||
return useMemo(() => store.update(flights), [store, flights]);
|
||||
}
|
||||
99
src/lib/cities.ts
Normal file
99
src/lib/cities.ts
Normal file
@ -0,0 +1,99 @@
|
||||
export type City = {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
iata: string;
|
||||
coordinates: [longitude: number, latitude: number];
|
||||
radius: number;
|
||||
};
|
||||
|
||||
export const CITIES: City[] = [
|
||||
{
|
||||
id: "nyc",
|
||||
name: "New York",
|
||||
country: "US",
|
||||
iata: "JFK",
|
||||
coordinates: [-73.7781, 40.6413],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "lax",
|
||||
name: "Los Angeles",
|
||||
country: "US",
|
||||
iata: "LAX",
|
||||
coordinates: [-118.4085, 33.9416],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "lhr",
|
||||
name: "London",
|
||||
country: "GB",
|
||||
iata: "LHR",
|
||||
coordinates: [-0.4614, 51.47],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "dxb",
|
||||
name: "Dubai",
|
||||
country: "AE",
|
||||
iata: "DXB",
|
||||
coordinates: [55.3644, 25.2532],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "nrt",
|
||||
name: "Tokyo",
|
||||
country: "JP",
|
||||
iata: "NRT",
|
||||
coordinates: [140.3929, 35.772],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "sin",
|
||||
name: "Singapore",
|
||||
country: "SG",
|
||||
iata: "SIN",
|
||||
coordinates: [103.9915, 1.3644],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "cdg",
|
||||
name: "Paris",
|
||||
country: "FR",
|
||||
iata: "CDG",
|
||||
coordinates: [2.5479, 49.0097],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "sfo",
|
||||
name: "San Francisco",
|
||||
country: "US",
|
||||
iata: "SFO",
|
||||
coordinates: [-122.379, 37.6213],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "ord",
|
||||
name: "Chicago",
|
||||
country: "US",
|
||||
iata: "ORD",
|
||||
coordinates: [-87.9073, 41.9742],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "fra",
|
||||
name: "Frankfurt",
|
||||
country: "DE",
|
||||
iata: "FRA",
|
||||
coordinates: [8.5622, 50.0379],
|
||||
radius: 1.5,
|
||||
},
|
||||
{
|
||||
id: "bom",
|
||||
name: "Mumbai",
|
||||
country: "IN",
|
||||
iata: "BOM",
|
||||
coordinates: [72.8679, 19.0896],
|
||||
radius: 1.5,
|
||||
},
|
||||
];
|
||||
73
src/lib/flight-utils.ts
Normal file
73
src/lib/flight-utils.ts
Normal file
@ -0,0 +1,73 @@
|
||||
const MAX_ALTITUDE_METERS = 13000;
|
||||
|
||||
type RGB = [number, number, number];
|
||||
|
||||
const ALTITUDE_STOPS: { t: number; color: RGB }[] = [
|
||||
{ t: 0.0, color: [72, 210, 160] },
|
||||
{ t: 0.1, color: [100, 200, 120] },
|
||||
{ t: 0.2, color: [160, 195, 80] },
|
||||
{ t: 0.3, color: [210, 180, 60] },
|
||||
{ t: 0.4, color: [235, 150, 60] },
|
||||
{ t: 0.52, color: [240, 110, 80] },
|
||||
{ t: 0.64, color: [220, 85, 130] },
|
||||
{ t: 0.76, color: [180, 90, 190] },
|
||||
{ t: 0.88, color: [120, 110, 220] },
|
||||
{ t: 1.0, color: [100, 170, 240] },
|
||||
];
|
||||
|
||||
function lerpColor(a: RGB, b: RGB, t: number): RGB {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
];
|
||||
}
|
||||
|
||||
export function altitudeToColor(
|
||||
altitude: number | null,
|
||||
): [number, number, number, number] {
|
||||
if (altitude === null) return [100, 100, 100, 200];
|
||||
|
||||
const normalized = Math.min(Math.max(altitude / MAX_ALTITUDE_METERS, 0), 1);
|
||||
const t = Math.pow(normalized, 0.4);
|
||||
|
||||
for (let i = 0; i < ALTITUDE_STOPS.length - 1; i++) {
|
||||
const a = ALTITUDE_STOPS[i];
|
||||
const b = ALTITUDE_STOPS[i + 1];
|
||||
if (t >= a.t && t <= b.t) {
|
||||
const segT = (t - a.t) / (b.t - a.t);
|
||||
const [r, g, bl] = lerpColor(a.color, b.color, segT);
|
||||
return [r, g, bl, 210];
|
||||
}
|
||||
}
|
||||
|
||||
const last = ALTITUDE_STOPS[ALTITUDE_STOPS.length - 1];
|
||||
return [last.color[0], last.color[1], last.color[2], 210];
|
||||
}
|
||||
|
||||
export function altitudeToElevation(altitude: number | null): number {
|
||||
if (altitude === null) return 0;
|
||||
return Math.max(altitude * 5, 200);
|
||||
}
|
||||
|
||||
export function metersToFeet(meters: number | null): string {
|
||||
if (meters === null) return "—";
|
||||
return `${Math.round(meters * 3.28084).toLocaleString()} ft`;
|
||||
}
|
||||
|
||||
export function msToKnots(ms: number | null): string {
|
||||
if (ms === null) return "—";
|
||||
return `${Math.round(ms * 1.94384)} kts`;
|
||||
}
|
||||
|
||||
export function formatCallsign(callsign: string | null): string {
|
||||
if (!callsign) return "N/A";
|
||||
return callsign.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function headingToCardinal(degrees: number | null): string {
|
||||
if (degrees === null) return "—";
|
||||
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
const index = Math.round(degrees / 45) % 8;
|
||||
return directions[index];
|
||||
}
|
||||
98
src/lib/map-styles.ts
Normal file
98
src/lib/map-styles.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export type MapStyleSpec = string | Record<string, unknown>;
|
||||
|
||||
export type MapStyle = {
|
||||
id: string;
|
||||
name: string;
|
||||
style: MapStyleSpec;
|
||||
preview: string;
|
||||
previewUrl: string;
|
||||
dark: boolean;
|
||||
};
|
||||
|
||||
const SATELLITE_STYLE: Record<string, unknown> = {
|
||||
version: 8,
|
||||
sources: {
|
||||
"esri-satellite": {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: "© Esri",
|
||||
},
|
||||
},
|
||||
layers: [{ id: "satellite", type: "raster", source: "esri-satellite" }],
|
||||
};
|
||||
|
||||
const TERRAIN_STYLE: Record<string, unknown> = {
|
||||
version: 8,
|
||||
sources: {
|
||||
opentopomap: {
|
||||
type: "raster",
|
||||
tiles: ["https://tile.opentopomap.org/{z}/{x}/{y}.png"],
|
||||
tileSize: 256,
|
||||
maxzoom: 17,
|
||||
attribution: "© OpenTopoMap",
|
||||
},
|
||||
},
|
||||
layers: [{ id: "terrain", type: "raster", source: "opentopomap" }],
|
||||
};
|
||||
|
||||
export const MAP_STYLES: MapStyle[] = [
|
||||
{
|
||||
id: "dark",
|
||||
name: "Dark",
|
||||
style:
|
||||
"https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json",
|
||||
preview: "linear-gradient(135deg, #191a1a 0%, #2d2d2d 50%, #191a1a 100%)",
|
||||
previewUrl: "https://a.basemaps.cartocdn.com/dark_nolabels/3/4/2@2x.png",
|
||||
dark: true,
|
||||
},
|
||||
{
|
||||
id: "dark-labels",
|
||||
name: "Annotated",
|
||||
style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
||||
preview: "linear-gradient(135deg, #1a1c1e 0%, #33363a 50%, #1a1c1e 100%)",
|
||||
previewUrl: "https://a.basemaps.cartocdn.com/dark_all/3/4/2@2x.png",
|
||||
dark: true,
|
||||
},
|
||||
{
|
||||
id: "voyager",
|
||||
name: "Voyager",
|
||||
style:
|
||||
"https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json",
|
||||
preview: "linear-gradient(135deg, #f2efe9 0%, #d4cfc4 50%, #f2efe9 100%)",
|
||||
previewUrl:
|
||||
"https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/3/4/2@2x.png",
|
||||
dark: false,
|
||||
},
|
||||
{
|
||||
id: "positron",
|
||||
name: "Light",
|
||||
style:
|
||||
"https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json",
|
||||
preview: "linear-gradient(135deg, #e8e8e8 0%, #fafafa 50%, #e8e8e8 100%)",
|
||||
previewUrl: "https://a.basemaps.cartocdn.com/light_nolabels/3/4/2@2x.png",
|
||||
dark: false,
|
||||
},
|
||||
{
|
||||
id: "satellite",
|
||||
name: "Satellite",
|
||||
style: SATELLITE_STYLE,
|
||||
preview: "linear-gradient(135deg, #0a1628 0%, #1a3050 50%, #0a1628 100%)",
|
||||
previewUrl:
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/3/2/4",
|
||||
dark: true,
|
||||
},
|
||||
{
|
||||
id: "terrain",
|
||||
name: "Terrain",
|
||||
style: TERRAIN_STYLE,
|
||||
preview: "linear-gradient(135deg, #c8d8c0 0%, #a8c098 50%, #d0d8c0 100%)",
|
||||
previewUrl: "https://tile.opentopomap.org/3/4/2.png",
|
||||
dark: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_STYLE = MAP_STYLES[0];
|
||||
93
src/lib/opensky.ts
Normal file
93
src/lib/opensky.ts
Normal file
@ -0,0 +1,93 @@
|
||||
export type FlightState = {
|
||||
icao24: string;
|
||||
callsign: string | null;
|
||||
originCountry: string;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
baroAltitude: number | null;
|
||||
onGround: boolean;
|
||||
velocity: number | null;
|
||||
trueTrack: number | null;
|
||||
verticalRate: number | null;
|
||||
geoAltitude: number | null;
|
||||
squawk: string | null;
|
||||
spiFlag: boolean;
|
||||
positionSource: number;
|
||||
};
|
||||
|
||||
export type OpenSkyResponse = {
|
||||
time: number;
|
||||
states: (string | number | boolean | null)[][] | null;
|
||||
rateLimited?: boolean;
|
||||
};
|
||||
|
||||
function parseStates(raw: OpenSkyResponse): FlightState[] {
|
||||
if (!raw.states) return [];
|
||||
|
||||
return raw.states
|
||||
.map((s) => ({
|
||||
icao24: s[0] as string,
|
||||
callsign: (s[1] as string)?.trim() || null,
|
||||
originCountry: s[2] as string,
|
||||
longitude: s[5] as number | null,
|
||||
latitude: s[6] as number | null,
|
||||
baroAltitude: s[7] as number | null,
|
||||
onGround: s[8] as boolean,
|
||||
velocity: s[9] as number | null,
|
||||
trueTrack: s[10] as number | null,
|
||||
verticalRate: s[11] as number | null,
|
||||
geoAltitude: s[13] as number | null,
|
||||
squawk: s[14] as string | null,
|
||||
spiFlag: s[15] as boolean,
|
||||
positionSource: s[16] as number,
|
||||
}))
|
||||
.filter(
|
||||
(f) =>
|
||||
f.longitude !== null &&
|
||||
f.latitude !== null &&
|
||||
!f.onGround &&
|
||||
f.baroAltitude !== null,
|
||||
);
|
||||
}
|
||||
|
||||
export type FetchResult = {
|
||||
flights: FlightState[];
|
||||
rateLimited: boolean;
|
||||
};
|
||||
|
||||
/** Fetch flights via the server-side proxy. */
|
||||
export async function fetchFlightsByBbox(
|
||||
lamin: number,
|
||||
lamax: number,
|
||||
lomin: number,
|
||||
lomax: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FetchResult> {
|
||||
const url = `/api/flights?lamin=${lamin}&lamax=${lamax}&lomin=${lomin}&lomax=${lomax}`;
|
||||
|
||||
const res = await fetch(url, { cache: "no-store", signal });
|
||||
|
||||
if (!res.ok) {
|
||||
// Don't throw — let the hook retry gracefully
|
||||
console.warn(`[aeris] Flight API returned ${res.status}`);
|
||||
return { flights: [], rateLimited: false };
|
||||
}
|
||||
|
||||
const data: OpenSkyResponse = await res.json();
|
||||
|
||||
if (data.rateLimited) {
|
||||
console.warn("[aeris] OpenSky rate limit hit, backing off");
|
||||
return { flights: [], rateLimited: true };
|
||||
}
|
||||
|
||||
const flights = parseStates(data);
|
||||
return { flights, rateLimited: false };
|
||||
}
|
||||
|
||||
export function bboxFromCenter(
|
||||
lng: number,
|
||||
lat: number,
|
||||
radiusDeg: number,
|
||||
): [lamin: number, lamax: number, lomin: number, lomax: number] {
|
||||
return [lat - radiusDeg, lat + radiusDeg, lng - radiusDeg, lng + radiusDeg];
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user