DetourBlock

←→

Carte d’une alternative à un point d’attention difficile — praticable, moins dure ou en cours de recherche — avec son écart de distance et de dénivelé, et le bouton pour l’appliquer à la trace.

stableÀ valider
// Titre : Alternative praticable, applicable à la trace
import { useState } from 'react';
import DetourBlock from '@pathou/components/DetourBlock';
import type { DetourFound } from '@pathou/lib/detour/result';

// distances cumulées de la trace (m) : seules celles des points de départ et d’arrivée servent ici
const d = Array.from({ length: 200 }, (_, i) => i * 100);
const found: DetourFound = {
  status: 'praticable',
  path: [],
  ele: [],
  leaveIdx: 124,
  rejoinIdx: 131,
  extraM: 380,
  extraGain: 22,
  mainClass: 'chemin_naturel',
  hardLeftM: 0,
  worst: 'bleu',
  approxRelief: false,
  alsoAvoids: [],
};

export default function Exemple() {
  const [applied, setApplied] = useState(false);
  return (
    <div style={{ width: 340, ['--sev' as string]: 'var(--exigeant)' }}>
      <DetourBlock
        r={found}
        d={d}
        shown
        apply={{ canApply: true, applied, onApply: () => (setApplied(true), { id: 'a', replacedKm: [] }), onRemove: () => setApplied(false) }}
      />
    </div>
  );
}

Installation

Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.

src/components/DetourBlock.tsx
import { useState } from 'react';
import type { DetourResult } from '../lib/detour/result';
import { formatDelta } from '../lib/detour/result';
import { RATING_COLOR, WAY_COLOR } from '../lib/colors';
import { RATING_LABEL } from '../lib/difficulty/rules';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import { fmt } from '../lib/format';
import DetourGlyph from './DetourGlyph';

export const fmtLen = (m: number) => (m >= 995 ? `${fmt(m / 1000, 1)} km` : `${Math.max(10, Math.round(m / 10) * 10)} m`);

export const WalkIcon = () => (
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <circle cx="13" cy="4" r="1" />
    <path d="M7 21l3-4M16 21l-2-4-3-3 1-6M6 12l2-3 4-1 3 3 3 1" />
  </svg>
);

/** Appliquer une alternative à la trace, ou la retirer tant que la réanalyse ne l'a pas intégrée. */
export interface DetourApply {
  /** faux : ne se rattache pas à l'origine (démarre sur une alternative appliquée) */
  canApply: boolean;
  applied: boolean;
  onApply: () => { id: string; replacedKm: number[] } | null;
  onRemove: () => void;
}

/** Alternative d'un point d'attention dur : trouvée, moins dure ou en cours ; rien s'il n'y en a pas. `shown` : elle est tracée sur la carte. */
export default function DetourBlock({ r, d, shown, apply }: { r: DetourResult; d: number[]; shown: boolean; apply?: DetourApply }) {
  const [note, setNote] = useState<string | null>(null);
  if (r.status === 'loading') return <div className="detour ld">Recherche d’une alternative…</div>;
  if (r.status === 'timeout') return <div className="detour ld">Recherche d’alternative abandonnée (trop longue)</div>;
  // sans alternative, rien à montrer : la carte du passage dit déjà la distance à pied
  if (r.status === 'none') return null;
  const gain = Math.round(r.extraGain);
  const from = fmt(d[Math.min(r.leaveIdx, r.rejoinIdx)] / 1000, 1);
  const to = fmt(d[Math.max(r.leaveIdx, r.rejoinIdx)] / 1000, 1);
  return (
    <div className={`detour ${r.status}${shown ? ' on' : ''}${apply?.applied ? ' applied' : ''}`}>
      <div className="detour-top">
        <DetourGlyph kind={r.status} worst={r.worst} />
        <div className="detour-head">
          {r.status === 'praticable' ? 'Alternative praticable' : 'Alternative moins dure'}
          <small>
            du km {from} au km {to}
          </small>
        </div>
        {apply && <ApplyButton apply={apply} onReplaced={setNote} />}
      </div>
      <div className="detour-stats">
        <div>
          <b>{formatDelta(r.extraM)}</b>
          <span>distance</span>
        </div>
        <div>
          <b>
            {gain < 0 ? '−' : '+'}
            {Math.abs(gain)} m
          </b>
          <span>D+</span>
        </div>
        <span className="detour-chip">
          <i style={{ background: WAY_COLOR[r.mainClass] }} />
          {r.mainClass === 'route' ? 'Route' : WAY_CLASS_LABEL[r.mainClass]}
        </span>
        {r.status === 'moins-dur' && (
          <span className="detour-chip">
            <i style={{ background: r.worst === 'noir' ? 'var(--text)' : RATING_COLOR[r.worst] }} />
            {RATING_LABEL[r.worst]} sur {fmtLen(r.hardLeftM)}
          </span>
        )}
      </div>
      {(r.approxRelief || r.alsoAvoids.length > 0 || note || (apply && !apply.canApply && !apply.applied)) && (
        <div className="detour-note">
          {r.alsoAvoids.length > 0 && <div>Évite aussi le passage du km {r.alsoAvoids.map((k) => fmt(k, 1)).join(', ')}</div>}
          {r.approxRelief && <div>Relief approximatif&nbsp;: altitude précise indisponible.</div>}
          {apply && !apply.canApply && !apply.applied && <div>Démarre sur une alternative déjà appliquée&nbsp;: retire-la pour appliquer celle-ci.</div>}
          {note && apply?.applied && <div>{note}</div>}
        </div>
      )}
    </div>
  );
}

/** Bouton rond du coin de la carte : ajoute l'alternative à la trace, puis la retire. Le clic ne sélectionne pas la carte du point d'attention. */
function ApplyButton({ apply, onReplaced }: { apply: DetourApply; onReplaced: (note: string | null) => void }) {
  // juste après le clic, la coche reste visible sous le pointeur : le × de retrait n'apparaît qu'au survol suivant
  const [fresh, setFresh] = useState(false);
  const blocked = !apply.canApply && !apply.applied;
  const label = apply.applied ? 'Retirer l’alternative de la trace' : blocked ? 'Impossible d’appliquer : démarre sur une alternative déjà appliquée' : 'Appliquer l’alternative à la trace';
  const click = (e: React.MouseEvent) => {
    e.stopPropagation();
    setFresh(true);
    if (apply.applied) {
      onReplaced(null);
      return apply.onRemove();
    }
    const r = apply.onApply();
    onReplaced(r && r.replacedKm.length > 0 ? `Remplace l’alternative du km ${r.replacedKm.map((k) => fmt(k, 1)).join(', ')}` : null);
  };
  return (
    <button type="button" className={`detour-apply${fresh ? ' fresh' : ''}`} onMouseLeave={() => setFresh(false)} aria-pressed={apply.applied} aria-label={label} title={label} disabled={blocked} onClick={click}>
      <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        {apply.applied ? (
          <>
            <path className="ic-on" d="M3.5 8.5l3 3 6-7" />
            <path className="ic-off" d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
          </>
        ) : (
          <path d="M8 3v10M3 8h10" />
        )}
      </svg>
    </button>
  );
}

Tokens : --panel --text --muted --detour --detour-ink --accent

Utilisation

import DetourBlock from './components/DetourBlock';

<DetourBlock r={detours[key]} d={base.d} shown={isSelected} apply={applyFor(found, alert)} />

Exemples

Alternative moins dure, qui évite aussi un autre passage

// Titre : Alternative moins dure, qui évite aussi un autre passage
import DetourBlock from '@pathou/components/DetourBlock';
import type { DetourFound } from '@pathou/lib/detour/result';

const d = Array.from({ length: 400 }, (_, i) => i * 100);
const found: DetourFound = {
  status: 'moins-dur',
  path: [],
  ele: [],
  leaveIdx: 210,
  rejoinIdx: 236,
  extraM: 1400,
  extraGain: 85,
  mainClass: 'piste_cassante',
  hardLeftM: 180,
  worst: 'rouge',
  approxRelief: false,
  alsoAvoids: [24.1],
};

export default function Exemple() {
  return (
    <div style={{ width: 340, ['--sev' as string]: 'var(--inadapte)' }}>
      <DetourBlock r={found} d={d} shown={false} />
    </div>
  );
}

Recherche en cours

// Titre : Recherche en cours
import DetourBlock from '@pathou/components/DetourBlock';

export default function Exemple() {
  return (
    <div style={{ width: 340 }}>
      <DetourBlock r={{ status: 'loading' }} d={[]} shown={false} />
    </div>
  );
}

Anatomie

#ÉlémentRôle
1En-têteLe pictogramme, le type d’alternative et la portion contournée (du km … au km …).
2AppliquerBouton rond à bascule (aria-pressed), nommé selon l’action ; son clic ne sélectionne pas la carte du point d’attention.
3BilanÉcart de distance, écart de D+, type de voie principal et, pour « moins dure », la cotation restante.

États

statusRésultat de la recherche d’alternative (r.status).
appliedL’alternative est appliquée à la trace ; le bouton devient une coche, puis une croix au survol pour la retirer.

Guidelines

  • MUST
    Ne rien afficher quand il n’y a pas d’alternative ; la carte du passage dit déjà la distance à pied.
  • MUST
    Dire pourquoi une alternative ne peut pas s’appliquer (elle démarre sur une alternative déjà appliquée) au lieu de seulement désactiver le bouton.
  • SHOULD
    Signaler un relief approximatif (altitude précise indisponible) ; la vérification est alors moins fiable.

Référence de l’API

DetourBlock (export par défaut)

PropTypeDéfaut
rDetourResultrequis
dnumber[]requis
shownbooleanrequis
applyDetourApply—

WalkIcon

Aucune prop.