diff --git a/docs/algorithm-test.mdx b/docs/algorithm-test.mdx new file mode 100644 index 0000000..4275962 --- /dev/null +++ b/docs/algorithm-test.mdx @@ -0,0 +1,78 @@ +--- +unlisted: true +title: Algorithm — pagina di test +description: Sandbox di verifica del componente Algorithm (animazioni di algoritmi) +--- + +# Algorithm — sandbox di test + +Pagina temporanea per validare le animazioni di algoritmi. Non è linkata dalla +sidebar (`unlisted: true`): la raggiungi solo via URL `/docs/algorithm-test`. Il +componente `` è registrato in `MDXComponents`, quindi qui sotto lo si usa +direttamente. In modalità **studio** si avanza passo-passo con le spiegazioni; in +modalità **lab** parte l'autoplay con dati casuali. + +## 1. Bubble sort (studio) + + + +## 2. Selection sort (studio) + + + +## 3. Insertion sort (studio) + + + +## 4. Ricerca lineare (studio) + + + +## 5. Ricerca binaria (studio) + + + +## 6. Quicksort (studio) + + + +## 7. Lab — autoplay e dati casuali + +Bubble sort con nove valori casuali: + + + +Quicksort con otto valori casuali: + + + +Ricerca binaria in lab (verifica `prepare` che ordina e target casuale): + + + +## 8. Casi limite + +Ricerca binaria su dati **non ordinati** (`prepare` deve ordinarli prima): + + + +Ricerca lineare con target assente → esito «non trovato»: + + + +Bubble sort con dodici elementi (overflow orizzontale della scena): + + + +Algoritmo inesistente (deve comparire il box d'errore, **senza** rompere la build): + + + +## Nota per la verifica + +Controllare anche la **dark mode** (palette scura, testo delle barre leggibile, +nessun colore «bloccato» chiaro) e `prefers-reduced-motion` (transizioni +disattivate, gli step diventano istantanei). diff --git a/src/components/Algorithm/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx new file mode 100644 index 0000000..fd94225 --- /dev/null +++ b/src/components/Algorithm/ArrayScene.tsx @@ -0,0 +1,320 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from 'react'; +import type { ArrayItem, ArraySceneState } from './types'; +import styles from './styles.module.css'; + +const BASE_BAR_W = 44; // larghezza barra a piena dimensione (desktop) +const BASE_GAP = 14; // spazio tra barre a piena dimensione +const MAX_UNIT = BASE_BAR_W + BASE_GAP; // 58 — passo orizzontale desktop +const MIN_UNIT = 36; // passo minimo su mobile (barra ~27 + gap ~9) +const BAR_RATIO = BASE_BAR_W / MAX_UNIT; // quota della barra dentro al passo +const TRACK_H = 170; // altezza zona barre (allineate in basso) +const MIN_H = 40; // altezza barra del valore minimo +const EXTRACT_DY = 58; // quanto scende l'elemento estratto (chiave) +const HEADROOM = 16; // spazio sopra le barre per «lift» e ombra (no clipping) +const EDGE = 6; // margine orizzontale interno: l'anello di selezione (~2.5px) +// delle barre ai bordi non viene tagliato dall'overflow del .sceneWrap. +const SPLIT_GAP = 16; // varco aperto a ogni confine ordinato/non ordinato +const BADGE_W = 20; +const BADGE_OFFSET = 22; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +// useLayoutEffect avverte in SSR: su server cade su useEffect (innocuo, il +// markup parte comunque dal passo MAX_UNIT, identico al primo render client). +const useIsoLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +// stato → variabile CSS dell'anello (colori in styles.module.css) +const RING: Record = { + compare: 'var(--alg-st-compare)', + swap: 'var(--alg-st-swap)', + sorted: 'var(--alg-st-sorted)', + key: 'var(--alg-st-key)', +}; + +function ringShadow(name: keyof typeof RING): string { + const c = RING[name]; + return `0 0 0 2.5px ${c}, 0 10px 24px -6px color-mix(in srgb, ${c} 38%, transparent)`; +} + +interface ArraySceneProps { + state: ArraySceneState; + target?: number; + label: string; + /** 'gradient' = tinta per valore (scala ordinabile); 'rainbow' = tinta per + * identità, stabile e che segue l'elemento negli spostamenti. */ + colorMode: 'gradient' | 'rainbow'; + /** Altezza della corsia sotto le barre, fissata sull'intera trace. */ + laneH: number; + /** Pannello codice attivo: l'indicatore di posizione mostra la variabile. */ + showLabels: boolean; +} + +export default function ArrayScene({ + state, + target, + label, + colorMode, + laneH, + showLabels, +}: ArraySceneProps) { + const n = state.items.length; + + // Larghezza disponibile = clientWidth di .sceneWrap (il genitore, che ha + // overflow-x: auto e già sconta il padding del .body). La misuro e seguo i + // resize/rotazioni con un ResizeObserver, restando dentro ArrayScene. + const sceneRef = useRef(null); + const [availW, setAvailW] = useState(0); + useIsoLayoutEffect(() => { + const wrap = sceneRef.current?.parentElement; + if (!wrap) return undefined; + const measure = () => setAvailW(wrap.clientWidth); + measure(); + if (typeof ResizeObserver === 'undefined') return undefined; + const ro = new ResizeObserver(measure); + ro.observe(wrap); + return () => ro.disconnect(); + }, []); + + // Passo adattivo: pieno (MAX_UNIT) finché c'è spazio, poi si stringe fino a + // MIN_UNIT; sotto, lo scroll orizzontale del .sceneWrap fa da rete (fallback). + // sceneWidth = n*unit − gap, quindi divido per (n − quota gap) per far stare + // l'intero array nello spazio disponibile quando possibile. + // Lo spazio per le barre è quello disponibile meno i due margini EDGE. + const unit = + availW > 0 && n > 0 + ? clamp((availW - 2 * EDGE) / (n - 1 + BAR_RATIO), MIN_UNIT, MAX_UNIT) + : MAX_UNIT; + const barW = unit * BAR_RATIO; + // Il valore in cifre non entra in barre molto strette: scalo il font solo + // verso il basso (mai oltre la dimensione desktop). + const valueScale = Math.min(1, barW / BASE_BAR_W); + + // Stacco visivo tra parte ordinata e parte da ordinare: a ogni confine (slot + // ordinato adiacente a uno non ordinato) si apre un piccolo varco, così «ciò + // che è a posto» si legge staccato dal resto. Array tutto ordinato o tutto da + // ordinare = nessun confine = nessun varco. splitOffset[i] è il varco + // accumulato a sinistra dello slot i. + const sortedSet = new Set(state.sorted); + const splitOffset: number[] = new Array(Math.max(n, 1)).fill(0); + for (let i = 1; i < n; i++) { + const boundary = sortedSet.has(i - 1) !== sortedSet.has(i); + splitOffset[i] = splitOffset[i - 1] + (boundary ? SPLIT_GAP : 0); + } + + // Le barre partono da EDGE (non da 0): così l'anello della prima e dell'ultima + // ha spazio a sinistra/destra e non viene clippato; ci si aggiunge il varco + // ordinato/non ordinato accumulato fino allo slot. + const x = (i: number) => EDGE + i * unit + (splitOffset[i] ?? 0); + const slotCenter = (i: number) => x(i) + barW / 2; + + const lastRight = n > 0 ? x(n - 1) + barW : barW; + const sceneWidth = lastRight + EDGE; + const base = HEADROOM + TRACK_H; // baseline (base delle barre) + + // L'insieme dei valori è costante (items + eventuale estratto): min/max fissi. + const values = state.items + .filter((it): it is ArrayItem => it !== null) + .map((it) => it.value); + if (state.extracted) values.push(state.extracted.item.value); + const minValue = Math.min(...values, 1); + const maxValue = Math.max(...values, 1); + const norm = (v: number) => + maxValue === minValue ? 0.6 : (v - minValue) / (maxValue - minValue); + const barHeight = (v: number) => MIN_H + norm(v) * (TRACK_H - MIN_H); + // Tinta della barra: per valore (gradiente ciano→magenta, si ordina in scala) + // oppure per identità (arcobaleno stabile, ogni elemento tiene il suo colore). + const hueOf = (item: ArrayItem) => + colorMode === 'rainbow' + ? (item.id / Math.max(n, 1)) * 320 + : 196 + norm(item.value) * 128; + + // Confronto in corso. + const comparingSlots = new Set(); + let comparingExtracted = false; + let comparingTarget = false; + if (state.comparing) { + for (const ref of [state.comparing.a, state.comparing.b]) { + if (ref.kind === 'item') comparingSlots.add(ref.i); + else if (ref.kind === 'extracted') comparingExtracted = true; + else comparingTarget = true; + } + } + + const fadedSet = new Set(state.faded); + const swappingSet = new Set(state.swapping); + const inRange = (slot: number) => + !state.range || (slot >= state.range.lo && slot <= state.range.hi); + const foundSlot = + state.outcome && state.outcome.found ? state.outcome.i : null; + + // Tutti gli item per identità (slot dagli items, più l'eventuale estratto). + type Entry = { item: ArrayItem; slot: number; isExtracted: boolean }; + const entries: Entry[] = []; + state.items.forEach((it, slot) => { + if (it) entries.push({ item: it, slot, isExtracted: false }); + }); + if (state.extracted) { + entries.push({ + item: state.extracted.item, + slot: state.extracted.overIndex, + isExtracted: true, + }); + } + entries.sort((p, q) => p.item.id - q.item.id); + + function barStyle({ item, slot, isExtracted }: Entry): CSSProperties { + const h = barHeight(item.value); + + // anello semantico (priorità crescente: vince l'ultimo) + let ring: keyof typeof RING | null = null; + let lift = 0; + if (isExtracted) { + ring = 'key'; + } else { + if (sortedSet.has(slot)) ring = 'sorted'; + if (comparingSlots.has(slot)) { + ring = 'compare'; + lift = 8; + } + if (swappingSet.has(slot)) ring = 'swap'; + if (foundSlot === slot) ring = 'sorted'; + } + if (isExtracted && comparingExtracted) lift = 0; // resta in basso + + const tx = x(slot); + const ty = (isExtracted ? base - h + EXTRACT_DY : base - h) - lift; + + const style: CSSProperties & Record<'--alg-hue', string> = { + transform: `translate(${tx}px, ${ty}px)`, + height: h, + width: barW, + color: 'var(--alg-bar-text)', + '--alg-hue': String(Math.round(hueOf(item))), + }; + if (ring) style.boxShadow = ringShadow(ring); + if (!isExtracted && (fadedSet.has(slot) || !inRange(slot))) { + style.opacity = 0.32; + } + return style; + } + + // Badge pointer raggruppati per slot. + const bySlot = new Map(); + for (const p of state.pointers) { + const list = bySlot.get(p.i) ?? []; + list.push(p.name); + bySlot.set(p.i, list); + } + const badges: { key: string; left: number; name: string }[] = []; + for (const [slot, names] of bySlot) { + const ordered = names.slice().sort((a, b) => a.localeCompare(b)); + const groupW = BADGE_W + (ordered.length - 1) * BADGE_OFFSET; + const start = slotCenter(slot) - groupW / 2; + ordered.forEach((name, k) => { + badges.push({ key: name, left: start + k * BADGE_OFFSET, name }); + }); + } + + return ( +
+ {target !== undefined && ( +
+
+ cerco: {target} +
+
+ )} + +
+
+ + {entries.map((entry) => ( +
+ {entry.isExtracted && KEY} + + {entry.item.value} + +
+ ))} + + {badges.map((b) => ( +
+ {b.name} +
+ ))} + + {state.cursor && state.cursor.i >= 0 && ( +
+ + {showLabels && state.cursor.label && ( + {state.cursor.label} + )} +
+ )} + + {state.range && ( + <> +
+ {state.range.label && ( +
+ {state.range.label} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/Algorithm/CodePanel.tsx b/src/components/Algorithm/CodePanel.tsx new file mode 100644 index 0000000..b7c19fa --- /dev/null +++ b/src/components/Algorithm/CodePanel.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { highlightPy } from './highlightPy'; +import Icon from './Icon'; +import styles from './styles.module.css'; + +interface CodePanelProps { + code: string[]; + activeLine: number | null; + comparisons: number; + swaps: number; + showStats: boolean; + /** I sort mostrano Confronti+Scambi; le ricerche solo Confronti. */ + withSwaps: boolean; +} + +/** Copia negli appunti con fallback per i contesti senza Clipboard API. */ +async function copyToClipboard(text: string): Promise { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + return; + } + const { default: copy } = await import('copy-text-to-clipboard'); + if (!copy(text)) throw new Error('copia non riuscita'); +} + +export default function CodePanel({ + code, + activeLine, + comparisons, + swaps, + showStats, + withSwaps, +}: CodePanelProps) { + const [copied, setCopied] = useState(false); + const timer = useRef(undefined); + const codeRef = useRef(null); + const activeRef = useRef(null); + + useEffect(() => () => window.clearTimeout(timer.current), []); + + // Porta la riga attiva in vista DENTRO al pannello codice (su mobile è + // scrollabile), aggiustando solo il suo scrollTop: non tocca lo scroll della + // pagina. Su desktop il pannello mostra tutto, quindi resta un no-op. + useEffect(() => { + const box = codeRef.current; + const line = activeRef.current; + if (!box || !line) return; + const b = box.getBoundingClientRect(); + const l = line.getBoundingClientRect(); + if (l.top < b.top) box.scrollTop += l.top - b.top - 8; + else if (l.bottom > b.bottom) box.scrollTop += l.bottom - b.bottom + 8; + }, [activeLine]); + + const onCopy = useCallback(() => { + copyToClipboard(code.join('\n')) + .then(() => { + setCopied(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 1000); + }) + .catch(() => { + /* copia non riuscita: nessun feedback positivo */ + }); + }, [code]); + + return ( +
+
+
+ Pseudocodice · Python + +
+
+ {code.map((line, li) => { + const on = activeLine === li; + return ( +
+ {li + 1} + {highlightPy(line)} +
+ ); + })} +
+
+ + {showStats && ( +
+
+
Confronti
+
{comparisons}
+
+ {withSwaps && ( +
+
Scambi
+
{swaps}
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/Algorithm/Icon.tsx b/src/components/Algorithm/Icon.tsx new file mode 100644 index 0000000..f642a58 --- /dev/null +++ b/src/components/Algorithm/Icon.tsx @@ -0,0 +1,144 @@ +import type { CSSProperties } from 'react'; + +/** + * Set di icone duotone inline (estratte da Font Awesome 7.1.0 Duotone). + * + * Perché inline e non il webfont: per la manciata di icone che servono qui + * (~9) il webfont duotone pesa ~318 KB; questi path sommano a un paio di KB, + * danno due-toni veri (layer primario opacità 1, secondario .4), sono temabili + * con `currentColor` (prendono l'accent del blocco) e non aggiungono download. + * + * `secondary` è il layer di sfondo (sotto, attenuato); `primary`, se presente, + * il layer in primo piano a piena opacità. Le icone "mono" (play/pause) hanno + * un solo layer: lì il secondary è disegnato a opacità piena. + */ + +interface IconDef { + vb: string; + secondary: string; + primary?: string; +} + +const ICONS = { + play: { + vb: '0 0 448 512', + secondary: + 'M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z', + }, + pause: { + vb: '0 0 384 512', + secondary: + 'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z', + }, + 'backward-step': { + vb: '0 0 384 512', + secondary: + 'M64 208.1l0 95.7 258 169.6c12.3 8.1 28 8.8 41 1.8s21-20.5 21-35.2l0-368c0-14.7-8.1-28.2-21-35.2s-28.7-6.3-41 1.8L64 208.1z', + primary: + 'M32 32l0 0C14.3 32 0 46.3 0 64L0 448c0 17.7 14.3 32 32 32l0 0c17.7 0 32-14.3 32-32L64 64c0-17.7-14.3-32-32-32z', + }, + 'forward-step': { + vb: '0 0 384 512', + secondary: + 'M0 72L0 440c0 14.7 8.1 28.2 21 35.2s28.7 6.3 41-1.8l258-169.6 0-95.7-258-169.6c-12.3-8.1-28-8.8-41-1.8S0 57.3 0 72z', + primary: + 'M352 32l0 0c17.7 0 32 14.3 32 32l0 384c0 17.7-14.3 32-32 32l0 0c-17.7 0-32-14.3-32-32l0-384c0-17.7 14.3-32 32-32z', + }, + 'forward-fast': { + vb: '0 0 512 512', + secondary: + 'M0 64L0 448c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9L224 301.3 224 448c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9L448 301.3 448 210.7 278.6 41.4c-9.2-9.2-22.9-11.9-34.9-6.9S224 51.1 224 64L224 210.7 54.6 41.4c-9.2-9.2-22.9-11.9-34.9-6.9S0 51.1 0 64z', + primary: + 'M480 480c17.7 0 32-14.3 32-32l0-384c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 384c0 17.7 14.3 32 32 32z', + }, + 'rotate-left': { + vb: '0 0 512 512', + secondary: + 'M39.8 393.1C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8l45.5 45.5c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2z', + primary: + 'M168 192L24 192c-13.3 0-24-10.7-24-24L0 24C0 14.3 5.8 5.5 14.8 1.8S34.1 .2 41 7L185 151c6.9 6.9 8.9 17.2 5.2 26.2S177.7 192 168 192z', + }, + shuffle: { + vb: '0 0 512 512', + secondary: + 'M0 384c0 17.7 14.3 32 32 32l64 0c30.2 0 58.7-14.2 76.8-38.4L224 309.3c-13.3-17.8-26.7-35.6-40-53.3l-62.4 83.2c-6 8.1-15.5 12.8-25.6 12.8l-64 0c-17.7 0-32 14.3-32 32zM224 202.7c13.3 17.8 26.7 35.6 40 53.3l62.4-83.2c6-8.1 15.5-12.8 25.6-12.8l32 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c6-6 9.4-14.1 9.4-22.6s-3.4-16.6-9.4-22.6l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S384 51.1 384 64l0 32-32 0c-30.2 0-58.7 14.2-76.8 38.4L224 202.7z', + primary: + 'M352 416c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0z', + }, + 'book-open': { + vb: '0 0 512 512', + secondary: + 'M256 72l0 445.3c4.2 0 8.4-.8 12.3-2.5l12.8-5.3c46.8-19.5 97-29.5 147.7-29.5l35.2 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-35.2 0c-50.7 0-100.9 10-147.7 29.5L256 72z', + primary: + 'M256 72L230.9 61.5C184.1 42 133.9 32 83.2 32L48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l35.2 0c50.7 0 100.9 10 147.7 29.5l12.8 5.3c3.9 1.6 8.1 2.5 12.3 2.5L256 72z', + }, + flask: { + vb: '0 0 448 512', + secondary: 'M68.9 448l73.1-128 164 0 73.1 128-310.3 0z', + primary: + 'M160 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l0 151.5 120.5 210.8c4.9 8.7 7.5 18.5 7.5 28.4 0 31.6-25.6 57.3-57.3 57.3L57.3 512C25.6 512 0 486.4 0 454.7 0 444.7 2.6 435 7.5 426.3L128 215.5 128 64c-17.7 0-32-14.3-32-32S110.3 0 128 0l32 0zm32 64l0 151.5c0 11.1-2.9 22.1-8.4 31.8L68.9 448 379.1 448 264.4 247.3c-5.5-9.7-8.4-20.6-8.4-31.8l0-151.5-64 0z', + }, + code: { + vb: '0 0 576 512', + secondary: + 'M193.2 471.2c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6s-34.7 5-39.6 22l-128 448z', + primary: + 'M425.4 137.4c-12.5 12.5-12.5 32.8 0 45.3l73.4 73.4-73.4 73.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3l-96-96c-12.5-12.5-32.8-12.5-45.3 0zm-274.7 0c-12.5-12.5-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 150.6 182.6c12.5-12.5 12.5-32.8 0-45.3z', + }, + palette: { + vb: '0 0 512 512', + secondary: + 'M0 256c0 141.4 114.6 256 256 256 3.5 0 7.1-.1 10.6-.2 31.8-1.3 53.4-30.1 53.4-62 0-14.5-6.1-28.3-12.1-42-4.3-9.8-8.7-19.7-10.8-29.9-.7-3.2-1-6.5-1-9.9 0-26.5 21.5-48 48-48l97.9 0c36.5 0 69.7-24.8 70.1-61.3 0-.9 0-1.8 0-2.7 0-141.4-114.6-256-256-256S0 114.6 0 256zm128 32a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm32-128a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zM288 96a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm128 64a32 32 0 1 1 -64 0 32 32 0 1 1 64 0z', + primary: + 'M224 96a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm-96 32a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM96 256a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM384 128a32 32 0 1 1 0 64 32 32 0 1 1 0-64z', + }, + copy: { + vb: '0 0 448 512', + secondary: + 'M0 192L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0c-35.3 0-64 28.7-64 64z', + primary: + 'M128 64c0-35.3 28.7-64 64-64L326.3 0c16.5 0 32.4 6.4 44.3 17.8l57.8 55.4C440.9 85.3 448 102 448 119.4L448 320c0 35.3-28.7 64-64 64l-192 0c-35.3 0-64-28.7-64-64l0-256z', + }, + check: { + vb: '0 0 448 512', + secondary: + 'M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z', + }, + 'comment-lines': { + vb: '0 0 512 512', + secondary: + 'M0 240c0 54.3 19.2 104.3 51.6 144.5L2.8 476.8c-4.8 9-3.3 20 3.6 27.5s17.8 9.8 27.1 5.8l118.4-50.7c31.8 13.3 67.1 20.7 104.1 20.7 141.4 0 256-107.5 256-240S397.4 0 256 0 0 107.5 0 240zm128-40c0-13.3 10.7-24 24-24l208 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-208 0c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24l112 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-112 0c-13.3 0-24-10.7-24-24z', + primary: + 'M128 200c0-13.3 10.7-24 24-24l208 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-208 0c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24l112 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-112 0c-13.3 0-24-10.7-24-24z', + }, +} satisfies Record; + +export type IconName = keyof typeof ICONS; + +interface IconProps { + name: IconName; + className?: string; + style?: CSSProperties; +} + +export default function Icon({ name, className, style }: IconProps) { + const def: IconDef = ICONS[name]; + return ( + + ); +} diff --git a/src/components/Algorithm/Player.tsx b/src/components/Algorithm/Player.tsx new file mode 100644 index 0000000..84fcb75 --- /dev/null +++ b/src/components/Algorithm/Player.tsx @@ -0,0 +1,379 @@ +import { useEffect, useMemo, useReducer, type CSSProperties } from 'react'; +import type { ArrayTrace, GeneratorDef } from './types'; +import { applyArrayStep, initArrayState } from './applyStep'; +import ArrayScene from './ArrayScene'; +import CodePanel from './CodePanel'; +import Icon, { type IconName } from './Icon'; +import styles from './styles.module.css'; + +const SPEEDS = [0.5, 1, 2, 4]; +const TICK_MS = [1100, 600, 300, 150]; +const DUR_MS = [520, 420, 300, 180]; + +interface PlayerProps { + trace: ArrayTrace; + gen: GeneratorDef; + showExplain: boolean; + colorMode: 'gradient' | 'rainbow'; + /** Pannello pseudocodice visibile (già combinato con il toggle). */ + showCode: boolean; + /** Modalità avanzata (segue il toggle codice): mostra shuffle, esegui e il + * selettore di velocità. Spenta: resta solo l'avanzamento passo-passo. */ + advanced: boolean; + showStats: boolean; + onShuffle: () => void; + initialSpeedIdx: number; +} + +interface PlayerState { + stepIndex: number; + playing: boolean; + speedIdx: number; +} + +type Action = + | { type: 'FWD' } + | { type: 'BACK' } + | { type: 'RESET' } + | { type: 'TOGGLE_PLAY' } + | { type: 'STOP' } + | { type: 'SEEK'; to: number } + | { type: 'SET_SPEED'; idx: number }; + +export default function Player({ + trace, + gen, + showExplain, + colorMode, + showCode, + advanced, + showStats, + onShuffle, + initialSpeedIdx, +}: PlayerProps) { + const total = trace.steps.length; + + const [state, dispatch] = useReducer( + (s: PlayerState, action: Action): PlayerState => { + switch (action.type) { + case 'FWD': { + const ni = Math.min(s.stepIndex + 1, total); + return { + ...s, + stepIndex: ni, + playing: ni >= total ? false : s.playing, + }; + } + case 'BACK': + return { + ...s, + stepIndex: Math.max(s.stepIndex - 1, 0), + playing: false, + }; + case 'RESET': + return { ...s, stepIndex: 0, playing: false }; + case 'TOGGLE_PLAY': + if (s.stepIndex >= total) + return { ...s, stepIndex: 0, playing: true }; + return { ...s, playing: !s.playing }; + case 'STOP': + return { ...s, playing: false }; + case 'SEEK': + return { ...s, stepIndex: action.to, playing: false }; + case 'SET_SPEED': + return { ...s, speedIdx: action.idx }; + default: + return s; + } + }, + { stepIndex: 0, playing: false, speedIdx: initialSpeedIdx }, + ); + + // Nuova trace (Mescola / cambio dati): torno all'inizio, tengo la velocità. + useEffect(() => { + dispatch({ type: 'RESET' }); + }, [trace]); + + // Uscendo dalla modalità avanzata (es. passaggio a Studio) fermo l'autoplay: + // senza il pulsante Esegui non sarebbe più interrompibile. + useEffect(() => { + if (!advanced) dispatch({ type: 'STOP' }); + }, [advanced]); + + // Autoplay. + useEffect(() => { + if (!state.playing) return undefined; + const id = setTimeout( + () => dispatch({ type: 'FWD' }), + TICK_MS[state.speedIdx], + ); + return () => clearTimeout(id); + }, [state.playing, state.stepIndex, state.speedIdx]); + + const scene = useMemo( + () => + trace.steps + .slice(0, state.stepIndex) + .reduce(applyArrayStep, initArrayState(trace)), + [trace, state.stepIndex], + ); + + const isSort = gen.kind === 'sort'; + + // Una sola scansione dell'intera trace per: + // - la corsia sotto le barre (altezza fissa → niente salti quando badge e + // graffe compaiono o spariscono; minima per chi non usa nulla); + // - la legenda, che elenca SOLO gli stati realmente usati dall'algoritmo + // (es. niente «Chiave» nel bubble sort). + const { laneH, legend } = useMemo(() => { + let badge = false; + let range = false; + let extract = false; + let hasCompare = false; + let hasSwap = false; + let hasSorted = false; + for (const s of trace.steps) { + switch (s.op) { + case 'pointer': + if (s.i !== null) badge = true; + break; + case 'range': + range = true; + break; + case 'extract': + extract = true; + break; + case 'compare': + case 'compareTarget': + case 'compareExtracted': + hasCompare = true; + break; + case 'swap': + case 'shiftRight': + hasSwap = true; + break; + case 'markSorted': + case 'outcome': + hasSorted = true; + break; + } + } + // Minimo: spazio per il «glow» (ombra dell'anello) sotto le barre, così + // non viene tagliato dove la corsia sarebbe altrimenti cortissima. + let h = 30; + if (extract) h = Math.max(h, 72); + if (badge) h = Math.max(h, 58); + if (range) h = Math.max(h, 98); + + const lg: [string, string][] = []; + if (hasCompare) lg.push(['compare', 'Confronto']); + if (hasSwap) lg.push(['swap', 'Scambio']); + if (extract) lg.push(['key', 'Chiave']); + if (hasSorted) lg.push(['sorted', isSort ? 'Ordinato' : 'Trovato']); + return { laneH: h, legend: lg }; + }, [trace, isSort]); + + const atStart = state.stepIndex === 0; + const atEnd = state.stepIndex >= total; + const progress = total > 0 ? (state.stepIndex / total) * 100 : 0; + + const status = atEnd + ? { cls: styles.dotDone, label: 'completato' } + : state.playing + ? { cls: styles.dotPlay, label: 'in esecuzione' } + : atStart + ? { cls: styles.dotIdle, label: 'pronto' } + : { cls: styles.dotPause, label: 'in pausa' }; + + const primary = scene.note || scene.phase; + const secondary = scene.note && scene.phase !== scene.note ? scene.phase : ''; + + const durStyle = { + '--alg-dur': `${DUR_MS[state.speedIdx]}ms`, + } as CSSProperties; + + return ( + <> +
+
+ +
+ + {showExplain && ( +
+

+ + {String(state.stepIndex).padStart(2, '0')} + + {primary} +

+ {secondary &&

{secondary}

} +
+ )} + +
+ {legend.map(([k, lbl]) => ( + + + {lbl} + + ))} +
+
+ + {showCode && gen.code && ( + + )} + +
+
+ + + {status.label} + + + passo {state.stepIndex} / {total} + +
+ +
+
+
+
+ + dispatch({ type: 'SEEK', to: Number(e.target.value) }) + } + /> +
+ +
+ {advanced && ( + + )} + dispatch({ type: 'RESET' })} + disabled={atStart} + /> + dispatch({ type: 'BACK' })} + disabled={atStart} + /> + {advanced && ( + + )} + {advanced ? ( + dispatch({ type: 'FWD' })} + disabled={atEnd} + /> + ) : ( + // Studio: senza «Esegui», l'avanzamento passo-passo è l'azione + // principale → bottone esteso e colorato come la primaria. + + )} + + {advanced && ( + <> + + +
+
+ + )} +
+
+ + ); +} + +interface IconBtnProps { + icon: IconName; + label: string; + onClick: () => void; + disabled?: boolean; +} + +function IconBtn({ icon, label, onClick, disabled }: IconBtnProps) { + return ( + + ); +} diff --git a/src/components/Algorithm/applyStep.ts b/src/components/Algorithm/applyStep.ts new file mode 100644 index 0000000..13f415e --- /dev/null +++ b/src/components/Algorithm/applyStep.ts @@ -0,0 +1,200 @@ +import type { ArraySceneState, ArrayStep, ArrayTrace } from './types'; + +/** Avviso solo in sviluppo: le precondizioni violate non lanciano. */ +function devWarn(message: string): void { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(`[Algorithm] ${message}`); + } +} + +/** Stato iniziale della scena a partire dalla trace. */ +export function initArrayState(trace: ArrayTrace): ArraySceneState { + return { + items: trace.initial.map((value, id) => ({ id, value })), + extracted: null, + pointers: [], + range: null, + comparing: null, + sorted: [], + faded: [], + swapping: [], + outcome: null, + phase: '', + note: '', + line: null, + cursor: null, + comparisons: 0, + swaps: 0, + }; +} + +/** Applica uno step allo stato in modo **puro** (ritorna un nuovo stato). + * La nota corrente si azzera se lo step non ne porta una; la fase persiste. */ +export function applyArrayStep( + state: ArraySceneState, + step: ArrayStep, +): ArraySceneState { + const next: ArraySceneState = { ...state }; + + // Evidenziazioni transienti: riflettono SOLO lo step corrente. + next.comparing = null; + next.swapping = []; + // Riga di pseudocodice e cursore di posizione persistono finché uno step + // non li cambia (undefined = invariato; null = nascondi). + if (step.line !== undefined) next.line = step.line; + if (step.cursor !== undefined) next.cursor = step.cursor; + + switch (step.op) { + case 'compare': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'item', i: step.j }, + }; + next.comparisons = state.comparisons + 1; + break; + + case 'compareTarget': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'target' }, + }; + next.comparisons = state.comparisons + 1; + break; + + case 'compareExtracted': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'extracted' }, + }; + next.comparisons = state.comparisons + 1; + break; + + case 'swap': { + const { i, j } = step; + if ( + i < 0 || + j < 0 || + i >= state.items.length || + j >= state.items.length + ) { + devWarn(`swap fuori range: ${i}, ${j}`); + break; + } + const items = state.items.slice(); + [items[i], items[j]] = [items[j], items[i]]; + next.items = items; + next.swapping = [i, j]; + next.swaps = state.swaps + 1; + break; + } + + case 'extract': { + const item = state.items[step.i]; + if (!item) { + devWarn(`extract su slot vuoto: ${step.i}`); + break; + } + const items = state.items.slice(); + items[step.i] = null; + next.items = items; + next.extracted = { item, overIndex: step.i }; + next.comparing = null; + break; + } + + case 'shiftRight': { + const { from } = step; + // precondizione: lo slot a destra dev'essere vuoto. + if (state.items[from + 1] !== null) { + devWarn(`shiftRight: slot ${from + 1} non vuoto`); + break; + } + if (!state.items[from]) { + devWarn(`shiftRight: slot ${from} vuoto`); + break; + } + const items = state.items.slice(); + items[from + 1] = items[from]; + items[from] = null; + next.items = items; + if (state.extracted) { + next.extracted = { ...state.extracted, overIndex: from }; + } + next.swapping = [from, from + 1]; + next.swaps = state.swaps + 1; + break; + } + + case 'insertAt': { + const { i } = step; + if (state.items[i] !== null) { + devWarn(`insertAt: slot ${i} non vuoto`); + break; + } + if (!state.extracted) { + devWarn('insertAt: nessun elemento estratto'); + break; + } + const items = state.items.slice(); + items[i] = state.extracted.item; + next.items = items; + next.extracted = null; + next.comparing = null; + break; + } + + case 'pointer': { + const pointers = state.pointers.filter((p) => p.name !== step.name); + if (step.i !== null) pointers.push({ name: step.name, i: step.i }); + next.pointers = pointers; + break; + } + + case 'range': + next.range = { lo: step.lo, hi: step.hi, label: step.label }; + break; + + case 'clearRange': + next.range = null; + break; + + case 'markSorted': { + const set = new Set(state.sorted); + step.indices.forEach((x) => set.add(x)); + next.sorted = Array.from(set).sort((a, b) => a - b); + break; + } + + case 'fade': { + const set = new Set(state.faded); + step.indices.forEach((x) => set.add(x)); + next.faded = Array.from(set).sort((a, b) => a - b); + break; + } + + case 'outcome': + next.outcome = step.found + ? { found: true, i: step.i as number } + : { found: false }; + next.comparing = null; + break; + + case 'phase': + next.phase = step.text; + break; + + case 'note': + // solo gestione nota (sotto) + break; + + default: { + const exhaustive: never = step; + devWarn(`op sconosciuta: ${JSON.stringify(exhaustive)}`); + } + } + + // Regola trasversale, dopo l'effetto specifico. + next.note = step.note ?? ''; + return next; +} diff --git a/src/components/Algorithm/generators/binarySearch.ts b/src/components/Algorithm/generators/binarySearch.ts new file mode 100644 index 0000000..08c6320 --- /dev/null +++ b/src/components/Algorithm/generators/binarySearch.ts @@ -0,0 +1,87 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; +import { pickSearchTarget } from './pickTarget'; + +function generate({ data, target }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const tgt = target as number; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + text: 'La sequenza è ordinata: a ogni passo posso scartare metà dei numeri rimasti.', + }); + + let lo = 0; + let hi = n - 1; + // Nota della decisione precedente, mostrata sulla prossima op `range`. + let pendingNote: string | undefined; + + while (lo <= hi) { + steps.push({ + op: 'range', + lo, + hi, + note: pendingNote ?? `Cerco tra le posizioni ${lo} e ${hi}.`, + }); + pendingNote = undefined; + const mid = (lo + hi) >> 1; + steps.push({ + op: 'pointer', + name: 'mid', + i: mid, + note: `Guardo il numero in mezzo: ${a[mid]}.`, + }); + steps.push({ + op: 'compareTarget', + i: mid, + note: `${a[mid]} è il numero che cerco?`, + }); + if (a[mid] === tgt) { + steps.push({ + op: 'outcome', + found: true, + i: mid, + note: `Trovato! ${tgt} è in posizione ${mid}.`, + }); + steps.push({ op: 'pointer', name: 'mid', i: null }); + steps.push({ op: 'clearRange' }); + return { initial: data.slice(), target: tgt, steps }; + } + if (a[mid] < tgt) { + pendingNote = `${a[mid]} è troppo piccolo: scarto la metà sinistra.`; + lo = mid + 1; + } else { + pendingNote = `${a[mid]} è troppo grande: scarto la metà destra.`; + hi = mid - 1; + } + } + + steps.push({ op: 'pointer', name: 'mid', i: null }); + steps.push({ op: 'clearRange' }); + steps.push({ + op: 'outcome', + found: false, + note: `Non è rimasto niente da controllare: ${tgt} non c’è.`, + }); + + return { initial: data.slice(), target: tgt, steps }; +} + +export const binarySearch: GeneratorDef = { + id: 'binary-search', + label: 'Ricerca binaria', + kind: 'search', + file: 'binary_search.py', + complexity: 'O(log n)', + blurb: 'Dimezza ogni volta l’intervallo di ricerca su dati ordinati.', + defaultData: [1, 3, 4, 7, 9, 11, 14, 17], + prepare: (data) => data.slice().sort((x, y) => x - y), + pickTarget: pickSearchTarget, + generate, +}; diff --git a/src/components/Algorithm/generators/bubbleSort.ts b/src/components/Algorithm/generators/bubbleSort.ts new file mode 100644 index 0000000..56ad321 --- /dev/null +++ b/src/components/Algorithm/generators/bubbleSort.ts @@ -0,0 +1,107 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +// Righe di pseudocodice (indice 0-based = valore di `line` negli step). +const CODE = [ + 'def bubble_sort(a):', // 0 + ' n = len(a)', // 1 + ' for i in range(n - 1):', // 2 + ' for j in range(n - 1 - i):', // 3 + ' if a[j] > a[j + 1]:', // 4 + ' a[j], a[j+1] = a[j+1], a[j]', // 5 + ' return a', // 6 +]; + +function generate({ data }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + line: 1, + text: 'Confronto i numeri vicini a due a due e, se sono nell’ordine sbagliato, li scambio: a ogni passata il più grande «risale» verso il fondo.', + }); + + let brokeEarly = false; + for (let pass = 0; pass <= n - 2; pass++) { + let swapped = false; + for (let j = 0; j <= n - 2 - pass; j++) { + steps.push({ + op: 'compare', + i: j, + j: j + 1, + line: 4, + cursor: { i: j, label: 'j' }, + note: `La coppia ${a[j]} e ${a[j + 1]}: il primo è più grande del secondo? Se sì, sono nell’ordine sbagliato.`, + }); + if (a[j] > a[j + 1]) { + const maggiore = a[j]; + steps.push({ + op: 'swap', + i: j, + j: j + 1, + line: 5, + cursor: { i: j, label: 'j' }, + note: `${maggiore} è più grande di ${a[j + 1]}: erano invertiti, li scambio così il maggiore scala verso destra.`, + }); + [a[j], a[j + 1]] = [a[j + 1], a[j]]; + swapped = true; + } else { + steps.push({ + op: 'note', + line: 4, + cursor: { i: j, label: 'j' }, + note: `${a[j]} non supera ${a[j + 1]}: la coppia è già ordinata, la lascio com’è e proseguo.`, + }); + } + } + steps.push({ + op: 'markSorted', + indices: [n - 1 - pass], + line: 3, + note: `Passata finita: ${a[n - 1 - pass]} era il più grande tra quelli rimasti ed è arrivato in fondo. Da qui non lo tocco più.`, + }); + if (!swapped) { + const rest: number[] = []; + for (let k = 0; k <= n - 2 - pass; k++) rest.push(k); + if (rest.length) steps.push({ op: 'markSorted', indices: rest, line: 6 }); + steps.push({ + op: 'phase', + line: 6, + cursor: null, + text: 'Una passata senza nemmeno uno scambio significa che è già tutto in ordine: posso fermarmi qui.', + }); + brokeEarly = true; + break; + } + } + + if (!brokeEarly) { + steps.push({ op: 'markSorted', indices: [0], line: 6 }); + steps.push({ + op: 'phase', + line: 6, + cursor: null, + text: 'Ogni numero è al suo posto: l’ordinamento è finito.', + }); + } + + return { initial: data.slice(), steps }; +} + +export const bubbleSort: GeneratorDef = { + id: 'bubble-sort', + label: 'Bubble sort', + kind: 'sort', + file: 'bubble_sort.py', + complexity: 'O(n²)', + blurb: 'Confronta coppie adiacenti e le scambia finché non sono in ordine.', + code: CODE, + defaultData: [5, 3, 8, 1, 9, 2, 7], + generate, +}; diff --git a/src/components/Algorithm/generators/index.ts b/src/components/Algorithm/generators/index.ts new file mode 100644 index 0000000..7739851 --- /dev/null +++ b/src/components/Algorithm/generators/index.ts @@ -0,0 +1,20 @@ +import type { GeneratorDef } from '../types'; +import { bubbleSort } from './bubbleSort'; +import { selectionSort } from './selectionSort'; +import { insertionSort } from './insertionSort'; +import { linearSearch } from './linearSearch'; +import { binarySearch } from './binarySearch'; +import { quicksort } from './quicksort'; + +const REGISTRY: Record = { + [bubbleSort.id]: bubbleSort, + [selectionSort.id]: selectionSort, + [insertionSort.id]: insertionSort, + [linearSearch.id]: linearSearch, + [binarySearch.id]: binarySearch, + [quicksort.id]: quicksort, +}; + +export function getGenerator(id: string): GeneratorDef | undefined { + return REGISTRY[id]; +} diff --git a/src/components/Algorithm/generators/insertionSort.ts b/src/components/Algorithm/generators/insertionSort.ts new file mode 100644 index 0000000..e476499 --- /dev/null +++ b/src/components/Algorithm/generators/insertionSort.ts @@ -0,0 +1,96 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +const CODE = [ + 'def insertion_sort(a):', // 0 + ' for i in range(1, len(a)):', // 1 + ' key = a[i]', // 2 + ' j = i - 1', // 3 + ' while j >= 0 and a[j] > key:', // 4 + ' a[j + 1] = a[j]', // 5 + ' j -= 1', // 6 + ' a[j + 1] = key', // 7 + ' return a', // 8 +]; + +function generate({ data }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + line: 1, + text: 'Prendo i numeri uno alla volta e li inserisco al posto giusto tra quelli già sistemati.', + }); + steps.push({ + op: 'markSorted', + indices: [0], + line: 1, + note: 'Il primo numero, da solo, è già “ordinato”.', + }); + + for (let i = 1; i <= n - 1; i++) { + const key = a[i]; + steps.push({ + op: 'extract', + i, + line: 2, + note: `Estraggo ${key} e gli cerco il posto tra i numeri a sinistra.`, + }); + let j = i - 1; + while (j >= 0 && a[j] > key) { + steps.push({ + op: 'compareExtracted', + i: j, + line: 4, + note: `${a[j]} è più grande di ${key}: lo faccio scorrere a destra.`, + }); + steps.push({ op: 'shiftRight', from: j, line: 5 }); + a[j + 1] = a[j]; + j -= 1; + } + if (j >= 0) { + steps.push({ + op: 'compareExtracted', + i: j, + line: 4, + note: `${a[j]} non è più grande di ${key}: il posto è qui accanto.`, + }); + } + steps.push({ + op: 'insertAt', + i: j + 1, + line: 7, + note: `Inserisco ${key}.`, + }); + a[j + 1] = key; + const range: number[] = []; + for (let k = 0; k <= i; k++) range.push(k); + steps.push({ op: 'markSorted', indices: range, line: 7 }); + } + + steps.push({ + op: 'phase', + line: 8, + text: 'Tutti i numeri sono al loro posto: ho finito.', + }); + + return { initial: data.slice(), steps }; +} + +export const insertionSort: GeneratorDef = { + id: 'insertion-sort', + label: 'Insertion sort', + kind: 'sort', + file: 'insertion_sort.py', + complexity: 'O(n²)', + blurb: 'Inserisce ogni elemento al posto giusto nella parte già ordinata.', + code: CODE, + defaultData: [3, 4, 5, 7, 2, 8, 6, 9, 1], + generate, +}; diff --git a/src/components/Algorithm/generators/linearSearch.ts b/src/components/Algorithm/generators/linearSearch.ts new file mode 100644 index 0000000..4a471cc --- /dev/null +++ b/src/components/Algorithm/generators/linearSearch.ts @@ -0,0 +1,64 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; +import { pickSearchTarget } from './pickTarget'; + +function generate({ data, target }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const tgt = target as number; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + text: 'Controllo i numeri uno per uno, dal primo all’ultimo.', + }); + + for (let i = 0; i <= n - 1; i++) { + steps.push({ op: 'pointer', name: 'i', i }); + steps.push({ + op: 'compareTarget', + i, + note: `${a[i]} è il numero che cerco?`, + }); + if (a[i] === tgt) { + steps.push({ + op: 'outcome', + found: true, + i, + note: `Sì! ${tgt} è in posizione ${i}.`, + }); + steps.push({ op: 'pointer', name: 'i', i: null }); + return { initial: data.slice(), target: tgt, steps }; + } + steps.push({ + op: 'fade', + indices: [i], + note: 'No: lo scarto e vado avanti.', + }); + } + + steps.push({ op: 'pointer', name: 'i', i: null }); + steps.push({ + op: 'outcome', + found: false, + note: `Li ho controllati tutti: ${tgt} non c’è.`, + }); + + return { initial: data.slice(), target: tgt, steps }; +} + +export const linearSearch: GeneratorDef = { + id: 'linear-search', + label: 'Ricerca lineare', + kind: 'search', + file: 'linear_search.py', + complexity: 'O(n)', + blurb: 'Scorre gli elementi uno a uno finché non trova il valore cercato.', + defaultData: [4, 7, 1, 9, 3, 6, 2], + pickTarget: pickSearchTarget, + generate, +}; diff --git a/src/components/Algorithm/generators/pickTarget.ts b/src/components/Algorithm/generators/pickTarget.ts new file mode 100644 index 0000000..f4867a1 --- /dev/null +++ b/src/components/Algorithm/generators/pickTarget.ts @@ -0,0 +1,13 @@ +/** Sceglie il valore da cercare per i generatori di ricerca. + * Con probabilità 0.75 un elemento presente (esito «trovato»), altrimenti + * il più piccolo intero positivo assente (esito «non trovato»). + * rnd ∈ [0,1) dal PRNG seeded → determinismo SSR. */ +export function pickSearchTarget(data: number[], rnd: () => number): number { + if (rnd() < 0.75 && data.length > 0) { + return data[Math.floor(rnd() * data.length)]; + } + const present = new Set(data); + let t = 1; + while (present.has(t)) t += 1; + return t; +} diff --git a/src/components/Algorithm/generators/quicksort.ts b/src/components/Algorithm/generators/quicksort.ts new file mode 100644 index 0000000..cd3dcce --- /dev/null +++ b/src/components/Algorithm/generators/quicksort.ts @@ -0,0 +1,105 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +function generate({ data }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + text: 'Scelgo un perno e divido i numeri: prima i più piccoli, poi il perno, poi i più grandi. Ripeto sulle due parti.', + }); + + function qs(lo: number, hi: number): void { + if (lo > hi) return; + if (lo === hi) { + steps.push({ + op: 'markSorted', + indices: [lo], + note: 'Un numero da solo è già al suo posto.', + }); + return; + } + steps.push({ op: 'range', lo, hi, label: 'partiziono' }); + const pivot = a[hi]; + steps.push({ + op: 'pointer', + name: 'P', + i: hi, + note: `Il perno è ${pivot}.`, + }); + let i = lo; + steps.push({ op: 'pointer', name: 'L', i }); + for (let j = lo; j <= hi - 1; j++) { + steps.push({ op: 'pointer', name: 'R', i: j }); + steps.push({ + op: 'compare', + i: j, + j: hi, + note: `${a[j]} è più piccolo del perno ${pivot}?`, + }); + if (a[j] < pivot) { + if (i !== j) { + steps.push({ + op: 'swap', + i, + j, + note: 'Sì: lo sposto nella zona dei più piccoli.', + }); + [a[i], a[j]] = [a[j], a[i]]; + } else { + steps.push({ op: 'note', note: 'Sì, ed è già nella zona giusta.' }); + } + i += 1; + steps.push({ op: 'pointer', name: 'L', i }); + } else { + steps.push({ op: 'note', note: 'No: per ora resta dov’è.' }); + } + } + if (i !== hi) { + steps.push({ + op: 'swap', + i, + j: hi, + note: 'Metto il perno tra le due zone: è al suo posto definitivo.', + }); + [a[i], a[hi]] = [a[hi], a[i]]; + } else { + steps.push({ + op: 'note', + note: 'Il perno è già al suo posto definitivo.', + }); + } + steps.push({ op: 'markSorted', indices: [i] }); + steps.push({ op: 'pointer', name: 'L', i: null }); + steps.push({ op: 'pointer', name: 'R', i: null }); + steps.push({ op: 'pointer', name: 'P', i: null }); + steps.push({ op: 'clearRange' }); + qs(lo, i - 1); + qs(i + 1, hi); + } + + qs(0, n - 1); + steps.push({ + op: 'phase', + text: 'Tutti i numeri sono al loro posto: ho finito.', + }); + + return { initial: data.slice(), steps }; +} + +export const quicksort: GeneratorDef = { + id: 'quicksort', + label: 'Quicksort', + kind: 'sort', + file: 'quicksort.py', + complexity: 'O(n log n)', + blurb: 'Sceglie un pivot e partiziona; poi ordina ricorsivamente i due lati.', + defaultData: [3, 5, 4, 1, 2, 9, 8, 7, 6], + generate, +}; diff --git a/src/components/Algorithm/generators/selectionSort.ts b/src/components/Algorithm/generators/selectionSort.ts new file mode 100644 index 0000000..9130ffe --- /dev/null +++ b/src/components/Algorithm/generators/selectionSort.ts @@ -0,0 +1,99 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +const CODE = [ + 'def selection_sort(a):', // 0 + ' n = len(a)', // 1 + ' for i in range(n):', // 2 + ' m = i', // 3 + ' for j in range(i + 1, n):', // 4 + ' if a[j] < a[m]:', // 5 + ' m = j', // 6 + ' a[i], a[m] = a[m], a[i]', // 7 + ' return a', // 8 +]; + +function generate({ data }: GeneratorInput): ArrayTrace { + const a = data.slice(); + const n = a.length; + const steps: ArrayStep[] = []; + + steps.push({ + op: 'phase', + line: 1, + text: 'A ogni giro cerco il numero più piccolo della parte non ordinata e lo porto in testa.', + }); + + for (let i = 0; i <= n - 2; i++) { + let min = i; + // `min` viaggia come cursore (caret + etichetta), lo stesso linguaggio + // visivo della `j` del bubble sort: niente badge stretto. + steps.push({ + op: 'note', + line: 3, + cursor: { i, label: 'min' }, + note: `Per ora il più piccolo è ${a[i]}.`, + }); + for (let j = i + 1; j <= n - 1; j++) { + steps.push({ + op: 'compare', + i: j, + j: min, + line: 5, + note: `Confronto ${a[j]} con il minimo attuale, ${a[min]}.`, + }); + if (a[j] < a[min]) { + min = j; + steps.push({ + op: 'note', + line: 6, + cursor: { i: j, label: 'min' }, + note: `${a[j]} è più piccolo: è il nuovo minimo.`, + }); + } + } + if (min !== i) { + steps.push({ + op: 'swap', + i, + j: min, + line: 7, + note: `Porto ${a[min]} all’inizio della parte non ordinata.`, + }); + [a[i], a[min]] = [a[min], a[i]]; + } else { + steps.push({ + op: 'note', + line: 7, + note: `${a[i]} è già al posto giusto.`, + }); + } + steps.push({ op: 'markSorted', indices: [i], line: 2 }); + } + + // Ultimo elemento ordinato di conseguenza: nascondo il cursore `min`. + steps.push({ op: 'markSorted', indices: [n - 1], line: 8, cursor: null }); + steps.push({ + op: 'phase', + line: 8, + text: 'Tutti i numeri sono al loro posto: ho finito.', + }); + + return { initial: data.slice(), steps }; +} + +export const selectionSort: GeneratorDef = { + id: 'selection-sort', + label: 'Selection sort', + kind: 'sort', + file: 'selection_sort.py', + complexity: 'O(n²)', + blurb: 'Trova il minimo della parte non ordinata e lo porta in testa.', + code: CODE, + defaultData: [9, 5, 3, 1, 2, 8, 6, 4, 7], + generate, +}; diff --git a/src/components/Algorithm/highlightPy.ts b/src/components/Algorithm/highlightPy.ts new file mode 100644 index 0000000..a104df7 --- /dev/null +++ b/src/components/Algorithm/highlightPy.ts @@ -0,0 +1,68 @@ +import { createElement, type ReactNode } from 'react'; +import styles from './styles.module.css'; + +const KEYWORDS = new Set([ + 'def', + 'for', + 'while', + 'if', + 'in', + 'range', + 'len', + 'return', + 'and', + 'or', + 'not', +]); + +const PUNCT = '(),=[]+-<>'; + +/** Evidenziazione Python minimale: numeri, keyword/funzioni, punteggiatura. + * I colori vivono in CSS (classi tematizzate light/dark). */ +export function highlightPy(line: string): ReactNode[] { + const out: ReactNode[] = []; + let rest = line; + let k = 0; + + while (rest.length) { + const numMatch = rest.match(/^\d+/); + if (numMatch) { + out.push( + createElement( + 'span', + { key: k++, className: styles.tNum }, + numMatch[0], + ), + ); + rest = rest.slice(numMatch[0].length); + continue; + } + const wordMatch = rest.match(/^[a-zA-Z_]\w*/); + if (wordMatch) { + const w = wordMatch[0]; + out.push( + createElement( + 'span', + { key: k++, className: KEYWORDS.has(w) ? styles.tKw : styles.tDef }, + w, + ), + ); + rest = rest.slice(w.length); + continue; + } + const ch = rest[0]; + out.push( + createElement( + 'span', + { + key: k++, + className: PUNCT.includes(ch) ? styles.tPunct : styles.tDef, + }, + ch, + ), + ); + rest = rest.slice(1); + } + + return out; +} diff --git a/src/components/Algorithm/index.tsx b/src/components/Algorithm/index.tsx new file mode 100644 index 0000000..1933f41 --- /dev/null +++ b/src/components/Algorithm/index.tsx @@ -0,0 +1,279 @@ +import { useMemo, useState } from 'react'; +import { getGenerator } from './generators'; +import { mulberry32, shuffledRange } from './prng'; +import Player from './Player'; +import Icon from './Icon'; +import { useAlgoPref } from './usePref'; +import type { AlgorithmMode } from './types'; +import styles from './styles.module.css'; + +const SPEED_IDX: Record, number> = { + slow: 0, + normal: 1, + fast: 2, +}; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +/** I preset configurano i toggle (codice, spiegazione). Il toggle «codice» + * governa anche i controlli avanzati (shuffle, esegui, velocità): in Studio, + * con il codice spento, resta solo l'avanzamento passo-passo. Toccare un + * toggle a mano «sgancia» il preset (mode → null); ripremerlo li riapplica. */ +const PRESETS: Record = { + study: { code: false, explain: true }, + lab: { code: true, explain: false }, +}; + +interface AlgorithmProps { + /** Id del generatore registrato (vedi generators/index.ts). */ + name: string; + /** Preset di partenza del blocco (regia d'autore). 'study' (default): + * spiegazione + codice; 'lab': solo codice. Lo studente può cambiarlo. */ + mode?: AlgorithmMode; + /** Valori espliciti (interi 1–99, max 12 elementi). */ + data?: number[]; + /** In alternativa a data: n valori casuali (permutazione di 1..n, clamp 4–10). */ + shuffle?: number; + /** Valore cercato (kind 'search'). */ + target?: number; + /** Velocità iniziale. */ + speed?: 'slow' | 'normal' | 'fast'; + /** Il blocco espone il pannello pseudocodice (richiede `code` nel generatore). */ + showCode?: boolean; + /** Il pannello include la colonna statistiche. */ + showStats?: boolean; + /** Didascalia sotto la card. */ + caption?: string; +} + +export default function Algorithm({ + name, + mode: initialMode = 'study', + data, + shuffle, + target, + speed = 'normal', + showCode, + showStats, + caption, +}: AlgorithmProps) { + // Seed iniziale fisso (1): markup server e client coincidono (hydration). + const [seed] = useState(1); + // Permutazione casuale impostata da «Mescola» (azione client). + const [override, setOverride] = useState(null); + // Preset attivo (null = configurazione «custom» dopo un toggle manuale) e i + // due toggle che esso governa: stato locale del blocco, NON persistito, così + // ogni blocco riparte dal default scelto dall'autore. Il colore invece è una + // preferenza globale (estetica, non didattica): condivisa e persistita. + const [mode, setMode] = useState(initialMode); + const [panelOpen, setPanelOpen] = useState(PRESETS[initialMode].code); + const [explainOpen, setExplainOpen] = useState(PRESETS[initialMode].explain); + const [colorMode, setColorMode] = useAlgoPref<'gradient' | 'rainbow'>( + 'color', + 'gradient', + ); + const gen = getGenerator(name); + + function applyPreset(p: AlgorithmMode) { + setMode(p); + setPanelOpen(PRESETS[p].code); + setExplainOpen(PRESETS[p].explain); + } + function toggleCode() { + setPanelOpen((v) => !v); + setMode(null); + } + function toggleExplain() { + setExplainOpen((v) => !v); + setMode(null); + } + + const trace = useMemo(() => { + if (!gen) return null; + const rnd = mulberry32(seed); + + let baseData: number[]; + if (override) { + baseData = override; + } else if (data && data.length) { + let d = data.map((v) => clamp(Math.round(v), 1, 99)); + if (d.length > 12) { + // eslint-disable-next-line no-console + console.warn( + `[Algorithm] data troncato a 12 elementi (ne aveva ${d.length}).`, + ); + d = d.slice(0, 12); + } + baseData = d; + } else if (shuffle !== undefined) { + baseData = shuffledRange(clamp(Math.round(shuffle), 4, 10), rnd); + } else { + baseData = gen.defaultData.slice(); + } + + const prepared = gen.prepare ? gen.prepare(baseData) : baseData; + + // Target: prop esplicita, altrimenti pickTarget con lo stesso PRNG + // (consumato DOPO i dati: ordine fisso per il determinismo SSR). + let tgt = target; + if (gen.kind === 'search' && tgt === undefined) { + tgt = gen.pickTarget ? gen.pickTarget(prepared, rnd) : prepared[0]; + } + + return gen.generate({ data: prepared, target: tgt }); + }, [gen, data, shuffle, target, seed, override]); + + if (!gen || !trace) { + // eslint-disable-next-line no-console + console.error(`[Algorithm] algoritmo "${name}" non registrato.`); + return ( +
+ Algorithm: algoritmo “{name}” non registrato +
+ ); + } + + // Il blocco ha un pannello se il generatore porta pseudocodice e l'autore + // non lo disabilita; la sua visibilità è poi governata dal toggle (panelPref). + const hasPanel = (showCode ?? true) && !!gen.code; + const statsOn = showStats ?? true; + + // Lunghezza attiva → «Mescola» genera una nuova permutazione di 1..n. + const activeLen = + override?.length ?? + (data && data.length + ? Math.min(data.length, 12) + : shuffle !== undefined + ? clamp(Math.round(shuffle), 4, 10) + : gen.defaultData.length); + + function mescola() { + const arr = Array.from({ length: activeLen }, (_, k) => k + 1); + for (let k = arr.length - 1; k > 0; k--) { + const r = Math.floor(Math.random() * (k + 1)); + [arr[k], arr[r]] = [arr[r], arr[k]]; + } + setOverride(arr); + } + + return ( + <> +
+
+
+
+ +
+ {hasPanel && ( + + )} + + + {gen.complexity && ( + <> + + {gen.complexity} + + )} +
+
+ + {/* Sotto-header: nome algoritmo + descrizione */} +
+ {gen.label} + {gen.blurb && ( + <> + + {gen.blurb} + + )} +
+ + +
+ {caption &&

{caption}

} + + ); +} diff --git a/src/components/Algorithm/prng.ts b/src/components/Algorithm/prng.ts new file mode 100644 index 0000000..e569b99 --- /dev/null +++ b/src/components/Algorithm/prng.ts @@ -0,0 +1,22 @@ +/** PRNG deterministico (mulberry32): stesso seed → stessa sequenza. + * Serve per SSR/hydration: i dati di `shuffle` devono coincidere tra + * server e client, quindi NIENTE Math.random nel percorso di render. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Permutazione di 1..n via Fisher–Yates con rnd dato. */ +export function shuffledRange(n: number, rnd: () => number): number[] { + const arr = Array.from({ length: n }, (_, i) => i + 1); + for (let i = n - 1; i > 0; i--) { + const j = Math.floor(rnd() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css new file mode 100644 index 0000000..312a3bf --- /dev/null +++ b/src/components/Algorithm/styles.module.css @@ -0,0 +1,1049 @@ +/* ───────────────────────────────────────────────────────────── + — visualizzatore di algoritmi. + Redesign su disegno «Visualizzatore Algoritmi» (Claude Design), + adattato ai token Atmospheric (--at-*) e a Monaspace. + Argon = UI, Neon (tabellare) = numeri/codice, Radon = corsivi. + ───────────────────────────────────────────────────────────── */ +.card { + /* lightness del fill cromatico + colori di stato (anelli e legenda): + definiti sulla card così sia la scena sia la legenda li vedono. */ + --alg-L: 50%; + --alg-bar-text: #ffffff; + /* Colori di stato (anelli + pallini della legenda) tarati sul tema CHIARO: + più scuri e saturi, così contrastano sia con lo sfondo chiaro sia con le + barre a media luminosità. Il tema scuro li sovrascrive più sotto (lì il + bianco e le tinte pastello funzionano contro il fondo scuro). */ + --alg-st-compare: #d97706; + --alg-st-swap: #059669; + --alg-st-sorted: #334155; + --alg-st-key: #9333ea; + border: 1px solid var(--at-border); + border-radius: 18px; + background: var(--at-bg-panel); + backdrop-filter: blur(8px); + margin: 1.4em 0; + overflow: hidden; + font-family: var(--font-mono-ui); + color: var(--at-fg); +} +:global(html[data-theme='dark']) .card { + --alg-L: 62%; + --alg-bar-text: #0b0b0f; + --alg-st-compare: #fbbf24; + --alg-st-swap: #34d399; + --alg-st-sorted: #ffffff; + --alg-st-key: #c084fc; +} + +/* ── Toolbar ────────────────────────────────────────────────── */ +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--at-bg-subtle); + border-bottom: 1px solid var(--at-border); +} + +.headerLeft { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.trafficLights { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.tlDot { + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2); +} +.tlClose { + background: #ff5f57; +} +.tlMin { + background: #febc2e; +} +.tlMax { + background: #28c840; +} + +.fileName { + margin-left: 6px; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--at-muted); + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerRight { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.complexity { + display: inline-flex; + align-items: center; + height: 30px; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--at-muted-soft); + padding: 0 11px; + border-radius: 8px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); +} + +/* Switch Studio/Lab */ +.segmented { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 10px; + padding: 3px; + border-radius: 9px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); +} + +.segBtn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--at-muted); + font-family: var(--font-mono-ui); + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} +.segBtn:hover { + color: var(--at-fg); +} +.segBtnOn { + background: var(--at-bg-panel); + color: var(--at-accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.segGlyph { + font-size: 0.92em; + /* L'icona (riempie tutta la sua cassa 1em) cadeva otticamente più in basso + del testo, il cui centro visivo sta sopra la metà della riga. La rialzo di + un soffio per allineare i due centri ottici. */ + transform: translateY(-1px); +} + +/* Toggle-icona (pannello, colori) */ +.toggleBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border-strong); + color: var(--at-muted); + font-size: 0.92rem; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} +.toggleBtn:hover { + color: var(--at-fg); + border-color: var(--at-accent-chip-border); +} +.toggleBtnOn { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); + color: var(--at-accent); +} + +.headerDivider { + width: 1px; + height: 18px; + background: var(--at-border); + margin: 0 2px; +} + +/* ── Sotto-header ───────────────────────────────────────────── */ +.subHeader { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 18px; + border-bottom: 1px solid var(--at-border); + flex-wrap: wrap; +} + +.algoName { + font-family: var(--font-mono-ui); + font-size: 0.92rem; + font-weight: 600; + color: var(--at-fg-strong); + letter-spacing: -0.01em; +} + +.subDivider { + width: 1px; + height: 13px; + background: var(--at-border); +} + +.blurb { + font-family: var(--font-mono-comment); + font-size: 0.8rem; + color: var(--at-muted-soft); + line-height: 1.4; + flex: 1; + min-width: 180px; +} + +/* ── Stage ──────────────────────────────────────────────────── */ +.body { + background: radial-gradient( + 120% 90% at 50% -10%, + color-mix(in srgb, var(--at-accent) 8%, transparent), + transparent 60% + ); + padding: 30px 24px 18px; +} + +.sceneWrap { + overflow-x: auto; +} + +.scene { + margin: 0 auto; + width: max-content; +} + +.baseline { + position: absolute; + left: -12px; + right: -12px; + height: 1px; + background: var(--at-border); +} + +.stage { + position: relative; +} + +.bar { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + border-radius: 8px 8px 5px 5px; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 7px; + /* tinta a scala cromatica: hue per valore, lightness per tema */ + background: linear-gradient( + 180deg, + hsl(var(--alg-hue) 88% calc(var(--alg-L) + 8%)), + hsl(var(--alg-hue) 82% calc(var(--alg-L) - 4%)) + ); + box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.4); + will-change: transform; + transition: + transform var(--alg-dur) cubic-bezier(0.34, 1.1, 0.38, 1), + height var(--alg-dur) cubic-bezier(0.34, 1.1, 0.38, 1), + box-shadow 0.25s ease, + opacity var(--alg-dur) ease; +} + +.barValue { + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.9rem; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--alg-bar-text); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.18); +} + +.keyTag { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.55rem; + letter-spacing: 0.08em; + color: var(--alg-st-key); + animation: algBlink 1s ease-in-out infinite; +} + +.chipRow { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.targetChip { + display: inline-block; + font-family: var(--font-mono-ui); + font-size: 0.8rem; + padding: 3px 10px; + border-radius: 8px; + background: var(--at-accent-chip); + border: 1px solid var(--at-accent-chip-border); + color: var(--at-fg); + white-space: nowrap; + transition: + border-color var(--alg-dur) ease, + box-shadow var(--alg-dur) ease; +} +.targetChipActive { + border-color: var(--at-accent); + box-shadow: 0 0 0 3px var(--at-accent-chip); +} + +.badge { + position: absolute; + width: 20px; + height: 20px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + border-radius: 6px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); + color: var(--at-fg); + transition: left var(--alg-dur) ease; +} + +/* Indicatore di posizione corrente (cursore di scansione) */ +.cursor { + position: absolute; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + pointer-events: none; + transition: left var(--alg-dur) cubic-bezier(0.34, 1.1, 0.38, 1); +} + +.cursorCaret { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 6px solid var(--at-accent); +} + +.cursorLabel { + font-family: var(--font-mono); + font-size: 0.66rem; + font-weight: 700; + line-height: 1.3; + color: #fff; + background: var(--at-accent); + border-radius: 5px; + padding: 0 6px; +} + +.range { + position: absolute; + height: 8px; + box-sizing: border-box; + border-left: 2px solid var(--at-muted); + border-right: 2px solid var(--at-muted); + border-bottom: 2px solid var(--at-muted); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + transition: + left var(--alg-dur) ease, + width var(--alg-dur) ease; +} + +.rangeLabel { + position: absolute; + text-align: center; + font-family: var(--font-mono-ui); + font-size: 0.72rem; + color: var(--at-muted); +} + +/* ── Descrizione step ───────────────────────────────────────── */ +.explanation { + margin: 16px auto 0; + /* Altezza FISSA: il blocco non cresce/cala tra uno step e l'altro, così i + pulsanti del footer non «saltano» sotto il dito (su mobile era ingestibile). + 124px copre il caso peggiore misurato a 720px (nota su 2 righe + fase). */ + height: 124px; + max-width: 720px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; +} + +.phase { + margin: 0; + font-family: var(--font-mono-ui); + font-size: 0.92rem; + font-weight: 500; + color: var(--at-fg-strong); + line-height: 1.5; +} + +.stepChip { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.66rem; + color: var(--at-muted); + padding: 3px 7px; + border-radius: 6px; + background: var(--at-bg-chip); + margin-right: 8px; + vertical-align: middle; +} + +.note { + margin: 3px 0 0; + font-family: var(--font-mono-comment); + font-size: 0.8rem; + color: var(--at-muted-soft); +} + +/* ── Legenda ────────────────────────────────────────────────── */ +.legend { + margin-top: 14px; + display: flex; + gap: 18px; + flex-wrap: wrap; + justify-content: center; +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono-ui); + font-size: 0.72rem; + color: var(--at-muted); +} + +.legendSwatch { + width: 11px; + height: 11px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.25); +} + +/* ── Pannello pseudocodice + statistiche ────────────────────── */ +.codeGrid { + display: grid; + grid-template-columns: minmax(0, 1fr) 172px; + border-top: 1px solid var(--at-border); + background: var(--at-bg-subtle); +} +.codeGrid[data-stats='off'] { + grid-template-columns: 1fr; +} + +.codeCol { + padding: 16px 0; + min-width: 0; +} + +.codeHead { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px 12px 20px; +} + +.codeLabel { + font-family: var(--font-mono-ui); + font-size: 0.62rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--at-muted); +} + +.copyBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid var(--at-border-strong); + border-radius: 50%; + background: transparent; + color: var(--at-muted); + font-size: 0.82rem; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} +.copyBtn:hover { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); + color: var(--at-accent); +} +.copyBtnDone, +.copyBtnDone:hover { + background: transparent; + border-color: color-mix(in srgb, #34d399 55%, transparent); + color: #34d399; +} + +/* Crossfade copia ↔ spunta: le due icone sono sovrapposte e si scambiano + opacità/scala secondo lo stato (copyBtnDone). */ +.copyBtnIcons { + position: relative; + width: 1em; + height: 1em; +} +.copyBtnIcon, +.copyBtnCheck { + position: absolute; + inset: 0; + transition: + opacity 0.2s ease, + transform 0.2s cubic-bezier(0.34, 1.4, 0.5, 1); +} +.copyBtnCheck { + opacity: 0; + transform: scale(0.4); + color: #34d399; +} +.copyBtnDone .copyBtnIcon { + opacity: 0; + transform: scale(0.4); +} +.copyBtnDone .copyBtnCheck { + opacity: 1; + transform: scale(1); +} + +.code { + font-family: var(--font-mono); + font-size: 0.82rem; + overflow-x: auto; +} + +.codeLine { + display: flex; + gap: 14px; + padding: 3px 20px 3px 16px; + line-height: 1.8; + border-left: 2px solid transparent; + transition: + background 0.25s ease, + border-color 0.25s ease; +} +.codeLineOn { + background: var(--at-accent-chip); + border-left-color: var(--at-accent); +} + +.codeNum { + min-width: 16px; + text-align: right; + user-select: none; + font-size: 0.72rem; + color: var(--at-muted-soft); +} +.codeLineOn .codeNum { + color: var(--at-accent); +} + +.codeText { + white-space: pre; + opacity: 0.74; +} +.codeLineOn .codeText { + opacity: 1; +} + +/* Stessa palette dei runner (PyRunner/SQLRunner): variabili --py-* globali. */ +.tKw { + color: var(--py-kw); +} +.tNum { + color: var(--py-num); +} +.tDef { + color: var(--py-def); +} +.tPunct { + color: var(--py-op); +} + +.stats { + border-left: 1px solid var(--at-border); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 16px; + justify-content: center; +} + +.statLabel { + font-family: var(--font-mono-ui); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--at-muted); + margin-bottom: 4px; +} + +.statValue { + font-family: var(--font-mono); + font-size: 1.6rem; + font-weight: 700; + color: var(--at-fg-strong); + font-variant-numeric: tabular-nums; + line-height: 1; +} + +/* ── Footer: stato + scrubber + transport ───────────────────── */ +.footer { + background: var(--at-bg-subtle); + border-top: 1px solid var(--at-border); +} + +.statusRow { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 16px 8px; + flex-wrap: wrap; +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono-ui); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--at-muted); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.dotDone { + background: #34d399; + box-shadow: 0 0 8px #34d399; +} +.dotPlay { + background: var(--at-accent); + box-shadow: 0 0 8px var(--at-accent); + animation: algBlink 1s ease-in-out infinite; +} +.dotIdle { + background: var(--at-muted-soft); +} +.dotPause { + background: #fbbf24; + box-shadow: 0 0 8px #fbbf24; +} + +.stepCount { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--at-muted-soft); + font-variant-numeric: tabular-nums; +} + +.statSummary { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--at-muted-soft); +} + +.scrub { + position: relative; + display: flex; + align-items: center; + height: 18px; + margin: 0 16px 12px; +} + +.scrubTrack { + position: absolute; + left: 0; + right: 0; + height: 5px; + border-radius: 99px; + background: var(--at-bg-chip); + overflow: hidden; +} + +.scrubFill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, #38bdf8, #818cf8, #c084fc); + transition: width 0.25s ease; +} + +.scrubInput { + position: relative; + width: 100%; + height: 18px; + margin: 0; + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; +} +.scrubInput::-webkit-slider-thumb { + -webkit-appearance: none; + width: 15px; + height: 15px; + border-radius: 50%; + background: #fff; + border: 2px solid var(--at-accent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + cursor: pointer; +} +.scrubInput::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 2px solid var(--at-accent); + cursor: pointer; +} +.scrubInput:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 4px; + border-radius: 99px; +} + +.transport { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px 14px; + flex-wrap: wrap; +} + +.spacer { + flex: 1; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 38px; + min-width: 38px; + padding: 0 13px; + border-radius: 10px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border-strong); + color: var(--at-fg); + font-family: var(--font-mono-ui); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + box-shadow 0.18s ease, + filter 0.18s ease, + transform 0.12s ease; +} +.btn:hover:not(:disabled) { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); +} +.btn:active:not(:disabled) { + transform: translateY(1px); +} +.btn:disabled { + opacity: 0.4; + cursor: default; +} +.btn:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.btnIcon { + width: 38px; + padding: 0; +} + +.btnWide { + padding: 0 20px; +} + +.btnPrimary { + color: #fff; + border: none; + background: linear-gradient(135deg, var(--at-accent), #6366f1); + box-shadow: 0 8px 20px -8px var(--at-accent); +} +.btnPrimary:hover:not(:disabled) { + /* Ridichiaro il gradiente: la regola generica .btn:hover lo appiattirebbe. */ + background: linear-gradient(135deg, var(--at-accent), #6366f1); + box-shadow: 0 11px 26px -8px var(--at-accent); + filter: saturate(1.08) brightness(1.03); +} + +.btnGlyph { + font-size: 1em; +} + +.speeds { + position: relative; + display: inline-flex; + align-items: center; + height: 38px; + padding: 4px; + border-radius: 10px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border-strong); +} + +/* Indicatore che scorre sotto la pill attiva (--spd-idx = indice scelto). */ +.speedThumb { + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + width: 42px; + border-radius: 7px; + background: var(--at-accent); + transform: translateX(calc(var(--spd-idx) * 100%)); + transition: transform 0.22s cubic-bezier(0.34, 1.1, 0.38, 1); +} + +.speedPill { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 28px; + border: none; + background: transparent; + color: var(--at-muted); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: color 0.18s ease; +} +.speedPill:hover { + color: var(--at-fg); +} +.speedPillOn, +.speedPillOn:hover { + color: #fff; +} + +/* ── Didascalia / errore ────────────────────────────────────── */ +.caption { + font-family: var(--font-mono-comment); + font-size: 0.85rem; + color: var(--at-muted); + text-align: center; + margin: 0.5em 0 1.4em; +} + +.errorBox { + border: 1px solid #dc2626; + border-radius: 10px; + background: rgba(220, 38, 38, 0.06); + color: #dc2626; + padding: 12px 16px; + margin: 1.4em 0; + font-family: var(--font-mono-ui); + font-size: 0.85rem; +} + +@keyframes algBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +/* ── Responsive / mobile ──────────────────────────────────────── + Sotto i ~430 px la toolbar accavallava i controlli e il pannello + codice schiacciava lo pseudocodice in una colonna strettissima. + Vedi pm/spec-algo-viz-mobile.md. */ + +/* P2 — il pannello codice impila statistiche sotto il codice. Soglia + più alta (560px) perché la colonna fissa da 172px soffoca il codice + già prima dei 480px. */ +@media (max-width: 560px) { + .codeGrid { + grid-template-columns: 1fr; + } + .stats { + border-left: none; + border-top: 1px solid var(--at-border); + flex-direction: row; + gap: 28px; + justify-content: flex-start; + padding: 12px 16px; + } + + /* Numero accanto alla label, sulla stessa riga: la card si accorcia. + Label un filo più grande, valore molto più piccolo (non serve il + numerone da 1.6rem quando è in linea). */ + .stat { + display: flex; + align-items: baseline; + gap: 8px; + } + .statLabel { + margin-bottom: 0; + font-size: 0.74rem; + letter-spacing: 0.06em; + } + .statValue { + font-size: 1.05rem; + } + + /* Interlinea del codice ben più stretta: su mobile lo pseudocodice + occupava troppo in verticale. */ + .codeLine { + line-height: 1.45; + padding: 1px 16px 1px 12px; + } + + /* Codice ad altezza limitata con scroll interno: scena, controlli e codice + stanno insieme senza scrollare la pagina. La riga attiva si porta in vista + da sola (auto-scroll in CodePanel). */ + .code { + max-height: 32vh; + overflow-y: auto; + overscroll-behavior: contain; + } +} + +/* P1 — la toolbar si stringe per stare su una riga sola: via i pallini + «traffic light» e il nome del file (decorativi, superflui su mobile), + e lo switch Studio/Lab resta solo-icona. Lo spazio liberato basta a + tenere icone + complessità sulla stessa riga, senza sprecare altezza. */ +@media (max-width: 480px) { + .header { + flex-wrap: wrap; + row-gap: 8px; + padding: 10px 12px; + } + .headerRight { + gap: 6px; + } + + /* Pallini e nome file: pura decorazione «finestra», fuori su mobile. */ + .trafficLights, + .fileName { + display: none; + } + + /* Switch Studio/Lab solo-icona: niente etichette, lo spazio è prezioso. + Il tap target resta comodo (height 34 + padding). */ + .segmented { + margin-left: 0; + } + .segBtn { + height: 34px; + gap: 0; + padding: 0 11px; + } + .segBtn span { + display: none; + } + .segGlyph { + font-size: 1.05em; + } + + /* P4 — densità: meno padding orizzontale (più spazio scena) e tap target + più comodi. */ + .body { + padding: 20px 12px 14px; + } + .toggleBtn { + width: 36px; + height: 36px; + } + + /* Spiegazione: su mobile lo stesso testo va a capo molto di più (fino a + ~180px con la fase). Nascondo la riga «fase» (l'intro, ripetuta a ogni + step) e riduco l'altezza fissa al solo testo dell'azione corrente. */ + .explanation { + height: 108px; + } + .explanation .note { + display: none; + } +} + +/* ── Movimento ridotto ──────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .bar, + .badge, + .range, + .cursor, + .speedThumb, + .scrubFill, + .codeLine, + .targetChip, + .copyBtnIcon, + .copyBtnCheck { + transition-duration: 0.01ms !important; + } + .keyTag, + .dotPlay { + animation: none !important; + } +} diff --git a/src/components/Algorithm/types.ts b/src/components/Algorithm/types.ts new file mode 100644 index 0000000..324e922 --- /dev/null +++ b/src/components/Algorithm/types.ts @@ -0,0 +1,111 @@ +export interface ArrayItem { + /** Identità stabile = indice nella sequenza iniziale. Determina il colore + * e permette le transizioni CSS quando l'elemento cambia slot. */ + id: number; + value: number; +} + +/** Riferimento a uno dei due lati di un confronto. */ +export type CompareRef = + | { kind: 'item'; i: number } // elemento nello slot i + | { kind: 'extracted' } // l'elemento estratto (insertion sort) + | { kind: 'target' }; // il valore cercato (ricerche) + +export interface ArraySceneState { + /** Slot della fila; null = slot vuoto (dopo extract/shiftRight). */ + items: (ArrayItem | null)[]; + /** Elemento fuori riga (insertion sort): disegnato sotto lo slot overIndex. */ + extracted: { item: ArrayItem; overIndex: number } | null; + /** Badge etichettati sotto gli slot (min, L, R, P, mid…). Upsert per name. */ + pointers: { name: string; i: number }[]; + /** Graffa sotto la fila, da lo a hi inclusi. */ + range: { lo: number; hi: number; label?: string } | null; + /** Coppia in confronto: bilancia centrata tra i due riferimenti. */ + comparing: { a: CompareRef; b: CompareRef } | null; + /** Indici di slot marcati come definitivi (barra grigia). Additivo. */ + sorted: number[]; + /** Indici di slot attenuati (es. già scartati nella ricerca lineare). */ + faded: number[]; + /** Slot coinvolti nello scambio appena avvenuto (anello «Scambio»). + * Transiente: vale solo per lo step che lo provoca. */ + swapping: number[]; + /** Esito ricerca: null finché non noto. */ + outcome: { found: true; i: number } | { found: false } | null; + /** Testo di fase (study): persiste finché un nuovo 'phase' lo sostituisce. */ + phase: string; + /** Nota puntuale dello step corrente (study). */ + note: string; + /** Riga di pseudocodice attiva (indice 0-based nel `code`); persiste. */ + line: number | null; + /** Indicatore di posizione corrente sotto le barre; persiste. */ + cursor: { i: number; label?: string } | null; + /** Contatori cumulativi per il pannello statistiche. */ + comparisons: number; + swaps: number; +} + +export type ArrayStep = ( + | { op: 'compare'; i: number; j: number } + | { op: 'compareTarget'; i: number } + | { op: 'compareExtracted'; i: number } + | { op: 'swap'; i: number; j: number } + | { op: 'extract'; i: number } + | { op: 'shiftRight'; from: number } + | { op: 'insertAt'; i: number } + | { op: 'pointer'; name: string; i: number | null } + | { op: 'range'; lo: number; hi: number; label?: string } + | { op: 'clearRange' } + | { op: 'markSorted'; indices: number[] } + | { op: 'fade'; indices: number[] } + | { op: 'outcome'; found: boolean; i?: number } + | { op: 'phase'; text: string } + | { op: 'note' } // cambia solo la nota corrente +) & { + /** Spiegazione puntuale mostrata in study. */ + note?: string; + /** Riga di pseudocodice (indice 0-based nel `code` del generatore) che lo + * step illumina. Emessa dai sort; assente negli altri (nessun pannello). */ + line?: number; + /** Indicatore di posizione corrente sotto le barre (es. la variabile di + * scansione `j`). Persiste come `line`: `undefined` = invariato, `null` = + * nascosto, oggetto = mostralo allo slot `i`. La `label` compare solo quando + * il pannello codice è attivo (lega l'indice alla variabile). */ + cursor?: { i: number; label?: string } | null; +}; + +export interface ArrayTrace { + initial: number[]; + target?: number; + steps: ArrayStep[]; +} + +export interface GeneratorInput { + data: number[]; + target?: number; +} + +export interface GeneratorDef { + id: string; + /** Nome mostrato nel sotto-header, es. «Bubble sort». */ + label: string; + kind: 'sort' | 'search'; + /** Nome file mostrato nella toolbar, es. «bubble_sort.py». */ + file?: string; + /** Complessità mostrata nel badge della toolbar, es. «O(n²)». */ + complexity?: string; + /** Frase descrittiva (corsivo) nel sotto-header. */ + blurb?: string; + /** Righe di pseudocodice Python; se presenti, abilita il pannello codice. + * Gli step emettono `line` come indice 0-based in questo array. */ + code?: string[]; + /** Dati usati in study quando il blocco non passa `data`. */ + defaultData: number[]; + /** Per kind 'search' senza target esplicito: sceglie il target. + * rnd ∈ [0,1) dal PRNG seeded (determinismo SSR). */ + pickTarget?: (data: number[], rnd: () => number) => number; + /** Trasforma i dati prima di generare (binary-search: ordina). */ + prepare?: (data: number[]) => number[]; + generate: (input: GeneratorInput) => ArrayTrace; +} + +export type AlgorithmMode = 'study' | 'lab'; diff --git a/src/components/Algorithm/usePref.ts b/src/components/Algorithm/usePref.ts new file mode 100644 index 0000000..ad9065e --- /dev/null +++ b/src/components/Algorithm/usePref.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; + +/** + * Preferenza UI condivisa da tutti i blocchi della pagina e + * persistita tra le sessioni (localStorage). + * + * Sincronizzazione: + * - **same-tab** (più blocchi nella stessa pagina): un `CustomEvent` su + * `window`, perché l'evento `storage` nativo NON scatta nella tab che ha + * scritto, solo nelle altre. + * - **cross-tab**: l'evento `storage` nativo. + * + * SSR-safe: parte dal `fallback` fisso (così il markup server e quello del + * primo render client coincidono) e legge il valore reale da localStorage in + * un effetto, dopo l'hydration. + */ +const PREFIX = 'pdb.algo.'; +const EVENT = 'pdb-algo-pref'; + +function readStored(key: string, fallback: T): T { + if (typeof window === 'undefined') return fallback; + try { + return (window.localStorage.getItem(PREFIX + key) as T) ?? fallback; + } catch { + return fallback; + } +} + +export function useAlgoPref( + key: string, + fallback: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(fallback); + + // Dopo l'hydration leggo il valore reale (sul server era il fallback). + useEffect(() => { + setValue(readStored(key, fallback)); + }, [key, fallback]); + + // Allineamento con gli altri blocchi (same-tab) e con le altre tab (storage). + useEffect(() => { + const onLocal = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail && detail.key === key) setValue(detail.value as T); + }; + const onStorage = (e: StorageEvent) => { + if (e.key === PREFIX + key && e.newValue != null) { + setValue(e.newValue as T); + } + }; + window.addEventListener(EVENT, onLocal); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(EVENT, onLocal); + window.removeEventListener('storage', onStorage); + }; + }, [key]); + + const set = (v: T) => { + setValue(v); + try { + window.localStorage.setItem(PREFIX + key, v); + } catch { + /* storage non disponibile: resta valido per questa sessione */ + } + window.dispatchEvent(new CustomEvent(EVENT, { detail: { key, value: v } })); + }; + + return [value, set]; +} diff --git a/src/theme/MDXComponents.tsx b/src/theme/MDXComponents.tsx index 7975508..e81e8f4 100644 --- a/src/theme/MDXComponents.tsx +++ b/src/theme/MDXComponents.tsx @@ -15,6 +15,7 @@ import Quiz, { } from '@site/src/components/Quiz'; import PyRunner from '@site/src/theme/PyRunner'; import SQLRunner from '@site/src/theme/SQLRunner'; +import Algorithm from '@site/src/components/Algorithm'; export default { ...MDXComponents, @@ -29,4 +30,5 @@ export default { QuizFeedback, PyRunner, SQLRunner, + Algorithm, };