From d31d82da1de3224c89b148c9d6c1989a1f99f536 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:21:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(codex):=20add=20a=20R=C3=A9seau=20view?= =?UTF-8?q?=20drawing=20the=20codex=20as=20a=20two-bank=20constellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-slot bricks on the left bank, second-slot bricks on the right, one band per unlocked fascicule, alphabetical rows of 44 px; each found word is a curve across the river. The newest discovery is drawn in Corail, a Rosée bar under each brick gives its family progress, long links in the "Tous" view become counted antennas, a tapped brick opens its family in a sticky panel, and a list view says the same in text. The layout is a pure, deterministic function (services/constellation.ts): no overlap, curves never under a brick or a label, and a discovery only moves the bricks that follow it alphabetically. Only found bricks show; no legendary, no locked fascicule (ADR 0043, 0051, 0069). Closes #91 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 --- docs/roadmap.md | 1 + web/src/codex.css | 71 +++++ web/src/composants/Codex.tsx | 17 +- web/src/composants/Constellation.test.tsx | 47 +++ web/src/composants/Constellation.tsx | 356 ++++++++++++++++++++++ web/src/services/constellation.test.ts | 168 ++++++++++ web/src/services/constellation.ts | 318 +++++++++++++++++++ web/src/services/route.ts | 4 +- 8 files changed, 977 insertions(+), 5 deletions(-) create mode 100644 web/src/composants/Constellation.test.tsx create mode 100644 web/src/composants/Constellation.tsx create mode 100644 web/src/services/constellation.test.ts create mode 100644 web/src/services/constellation.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index b6ff7a0..905ffef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -91,6 +91,7 @@ Reportés : file d’attente des fusions hors connexion (supprimée, ADR 0041), | Ricochets, PWA : moins de touchers ([ADR 0068](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0068-ricochets-moins-de-touchers-pour-decouper-et-assembler.md)) : la lame de découpe arrive, et repart après chaque coupe, sur une jointure tirée au hasard qui n’est jamais une coupe attendue ; *Assembler* envoie la réponse à la seconde brique posée, sans « Valider » ; tests du composant `Assembler` | fait | | Ricochets, PWA : *Paires* envoie les réponses à la dernière paire reliée, sans « Valider les paires », comme *Découper* et *Assembler* ; la place du numéro de paire est réservée, les colonnes ne se redimensionnent plus à chaque paire reliée ; tests du composant `Paires` | fait | | « En savoir plus » (I-16) : chemin des langues, relations, transformations, certitude et sources citées ; dépliée depuis la carte du codex (`plus=1`) | fait (Content publie les types de relation et de transformation dans les référentiels) | +| Vue Réseau (App#91) : la constellation en deux rives (1ʳᵉ case à gauche, 2ᵉ à droite, une bande par fascicule débloqué, ordre alphabétique, rangées de 44 px), dernière découverte en Corail, barre de famille Rosée, antennes chiffrées dans la vue « Tous », famille suivie dans un panneau collé, liste en repli ; calculée côté client (`services/constellation.ts`), sans légendaire ni fascicule verrouillé | fait en local ; ADR et maquette à écrire dans Design | ### Exigence reportée à J6 diff --git a/web/src/codex.css b/web/src/codex.css index 1adbe33..1f44689 100644 --- a/web/src/codex.css +++ b/web/src/codex.css @@ -571,3 +571,74 @@ .cb-fasc-btn.is-locked .jk-mini { opacity: 0.45; } .cb-fasc-verrou { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; line-height: 1.3; } .cb-fasc-verrou .ic { flex: none; width: 16px; height: 16px; } + +/* ---------- Codex : vue Réseau, la constellation en deux rives (App#91) ---------- */ +.cst { display: grid; gap: 12px; min-width: 0; } +.cst-choix { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0 24px; min-width: 0; } +.cst-choix .cb-views { margin-bottom: 0; } +.cst-vues { flex: 1 1 260px; min-width: 0; } +.cst .cb-note { margin: 0; } +.cst-mot { font-family: var(--serif); } +/* Cadre à l'échelle 1 : il défile en hauteur avec la page, en largeur dans son propre cadre. */ +.cst-cadre { min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; border: 1.5px solid var(--line); border-radius: 14px; background: var(--paper); } +.cst-svg { display: block; } +.cst-filet { fill: none; stroke: var(--line-strong); stroke-opacity: 0.35; stroke-width: 1; } +.cst-titre { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-weight: 700; } +/* Courbes : neutres ; la dernière découverte en Corail (nouveauté) ; la famille suivie en Encre. */ +.cst-lien { fill: none; stroke: var(--ink-3); stroke-width: 1; } +.cst-lien.is-dernier { stroke: var(--c-action); stroke-width: 2.5; } +.cst-antenne { fill: none; stroke: var(--ink-3); stroke-width: 1.5; stroke-linecap: round; } +.cst-compte { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; } +.has-focus :is(.cst-lien, .cst-antenne) { stroke: var(--line); } +.has-focus .cst-compte { opacity: 0.45; } +.has-focus .cst-lien.is-famille { stroke: var(--ink); stroke-width: 2.5; } +/* Briques : toute la rangée est la cible. */ +.cst-n { cursor: pointer; outline: none; } +.cst-cible { fill: transparent; stroke: none; } +.cst-n:hover .cst-cible { fill: color-mix(in srgb, var(--ink) 6%, transparent); } +.cst-n:focus-visible .cst-cible { stroke: var(--ink); stroke-width: 2; } +.cst-ombre { fill: var(--pop-shadow); } +.cst-brique { fill: var(--c-prefix); stroke: var(--fixed-ink); stroke-width: 1.5; } +.cst-brique[data-kind="suffixe"] { fill: var(--c-suffix); } +/* Cartes de mot neutres : Encre sur Crème, de jour comme de nuit (comme .brick). */ +.cst-brique[data-kind="mot"] { fill: #fffbf3; } +.cst-etiquette { fill: var(--fixed-ink); font-family: var(--serif); font-weight: 700; pointer-events: none; } +.cst-rail { fill: var(--paper-2); stroke: var(--ink-3); stroke-width: 0.75; } +.cst-part { fill: var(--c-family); } +.cst-cpt { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; pointer-events: none; } +.cst-n.is-focus .cst-brique { stroke-width: 3; } +.cst-n.is-focus .cst-cible { fill: color-mix(in srgb, var(--ink) 8%, transparent); } +/* Hors famille : la brique pâlit, son étiquette reste lisible (Encre fixe sur couleur vive). */ +.cst-n.is-loin .cst-brique { fill-opacity: 0.45; stroke-opacity: 0.45; } +.cst-n.is-loin :is(.cst-ombre, .cst-rail, .cst-part) { opacity: 0.45; } +.cst-n.is-loin .cst-cpt { fill: var(--ink-3); } +/* Panneau de la famille suivie : collé en bas de l'écran, repliable. */ +.cst-panneau { position: sticky; bottom: 12px; z-index: 2; display: grid; gap: 8px; padding: 6px 10px; border: 1.5px solid var(--line-strong); border-radius: 14px; background: var(--cream); box-shadow: 4px 4px 0 var(--pop-shadow); } +.cst-tete { display: flex; align-items: center; gap: 8px; min-width: 0; } +.cst-deplier { display: flex; flex: 1 1 auto; align-items: center; gap: 8px; min-width: 0; min-height: 48px; padding: 4px 6px; border: 0; border-radius: 10px; background: transparent; color: var(--ink); cursor: pointer; font: inherit; font-size: 14px; text-align: left; } +.cst-deplier:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); } +.cst-deplier:focus-visible, .cst-partenaire:focus-visible, .cst-lien-mot:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; } +.cst-resume-fam { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 0 8px; min-width: 0; } +.cst-glose { font-family: var(--serif); } +.cst-nb { color: var(--ink-2); font-family: var(--mono); font-size: 12px; } +.cst-chevron { flex: none; width: 8px; height: 8px; margin: 0 6px; border-right: 2px solid var(--ink-3); border-bottom: 2px solid var(--ink-3); transform: rotate(45deg); transition: transform var(--dur-rapide) var(--ease-out); } +.cst-deplier[aria-expanded="true"] .cst-chevron { transform: rotate(-135deg); } +.cst-fermer { flex: none; min-height: 44px; padding: 6px 12px; } +.cst-mots { display: flex; flex-wrap: wrap; gap: 0 14px; max-height: 38vh; margin: 0; padding: 0 0 4px; overflow-y: auto; list-style: none; } +.cst-mots li { display: inline-flex; align-items: center; gap: 2px; } +.cst-lien-mot { min-height: 44px; padding: 0 4px; border: 0; border-radius: 8px; background: transparent; color: var(--ink); cursor: pointer; font-family: var(--serif); font-size: 15px; font-style: italic; text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; } +.cst-partenaire { display: inline-grid; place-items: center; min-width: 44px; min-height: 44px; padding: 0 6px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; } +.cst-partenaire:hover .bchip { box-shadow: 3px 3px 0 var(--pop-shadow); } +/* Liste en repli, qui sert aussi aux lecteurs d'écran. */ +.cst-liste { display: grid; gap: 18px; } +.cst-bande h3 { margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1.5px solid var(--line); font-family: var(--mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; } +.cst-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px 24px; } +.cst-familles { display: grid; align-content: start; gap: 10px; margin: 0; padding: 0; list-style: none; } +.cst-fam-tete { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } +.cst-barre { display: inline-block; width: 72px; height: 7px; overflow: hidden; border: 1px solid var(--ink-3); border-radius: 999px; background: var(--paper-2); } +.cst-barre i { display: block; height: 100%; background: var(--c-family); } +.cst-num { font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; } +.cst-mots-liste { margin: 0; color: var(--ink-2); } +.cst-mots-liste .cst-lien-mot { min-height: 32px; font-size: 14px; } +:root[data-mouvement="reduit"] .cst-chevron { transition: none; } +@media (prefers-reduced-motion: reduce) { .cst-chevron { transition: none; } } diff --git a/web/src/composants/Codex.tsx b/web/src/composants/Codex.tsx index 40a58c7..e582e02 100644 --- a/web/src/composants/Codex.tsx +++ b/web/src/composants/Codex.tsx @@ -1,4 +1,4 @@ -import { Fragment, cloneElement, createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Fragment, cloneElement, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { CERTITUDES, @@ -30,6 +30,8 @@ import { bandeau } from './bandeau' import { FormeRarete } from './Rarete' import { Filet, Galet } from './Maitrise' import { Jaquette, SelecteurFascicules, TitreFascicule } from './Fascicules' +import { Constellation } from './Constellation' +import { useDeblocage } from '../services/deblocage' type Tri = TriCodex const TRIS: [Tri, string][] = [ @@ -53,6 +55,7 @@ const VUES: [VueCodex, string][] = [ ['tout', 'Tout'], ['recentes', 'Récentes'], ['familles', 'Familles'], + ['reseau', 'Réseau'], ['cabinet', 'Cabinet'], ] @@ -947,6 +950,12 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang const lesFamilles = familles(entrees, jeu) const aCompleter = lesFamilles.filter((f) => f.trouves < f.mots.length) const stats = cabinet(entrees, jeu) + // Vue Réseau : seuls les fascicules débloqués y entrent (ADR 0069) ; sans contexte, tous les parus. + const deblocage = useDeblocage() + const debloques = useMemo( + () => new Set(jeu.fascicules.filter((f) => !deblocage || deblocage.debloques.has(f.manifeste.id)).map((f) => f.numero)), + [jeu, deblocage], + ) // Précédente et suivante : l'ordre de ce qui est affiché. const sequence = enRecherche @@ -955,7 +964,7 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang ? dernieres : vue.vue === 'familles' ? lesFamilles.flatMap((f) => f.mots) - : vue.vue === 'cabinet' + : vue.vue === 'cabinet' || vue.vue === 'reseau' ? [] : visibles.filter((e) => !replie(e)) @@ -1024,7 +1033,7 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang ) : ( <> - Vue {{ tout: 'globale', familles: 'des familles', cabinet: 'du cabinet' }[vue.vue]} + Vue {{ tout: 'globale', familles: 'des familles', reseau: 'en réseau', cabinet: 'du cabinet' }[vue.vue]} )} @@ -1141,6 +1150,8 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang ))} ) + ) : vue.vue === 'reseau' ? ( + ) : vue.vue === 'cabinet' ? (
diff --git a/web/src/composants/Constellation.test.tsx b/web/src/composants/Constellation.test.tsx new file mode 100644 index 0000000..f811f4f --- /dev/null +++ b/web/src/composants/Constellation.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import type { EntreeCodex } from '../services/codex' +import type { BriqueJeu, CatalogueJeu } from '../services/jeu' +import { Constellation } from './Constellation' + +const brique = (id: string, nature: BriqueJeu['nature'], libelle: string): BriqueJeu => + ({ id, uniteId: `u_${id}`, nature, forme: libelle, libelle, rarete: 'commune', glose: 'sens', langue: null, origine: null }) as BriqueJeu +const jeu = { + fascicules: [ + { numero: 1, manifeste: { id: 'fsc_1', nom: 'Rosette' } }, + { numero: 2, manifeste: { id: 'fsc_2', nom: 'Argile' } }, + ], + briques: new Map([brique('geo', 'prefixe', 'géo-'), brique('bio', 'prefixe', 'bio-'), brique('logie', 'suffixe', '-logie')].map((b) => [b.id, b])), + recettes: [ + { id: 'r1', motId: 'geologie', emplacements: [['geo'], ['logie']] }, + { id: 'r2', motId: 'biologie', emplacements: [['bio'], ['logie']] }, + ], +} as unknown as CatalogueJeu +const entrees = [ + { id: 'geologie', fascicule: 1, type: 'mot', libelle: 'géologie', trouvee: true, trouveeLe: '2026-10-01T08:00:00Z', legendaire: false }, + { id: 'biologie', fascicule: 1, type: 'mot', libelle: 'biologie', trouvee: false, trouveeLe: null, legendaire: false }, + { id: 'u_geo', fascicule: 1, type: 'prefixe', libelle: 'géo-', trouvee: true, legendaire: false }, + { id: 'u_bio', fascicule: 1, type: 'prefixe', libelle: 'bio-', trouvee: false, legendaire: false }, + { id: 'u_logie', fascicule: 1, type: 'suffixe', libelle: '-logie', trouvee: true, legendaire: false }, +] as unknown as EntreeCodex[] + +describe('Constellation', () => { + it('ne montre que les briques trouvées des fascicules débloqués, et suit une famille', () => { + render( {}} />) + expect(screen.getByRole('button', { name: /^1.·.Rosette$/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.queryByRole('button', { name: /^2.·.Argile$/ })).not.toBeInTheDocument() + expect(screen.getByText(/1 sur 2\s+mots dans Rosette/)).toBeInTheDocument() + expect(screen.queryByText('bio-')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /^-logie/ })) + expect(screen.getByRole('region', { name: 'Famille suivie' })).toHaveTextContent('1 mot formé sur 2') + }) + + it('offre une liste en repli, qui ouvre la carte d’un mot', () => { + const ouverts: string[] = [] + render( ouverts.push(id)} />) + fireEvent.click(screen.getByRole('button', { name: 'Liste' })) + fireEvent.click(screen.getAllByRole('button', { name: 'géologie' })[0]!) + expect(ouverts).toEqual(['geologie']) + }) +}) diff --git a/web/src/composants/Constellation.tsx b/web/src/composants/Constellation.tsx new file mode 100644 index 0000000..48afe57 --- /dev/null +++ b/web/src/composants/Constellation.tsx @@ -0,0 +1,356 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import { mouvementReduit } from '../services/animation' +import type { EntreeCodex } from '../services/codex' +import { + BARRE, + JEU_BARRE, + MARGE, + type Mesurer, + OMBRE, + PAS, + type VueReseau, + cle, + construireReseau, + derniere, + disposer, + estimer, + famille, + portee, + tracer, +} from '../services/constellation' +import type { CatalogueJeu } from '../services/jeu' +import { PetiteBrique } from './Brique' + +interface Props { + jeu: CatalogueJeu + entrees: EntreeCodex[] + /** Numéros des fascicules débloqués : un fascicule verrouillé n'entre jamais dans le réseau (ADR 0069). */ + debloques: Set + /** Ouvrir la carte d'un mot au codex. */ + onOuvrir: (id: string) => void +} + +const pluriel = (n: number, mot: string) => `${n} ${mot}${n > 1 ? 's' : ''}` +const formes = (n: number, total: number) => `${n} mot${n > 1 ? 's' : ''} formé${n > 1 ? 's' : ''} sur ${total}` + +/** Mesure au canvas, dans les polices de la page ; estimation hors navigateur. */ +function useMesure(): Mesurer { + return useMemo(() => { + const ctx = typeof document !== 'undefined' ? document.createElement('canvas').getContext?.('2d') : null + if (!ctx) return estimer + const css = getComputedStyle(document.documentElement) + const polices = { serif: css.getPropertyValue('--serif').trim() || 'serif', mono: css.getPropertyValue('--mono').trim() || 'monospace' } + const memo = new Map() + return (texte, taille, famille, gras) => { + const police = `${gras ? 700 : 400} ${taille}px ${polices[famille]}` + const k = `${police}|${texte}` + let l = memo.get(k) + if (l === undefined) { + ctx.font = police + l = ctx.measureText(texte).width + memo.set(k, l) + } + return l + } + }, []) +} + +/** + * Vue Réseau du codex (App#91) : la constellation en deux rives. Elle s'ouvre sur + * le dernier fascicule débloqué ; toucher une brique suit sa famille dans un + * panneau collé en bas ; une liste en repli dit la même chose en texte. + */ +export function Constellation({ jeu, entrees, debloques, onOuvrir }: Props) { + const reseau = useMemo(() => construireReseau(jeu, entrees, debloques), [jeu, entrees, debloques]) + // Par défaut, le dernier fascicule débloqué où le joueur a trouvé un mot : une vue vide n'apprend rien. + const avecMot = new Set(reseau.mots.filter((m) => m.trouve).map((m) => m.fascicule)) + const dernierFascicule = [...reseau.fascicules].reverse().find((f) => avecMot.has(f.numero))?.numero ?? reseau.fascicules.at(-1)?.numero ?? 'tous' + const [vue, setVue] = useState(null) + const [mode, setMode] = useState<'reseau' | 'liste'>('reseau') + const [focus, setFocus] = useState(null) + const [deplie, setDeplie] = useState(false) + const cadre = useRef(null) + const svg = useRef(null) + const [largeur, setLargeur] = useState(0) + const mesurer = useMesure() + + // Un fascicule qui se verrouille (réinitialisation) ou disparaît : retour au dernier débloqué. + const vueValide = vue !== null && (vue === 'tous' || reseau.fascicules.some((f) => f.numero === vue)) ? vue : dernierFascicule + const { mots, bandes } = useMemo(() => portee(reseau, vueValide), [reseau, vueValide]) + const dernier = derniere(mots) + const focusValide = focus && mots.some((m) => cle('g', m.gauche) === focus || cle('d', m.droite) === focus) ? focus : null + + useLayoutEffect(() => { + const el = cadre.current + if (!el) return + const lire = () => setLargeur(Math.floor(el.clientWidth)) + lire() + if (typeof ResizeObserver === 'undefined') return + const obs = new ResizeObserver(lire) + obs.observe(el) + return () => obs.disconnect() + }, [mode]) + + const L = useMemo(() => disposer(reseau, bandes, largeur || 360, vueValide === 'tous', mesurer), [reseau, bandes, largeur, vueValide, mesurer]) + const { liens, antennes } = useMemo(() => tracer(L, mots, dernier, focusValide), [L, mots, dernier, focusValide]) + + // Insertion : les briques déjà là qui changent de rangée glissent (240 ms), les nouvelles apparaissent. + const precedent = useRef<{ cle: string; positions: Map } | null>(null) + useLayoutEffect(() => { + const cleRendu = `${vueValide}|${L.largeur}` + const positions = new Map([...L.places].map(([k, p]) => [k, p.y])) + const avant = precedent.current + precedent.current = { cle: cleRendu, positions } + if (!avant || avant.cle !== cleRendu || mouvementReduit() || !svg.current) return + svg.current.querySelectorAll('.cst-n').forEach((n) => { + if (typeof n.animate !== 'function') return + const k = n.dataset.cle ?? '' + const y0 = avant.positions.get(k) + const y1 = positions.get(k) + if (y0 === undefined) n.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240, easing: 'ease-out' }) + else if (y1 !== undefined && y0 !== y1) n.animate([{ transform: `translateY(${y0 - y1}px)` }, { transform: 'none' }], { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }) + }) + }, [L, vueValide]) + + // Suivre une brique depuis le panneau : elle passe au centre et prend le focus. + const [aSuivre, setASuivre] = useState(null) + useEffect(() => { + if (!aSuivre || !svg.current) return + const n = svg.current.querySelector(`[data-cle="${CSS.escape(aSuivre)}"]`) + n?.scrollIntoView?.({ block: 'center', behavior: mouvementReduit() ? 'auto' : 'smooth' }) + n?.focus({ preventScroll: true }) + setASuivre(null) + }, [aSuivre]) + + const nomFascicule = (n: number) => reseau.fascicules.find((f) => f.numero === n)?.nom ?? `Fascicule ${n}` + const ou = vueValide === 'tous' ? 'dans les fascicules ouverts' : `dans ${nomFascicule(vueValide)}` + const nbBriques = bandes.reduce((t, b) => t + b.g.length + b.d.length, 0) + const totalVue = vueValide === 'tous' ? reseau.mots.length : reseau.mots.filter((m) => m.fascicule === vueValide).length + const voisins = new Set(focusValide ? mots.filter((m) => cle('g', m.gauche) === focusValide || cle('d', m.droite) === focusValide).flatMap((m) => [cle('g', m.gauche), cle('d', m.droite)]) : []) + const briqueDe = (k: string) => jeu.briques.get(k.slice(2)) + const choisir = (k: string) => { + setFocus(focusValide === k ? null : k) + setDeplie(false) + } + const suivre = (k: string) => { + setFocus(k) + setASuivre(k) + } + const vide = vueValide === 'tous' ? 'Aucun mot trouvé pour l’instant.' : `Aucun mot trouvé dans ${nomFascicule(vueValide)} pour l’instant : sa constellation commence à votre première découverte.` + + const familleFocus = focusValide ? famille(reseau, focusValide.slice(2), vueValide) : null + const briqueFocus = focusValide ? briqueDe(focusValide) : undefined + + return ( +
+
+
+ {reseau.fascicules.map((f) => ( + + ))} + +
+
+ + +
+
+ +

+ {vueValide === 'tous' ? `${pluriel(mots.length, 'mot')} trouvé${mots.length > 1 ? 's' : ''}` : `${mots.length} sur ${totalVue} mots ${ou}`} · {pluriel(nbBriques, 'brique')}. + {dernier && ( + <> + {' '} + Dernière découverte : {dernier.libelle}. + + )} +

+ + {mots.length === 0 ? ( +
+

{vide}

+
+ ) : mode === 'reseau' ? ( +
+ + + + + + {[...L.places].map(([k, p]) => { + const b = reseau.briques.get(p.brique) + if (!b) return null + const g = p.cote === 'g' + const { trouves, total } = famille(reseau, p.brique, vueValide) + const haut = p.yb - L.h / 2 + const yBarre = haut + L.h + OMBRE + JEU_BARRE + const classes = ['cst-n', focusValide === k ? 'is-focus' : '', focusValide && !voisins.has(k) ? 'is-loin' : ''].filter(Boolean).join(' ') + return ( + choisir(k)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + choisir(k) + } + }} + > + + + + + {b.libelle} + + + {trouves > 0 && } + {L.compteurs && ( + + {trouves}/{total} + + )} + + ) + })} + + +
+ ) : ( +
+ {bandes.map((bande) => ( +
+

+ {bande.numero} · {nomFascicule(bande.numero)} +

+
+ {(['g', 'd'] as const).map((cote) => ( +
    + {bande[cote].map((id) => { + const b = jeu.briques.get(id) + const { formes: siens, trouves, total } = famille(reseau, id, vueValide) + return ( +
  • +
    + {b && } + + + {trouves} sur {total} + +
    +

    + {siens.map((m, i) => ( + + {i > 0 && ', '} + + + ))} +

    +
  • + ) + })} +
+ ))} +
+
+ ))} +
+ )} + + {mode === 'reseau' && focusValide && familleFocus && briqueFocus && ( +
+
+ + +
+ +
+ )} +
+ ) +} diff --git a/web/src/services/constellation.test.ts b/web/src/services/constellation.test.ts new file mode 100644 index 0000000..4fe23bd --- /dev/null +++ b/web/src/services/constellation.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' + +import type { EntreeCodex } from './codex' +import { PAS, SEUIL_LOIN, cle, construireReseau, derniere, disposer, famille, portee, tracer } from './constellation' +import type { BriqueJeu, CatalogueJeu } from './jeu' + +// Catalogue fictif : deux fascicules, cinq préfixes, trois suffixes, une légendaire. +const brique = (id: string, nature: BriqueJeu['nature'], libelle: string, rarete: BriqueJeu['rarete'] = 'commune'): BriqueJeu => ({ + id, + uniteId: `u_${id}`, + nature, + forme: libelle.replace(/-/g, ''), + libelle, + rarete, + glose: null, + langue: null, + origine: null, +}) +const BRIQUES = [ + brique('geo', 'prefixe', 'géo-'), + brique('bio', 'prefixe', 'bio-'), + brique('zoo', 'prefixe', 'zoo-'), + brique('hydro', 'prefixe', 'hydro-'), + brique('astro', 'prefixe', 'astro-'), + brique('baro', 'prefixe', 'baro-', 'legendaire'), + brique('logie', 'suffixe', '-logie'), + brique('graphe', 'suffixe', '-graphe'), + brique('metre', 'suffixe', '-mètre'), +] +// mot, fascicule, gauche, droite +const MOTS: [string, number, string, string][] = [ + ['geologie', 1, 'geo', 'logie'], + ['biologie', 1, 'bio', 'logie'], + ['zoologie', 1, 'zoo', 'logie'], + ['geographe', 1, 'geo', 'graphe'], + ['hydrologie', 2, 'hydro', 'logie'], + ['hydrometre', 2, 'hydro', 'metre'], + ['astrologie', 2, 'astro', 'logie'], + ['barometre', 2, 'baro', 'metre'], +] + +function jeu(): CatalogueJeu { + return { + fascicules: [1, 2, 3].map((numero) => ({ numero, manifeste: { id: `fsc_${numero}`, nom: ['Rosette', 'Argile', 'Aleph'][numero - 1] } })), + briques: new Map(BRIQUES.map((b) => [b.id, b])), + recettes: MOTS.map(([m, , g, d]) => ({ id: `r_${m}`, motId: m, emplacements: [[g], [d]] })), + } as unknown as CatalogueJeu +} + +function entrees(trouves: string[], dates: Record = {}): EntreeCodex[] { + const mots = MOTS.map(([m, f, g, d]) => ({ + id: m, + n: 0, + fascicule: f, + type: 'mot', + libelle: m, + trouvee: trouves.includes(m), + trouveeLe: dates[m] ?? null, + legendaire: g === 'baro' || d === 'baro', + })) + const briques = BRIQUES.map((b) => ({ + id: b.uniteId, + n: 0, + fascicule: 1, + type: b.nature, + libelle: b.libelle, + trouvee: MOTS.some(([m, , g, d]) => trouves.includes(m) && (g === b.id || d === b.id)), + trouveeLe: null, + legendaire: b.rarete === 'legendaire', + })) + return [...mots, ...briques] as unknown as EntreeCodex[] +} + +describe('constellation', () => { + const tout = ['geologie', 'biologie', 'geographe', 'hydrologie', 'hydrometre', 'barometre'] + + it('ne garde ni légendaire, ni fascicule verrouillé ; les totaux ne comptent que le visible', () => { + const reseau = construireReseau(jeu(), entrees(tout), new Set([1])) + expect(reseau.mots.map((m) => m.id).sort()).toEqual(['biologie', 'geographe', 'geologie', 'zoologie']) + expect(reseau.fascicules.map((f) => f.nom)).toEqual(['Rosette']) + expect([...reseau.briques.keys()]).not.toContain('baro') + const deux = construireReseau(jeu(), entrees(tout), new Set([1, 2])) + expect(deux.mots.map((m) => m.id)).not.toContain('barometre') + // -logie : 3 sur 3 dans Rosette, 4 sur 5 dans les deux fascicules ouverts. + expect(famille(deux, 'logie', 1)).toMatchObject({ trouves: 2, total: 3 }) + expect(famille(deux, 'logie', 'tous')).toMatchObject({ trouves: 3, total: 5 }) + }) + + it('ne montre que les briques des mots trouvés, par ordre alphabétique', () => { + const reseau = construireReseau(jeu(), entrees(['geologie', 'biologie']), new Set([1, 2])) + const { bandes, mots } = portee(reseau, 1) + expect(mots.map((m) => m.id)).toEqual(['geologie', 'biologie']) + expect(bandes).toEqual([{ numero: 1, g: ['bio', 'geo'], d: ['logie'] }]) + expect(portee(reseau, 2).bandes).toEqual([]) + }) + + it('range une brique dans la bande du premier fascicule qui l’emploie, dans la vue d’ensemble', () => { + const reseau = construireReseau(jeu(), entrees(['hydrologie', 'hydrometre']), new Set([1, 2])) + expect(portee(reseau, 'tous').bandes).toEqual([ + { numero: 1, g: [], d: ['logie'] }, + { numero: 2, g: ['hydro'], d: ['metre'] }, + ]) + }) + + it('dispose sans chevauchement, et une découverte ne déplace que les briques qui suivent dans l’alphabet', () => { + const avant = construireReseau(jeu(), entrees(['geologie', 'geographe', 'zoologie']), new Set([1])) + const apres = construireReseau(jeu(), entrees(['geologie', 'geographe', 'zoologie', 'biologie']), new Set([1])) + const L1 = disposer(avant, portee(avant, 1).bandes, 390, false) + const L2 = disposer(apres, portee(apres, 1).bandes, 390, false) + for (const L of [L1, L2]) { + const places = [...L.places.values()] + for (const a of places) { + for (const b of places) { + if (a === b) continue + const chevauche = a.x < b.x + b.largeur && b.x < a.x + a.largeur && Math.abs(a.y - b.y) < PAS + expect(chevauche).toBe(false) + } + } + // Les briques ne débordent jamais dans la rivière. + for (const p of places) expect(p.cote === 'g' ? p.x + p.largeur <= L.xG : p.x >= L.xD).toBe(true) + } + // bio- s'insère avant géo- : géo- descend d'une rangée, zoo- aussi ; les suffixes ne bougent pas. + expect(L2.places.get(cle('g', 'geo'))!.y - L1.places.get(cle('g', 'geo'))!.y).toBe(PAS) + expect(L2.places.get(cle('d', 'graphe'))!.y).toBe(L1.places.get(cle('d', 'graphe'))!.y) + expect(L2.places.get(cle('d', 'logie'))!.y).toBe(L1.places.get(cle('d', 'logie'))!.y) + // Même largeur de rive : elle dépend de tout le réseau, pas des briques trouvées. + expect(L2.xG).toBe(L1.xG) + }) + + it('trace des courbes qui restent dans la rivière, la dernière découverte à part', () => { + const reseau = construireReseau(jeu(), entrees(['geologie', 'biologie'], { geologie: '2026-10-01T08:00:00Z', biologie: '2026-10-02T08:00:00Z' }), new Set([1])) + const { mots, bandes } = portee(reseau, 1) + const L = disposer(reseau, bandes, 390, false) + const dernier = derniere(mots) + expect(dernier?.id).toBe('biologie') + const { liens, antennes } = tracer(L, mots, dernier, null) + expect(antennes).toEqual([]) + expect(liens.at(-1)?.dernier).toBe(true) + // Abscisses du chemin « Mx y Hx Cx y x y x y Hx » : toutes entre les deux rives. + const abscisses = (d: string) => + [...d.matchAll(/([MHC])([^MHC]+)/g)].flatMap(([, op, args]) => { + const n = args!.trim().split(/\s+/).map(Number) + return op === 'H' ? n : n.filter((_, i) => i % 2 === 0) + }) + for (const l of liens) { + for (const x of abscisses(l.d)) { + expect(x).toBeGreaterThan(L.xG) + expect(x).toBeLessThan(L.xD) + } + } + }) + + it('change une courbe trop haute en antennes, dans la vue d’ensemble seulement', () => { + const reseau = construireReseau(jeu(), entrees(['geologie', 'biologie', 'zoologie', 'geographe', 'astrologie']), new Set([1, 2])) + const { mots, bandes } = portee(reseau, 'tous') + const L = disposer(reseau, bandes, 900, true) + // Écart artificiel : la rangée d'astro- part loin sous -logie. + L.places.get(cle('g', 'astro'))!.yb += SEUIL_LOIN + const { liens, antennes } = tracer(L, mots, null, null) + expect(liens.map((l) => l.mot.id)).not.toContain('astrologie') + expect(antennes.map((a) => [a.cle, a.sens, a.nombre]).sort()).toEqual([ + [cle('d', 'logie'), 1, 1], + [cle('g', 'astro'), -1, 1], + ]) + // Suivie, la famille d'astro- reste tracée en entier. + expect(tracer(L, mots, null, cle('g', 'astro')).liens.map((l) => l.mot.id)).toContain('astrologie') + }) +}) diff --git a/web/src/services/constellation.ts b/web/src/services/constellation.ts new file mode 100644 index 0000000..e7c8e55 --- /dev/null +++ b/web/src/services/constellation.ts @@ -0,0 +1,318 @@ +// Vue Réseau du codex (App#91) : la constellation en deux rives. Les briques +// trouvées du 1er emplacement des recettes à gauche, celles du 2ᵈ à droite, une +// bande par fascicule ; chaque mot trouvé est une courbe qui traverse la +// « rivière » entre ses deux briques. Tout se calcule ici, sur la projection du +// codex : une fonction pure et déterministe, que le composant ne fait que dessiner. +// +// Garde-fous : seules les briques et les mots trouvés apparaissent (ADR 0051), +// jamais une légendaire (ADR 0043), jamais un fascicule verrouillé (ADR 0069). +// La difficulté n'est jamais lue (ADR 0047). + +import type { Carte } from '../contrat' +import type { EntreeCodex } from './codex' +import type { CatalogueJeu } from './jeu' + +export type Cote = 'g' | 'd' + +/** Un mot du réseau : trouvé ou non, toujours d'un fascicule débloqué, jamais légendaire. */ +export interface MotReseau { + id: string + libelle: string + fascicule: number + trouve: boolean + trouveLe: string | null + /** Briques retenues pour le tracé : la première alternative trouvée de chaque emplacement. */ + gauche: string + droite: string +} + +export interface BriqueReseau { + id: string + libelle: string + nature: Carte['nature'] + glose: string | null + /** Unité de la brique : sa carte au codex. */ + uniteId: string +} + +export interface Reseau { + /** Mots des fascicules débloqués, hors légendaires : ils font les totaux des familles. */ + mots: MotReseau[] + briques: Map + /** Fascicules débloqués, par numéro croissant, avec leur nom. */ + fascicules: { numero: number; nom: string }[] +} + +/** Nœud du réseau : une brique sur une rive (une même brique peut tenir les deux). */ +export const cle = (cote: Cote, brique: string) => `${cote}:${brique}` + +/** + * Le réseau du joueur. Une recette de deux emplacements seulement ; pour chaque + * emplacement, la première brique trouvée parmi ses alternatives (une forme + * d'allomorphe), sinon la première : un mot non trouvé ne compte que dans les totaux. + */ +export function construireReseau(jeu: CatalogueJeu, entrees: EntreeCodex[], debloques: Set): Reseau { + const parUnite = new Map(entrees.map((e) => [e.id, e])) + const briqueTrouvee = (id: string) => { + const b = jeu.briques.get(id) + return b !== undefined && parUnite.get(b.uniteId)?.trouvee === true + } + const mots: MotReseau[] = [] + for (const recette of jeu.recettes) { + if (recette.emplacements.length !== 2) continue + const entree = parUnite.get(recette.motId) + if (!entree || entree.legendaire || !debloques.has(entree.fascicule)) continue + if (mots.some((m) => m.id === entree.id)) continue + const choisir = (alternatives: string[]) => alternatives.find(briqueTrouvee) ?? alternatives[0] + const gauche = choisir(recette.emplacements[0] ?? []) + const droite = choisir(recette.emplacements[1] ?? []) + if (!gauche || !droite) continue + // Une brique légendaire ne s'affiche jamais : le mot n'entre pas dans le réseau. + if ([gauche, droite].some((b) => jeu.briques.get(b)?.rarete === 'legendaire')) continue + mots.push({ id: entree.id, libelle: entree.libelle, fascicule: entree.fascicule, trouve: entree.trouvee, trouveLe: entree.trouveeLe, gauche, droite }) + } + const briques = new Map() + for (const m of mots) { + for (const id of [m.gauche, m.droite]) { + const b = jeu.briques.get(id) + if (b && !briques.has(id)) briques.set(id, { id, libelle: b.libelle, nature: b.nature, glose: b.glose, uniteId: b.uniteId }) + } + } + const fascicules = jeu.fascicules + .filter((f) => debloques.has(f.numero)) + .sort((a, b) => a.numero - b.numero) + .map((f) => ({ numero: f.numero, nom: f.manifeste.nom ?? `Fascicule ${f.numero}` })) + return { mots, briques, fascicules } +} + +export type VueReseau = number | 'tous' + +export interface Bande { + numero: number + g: string[] + d: string[] +} + +const alpha = new Intl.Collator('fr').compare + +/** Mots trouvés que la vue couvre, et ses bandes : seules les briques de ces mots y figurent. */ +export function portee(reseau: Reseau, vue: VueReseau): { mots: MotReseau[]; bandes: Bande[] } { + const tous = vue === 'tous' + // Vue d'ensemble : une brique tient la bande du premier fascicule dont un mot l'emploie, trouvé ou non, + // pour qu'elle ne change pas de bande quand le joueur avance. + const premiere = new Map() + for (const m of reseau.mots) { + for (const k of [cle('g', m.gauche), cle('d', m.droite)]) premiere.set(k, Math.min(premiere.get(k) ?? Infinity, m.fascicule)) + } + const mots = reseau.mots.filter((m) => m.trouve && (tous || m.fascicule === vue)) + const bandes = new Map; d: Set }>() + for (const m of mots) { + for (const [cote, b] of [['g', m.gauche], ['d', m.droite]] as const) { + const numero = tous ? (premiere.get(cle(cote, b)) ?? m.fascicule) : m.fascicule + if (!bandes.has(numero)) bandes.set(numero, { g: new Set(), d: new Set() }) + bandes.get(numero)![cote].add(b) + } + } + const libelle = (id: string) => reseau.briques.get(id)?.libelle ?? id + const trier = (ids: Set) => [...ids].sort((a, b) => alpha(libelle(a), libelle(b)) || alpha(a, b)) + return { + mots, + bandes: [...bandes].sort(([a], [b]) => a - b).map(([numero, b]) => ({ numero, g: trier(b.g), d: trier(b.d) })), + } +} + +/** Famille d'une brique dans la vue : ses mots trouvés, et le total des fascicules débloqués que la vue couvre. */ +export function famille(reseau: Reseau, brique: string, vue: VueReseau): { formes: MotReseau[]; trouves: number; total: number } { + const siens = reseau.mots.filter((m) => (m.gauche === brique || m.droite === brique) && (vue === 'tous' || m.fascicule === vue)) + const formes = siens.filter((m) => m.trouve).sort((a, b) => alpha(a.libelle, b.libelle)) + return { formes, trouves: formes.length, total: siens.length } +} + +/** Dernière découverte de la vue : elle seule passe en Corail. */ +export function derniere(mots: MotReseau[]): MotReseau | null { + return mots.reduce((a, m) => (m.trouveLe && (!a?.trouveLe || m.trouveLe > a.trouveLe) ? m : a), null) +} + +// ------------------------------------------------------------------ disposition + +/** Une rangée par brique : la brique, sa barre de famille, et toute la rangée comme cible tactile. */ +export const PAS = 44 +export const MARGE = 4 +export const OMBRE = 2 +export const PAD_X = 8 +/** Neutre entre une brique et le départ de ses courbes. */ +export const ECART = 4 +export const BARRE = 5 +export const JEU_BARRE = 3 +/** Vue d'ensemble : au-delà de cette hauteur, une courbe devient une antenne chiffrée. */ +export const SEUIL_LOIN = 640 +/** Vue d'ensemble : départ horizontal des courbes, où se logent les comptes d'antenne. */ +export const ABORD = 40 +export const RIVIERE_MIN = 90 +export const POLICE_TITRE = 12 +const INTERLIGNE_TITRE = 16 + +/** Mesure d'un texte en pixels, pour une taille et une famille (`serif`, `mono`). */ +export type Mesurer = (texte: string, taille: number, famille: 'serif' | 'mono', gras?: boolean) => number + +/** Estimation sans canvas (tests, rendu hors navigateur) : un peu large, jamais trop étroite. */ +export const estimer: Mesurer = (texte, taille, famille) => Array.from(texte).length * taille * (famille === 'mono' ? 0.62 : 0.6) + +export interface Place { + cote: Cote + brique: string + bande: number + /** Centre de la rangée. */ + y: number + /** Centre de la brique dans sa rangée. */ + yb: number + x: number + largeur: number +} + +export interface Disposition { + largeur: number + hauteur: number + police: number + /** Hauteur d'une brique. */ + h: number + compteurs: boolean + /** Largeur réservée aux comptes « 2/5 » de chaque rive. */ + k: number + /** Largeur d'une rive : la plus longue brique du réseau, jamais celle de la vue. */ + col: number + /** Bord droit des briques de gauche, bord gauche de celles de droite. */ + xG: number + xD: number + abord: number + tous: boolean + places: Map + bandes: { numero: number; lignes: string[]; y0: number; haut: number; bas: number }[] +} + +/** + * Disposition en deux rives : une bande par fascicule, les briques par ordre + * alphabétique, une rangée de 44 px chacune. La largeur des rives dépend de tout le + * réseau (pas de la vue) : une brique nouvelle ne déplace rien en largeur, et ne + * déplace en hauteur que les briques qui la suivent dans l'alphabet. + */ +export function disposer(reseau: Reseau, bandes: Bande[], largeurCadre: number, tous: boolean, mesurer: Mesurer = estimer): Disposition { + const police = largeurCadre < 480 ? 14 : 15 + const h = police + 10 + const compteurs = largeurCadre >= 560 + const k = compteurs ? Math.ceil(mesurer('00/00', 12, 'mono')) + 10 : 0 + const col = Math.max(60, ...[...reseau.briques.values()].map((b) => Math.ceil(mesurer(b.libelle, police, 'serif', true)) + 2 * PAD_X)) + const abord = tous ? ABORD : ECART + const riviere = Math.max(RIVIERE_MIN, 2 * abord + 50) + const largeur = Math.max(largeurCadre, 2 * (MARGE + k + col) + OMBRE + riviere) + const xG = MARGE + k + col + const xD = largeur - MARGE - OMBRE - k - col + const rive = k + col + const nom = (n: number) => reseau.fascicules.find((f) => f.numero === n)?.nom ?? `Fascicule ${n}` + const places = new Map() + const plan: Disposition['bandes'] = [] + // Centre vertical de la brique dans sa rangée : brique, ombre, 3 px de neutre, barre. + const yBrique = (y: number) => y - PAS / 2 + (PAS - (h + OMBRE + JEU_BARRE + BARRE)) / 2 + h / 2 + let y = 0 + for (const bande of bandes) { + // Titre « 3 · ROUTES DES MOTS », replié sur la largeur d'une rive : il ne déborde jamais dans la rivière. + const lignes: string[] = [] + for (const morceau of [`${bande.numero} ·`, ...nom(bande.numero).toUpperCase().split(' ')]) { + const dernier = lignes.at(-1) + const essai = dernier === undefined ? morceau : `${dernier} ${morceau}` + if (dernier !== undefined && mesurer(essai, POLICE_TITRE, 'mono', true) > rive) lignes.push(morceau) + else if (dernier !== undefined) lignes[lignes.length - 1] = essai + else lignes.push(morceau) + } + const haut = y + 10 + lignes.length * INTERLIGNE_TITRE + for (const cote of ['g', 'd'] as const) { + bande[cote].forEach((b, i) => { + const yr = haut + (i + 0.5) * PAS + const largeurBrique = Math.ceil(mesurer(reseau.briques.get(b)?.libelle ?? b, police, 'serif', true)) + 2 * PAD_X + places.set(cle(cote, b), { cote, brique: b, bande: bande.numero, y: yr, yb: yBrique(yr), x: cote === 'g' ? xG - largeurBrique : xD, largeur: largeurBrique }) + }) + } + const bas = haut + Math.max(bande.g.length, bande.d.length) * PAS + plan.push({ numero: bande.numero, lignes, y0: y, haut, bas }) + y = bas + } + return { largeur, hauteur: y + 8, police, h, compteurs, k, col, xG, xD, abord, tous, places, bandes: plan } +} + +export interface Lien { + mot: MotReseau + d: string + dernier: boolean + famille: boolean +} + +export interface Antenne { + cle: string + cote: Cote + /** -1 : vers le haut, 1 : vers le bas. */ + sens: -1 | 1 + nombre: number + d: string + x: number + y: number +} + +/** + * Courbes et antennes. Une courbe part horizontalement de sa brique, traverse la + * rivière et repart horizontalement : elle ne passe jamais sous une brique ni sous + * un texte. Dans la vue d'ensemble, une courbe de plus de 640 px de haut devient + * deux antennes chiffrées, sauf pour la famille suivie et la dernière découverte. + */ +export function tracer(L: Disposition, mots: MotReseau[], dernier: MotReseau | null, focus: string | null): { liens: Lien[]; antennes: Antenne[] } { + const f = (v: number) => v.toFixed(1) + const enFamille = (m: MotReseau) => focus !== null && (cle('g', m.gauche) === focus || cle('d', m.droite) === focus) + const pg = (m: MotReseau) => L.places.get(cle('g', m.gauche)) + const pd = (m: MotReseau) => L.places.get(cle('d', m.droite)) + const visibles = mots.filter((m) => pg(m) && pd(m)) + const lointain = (m: MotReseau) => L.tous && !enFamille(m) && m !== dernier && Math.abs(pg(m)!.yb - pd(m)!.yb) > SEUIL_LOIN + const traces = visibles.filter((m) => !lointain(m)) + const comptes = new Map() + for (const m of visibles.filter(lointain)) { + const sens = Math.sign(pd(m)!.yb - pg(m)!.yb) + for (const [k, s] of [[cle('g', m.gauche), sens], [cle('d', m.droite), -sens]] as const) comptes.set(`${k}|${s}`, (comptes.get(`${k}|${s}`) ?? 0) + 1) + } + // Ports : les départs d'une brique s'étalent sur 10 px, dans l'ordre de leurs partenaires ; les antennes aux extrémités. + const ports = new Map() + for (const [k, p] of L.places) { + const departs = traces + .filter((m) => cle('g', m.gauche) === k || cle('d', m.droite) === k) + .map((m) => ({ id: m.id, y: (p.cote === 'g' ? pd(m)! : pg(m)!).yb })) + if (comptes.has(`${k}|-1`)) departs.push({ id: 'haut', y: -Infinity }) + if (comptes.has(`${k}|1`)) departs.push({ id: 'bas', y: Infinity }) + departs.sort((a, b) => a.y - b.y || (a.id > b.id ? 1 : -1)) + departs.forEach((o, i) => ports.set(`${k}|${o.id}`, p.yb + (departs.length === 1 ? 0 : (i / (departs.length - 1) - 0.5) * 10))) + } + const gauche = L.xG + OMBRE + ECART + const droite = L.xD - ECART + const liens = traces + .map((m) => { + const y1 = ports.get(`${cle('g', m.gauche)}|${m.id}`)! + const y2 = ports.get(`${cle('d', m.droite)}|${m.id}`)! + const a = L.xG + OMBRE + L.abord + const b = L.xD - L.abord + const c = (b - a) / 2 + return { mot: m, d: `M${f(gauche)} ${f(y1)}H${f(a)}C${f(a + c)} ${f(y1)} ${f(b - c)} ${f(y2)} ${f(b)} ${f(y2)}H${f(droite)}`, dernier: m === dernier, famille: enFamille(m) } + }) + // La dernière découverte puis la famille suivie, par-dessus les autres. + .sort((a, b) => Number(a.dernier) - Number(b.dernier) || Number(a.famille) - Number(b.famille)) + const antennes: Antenne[] = [] + for (const [k, nombre] of comptes) { + const i = k.lastIndexOf('|') + const noeud = k.slice(0, i) + const sens = Number(k.slice(i + 1)) as -1 | 1 + const p = L.places.get(noeud)! + const g = p.cote === 'g' + const x0 = g ? gauche : droite + const s1 = g ? 1 : -1 + const y0 = ports.get(`${noeud}|${sens < 0 ? 'haut' : 'bas'}`)! + // Le compte se loge entre deux rangées de ports : « bas » juste sous la sienne, « haut » juste au-dessus. + const yt = p.yb + sens * 13 + antennes.push({ cle: noeud, cote: p.cote, sens, nombre, d: `M${f(x0)} ${f(y0)}C${f(x0 + s1 * 6)} ${f(y0)} ${f(x0 + s1 * 6)} ${f(yt)} ${f(x0 + s1 * 10)} ${f(yt)}`, x: x0 + s1 * 12, y: yt }) + } + return { liens, antennes } +} diff --git a/web/src/services/route.ts b/web/src/services/route.ts index 060b5a6..a7c66c2 100644 --- a/web/src/services/route.ts +++ b/web/src/services/route.ts @@ -11,7 +11,7 @@ export type Onglet = 'table' | 'codex' | 'plis' | 'quetes' | 'boutique' | 'ricoc export type FiltreCodex = 'tout' | 'mot' | 'prefixe' | 'suffixe' | 'langue' export type TriCodex = 'n' | 'az' | 'type' | 'fasc' | 'date' | 'maitrise' /** Vues du codex (maquette I-14) ; les types se filtrent à part. */ -export type VueCodex = 'tout' | 'recentes' | 'familles' | 'cabinet' +export type VueCodex = 'tout' | 'recentes' | 'familles' | 'reseau' | 'cabinet' /** Onglets des quêtes (ADR 0042, 0045) : journalières, récompenses de fascicule, puis quêtes au long cours. */ export type OngletQuetes = 'journalieres' | 'ponctuelles' | 'fascicules' | 'au-long-cours' @@ -38,7 +38,7 @@ const ONGLETS: Onglet[] = ['table', 'codex', 'plis', 'quetes', 'boutique', 'rico const QUETES: OngletQuetes[] = ['journalieres', 'ponctuelles', 'fascicules', 'au-long-cours'] const FILTRES: FiltreCodex[] = ['tout', 'mot', 'prefixe', 'suffixe', 'langue'] const TRIS: TriCodex[] = ['n', 'az', 'type', 'fasc', 'date', 'maitrise'] -const VUES: VueCodex[] = ['tout', 'recentes', 'familles', 'cabinet'] +const VUES: VueCodex[] = ['tout', 'recentes', 'familles', 'reseau', 'cabinet'] const IDENTIFIANT = /^(unt|lng)_[0-9a-hjkmnp-tv-z]{8}$/ const FASCICULE = /^fsc_[0-9a-hjkmnp-tv-z]{8}$/ From 4ee2d27ffc91743010355a563f60caf7b1eb07c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:53:09 +0000 Subject: [PATCH 2/2] fix(deps): update rustls-webpki, webpki-roots, ctutils and vite for audits cargo audit flagged RUSTSEC-2026-0381/0382/0383/0385 and npm audit a high advisory on vite 8.3.1 (and moderate ones on vitest): lockfile-only updates to the patched versions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0151stDRaZV3ScbhwvwjrfQ7 (cherry picked from commit a140f55df0d3ce823acc6ff6d9534bce30e72ab8) --- Cargo.lock | 18 +-- web/package-lock.json | 248 +++++++++++++++++++++--------------------- 2 files changed, 133 insertions(+), 133 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d4332a..b306375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,9 +479,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -2168,9 +2168,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3235,9 +3235,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -3248,14 +3248,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] diff --git a/web/package-lock.json b/web/package-lock.json index c5f9c8a..1050295 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1900,9 +1900,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -2279,9 +2279,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -2296,9 +2296,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -2313,9 +2313,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -2330,9 +2330,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -2347,9 +2347,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -2364,9 +2364,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -2381,9 +2381,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -2398,9 +2398,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -2415,9 +2415,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -2432,9 +2432,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -2449,9 +2449,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -2466,9 +2466,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -2483,9 +2483,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -2500,9 +2500,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -2517,9 +2517,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -3246,22 +3246,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -3274,9 +3274,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -3672,9 +3672,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -5670,9 +5670,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5737,9 +5737,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5810,9 +5810,9 @@ } }, "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -6025,9 +6025,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -6045,9 +6045,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6270,13 +6270,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6286,21 +6286,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/rollup": { @@ -6656,9 +6656,9 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -6890,9 +6890,9 @@ } }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -7229,16 +7229,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -7255,7 +7255,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7338,25 +7338,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -7371,12 +7371,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -7574,9 +7574,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": {