momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Reducerea numărului de ferestre pe orarul şcolar al unei zile

jQuery | orar şcolar | widget
2014 nov

Avem obiceiul de a revizui (şi povesti) aplicaţiile pe care le-am realizat.
Foarte bun obicei!… te ajută să o iei de la capăt.

Ferestrele profesorului

Dacă în intervalul orar 8-14 al unei zile profesorului îi sunt repartizate 4 ore, prima la ora 8 iar ultima la ora 13 - atunci profesorul respectiv are "ferestre": sau două ore libere consecutive (de exemplu [oră, oră, oră, -, -, oră]), sau două intercalări de câte o oră liberă (de exemplu [oră, -, oră, oră, -, oră]).

Desigur, nu orice interval de ore libere consecutive trebuie considerat ca "fereastră"; de exemplu, dacă ai ore de la 8 la 11 (trei ore) şi apoi la sfârşitul zilei, de la ora 17 la ora 20 (3 ore) - atunci este absurd să zicem că ai fereastră de la ora 11 la ora 17, fiind vorba mai degrabă de o convenţie de genul "vreau primele ore în schimbul I şi ultimele din schimbul al II-lea".

Ferestrele pot să deranjeze (din punctul de vedere al profesorului), sau dimpotrivă, pot fi binevenite - putem face o problemă din asta, sau nu… Noi căutăm să facem o problemă şi deocamdată, vizăm o aplicaţie Web pentru reducerea numărului de ferestre de pe orarul şcolar al unei zile.

Este rezonabil să considerăm ca fereastră numai două cazuri: "oră, -, oră" şi respectiv "oră, -, -, oră" (una sau două ore libere intercalate între ore propriu-zise); desigur, situaţia a patru ore plasate în ziua respectivă astfel: "oră, oră, oră, -, -, -, oră" (când sunt intercalate mai mult de două ore libere, între ore propriu-zise) - poate fi extrem de neplăcută, dar această situaţie (care va putea fi totuşi remediată, prin aplicaţia pe care o prezentăm aici) apare mult mai rar decât cele două cazuri menţionate.

Dacă nu putem corija convenabil (pentru ansamblul orarului zilei) cazul "oră, oră, oră,-,-,-,oră", atunci acesta trebuie rezolvat angajând şi orarul unei alte zile - încercând interschimbarea între cele două orare zilnice a clasei indicate de "oră".

Convenind astfel termenul de "fereastră", obţinem un criteriu realist de reducere: numărul de ore libere intercalate între orele propriu-zise ale unui profesor este dat de diferenţa dintre rangul (+1) în cadrul zilei al ultimei ore din orarul său, rangul primeia şi respectiv numărul de ore propriu-zise; reţinând numai cazul când această diferenţă este 1 sau 2 şi însumând pentru toţi profesorii din orarul zilei - obţinem numărul de ferestre de pe orarul curent al zilei şi urmărim ca el să fie cât mai mic faţă de cel calculat pentru orarul furnizat iniţial aplicaţiei.

Mecanisme de acoperire a ferestrelor

Exemplificăm întâi un caz care poate fi considerat ca fiind banal - există doi profesori ("X" şi "T") care au o clasă comună (clasa "10G") şi fiecare este liber când celălalt are oră la acea clasă:

rang: 1   2   3   4   5   6    7   8   9   10  11  12
X     -   -   -   -   -   -    10H ~   10G ~   ~   ~      (9+1)-7-2 = 1 (o fereastră)
Y     11G 11E 12D 10A 12G -    ~   ~   ~   ~   10H 10C
Z     -   -   -   -   -   11D  ~   10D ~   9E  9G  9F     (12+1)-6-5 = 2 (două ferestre)
T     -   -   -   -   -   -    ~   10G ~   9F  10E 10B    (12+1)-8-4 = 1 (o fereastră)

Transferând clasa 10G din coloana 9 în coloana 8 pe linia X şi din coloana 8 în coloana 9 pe linia T - acoperim şi fereastra lui X şi pe cea a lui T; altfel spus, "urcăm" clasa 10G de la T la X şi o "coborâm" de la X la T. Implementarea va depinde însă de principiul de lucru pe care îl adoptăm; alegem să ne bazăm pe transferul dintr-o coloană în alta pe o aceeaşi linie (şi nu pe transfer între linii).

Ilustrăm acum un caz mai general; "Q" şi "W" au clasa comună 10A, iar "Q" este liber când "W" are oră la 10A, dar "W" nu este liber când "Q" are oră la 10A (prima diagramă):

   1   2   3   4   5   6  
Q  10A 12D 11D -   12C 11E 
U  -   10A 9B  12A -   -   
V  12A 12C 11E 12G -   -   
W  12G 12A 12F 10A -   -   
1   2   3   4   5   6 
-   12D 11D 10A 12C 11E 
-   10A 9B  12A -   -   
12A 12C 11E 12G -   -   
10A 12A 12F 12G -   -   
1   2   3   4   5   6 
-   12D 11D 10A 12C 11E 
-   10A 9B  12A -   -   
12G 12C 11E 12A -   -   
10A 12A 12F 12G -   -   
1   2   3   4   5   6 
-   12D 11D 10A 12C 11E
12A 10A 9B  -   -   -  
12G 12C 11E 12A -   -  
10A 12A 12F 12G -   -   

Pe linia "Q" mutăm 10A de la rangul 1 la rangul 4, iar pe linia "W" interschimbăm coloanele 1 şi 4 (vezi a doua diagramă); ca urmare, clasa 12G apare în ora a 4-a la "W", dar ea apărea şi la "V". Fiindcă 12G a plecat din coloana 1, rezultă că 12G de pe linia "V" trebuie mutată de pe rangul 4 pe rangul 1 - vezi situaţia din diagrama a treia; 12A ajunge astfel în aceeaşi oră (a 4-a) şi la "V" şi la "U" - deci trebuie să transferăm 12A pe linia "U" de pe rangul 4 pe rangul 1 şi - fiindcă "U" era liber în ora de rang 1 - putem încheia: corectitudinea orarului este restabilită (vezi ultima diagramă).

Desigur că dacă "U" nu ar fi fost liber în ora de rang 1, transferurile descrise trebuie continuate şi se pune problema de a demonstra că nu vom ajunge cumva într-o situaţie de "blocare", ci totdeauna vom putea restabili corectitudinea orarului printr-un număr finit de asemenea transferuri; formularea unei demonstraţii riguroase este nebanală (şi meritorie)…

Schema aplicaţiei pentru reducerea de ferestre

Vizând retrospectiv, aplicaţia constă într-un widget jQuery denumit vb.orarwg, care instituie în jurul unui element HTML <textarea> existent, întreaga infrastructură DOM necesară pentru preluarea ca text de un anumit format a orarului zilei şi transformarea acestuia într-un element <table> echipat corespunzător (cu anumite "handlere" de click) pentru a asigura (pe baza indicaţiilor percepute de la utilizator) efectuarea corectă a translaţiilor de clase şi ferestre, precum şi pentru efectuarea altor operaţii utile (indicarea numărului de ferestre curent, salvarea orarului modificat, etc.).

În maniera de utilizare cea mai directă, se pleacă de la un fişier HTML care conţine (în principal) două elemente - un <textarea> şi un <script>:

<textarea id="orar_zi"></textarea>  <!-- Elemente iniţiale -->
<script>$(function(){ $('#orar_zi').orarwg({}); });</script>

<script>-ul indicat activează widget-ul orarwg(), pentru elementul <textarea> (bineînţeles că fişierul HTML respectiv are de "inclus" jquery.js, utilitarul jquery.ui.widget.js, şi fişierul javaScript în care am definit orarwg(); şi desigur, el poate conţine şi alte elemente, pe lângă cele două menţionate).

"Activarea" widget-ului respectiv înseamnă de fapt executarea metodei specifice _create(), care în cazul nostru inserează în DOM-ul creat de browser pentru fişierul HTML tocmai încărcat, un paragraf conţinând un buton "Load" şi o diviziune deocamdată vidă:

<textarea id="orar_zi"></textarea>
<p id="tools-bar"><button>Load</button></p>  <!-- Inserate de orarwg._create() -->
<div id="grid-orar"></div>

Prevăzând suplimentar unele indicaţii, conţinutul rezultat prin cele de mai sus poate arăta astfel:

Pastăm cum s-a indicat, orarul unei zile:

Metoda orarwg._create() a montat pe butonul 'Load' un "handler" care la semnalarea unui "click" pe acest buton, declanşează metoda orargw._init(); aceasta verifică întâi corectitudinea orarului, alertând eventualele deficienţe - iar dacă orarul este corect, atunci definitivează infrastructura care să permită modificările de orar dorite:

Butonele 'reLoad', 'oreAM', 'orePM', 'Mark' şi 'Help' permit (prin handlerele de click asociate de către orargw()) anumite operaţii "utilitare": reîncărcarea orarului iniţial (abandonând modificările întreprinse), mascarea profesorilor care au ore numai într-unul dintre cele două schimburi de lucru, etc.

Butonul 'swap' constituie "miezul" aplicaţiei. Am modelat un procedeu foarte simplu (ignorând astfel tehnologia stufoasă "drag-and-drop"): pentru a acoperi o fereastră oarecare - aceasta trebuie marcată prin click, urmând a indica la fel o clasă de pe acelaşi rând cu fereastra; apoi, click pe butonul 'swap' va declanşa transferurile (de clase şi ferestre) necesare restabilirii corectitudinii orarului (după "acoperirea" indicată de utilizator).

Ilustrăm în treacăt şi o altă manieră de utilizare: putem presupune că pe [2] avem într-un fişier .js "dicţionarul de bază al orarului" în format JSON (vezi [1]); atunci, putem adăuga o funcţie javaScript care să insereze temporar un element <textarea> conţinând orarul unei zile (extras din dicţionarul menţionat), să aplice asupra acestui element widget-ul orarwg() şi să apeleze metoda click() ataşată butonului 'Load' - ajungând direct la infrastructura redată mai sus (de integrat apoi pe [2]).

De fapt, dacă dispunem de la bun început de orarul zilei ca Array() javaScript, atunci nu mai este necesară intermedierea unui <textarea> şi - renunţând la acest element, căruia i-am montat în mod firesc widget-ul - modelarea aplicaţiei ca "widget jQuery" (destinat să fie ataşat unui element HTML existent) devine discutabilă. Dar nu ne străduim să "împăcăm" cele două metode de utilizare vizate mai sus, considerând că prima dintre ele este prioritară (iar a doua se poate totuşi folosi, fără a o avea în vedere în mod special); implicit - păstrăm ideea organizării ca "widget jQuery" a aplicaţiei.

Instrucţia implementării aplicaţiei

Fireşte că lucrurile se clarifică şi se corelează pe parcurs, nu dintrodată şi în general, dezvoltarea în realitate a unei aplicaţii are un curs neliniar (spre întortochiat). Ai terminat-o şi eşti mulţumit de ea? - încercarea ulterioară a unui discurs didactic fluent pe seama aplicaţiei respective dezvăluie mereu corecturi, extensii şi reduceri asupra unora dintre vederile iniţiale.

Pe retrospecţia desfăşurată ideatic mai sus avem de observat aceste inadvertenţe: am separat butonul 'Load' instituit de constructorul _create(), de celelalte butoane, create de metoda _init() - iar _init() îl redenumeşte "reLoad" şi-i schimbă poziţia, alăturându-l celorlalte; dar nu are de ce să-i modifice handlerul de click - ori click pe 'Load' (şi ulterior, pe 'reLoad') apelează _init(), ceea ce înseamnă în principiu că butoanele respective sunt re-create (la fiecare click pe 'Load').

Este adevărat că acest defect este insesizabil în cursul practicării aplicaţiei (fiind puţine elemente de re-creat); dar în principiu… este un bun prilej de a o lua de la capăt.

E clar că am procedat "întortochiat" şi nefiresc. Infrastructura aplicaţiei (butoanele de operare, zicând mai simplu) trebuie creată o singură dată - prin constructorul widget-ului, _create(); de iniţializat sau re-iniţializat este conţinutul (asupra căruia se va opera), nu infrastructura - şi numai setarea acestui conţinut trebuie să fie în seama metodei _init().

Necesităţile infrastructurii aplicaţiei

Declarăm de la bun început ("deasupra" definiţiei widget-ului) infrastructura HTML pe care bazăm funcţionalitatea aplicaţiei - un element <p id="tools-bar"> conţinând butoanele 'Load', 'oreAM', etc., apoi diviziunea <div id="grid-orar"> în care va fi constituit tabelul HTML aferent orarului:

(function($) {
    function BAR() { // infrastructura HTML specifică aplicaţiei
        var bar = [
            '<p id="tools-bar">',
                '<button>Load</button>',                    // Load
                '<button>oreAM</button>',                   // oreAM
                '<button>orePM</button>',                   // orePM
                '<input value="9A"><button>Mark</button>',  // Mark
                '<button>swap</button>',                    // swap
                '<a>Export</a>',                            // Export
                '<button>Help</button>',                    // Help
                '<span></span><span></span>',   // numărul de ferestre (iniţial, curent)
            '</p>',
            '<div id="grid-orar">',  // tabelul HTML care va conţine orarul zilei
                '<table border="1" cellspacing="2" cellpadding="2"></table>',
            '</div>'
        ];
        return $(bar.join(''));
    };

Funcţia BAR() returnează direct obiectul jQuery() (gata de a fi inserat în DOM-ul paginii) corespunzător fragmentului HTML rezultat prin concatenarea elementelor tabloului bar[] şi va fi apelată o singură dată - din constructorul _create() al widget-ului. Anticipând - _create() va trebui să completeze obiectul primit de la BAR(), instituind şi handlerele necesare (click pe 'Load', etc.).

Mai adăugăm deasupra widget-ului, următoarele două funcţii utilitare:

    function TR_data(arr) { // rangul primei şi ultimei ore, numărul de ore
        var first = last = nore = 0, n = arr.length;
        for(var i = 1; i < n; i++) // `arr` = [nume, '-', '9B', '11A', ...]
            if(arr[i] != '-' && arr[i] != '~') {
                nore ++;
                if(!first) first = i;
                last = i; // în final - rangul ultimei ore a profesorului
            }    
        return {'first': first, 'last': last, 'nore': nore};
    };
    
    function SWAP(td1, td2) {  // interschimbă conţinutul a două celule din tabel
        var x1 = td1.text(), x2 = td2.text();
        td1.text(x2);
        td2.text(x1);
    };

Funcţia TR_data() primeşte o referinţă la tabloul orelor unui profesor şi returnează un "dicţionar" care indică rangul primei şi ultimei ore şi numărul de ore ale profesorului respectiv - cum putem ilustra prin următorul experiment direct:

var orar = ["Diaconu Oana", "11F", "11B", "12E", "12D", "-", "11F", 
                            "9F", "~", "~", "~", "~", "~"];
alert(JSON.stringify(TR_data(orar))); // {"first": 1, "last": 7, "nore": 6}

Pe de o parte, TR_data() ne va servi pentru calcularea numărului de ferestre (= last - first + 1 - nore); pe de altă parte - când vom crea elementele <tr> ale tabelului care trebuie inserat diviziunii "#grid-orar", vom putea folosi atributul HTML5 data-* pentru a reţine aceste trei caracteristici:

<tr data-first="1" data-last="7" data-nore="6">
    <td>Diaconu Oana</td>
    <td>11F</td><td>11B</td><td>12E</td><td>12D</td><td>-</td><td>11F</td>
    <td>9F</td><td>~</td><td>~</td><td>~</td><td>~</td><td>~</td>
</tr>

şi astfel avem o modalitate foarte simplă de filtrare a rândurilor (implicată în modelarea acţiunilor 'oreAM' şi 'orePM'); de exemplu, profesorii care au ore numai în cadrul schimbului a II-lea corespund rândurilor <tr> pentru care .data('first') > 6.

Funcţia SWAP() primeşte referinţe la obiectele jQuery() asociate unor elemente <td> şi interschimbă conţinuturile acestora - servind în cadrul procedurii de "acoperire" a unei ferestre (acţiune care va fi montată pe butonul swap).

Corelarea acţiunii 'Load' cu metodele _create() şi _init()

Într-un widget jQuery, this.element referă obiectul jQuery asociat elementului HTML pe care este (sau va fi) "montat" widget-ul respectiv - în cazul nostru, acel element <textarea id="orar_zi"> în care utilizatorul va pasta orarul zilei. Construcţia respectivă se obţine prin invocarea $('#orar_zi').orarwg() - care declanşează executarea metodei _create(), urmată imediat de executarea metodei _init().

Constructorul _create() ne apelează BAR() pentru a institui "bara de instrumente", apelează metoda internă _set_handlers() în care vom defini mai jos acţiunile acestor instrumente şi prevede un câmp intern .orar[] (tablou javaScript) pentru a păstra orarul, după ce textul acestuia va fi pastat în caseta '#orar_zi' (şi utilizatorul va acţiona butonul 'Load'):

    $.widget("vb.orarwg", {
        _create: function() { 
            BAR().insertAfter(this.element); // adaugă în DOM infrastructura HTML
            this.orar = []; // păstrează datele iniţiale (orarul zilei)
            this._set_handlers(); // defineşte interacţiunile cu utilizatorul
        },
        
        _init: function() { // prezintă (<table>) datele pe care urmează să se opereze
            if(this.orar.length == 0) return; // renunţă, dacă nu există date
            /* ... */
            this.element.hide(); // ascunde '#orar_zi', după "citirea" orarului pastat 
        },

        _set_orar: function() {
            if(! this.element.val()) return; // încheie, dacă nu s-a pastat orarul
            /* preia textul orarului şi înscrie datele în tabloul intern `.orar`[] */
        },
        
        _set_handlers: function() {
            var bar = $('#tools-bar');
            var Self = this; // pentru a referi instanţa curentă (this), din 'click'
            bar.find('button:first').on('click', function(event) {  // butonul 'Load'
                if(Self.element.is(':visible'))
                    Self.orar = Self._set_orar(); // numai în etapa '_create()'
                Self._init();                
            });
            /* ... */
        }
    });
})(jQuery);

În momentul iniţial (la prima instanţiere a widget-ului), tabloul intern orar[] este vid (nu există date) şi atunci, metoda _init() - lansată automat după _create() - îşi încheie imediat execuţia. Subtilitatea (de se petrec lucrurile tocmai aşa) ar fi că orar[] rămâne vid la încheierea apelului _create(), fiindcă metoda _set_handlers() (invocată din _create()) nu se atinge de tabloul orar[] decât în cadrul acţiunii definite pentru butonul 'Load' - acţiune în care orar[] este completat (apelând metoda internă _set_orar()) cu datele preluate din '#orar_zi' numai dacă acest element este încă vizibil; după completarea tabloului orar[], elementul '#orar_zi' este pus în starea de "invizibil", în finalul apelului _init() - încât un click ulterior pe 'Load' nu va mai apela _set_orar() (deci nu se va repeta, completarea tabloului orar[]).

Completarea tabloului intern .orar[]

În caseta '#orar_zi', utilizatorul va putea să pasteze textul orarului zilei în format CSV - cu linii precum:

Diaconu Oana,11F,11B,12E,-,12D,11F,9F,~,~,~,~,~,
Dima Laura,11G,12F,11G,-,12G,11G,~,~,~,~,~,~,

În acest scop, orar_cear oferă spre "download" o arhivă conţinând orarele zilelor (pentru şcoala respectivă) în format CSV.

Altă variantă constă în pastarea textului preformatat al orarului (selectând şi copiind orarul unei zile, de exemplu de pe orar_cear) - cu linii precum:

Diaconu Oana               11F 11B 12E 12D -   11F    9F  ~   ~   ~   ~   ~
Dima Laura                 12F -   11G 11G 12G 11G    ~   ~   ~   ~   ~   ~

În orice caz însă, avem în vedere un orar care vizează două schimburi de câte 6 ore; orele libere sunt marcate cu '-' pentru primul schimb şi cu '~' pentru al doilea; în plus, avem o pretenţie elementară: numele claselor nu conţin spaţiu (trebuie scris "9A" şi nu "9 A").

Metoda ._set_orar() testează întâi dacă s-a pastat un text în caseta '#orar_zi'; dacă nu, atunci încheie imediat (şi tabloul intern .orar[] rămâne vid) - altfel, splitează textul respectiv obţinând tabloul liniilor de text şi apoi splitează fiecare linie - la virgulă în cazul CSV, respectiv la spaţiu - şi înscrie tabloul obţinut pentru linia curentă în tabloul local orar[]. Dacă orarul înscris astfel nu prezintă "coliziuni" (suprapuneri de ore - verificate printr-o funcţie internă metodei), atunci se returnează tabloul orar[] - altfel, se returnează "undefined":

_set_orar: function() {
    if(! this.element.val()) return; //renunţă, dacă nu s-a pastat orarul
    var orar = [];
    var lines = this.element.val().trim().split(/\n/); // tabloul liniilor de text
    if(/,/.test(lines[0]))  // (posibil) format CSV
        $.each(lines, function(i, el) {
            orar.push(el.split(','));
        });
    else { // text preformatat: nume-profesor, apoi ore separate prin TAB
        var nume = /^\S+\s\S*\s\S*\s+/; // "Nume", sau "Nume Pren", sau "Nume Pren Pren" 
        $.each(lines, function(i, el) {
            prof = el.match(nume)[0], n = prof.length;
            orar.push([prof.trim()].concat(el.slice(n).split(/\s+/)));
        });
    }
    if(collision()) return; // renunţă, dacă există suprapuneri de ore
    return orar; // altfel - returnează tabloul orar[]
         
    function collision() { // verifică, formulând eventual un mesaj de eroare
        var err = []
        for(var j=1, m=orar[0].length; j < m; ++j)
            for(var i=0, n=orar.length-1; i < n; ++i) 
                for(var k=i+1, p=n+1; k < p; ++k) {
                    var ora = orar[i][j];
                    if(ora.length > 1 && ora == orar[k][j])
                        err.push(orar[i][0] + ' / ' + orar[k][0] + ': ' + ora + ', ora ' + j);
                }
                if(err.length > 0) {
                    alert("Suprapuneri:\n\n" + err.join('\n'));
                    return true;
                }
    }
},

În final, orar[] este o listă (tablou javaScript) ale cărei elemente sunt tablouri conţinând fiecare, orarul câte unui profesor:

[["Adam Loredana","12E","12G","12D","11F","9C","11A","~","~","~","~","9I","9D"],
 ["Agafiţei Gabriela","-","-","-","-","-","-","~","9G","9E","9F","9D","9I"],
 /* ... */
]

Aceasta se poate constata în mod direct, inserând (înainte de return) alert( JSON.stringify(orar) ); - ceea ce serializează obiectul şi "afişează" textul rezultat.

Desigur… trebuia să ne străduim mai mult, pentru a testa că textul furnizat în '#orar_zi' reprezintă într-adevăr un orar într-un format sau în altul (a vedea o virgulă nu este suficient pentru a zice că ai de-a face cu un text CSV).

Transformarea tabloului javaScript în tabel HTML

După ce apelează _set_orar(), completând tabloul intern .orar[], acţiunea 'Load' redată mai sus continuă prin apelarea metodei _init(); aceasta, găsind că .orar[] este nevid - produce elementele HTML <tr> corespunzătoare subtablourilor din lista internă .orar[] şi le înscrie în final în elementul <table> din diviziunea '#grid-orar' instituită prin apelul anterior al funcţiei BAR():

_init: function() {  // prezintă (<table>) datele pe care urmează să se opereze 
    if(this.orar.length == 0) return; // renunţă, dacă nu există date
    var scor = 0; // numărul de ferestre
    var html = []; // tablou JS în care se construiesc elementele <tr> şi <td>
    $.each(this.orar, function(i, el) {
        var filan = TR_data(el);
        var first = filan['first'], last = filan['last'], nore = filan['nore'];
        var nfer = last - first + 1 - nore;
        if(nfer > 0 && nfer < 3) 
            scor += nfer; // contabilizează numărul de ferestre
        html.push('<tr data-first="', first, '" data-last="', last, 
                  '" data-nore="', nore, '"><td>', el[0], '</td>');
        for(var i=1, n=el.length; i < n; ++i)
            html.push('<td>', el[i], '</td>');
        html.push('</tr>');
    });
    this.element.prev().hide(); // ascunde zonele '#indicatii', '#orar_zi'
    this.element.hide();
    $('#grid-orar table').html($(html.join(''))); // inserează rândurile în DOM 
    $('#tools-bar').find('span:first').text('iniţial: ' + scor + ' ferestre; ')
                                      .next().text(''); // anulează "scorul" curent
},

Înainte de a se încheia, _init() face două ajustări: ascunde caseta '#orar_zi' (încât ulterior 'Load' nu va re-apela _set_orar(), cum am arătat mai sus) şi înscrie la capătul barei de instrumente numărul iniţial de ferestre, ştergând totodată pe cel "curent".

Avem şi aici o "subtilitate": poate că utilizatorul a folosit 'oreAM' sau 'orePM', ceea ce înseamnă că au fost ascunse unele rânduri <tr> dintre cele create iniţial de _init(); la prima vedere, click pe 'Load' ar trebui doar să modifice setările de ascundere de pe rândurile respective - dar aceasta nu este tocmai bine: utilizatorul va fi folosit şi operaţia 'swap', modificând tabelul HTML şi poate că nu este mulţumit de rezultat (reflectat în principal de "scorul curent"), încât vrea să o ia de la capăt - ceea ce înseamnă obligatoriu, reconstrucţia tabelului HTML plecând de la datele iniţiale păstrate în tabloul intern .orar[]; rezultă şi motivul pentru care _init() are de şters în final "scorul curent".

Rezultă şi această clarificare definitivă a rolurilor: 'Load' (re)formulează tabelul HTML corespunzător orarului iniţial (ignorând eventualele modificări ulterioare); 'oreAM' şi 'orePM' filtrează tabelul HTML curent (modificat eventual, faţă de cel iniţial), dar - în plus faţă de vederea iniţială (descrisă retrospectiv mai sus) a aplicaţiei - trebuie să fie reversibile: un al doilea click pe acelaşi buton restabileşte tabelul dinaintea filtrării obţinute la primul click.

Definirea acţiunilor specifice aplicaţiei

Reluăm şi completăm definiţia metodei _set_handlers(). Mai întâi, creem două referinţe locale bar şi got pentru obiectele pe care le vom accesa frecvent în cadrul metodei:

_set_handlers: function() {
    var bar = $('#tools-bar'),  // referinţe locale pe obiecte jQuery frecvent accesate
        got = $('#grid-orar table');  // referă tabelul HTML constituit prin _init()
    var Self = this;
    bar.find('button:first').on('click', function(event) {  // Load
        if(Self.element.is(':visible'))
            Self.orar = Self._set_orar();  // se execută numai în etapa '_create()'
        Self._init();                
    });

Am repetat aici, handlerul de click pentru butonul 'Load' - discutat deja mai sus; adăugăm doar că referinţa locală Self (externă handlerului) este necesară pentru motivul că din interiorul funcţiei 'click' a butonului respectiv, this ar fi indicat butonul căruia îi montăm handlerul şi nu instanţa curentă a widget-ului (cum este necesar, pentru a accesa tabloul intern .orar[], etc.).

Operaţii de filtrare

Handlerele de click pentru 'oreAM' şi 'orePM' schimbă vizibilitatea rândurilor din tabelul HTML referit de got: primul click pe 'oreAM' ascunde rândurile corespunzătoare profesorilor care au ore numai în al doilea schimb (facilitând rezolvarea unor ferestre existente în primul schimb) - iar apoi, un al doilea click pe 'oreAM' va reface vizibilitatea iniţială. Aceste două handlere sunt aproape identice (diferă doar condiţia de filtrare) - dar încă nu văd o modalitate simplă pentru a evita dublarea codului.

Alternăm vizibilitatea rândurilor folosind o clasă CSS "fictivă" (reverse), asociată şi apoi retractată butonului respectiv:

    bar.find('button:contains(oreAM)').on('click', function(event) {  // oreAM
        var btn = $(this);  // `this` referă butonul
        if(btn.hasClass('reverse')) {
            got.find('tr').show();  // toate rândurile devin vizibile
            btn.removeClass('reverse');
        }
        else {
            btn.addClass('reverse');
            got.find('tr').show().each(function(){
                if($(this).data('first') > 6)  // aici, `this` referă rândul curent
                    $(this).hide();  // ascunde rândurile care satisfac filtrul
            });
       }
    });

    bar.find('button:contains(orePM)').on('click', function(event) {  // orePM
        var btn = $(event.target);
        if(btn.hasClass('reverse')) {
            got.find('tr').show();
            btn.removeClass('reverse');
        }
        else {
            btn.addClass('reverse');
            got.find('tr').show().each(function(){
                if($(this).data('last') < 7)  // este drept că numai aici, diferă de 'oreAM'
                    $(this).hide();
            });
        }
    });

    bar.find('button:contains(Mark)').on('click', function(event) {  // Mark
        var clasa = $(this).prev().val();  // clasa tastată în 'input'-ul alăturat butonului
        got.find('td:contains(' + clasa + ')')
           .toggleClass('syntax');  // evidenţiază celulele care conţin clasa respectivă
    });

Şi 'Mark' asigură un soi de "filtrare": evidenţiază pe tabelul HTML indicat de got orele existente la clasa indicată (presupunând existenţa unei definiţii de stil CSS pentru '.syntax'); repetând pentru câte o altă clasă, evidenţiem orele la un anumit grup de clase - facilitând planificarea unor intervertiri de ore în scopul reducerii numărului de ferestre.

Necesitatea diviziunii 'Help'

Ideal ar fi ca denumirile butoanelor să "spună" utilizatorului aplicaţiei ce "fac" acestea, încât două-trei experimente simple să-l lămurească asupra exploatării aplicaţiei respective. Pentru aplicaţia noastră, operaţia esenţială este "swap" - ceea ce ar însemna că se vor interschimba două obiecte; dar maniera de folosire a acestui buton trebuie explicitată.

Am putea prevedea o funcţie analogă funcţiei BAR(), care să formuleze o diviziune de indicaţii, gata de inserat în DOM la click pe butonul 'Help'. Dar preferăm să procedăm direct: prevedem în pagina HTML în care instanţiem widget-ul un element <div id="orar-help"> şi formulăm aici indicaţiile cuvenite.

Nu ne-am referit până aici, la aspectele CSS ale aplicaţiei; dar este clar că a trebuit prevăzut un fişier "orarwg.css", pentru a stila convenabil elementele HTML implicate (setând distanţa între butoane, tipul de cursor, etc.). Pentru '#orar-help' prevedem aici următoarele proprietăţi CSS:

#orar-help {
    display: none; /* $('#orar-help').toggle() va arăta/ascunde conţinutul */
    position:absolute; /* la dreapta tabelului HTML al orelor */
    top: 40px; /* a seta left = lăţimea tabelului orelor */
    width: 30%;
}

'#orar-help' fiind iniţial ascunsă ("display: none") şi având poziţionare absolută - nu contează locul în care o inserăm în fişierul HTML respectiv. Proprietatea left urmează a fi setată în handlerul de click:

    bar.find('button:contains(Help)').on('click', function(event){  // Help
        var left = $('#grid-orar table').width() + 50;
        if(left < 100) left = 550; // pentru situaţia iniţială (nu există date)
        $('#orar-help').css({'left': left+'px'}).toggle();
    });

'Help' este o acţiune reversibilă: .toggle() va ascunde indicaţiile dacă acestea sunt vizibile şi le va arăta în caz contrar.

Imaginea tocmai redată ilustrează efectul acţionării butonului 'Help', evidenţiind indicaţiile din '#orar-help' referitoare la folosirea butonului 'swap'.

Operaţii de transfer

Cum se vede pe indicaţiile redate, utilizatorul va trebui să indice (prin click) o fereastră şi apoi clasa cu care ar dori să o acopere; de aceea, montăm pe tabelul HTML indicat de got un "handler" care la click pe un <td> diferit de primul din rând (acesta conţine numele profesorului) - va marca vizual <td>-ul respectiv, aplicându-i o definiţie CSS care setează o altă valoare pentru background:

    got.on('click', function(event) {
        var target = $(event.target); // elementul din tabelul HTML indicat prin 'click'
        if(target.is('td') && target.index() > 0) { // evită primul TD (nume-profesor)
            var ql = target.text();
            if(ql == '-' || ql == '~') // fereastră
                target.addClass('gap-source'); // .gap- {background: yellow;} (CSS)
            else
                target.addClass('gap-dest'); // clasă (oră propriu-zisă)
        }
    });

Mai avem nevoie de o funcţie care să determine numărul de ferestre din orarul reflectat curent (după diverse operaţii "swap") pe tabelul HTML got[0]. Ştim desigur că sufixând cu "[0]" un obiect jQuery, ajungem la elementul HTML încorporat; în cazul nostru, got[0] este chiar tabelul HTML (ambalat în obiectul jQuery got) - încât putem folosi proprietăţile DOM standard .rows care colectează elementele <tr>, rows[0].cells (colecţia elementelor TD ale rândului), precum şi .innerHTML:

    function get_scor() {
        var grid = got[0], scor = 0;
        for(var i=0, rl=grid.rows.length; i < rl; i++) { // pentru fiecare profesor
            var line = []; // lista orelor (şi ferestrelor) profesorului
            for(var j=0, cl=grid.rows[0].cells.length; j < cl; j++)
                line.push(grid.rows[i].cells[j].innerHTML);
            var filan = TR_data(line); // rangul primei şi ultimei ore, numărul de ore
            var first = filan['first'], last = filan['last'], nore = filan['nore'];
            var nfer = last - first + 1 - nore; // numărul de ferestre
            if(nfer > 0 && nfer < 3) 
                scor += nfer;  // contorizează ferestrele
        }
        return scor; // numărul total de ferestre
    };

Cu aceste două pregătiri, operaţia 'swap' (pe care am exemplificat-o ca procedeu manual, undeva mai pe la început) poate fi modelată astfel:

    bar.find('button:contains(swap)').on('click', function(event) {  // swap
        var td1 = got.find('td.gap-source'); // identifică fereastra
        if(td1.length == 1) {
            var td2 = td1.parent().find('td.gap-dest'); // clasa (pe rândul lui `td1`)
            if(td2.length == 1) {
                var id1 = td1.index(), // a câta oră este fereastra, clasa
                    id2 = td2.index();
                // cele două ore trebuie să fie într-un acelaşi schimb
                if((id1 < 7 && id2 < 7) || (id1 > 6 && id2 > 6)) {
                    var sig = id1 < 7 ? '-' : '~';
                    SWAP(td1, td2); // interschimbă orele indicate
                    do { // reconstituie corectitudinea orarului
                         td2 = td1.parent().siblings()
                                           .find("td:contains("+td1.text()+")")
                                           .filter(function() {
                                                return $(this).index() == id1;});
                         td1 = td2.parent().find("td")
                                           .filter(function() {
                                                return $(this).index() == id2;})
                         SWAP(td1, td2);
                         td1 = td2;
                     } while(td1.text() != sig);
                 }
                 // determină şi înscrie numărul de ferestre rezultat
                 bar.find('span:last').text('curent: ' + get_scor());
            }
        }
        // elimină clasa CSS de marcare a orelor de interschimbat
        got.find('td').removeClass('gap-source').removeClass('gap-dest');
    });

Bineînţeles că am avut în vedere indicarea unei singure ferestre şi a unei singure clase, aflate pe un acelaşi rând şi în cadrul unui aceluiaşi schimb; altfel (nu sunt pe acelaşi rând, sau sunt în schimburi diferite, sau sunt marcate mai mult de două ore) se va executa în principiu numai ultima linie, prin care se elimină clasa CSS care fusese adăugată prin click pe elementele <td> respective.

"Exportarea" orarului curent

Să presupunem că - tot jucându-ne cu 'swap', sau poate în urma unei planificări mai inteligente a succesiunii de operaţii 'swap' - am reuşit cel puţin să înjumătăţim numărul de ferestre, faţă de cel iniţial. Atunci este firesc să "salvăm" acest rezultat, actualizând eventual orarul real existent.

Avem deja un procedeu standard şi foarte banal (iar banalitatea de operare este totdeauna binevenită), pentru a "importa" orarul respectiv de exemplu în Gnumeric (probabil că la fel stau lucrurile şi pentru Microsoft Excel): pur şi simplu selectăm cu mouse-ul tabelul respectiv, tastăm combinaţia de taste CTRL + C (ceea ce copiază selecţia în "clipboard", dar desigur - nu ca fragment HTML, ci doar ca text "pre-formatat") şi apoi pastăm într-un "Sheet" din Gnumeric (folosind combinaţia CTRL + V):

Dar prevedem şi următorul "handler" pentru link-ul 'Export', permiţând descărcarea unui fişier care conţine orarul respectiv în format CSV - format care se obţine concatenând conţinuturile elementelor <td>, având grijă să intercalăm ',' şi (la sfârşitul fiecărui rând) '\n':

    bar.find('a').on('click', function(event){  // Export
        var grid = got[0],  // elementul <table> din `#grid-orar`
            gridS = '';  // formatul CSV al conţinutului tabelului HTML
        var cols = grid.rows[0].cells.length - 1;
        for(var i=0, rl=grid.rows.length; i < rl; i++) {
            for(var j=0; j < cols; j++)
                gridS += grid.rows[i].cells[j].innerHTML + ",";
            gridS += grid.rows[i].cells[cols].innerHTML + "\r\n"; // "\n"
        }
        $(event.target).prop({
            'href': 'data:application/csv;charset=utf-8,' + encodeURIComponent(gridS),
            'target': '_blank',
            'download': 'ret_orar.csv'
        });
    });

În final, textul CSV rezultat este salvat ("download"-at) ca fişier CSV prin implicarea mecanismului Data URI; acesta nu este suportat pe elemente <button>, încât 'Export' a trebuit prevăzut ca link.

Sugestii de dezvoltare

Cel mai probabil, la primul contact cu aplicaţia utilizatorul va începe să se joace cu 'swap': câştigă cel care într-un timp convenit reuşeşte cea mai mare diminuare a numărului de ferestre, pe un orar fixat. Pentru a-ţi spori şansele ar trebui ca în prealabil să observi anumite particularităţi ale aşezării orelor - încât să poţi închipui o succesiune de operaţii 'swap' cât mai potrivită.

Mai importantă ar fi însă, realizarea următoarei idei de automatizare a lucrului: pentru orarul dat iniţial, se constituie o listă a tuturor ferestrelor şi interschimbărilor imediate care le-ar putea rezolva; pe baza acestei liste, se declanşează secvenţe aleatoare de operaţii 'swap', reţinând două-trei dintre orarele rezultate care au "scorul" cel mai mic; apoi se repetă, luând ca orar iniţial câte unul dintre cele reţinute anterior - până ce obţinem un "scor" suficient de bun.

O primă "dezvoltare" constă în adăugarea unui "dicţionar" intern {clasă: [indecşii din this.orar[] ai profesorilor care au ore la clasa respectivă]} pe baza căruia am extins handlerul de click asociat tabelului HTML (care mai sus viza numai elementele <td> pentru ore şi ferestre): acum, la 'click' pe numele profesorului sunt arătate numai rândurile care conţin măcar una dintre orele acestuia, ascunzând rândurile care nu au nimic în comun cu el (şi deci, nu sunt afectate de operaţiile "swap" asupra orelor acestuia - încât "excluderea" acestor rânduri uşurează sesizarea unei succesiuni de operaţii cât mai potrivite).

În 2018, am montat aplicaţia descrisă mai sus la school_apps - "Actualizarea orarului".

vezi Cărţile mele (de programare)

docerpro | Prev | Next