Skip to content

Commit e0116d6

Browse files
committed
Cleanup
1 parent f5ef029 commit e0116d6

7 files changed

Lines changed: 288 additions & 360 deletions

File tree

Lines changed: 253 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,264 @@
1-
import { useNeapsConfig } from "../../provider.js";
2-
import { TideGraphScroll } from "./TideGraphScroll.js";
3-
import { PX_PER_DAY_DEFAULT } from "./constants.js";
1+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2+
import { useTooltip } from "@visx/tooltip";
3+
4+
import { useTideChunks } from "../../hooks/use-tide-chunks.js";
5+
import { useCurrentLevel } from "../../hooks/use-current-level.js";
6+
import { useTideScales } from "../../hooks/use-tide-scales.js";
7+
import { TideGraphChart } from "./TideGraphChart.js";
8+
import { YAxisOverlay } from "./YAxisOverlay.js";
9+
import { HEIGHT, MARGIN, MS_PER_DAY, PX_PER_DAY_DEFAULT } from "./constants.js";
10+
import type { TimelineEntry } from "../../types.js";
411

512
export interface TideGraphProps {
613
id: string;
714
pxPerDay?: number;
815
className?: string;
916
}
1017

11-
export function TideGraph(props: TideGraphProps) {
12-
const config = useNeapsConfig();
18+
export function TideGraph({ id, pxPerDay = PX_PER_DAY_DEFAULT, className }: TideGraphProps) {
19+
const scrollRef = useRef<HTMLDivElement>(null);
20+
const prevDataStartRef = useRef<number | null>(null);
21+
const prevScrollWidthRef = useRef<number | null>(null);
22+
const hasScrolledToNow = useRef(false);
23+
24+
const {
25+
timeline,
26+
extremes,
27+
dataStart,
28+
dataEnd,
29+
yDomain,
30+
loadPrevious,
31+
loadNext,
32+
isLoadingPrevious,
33+
isLoadingNext,
34+
isLoading,
35+
error,
36+
station,
37+
timezone,
38+
units,
39+
} = useTideChunks({ id });
40+
41+
const totalMs = dataEnd - dataStart;
42+
const totalDays = totalMs / MS_PER_DAY;
43+
const svgWidth = Math.max(1, totalDays * pxPerDay + MARGIN.left + MARGIN.right);
44+
const innerW = svgWidth - MARGIN.left - MARGIN.right;
45+
46+
// Y-axis scales (for the overlay)
47+
const { yScale } = useTideScales({
48+
timeline,
49+
extremes,
50+
width: svgWidth,
51+
height: HEIGHT,
52+
margin: MARGIN,
53+
yDomainOverride: yDomain,
54+
domainOverride: { xMin: dataStart, xMax: dataEnd },
55+
});
56+
57+
const narrowRange = useMemo(() => {
58+
const range = yDomain[1] - yDomain[0];
59+
return range > 0 && range < 3;
60+
}, [yDomain]);
61+
62+
const unitSuffix = units === "feet" ? "ft" : "m";
63+
64+
// Annotation state: entries, not timestamps
65+
const currentLevel = useCurrentLevel(timeline);
66+
const { tooltipData, showTooltip, hideTooltip } = useTooltip<TimelineEntry>();
67+
const [pinnedEntry, setPinnedEntry] = useState<TimelineEntry | null>(null);
68+
const activeEntry = tooltipData ?? pinnedEntry ?? currentLevel;
69+
70+
const handleSelect = useCallback(
71+
(entry: TimelineEntry | null, sticky?: boolean) => {
72+
if (sticky) setPinnedEntry(entry);
73+
else if (entry) showTooltip({ tooltipData: entry });
74+
else hideTooltip();
75+
},
76+
[showTooltip, hideTooltip],
77+
);
78+
79+
// Position of "now" in SVG coordinates (for today-button visibility)
80+
const nowMs = currentLevel ? currentLevel.time.getTime() : null;
81+
const nowPx = useMemo(() => {
82+
if (nowMs === null) return null;
83+
return ((nowMs - dataStart) / totalMs) * innerW + MARGIN.left;
84+
}, [nowMs, dataStart, totalMs, innerW]);
85+
86+
// Scroll to "now" on initial data load
87+
useEffect(() => {
88+
if (hasScrolledToNow.current || !timeline.length || !scrollRef.current) return;
89+
const container = scrollRef.current;
90+
const nowPx = ((Date.now() - dataStart) / totalMs) * innerW + MARGIN.left;
91+
container.scrollLeft = nowPx - container.clientWidth / 2;
92+
hasScrolledToNow.current = true;
93+
prevDataStartRef.current = dataStart;
94+
prevScrollWidthRef.current = container.scrollWidth;
95+
}, [timeline.length, dataStart, totalMs, innerW]);
96+
97+
// Preserve scroll position when chunks prepend (leftward)
98+
useLayoutEffect(() => {
99+
const container = scrollRef.current;
100+
if (!container || prevDataStartRef.current === null || prevScrollWidthRef.current === null)
101+
return;
102+
if (dataStart < prevDataStartRef.current) {
103+
const widthAdded = container.scrollWidth - prevScrollWidthRef.current;
104+
container.scrollLeft += widthAdded;
105+
}
106+
prevDataStartRef.current = dataStart;
107+
prevScrollWidthRef.current = container.scrollWidth;
108+
}, [dataStart]);
109+
110+
// Sentinel-based edge detection
111+
const leftSentinelRef = useRef<HTMLDivElement>(null);
112+
const rightSentinelRef = useRef<HTMLDivElement>(null);
113+
114+
useEffect(() => {
115+
const container = scrollRef.current;
116+
const leftSentinel = leftSentinelRef.current;
117+
const rightSentinel = rightSentinelRef.current;
118+
if (!container || !leftSentinel || !rightSentinel) return;
119+
120+
const observer = new IntersectionObserver(
121+
(entries) => {
122+
for (const entry of entries) {
123+
if (!entry.isIntersecting) continue;
124+
if (entry.target === leftSentinel) loadPrevious();
125+
if (entry.target === rightSentinel) loadNext();
126+
}
127+
},
128+
{ root: container, rootMargin: `0px ${pxPerDay}px` },
129+
);
130+
131+
observer.observe(leftSentinel);
132+
observer.observe(rightSentinel);
133+
return () => observer.disconnect();
134+
}, [loadPrevious, loadNext, pxPerDay]);
135+
136+
// Today button direction
137+
const [todayDirection, setTodayDirection] = useState<"left" | "right" | null>(null);
138+
139+
useEffect(() => {
140+
const container = scrollRef.current;
141+
if (!container) return;
142+
143+
function onScroll() {
144+
const sl = container!.scrollLeft;
145+
const w = container!.clientWidth;
146+
147+
if (pinnedEntry && nowMs !== null) {
148+
const pinnedMs = pinnedEntry.time.getTime();
149+
setTodayDirection(pinnedMs < nowMs ? "right" : "left");
150+
} else if (nowPx !== null) {
151+
const nowVx = nowPx - sl;
152+
if (nowVx < 60) setTodayDirection("left");
153+
else if (nowVx > w - 10) setTodayDirection("right");
154+
else setTodayDirection(null);
155+
} else {
156+
setTodayDirection(null);
157+
}
158+
159+
// Clear pinned entry when it scrolls far out of view
160+
if (pinnedEntry) {
161+
const pinnedMs = pinnedEntry.time.getTime();
162+
const pinnedPx = ((pinnedMs - dataStart) / totalMs) * innerW + MARGIN.left;
163+
const pvx = pinnedPx - sl;
164+
if (pvx < -w || pvx > 2 * w) {
165+
setPinnedEntry(null);
166+
}
167+
}
168+
}
169+
170+
onScroll();
171+
container.addEventListener("scroll", onScroll, { passive: true });
172+
return () => container.removeEventListener("scroll", onScroll);
173+
}, [nowPx, nowMs, pinnedEntry, dataStart, totalMs, innerW]);
174+
175+
// Scroll to now handler
176+
const scrollToNow = useCallback(() => {
177+
setPinnedEntry(null);
178+
const container = scrollRef.current;
179+
if (!container) return;
180+
const nowPx = ((Date.now() - dataStart) / totalMs) * innerW + MARGIN.left;
181+
container.scrollTo({ left: nowPx - container.clientWidth / 2, behavior: "smooth" });
182+
}, [dataStart, totalMs, innerW]);
183+
184+
if (isLoading && !timeline.length) {
185+
return (
186+
<div className={`p-4 text-center text-sm text-(--neaps-text-muted) ${className ?? ""}`}>
187+
Loading tide data...
188+
</div>
189+
);
190+
}
191+
192+
if (error && !timeline.length) {
193+
return (
194+
<div className={`p-4 text-center text-sm text-red-500 ${className ?? ""}`}>
195+
{error.message}
196+
</div>
197+
);
198+
}
13199

14200
return (
15-
<TideGraphScroll
16-
id={props.id}
17-
pxPerDay={props.pxPerDay ?? PX_PER_DAY_DEFAULT}
18-
locale={config.locale}
19-
className={props.className}
20-
/>
201+
<div className={className}>
202+
<div className="relative overflow-hidden border border-(--neaps-border) rounded-md">
203+
{/* Scrollable chart area */}
204+
<div
205+
ref={scrollRef}
206+
className="overflow-x-auto scrollbar-hide"
207+
tabIndex={0}
208+
role="region"
209+
aria-label="Tide level graph, scrollable"
210+
>
211+
<div className="relative">
212+
{/* Left sentinel */}
213+
<div ref={leftSentinelRef} className="absolute top-0 w-px h-px left-0" />
214+
215+
<TideGraphChart
216+
timeline={timeline}
217+
extremes={extremes}
218+
timezone={timezone}
219+
units={units}
220+
svgWidth={svgWidth}
221+
yDomainOverride={yDomain}
222+
latitude={station?.latitude}
223+
longitude={station?.longitude}
224+
activeEntry={activeEntry}
225+
onSelect={handleSelect}
226+
/>
227+
228+
{/* Right sentinel */}
229+
<div ref={rightSentinelRef} className="absolute right-0 top-0 w-px h-px" />
230+
</div>
231+
232+
{/* Edge loading indicators */}
233+
{isLoadingPrevious && (
234+
<div className="absolute left-16 top-1/2 -translate-y-1/2 text-xs text-(--neaps-text-muted)">
235+
Loading...
236+
</div>
237+
)}
238+
{isLoadingNext && (
239+
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-(--neaps-text-muted)">
240+
Loading...
241+
</div>
242+
)}
243+
</div>
244+
245+
{/* Right edge fade */}
246+
<div className="absolute top-0 bottom-0 w-10 right-0 pointer-events-none bg-linear-to-l from-(--neaps-bg) to-transparent" />
247+
248+
{/* Y-axis overlay (fixed left) */}
249+
<YAxisOverlay yScale={yScale} narrowRange={narrowRange} unitSuffix={unitSuffix} />
250+
251+
{/* Today button — fades in when now is off-screen or a point is pinned */}
252+
<button
253+
type="button"
254+
onClick={scrollToNow}
255+
className={`absolute px-2 py-1 text-xs font-medium rounded-md border border-(--neaps-border) bg-(--neaps-bg) text-(--neaps-text-muted) hover:text-(--neaps-text) hover:border-(--neaps-primary) cursor-pointer transition-all duration-300 ${todayDirection ? "opacity-100" : "opacity-0 pointer-events-none"} ${todayDirection === "left" ? "left-16" : "right-2"}`}
256+
style={{ top: MARGIN.top }}
257+
aria-label="Scroll to current time"
258+
>
259+
{todayDirection === "left" ? "\u2190 Now" : "Now \u2192"}
260+
</button>
261+
</div>
262+
</div>
21263
);
22264
}

0 commit comments

Comments
 (0)