Skip to content

Commit 7486126

Browse files
committed
Add bookings map control
1 parent c1ce0fb commit 7486126

20 files changed

Lines changed: 287 additions & 535 deletions

File tree

apps/ingestion/src/bookings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@ export async function updateBookingsData(): Promise<void> {
1313

1414
const bookings = await axios.get<VatsimBooking[]>("https://atc-bookings.vatsim.net/api/booking").then((res) => res.data);
1515
const parsedBookings = parseBookings(bookings);
16-
rdsPub("data:bookings", parsedBookings);
1716

17+
const twoDaysFromNow = now + 2 * 24 * 60 * 60 * 1000;
18+
const limitedBookings = parsedBookings.filter((booking) => {
19+
const bookingStart = new Date(booking.start).getTime();
20+
return bookingStart <= twoDaysFromNow;
21+
});
22+
23+
const sortedBookings = limitedBookings.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
24+
25+
rdsPub("data:bookings", sortedBookings);
1826
lastUpdate = now;
1927
}
2028

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,108 @@
11
"use client";
22

33
import type { Booking } from "@sr24/types/interface";
4-
import { useEffect } from "react";
4+
import { useEffect, useMemo, useState } from "react";
55
import useSWR from "swr";
66
import Spinner from "@/components/Spinner/Spinner";
77
import { fetchApi } from "@/utils/api";
8-
import { init } from "../lib";
9-
import BookingsControls from "./BookingsControls";
8+
import { init, setFeaturesByTime } from "../lib";
9+
import { BookingsControl } from "./BookingsControls";
1010
import BookingsMap from "./BookingsMap";
1111

12+
export const REPLAY_SPEEDS = [1, 2, 4, 8, 16];
13+
1214
export default function Bookings() {
1315
const { data, isLoading } = useSWR<Booking[]>("/data/bookings", fetchApi, {
1416
refreshInterval: 10 * 60 * 1000,
1517
revalidateOnFocus: false,
1618
});
1719

20+
const [progress, setProgress] = useState(0);
21+
const [playing, setPlaying] = useState(false);
22+
const [speedIndex, setSpeedIndex] = useState(3);
23+
24+
const timeline = useMemo(() => buildTimeline(data || []), [data]);
25+
26+
useEffect(() => {
27+
if (!timeline.length) return;
28+
setProgress(getCurrentTimelineStep(timeline));
29+
}, [timeline]);
30+
1831
useEffect(() => {
1932
if (data) {
2033
init(data);
2134
}
2235
}, [data]);
2336

37+
useEffect(() => {
38+
setFeaturesByTime(timeline[progress]);
39+
}, [progress, timeline]);
40+
41+
useEffect(() => {
42+
if (!playing) return;
43+
if (!timeline.length) return;
44+
45+
const intervalMs = (STEP_MINUTES * 60 * 1000) / REPLAY_SPEEDS[speedIndex];
46+
const maxIndex = timeline.length - 1;
47+
48+
const interval = setInterval(() => {
49+
setProgress((prev) => {
50+
if (prev >= maxIndex) {
51+
setPlaying(false);
52+
return prev;
53+
}
54+
return prev + 1;
55+
});
56+
}, intervalMs);
57+
58+
return () => clearInterval(interval);
59+
}, [playing, speedIndex, timeline.length]);
60+
2461
if (!data || isLoading) {
2562
return <Spinner />;
2663
}
2764

2865
return (
29-
<>
30-
<BookingsControls />
66+
<div id="map-wrapper">
3167
<BookingsMap />
32-
</>
68+
<BookingsControl
69+
progress={progress}
70+
setProgress={setProgress}
71+
setNow={() => setProgress(getCurrentTimelineStep(timeline))}
72+
setSpeedIndex={setSpeedIndex}
73+
speedIndex={speedIndex}
74+
setPlaying={setPlaying}
75+
playing={playing}
76+
currentTime={timeline[progress]}
77+
max={timeline.length - 1}
78+
/>
79+
</div>
3380
);
3481
}
82+
83+
const STEP_MINUTES = 30;
84+
85+
function buildTimeline(bookings: Booking[]) {
86+
if (!bookings.length) return [];
87+
88+
const start = new Date(bookings[0].start).getTime();
89+
const end = new Date(bookings[bookings.length - 1].end).getTime();
90+
91+
const stepMs = STEP_MINUTES * 60 * 1000;
92+
const timeline = [];
93+
94+
for (let t = start; t <= end; t += stepMs) {
95+
timeline.push(t);
96+
}
97+
98+
return timeline;
99+
}
100+
101+
function getCurrentTimelineStep(timeline: number[]) {
102+
if (!timeline.length) return 0;
103+
104+
const now = Date.now();
105+
106+
const idx = timeline.findIndex((t) => t > now);
107+
return idx === -1 ? timeline.length - 1 : Math.max(0, idx - 1);
108+
}
Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,65 @@
1-
export default function BookingsControls() {
2-
return <div id="bookings-control"></div>;
1+
import type { SyntheticEvent } from "react";
2+
import Icon from "@/components/Icon/Icon";
3+
import { RangeSwitch } from "@/components/Input/Input";
4+
import { REPLAY_SPEEDS } from "./Bookings";
5+
import "@/components/Map/ReplayControl/ReplayControl.css";
6+
import { useSettingsStore } from "@/storage/zustand";
7+
import { convertTime } from "@/utils/helpers";
8+
9+
export function BookingsControl({
10+
progress,
11+
setProgress,
12+
setNow,
13+
setSpeedIndex,
14+
speedIndex,
15+
setPlaying,
16+
playing,
17+
currentTime,
18+
max,
19+
}: {
20+
progress: number;
21+
setProgress: React.Dispatch<React.SetStateAction<number>>;
22+
setNow: () => void;
23+
setSpeedIndex: React.Dispatch<React.SetStateAction<number>>;
24+
speedIndex: number;
25+
setPlaying: React.Dispatch<React.SetStateAction<boolean>>;
26+
playing: boolean;
27+
currentTime: number | undefined;
28+
max: number;
29+
}) {
30+
const { timeFormat, timeZone } = useSettingsStore();
31+
32+
return (
33+
<div id="replay-control">
34+
<button type="button" className="replay-button" onClick={() => setPlaying((prev) => !prev)}>
35+
<Icon name={playing ? "pause" : "play"} size={24} />
36+
</button>
37+
<button
38+
type="button"
39+
className="replay-button"
40+
style={{ width: 48 }}
41+
onClick={() => setSpeedIndex((prev) => (prev === REPLAY_SPEEDS.length - 1 ? 0 : prev + 1))}
42+
>
43+
{REPLAY_SPEEDS[speedIndex]}
44+
</button>
45+
<button type="button" className="replay-button" style={{ width: 48 }} onClick={() => setNow()}>
46+
Now
47+
</button>
48+
<RangeSwitch
49+
value={progress}
50+
onChange={(_event: Event | SyntheticEvent<Element, Event>, newValue: number | number[]) => {
51+
setProgress(newValue as number);
52+
}}
53+
min={0}
54+
max={max}
55+
/>
56+
<div id="replay-info">{`${getShortDate(currentTime)} ${convertTime(currentTime, timeFormat, timeZone)}`}</div>
57+
</div>
58+
);
59+
}
60+
61+
function getShortDate(time: number | undefined): string {
62+
if (time === undefined) return "";
63+
const date = new Date(time);
64+
return date.toISOString().split("T")[0];
365
}

apps/web/app/bookings/components/BookingsMap.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function BookingsMap() {
1111
const { dayNightLayer, dayNightLayerBrightness, airportMarkerSize, traconColor, firColor } = useSettingsStore();
1212

1313
useEffect(() => {
14-
const map = mapService.init({ autoTrackPoints: false, disableCenterOnPageLoad: true });
14+
const map = mapService.init({ autoTrackPoints: false, disableCenterOnPageLoad: true, sunTime: new Date() });
1515
mapService.addEventListeners();
1616

1717
return () => {

apps/web/app/bookings/lib/index.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,43 @@ export async function setFeaturesByTime(time: number): Promise<void> {
1919
return startTime <= time && time < endTime;
2020
});
2121

22-
const controllersMerged = parseBookings(currentBookings);
23-
const staticAirports = await Promise.all(controllersMerged.filter((c) => c.facility === "airport").map((c) => getCachedAirport(c.id)));
24-
mapService.setFeatures({ controllers: controllersMerged, airports: staticAirports.filter((a): a is NonNullable<typeof a> => a !== null) });
22+
const controllers = parseBookings(currentBookings);
23+
const staticAirports = await Promise.all(
24+
controllers.filter((c) => c.facility === "airport").map((c) => getCachedAirport(c.id.replace(/^airport_/, ""))),
25+
);
26+
27+
mapService.setStore({ controllers });
28+
mapService.setFeatures({
29+
controllers,
30+
airports: staticAirports.filter((a): a is NonNullable<typeof a> => a !== null),
31+
sunTime: new Date(time),
32+
});
2533
}
2634

2735
function parseBookings(bookings: Booking[]): ControllerMerged[] {
2836
const controllersMerged = new Map<string, ControllerMerged>();
2937

3038
for (const booking of bookings) {
31-
if (!controllersMerged.has(booking.id)) {
32-
controllersMerged.set(booking.id, {
33-
id: booking.id,
39+
const id = `${getFacilityType(booking.facility)}_${booking.id}`;
40+
41+
if (!controllersMerged.has(id)) {
42+
controllersMerged.set(id, {
43+
id,
3444
facility: getFacilityType(booking.facility),
3545
controllers: [],
3646
});
3747
}
38-
const merged = controllersMerged.get(booking.id);
48+
const merged = controllersMerged.get(id);
3949
if (!merged) continue;
4050

4151
const controllerShort: ControllerShort = {
4252
callsign: booking.callsign,
4353
facility: booking.facility,
54+
booking: {
55+
start: booking.start,
56+
end: booking.end,
57+
type: booking.type,
58+
},
4459
};
4560
merged.controllers.push(controllerShort);
4661
}

apps/web/app/data/components/Flights/Flights.css

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -104,56 +104,3 @@
104104
width: 20rem;
105105
max-height: 100%;
106106
}
107-
108-
#replay-control {
109-
background: var(--color-glass-bg);
110-
backdrop-filter: blur(5px);
111-
border: 1px solid var(--color-border);
112-
height: 32px;
113-
flex: 1;
114-
display: flex;
115-
align-items: center;
116-
gap: 4px;
117-
padding: 4px;
118-
box-sizing: border-box;
119-
}
120-
121-
.replay-button {
122-
height: 100%;
123-
background: var(--color-green);
124-
aspect-ratio: 1 / 1;
125-
display: flex;
126-
align-items: center;
127-
justify-content: center;
128-
color: white;
129-
z-index: 1;
130-
}
131-
132-
.replay-button:hover {
133-
background-color: var(--color-hover);
134-
color: white;
135-
}
136-
137-
.replay-button.active {
138-
background: var(--color-blue);
139-
}
140-
141-
.replay-button use {
142-
color: white;
143-
}
144-
145-
#replay-speed {
146-
width: 3rem;
147-
}
148-
149-
#replay-close {
150-
background: var(--color-red);
151-
}
152-
153-
#replay-close:hover {
154-
background-color: white;
155-
}
156-
157-
#replay-close:hover use {
158-
color: var(--color-red);
159-
}

apps/web/app/data/components/Flights/Replay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { toLonLat } from "ol/proj";
55
import { useEffect, useState } from "react";
66
import useSWR from "swr";
77
import Spinner from "@/components/Spinner/Spinner";
8+
import { decodeTrackPoints } from "@/lib/map/tracks";
89
import { fetchApi } from "@/utils/api";
910
import { init, updatePilot } from "../../lib";
1011
import { ReplayControl } from "./ReplayControl";
1112
import ReplayMap from "./ReplayMap";
1213
import ReplayPanel from "./ReplayPanel";
13-
import { decodeTrackPoints } from "@/lib/map/tracks";
1414

1515
interface ApiData {
1616
pilot: PilotLong;
@@ -26,8 +26,8 @@ export function Replay({ id }: { id: string }) {
2626
shouldRetryOnError: false,
2727
});
2828
const [trackPoints, setTrackPoints] = useState<Required<TrackPoint>[]>([]);
29-
const [progress, setProgress] = useState(0);
3029

30+
const [progress, setProgress] = useState(0);
3131
const [playing, setPlaying] = useState(false);
3232
const [speedIndex, setSpeedIndex] = useState(3);
3333

apps/web/app/data/components/Flights/ReplayControl.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Icon from "@/components/Icon/Icon";
44
import { RangeSwitch } from "@/components/Input/Input";
55
import { fitRouteToView, followPilot } from "../../lib";
66
import { REPLAY_SPEEDS } from "./Replay";
7+
import "@/components/Map/ReplayControl/ReplayControl.css";
78

89
export function ReplayControl({
910
progress,
@@ -35,7 +36,7 @@ export function ReplayControl({
3536
<button
3637
type="button"
3738
className="replay-button"
38-
id="replay-speed"
39+
style={{ width: 48 }}
3940
onClick={() => setSpeedIndex((prev) => (prev === REPLAY_SPEEDS.length - 1 ? 0 : prev + 1))}
4041
>
4142
{REPLAY_SPEEDS[speedIndex]}

apps/web/app/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,4 @@ td {
242242
75% {
243243
opacity: 0;
244244
}
245-
}
245+
}

apps/web/components/Map copy/airportFeatures.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)