From a9295133b480e10670ccdbd59151345973a83e26 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Mon, 20 Jan 2025 23:12:47 +0100 Subject: [PATCH] feat: grid for a professional look and mobile support --- src/DisplayProblemInput.tsx | 22 +-- src/Primale.tsx | 84 +++++++----- src/components.tsx | 9 ++ src/example-problems.json | 18 +++ src/lib-v2/canvas/index.ts | 25 +++- src/lib-v2/ro/primal-simplex.ts | 231 +++++++++++++++++++------------- src/lib-v2/vector.ts | 8 ++ src/main.tsx | 29 ++-- src/style.css | 100 +++++++++++++- src/vite.d.ts | 5 + 10 files changed, 375 insertions(+), 156 deletions(-) create mode 100644 src/components.tsx create mode 100644 src/example-problems.json diff --git a/src/DisplayProblemInput.tsx b/src/DisplayProblemInput.tsx index 9cfcbe6..9e0c638 100644 --- a/src/DisplayProblemInput.tsx +++ b/src/DisplayProblemInput.tsx @@ -6,14 +6,18 @@ export const DisplayProblemInput = ({ problemInput }: { problemInput: ProblemInp const { A, b, c, B } = problemInput return ( - (r + 1).toString()).join(', ')}\\}`, - ].join(' \\qquad ')} - /> +
+
+ (r + 1).toString()).join(', ')}\\}`, + ].join(' \\qquad ')} + /> +
+
) } diff --git a/src/Primale.tsx b/src/Primale.tsx index 71d4828..9e4fd5b 100644 --- a/src/Primale.tsx +++ b/src/Primale.tsx @@ -1,5 +1,6 @@ +import { MobileScrollable } from './components' import { Katex } from './Katex' -import { fillDot, drawSemiplane, drawSimpleArrow } from './lib-v2/canvas' +import { fillDot, drawSemiplane, drawSimpleArrow, strokeInfiniteLine } from './lib-v2/canvas' import { range } from './lib-v2/math' import { Matrix } from './lib-v2/matrix' import { Rational } from './lib-v2/rationals' @@ -47,7 +48,7 @@ const PrimalStep = ({ return (
-
+

Iterazione {iter + 1} dell'algoritmo

@@ -55,11 +56,11 @@ const PrimalStep = ({ {comments.map(comment => comment.type === 'formula' ? ( -
+ -
+ ) : ( -
+
) @@ -94,6 +95,9 @@ export const Primale = ({ input }: { input: ProblemInput }) => { return (
+
+

Svolgimento

+
{problemOutput.steps.map((step, iter) => ( diff --git a/src/components.tsx b/src/components.tsx new file mode 100644 index 0000000..1b3e4d2 --- /dev/null +++ b/src/components.tsx @@ -0,0 +1,9 @@ +import { JSX } from 'preact/jsx-runtime' + +export const MobileScrollable = ({ children }: { children: JSX.Element }) => { + return ( +
+
{children}
+
+ ) +} diff --git a/src/example-problems.json b/src/example-problems.json new file mode 100644 index 0000000..86dffe8 --- /dev/null +++ b/src/example-problems.json @@ -0,0 +1,18 @@ +[ + { + "name": "Pintel", + "source": "c' = 500 200;\\n\\nA = 1 0\\n 0 1\\n 2 1\\n -1 0\\n 0 -1;\\n\\nb = 4\\n 7\\n 9\\n 0\\n 0;\\n\\nB = 2 3;" + }, + { + "name": "Pintel non amm", + "source": "c' = 500 200;\\n\\nA = 1 0\\n 0 1\\n 2 1\\n -1 0\\n 0 -1;\\n\\nb = 4\\n 7\\n 9\\n 0\\n 0;\\n\\nB = 1 2;" + }, + { + "name": "Pintel (hard)", + "source": "c' = -500 -200;\\n\\nA = 1 0\\n 0 1\\n 2 1\\n -1 0\\n 0 -1;\\n\\nb = 4\\n 7\\n 9\\n 0\\n 0;\\n\\nB = 1 3;" + }, + { + "name": "Pintel non amm 2", + "source": "c' = -500 -200;\\n\\nA = 1 0\\n 0 1\\n 2 1\\n -1 0\\n 0 -1;\\n\\nb = 4\\n 7\\n 9\\n 0\\n 0;\\n\\nB = 1 2;" + } +] diff --git a/src/lib-v2/canvas/index.ts b/src/lib-v2/canvas/index.ts index 2c7e1d3..5a3d49b 100644 --- a/src/lib-v2/canvas/index.ts +++ b/src/lib-v2/canvas/index.ts @@ -88,22 +88,28 @@ export function drawSimpleArrow( y1: number, x2: number, y2: number, - size: number + size: number, + color: string = '#333' ) { const arrowLength = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) g.save() + g.strokeStyle = color + g.fillStyle = color + g.beginPath() g.translate(x1, y1) g.rotate(Math.atan2(y2 - y1, x2 - x1)) g.moveTo(0, 0) - g.lineTo(arrowLength, 0) + g.lineTo(arrowLength - size / 2, 0) + g.stroke() - g.moveTo(arrowLength - size, -size) - g.lineTo(arrowLength, 0) - g.moveTo(arrowLength - size, +size) + g.beginPath() + g.moveTo(arrowLength, 0) + g.lineTo(arrowLength - size, -size * 0.75) + g.lineTo(arrowLength - size, +size * 0.75) g.lineTo(arrowLength, 0) - g.stroke() + g.fill() g.restore() } @@ -118,3 +124,10 @@ export function strokeDot(g: CanvasRenderingContext2D, x: number, y: number, rad g.arc(x, y, radius, 0, 2 * Math.PI) g.stroke() } + +export function strokeInfiniteLine(g: CanvasRenderingContext2D, x1: number, y1: number, angle: number) { + g.beginPath() + g.moveTo(x1 - Math.cos(angle) * MAX_LINE_SIZE, y1 - Math.sin(angle) * MAX_LINE_SIZE) + g.lineTo(x1 + Math.cos(angle) * MAX_LINE_SIZE, y1 + Math.sin(angle) * MAX_LINE_SIZE) + g.stroke() +} diff --git a/src/lib-v2/ro/primal-simplex.ts b/src/lib-v2/ro/primal-simplex.ts index 364fae6..fefac07 100644 --- a/src/lib-v2/ro/primal-simplex.ts +++ b/src/lib-v2/ro/primal-simplex.ts @@ -30,6 +30,7 @@ export type ProblemStatus = | { result: 'unbounded'; xi: Vector } | { result: 'infeasible' } | { result: 'too-many-iterations' } + | { result: 'wrong-starting-basis' } export type ProblemOutput = { status: ProblemStatus @@ -66,6 +67,7 @@ export function computePrimalSimplexSteps(input: ProblemInput): ProblemOutput { comments.push({ type: 'formula', content: [ + `B = ${indexSetToLatex(B)}`, `A_B = ${matrixToLatex(A_B)}`, `b_B = ${vectorToLatex(b_B)}`, `c^t = ${rowVectorToLatex(c)}`, @@ -74,144 +76,183 @@ export function computePrimalSimplexSteps(input: ProblemInput): ProblemOutput { const x = A_B_inverse.apply(b_B) - comments.push({ - type: 'formula', - content: [ - String.raw`\bar{x}`, - `A_B^{-1} b_B`, - `${matrixToLatex(A_B)}^{-1} ${vectorToLatex(b_B)}`, - `${matrixToLatex(A_B_inverse)} ${vectorToLatex(b_B)}`, - `${vectorToLatex(x)}`, - ].join(' = '), - }) - - const y_B = A_B_inverse.transpose().apply(c) - - comments.push({ - type: 'formula', - content: [ - String.raw`\bar{y}_B^t`, - `c_B^t A_B^{-1}`, - `${rowVectorToLatex(c)} ${matrixToLatex(A_B_inverse)}`, - `${rowVectorToLatex(y_B)}`, - ].join(' = '), - }) - - const y_Zero = Vector.zero(RationalField, A.rows) - - const y = y_Zero.with(B, y_B) - - comments.push({ type: 'formula', content: String.raw`\implies \bar{y}^t = ${rowVectorToLatex(y)}` }) - - const I_x = activeIndices(input, x) + const isAdmissible = A.apply(x).leq(b) - comments.push({ - type: 'formula', - content: [ - String.raw`I(\bar{x})`, - String.raw`\{ i \in \{1, \dots, m\} \mid A_i \bar{x}_i = b_i \}`, - indexSetToLatex(I_x), - ].join(' = '), - }) - - const isDegenerate = I_x.length < A_B.rows - const isDualAdmissible = y_B.getData().every(y => y.geq(RationalField.zero)) - const isDualDegenerate = y_B.getData().some(y => y.eq(RationalField.zero)) - - comments.push({ - type: 'text', - content: `La soluzione primale è *${isDegenerate ? 'degenere' : 'non degenere'}*.`, - }) - - comments.push({ - type: 'text', - content: `La soluzione duale è *${isDualAdmissible ? 'ammissibile' : 'non ammissibile'}* e *${ - isDualDegenerate ? 'degenere' : 'non degenere' - }*.`, - }) + if (isAdmissible) { + comments.push({ + type: 'formula', + content: [ + String.raw`\bar{x}`, + `A_B^{-1} b_B`, + `${matrixToLatex(A_B)}^{-1} ${vectorToLatex(b_B)}`, + `${matrixToLatex(A_B_inverse)} ${vectorToLatex(b_B)}`, + `${vectorToLatex(x)}`, + ].join(' = '), + }) - if (!isDualAdmissible) { - const h = Math.min(...y.getData().flatMap((y, i) => (y.lt(RationalField.zero) ? [i] : []))) + const y_B = A_B_inverse.transpose().apply(c) comments.push({ type: 'formula', content: [ - // prettier-ignore - `h`, - String.raw`\min \{ i \in B \mid \bar{y}_i < 0 \}`, - `${h + 1}`, + String.raw`\bar{y}_B^t`, + `c_B^t A_B^{-1}`, + `${rowVectorToLatex(c)} ${matrixToLatex(A_B_inverse)}`, + `${rowVectorToLatex(y_B)}`, ].join(' = '), }) - const e_h = Vector.oneHot(RationalField, A.rows, h).slice(B) - console.log(e_h) + const y_Zero = Vector.zero(RationalField, A.rows) + + const y = y_Zero.with(B, y_B) + + comments.push({ type: 'formula', content: String.raw`\implies \bar{y}^t = ${rowVectorToLatex(y)}` }) - const xi = A_B_inverse.apply(e_h).neg() - stepResult_xi = xi + const I_x = activeIndices(input, x) comments.push({ type: 'formula', content: [ - // prettier-ignore - `\\xi`, - `-A_B^{-1} u_{B(h)}`, - `${vectorToLatex(xi)}`, + String.raw`I(\bar{x})`, + String.raw`\{ i \in \{1, \dots, m\} \mid A_i \bar{x}_i = b_i \}`, + indexSetToLatex(I_x), ].join(' = '), }) - const N = range(0, A.rows).filter(i => !B.includes(i)) - const A_N = A.slice({ rows: N }) + const isDegenerate = I_x.length < A_B.rows + const isDualAdmissible = y_B.getData().every(y => y.geq(RationalField.zero)) + const isDualDegenerate = y_B.getData().some(y => y.eq(RationalField.zero)) - const A_N__xi = A_N.apply(xi) + comments.push({ + type: 'text', + content: `La soluzione primale è *${isAdmissible ? 'ammissibile' : 'non ammissibile'}* e *${ + isDegenerate ? 'degenere' : 'non degenere' + }*.`, + }) comments.push({ - type: 'formula', - content: [ - [`N = \\{1, \\dots, m\\} \\setminus B = ${indexSetToLatex(N)}`], - [`A_N \\xi`, `${matrixToLatex(A_N)} ${vectorToLatex(xi)}`, `${vectorToLatex(A_N__xi)}`].join(' = '), - ].join(' \\qquad '), + type: 'text', + content: `La soluzione duale è *${isDualAdmissible ? 'ammissibile' : 'non ammissibile'}* e *${ + isDualDegenerate ? 'degenere' : 'non degenere' + }*.`, }) - if (!A_N__xi.getData().every(x => x.leq(RationalField.zero))) { - const [k, lambda] = N.filter(i => A_N__xi.at(A_N.forwardRowIndices[i]).gt(RationalField.zero)) - .map<[number, Rational]>(i => [i, b.at(i).sub(A.rowAt(i).dot(x)).div(A.rowAt(i).dot(xi))]) - .reduce(([i1, lambda1], [i2, lambda2]) => (lambda1.lt(lambda2) ? [i1, lambda1] : [i2, lambda2])) + if (!isDualAdmissible) { + const h = Math.min(...y.getData().flatMap((y, i) => (y.lt(RationalField.zero) ? [i] : []))) comments.push({ type: 'formula', content: [ - `\\bar\\lambda`, - `\\min_i \\left\\{ \\frac{b_i - A_i \\bar{x}}{A_i \\xi} \\; \\middle| \\; i \\in N, A_i \\xi > 0 \\right\\}`, - `${lambda}`, + // prettier-ignore + `h`, + String.raw`\min \{ i \in B \mid \bar{y}_i < 0 \}`, + `${h + 1}`, ].join(' = '), }) + const e_h = Vector.oneHot(RationalField, A.rows, h).slice(B) + console.log(e_h) + + const xi = A_B_inverse.apply(e_h).neg() + stepResult_xi = xi + comments.push({ type: 'formula', content: [ - `k`, - `\\argmin_i \\left\\{ \\bar\\lambda_i \\; \\middle| \\; i \\in N, A_i \\xi > 0 \\right\\}`, - `${k + 1}`, + // prettier-ignore + `\\xi`, + `-A_B^{-1} u_{B(h)}`, + `${vectorToLatex(xi)}`, ].join(' = '), }) + const N = range(0, A.rows).filter(i => !B.includes(i)) + const A_N = A.slice({ rows: N }) + + const A_N__xi = A_N.apply(xi) + comments.push({ type: 'formula', content: [ - `B'`, - `B \\setminus \\{h + 1\\} \\cup \\{k + 1\\}`, - `${indexSetToLatex([...B.filter(i => i !== h), k].toSorted())}`, - ].join(' = '), + [`N = \\{1, \\dots, m\\} \\setminus B = ${indexSetToLatex(N)}`], + [`A_N \\xi`, `${matrixToLatex(A_N)} ${vectorToLatex(xi)}`, `${vectorToLatex(A_N__xi)}`].join( + ' = ' + ), + ].join(' \\qquad '), }) - stepResult_B = [...B.filter(i => i !== h), k].toSorted() + if (!A_N__xi.getData().every(x => x.leq(RationalField.zero))) { + const [k, lambda] = N.filter(i => A_N__xi.at(A_N.forwardRowIndices[i]).gt(RationalField.zero)) + .map<[number, Rational]>(i => [i, b.at(i).sub(A.rowAt(i).dot(x)).div(A.rowAt(i).dot(xi))]) + .reduce(([i1, lambda1], [i2, lambda2]) => (lambda1.lt(lambda2) ? [i1, lambda1] : [i2, lambda2])) + + comments.push({ + type: 'formula', + content: [ + `\\bar\\lambda`, + `\\min_i \\left\\{ \\frac{b_i - A_i \\bar{x}}{A_i \\xi} \\; \\middle| \\; i \\in N, A_i \\xi > 0 \\right\\}`, + `${lambda}`, + ].join(' = '), + }) + + comments.push({ + type: 'formula', + content: [ + `k`, + `\\argmin_i \\left\\{ \\bar\\lambda_i \\; \\middle| \\; i \\in N, A_i \\xi > 0 \\right\\}`, + `${k + 1}`, + ].join(' = '), + }) + + comments.push({ + type: 'formula', + content: [ + `B'`, + `B \\setminus \\{${h + 1}\\} \\cup \\{${k + 1}\\}`, + `${indexSetToLatex([...B.filter(i => i !== h), k].toSorted())}`, + ].join(' = '), + }) + + stepResult_B = [...B.filter(i => i !== h), k].toSorted() + } else { + comments.push({ type: 'text', content: 'La soluzione è *illimitata*' }) + + status = { result: 'unbounded', xi } + } } else { - comments.push({ type: 'text', content: 'La soluzione è *illimitata*' }) - status = { result: 'unbounded', xi } + comments.push({ type: 'text', content: 'La soluzione è *ottima*' }) + + status = { result: 'optimal', x } } } else { - comments.push({ type: 'text', content: 'La soluzione è *ottima*' }) - status = { result: 'optimal', x } + comments.push({ + type: 'formula', + content: [ + String.raw`\bar{x}`, + `A_B^{-1} b_B`, + `${matrixToLatex(A_B)}^{-1} ${vectorToLatex(b_B)}`, + `${matrixToLatex(A_B_inverse)} ${vectorToLatex(b_B)}`, + `${vectorToLatex(x)}`, + ].join(' = '), + }) + + comments.push({ + type: 'formula', + content: + [ + // + `A \\bar{x}`, + `${matrixToLatex(A)} ${vectorToLatex(x)}`, + `${vectorToLatex(A.apply(x))}`, + ].join(' = ') + ` \\not\\leq ${vectorToLatex(b)} = b`, + }) + + comments.push({ + type: 'text', + content: 'In questo caso bisogna usare il metodo del *simplesso duale*.', + }) + + status = { result: 'wrong-starting-basis' } } steps.push({ diff --git a/src/lib-v2/vector.ts b/src/lib-v2/vector.ts index 223e48e..d9c98b7 100644 --- a/src/lib-v2/vector.ts +++ b/src/lib-v2/vector.ts @@ -26,6 +26,14 @@ export abstract class Vector> { return new VectorDense(this.getData().map(v => v.neg())) } + leq(vector: Vector): boolean { + if (this.size !== vector.size) { + throw new Error('Vector dimensions do not match') + } + + return this.getData().every((v, i) => v.leq(vector.at(i))) + } + with(indices: number[], vector: Vector): Vector { if (indices.length !== vector.size) { throw new Error('Vector dimensions do not match') diff --git a/src/main.tsx b/src/main.tsx index 8faa525..cf86138 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,8 @@ import { parseSafeProblemInput } from './parser-problem' import { DisplayProblemInput } from './DisplayProblemInput' import { Primale } from './Primale' +import exampleProblems from './example-problems.json' + const INITIAL_PROBLEM_INPUT = ` c' = 500 200; @@ -37,18 +39,30 @@ const useLocalStorage = (key: string, initialValue: T) => { } const App = () => { - const [currentProblemName, setCurrentProblemName] = useState('Pintel') - const [savedProblems, setSavedProblems] = useLocalStorage<{ name: string; source: string }[]>('savedProblems', [ - { name: 'Pintel', source: INITIAL_PROBLEM_INPUT }, - ]) + const [currentProblemName, setCurrentProblemName] = useLocalStorage( + 'ricerca-operativa.currentProblemName', + 'Pintel' + ) + const [savedProblems, setSavedProblems] = useLocalStorage<{ name: string; source: string }[]>( + 'ricerca-operativa.savedProblems', + exampleProblems + ) - const [problemInput, setProblemInput] = useState(INITIAL_PROBLEM_INPUT) + const [problemInput, setProblemInput] = useState( + savedProblems.find(p => p.name === currentProblemName)?.source ?? INITIAL_PROBLEM_INPUT + ) const problemValuesResult = parseSafeProblemInput(problemInput) return ( <> -

Ricerca Operativa / Programmazione Lineare

+

+ Ricerca Operativa / Programmazione Lineare + + {' '} + by @aziis98 + +

Questo sito è un progetto per il{' '} corso di Ricerca Operativa{' '} @@ -113,6 +127,7 @@ const App = () => { rows={Math.max(problemInput.split('\n').length, 5)} cols={100} > +

Problema di Input

{'result' in problemValuesResult ? ( @@ -122,8 +137,6 @@ const App = () => {

)} -

Svolgimento

- {'result' in problemValuesResult && } ) diff --git a/src/style.css b/src/style.css index dc92ef0..750b410 100644 --- a/src/style.css +++ b/src/style.css @@ -26,9 +26,10 @@ h2, h3, h4 { - font-weight: 300; + font-weight: 600; margin: 0; - color: #222; + color: #444; + line-height: 1.25; } a { @@ -101,6 +102,11 @@ font-weight: 700; color: #444; } + + small { + font-size: 0.5em; + font-weight: 400; + } } @layer components { @@ -115,8 +121,9 @@ > * { display: block; - max-width: 900px; - margin: 0.5rem auto; + width: 900px; + max-width: 100%; + margin: 1rem auto; } } @@ -144,8 +151,21 @@ border-radius: 1rem; padding: 2rem; + margin: 2rem auto; + + width: 100%; max-width: fit-content; + > .title { + grid-column: span 2; + text-align: center; + padding-bottom: 1rem; + + @media (width < 768px) { + grid-column: span 1; + } + } + > .step { grid-column: span 2; @@ -157,6 +177,7 @@ gap: 0; @media (width < 768px) { + grid-column: span 1; grid-template-columns: 1fr; } @@ -169,21 +190,46 @@ border-radius: 1rem; padding: 0 1rem; + > * { + max-width: 100%; + } + > * + * { - margin-top: 0.5rem; + margin-top: 1rem; } } > .geometric-step { - /* padding: 1rem; */ + @media (width < 768px) { + min-width: 0; + } + } + + > * { min-width: 30rem; } padding: 2rem 0; - &:not(:first-child) { + &:not(:nth-child(1), :nth-child(2)) { border-top: 1px solid #ddd; } + + @media (width < 768px) { + max-width: 100%; + gap: 1rem; + padding: 1rem 0; + + > * { + min-width: 0; + } + } + } + + @media (width < 768px) { + grid-template-columns: 1fr; + padding: 1rem 0 0 0; + max-width: 100%; } } } @@ -203,6 +249,34 @@ align-items: center; } + .scrollable { + display: grid; + + > .scroll-content { + display: grid; + } + + @media (width < 768px) { + position: relative; + + > .scroll-content { + overflow-x: auto; + padding: 1rem; + } + + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 0.5rem; + background: linear-gradient(to left, #0003, #0000); + border-radius: 0.25rem; + } + } + } + .flex-row { display: flex; flex-direction: row; @@ -212,5 +286,17 @@ > .spacer { flex-grow: 1; } + + @media (width < 768px) { + flex-wrap: wrap; + } + } + + .text-center { + text-align: center; } } + +.katex-display { + margin: 0 !important; +} diff --git a/src/vite.d.ts b/src/vite.d.ts index bb3f848..b6b1908 100644 --- a/src/vite.d.ts +++ b/src/vite.d.ts @@ -2,3 +2,8 @@ declare module '*.css' { const classes: { readonly [key: string]: string } export default classes } + +declare module '*.json' { + const value: any + export default value +}