From 073b5c6dee38e12014c6cf9e3ec413c6c4d701ee Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Sat, 13 Jun 2026 02:04:18 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(algoviz):=20fase=201=20=E2=80=94=20pla?= =?UTF-8?q?yer,=20ArrayScene=20e=206=20generatori=20(spec=20pm/spec-algo-v?= =?UTF-8?q?iz-fase1.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Componente con player passo-passo (study) e autoplay (lab): - scena array DOM+CSS transitions: barre per identità, bilancia di confronto, badge puntatori, graffa range, chip target, esiti ricerca - generatori: bubble/selection/insertion sort, quicksort (Lomuto), ricerca lineare e binaria, con note didattiche in italiano - dati deterministici via mulberry32 (SSR/hydration safe), shuffle con «Rigenera», dark mode e prefers-reduced-motion - pagina demo unlisted /docs/algoviz-test Implementazione delegata (Opus) + review: rimosso «Indietro» dai controlli lab e allineate le virgolette del box d'errore alla spec. Co-Authored-By: Claude Opus 4.8 --- docs/algoviz-test.mdx | 78 +++++ src/components/AlgoViz/ArrayScene.tsx | 212 ++++++++++++ src/components/AlgoViz/Controls.tsx | 111 +++++++ src/components/AlgoViz/Explanation.tsx | 18 + src/components/AlgoViz/Player.tsx | 131 ++++++++ src/components/AlgoViz/applyStep.ts | 182 ++++++++++ .../AlgoViz/generators/binarySearch.ts | 94 ++++++ .../AlgoViz/generators/bubbleSort.ts | 80 +++++ src/components/AlgoViz/generators/index.ts | 30 ++ .../AlgoViz/generators/insertionSort.ts | 69 ++++ .../AlgoViz/generators/linearSearch.ts | 72 ++++ .../AlgoViz/generators/quicksort.ts | 102 ++++++ .../AlgoViz/generators/selectionSort.ts | 73 ++++ src/components/AlgoViz/index.tsx | 119 +++++++ src/components/AlgoViz/prng.ts | 22 ++ src/components/AlgoViz/styles.module.css | 313 ++++++++++++++++++ src/components/AlgoViz/types.ts | 86 +++++ src/theme/MDXComponents.tsx | 2 + 18 files changed, 1794 insertions(+) create mode 100644 docs/algoviz-test.mdx create mode 100644 src/components/AlgoViz/ArrayScene.tsx create mode 100644 src/components/AlgoViz/Controls.tsx create mode 100644 src/components/AlgoViz/Explanation.tsx create mode 100644 src/components/AlgoViz/Player.tsx create mode 100644 src/components/AlgoViz/applyStep.ts create mode 100644 src/components/AlgoViz/generators/binarySearch.ts create mode 100644 src/components/AlgoViz/generators/bubbleSort.ts create mode 100644 src/components/AlgoViz/generators/index.ts create mode 100644 src/components/AlgoViz/generators/insertionSort.ts create mode 100644 src/components/AlgoViz/generators/linearSearch.ts create mode 100644 src/components/AlgoViz/generators/quicksort.ts create mode 100644 src/components/AlgoViz/generators/selectionSort.ts create mode 100644 src/components/AlgoViz/index.tsx create mode 100644 src/components/AlgoViz/prng.ts create mode 100644 src/components/AlgoViz/styles.module.css create mode 100644 src/components/AlgoViz/types.ts diff --git a/docs/algoviz-test.mdx b/docs/algoviz-test.mdx new file mode 100644 index 0000000..48e8559 --- /dev/null +++ b/docs/algoviz-test.mdx @@ -0,0 +1,78 @@ +--- +unlisted: true +title: AlgoViz — pagina di test +description: Sandbox di verifica del componente AlgoViz (animazioni di algoritmi) +--- + +# AlgoViz — sandbox di test + +Pagina temporanea per validare le animazioni di algoritmi. Non è linkata dalla +sidebar (`unlisted: true`): la raggiungi solo via URL `/docs/algoviz-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/AlgoViz/ArrayScene.tsx b/src/components/AlgoViz/ArrayScene.tsx new file mode 100644 index 0000000..55b4a4d --- /dev/null +++ b/src/components/AlgoViz/ArrayScene.tsx @@ -0,0 +1,212 @@ +import { type CSSProperties } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { ArrayItem, ArraySceneState, CompareRef } from './types'; +import styles from './styles.module.css'; + +const BAR_W = 38; // larghezza barra +const GAP = 8; // spazio tra barre +const UNIT = BAR_W + GAP; +const TRACK_H = 150; // altezza zona barre (allineate in basso) +const EXTRACT_DY = 52; // quanto scende l'elemento estratto +const LANE_H = 96; // zona sotto la track: bilancia/badge/graffa/estratto +const BADGE_W = 20; +const BADGE_OFFSET = 22; +const TARGET_CHIP_W = 96; // stima per centrare la bilancia sul chip target + +const x = (i: number) => i * UNIT; +const slotCenter = (i: number) => x(i) + BAR_W / 2; + +interface ArraySceneProps { + state: ArraySceneState; + target?: number; + label: string; +} + +export default function ArrayScene({ state, target, label }: ArraySceneProps) { + const n = state.items.length; + const sceneWidth = Math.max(n * UNIT - GAP, BAR_W); + + // maxValue = max(initial): l'insieme dei valori è costante (items + estratto). + 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 maxValue = Math.max(...values, 1); + const barHeight = (value: number) => + 28 + Math.round((value / maxValue) * 110); + + // Analisi del 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 sortedSet = new Set(state.sorted); + const fadedSet = new Set(state.faded); + const inRange = (slot: number) => + !state.range || (slot >= state.range.lo && slot <= state.range.hi); + + // 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); + const tx = x(slot); + const ty = isExtracted ? TRACK_H - h + EXTRACT_DY : TRACK_H - h; + const style: CSSProperties = { + transform: `translate(${tx}px, ${ty}px)`, + height: h, + width: BAR_W, + }; + + const isSorted = !isExtracted && sortedSet.has(slot); + if (isSorted) { + style.background = 'var(--av-sorted)'; + style.color = 'var(--av-sorted-text)'; + } else { + style.background = `var(--av-item-${item.id % 10})`; + style.color = 'var(--av-bar-text)'; + } + + if (!isExtracted && (fadedSet.has(slot) || !inRange(slot))) { + style.opacity = 0.35; + } + + const isComparing = isExtracted + ? comparingExtracted + : comparingSlots.has(slot); + const isFound = + !isExtracted && + state.outcome !== null && + state.outcome.found && + state.outcome.i === slot; + if (isFound) style.boxShadow = '0 0 0 3px var(--av-ok)'; + else if (isComparing) style.boxShadow = '0 0 0 2px var(--at-accent)'; + + return style; + } + + // Bilancia: centrata alla x media dei due riferimenti. + function refCenterX(ref: CompareRef): number { + if (ref.kind === 'item') return slotCenter(ref.i); + if (ref.kind === 'extracted') { + return state.extracted ? slotCenter(state.extracted.overIndex) : 0; + } + return sceneWidth - TARGET_CHIP_W / 2; // target chip + } + let balanceX: number | null = null; + if (state.comparing) { + balanceX = + (refCenterX(state.comparing.a) + refCenterX(state.comparing.b)) / 2; + } + + // 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) => { + // key stabile per nome: il badge slitta (transizione su `left`) + // quando il puntatore cambia slot, invece di rimontare. + badges.push({ key: name, left: start + k * BADGE_OFFSET, name }); + }); + } + + return ( +
+ {target !== undefined && ( +
+
+ cerco: {target} +
+
+ )} + +
+ {entries.map((entry) => ( +
+ {entry.item.value} +
+ ))} + + {balanceX !== null && ( +
+ +
+ )} + + {badges.map((b) => ( +
+ {b.name} +
+ ))} + + {state.range && ( + <> +
+ {state.range.label && ( +
+ {state.range.label} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/AlgoViz/Controls.tsx b/src/components/AlgoViz/Controls.tsx new file mode 100644 index 0000000..7c7eb3d --- /dev/null +++ b/src/components/AlgoViz/Controls.tsx @@ -0,0 +1,111 @@ +import { type Dispatch } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { IconName } from '@fortawesome/fontawesome-svg-core'; +import type { AlgoVizMode } from './types'; +import type { PlayerAction } from './Player'; +import styles from './styles.module.css'; + +interface ControlsProps { + mode: AlgoVizMode; + stepIndex: number; + total: number; + playing: boolean; + speedLabel: string; + showRegenerate: boolean; + onRegenerate?: () => void; + dispatch: Dispatch; +} + +interface BtnProps { + icon: IconName; + label: string; + onClick: () => void; + disabled?: boolean; + primary?: boolean; +} + +function Btn({ icon, label, onClick, disabled, primary }: BtnProps) { + return ( + + ); +} + +export default function Controls({ + mode, + stepIndex, + total, + playing, + speedLabel, + showRegenerate, + onRegenerate, + dispatch, +}: ControlsProps) { + const atStart = stepIndex === 0; + const atEnd = stepIndex >= total; + + return ( +
+
+ dispatch({ type: 'RESET' })} + disabled={atStart} + /> + {mode === 'study' && ( + dispatch({ type: 'BACK' })} + disabled={atStart} + /> + )} + + {mode === 'lab' && showRegenerate && onRegenerate && ( + + )} + + {mode === 'lab' && ( + + )} + + dispatch({ type: 'FWD' })} + disabled={atEnd} + primary={mode === 'study'} + /> + + {mode === 'lab' && ( + dispatch({ type: 'TOGGLE_PLAY' })} + primary + /> + )} +
+ +
+ passo {stepIndex} di {total} +
+
+ ); +} diff --git a/src/components/AlgoViz/Explanation.tsx b/src/components/AlgoViz/Explanation.tsx new file mode 100644 index 0000000..b5e8bb5 --- /dev/null +++ b/src/components/AlgoViz/Explanation.tsx @@ -0,0 +1,18 @@ +import type { ArraySceneState } from './types'; +import styles from './styles.module.css'; + +interface ExplanationProps { + phase: string; + note: string; + /** L'esito arriva già dalle note degli step `outcome`: la nota resta. */ + outcome: ArraySceneState['outcome']; +} + +export default function Explanation({ phase, note }: ExplanationProps) { + return ( +
+
{phase}
+
{note}
+
+ ); +} diff --git a/src/components/AlgoViz/Player.tsx b/src/components/AlgoViz/Player.tsx new file mode 100644 index 0000000..9599a29 --- /dev/null +++ b/src/components/AlgoViz/Player.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useReducer, type CSSProperties } from 'react'; +import type { ArrayTrace, AlgoVizMode } from './types'; +import { applyArrayStep, initArrayState } from './applyStep'; +import ArrayScene from './ArrayScene'; +import Controls from './Controls'; +import Explanation from './Explanation'; +import styles from './styles.module.css'; + +const TICK_MS = [1400, 800, 450, 220]; +const DUR_MS = [400, 300, 220, 140]; +const SPEED_LABELS = ['×½', '×1', '×2', '×4']; + +interface PlayerProps { + trace: ArrayTrace; + mode: AlgoVizMode; + /** Nome algoritmo per aria-label. */ + label: string; + showRegenerate: boolean; + onRegenerate?: () => void; + /** 0..3, default 1. */ + initialSpeedIdx: number; +} + +interface PlayerState { + stepIndex: number; + playing: boolean; + speedIdx: number; +} + +export type PlayerAction = + | { type: 'FWD' } + | { type: 'BACK' } + | { type: 'RESET' } + | { type: 'TOGGLE_PLAY' } + | { type: 'CYCLE_SPEED' }; + +export default function Player({ + trace, + mode, + label, + showRegenerate, + onRegenerate, + initialSpeedIdx, +}: PlayerProps) { + const total = trace.steps.length; + + const [state, dispatch] = useReducer( + (s: PlayerState, action: PlayerAction): 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 'CYCLE_SPEED': + // cicla 1→2→3→1; lo 0 (×½) è raggiungibile solo da prop. + return { ...s, speedIdx: s.speedIdx === 3 ? 1 : s.speedIdx + 1 }; + default: + return s; + } + }, + { stepIndex: 0, playing: false, speedIdx: initialSpeedIdx }, + ); + + // Autoplay: avanza dopo TICK_MS; cleanup al cambio di stepIndex/playing/speedIdx. + 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]); + + // Stato scena: ricalcolo dall'inizio (ok per n piccoli). + const sceneState = useMemo( + () => + trace.steps + .slice(0, state.stepIndex) + .reduce(applyArrayStep, initArrayState(trace)), + [trace, state.stepIndex], + ); + + const durStyle = { + '--av-dur': `${DUR_MS[state.speedIdx]}ms`, + } as CSSProperties; + + return ( + <> +
+
+ +
+ {mode === 'study' && ( + + )} +
+
+ +
+ + ); +} diff --git a/src/components/AlgoViz/applyStep.ts b/src/components/AlgoViz/applyStep.ts new file mode 100644 index 0000000..1978cb0 --- /dev/null +++ b/src/components/AlgoViz/applyStep.ts @@ -0,0 +1,182 @@ +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(`[AlgoViz] ${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: [], + outcome: null, + phase: '', + note: '', + }; +} + +/** 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 }; + + switch (step.op) { + case 'compare': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'item', i: step.j }, + }; + break; + + case 'compareTarget': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'target' }, + }; + break; + + case 'compareExtracted': + next.comparing = { + a: { kind: 'item', i: step.i }, + b: { kind: 'extracted' }, + }; + 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.comparing = null; + 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.comparing = null; + 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/AlgoViz/generators/binarySearch.ts b/src/components/AlgoViz/generators/binarySearch.ts new file mode 100644 index 0000000..4f2fcf1 --- /dev/null +++ b/src/components/AlgoViz/generators/binarySearch.ts @@ -0,0 +1,94 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +/** Come la ricerca lineare, ma applicato al dato già ordinato. */ +function pickTarget(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; +} + +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', + defaultData: [1, 3, 4, 7, 9, 11, 14, 17], + prepare: (data) => data.slice().sort((x, y) => x - y), + pickTarget, + generate, +}; diff --git a/src/components/AlgoViz/generators/bubbleSort.ts b/src/components/AlgoViz/generators/bubbleSort.ts new file mode 100644 index 0000000..f5c0f0c --- /dev/null +++ b/src/components/AlgoViz/generators/bubbleSort.ts @@ -0,0 +1,80 @@ +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: 'Confronto le coppie di numeri vicini: a ogni giro il più grande “sale” in 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, + note: `Confronto ${a[j]} e ${a[j + 1]}.`, + }); + if (a[j] > a[j + 1]) { + const maggiore = a[j]; + steps.push({ + op: 'swap', + i: j, + j: j + 1, + note: `${maggiore} è più grande: li scambio.`, + }); + [a[j], a[j + 1]] = [a[j + 1], a[j]]; + swapped = true; + } else { + steps.push({ + op: 'note', + note: 'Sono già nell’ordine giusto: li lascio così.', + }); + } + } + steps.push({ + op: 'markSorted', + indices: [n - 1 - pass], + note: `${a[n - 1 - pass]} è arrivato al suo posto.`, + }); + 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 }); + steps.push({ + op: 'phase', + text: 'Nessuno scambio in questo giro: la sequenza è ordinata.', + }); + brokeEarly = true; + break; + } + } + + if (!brokeEarly) { + steps.push({ op: 'markSorted', indices: [0] }); + steps.push({ + op: 'phase', + text: 'Tutti i numeri sono al loro posto: ho finito.', + }); + } + + return { initial: data.slice(), steps }; +} + +export const bubbleSort: GeneratorDef = { + id: 'bubble-sort', + label: 'Bubble sort', + kind: 'sort', + defaultData: [5, 3, 8, 1, 9, 2, 7], + generate, +}; diff --git a/src/components/AlgoViz/generators/index.ts b/src/components/AlgoViz/generators/index.ts new file mode 100644 index 0000000..639ee7b --- /dev/null +++ b/src/components/AlgoViz/generators/index.ts @@ -0,0 +1,30 @@ +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]; +} + +/** Tutti gli id registrati, nell'ordine di presentazione della pagina demo. */ +export const ALGO_IDS: string[] = [ + bubbleSort.id, + selectionSort.id, + insertionSort.id, + linearSearch.id, + binarySearch.id, + quicksort.id, +]; diff --git a/src/components/AlgoViz/generators/insertionSort.ts b/src/components/AlgoViz/generators/insertionSort.ts new file mode 100644 index 0000000..e30f6b8 --- /dev/null +++ b/src/components/AlgoViz/generators/insertionSort.ts @@ -0,0 +1,69 @@ +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: 'Prendo i numeri uno alla volta e li inserisco al posto giusto tra quelli già sistemati.', + }); + steps.push({ + op: 'markSorted', + indices: [0], + 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, + 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, + note: `${a[j]} è più grande di ${key}: lo faccio scorrere a destra.`, + }); + steps.push({ op: 'shiftRight', from: j }); + a[j + 1] = a[j]; + j -= 1; + } + if (j >= 0) { + steps.push({ + op: 'compareExtracted', + i: j, + note: `${a[j]} non è più grande di ${key}: il posto è qui accanto.`, + }); + } + steps.push({ op: 'insertAt', i: j + 1, 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 }); + } + + steps.push({ + op: 'phase', + 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', + defaultData: [3, 4, 5, 7, 2, 8, 6, 9, 1], + generate, +}; diff --git a/src/components/AlgoViz/generators/linearSearch.ts b/src/components/AlgoViz/generators/linearSearch.ts new file mode 100644 index 0000000..6891dff --- /dev/null +++ b/src/components/AlgoViz/generators/linearSearch.ts @@ -0,0 +1,72 @@ +import type { + ArrayStep, + ArrayTrace, + GeneratorDef, + GeneratorInput, +} from '../types'; + +/** Con probabilità 0.75 un elemento presente (esito «trovato»), altrimenti + * il più piccolo intero positivo assente (esito «non trovato»). */ +function pickTarget(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; +} + +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', + defaultData: [4, 7, 1, 9, 3, 6, 2], + pickTarget, + generate, +}; diff --git a/src/components/AlgoViz/generators/quicksort.ts b/src/components/AlgoViz/generators/quicksort.ts new file mode 100644 index 0000000..0d36740 --- /dev/null +++ b/src/components/AlgoViz/generators/quicksort.ts @@ -0,0 +1,102 @@ +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', + defaultData: [3, 5, 4, 1, 2, 9, 8, 7, 6], + generate, +}; diff --git a/src/components/AlgoViz/generators/selectionSort.ts b/src/components/AlgoViz/generators/selectionSort.ts new file mode 100644 index 0000000..ff5fc18 --- /dev/null +++ b/src/components/AlgoViz/generators/selectionSort.ts @@ -0,0 +1,73 @@ +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: '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; + steps.push({ + op: 'pointer', + name: 'min', + i, + 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, + note: `Confronto ${a[j]} con il minimo attuale, ${a[min]}.`, + }); + if (a[j] < a[min]) { + min = j; + steps.push({ + op: 'pointer', + name: 'min', + i: j, + note: `${a[j]} è più piccolo: è il nuovo minimo.`, + }); + } + } + if (min !== i) { + steps.push({ + op: 'swap', + i, + j: min, + note: `Porto ${a[min]} all’inizio della parte non ordinata.`, + }); + [a[i], a[min]] = [a[min], a[i]]; + } else { + steps.push({ op: 'note', note: `${a[i]} è già al posto giusto.` }); + } + steps.push({ op: 'markSorted', indices: [i] }); + } + + steps.push({ op: 'pointer', name: 'min', i: null }); + steps.push({ op: 'markSorted', indices: [n - 1] }); + steps.push({ + op: 'phase', + 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', + defaultData: [9, 5, 3, 1, 2, 8, 6, 4, 7], + generate, +}; diff --git a/src/components/AlgoViz/index.tsx b/src/components/AlgoViz/index.tsx new file mode 100644 index 0000000..24dcc47 --- /dev/null +++ b/src/components/AlgoViz/index.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { getGenerator } from './generators'; +import { mulberry32, shuffledRange } from './prng'; +import Player from './Player'; +import type { AlgoVizMode } 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)); + +interface AlgoVizProps { + /** Id del generatore registrato (vedi generators/index.ts). */ + algo: string; + /** 'study' (default): passo-passo con spiegazioni. 'lab': autoplay e dati casuali. */ + mode?: AlgoVizMode; + /** 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'; + /** Didascalia sotto la card. */ + caption?: string; +} + +export default function AlgoViz({ + algo, + mode = 'study', + data, + shuffle, + target, + speed = 'normal', + caption, +}: AlgoVizProps) { + // Seed iniziale fisso (1): markup server e client coincidono (hydration). + const [seed, setSeed] = useState(1); + const gen = getGenerator(algo); + + const trace = useMemo(() => { + if (!gen) return null; + const rnd = mulberry32(seed); + + let baseData: number[]; + 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( + `[AlgoViz] 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]); + + if (!gen || !trace) { + // eslint-disable-next-line no-console + console.error(`[AlgoViz] algoritmo "${algo}" non registrato.`); + return ( +
+ AlgoViz: algoritmo “{algo}” non registrato +
+ ); + } + + // «Rigenera» ha senso solo con dati casuali (shuffle, senza data esplicito). + const showRegenerate = shuffle !== undefined && !(data && data.length); + + return ( + <> +
+
+ {gen.label} + + + {mode === 'study' ? 'studio' : 'lab'} + +
+ setSeed((s) => s + 1)} + initialSpeedIdx={SPEED_IDX[speed]} + /> +
+ {caption &&

{caption}

} + + ); +} diff --git a/src/components/AlgoViz/prng.ts b/src/components/AlgoViz/prng.ts new file mode 100644 index 0000000..e569b99 --- /dev/null +++ b/src/components/AlgoViz/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/AlgoViz/styles.module.css b/src/components/AlgoViz/styles.module.css new file mode 100644 index 0000000..50f9fa4 --- /dev/null +++ b/src/components/AlgoViz/styles.module.css @@ -0,0 +1,313 @@ +/* ───────────────────────────────────────────────────────────── + Card (stile coerente con PyRunner / SQLRunner) + ───────────────────────────────────────────────────────────── */ +.card { + border: 1px solid var(--at-border); + border-radius: 14px; + background: var(--at-bg-panel); + margin: 1.4em 0; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--at-border); + background: var(--at-bg-subtle); + padding: 8px 12px; +} + +.headerLabel { + font-size: 15px; + font-weight: 600; + color: var(--at-fg-strong); +} + +.modeChip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--at-muted); +} + +.body { + padding: 16px; +} + +.footer { + border-top: 1px solid var(--at-border); + padding: 8px 12px; +} + +.caption { + font-size: 13px; + font-style: italic; + 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-size: 14px; +} + +/* ───────────────────────────────────────────────────────────── + Scena array + ───────────────────────────────────────────────────────────── */ +.sceneWrap { + overflow-x: auto; +} + +.scene { + /* palette per identità (id % 10) */ + --av-item-0: #64748b; + --av-item-1: #16a34a; + --av-item-2: #dc2626; + --av-item-3: #0d9488; + --av-item-4: #2563eb; + --av-item-5: #65a30d; + --av-item-6: #ea580c; + --av-item-7: #ca8a04; + --av-item-8: #9333ea; + --av-item-9: #db2777; + --av-sorted: #cbd5e1; + --av-sorted-text: #334155; + --av-ok: #16a34a; + --av-bar-text: #ffffff; + + margin: 0 auto; + width: max-content; +} + +:global(html[data-theme='dark']) .scene { + --av-item-0: #94a3b8; + --av-item-1: #4ade80; + --av-item-2: #f87171; + --av-item-3: #2dd4bf; + --av-item-4: #60a5fa; + --av-item-5: #a3e635; + --av-item-6: #fb923c; + --av-item-7: #facc15; + --av-item-8: #c084fc; + --av-item-9: #f472b6; + --av-sorted: #3f3f46; + --av-sorted-text: #fafafa; + --av-ok: #4ade80; + --av-bar-text: #18181b; +} + +.chipRow { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.targetChip { + display: inline-block; + font-size: 13px; + 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(--av-dur) ease; +} + +.targetChipActive { + border-color: var(--at-accent); +} + +.stage { + position: relative; +} + +.bar { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + border-radius: 8px; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 6px; + will-change: transform; + transition: + transform var(--av-dur) ease, + height var(--av-dur) ease, + opacity var(--av-dur) ease, + box-shadow var(--av-dur) ease, + background var(--av-dur) ease; +} + +.barValue { + font-size: 14px; + font-weight: 700; + line-height: 1; +} + +.balance { + position: absolute; + left: 0; + top: 156px; /* TRACK_H + 6 */ + width: max-content; + font-size: 18px; + color: var(--at-muted); + pointer-events: none; + transition: transform var(--av-dur) ease; +} + +.badge { + position: absolute; + top: 184px; /* TRACK_H + 34 */ + width: 20px; + height: 20px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + border-radius: 6px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); + color: var(--at-fg); + transition: left var(--av-dur) ease; +} + +.range { + position: absolute; + top: 212px; /* TRACK_H + 62 */ + 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(--av-dur) ease, + width var(--av-dur) ease; +} + +.rangeLabel { + position: absolute; + top: 224px; /* TRACK_H + 74 */ + text-align: center; + font-size: 11px; + color: var(--at-muted); +} + +/* ───────────────────────────────────────────────────────────── + Spiegazioni (solo study) + ───────────────────────────────────────────────────────────── */ +.explanation { + min-height: 3.2em; + margin-top: 14px; +} + +.phase { + font-size: 15px; + color: var(--at-fg-strong); +} + +.note { + font-size: 14px; + color: var(--at-muted-soft); + font-style: italic; + margin-top: 2px; +} + +/* ───────────────────────────────────────────────────────────── + Pulsantiera + ───────────────────────────────────────────────────────────── */ +.controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.controlsLeft { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: inherit; + font-size: 13px; + padding: 6px 12px; + border-radius: 10px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); + color: var(--at-fg); + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.btnPrimary { + background: var(--at-accent); + border-color: var(--at-accent); + color: #ffffff; +} + +.speedChip { + font-family: inherit; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 6px 12px; + border-radius: 10px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border); + color: var(--at-fg); + cursor: pointer; +} + +.speedChip:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.stepIndicator { + font-size: 13px; + color: var(--at-muted); + font-variant-numeric: tabular-nums; +} + +/* ───────────────────────────────────────────────────────────── + Movimento ridotto: niente transizioni, step istantanei + ───────────────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .scene * { + transition-duration: 0.01ms !important; + } +} diff --git a/src/components/AlgoViz/types.ts b/src/components/AlgoViz/types.ts new file mode 100644 index 0000000..893e50b --- /dev/null +++ b/src/components/AlgoViz/types.ts @@ -0,0 +1,86 @@ +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[]; + /** 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; +} + +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; + /** Riservato alla fase 2 (showCode): non emesso dai generatori fase 1. */ + line?: number; +}; + +export interface ArrayTrace { + initial: number[]; + target?: number; + steps: ArrayStep[]; +} + +export interface GeneratorInput { + data: number[]; + target?: number; +} + +export interface GeneratorDef { + id: string; + /** Nome mostrato nell'header della card, es. «Bubble sort». */ + label: string; + kind: 'sort' | 'search'; + /** 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 AlgoVizMode = 'study' | 'lab'; diff --git a/src/theme/MDXComponents.tsx b/src/theme/MDXComponents.tsx index 7975508..f392141 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 AlgoViz from '@site/src/components/AlgoViz'; export default { ...MDXComponents, @@ -29,4 +30,5 @@ export default { QuizFeedback, PyRunner, SQLRunner, + AlgoViz, }; From 3a25fe946ee1b0f2fba039123d8ba04e50048433 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Sun, 14 Jun 2026 03:15:20 +0200 Subject: [PATCH 2/9] =?UTF-8?q?refactor(algoviz):=20rinomina=20AlgoViz=20?= =?UTF-8?q?=E2=86=92=20Algorithm=20e=20riallinea=20lo=20stile=20ai=20runne?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename del componente e riprogettazione visiva sulla scorta del feedback. Rename - AlgoViz → Algorithm (cartella, tipi, CSS vars --av- → --alg-, registrazione in MDXComponents). Prop `algo` → `name`. - Pagina di test: docs/algoviz-test.mdx → docs/algorithm-test.mdx. Stile (coerenza con PyRunner / SQLRunner) - Tipografia tutta Monaspace: Argon (UI), Neon tabellare (numeri), Radon (didascalie/note). - Icone: nuovo Icon.tsx con SVG duotone inline (da FA 7.1.0), al posto di react-fontawesome — due-toni veri, ~2 KB, prendono l'accent. - Pallini macOS nell'header + pulsanti pill come .iconBtn dei runner. - Dimensioni testi riportate alla scala dei runner. - Progress bar minimale (2px) sulla linea di separazione body/footer. - Scritte degli step centrate sotto le barre. - Confronto: anello a 4 colori ciclico (stile «AI border» Google) al posto del box-shadow pieno e della bilancia (rimossa). - Barre: gradiente verticale + ombra moderni; --alg-item-0 da grigio a indigo (niente più collisione col grigio «ordinato»). Build, typecheck e lint verdi. La visualizzazione del confronto (connettore tra le barre) resta da rifinire sugli screenshot di riferimento. Co-Authored-By: Claude Opus 4.8 --- docs/{algoviz-test.mdx => algorithm-test.mdx} | 38 +- src/components/AlgoViz/styles.module.css | 313 ----------- .../{AlgoViz => Algorithm}/ArrayScene.tsx | 60 +-- .../{AlgoViz => Algorithm}/Controls.tsx | 21 +- .../{AlgoViz => Algorithm}/Explanation.tsx | 0 src/components/Algorithm/Icon.tsx | 111 ++++ .../{AlgoViz => Algorithm}/Player.tsx | 20 +- .../{AlgoViz => Algorithm}/applyStep.ts | 2 +- .../generators/binarySearch.ts | 0 .../generators/bubbleSort.ts | 0 .../generators/index.ts | 0 .../generators/insertionSort.ts | 0 .../generators/linearSearch.ts | 0 .../generators/quicksort.ts | 0 .../generators/selectionSort.ts | 0 .../{AlgoViz => Algorithm}/index.tsx | 39 +- src/components/{AlgoViz => Algorithm}/prng.ts | 0 src/components/Algorithm/styles.module.css | 489 ++++++++++++++++++ .../{AlgoViz => Algorithm}/types.ts | 2 +- src/theme/MDXComponents.tsx | 4 +- 20 files changed, 695 insertions(+), 404 deletions(-) rename docs/{algoviz-test.mdx => algorithm-test.mdx} (61%) delete mode 100644 src/components/AlgoViz/styles.module.css rename src/components/{AlgoViz => Algorithm}/ArrayScene.tsx (78%) rename src/components/{AlgoViz => Algorithm}/Controls.tsx (83%) rename src/components/{AlgoViz => Algorithm}/Explanation.tsx (100%) create mode 100644 src/components/Algorithm/Icon.tsx rename src/components/{AlgoViz => Algorithm}/Player.tsx (87%) rename src/components/{AlgoViz => Algorithm}/applyStep.ts (99%) rename src/components/{AlgoViz => Algorithm}/generators/binarySearch.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/bubbleSort.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/index.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/insertionSort.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/linearSearch.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/quicksort.ts (100%) rename src/components/{AlgoViz => Algorithm}/generators/selectionSort.ts (100%) rename src/components/{AlgoViz => Algorithm}/index.tsx (74%) rename src/components/{AlgoViz => Algorithm}/prng.ts (100%) create mode 100644 src/components/Algorithm/styles.module.css rename src/components/{AlgoViz => Algorithm}/types.ts (98%) diff --git a/docs/algoviz-test.mdx b/docs/algorithm-test.mdx similarity index 61% rename from docs/algoviz-test.mdx rename to docs/algorithm-test.mdx index 48e8559..4275962 100644 --- a/docs/algoviz-test.mdx +++ b/docs/algorithm-test.mdx @@ -1,75 +1,75 @@ --- unlisted: true -title: AlgoViz — pagina di test -description: Sandbox di verifica del componente AlgoViz (animazioni di algoritmi) +title: Algorithm — pagina di test +description: Sandbox di verifica del componente Algorithm (animazioni di algoritmi) --- -# AlgoViz — sandbox di test +# Algorithm — sandbox di test Pagina temporanea per validare le animazioni di algoritmi. Non è linkata dalla -sidebar (`unlisted: true`): la raggiungi solo via URL `/docs/algoviz-test`. Il -componente `` è registrato in `MDXComponents`, quindi qui sotto lo si usa +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 diff --git a/src/components/AlgoViz/styles.module.css b/src/components/AlgoViz/styles.module.css deleted file mode 100644 index 50f9fa4..0000000 --- a/src/components/AlgoViz/styles.module.css +++ /dev/null @@ -1,313 +0,0 @@ -/* ───────────────────────────────────────────────────────────── - Card (stile coerente con PyRunner / SQLRunner) - ───────────────────────────────────────────────────────────── */ -.card { - border: 1px solid var(--at-border); - border-radius: 14px; - background: var(--at-bg-panel); - margin: 1.4em 0; - overflow: hidden; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid var(--at-border); - background: var(--at-bg-subtle); - padding: 8px 12px; -} - -.headerLabel { - font-size: 15px; - font-weight: 600; - color: var(--at-fg-strong); -} - -.modeChip { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--at-muted); -} - -.body { - padding: 16px; -} - -.footer { - border-top: 1px solid var(--at-border); - padding: 8px 12px; -} - -.caption { - font-size: 13px; - font-style: italic; - 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-size: 14px; -} - -/* ───────────────────────────────────────────────────────────── - Scena array - ───────────────────────────────────────────────────────────── */ -.sceneWrap { - overflow-x: auto; -} - -.scene { - /* palette per identità (id % 10) */ - --av-item-0: #64748b; - --av-item-1: #16a34a; - --av-item-2: #dc2626; - --av-item-3: #0d9488; - --av-item-4: #2563eb; - --av-item-5: #65a30d; - --av-item-6: #ea580c; - --av-item-7: #ca8a04; - --av-item-8: #9333ea; - --av-item-9: #db2777; - --av-sorted: #cbd5e1; - --av-sorted-text: #334155; - --av-ok: #16a34a; - --av-bar-text: #ffffff; - - margin: 0 auto; - width: max-content; -} - -:global(html[data-theme='dark']) .scene { - --av-item-0: #94a3b8; - --av-item-1: #4ade80; - --av-item-2: #f87171; - --av-item-3: #2dd4bf; - --av-item-4: #60a5fa; - --av-item-5: #a3e635; - --av-item-6: #fb923c; - --av-item-7: #facc15; - --av-item-8: #c084fc; - --av-item-9: #f472b6; - --av-sorted: #3f3f46; - --av-sorted-text: #fafafa; - --av-ok: #4ade80; - --av-bar-text: #18181b; -} - -.chipRow { - display: flex; - justify-content: flex-end; - margin-bottom: 8px; -} - -.targetChip { - display: inline-block; - font-size: 13px; - 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(--av-dur) ease; -} - -.targetChipActive { - border-color: var(--at-accent); -} - -.stage { - position: relative; -} - -.bar { - position: absolute; - top: 0; - left: 0; - box-sizing: border-box; - border-radius: 8px; - display: flex; - align-items: flex-end; - justify-content: center; - padding-bottom: 6px; - will-change: transform; - transition: - transform var(--av-dur) ease, - height var(--av-dur) ease, - opacity var(--av-dur) ease, - box-shadow var(--av-dur) ease, - background var(--av-dur) ease; -} - -.barValue { - font-size: 14px; - font-weight: 700; - line-height: 1; -} - -.balance { - position: absolute; - left: 0; - top: 156px; /* TRACK_H + 6 */ - width: max-content; - font-size: 18px; - color: var(--at-muted); - pointer-events: none; - transition: transform var(--av-dur) ease; -} - -.badge { - position: absolute; - top: 184px; /* TRACK_H + 34 */ - width: 20px; - height: 20px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 700; - border-radius: 6px; - background: var(--at-bg-chip); - border: 1px solid var(--at-border); - color: var(--at-fg); - transition: left var(--av-dur) ease; -} - -.range { - position: absolute; - top: 212px; /* TRACK_H + 62 */ - 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(--av-dur) ease, - width var(--av-dur) ease; -} - -.rangeLabel { - position: absolute; - top: 224px; /* TRACK_H + 74 */ - text-align: center; - font-size: 11px; - color: var(--at-muted); -} - -/* ───────────────────────────────────────────────────────────── - Spiegazioni (solo study) - ───────────────────────────────────────────────────────────── */ -.explanation { - min-height: 3.2em; - margin-top: 14px; -} - -.phase { - font-size: 15px; - color: var(--at-fg-strong); -} - -.note { - font-size: 14px; - color: var(--at-muted-soft); - font-style: italic; - margin-top: 2px; -} - -/* ───────────────────────────────────────────────────────────── - Pulsantiera - ───────────────────────────────────────────────────────────── */ -.controls { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} - -.controlsLeft { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.btn { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: inherit; - font-size: 13px; - padding: 6px 12px; - border-radius: 10px; - background: var(--at-bg-chip); - border: 1px solid var(--at-border); - color: var(--at-fg); - cursor: pointer; - transition: - background 0.15s ease, - border-color 0.15s ease; -} - -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.btn:focus-visible { - outline: 2px solid var(--at-accent); - outline-offset: 2px; -} - -.btnPrimary { - background: var(--at-accent); - border-color: var(--at-accent); - color: #ffffff; -} - -.speedChip { - font-family: inherit; - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; - padding: 6px 12px; - border-radius: 10px; - background: var(--at-bg-chip); - border: 1px solid var(--at-border); - color: var(--at-fg); - cursor: pointer; -} - -.speedChip:focus-visible { - outline: 2px solid var(--at-accent); - outline-offset: 2px; -} - -.stepIndicator { - font-size: 13px; - color: var(--at-muted); - font-variant-numeric: tabular-nums; -} - -/* ───────────────────────────────────────────────────────────── - Movimento ridotto: niente transizioni, step istantanei - ───────────────────────────────────────────────────────────── */ -@media (prefers-reduced-motion: reduce) { - .scene * { - transition-duration: 0.01ms !important; - } -} diff --git a/src/components/AlgoViz/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx similarity index 78% rename from src/components/AlgoViz/ArrayScene.tsx rename to src/components/Algorithm/ArrayScene.tsx index 55b4a4d..d166cd3 100644 --- a/src/components/AlgoViz/ArrayScene.tsx +++ b/src/components/Algorithm/ArrayScene.tsx @@ -1,6 +1,5 @@ import { type CSSProperties } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { ArrayItem, ArraySceneState, CompareRef } from './types'; +import type { ArrayItem, ArraySceneState } from './types'; import styles from './styles.module.css'; const BAR_W = 38; // larghezza barra @@ -8,10 +7,9 @@ const GAP = 8; // spazio tra barre const UNIT = BAR_W + GAP; const TRACK_H = 150; // altezza zona barre (allineate in basso) const EXTRACT_DY = 52; // quanto scende l'elemento estratto -const LANE_H = 96; // zona sotto la track: bilancia/badge/graffa/estratto +const LANE_H = 96; // zona sotto la track: badge/graffa/estratto const BADGE_W = 20; const BADGE_OFFSET = 22; -const TARGET_CHIP_W = 96; // stima per centrare la bilancia sul chip target const x = (i: number) => i * UNIT; const slotCenter = (i: number) => x(i) + BAR_W / 2; @@ -71,51 +69,42 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) { const h = barHeight(item.value); const tx = x(slot); const ty = isExtracted ? TRACK_H - h + EXTRACT_DY : TRACK_H - h; - const style: CSSProperties = { + // `--alg-bar` = tinta dell'elemento; il gradiente moderno è composto in CSS. + const style: CSSProperties & Record<'--alg-bar', string> = { transform: `translate(${tx}px, ${ty}px)`, height: h, width: BAR_W, + '--alg-bar': `var(--alg-item-${item.id % 10})`, }; const isSorted = !isExtracted && sortedSet.has(slot); if (isSorted) { - style.background = 'var(--av-sorted)'; - style.color = 'var(--av-sorted-text)'; + style['--alg-bar'] = 'var(--alg-sorted)'; + style.color = 'var(--alg-sorted-text)'; } else { - style.background = `var(--av-item-${item.id % 10})`; - style.color = 'var(--av-bar-text)'; + style.color = 'var(--alg-bar-text)'; } if (!isExtracted && (fadedSet.has(slot) || !inRange(slot))) { style.opacity = 0.35; } - const isComparing = isExtracted - ? comparingExtracted - : comparingSlots.has(slot); + return style; + } + + // Stato di evidenziazione → classe (anello animato gestito in CSS). + function barClass({ slot, isExtracted }: Entry): string { const isFound = !isExtracted && state.outcome !== null && state.outcome.found && state.outcome.i === slot; - if (isFound) style.boxShadow = '0 0 0 3px var(--av-ok)'; - else if (isComparing) style.boxShadow = '0 0 0 2px var(--at-accent)'; - - return style; - } - - // Bilancia: centrata alla x media dei due riferimenti. - function refCenterX(ref: CompareRef): number { - if (ref.kind === 'item') return slotCenter(ref.i); - if (ref.kind === 'extracted') { - return state.extracted ? slotCenter(state.extracted.overIndex) : 0; - } - return sceneWidth - TARGET_CHIP_W / 2; // target chip - } - let balanceX: number | null = null; - if (state.comparing) { - balanceX = - (refCenterX(state.comparing.a) + refCenterX(state.comparing.b)) / 2; + if (isFound) return `${styles.bar} ${styles.barFound}`; + const isComparing = isExtracted + ? comparingExtracted + : comparingSlots.has(slot); + if (isComparing) return `${styles.bar} ${styles.barComparing}`; + return styles.bar; } // Badge pointer raggruppati per slot. @@ -162,22 +151,13 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) { {entries.map((entry) => (
{entry.item.value}
))} - {balanceX !== null && ( -
- -
- )} - {badges.map((b) => (
{b.name} diff --git a/src/components/AlgoViz/Controls.tsx b/src/components/Algorithm/Controls.tsx similarity index 83% rename from src/components/AlgoViz/Controls.tsx rename to src/components/Algorithm/Controls.tsx index 7c7eb3d..21cc330 100644 --- a/src/components/AlgoViz/Controls.tsx +++ b/src/components/Algorithm/Controls.tsx @@ -1,12 +1,11 @@ import { type Dispatch } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { IconName } from '@fortawesome/fontawesome-svg-core'; -import type { AlgoVizMode } from './types'; +import Icon, { type IconName } from './Icon'; +import type { AlgorithmMode } from './types'; import type { PlayerAction } from './Player'; import styles from './styles.module.css'; interface ControlsProps { - mode: AlgoVizMode; + mode: AlgorithmMode; stepIndex: number; total: number; playing: boolean; @@ -22,19 +21,23 @@ interface BtnProps { onClick: () => void; disabled?: boolean; primary?: boolean; + /** Solo icona (label come aria-label, non visibile). */ + iconOnly?: boolean; } -function Btn({ icon, label, onClick, disabled, primary }: BtnProps) { +function Btn({ icon, label, onClick, disabled, primary, iconOnly }: BtnProps) { return ( ); } @@ -60,6 +63,7 @@ export default function Controls({ label="Reset" onClick={() => dispatch({ type: 'RESET' })} disabled={atStart} + iconOnly /> {mode === 'study' && ( dispatch({ type: 'BACK' })} disabled={atStart} + iconOnly /> )} diff --git a/src/components/AlgoViz/Explanation.tsx b/src/components/Algorithm/Explanation.tsx similarity index 100% rename from src/components/AlgoViz/Explanation.tsx rename to src/components/Algorithm/Explanation.tsx diff --git a/src/components/Algorithm/Icon.tsx b/src/components/Algorithm/Icon.tsx new file mode 100644 index 0000000..f2791f6 --- /dev/null +++ b/src/components/Algorithm/Icon.tsx @@ -0,0 +1,111 @@ +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', + }, +} 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/AlgoViz/Player.tsx b/src/components/Algorithm/Player.tsx similarity index 87% rename from src/components/AlgoViz/Player.tsx rename to src/components/Algorithm/Player.tsx index 9599a29..2f2873a 100644 --- a/src/components/AlgoViz/Player.tsx +++ b/src/components/Algorithm/Player.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useReducer, type CSSProperties } from 'react'; -import type { ArrayTrace, AlgoVizMode } from './types'; +import type { ArrayTrace, AlgorithmMode } from './types'; import { applyArrayStep, initArrayState } from './applyStep'; import ArrayScene from './ArrayScene'; import Controls from './Controls'; @@ -12,7 +12,7 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×4']; interface PlayerProps { trace: ArrayTrace; - mode: AlgoVizMode; + mode: AlgorithmMode; /** Nome algoritmo per aria-label. */ label: string; showRegenerate: boolean; @@ -97,9 +97,11 @@ export default function Player({ ); const durStyle = { - '--av-dur': `${DUR_MS[state.speedIdx]}ms`, + '--alg-dur': `${DUR_MS[state.speedIdx]}ms`, } as CSSProperties; + const progress = total > 0 ? (state.stepIndex / total) * 100 : 0; + return ( <>
@@ -115,6 +117,18 @@ export default function Player({ )}
+
+
+
, number> = { +const SPEED_IDX: Record, number> = { slow: 0, normal: 1, fast: 2, @@ -15,11 +15,11 @@ const SPEED_IDX: Record, number> = { const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); -interface AlgoVizProps { +interface AlgorithmProps { /** Id del generatore registrato (vedi generators/index.ts). */ - algo: string; + name: string; /** 'study' (default): passo-passo con spiegazioni. 'lab': autoplay e dati casuali. */ - mode?: AlgoVizMode; + 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). */ @@ -32,18 +32,18 @@ interface AlgoVizProps { caption?: string; } -export default function AlgoViz({ - algo, +export default function Algorithm({ + name, mode = 'study', data, shuffle, target, speed = 'normal', caption, -}: AlgoVizProps) { +}: AlgorithmProps) { // Seed iniziale fisso (1): markup server e client coincidono (hydration). const [seed, setSeed] = useState(1); - const gen = getGenerator(algo); + const gen = getGenerator(name); const trace = useMemo(() => { if (!gen) return null; @@ -55,7 +55,7 @@ export default function AlgoViz({ if (d.length > 12) { // eslint-disable-next-line no-console console.warn( - `[AlgoViz] data troncato a 12 elementi (ne aveva ${d.length}).`, + `[Algorithm] data troncato a 12 elementi (ne aveva ${d.length}).`, ); d = d.slice(0, 12); } @@ -80,10 +80,10 @@ export default function AlgoViz({ if (!gen || !trace) { // eslint-disable-next-line no-console - console.error(`[AlgoViz] algoritmo "${algo}" non registrato.`); + console.error(`[Algorithm] algoritmo "${name}" non registrato.`); return (
- AlgoViz: algoritmo “{algo}” non registrato + Algorithm: algoritmo “{name}” non registrato
); } @@ -95,11 +95,16 @@ export default function AlgoViz({ <>
- {gen.label} +
+
- + {mode === 'study' ? 'studio' : 'lab'}
diff --git a/src/components/AlgoViz/prng.ts b/src/components/Algorithm/prng.ts similarity index 100% rename from src/components/AlgoViz/prng.ts rename to src/components/Algorithm/prng.ts diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css new file mode 100644 index 0000000..7ca955f --- /dev/null +++ b/src/components/Algorithm/styles.module.css @@ -0,0 +1,489 @@ +/* ───────────────────────────────────────────────────────────── + — animazioni di algoritmi, look coerente con + PyRunner / SQLRunner (Atmospheric). Tutta la tipografia è + Monaspace: Argon per la UI, Neon (tabellare) per i numeri, + Radon per le didascalie. + ───────────────────────────────────────────────────────────── */ +.card { + border: 1px solid var(--at-border); + border-radius: 14px; + background: var(--at-bg-panel); + backdrop-filter: blur(8px); + margin: 1.4em 0; + overflow: hidden; +} + +/* Header / toolbar ------------------------------------------------ */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--at-border); + background: var(--at-bg-subtle); + padding: 8px 12px; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +/* Pallini stile macOS (decorativi) — identici a PyRunner */ +.trafficLights { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.tlDot { + width: 11px; + height: 11px; + border-radius: 50%; + display: inline-block; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2); +} + +.tlClose { + background: #ff5f57; +} +.tlMin { + background: #febc2e; +} +.tlMax { + background: #28c840; +} + +.headerLabel { + font-family: var(--font-mono-ui); + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--at-fg-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modeChip { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-family: var(--font-mono-ui); + font-size: 0.8rem; + color: var(--at-muted); +} + +/* Corpo / footer -------------------------------------------------- */ +.body { + padding: 16px; +} + +.footer { + position: relative; + padding: 12px 12px 10px; +} + +/* Progress bar minimale: È la linea di separazione body/footer. + Track = colore bordo, riempimento = accent. */ +.progress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--at-border); + overflow: hidden; +} + +.progressFill { + height: 100%; + background: var(--at-accent); + border-radius: 0 2px 2px 0; + transition: width 240ms ease; +} + +.caption { + font-family: var(--font-mono-comment); + font-size: 0.85rem; + font-style: italic; + 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; +} + +/* ───────────────────────────────────────────────────────────── + Scena array + ───────────────────────────────────────────────────────────── */ +.sceneWrap { + overflow-x: auto; +} + +.scene { + /* palette per identità (id % 10) — niente grigi: il grigio è + riservato alle barre «ordinate» (--alg-sorted). */ + --alg-item-0: #4f46e5; /* indigo (era slate) */ + --alg-item-1: #16a34a; + --alg-item-2: #dc2626; + --alg-item-3: #0d9488; + --alg-item-4: #2563eb; + --alg-item-5: #65a30d; + --alg-item-6: #ea580c; + --alg-item-7: #ca8a04; + --alg-item-8: #9333ea; + --alg-item-9: #db2777; + --alg-sorted: #cbd5e1; + --alg-sorted-text: #334155; + --alg-ok: #16a34a; + --alg-bar-text: #ffffff; + + margin: 0 auto; + width: max-content; +} + +:global(html[data-theme='dark']) .scene { + --alg-item-0: #818cf8; + --alg-item-1: #4ade80; + --alg-item-2: #f87171; + --alg-item-3: #2dd4bf; + --alg-item-4: #60a5fa; + --alg-item-5: #a3e635; + --alg-item-6: #fb923c; + --alg-item-7: #facc15; + --alg-item-8: #c084fc; + --alg-item-9: #f472b6; + --alg-sorted: #3f3f46; + --alg-sorted-text: #fafafa; + --alg-ok: #4ade80; + --alg-bar-text: #18181b; +} + +.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); +} + +.stage { + position: relative; +} + +/* Barre ----------------------------------------------------------- */ +.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: 6px; + /* gradiente verticale soft: luce in alto, ombra sul fondo */ + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--alg-bar) 80%, white) 0%, + var(--alg-bar) 55%, + color-mix(in srgb, var(--alg-bar) 86%, black) 100% + ); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.28); + will-change: transform; + transition: + transform var(--alg-dur) ease, + height var(--alg-dur) ease, + opacity var(--alg-dur) ease, + box-shadow var(--alg-dur) ease; +} + +:global(html[data-theme='dark']) .bar { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--alg-bar) 88%, white) 0%, + var(--alg-bar) 55%, + color-mix(in srgb, var(--alg-bar) 88%, black) 100% + ); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.35), + inset 0 1px 0 rgba(255, 255, 255, 0.16); +} + +.barValue { + font-family: var(--font-mono); + font-size: 0.92rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +/* Confronto in corso: anello che cicla i 4 colori, stile «AI border» + dei pulsanti moderni Google. Ben visibile su barre già colorate + grazie allo stacco col colore del pannello. */ +.barComparing { + z-index: 2; + animation: algCompareRing 2.2s linear infinite; +} + +@keyframes algCompareRing { + 0% { + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px #4285f4, + 0 0 9px 3px rgba(66, 133, 244, 0.4); + } + 25% { + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px #ea4335, + 0 0 9px 3px rgba(234, 67, 53, 0.4); + } + 50% { + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px #fbbc05, + 0 0 9px 3px rgba(251, 188, 5, 0.4); + } + 75% { + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px #34a853, + 0 0 9px 3px rgba(52, 168, 83, 0.4); + } + 100% { + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px #4285f4, + 0 0 9px 3px rgba(66, 133, 244, 0.4); + } +} + +/* Esito ricerca: anello verde fisso. */ +.barFound { + z-index: 2; + box-shadow: + 0 0 0 2px var(--at-bg-panel), + 0 0 0 4px var(--alg-ok), + 0 0 11px 3px color-mix(in srgb, var(--alg-ok) 45%, transparent); +} + +/* Lane sotto la track: badge, graffa, etichette ------------------- */ +.badge { + position: absolute; + top: 184px; /* TRACK_H + 34 */ + width: 20px; + height: 20px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-size: 0.78rem; + 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; +} + +.range { + position: absolute; + top: 212px; /* TRACK_H + 62 */ + 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; + top: 224px; /* TRACK_H + 74 */ + text-align: center; + font-family: var(--font-mono-ui); + font-size: 0.78rem; + color: var(--at-muted); +} + +/* ───────────────────────────────────────────────────────────── + Spiegazioni (solo study) — centrate sotto le barre + ───────────────────────────────────────────────────────────── */ +.explanation { + min-height: 3.2em; + margin-top: 14px; + text-align: center; +} + +.phase { + font-family: var(--font-mono-ui); + font-size: 0.95rem; + color: var(--at-fg-strong); +} + +.note { + font-family: var(--font-mono-comment); + font-size: 0.85rem; + color: var(--at-muted-soft); + margin-top: 3px; +} + +/* ───────────────────────────────────────────────────────────── + Pulsantiera — pill coerenti con PyRunner / SQLRunner + ───────────────────────────────────────────────────────────── */ +.controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.controlsLeft { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 999px; + 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; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.btn:hover:not(:disabled) { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); + color: var(--at-fg-strong); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.btnGlyph { + font-size: 0.95em; +} + +.btnIcon { + width: 32px; + padding: 0; +} + +.btnPrimary { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); + color: var(--at-accent); +} + +.btnPrimary:hover:not(:disabled) { + background: var(--at-accent-bg); + color: var(--at-accent-soft); +} + +.speedChip { + height: 32px; + padding: 0 12px; + border-radius: 999px; + background: var(--at-bg-chip); + border: 1px solid var(--at-border-strong); + color: var(--at-fg); + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.speedChip:hover { + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); +} + +.speedChip:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.stepIndicator { + font-family: var(--font-mono-ui); + font-size: 0.82rem; + color: var(--at-muted); + font-variant-numeric: tabular-nums; +} + +/* ───────────────────────────────────────────────────────────── + Movimento ridotto: niente transizioni/animazioni + ───────────────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .scene *, + .progressFill, + .barComparing { + transition-duration: 0.01ms !important; + animation: none !important; + } +} diff --git a/src/components/AlgoViz/types.ts b/src/components/Algorithm/types.ts similarity index 98% rename from src/components/AlgoViz/types.ts rename to src/components/Algorithm/types.ts index 893e50b..7ebfb73 100644 --- a/src/components/AlgoViz/types.ts +++ b/src/components/Algorithm/types.ts @@ -83,4 +83,4 @@ export interface GeneratorDef { generate: (input: GeneratorInput) => ArrayTrace; } -export type AlgoVizMode = 'study' | 'lab'; +export type AlgorithmMode = 'study' | 'lab'; diff --git a/src/theme/MDXComponents.tsx b/src/theme/MDXComponents.tsx index f392141..e81e8f4 100644 --- a/src/theme/MDXComponents.tsx +++ b/src/theme/MDXComponents.tsx @@ -15,7 +15,7 @@ import Quiz, { } from '@site/src/components/Quiz'; import PyRunner from '@site/src/theme/PyRunner'; import SQLRunner from '@site/src/theme/SQLRunner'; -import AlgoViz from '@site/src/components/AlgoViz'; +import Algorithm from '@site/src/components/Algorithm'; export default { ...MDXComponents, @@ -30,5 +30,5 @@ export default { QuizFeedback, PyRunner, SQLRunner, - AlgoViz, + Algorithm, }; From dbc96c8bf49a30482d694dff1f6d998abd610006 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Sun, 14 Jun 2026 13:51:35 +0200 Subject: [PATCH 3/9] =?UTF-8?q?feat(algoviz):=20redesign=20Algorithm=20?= =?UTF-8?q?=E2=80=94=20pannello=20codice,=20preset=20Studio/Lab,=20indicat?= =?UTF-8?q?ore=20di=20posizione?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodePanel: pseudocodice con evidenziazione Python (highlightPy), colori allineati ai runner (--py-*), bottone copia con crossfade copia↔spunta; colonna statistiche (confronti/scambi) attivabile. - Preset Studio/Lab che configurano i toggle (codice, spiegazione). Il toggle codice governa anche i controlli avanzati (shuffle, esegui, velocità): Studio = passo-passo guidato, Lab = tutto visibile. Toccare un toggle sgancia il preset, ripremerlo lo riapplica; default per blocco via prop `mode`. - Toggle colore globale e persistito (gradiente/arcobaleno) via useAlgoPref; modalità e toggle codice/spiegazione restano locali al blocco. - Indicatore di posizione corrente sotto le barre (campo `cursor` generico), con etichetta della variabile quando il codice è attivo; applicato al bubble sort. - Icone duotone inline FA 7.1.0 (code, palette, copy, check, comment-lines). - Selettore velocità con indicatore scorrevole; legenda dinamica (solo stati usati); colori stato scambio=verde / ordinato=bianco; corsia dimensionata sull'intera trace; note del bubble sort riscritte (ragionamento, non cronaca). - Controls.tsx ed Explanation.tsx assorbiti in Player. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/ArrayScene.tsx | 151 ++- src/components/Algorithm/CodePanel.tsx | 101 ++ src/components/Algorithm/Controls.tsx | 116 --- src/components/Algorithm/Explanation.tsx | 18 - src/components/Algorithm/Icon.tsx | 33 + src/components/Algorithm/Player.tsx | 332 +++++- src/components/Algorithm/applyStep.ts | 22 +- .../Algorithm/generators/binarySearch.ts | 3 + .../Algorithm/generators/bubbleSort.ts | 45 +- .../Algorithm/generators/insertionSort.ts | 33 +- .../Algorithm/generators/linearSearch.ts | 3 + .../Algorithm/generators/quicksort.ts | 3 + .../Algorithm/generators/selectionSort.ts | 32 +- src/components/Algorithm/highlightPy.ts | 68 ++ src/components/Algorithm/index.tsx | 189 +++- src/components/Algorithm/styles.module.css | 955 +++++++++++++----- src/components/Algorithm/types.ts | 29 +- src/components/Algorithm/usePref.ts | 70 ++ 18 files changed, 1669 insertions(+), 534 deletions(-) create mode 100644 src/components/Algorithm/CodePanel.tsx delete mode 100644 src/components/Algorithm/Controls.tsx delete mode 100644 src/components/Algorithm/Explanation.tsx create mode 100644 src/components/Algorithm/highlightPy.ts create mode 100644 src/components/Algorithm/usePref.ts diff --git a/src/components/Algorithm/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx index d166cd3..82888e7 100644 --- a/src/components/Algorithm/ArrayScene.tsx +++ b/src/components/Algorithm/ArrayScene.tsx @@ -2,38 +2,75 @@ import { type CSSProperties } from 'react'; import type { ArrayItem, ArraySceneState } from './types'; import styles from './styles.module.css'; -const BAR_W = 38; // larghezza barra -const GAP = 8; // spazio tra barre +const BAR_W = 44; // larghezza barra +const GAP = 14; // spazio tra barre const UNIT = BAR_W + GAP; -const TRACK_H = 150; // altezza zona barre (allineate in basso) -const EXTRACT_DY = 52; // quanto scende l'elemento estratto -const LANE_H = 96; // zona sotto la track: badge/graffa/estratto +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 BADGE_W = 20; const BADGE_OFFSET = 22; const x = (i: number) => i * UNIT; const slotCenter = (i: number) => x(i) + BAR_W / 2; +// 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 }: ArraySceneProps) { +export default function ArrayScene({ + state, + target, + label, + colorMode, + laneH, + showLabels, +}: ArraySceneProps) { const n = state.items.length; const sceneWidth = Math.max(n * UNIT - GAP, BAR_W); + const base = HEADROOM + TRACK_H; // baseline (base delle barre) - // maxValue = max(initial): l'insieme dei valori è costante (items + estratto). + // 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 barHeight = (value: number) => - 28 + Math.round((value / maxValue) * 110); - - // Analisi del confronto in corso. + 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; @@ -47,10 +84,16 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) { const sortedSet = new Set(state.sorted); const fadedSet = new Set(state.faded); + const swappingSet = new Set(state.swapping); + const minSlots = new Set( + state.pointers.filter((p) => p.name === 'min').map((p) => p.i), + ); 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). + // 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) => { @@ -67,46 +110,41 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) { 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 (minSlots.has(slot)) ring = 'compare'; + 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 ? TRACK_H - h + EXTRACT_DY : TRACK_H - h; - // `--alg-bar` = tinta dell'elemento; il gradiente moderno è composto in CSS. - const style: CSSProperties & Record<'--alg-bar', string> = { + 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: BAR_W, - '--alg-bar': `var(--alg-item-${item.id % 10})`, + color: 'var(--alg-bar-text)', + '--alg-hue': String(Math.round(hueOf(item))), }; - - const isSorted = !isExtracted && sortedSet.has(slot); - if (isSorted) { - style['--alg-bar'] = 'var(--alg-sorted)'; - style.color = 'var(--alg-sorted-text)'; - } else { - style.color = 'var(--alg-bar-text)'; - } - + if (ring) style.boxShadow = ringShadow(ring); if (!isExtracted && (fadedSet.has(slot) || !inRange(slot))) { - style.opacity = 0.35; + style.opacity = 0.32; } - return style; } - // Stato di evidenziazione → classe (anello animato gestito in CSS). - function barClass({ slot, isExtracted }: Entry): string { - const isFound = - !isExtracted && - state.outcome !== null && - state.outcome.found && - state.outcome.i === slot; - if (isFound) return `${styles.bar} ${styles.barFound}`; - const isComparing = isExtracted - ? comparingExtracted - : comparingSlots.has(slot); - if (isComparing) return `${styles.bar} ${styles.barComparing}`; - return styles.bar; - } - // Badge pointer raggruppati per slot. const bySlot = new Map(); for (const p of state.pointers) { @@ -120,8 +158,6 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) { const groupW = BADGE_W + (ordered.length - 1) * BADGE_OFFSET; const start = slotCenter(slot) - groupW / 2; ordered.forEach((name, k) => { - // key stabile per nome: il badge slitta (transizione su `left`) - // quando il puntatore cambia slot, invece di rimontare. badges.push({ key: name, left: start + k * BADGE_OFFSET, name }); }); } @@ -146,29 +182,49 @@ export default function ArrayScene({ state, target, label }: ArraySceneProps) {
+
+ {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 && ( <>
{ + 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); + + useEffect(() => () => window.clearTimeout(timer.current), []); + + 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/Controls.tsx b/src/components/Algorithm/Controls.tsx deleted file mode 100644 index 21cc330..0000000 --- a/src/components/Algorithm/Controls.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { type Dispatch } from 'react'; -import Icon, { type IconName } from './Icon'; -import type { AlgorithmMode } from './types'; -import type { PlayerAction } from './Player'; -import styles from './styles.module.css'; - -interface ControlsProps { - mode: AlgorithmMode; - stepIndex: number; - total: number; - playing: boolean; - speedLabel: string; - showRegenerate: boolean; - onRegenerate?: () => void; - dispatch: Dispatch; -} - -interface BtnProps { - icon: IconName; - label: string; - onClick: () => void; - disabled?: boolean; - primary?: boolean; - /** Solo icona (label come aria-label, non visibile). */ - iconOnly?: boolean; -} - -function Btn({ icon, label, onClick, disabled, primary, iconOnly }: BtnProps) { - return ( - - ); -} - -export default function Controls({ - mode, - stepIndex, - total, - playing, - speedLabel, - showRegenerate, - onRegenerate, - dispatch, -}: ControlsProps) { - const atStart = stepIndex === 0; - const atEnd = stepIndex >= total; - - return ( -
-
- dispatch({ type: 'RESET' })} - disabled={atStart} - iconOnly - /> - {mode === 'study' && ( - dispatch({ type: 'BACK' })} - disabled={atStart} - iconOnly - /> - )} - - {mode === 'lab' && showRegenerate && onRegenerate && ( - - )} - - {mode === 'lab' && ( - - )} - - dispatch({ type: 'FWD' })} - disabled={atEnd} - primary={mode === 'study'} - /> - - {mode === 'lab' && ( - dispatch({ type: 'TOGGLE_PLAY' })} - primary - /> - )} -
- -
- passo {stepIndex} di {total} -
-
- ); -} diff --git a/src/components/Algorithm/Explanation.tsx b/src/components/Algorithm/Explanation.tsx deleted file mode 100644 index b5e8bb5..0000000 --- a/src/components/Algorithm/Explanation.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ArraySceneState } from './types'; -import styles from './styles.module.css'; - -interface ExplanationProps { - phase: string; - note: string; - /** L'esito arriva già dalle note degli step `outcome`: la nota resta. */ - outcome: ArraySceneState['outcome']; -} - -export default function Explanation({ phase, note }: ExplanationProps) { - return ( -
-
{phase}
-
{note}
-
- ); -} diff --git a/src/components/Algorithm/Icon.tsx b/src/components/Algorithm/Icon.tsx index f2791f6..f642a58 100644 --- a/src/components/Algorithm/Icon.tsx +++ b/src/components/Algorithm/Icon.tsx @@ -78,6 +78,39 @@ const ICONS = { 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; diff --git a/src/components/Algorithm/Player.tsx b/src/components/Algorithm/Player.tsx index 2f2873a..305c618 100644 --- a/src/components/Algorithm/Player.tsx +++ b/src/components/Algorithm/Player.tsx @@ -1,23 +1,27 @@ import { useEffect, useMemo, useReducer, type CSSProperties } from 'react'; -import type { ArrayTrace, AlgorithmMode } from './types'; +import type { ArrayTrace, GeneratorDef } from './types'; import { applyArrayStep, initArrayState } from './applyStep'; import ArrayScene from './ArrayScene'; -import Controls from './Controls'; -import Explanation from './Explanation'; +import CodePanel from './CodePanel'; +import Icon, { type IconName } from './Icon'; import styles from './styles.module.css'; -const TICK_MS = [1400, 800, 450, 220]; -const DUR_MS = [400, 300, 220, 140]; -const SPEED_LABELS = ['×½', '×1', '×2', '×4']; +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; - mode: AlgorithmMode; - /** Nome algoritmo per aria-label. */ - label: string; - showRegenerate: boolean; - onRegenerate?: () => void; - /** 0..3, default 1. */ + 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; } @@ -27,25 +31,30 @@ interface PlayerState { speedIdx: number; } -export type PlayerAction = +type Action = | { type: 'FWD' } | { type: 'BACK' } | { type: 'RESET' } | { type: 'TOGGLE_PLAY' } - | { type: 'CYCLE_SPEED' }; + | { type: 'STOP' } + | { type: 'SEEK'; to: number } + | { type: 'SET_SPEED'; idx: number }; export default function Player({ trace, - mode, - label, - showRegenerate, - onRegenerate, + gen, + showExplain, + colorMode, + showCode, + advanced, + showStats, + onShuffle, initialSpeedIdx, }: PlayerProps) { const total = trace.steps.length; const [state, dispatch] = useReducer( - (s: PlayerState, action: PlayerAction): PlayerState => { + (s: PlayerState, action: Action): PlayerState => { switch (action.type) { case 'FWD': { const ni = Math.min(s.stepIndex + 1, total); @@ -67,9 +76,12 @@ export default function Player({ if (s.stepIndex >= total) return { ...s, stepIndex: 0, playing: true }; return { ...s, playing: !s.playing }; - case 'CYCLE_SPEED': - // cicla 1→2→3→1; lo 0 (×½) è raggiungibile solo da prop. - return { ...s, speedIdx: s.speedIdx === 3 ? 1 : s.speedIdx + 1 }; + 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; } @@ -77,7 +89,18 @@ export default function Player({ { stepIndex: 0, playing: false, speedIdx: initialSpeedIdx }, ); - // Autoplay: avanza dopo TICK_MS; cleanup al cambio di stepIndex/playing/speedIdx. + // 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( @@ -87,8 +110,7 @@ export default function Player({ return () => clearTimeout(id); }, [state.playing, state.stepIndex, state.speedIdx]); - // Stato scena: ricalcolo dall'inizio (ok per n piccoli). - const sceneState = useMemo( + const scene = useMemo( () => trace.steps .slice(0, state.stepIndex) @@ -96,50 +118,248 @@ export default function Player({ [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; - const progress = total > 0 ? (state.stepIndex / total) * 100 : 0; - return ( <> -
-
- -
- {mode === 'study' && ( - +
+ +
+ + {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 && ( + + )} + dispatch({ type: 'FWD' })} + disabled={atEnd} + /> + + {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 index 4d1f41e..13f415e 100644 --- a/src/components/Algorithm/applyStep.ts +++ b/src/components/Algorithm/applyStep.ts @@ -18,9 +18,14 @@ export function initArrayState(trace: ArrayTrace): ArraySceneState { comparing: null, sorted: [], faded: [], + swapping: [], outcome: null, phase: '', note: '', + line: null, + cursor: null, + comparisons: 0, + swaps: 0, }; } @@ -32,12 +37,21 @@ export function applyArrayStep( ): 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': @@ -45,6 +59,7 @@ export function applyArrayStep( a: { kind: 'item', i: step.i }, b: { kind: 'target' }, }; + next.comparisons = state.comparisons + 1; break; case 'compareExtracted': @@ -52,6 +67,7 @@ export function applyArrayStep( a: { kind: 'item', i: step.i }, b: { kind: 'extracted' }, }; + next.comparisons = state.comparisons + 1; break; case 'swap': { @@ -68,7 +84,8 @@ export function applyArrayStep( const items = state.items.slice(); [items[i], items[j]] = [items[j], items[i]]; next.items = items; - next.comparing = null; + next.swapping = [i, j]; + next.swaps = state.swaps + 1; break; } @@ -104,7 +121,8 @@ export function applyArrayStep( if (state.extracted) { next.extracted = { ...state.extracted, overIndex: from }; } - next.comparing = null; + next.swapping = [from, from + 1]; + next.swaps = state.swaps + 1; break; } diff --git a/src/components/Algorithm/generators/binarySearch.ts b/src/components/Algorithm/generators/binarySearch.ts index 4f2fcf1..3e909d4 100644 --- a/src/components/Algorithm/generators/binarySearch.ts +++ b/src/components/Algorithm/generators/binarySearch.ts @@ -87,6 +87,9 @@ 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, diff --git a/src/components/Algorithm/generators/bubbleSort.ts b/src/components/Algorithm/generators/bubbleSort.ts index f5c0f0c..56ad321 100644 --- a/src/components/Algorithm/generators/bubbleSort.ts +++ b/src/components/Algorithm/generators/bubbleSort.ts @@ -5,6 +5,17 @@ import type { 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; @@ -12,7 +23,8 @@ function generate({ data }: GeneratorInput): ArrayTrace { steps.push({ op: 'phase', - text: 'Confronto le coppie di numeri vicini: a ogni giro il più grande “sale” in fondo.', + 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; @@ -23,7 +35,9 @@ function generate({ data }: GeneratorInput): ArrayTrace { op: 'compare', i: j, j: j + 1, - note: `Confronto ${a[j]} e ${a[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]; @@ -31,29 +45,36 @@ function generate({ data }: GeneratorInput): ArrayTrace { op: 'swap', i: j, j: j + 1, - note: `${maggiore} è più grande: li scambio.`, + 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', - note: 'Sono già nell’ordine giusto: li lascio così.', + 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], - note: `${a[n - 1 - pass]} è arrivato al suo posto.`, + 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 }); + if (rest.length) steps.push({ op: 'markSorted', indices: rest, line: 6 }); steps.push({ op: 'phase', - text: 'Nessuno scambio in questo giro: la sequenza è ordinata.', + line: 6, + cursor: null, + text: 'Una passata senza nemmeno uno scambio significa che è già tutto in ordine: posso fermarmi qui.', }); brokeEarly = true; break; @@ -61,10 +82,12 @@ function generate({ data }: GeneratorInput): ArrayTrace { } if (!brokeEarly) { - steps.push({ op: 'markSorted', indices: [0] }); + steps.push({ op: 'markSorted', indices: [0], line: 6 }); steps.push({ op: 'phase', - text: 'Tutti i numeri sono al loro posto: ho finito.', + line: 6, + cursor: null, + text: 'Ogni numero è al suo posto: l’ordinamento è finito.', }); } @@ -75,6 +98,10 @@ 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/insertionSort.ts b/src/components/Algorithm/generators/insertionSort.ts index e30f6b8..e476499 100644 --- a/src/components/Algorithm/generators/insertionSort.ts +++ b/src/components/Algorithm/generators/insertionSort.ts @@ -5,6 +5,18 @@ import type { 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; @@ -12,11 +24,13 @@ function generate({ data }: GeneratorInput): ArrayTrace { 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”.', }); @@ -25,6 +39,7 @@ function generate({ data }: GeneratorInput): ArrayTrace { steps.push({ op: 'extract', i, + line: 2, note: `Estraggo ${key} e gli cerco il posto tra i numeri a sinistra.`, }); let j = i - 1; @@ -32,9 +47,10 @@ function generate({ data }: GeneratorInput): ArrayTrace { 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 }); + steps.push({ op: 'shiftRight', from: j, line: 5 }); a[j + 1] = a[j]; j -= 1; } @@ -42,18 +58,25 @@ function generate({ data }: GeneratorInput): ArrayTrace { 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, note: `Inserisco ${key}.` }); + 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 }); + steps.push({ op: 'markSorted', indices: range, line: 7 }); } steps.push({ op: 'phase', + line: 8, text: 'Tutti i numeri sono al loro posto: ho finito.', }); @@ -64,6 +87,10 @@ 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 index 6891dff..5481569 100644 --- a/src/components/Algorithm/generators/linearSearch.ts +++ b/src/components/Algorithm/generators/linearSearch.ts @@ -66,6 +66,9 @@ 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, generate, diff --git a/src/components/Algorithm/generators/quicksort.ts b/src/components/Algorithm/generators/quicksort.ts index 0d36740..cd3dcce 100644 --- a/src/components/Algorithm/generators/quicksort.ts +++ b/src/components/Algorithm/generators/quicksort.ts @@ -97,6 +97,9 @@ 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 index ff5fc18..713cc11 100644 --- a/src/components/Algorithm/generators/selectionSort.ts +++ b/src/components/Algorithm/generators/selectionSort.ts @@ -5,6 +5,18 @@ import type { 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; @@ -12,6 +24,7 @@ function generate({ data }: GeneratorInput): ArrayTrace { steps.push({ op: 'phase', + line: 1, text: 'A ogni giro cerco il numero più piccolo della parte non ordinata e lo porto in testa.', }); @@ -21,6 +34,7 @@ function generate({ data }: GeneratorInput): ArrayTrace { op: 'pointer', name: 'min', i, + line: 3, note: `Per ora il più piccolo è ${a[i]}.`, }); for (let j = i + 1; j <= n - 1; j++) { @@ -28,6 +42,7 @@ function generate({ data }: GeneratorInput): ArrayTrace { op: 'compare', i: j, j: min, + line: 5, note: `Confronto ${a[j]} con il minimo attuale, ${a[min]}.`, }); if (a[j] < a[min]) { @@ -36,6 +51,7 @@ function generate({ data }: GeneratorInput): ArrayTrace { op: 'pointer', name: 'min', i: j, + line: 6, note: `${a[j]} è più piccolo: è il nuovo minimo.`, }); } @@ -45,19 +61,25 @@ function generate({ data }: GeneratorInput): ArrayTrace { 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', note: `${a[i]} è già al posto giusto.` }); + steps.push({ + op: 'note', + line: 7, + note: `${a[i]} è già al posto giusto.`, + }); } - steps.push({ op: 'markSorted', indices: [i] }); + steps.push({ op: 'markSorted', indices: [i], line: 2 }); } steps.push({ op: 'pointer', name: 'min', i: null }); - steps.push({ op: 'markSorted', indices: [n - 1] }); + steps.push({ op: 'markSorted', indices: [n - 1], line: 8 }); steps.push({ op: 'phase', + line: 8, text: 'Tutti i numeri sono al loro posto: ho finito.', }); @@ -68,6 +90,10 @@ 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 index 6db57ea..1933f41 100644 --- a/src/components/Algorithm/index.tsx +++ b/src/components/Algorithm/index.tsx @@ -3,6 +3,7 @@ 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'; @@ -15,10 +16,20 @@ const SPEED_IDX: Record, number> = { 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; - /** 'study' (default): passo-passo con spiegazioni. 'lab': autoplay e dati casuali. */ + /** 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[]; @@ -28,29 +39,64 @@ interface AlgorithmProps { 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 = 'study', + mode: initialMode = 'study', data, shuffle, target, speed = 'normal', + showCode, + showStats, caption, }: AlgorithmProps) { // Seed iniziale fisso (1): markup server e client coincidono (hydration). - const [seed, setSeed] = useState(1); + 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 (data && data.length) { + 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 @@ -76,7 +122,7 @@ export default function Algorithm({ } return gen.generate({ data: prepared, target: tgt }); - }, [gen, data, shuffle, target, seed]); + }, [gen, data, shuffle, target, seed, override]); if (!gen || !trace) { // eslint-disable-next-line no-console @@ -88,8 +134,28 @@ export default function Algorithm({ ); } - // «Rigenera» ha senso solo con dati casuali (shuffle, senza data esplicito). - const showRegenerate = shuffle !== undefined && !(data && data.length); + // 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 ( <> @@ -101,20 +167,109 @@ export default function Algorithm({ - {gen.label} + {gen.file ?? gen.label} + +
+ + +
+
+ +
+ {hasPanel && ( + + )} + + + {gen.complexity && ( + <> + + {gen.complexity} + + )}
- - - {mode === 'study' ? 'studio' : 'lab'} -
+ + {/* Sotto-header: nome algoritmo + descrizione */} +
+ {gen.label} + {gen.blurb && ( + <> + + {gen.blurb} + + )} +
+ setSeed((s) => s + 1)} + gen={gen} + showExplain={explainOpen} + colorMode={colorMode} + showCode={hasPanel && panelOpen} + advanced={panelOpen} + showStats={statsOn} + onShuffle={mescola} initialSpeedIdx={SPEED_IDX[speed]} />
diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css index 7ca955f..aed6b37 100644 --- a/src/components/Algorithm/styles.module.css +++ b/src/components/Algorithm/styles.module.css @@ -1,37 +1,49 @@ /* ───────────────────────────────────────────────────────────── - — animazioni di algoritmi, look coerente con - PyRunner / SQLRunner (Atmospheric). Tutta la tipografia è - Monaspace: Argon per la UI, Neon (tabellare) per i numeri, - Radon per le didascalie. + — 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; + --alg-st-compare: #fbbf24; + --alg-st-swap: #34d399; + --alg-st-sorted: #ffffff; + --alg-st-key: #c084fc; border: 1px solid var(--at-border); - border-radius: 14px; + 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; } -/* Header / toolbar ------------------------------------------------ */ +/* ── Toolbar ────────────────────────────────────────────────── */ .header { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid var(--at-border); + gap: 8px; + padding: 12px 16px; background: var(--at-bg-subtle); - padding: 8px 12px; + border-bottom: 1px solid var(--at-border); } .headerLeft { display: flex; align-items: center; - gap: 12px; + gap: 8px; min-width: 0; } -/* Pallini stile macOS (decorativi) — identici a PyRunner */ .trafficLights { display: inline-flex; align-items: center; @@ -40,13 +52,11 @@ } .tlDot { - width: 11px; - height: 11px; + width: 12px; + height: 12px; border-radius: 50%; - display: inline-block; box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2); } - .tlClose { background: #ff5f57; } @@ -57,153 +67,180 @@ background: #28c840; } -.headerLabel { - font-family: var(--font-mono-ui); - font-size: 0.9rem; - font-weight: 600; +.fileName { + margin-left: 6px; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--at-muted); letter-spacing: 0.01em; - color: var(--at-fg-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.modeChip { +.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; - flex-shrink: 0; - font-family: var(--font-mono-ui); - font-size: 0.8rem; + 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); } -/* Corpo / footer -------------------------------------------------- */ -.body { - padding: 16px; +.segGlyph { + font-size: 0.92em; } -.footer { - position: relative; - padding: 12px 12px 10px; +/* 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); } -/* Progress bar minimale: È la linea di separazione body/footer. - Track = colore bordo, riempimento = accent. */ -.progress { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; +.headerDivider { + width: 1px; + height: 18px; background: var(--at-border); - overflow: hidden; + margin: 0 2px; } -.progressFill { - height: 100%; - background: var(--at-accent); - border-radius: 0 2px 2px 0; - transition: width 240ms ease; +/* ── Sotto-header ───────────────────────────────────────────── */ +.subHeader { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 18px; + border-bottom: 1px solid var(--at-border); + flex-wrap: wrap; } -.caption { +.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.85rem; - font-style: italic; - color: var(--at-muted); - text-align: center; - margin: 0.5em 0 1.4em; + font-size: 0.8rem; + color: var(--at-muted-soft); + line-height: 1.4; + flex: 1; + min-width: 180px; } -.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; +/* ── 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; } -/* ───────────────────────────────────────────────────────────── - Scena array - ───────────────────────────────────────────────────────────── */ .sceneWrap { overflow-x: auto; } .scene { - /* palette per identità (id % 10) — niente grigi: il grigio è - riservato alle barre «ordinate» (--alg-sorted). */ - --alg-item-0: #4f46e5; /* indigo (era slate) */ - --alg-item-1: #16a34a; - --alg-item-2: #dc2626; - --alg-item-3: #0d9488; - --alg-item-4: #2563eb; - --alg-item-5: #65a30d; - --alg-item-6: #ea580c; - --alg-item-7: #ca8a04; - --alg-item-8: #9333ea; - --alg-item-9: #db2777; - --alg-sorted: #cbd5e1; - --alg-sorted-text: #334155; - --alg-ok: #16a34a; - --alg-bar-text: #ffffff; - margin: 0 auto; width: max-content; } -:global(html[data-theme='dark']) .scene { - --alg-item-0: #818cf8; - --alg-item-1: #4ade80; - --alg-item-2: #f87171; - --alg-item-3: #2dd4bf; - --alg-item-4: #60a5fa; - --alg-item-5: #a3e635; - --alg-item-6: #fb923c; - --alg-item-7: #facc15; - --alg-item-8: #c084fc; - --alg-item-9: #f472b6; - --alg-sorted: #3f3f46; - --alg-sorted-text: #fafafa; - --alg-ok: #4ade80; - --alg-bar-text: #18181b; -} - -.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); +.baseline { + position: absolute; + left: -12px; + right: -12px; + height: 1px; + background: var(--at-border); } .stage { position: relative; } -/* Barre ----------------------------------------------------------- */ .bar { position: absolute; top: 0; @@ -213,99 +250,72 @@ display: flex; align-items: flex-end; justify-content: center; - padding-bottom: 6px; - /* gradiente verticale soft: luce in alto, ombra sul fondo */ + padding-bottom: 7px; + /* tinta a scala cromatica: hue per valore, lightness per tema */ background: linear-gradient( 180deg, - color-mix(in srgb, var(--alg-bar) 80%, white) 0%, - var(--alg-bar) 55%, - color-mix(in srgb, var(--alg-bar) 86%, black) 100% + hsl(var(--alg-hue) 88% calc(var(--alg-L) + 8%)), + hsl(var(--alg-hue) 82% calc(var(--alg-L) - 4%)) ); - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.28); + box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.4); will-change: transform; transition: - transform var(--alg-dur) ease, - height var(--alg-dur) ease, - opacity var(--alg-dur) ease, - box-shadow var(--alg-dur) ease; -} - -:global(html[data-theme='dark']) .bar { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--alg-bar) 88%, white) 0%, - var(--alg-bar) 55%, - color-mix(in srgb, var(--alg-bar) 88%, black) 100% - ); - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.16); + 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-size: 0.92rem; - font-weight: 600; + 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); } -/* Confronto in corso: anello che cicla i 4 colori, stile «AI border» - dei pulsanti moderni Google. Ben visibile su barre già colorate - grazie allo stacco col colore del pannello. */ -.barComparing { - z-index: 2; - animation: algCompareRing 2.2s linear infinite; +.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; } -@keyframes algCompareRing { - 0% { - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px #4285f4, - 0 0 9px 3px rgba(66, 133, 244, 0.4); - } - 25% { - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px #ea4335, - 0 0 9px 3px rgba(234, 67, 53, 0.4); - } - 50% { - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px #fbbc05, - 0 0 9px 3px rgba(251, 188, 5, 0.4); - } - 75% { - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px #34a853, - 0 0 9px 3px rgba(52, 168, 83, 0.4); - } - 100% { - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px #4285f4, - 0 0 9px 3px rgba(66, 133, 244, 0.4); - } +.chipRow { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; } -/* Esito ricerca: anello verde fisso. */ -.barFound { - z-index: 2; - box-shadow: - 0 0 0 2px var(--at-bg-panel), - 0 0 0 4px var(--alg-ok), - 0 0 11px 3px color-mix(in srgb, var(--alg-ok) 45%, transparent); +.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); } -/* Lane sotto la track: badge, graffa, etichette ------------------- */ .badge { position: absolute; - top: 184px; /* TRACK_H + 34 */ width: 20px; height: 20px; box-sizing: border-box; @@ -313,7 +323,7 @@ align-items: center; justify-content: center; font-family: var(--font-mono); - font-size: 0.78rem; + font-size: 0.72rem; font-weight: 600; border-radius: 6px; background: var(--at-bg-chip); @@ -322,9 +332,39 @@ 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; - top: 212px; /* TRACK_H + 62 */ height: 8px; box-sizing: border-box; border-left: 2px solid var(--at-muted); @@ -339,151 +379,544 @@ .rangeLabel { position: absolute; - top: 224px; /* TRACK_H + 74 */ text-align: center; font-family: var(--font-mono-ui); - font-size: 0.78rem; + font-size: 0.72rem; color: var(--at-muted); } -/* ───────────────────────────────────────────────────────────── - Spiegazioni (solo study) — centrate sotto le barre - ───────────────────────────────────────────────────────────── */ +/* ── Descrizione step ───────────────────────────────────────── */ .explanation { - min-height: 3.2em; - margin-top: 14px; + margin: 16px auto 0; + /* Spazio riservato per ~2 righe di fase + nota: l'altezza del blocco non + cambia quando la spiegazione compare al primo passo. */ + min-height: 58px; + max-width: 720px; text-align: center; + display: flex; + flex-direction: column; + justify-content: center; } .phase { + margin: 0; font-family: var(--font-mono-ui); - font-size: 0.95rem; + 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.85rem; + font-size: 0.8rem; color: var(--at-muted-soft); - margin-top: 3px; } -/* ───────────────────────────────────────────────────────────── - Pulsantiera — pill coerenti con PyRunner / SQLRunner - ───────────────────────────────────────────────────────────── */ -.controls { +/* ── 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; } -.controlsLeft { +.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: 6px; - height: 32px; - padding: 0 12px; - border-radius: 999px; + 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 120ms ease, - border-color 120ms ease, - color 120ms ease; + 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); - color: var(--at-fg-strong); } - +.btn:active:not(:disabled) { + transform: translateY(1px); +} .btn:disabled { opacity: 0.4; - cursor: not-allowed; + cursor: default; } - .btn:focus-visible { outline: 2px solid var(--at-accent); outline-offset: 2px; } -.btnGlyph { - font-size: 0.95em; -} - .btnIcon { - width: 32px; + width: 38px; padding: 0; } -.btnPrimary { - background: var(--at-accent-chip); - border-color: var(--at-accent-chip-border); - color: var(--at-accent); +.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) { - background: var(--at-accent-bg); - color: var(--at-accent-soft); + /* 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); } -.speedChip { - height: 32px; - padding: 0 12px; - border-radius: 999px; +.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); - color: var(--at-fg); +} + +/* 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.82rem; - font-weight: 600; + font-size: 0.78rem; + font-weight: 700; font-variant-numeric: tabular-nums; cursor: pointer; - transition: - background 120ms ease, - border-color 120ms ease; + transition: color 0.18s ease; } - -.speedChip:hover { - background: var(--at-accent-chip); - border-color: var(--at-accent-chip-border); +.speedPill:hover { + color: var(--at-fg); +} +.speedPillOn, +.speedPillOn:hover { + color: #fff; } -.speedChip:focus-visible { - outline: 2px solid var(--at-accent); - outline-offset: 2px; +/* ── 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; } -.stepIndicator { +.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.82rem; - color: var(--at-muted); - font-variant-numeric: tabular-nums; + font-size: 0.85rem; } -/* ───────────────────────────────────────────────────────────── - Movimento ridotto: niente transizioni/animazioni - ───────────────────────────────────────────────────────────── */ +@keyframes algBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +/* ── Movimento ridotto ──────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { - .scene *, - .progressFill, - .barComparing { + .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 index 7ebfb73..324e922 100644 --- a/src/components/Algorithm/types.ts +++ b/src/components/Algorithm/types.ts @@ -26,12 +26,22 @@ export interface ArraySceneState { 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 = ( @@ -53,8 +63,14 @@ export type ArrayStep = ( ) & { /** Spiegazione puntuale mostrata in study. */ note?: string; - /** Riservato alla fase 2 (showCode): non emesso dai generatori fase 1. */ + /** 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 { @@ -70,9 +86,18 @@ export interface GeneratorInput { export interface GeneratorDef { id: string; - /** Nome mostrato nell'header della card, es. «Bubble sort». */ + /** 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. 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]; +} From ee1298f3ff18481ff4a00a2e91181add1782ebd8 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Mon, 15 Jun 2026 01:15:19 +0200 Subject: [PATCH 4/9] refactor(algoviz): dedup pickTarget tra le ricerche, rimuovi export morto - Estrae pickSearchTarget in generators/pickTarget.ts: linearSearch e binarySearch ne condividevano una copia identica. - Rimuove ALGO_IDS da generators/index.ts: esportato ma mai importato (era previsto per una pagina galleria non realizzata). Nessun cambiamento di comportamento: stessa funzione, stesso ordine di consumo del PRNG seeded (determinismo SSR invariato). Co-Authored-By: Claude Opus 4.8 --- .../Algorithm/generators/binarySearch.ts | 14 ++------------ src/components/Algorithm/generators/index.ts | 10 ---------- .../Algorithm/generators/linearSearch.ts | 15 ++------------- src/components/Algorithm/generators/pickTarget.ts | 13 +++++++++++++ 4 files changed, 17 insertions(+), 35 deletions(-) create mode 100644 src/components/Algorithm/generators/pickTarget.ts diff --git a/src/components/Algorithm/generators/binarySearch.ts b/src/components/Algorithm/generators/binarySearch.ts index 3e909d4..08c6320 100644 --- a/src/components/Algorithm/generators/binarySearch.ts +++ b/src/components/Algorithm/generators/binarySearch.ts @@ -4,17 +4,7 @@ import type { GeneratorDef, GeneratorInput, } from '../types'; - -/** Come la ricerca lineare, ma applicato al dato già ordinato. */ -function pickTarget(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; -} +import { pickSearchTarget } from './pickTarget'; function generate({ data, target }: GeneratorInput): ArrayTrace { const a = data.slice(); @@ -92,6 +82,6 @@ export const binarySearch: GeneratorDef = { 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, + pickTarget: pickSearchTarget, generate, }; diff --git a/src/components/Algorithm/generators/index.ts b/src/components/Algorithm/generators/index.ts index 639ee7b..7739851 100644 --- a/src/components/Algorithm/generators/index.ts +++ b/src/components/Algorithm/generators/index.ts @@ -18,13 +18,3 @@ const REGISTRY: Record = { export function getGenerator(id: string): GeneratorDef | undefined { return REGISTRY[id]; } - -/** Tutti gli id registrati, nell'ordine di presentazione della pagina demo. */ -export const ALGO_IDS: string[] = [ - bubbleSort.id, - selectionSort.id, - insertionSort.id, - linearSearch.id, - binarySearch.id, - quicksort.id, -]; diff --git a/src/components/Algorithm/generators/linearSearch.ts b/src/components/Algorithm/generators/linearSearch.ts index 5481569..4a471cc 100644 --- a/src/components/Algorithm/generators/linearSearch.ts +++ b/src/components/Algorithm/generators/linearSearch.ts @@ -4,18 +4,7 @@ import type { GeneratorDef, GeneratorInput, } from '../types'; - -/** Con probabilità 0.75 un elemento presente (esito «trovato»), altrimenti - * il più piccolo intero positivo assente (esito «non trovato»). */ -function pickTarget(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; -} +import { pickSearchTarget } from './pickTarget'; function generate({ data, target }: GeneratorInput): ArrayTrace { const a = data.slice(); @@ -70,6 +59,6 @@ export const linearSearch: GeneratorDef = { 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, + 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; +} From d73c0896fa0c8540cc85ae3b2b49f2049d3b0153 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Mon, 15 Jun 2026 10:23:04 +0200 Subject: [PATCH 5/9] style(algoviz): toolbar e pannello codice riflowano su mobile (P1, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sotto i ~430px la toolbar (.header, flex senza wrap) accavallava le 3 icone toggle sopra lo switch Studio/Lab, rendendolo incliccabile; il pannello codice teneva la colonna statistiche fissa a 172px schiacciando lo pseudocodice in overflow. - @media (max-width: 480px): .header wrappa, headerRight scende su una seconda riga a tutta larghezza allineata a destra. - @media (max-width: 560px): .codeGrid a una colonna, .stats in riga sotto il codice (soglia più alta: la colonna fissa soffoca già <480px). Rif. pm/spec-algo-viz-mobile.md. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/styles.module.css | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css index aed6b37..283d852 100644 --- a/src/components/Algorithm/styles.module.css +++ b/src/components/Algorithm/styles.module.css @@ -901,6 +901,42 @@ } } +/* ── 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: 24px; + justify-content: flex-start; + } +} + +/* P1 — la toolbar riflowa: pallini + nome file + Studio/Lab su riga 1, + icone + complessità su riga 2 a tutta larghezza, allineate a destra. + Così nessun controllo si sovrappone e Studio/Lab resta cliccabile. */ +@media (max-width: 480px) { + .header { + flex-wrap: wrap; + row-gap: 8px; + } + .headerRight { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } +} + /* ── Movimento ridotto ──────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { .bar, From 4dbd612a7fae5ebb393ed173bd95c5bf546ed0e9 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Mon, 15 Jun 2026 10:23:04 +0200 Subject: [PATCH 6/9] feat(algoviz): barre adattive alla larghezza disponibile (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BAR_W/GAP erano costanti (58px a elemento): a 360px un array da 8 usciva dal viewport e andava trascinato. Ora ArrayScene misura .sceneWrap con un ResizeObserver e ricava un passo adattivo (clamp tra MIN_UNIT 36 e MAX_UNIT 58), così l'intero array entra senza scroll quando c'è spazio; oltre, lo scroll orizzontale resta come fallback. Il font del valore scala verso il basso nelle barre strette. x()/slotCenter() sono ora closure sul passo misurato; nessun'altra parte conosce le coordinate. Rif. pm/spec-algo-viz-mobile.md. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/ArrayScene.tsx | 79 +++++++++++++++++++++---- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/components/Algorithm/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx index 82888e7..b11b9be 100644 --- a/src/components/Algorithm/ArrayScene.tsx +++ b/src/components/Algorithm/ArrayScene.tsx @@ -1,10 +1,18 @@ -import { type CSSProperties } from 'react'; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from 'react'; import type { ArrayItem, ArraySceneState } from './types'; import styles from './styles.module.css'; -const BAR_W = 44; // larghezza barra -const GAP = 14; // spazio tra barre -const UNIT = BAR_W + GAP; +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) @@ -12,8 +20,13 @@ const HEADROOM = 16; // spazio sopra le barre per «lift» e ombra (no clipping) const BADGE_W = 20; const BADGE_OFFSET = 22; -const x = (i: number) => i * UNIT; -const slotCenter = (i: number) => x(i) + BAR_W / 2; +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 = { @@ -50,7 +63,41 @@ export default function ArrayScene({ showLabels, }: ArraySceneProps) { const n = state.items.length; - const sceneWidth = Math.max(n * UNIT - GAP, BAR_W); + + // 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. + const unit = + availW > 0 && n > 0 + ? clamp(availW / (n - 1 + BAR_RATIO), MIN_UNIT, MAX_UNIT) + : MAX_UNIT; + const barW = unit * BAR_RATIO; + const gap = unit - barW; + // 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); + + const x = (i: number) => i * unit; + const slotCenter = (i: number) => x(i) + barW / 2; + + const sceneWidth = Math.max(n * unit - gap, barW); const base = HEADROOM + TRACK_H; // baseline (base delle barre) // L'insieme dei valori è costante (items + eventuale estratto): min/max fissi. @@ -134,7 +181,7 @@ export default function ArrayScene({ const style: CSSProperties & Record<'--alg-hue', string> = { transform: `translate(${tx}px, ${ty}px)`, height: h, - width: BAR_W, + width: barW, color: 'var(--alg-bar-text)', '--alg-hue': String(Math.round(hueOf(item))), }; @@ -164,6 +211,7 @@ export default function ArrayScene({ return (
{entry.isExtracted && KEY} - {entry.item.value} + + {entry.item.value} +
))} @@ -226,7 +283,7 @@ export default function ArrayScene({ style={{ top: base + 46, left: x(state.range.lo), - width: x(state.range.hi) + BAR_W - x(state.range.lo), + width: x(state.range.hi) + barW - x(state.range.lo), }} /> {state.range.label && ( @@ -235,7 +292,7 @@ export default function ArrayScene({ style={{ top: base + 58, left: x(state.range.lo), - width: x(state.range.hi) + BAR_W - x(state.range.lo), + width: x(state.range.hi) + barW - x(state.range.lo), }} > {state.range.label} From 8d5fa699cd6b58411d158ab5105914cbd4120104 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Mon, 15 Jun 2026 10:23:41 +0200 Subject: [PATCH 7/9] =?UTF-8?q?style(algoviz):=20densit=C3=A0=20e=20tap=20?= =?UTF-8?q?target=20su=20mobile=20(P4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @media (max-width: 480px): padding orizzontale del .body da 24px a 12px (più spazio per la scena) e tap target più comodi (.toggleBtn 30→36px, .segBtn alto 24→34px). Applicato dopo il reflow della toolbar (P1) così gli ingrandimenti non riaprono l'accavallamento. Rif. pm/spec-algo-viz-mobile.md. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/styles.module.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css index 283d852..892103c 100644 --- a/src/components/Algorithm/styles.module.css +++ b/src/components/Algorithm/styles.module.css @@ -935,6 +935,19 @@ margin-left: 0; justify-content: flex-end; } + + /* P4 — densità: meno padding orizzontale (più spazio scena) e tap target + più comodi. Fatto dopo il reflow della toolbar così non riapre P1. */ + .body { + padding: 20px 12px 14px; + } + .toggleBtn { + width: 36px; + height: 36px; + } + .segBtn { + height: 34px; + } } /* ── Movimento ridotto ──────────────────────────────────────── */ From 1594b654a7493f242fb789a7928102e9b6a3fbe1 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Mon, 15 Jun 2026 20:05:51 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(algoviz):=20mobile=20usabile=20?= =?UTF-8?q?=E2=80=94=20anelli,=20jitter,=20controlli=20Lab,=20Step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArrayScene: margine interno orizzontale (EDGE) così l'anello di selezione di prima/ultima barra non viene clippato dall'overflow. - spiegazione ad altezza fissa: i pulsanti del footer non «saltano» tra uno step e l'altro. Su mobile nascondo la riga «fase» ripetuta e riduco l'altezza (il testo a capo arrivava a ~180px). - Lab su mobile: pannello codice con max-height + scroll interno; la riga attiva si porta in vista da sola (auto-scroll che tocca solo lo scrollTop del pannello, mai la pagina). Così scena, codice e controlli stanno insieme senza scrollare. - Studio: il pulsante avanti diventa «Step» esteso e primario. - toolbar mobile: via pallini e nome file, switch Studio/Lab solo-icona, statistiche col numero accanto alla label, interlinea codice più stretta. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/ArrayScene.tsx | 12 ++- src/components/Algorithm/CodePanel.tsx | 18 ++++- src/components/Algorithm/Player.tsx | 26 +++++-- src/components/Algorithm/styles.module.css | 90 +++++++++++++++++++--- 4 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/components/Algorithm/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx index b11b9be..c9f5998 100644 --- a/src/components/Algorithm/ArrayScene.tsx +++ b/src/components/Algorithm/ArrayScene.tsx @@ -17,6 +17,8 @@ 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 BADGE_W = 20; const BADGE_OFFSET = 22; @@ -84,9 +86,10 @@ export default function ArrayScene({ // 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 / (n - 1 + BAR_RATIO), MIN_UNIT, MAX_UNIT) + ? clamp((availW - 2 * EDGE) / (n - 1 + BAR_RATIO), MIN_UNIT, MAX_UNIT) : MAX_UNIT; const barW = unit * BAR_RATIO; const gap = unit - barW; @@ -94,10 +97,13 @@ export default function ArrayScene({ // verso il basso (mai oltre la dimensione desktop). const valueScale = Math.min(1, barW / BASE_BAR_W); - const x = (i: number) => i * unit; + // 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. + const x = (i: number) => EDGE + i * unit; const slotCenter = (i: number) => x(i) + barW / 2; - const sceneWidth = Math.max(n * unit - gap, barW); + const contentWidth = Math.max(n * unit - gap, barW); + const sceneWidth = contentWidth + 2 * EDGE; const base = HEADROOM + TRACK_H; // baseline (base delle barre) // L'insieme dei valori è costante (items + eventuale estratto): min/max fissi. diff --git a/src/components/Algorithm/CodePanel.tsx b/src/components/Algorithm/CodePanel.tsx index f55a5d8..b7c19fa 100644 --- a/src/components/Algorithm/CodePanel.tsx +++ b/src/components/Algorithm/CodePanel.tsx @@ -33,9 +33,24 @@ export default function CodePanel({ }: 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(() => { @@ -66,12 +81,13 @@ export default function CodePanel({
-
+
{code.map((line, li) => { const on = activeLine === li; return (
{li + 1} diff --git a/src/components/Algorithm/Player.tsx b/src/components/Algorithm/Player.tsx index 305c618..84fcb75 100644 --- a/src/components/Algorithm/Player.tsx +++ b/src/components/Algorithm/Player.tsx @@ -304,12 +304,26 @@ export default function Player({ )} - dispatch({ type: 'FWD' })} - disabled={atEnd} - /> + {advanced ? ( + dispatch({ type: 'FWD' })} + disabled={atEnd} + /> + ) : ( + // Studio: senza «Esegui», l'avanzamento passo-passo è l'azione + // principale → bottone esteso e colorato come la primaria. + + )} {advanced && ( <> diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css index 892103c..64e557f 100644 --- a/src/components/Algorithm/styles.module.css +++ b/src/components/Algorithm/styles.module.css @@ -388,14 +388,16 @@ /* ── Descrizione step ───────────────────────────────────────── */ .explanation { margin: 16px auto 0; - /* Spazio riservato per ~2 righe di fase + nota: l'altezza del blocco non - cambia quando la spiegazione compare al primo passo. */ - min-height: 58px; + /* 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 { @@ -917,27 +919,84 @@ border-left: none; border-top: 1px solid var(--at-border); flex-direction: row; - gap: 24px; + 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 riflowa: pallini + nome file + Studio/Lab su riga 1, - icone + complessità su riga 2 a tutta larghezza, allineate a destra. - Così nessun controllo si sovrappone e Studio/Lab resta cliccabile. */ +/* 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 { - width: 100%; + 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; - justify-content: flex-end; + } + .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. Fatto dopo il reflow della toolbar così non riapre P1. */ + più comodi. */ .body { padding: 20px 12px 14px; } @@ -945,8 +1004,15 @@ width: 36px; height: 36px; } - .segBtn { - height: 34px; + + /* 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; } } From 5fbb16d2a2f4ec5307ee839a90495b080abd7dfe Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Tue, 16 Jun 2026 00:15:14 +0200 Subject: [PATCH 9/9] fix(algoviz): contrasto tema chiaro, allineamento Studio/Lab, min come la j MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colori di stato (anelli + pallini legenda) ora per-tema: in chiaro ambra/smeraldo/ardesia/viola più scuri e saturi, così «Ordinato» (prima bianco su bianco), «Confronto» e «Scambio» risaltano sia sullo sfondo chiaro sia sulle barre. Il tema scuro mantiene bianco e pastelli. - Icone Studio/Lab rialzate di 1px: il centro ottico dell'icona coincide con quello del testo, prima cadeva più in basso. - Selection sort: «min» non è più un badge stretto ma viaggia col cursore caret + etichetta, lo stesso linguaggio visivo della «j» del bubble sort. - Stacco visivo tra parte ordinata e parte da ordinare: a ogni confine si apre un varco (vale per tutti gli ordinamenti, si adatta al lato giusto). - Rimossa la logica minSlots ormai morta in ArrayScene. Co-Authored-By: Claude Opus 4.8 --- src/components/Algorithm/ArrayScene.tsx | 28 ++++++++++++------- .../Algorithm/generators/selectionSort.ts | 16 +++++------ src/components/Algorithm/styles.module.css | 20 ++++++++++--- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/components/Algorithm/ArrayScene.tsx b/src/components/Algorithm/ArrayScene.tsx index c9f5998..fd94225 100644 --- a/src/components/Algorithm/ArrayScene.tsx +++ b/src/components/Algorithm/ArrayScene.tsx @@ -19,6 +19,7 @@ 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; @@ -92,18 +93,30 @@ export default function ArrayScene({ ? clamp((availW - 2 * EDGE) / (n - 1 + BAR_RATIO), MIN_UNIT, MAX_UNIT) : MAX_UNIT; const barW = unit * BAR_RATIO; - const gap = unit - barW; // 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. - const x = (i: number) => EDGE + i * unit; + // 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 contentWidth = Math.max(n * unit - gap, barW); - const sceneWidth = contentWidth + 2 * EDGE; + 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. @@ -135,12 +148,8 @@ export default function ArrayScene({ } } - const sortedSet = new Set(state.sorted); const fadedSet = new Set(state.faded); const swappingSet = new Set(state.swapping); - const minSlots = new Set( - state.pointers.filter((p) => p.name === 'min').map((p) => p.i), - ); const inRange = (slot: number) => !state.range || (slot >= state.range.lo && slot <= state.range.hi); const foundSlot = @@ -171,7 +180,6 @@ export default function ArrayScene({ ring = 'key'; } else { if (sortedSet.has(slot)) ring = 'sorted'; - if (minSlots.has(slot)) ring = 'compare'; if (comparingSlots.has(slot)) { ring = 'compare'; lift = 8; diff --git a/src/components/Algorithm/generators/selectionSort.ts b/src/components/Algorithm/generators/selectionSort.ts index 713cc11..9130ffe 100644 --- a/src/components/Algorithm/generators/selectionSort.ts +++ b/src/components/Algorithm/generators/selectionSort.ts @@ -30,11 +30,12 @@ function generate({ data }: GeneratorInput): ArrayTrace { 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: 'pointer', - name: 'min', - i, + 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++) { @@ -48,10 +49,9 @@ function generate({ data }: GeneratorInput): ArrayTrace { if (a[j] < a[min]) { min = j; steps.push({ - op: 'pointer', - name: 'min', - i: j, + op: 'note', line: 6, + cursor: { i: j, label: 'min' }, note: `${a[j]} è più piccolo: è il nuovo minimo.`, }); } @@ -75,8 +75,8 @@ function generate({ data }: GeneratorInput): ArrayTrace { steps.push({ op: 'markSorted', indices: [i], line: 2 }); } - steps.push({ op: 'pointer', name: 'min', i: null }); - steps.push({ op: 'markSorted', indices: [n - 1], line: 8 }); + // 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, diff --git a/src/components/Algorithm/styles.module.css b/src/components/Algorithm/styles.module.css index 64e557f..312a3bf 100644 --- a/src/components/Algorithm/styles.module.css +++ b/src/components/Algorithm/styles.module.css @@ -9,10 +9,14 @@ definiti sulla card così sia la scena sia la legenda li vedono. */ --alg-L: 50%; --alg-bar-text: #ffffff; - --alg-st-compare: #fbbf24; - --alg-st-swap: #34d399; - --alg-st-sorted: #ffffff; - --alg-st-key: #c084fc; + /* 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); @@ -25,6 +29,10 @@ :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 ────────────────────────────────────────────────── */ @@ -140,6 +148,10 @@ .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) */