Three Small Atoms
Some components are so small they seem beneath documentation — until you misuse one and lose an afternoon. These three atoms appear all over the feature code, and each hides a behavior you can't guess from the call site: a snackbar with two personalities, a lazy renderer nothing actually uses yet, and three fade wrappers with three different ideas of "go away." Meet them properly once, here.
Notification — one snackbar, two ways to drive it
components/atoms/Notification/Notification.tsx is a thin MUI Snackbar. Exactly one instance
is mounted with no props in app/layout.tsx; that instance reads notificationStore, and
anything in the app can fire it:
import { useNotificationStore } from '@/stores/notificationStore';
// Inside a component
const { showNotification } = useNotificationStore(s => ({
showNotification: s.showNotification,
}));
// Inside a callback, effect, or plain async function
useNotificationStore
.getState()
.showNotification('Error loading cameras', 'error');
The store is three fields (message, severity, open) and two actions (showNotification,
closeNotification). severity is 'error' | 'success' | 'info' — the component's prop type
also accepts 'warning', but the store cannot produce it.
The second mode is local: pass open explicitly and the instance ignores the store entirely.
That is how ImageActions shows copy/download feedback scoped to the image viewer, and how
GraphicNotification puts a snackbar inside the map. Per field, a prop that is not undefined
wins over the store value; severity uses first-truthy rather than !== undefined.
Two props are worth knowing before you fight the z-index: displayInline skips the
createPortal into document.body (needed when the snackbar must position relative to a
parent, e.g. the map), and closeIconPosition moves the ✕ from left to right. Default
autoHideDuration is 15 seconds. Pass children instead of message for rich content.
LazyLoader — render only what is near the viewport
components/atoms/LazyLoader/LazyLoader.tsx renders a placeholder <div> sized by the parent
grid, watches it with an IntersectionObserver, and mounts children when it comes within
rootMargin (default 300px) of the viewport. With the default keepMounted, the observer
disconnects on first intersection and the child stays mounted forever, so component state
survives scrolling.
It exists because mounting a thousand tiles at once freezes the main thread — each
CameraDetails runs ten-plus hooks, subscribes to three stores, and sets up its own observer.
| Prop | Default | Notes |
|---|---|---|
children | required | ReactElement, or a () => JSX.Element that isn't called until visible |
rootMargin | '300px' | how far ahead to mount |
threshold | 0 | passed straight to the observer |
placeholderProps | — | spread onto the placeholder; use for data-* an outer observer tracks |
placeholderStyle | grey rounded box | merged over the default |
placeholderClassName | — | applies to the placeholder and the mounted wrapper |
keepMounted | true | false unmounts children on scroll-away — memory over state |
data-testid | 'lazy-loader-placeholder' | on the placeholder only |
The render-function form matters when even building the element tree is expensive:
<LazyLoader>{() => <ExpensiveTree data={data} />}</LazyLoader>. With keepMounted, note that
placeholderProps and placeholderClassName are re-applied to the wrapper around the mounted
children, so an external observer keeps a stable element to track — but placeholderStyle is
not.
Fade containers — three triggers, one visual
Three atoms wrap children and hide them on a trigger. All three take
divRefToWatchForMovement (the element being watched) and disable.
| Component | Trigger | Fades out? |
|---|---|---|
FadeAfterInactivityContainer | mouse idle inside the watched div | yes, 400 ms |
FadeAfterExitContainer | cursor leaves the watched div's bounding box | yes, 400 ms |
FadeAfterClickContainer | any click inside the watched div toggles | no — instant |
The two that fade use the same two-step hide: set the fading flag so CSS transitions opacity
to 0 over 400 ms, then unmount 300 ms later. FadeAfterClickContainer flips visibility
directly and explicitly clears its fading flag, so nothing animates — the extra state is vestigial.
FadeAfterInactivityContainer reads its timeout from configStore.fto, falling back to 5000 ms,
so it is tunable per environment without a deploy. It and FadeAfterExitContainer both call
useScreenWidth and short-circuit to always-visible on mobile or landscape.
FadeAfterClickContainer has no screen-size awareness at all — on mobile it keeps toggling,
which is exactly what MapButtonsFL wants from it, since that is the mobile-side wrapper.
FadeAfterExitContainer does a getBoundingClientRect() check on a document-level mousemove
rather than listening for mouseleave. That is deliberate: mouseleave fires spuriously when
the cursor crosses into a child element, and the map controls are full of children. It also
starts hidden on desktop — children appear only once the cursor enters — while the other two
start visible.
Next: camera filtering and sorting, which is where the tile grid's rendering pressure actually comes from.