Départ, arrivée et lumière du jour — bouton flottant de l’heure d’arrivée et son panneau, où un ruban montre la journée et la sortie posée dessus, à l’heure du pays de la trace.
stableÀ valider
// Titre : Bouton de l’heure d’arrivée, sortie de 4 h 30
import DayPlan from '@pathou/components/DayPlan';
import { base } from '../_fixtures/base';
export default function Exemple() {
return (
<div style={{ position: 'relative', width: 380, minHeight: 80 }}>
<DayPlan base={base} hours={4.5} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDismiss } from '../hooks/useDismiss';
import { usePresence } from '../hooks/usePresence';
import type { BaseAnalysis } from '../lib/analyze';
import { regionAt } from '../lib/sources/regions';
import { load, save } from '../lib/storage';
import { clock, parseClock, sunTimes, timeZoneOf, wallClock, zonedMs } from '../lib/sun';
import { formatHours } from '../lib/time';
const DEPART_KEY = 'trace3d.depart';
const DEFAULT_DEPART = '09:00';
/** heures valides en mémoire (HH:MM) */
const MINUTES = Array.from({ length: 24 * 60 }, (_, k) => `${String(Math.floor(k / 60)).padStart(2, '0')}:${String(k % 60).padStart(2, '0')}`);
const H = 3600000;
const MIN = 60000;
const dayFmt = new Intl.DateTimeFormat('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' });
const dayOf = (iso: string) => new Date(`${iso}T12:00:00Z`);
const addDays = (iso: string, n: number) => new Date(dayOf(iso).getTime() + n * 86400000).toISOString().slice(0, 10);
const toMinute = (ms: number) => Math.round(ms / MIN) * MIN;
/** « 45 min », « 3 h 32 » */
const duration = (ms: number) => {
const m = Math.round(Math.abs(ms) / MIN);
return m < 60 ? `${m} min` : formatHours(m / 60).replace(/ /g, ' ');
};
const at = (ms: number, tz?: string) => clock(wallClock(ms, tz)).replace(/ /g, ' ');
type State = 'ok' | 'tight' | 'late';
/**
* Départ, arrivée et lumière du jour : bouton flottant de la carte (l'heure d'arrivée) et son panneau, où un ruban
* montre la journée (nuit, jour du lever au coucher) et la sortie posée dessus, du départ à l'arrivée en roulant sans pause.
* Heures du pays de la trace ; le départ (heure) est mémorisé, le jour repart d'aujourd'hui.
*/
export default function DayPlan({ base, hours }: { base: BaseAnalysis; hours: number }) {
const first = base.samples[0];
const end = base.samples[base.samples.length - 1];
const [tzStart, tzEnd] = useMemo(
() => [timeZoneOf(regionAt(first.lat, first.lon), first.lon), timeZoneOf(regionAt(end.lat, end.lon), end.lon)],
[first, end],
);
const today = useMemo(() => wallClock(Date.now(), tzStart).date, [tzStart]);
const [time, setTime] = useState(() => load(DEPART_KEY, MINUTES, DEFAULT_DEPART));
const [date, setDate] = useState(today);
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
useDismiss(open, root, () => setOpen(false));
const panel = usePresence(open, 150);
const menu = useRef<HTMLDivElement>(null);
// Sous son bouton, comme « Contenu de la carte ». Sur ordinateur, décalé vers la gauche s'il passerait sous le
// panneau du verdict (à droite de la carte) ; sur mobile, il prend toute la largeur (styles.css).
const [shift, setShift] = useState(0);
useLayoutEffect(() => {
if (!panel.mounted) return;
const place = () => {
const el = menu.current;
const wrap = root.current?.closest('.map-wrap');
if (!el || !wrap || !root.current || !window.matchMedia('(min-width: 901px)').matches) return setShift(0);
const btn = root.current.getBoundingClientRect();
const area = wrap.getBoundingClientRect();
const side = parseFloat(getComputedStyle(wrap).getPropertyValue('--side-inset')) || 0;
const over = btn.left + el.offsetWidth - (area.right - side - 10);
setShift(over > 0 ? -Math.min(over, btn.left - area.left - 10) : 0);
};
place();
// la carte change de taille (fenêtre, panneau du verdict, rotation du téléphone) : le panneau se recale
const wrap = root.current?.closest('.map-wrap');
const ro = new ResizeObserver(place);
if (wrap) ro.observe(wrap);
return () => ro.disconnect();
}, [panel.mounted]);
const depart = zonedMs(date, time, tzStart);
// à la minute, comme le temps estimé du profil : 9 h 00 + 4 h 02 = 13 h 02
const arrive = depart + Math.round(hours * 60) * MIN;
const arriveDay = wallClock(arrive, tzEnd).date;
const sunStart = sunTimes(zonedMs(date, '12:00', tzStart), first.lat, first.lon);
const sunEnd = sunTimes(zonedMs(arriveDay, '12:00', tzEnd), end.lat, end.lon);
const sunset = sunEnd && toMinute(sunEnd.set);
const margin = sunset === null ? null : sunset - arrive;
const state: State = margin === null || margin >= H ? 'ok' : margin >= 0 ? 'tight' : 'late';
const nextDay = arriveDay !== date;
const dayLabel = date === today ? 'aujourd’hui' : date === addDays(today, 1) ? 'demain' : dayFmt.format(dayOf(date));
const ride = Math.round(hours * 60) * MIN;
const sunsetAt = sunset === null ? '' : at(sunset, tzEnd);
/** dernier départ (heure murale du départ) qui laisse `keep` de jour à l'arrivée */
const latest = (keep: number) => at(sunset! - ride - keep, tzStart);
// trop longue pour la journée : même au lever du soleil, on finirait de nuit
const tooLong = sunset !== null && sunStart !== null && sunset - ride < toMinute(sunStart.rise);
const summary =
margin === null
? 'Le soleil ne se couche pas ou ne se lève pas ce jour-là à cette latitude.'
: margin >= H
? `Il te reste ${duration(margin)} de jour pour tes pauses\u00a0: le soleil se couche à ${sunsetAt}.`
: margin >= 0
? `Il ne te reste que ${duration(margin)} de jour pour tes pauses\u00a0: le soleil se couche à ${sunsetAt}. Pars avant ${latest(H)} pour en garder une heure.`
: tooLong
? `Tu arriverais de nuit, même en partant au lever du soleil (${at(toMinute(sunStart!.rise), tzStart)})\u00a0: prévois un éclairage ou coupe la sortie en deux.`
: `Tu arriverais ${duration(margin)} après le coucher du soleil (${sunsetAt}). Pour arriver de jour, même sans pause, pars avant ${latest(0)}.`;
const stateText = state === 'late' ? ', après le coucher du soleil' : state === 'tight' ? ', moins d’une heure avant le coucher du soleil' : '';
return (
<div ref={root} className="map-content day-plan">
<button
className={`map-fab day-fab day-${state}${open ? ' open' : ''}`}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls="day-plan-menu"
// nom lu = texte visible (« Arrivée 13 h 02 », « Arrivée » masqué sur mobile), puis l'état de la marge
aria-label={`Arrivée ${at(arrive, tzEnd)}${stateText}`}
title="Choisir l’heure de départ et voir la marge avant la nuit"
>
<SunIcon />
<span className="map-fab-label">Arrivée</span>
<span className="day-fab-time">{at(arrive, tzEnd)}</span>
{state !== 'ok' && <span className="day-fab-dot" aria-hidden="true" />}
</button>
{panel.mounted && (
<div
ref={menu}
id="day-plan-menu"
className={`map-menu day-menu${panel.closing ? ' closing' : ''}`}
style={shift ? { left: shift } : undefined}
inert={panel.closing}
role="dialog"
aria-label="Heure de départ et d’arrivée"
>
<div className="map-menu-head">
<h2 className={`day-title day-${state}`}>
Arrivée vers {at(arrive, tzEnd)}
{nextDay && <small> le {dayFmt.format(dayOf(arriveDay))}</small>}
</h2>
<button className="map-menu-close" onClick={() => setOpen(false)} aria-label="Fermer">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
<div className="day-fields">
<DepartInput
value={time}
onChange={(t) => {
setTime(t);
save(DEPART_KEY, t);
}}
/>
<label>
<span>Jour du départ</span>
<input type="date" value={date} min={today} onChange={(e) => e.target.value && setDate(e.target.value)} aria-describedby="day-plan-day" />
<small id="day-plan-day">{dayLabel}</small>
</label>
</div>
<DayRibbon depart={depart} arrive={arrive} days={[sunStart, ...(nextDay ? [sunEnd] : [])]} state={state} tzStart={tzStart} tzEnd={tzEnd} />
<div className="day-text">
<p className={`day-summary day-${state}`}>
{/* même pastille que le bouton : orange sous une heure de marge, rouge après le coucher */}
{state !== 'ok' && <span className="day-summary-dot" aria-hidden="true" />}
{summary}
</p>
<p className="day-note">
Calculé sur {formatHours(ride / H)} en roulant, sans pause, d’après ton niveau et ta vitesse.{' '}
{tzEnd ? 'Heures locales.' : 'Heures de ton appareil\u00a0: le fuseau horaire de l’arrivée n’est pas connu.'}
</p>
</div>
</div>
)}
</div>
);
}
const shown = (t: string) => clock({ h: Number(t.slice(0, 2)), m: Number(t.slice(3)) });
/**
* Heure de départ saisie au clavier, comme la vitesse du profil : écrite librement (« 9h30 », « 930 »…), retenue
* à Entrée ou en quittant le champ ; une saisie illisible est signalée, puis abandonnée en quittant le champ.
*/
function DepartInput({ value, onChange }: { value: string; onChange: (t: string) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const hint = useId();
const parsed = draft === null ? value : parseClock(draft);
const invalid = draft !== null && parsed === null;
const commit = () => {
if (parsed && parsed !== value) onChange(parsed);
setDraft(null);
};
return (
<label>
<span>Heure de départ</span>
<input
className={`day-time${invalid ? ' invalid' : ''}`}
value={draft ?? shown(value)}
inputMode="decimal"
autoComplete="off"
spellCheck={false}
aria-invalid={invalid}
aria-describedby={hint}
onFocus={(e) => {
setDraft(shown(value));
e.target.select();
}}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') {
// Échap annule la saisie sans fermer le panneau
e.stopPropagation();
setDraft(null);
e.currentTarget.blur();
}
}}
onBlur={commit}
/>
{/* ligne d'aide toujours là (comme « aujourd'hui » sous le jour) : l'erreur la remplace sans décaler le panneau */}
<small id={hint} className={invalid ? 'day-error' : undefined}>
{invalid ? (
<>
Heure non reconnue<span className="sr-only">, écris par ex. 9 h 30</span>
</>
) : (
'ex. 9 h 30'
)}
</small>
</label>
);
}
function SunIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 17a5 5 0 0 1 10 0" />
<path d="M3 17h18M12 7V5M5.6 10.6 4.2 9.2M18.4 10.6l1.4-1.4M8 21h8" />
</svg>
);
}
/**
* La journée à l'échelle : nuit en fond, jour du lever au coucher, la sortie posée dessus. La part de la sortie
* après le coucher passe en rouge ; l'écart entre l'arrivée et le coucher, c'est la marge pour les pauses.
*/
function DayRibbon({
depart,
arrive,
days,
state,
tzStart,
tzEnd,
}: {
depart: number;
arrive: number;
days: ({ rise: number; set: number } | null)[];
state: State;
tzStart?: string;
tzEnd?: string;
}) {
const suns = days.filter((d): d is { rise: number; set: number } => d !== null).map((d) => ({ rise: toMinute(d.rise), set: toMinute(d.set) }));
// de l'heure pleine avant le lever (ou le départ) à l'heure pleine après le coucher (ou l'arrivée)
const lo = Math.floor((Math.min(depart, ...suns.map((s) => s.rise)) - 30 * MIN) / H) * H;
const hi = Math.ceil((Math.max(arrive, ...suns.map((s) => s.set)) + 30 * MIN) / H) * H;
const x = (ms: number) => `${(((ms - lo) / (hi - lo)) * 100).toFixed(2)}%`;
const w = (a: number, b: number) => `${(((b - a) / (hi - lo)) * 100).toFixed(2)}%`;
const lastSet = suns.length ? suns[suns.length - 1].set : null;
const dark = lastSet !== null && arrive > lastSet ? Math.max(depart, lastSet) : null;
const first = suns[0];
return (
<div className="day-ribbon" role="img" aria-label={`Journée : sortie de ${at(depart, tzStart)} à ${at(arrive, tzEnd)}${first ? `, soleil levé à ${at(first.rise, tzStart)} et couché à ${at(lastSet!, tzEnd)}` : ''}.`}>
<div className="day-track">
{suns.map((s) => (
<span key={s.rise} className="day-light" style={{ left: x(s.rise), width: w(s.rise, s.set) }} />
))}
<span className="day-ride" style={{ left: x(depart), width: w(depart, dark ?? arrive) }} />
{dark !== null && <span className="day-ride day-ride-dark" style={{ left: x(dark), width: w(dark, arrive) }} />}
{state !== 'late' && lastSet !== null && arrive < lastSet && <span className={`day-gap day-${state}`} style={{ left: x(arrive), width: w(arrive, lastSet) }} />}
</div>
<div className="day-scale" aria-hidden="true">
{first && (
<span className="day-tick" style={{ left: x(first.rise) }}>
Lever {at(first.rise, tzStart)}
</span>
)}
{lastSet !== null && (
<span className="day-tick day-tick-end" style={{ left: x(lastSet) }}>
Coucher {at(lastSet, tzEnd)}
</span>
)}
</div>
</div>
);
}
Tokens : --bg --panel --text --muted --line --accent --chip --shadow --engage (non déterminé) --exigeant --ease-out
Utilisation
import DayPlan from './components/DayPlan';
<DayPlan base={base} hours={rider.hours} />Exemples
Panneau ouvert
// Titre : Panneau ouvert
import DayPlan from '@pathou/components/DayPlan';
import Clicked from '../../src/preview/Clicked';
import { base } from '../_fixtures/base';
// <Clicked> ouvre le panneau au chargement de l’aperçu ; dans Pathou, c’est le clic sur le bouton
export default function Exemple() {
return (
<div style={{ position: 'relative', width: 420, minHeight: 420 }}>
<Clicked selector=".day-fab">
<DayPlan base={base} hours={4.5} />
</Clicked>
</div>
);
}
Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Bouton | L’heure d’arrivée estimée, et une pastille d’état. |
| 2 | Champs | Heure et jour de départ ; l’heure est mémorisée, le jour repart d’aujourd’hui. |
| 3 | Ruban | La journée (nuit, jour du lever au coucher) et la sortie posée dessus. |
États
stateRapport entre la sortie et la lumière du jour, qui colore le bouton et le ruban.Guidelines
- MUSTCalculer les heures dans le fuseau du pays de la trace, pas celui du navigateur.
- SHOULDDire que la durée est en roulant, sans pause.
Clavier
| Touche | Effet |
|---|---|
| Entrée | Valide l’heure saisie. |
| Échap | Annule la saisie. |
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
hours | number | requis |