Skip to content

Commit 79734ad

Browse files
committed
feat: timepoints
1 parent 2bd0ded commit 79734ad

12 files changed

Lines changed: 280 additions & 21 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script lang="ts">
2+
import * as Dialog from '$lib/components/ui/dialog';
3+
import { cn } from '$lib/utils.js';
4+
import { OctagonPauseIcon } from '@lucide/svelte';
5+
6+
let { class: className }: { class?: string } = $props();
7+
</script>
8+
9+
<Dialog.Root>
10+
<Dialog.Trigger class={cn('text-muted-foreground', className)} aria-label="What is a time point?">
11+
<OctagonPauseIcon class="size-6" />
12+
</Dialog.Trigger>
13+
14+
<Dialog.Content>
15+
<Dialog.Title>Time Point</Dialog.Title>
16+
<Dialog.Description>
17+
Buses will always stop at this location and wait until the scheduled departure time if they
18+
are running early.
19+
</Dialog.Description>
20+
</Dialog.Content>
21+
</Dialog.Root>

src/lib/components/map/MapElements.svelte

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
type MapCircleStyle,
77
} from '$lib/components/ui/map/MapCircleLayer.svelte';
88
import { useRoutes, useVehicles } from '$lib/data/app';
9-
import { type Bus, type Location, type Route, type Stop } from '$lib/data/types';
9+
import { isTimepoint, useTimepointsAPI } from '$lib/data/timepoints';
10+
import { type Bus, type Direction, type Location, type Route, type Stop } from '$lib/data/types';
1011
import { mapManager } from '$lib/managers/map.manager.svelte';
1112
import { themeManager } from '$lib/managers/theme.manager.svelte';
1213
import { getRouteTint } from '$lib/utils/tints';
@@ -25,6 +26,7 @@
2526
const STOPS_ID = 'stops';
2627
2728
const routes = useRoutes();
29+
const timepoints = useTimepointsAPI();
2830
const busLocations = $derived(useVehicles(() => ({ route: mapManager.selectedRoute })));
2931
3032
let userLocation: Location | null = $state(null);
@@ -83,24 +85,43 @@
8385
})),
8486
);
8587
88+
function isStopTimepoint(stop: Stop) {
89+
const routeCode = mapManager.selectedRoute?.routeCode;
90+
return !!routeCode && isTimepoint(timepoints.data ?? {}, routeCode, stop.id);
91+
}
92+
93+
// timepoints render as square DOM markers instead of circles, so they are excluded here
8694
const stopCircles = $derived(
8795
stopsByDirection.flatMap(({ direction, stops }) =>
88-
stops.map(
89-
(stop): MapCircle => ({
90-
id: `${direction.id}-${stop.id}`,
91-
longitude: stop.location.longitude,
92-
latitude: stop.location.latitude,
93-
}),
94-
),
96+
stops
97+
.filter((stop) => !isStopTimepoint(stop))
98+
.map(
99+
(stop): MapCircle => ({
100+
id: `${direction.id}-${stop.id}`,
101+
longitude: stop.location.longitude,
102+
latitude: stop.location.latitude,
103+
}),
104+
),
95105
),
96106
);
97107
98-
const stopCircleStyles = $derived.by(() => {
108+
const timepointStops = $derived(
109+
stopsByDirection.flatMap(({ direction, stops }) =>
110+
stops.filter(isStopTimepoint).map((stop) => ({ direction, stop })),
111+
),
112+
);
113+
114+
const stopColors = $derived.by(() => {
99115
const route = mapManager.selectedRoute;
100-
if (!route) return {};
116+
if (!route) return null;
101117
102118
const color = getRouteTint(route, themeManager.theme);
103-
const strokeColor = getLighterColor(color);
119+
return { color, strokeColor: getLighterColor(color) };
120+
});
121+
122+
const stopCircleStyles = $derived.by(() => {
123+
if (!stopColors) return {};
124+
const { color, strokeColor } = stopColors;
104125
105126
return Object.fromEntries(
106127
stopsByDirection.flatMap(({ direction, stops }) => {
@@ -113,6 +134,11 @@
113134
);
114135
});
115136
137+
function selectStop(stopId: string) {
138+
markStopTapped();
139+
mapManager.selected = stopId === selectedStop?.stop.id ? null : { type: 'stop', id: stopId };
140+
}
141+
116142
const stopsByCircleId = $derived(
117143
new Map<string, Stop>(
118144
stopsByDirection.flatMap(({ direction, stops }) =>
@@ -164,6 +190,26 @@
164190
{/each}
165191
{/snippet}
166192

193+
{#snippet timepointMarker(direction: Direction, stop: Stop)}
194+
<MapMarker
195+
longitude={stop.location.longitude}
196+
latitude={stop.location.latitude}
197+
zIndex={10}
198+
onclick={() => selectStop(stop.id)}
199+
>
200+
<MarkerContent class="flex size-7 items-center justify-center">
201+
<div
202+
class="size-3 rounded-[2px] border-2"
203+
style="background-color: {stopColors?.color}; border-color: {stopColors?.strokeColor}; opacity: {isDirectionSelected(
204+
direction.id,
205+
)
206+
? 1
207+
: 0.5}"
208+
></div>
209+
</MarkerContent>
210+
</MapMarker>
211+
{/snippet}
212+
167213
{#snippet busMarker(bus: Bus)}
168214
<MapMarker
169215
longitude={bus.location.longitude}
@@ -186,12 +232,18 @@
186232
styles={stopCircleStyles}
187233
onclick={(id) => {
188234
const stopId = (id && stopsByCircleId.get(id)?.id) || null;
189-
if (stopId) markStopTapped();
190-
mapManager.selected =
191-
!stopId || stopId === selectedStop?.stop.id ? null : { type: 'stop', id: stopId };
235+
if (!stopId) {
236+
mapManager.selected = null;
237+
return;
238+
}
239+
selectStop(stopId);
192240
}}
193241
/>
194242

243+
{#each timepointStops as { direction, stop } (`${direction.id}-${stop.id}`)}
244+
{@render timepointMarker(direction, stop)}
245+
{/each}
246+
195247
{#each busLocations?.data ?? [] as bus (bus.id)}
196248
{@render busMarker(bus)}
197249
{/each}

src/lib/components/map/popup/StopPopup.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
</div>
4242

4343
<div class="flex items-center gap-2">
44-
{#each amenities ?? [] as amenity}
44+
{#each (amenities ?? []).filter((amenity) => amenity !== Amenity.TIME_POINT) as amenity}
4545
{@const AmenityIcon = Amenity.getIcon(amenity)}
4646
<AmenityIcon class="size-6 text-muted-foreground" />
4747
{/each}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script lang="ts">
2+
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
3+
import { XIcon } from '@lucide/svelte';
4+
import { Dialog as DialogPrimitive } from 'bits-ui';
5+
import type { Snippet } from 'svelte';
6+
import DialogOverlay from './dialog-overlay.svelte';
7+
8+
let {
9+
ref = $bindable(null),
10+
class: className,
11+
children,
12+
showCloseButton = true,
13+
...restProps
14+
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
15+
children?: Snippet;
16+
showCloseButton?: boolean;
17+
} = $props();
18+
</script>
19+
20+
<DialogPrimitive.Portal>
21+
<DialogOverlay />
22+
<DialogPrimitive.Content
23+
bind:ref
24+
data-slot="dialog-content"
25+
class={cn(
26+
'fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col gap-2 rounded-xl border bg-popover p-5 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
27+
className,
28+
)}
29+
{...restProps}
30+
>
31+
{@render children?.()}
32+
33+
{#if showCloseButton}
34+
<DialogPrimitive.Close
35+
class="absolute end-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
36+
>
37+
<XIcon class="size-4" />
38+
<span class="sr-only">Close</span>
39+
</DialogPrimitive.Close>
40+
{/if}
41+
</DialogPrimitive.Content>
42+
</DialogPrimitive.Portal>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import { cn } from '$lib/utils.js';
3+
import { Dialog as DialogPrimitive } from 'bits-ui';
4+
5+
let {
6+
ref = $bindable(null),
7+
class: className,
8+
children,
9+
...restProps
10+
}: DialogPrimitive.DescriptionProps = $props();
11+
</script>
12+
13+
<DialogPrimitive.Description
14+
bind:ref
15+
data-slot="dialog-description"
16+
class={cn('text-sm text-muted-foreground', className)}
17+
{...restProps}
18+
>
19+
{@render children?.()}
20+
</DialogPrimitive.Description>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
3+
import { Dialog as DialogPrimitive } from 'bits-ui';
4+
5+
let {
6+
ref = $bindable(null),
7+
class: className,
8+
...restProps
9+
}: WithoutChildrenOrChild<DialogPrimitive.OverlayProps> = $props();
10+
</script>
11+
12+
<DialogPrimitive.Overlay
13+
bind:ref
14+
data-slot="dialog-overlay"
15+
class={cn(
16+
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
17+
className,
18+
)}
19+
{...restProps}
20+
/>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import { cn } from '$lib/utils.js';
3+
import { Dialog as DialogPrimitive } from 'bits-ui';
4+
5+
let {
6+
ref = $bindable(null),
7+
class: className,
8+
children,
9+
...restProps
10+
}: DialogPrimitive.TitleProps = $props();
11+
</script>
12+
13+
<DialogPrimitive.Title
14+
bind:ref
15+
data-slot="dialog-title"
16+
class={cn('pe-6 text-base font-semibold', className)}
17+
{...restProps}
18+
>
19+
{@render children?.()}
20+
</DialogPrimitive.Title>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Dialog as DialogPrimitive } from 'bits-ui';
2+
import Content from './dialog-content.svelte';
3+
import Description from './dialog-description.svelte';
4+
import Overlay from './dialog-overlay.svelte';
5+
import Title from './dialog-title.svelte';
6+
7+
const Root = DialogPrimitive.Root;
8+
const Trigger = DialogPrimitive.Trigger;
9+
const Close = DialogPrimitive.Close;
10+
11+
export {
12+
Close,
13+
Content,
14+
Description,
15+
Close as DialogClose,
16+
Content as DialogContent,
17+
Description as DialogDescription,
18+
Overlay as DialogOverlay,
19+
Title as DialogTitle,
20+
Trigger as DialogTrigger,
21+
Overlay,
22+
//
23+
Root as Dialog,
24+
Root,
25+
Title,
26+
Trigger,
27+
};

src/lib/data/structure/aggie_spirit.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type StopSchedule,
1212
type TimeEstimate,
1313
} from '$lib/data/types';
14+
import { isTimepoint, useTimepointsAPI } from '$lib/data/timepoints';
1415
import { findBoundingBox } from '$lib/utils/geo';
1516
import { compact, keyBy } from 'lodash-es';
1617
import moment from 'moment';
@@ -260,6 +261,8 @@ export const useASStopAmenities = (
260261
return { routeKey: route.id, directionKey: direction.id, stopCode: stop.id };
261262
});
262263

264+
const apiTimepointsQuery = useTimepointsAPI();
265+
263266
const query = createDependencyQuery<Amenity[]>(() => ({
264267
queryKey: [
265268
ASQueryKey.STOP_AMENITIES,
@@ -269,9 +272,16 @@ export const useASStopAmenities = (
269272
],
270273
queryFn: async () => {
271274
const stopEstimates = apiStopEstimateQuery.data!;
272-
return Amenity.fromAPI(stopEstimates.amenities);
275+
const { route, stop } = params();
276+
277+
return [
278+
...Amenity.fromAPI(stopEstimates.amenities),
279+
...(isTimepoint(apiTimepointsQuery.data!, route.routeCode, stop.id)
280+
? [Amenity.TIME_POINT]
281+
: []),
282+
];
273283
},
274-
dependents: [apiStopEstimateQuery],
284+
dependents: [apiStopEstimateQuery, apiTimepointsQuery],
275285
}));
276286

277287
return query;

src/lib/data/timepoints.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createLoggingQuery } from '$lib/utils/queries';
2+
import moment from 'moment';
3+
import { z } from 'zod';
4+
5+
const TIMEPOINTS_URL = 'https://auth.maroonrides.app/timepoints.json';
6+
7+
export const TimepointStopsSchema = z.record(z.string(), z.array(z.string()));
8+
export type TimepointStops = z.infer<typeof TimepointStopsSchema>;
9+
10+
export const useTimepointsAPI = () => {
11+
const query = createLoggingQuery<TimepointStops>(() => ({
12+
label: 'MRTimepoints',
13+
queryKey: ['MRTimepoints'],
14+
// never rejects: the route list depends on this, so a missing or malformed
15+
// file has to degrade to "no timepoints" rather than block every route
16+
queryFn: async () => {
17+
try {
18+
const res = await fetch(TIMEPOINTS_URL);
19+
if (!res.ok) return {};
20+
21+
return TimepointStopsSchema.parse(await res.json());
22+
} catch {
23+
return {};
24+
}
25+
},
26+
staleTime: moment.duration(1, 'day'),
27+
}));
28+
29+
return query;
30+
};
31+
32+
export function isTimepoint(
33+
timepoints: TimepointStops,
34+
routeCode: string,
35+
stopId: string,
36+
): boolean {
37+
return timepoints[routeCode]?.includes(stopId) ?? false;
38+
}

0 commit comments

Comments
 (0)