Les Glyphes de Narayan le 07/08/2022 à 16:08,

Petite aventure de dessins CSS, de générateur procédural en JS et d'un peu d'astuce algorithmique. Je voulais en faire un thread twitter mais je me suis rendu compte que ça allait être très long... Donc ce sera un article de blog !

Je suis en congé et j'écris du Jeu de Rôle (JDR). Au milieu du chaos de mes notes je retombe sur les glyphes de Narayan, venant du 3ème jeu de la série Myst. Ce sont de simples tracés avec des quarts de cercles on/off (pour ne pas trop spoiler le jeu) qui "signifient" quelque chose.

Screenshot de Myst III:Exile

Me voilà parti pour réaliser un générateur en HTML5 capable d'afficher toutes les combinaisons de manière aléatoire comme contrôlée !

Vous pouvez retrouver le projet directement ici. Il suffit de cliquer sur la page pour générer un nouveau glyphe.

Je commence par un rapide calcul, il y a 4 cercles, de 4 arcs de cercles chacun avec un grand cercle, avec 4 arcs de cercle. Soit 20 "quarts de cercle". Et chacun peut être on/off. Le nombre de combinaisons (20 parmi 2, dans l'ordre) s'obtient avec la formule (2^n)-1 soit 1 048 575 combinaisons !

Dessin et CSS

Première étape, comment afficher des cercles facilement (et en restant responsive) en CSS ?

Les blocs HTML (des <div> dans mon cas) peuvent avoir la propriété border-radius. Si elle est poussée à 50% et que le bloc est un beau carré, on obtient un cercle !

Un cercle et un carré

Du coup, le problème devient : « comment afficher des carrés parfaits, responsive en CSS ? » La propriété aspect-ratio: 1; permet ce petit tour de passe-passe. Il suffit juste de définir un des côtés. Dans mon cas ce sera la largeur du grand cercle (80% du conteneur) qui servira de référence. Cette propriété de ratio est, hélas, encore au stade expérimental, donc a utiliser avec précaution.

<main>
    <div class="square"></div>
    <div class="square round"></div>
</main>
main
{
    width: 50%;
}
.square
{
    background-color: red;
    margin : 100px;
    width: 20%;
    aspect-ratio: 1;
}
.round
{
    border-radius: 50%;
}

A partir de là, flexbox nous permet de positionner un grand carré (notre futur grand cercle) et 4 petits carrés dedans (nos futur petit cercles).

Un grand cercle et 4 petit cercles qui se décalent

Catastrophe ! Avec les bordures, les blocs prennent plus de place que prévu ! J'opte pour l'option calc() en décomposant de la manière suivante la largeur des petit cercles :

50% - (épaisseur des bordures)*2

Ça commence a prendre forme. Se pose du coup la question : comment faire pour que les cercles se croisent proprement ?

Un grand cercle et 4 petit cercles

Je prends l'option d'ajouter 4 classes "top, right, left, bottom" aux 4 petit cercles afin de les "décaler" via un positionnement relatif. J'aurais pu faire un calcul propre mais je préfère tâtonner un peu pour trouver le bon offset.

.top
{
    top: var(--offset);
}

Depuis le début de mon projet je manipule beaucoup de variables et données répétés ou utilisées dans des fonctions calc(), elles sont toutes déclarées dans le pseudo-format :root et peuvent être appelées via var(--nom)

:root{
    --borderSize:5px;
    --offset:7.3%;
    --backgroundColor:brown;
    --borderColorOff:transparent;
    --light:goldenrod;
    --shadow:rgba(0, 0, 0, 0.05);
    --bigFactor:0.1;
}

Un grand cercle et 4 petit cercles qui s'entrecroisent

La fin de la partie CSS sera consacrée à nettoyer les classes, grouper et escalader les informations dupliquées (notamment en créant la classe "circle") J'en profite pour créer différentes classes avec une couleur dorée pour allumer/éteindre les arcs de cercle. En effet c'est avec un peu de Javascript que les bordures vont être activées ou non.

Un grand cercle et 4 petit cercles qui s'entrecroisent avec des couleurs

L'astuce binaire

C'est le début de la deuxième étape : contrôler quelles bordures afficher ou non pour tracer les glyphes. Pendant un moment je me creuse le ciboulot sur comment représenter les différents glyphes simplement. Tous sont une combinaison de 20 élément, chacun vrai (allumé) ou faux (éteint). De prime abord, un objet ou un tableau avec le lien devrait faire l'affaire. Et puis une idée, murmurée dans ma tête par la voix de mon prof à IN'TECH : "un bit c'est 0 ou 1". Mais oui ! Il suffit de stocker sur une séquence binaire fixe les informations de constructions du glyphe. Ainsi les 4 premiers bits représentent les 4 arcs de cercle du grand cercle, les 4 suivant les 4 arcs de cercle du petit cercle en haut à gauche et ainsi de suite... Soit 20 bits.

0011 0001 1110 0111 1010 = 204410

Cette séquence dessinera donc le glyphe suivant :

Un glyphe de cercles et arcs de cercle

Détail amusant, si je convertis le nombre binaire à 20 bits "full" alors sa valeur en décimal vaut 1 048 575. C'est à dire le nombre de combinaison plus haut ! Je me dis que je suis vraiment sur la bonne voie. Je n'ai donc qu'a générer des nombres aléatoires de 1 = 00000000000000000001 à 1 048 575 = 11111111111111111111 et lire la conversion en binaire bit à bit pour constituer le glyphe unique associé. Réciproquement, je pourrais saisir un nombre pour afficher un glyphe donné.

La génération procédurale

Voyons donc l'implémentation Javascript. Petite visite rapide sur stack-overflow (!) pour générer facilement un nombre aléatoire entre 2 bornes :

function getRandomIntInclusive(min, max) {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min + 1)) + min;
};

Puis fournir ce nombre aléatoire à 2 fonctions chainées

input.toString(2).padStart(20, '0');

.toString(2) va convertir le Number en string en utilisant une base donnée (2, binaire donc). Mais sans les zéros non-significatifs au début de la string. Heureusement la fonction padstart(20, '0') vas compléter la string au début avec des '0' jusqu'a ce que la string fasse 20 caractères. A ce stade j'ai donc une string de la forme 11110000000000000001.

Je vais donc itérer sur ces 20 caractères (forcément 0 ou 1) et si je tombe sur un '1' alors je n'ai qu'à afficher la bordure au bon endroit. Du coup la question se pose, comment savoir que le 6ème bit est l'arc de cercle droit du premier petit cercle en haut a gauche ?

Première option, un gros switch de 20 cases mais je trouve l'option peu élégante et surtout avec beaucoup de répétition (il y a 5 arcs de cercle en "bas", 5 en haut ...). Je prends d'ailleurs l'option (discutable, on verra plus loin) d'utiliser la fonction document.querySelector(). Elle permet de récupérer un élément HTML a l'aide d'un sélecteur CSS. (Le célèbre $() du JQuery). Ainsi la Query ".small.circle.top.left" me renverra le cercle cité précédemment. Et en plus c'est lisible pour débugger,... que demande le peuple ?

 const element = document.querySelector(query);

Je n'aurais plus qu'à assigner une classe CSS spécifique (light-top) qui édite juste la bordure du haut de l'élément sélectionné. Ainsi un cercle avec juste les arcs gauche et droit actifs aura les classes ".light-left" et ".light-right" ajoutées. Comme je n'aime pas me répéter, un tableau avec les 4 classes me permet de pointer facilement dessus à l'aide d'un index. La bordure du haut sera la 0ième, et ainsi de suite dans l'ordre des aiguilles d'une montre.

const BORDERS = [
    "light-top",
    "light-right",
    "light-bottom",
    "light-left"
];
const border = BORDERS[borderId];
element.classList.add(border);

Reste la question finale, composer les Query. Je remarque alors que mes binaires se regroupent en quadruplet. Donc tous les 4 bits, je change de cercle ! En utilisant un modulo je peux aussi récupérer l'id précis de la bordure dans ce quadruplet

    let circle = -1;
    for (let i = 0; i < 20; i++) {
        let borderId = i % 4;

        const bit = binary[i];
        if (borderId === 0) circle++;

        //...
    }

Avec cette boucle, j'itère le long du nombre binaire (devenu une string, donc un tableau) et j'en récupère le modulo 4. Si le modulo vaut 0, alors j'ai changé de cercle et je peux ainsi stocker celui-ci dans une variable externe à la boucle. Le cercle 0 sera le grand cercle, le cercle 1 celui en haut a gauche etc... Je me retrouve enfin avec les 3 informations nécessaire : l'id du cercle, l'id de la bordure actuelle et le bit !

if (bit === '1') {
    let query = "";
    if (circle === 0) {
        query = ".big.circle";
        borderId = i;
    }
    else {
        query = ".small.circle";
        switch (circle) {
            case 0: query += ".top.left"; break;
            case 1: query += ".top.right"; break;
            case 2: query += ".bottom.right"; break;
            case 3: query += ".bottom.left"; break;
        }
    }
}

La fin de l'algorithme est plutôt triviale et nécessite tout de même un switch. Une variable "query" accumule la requête. Si le cercle est 0 c'est un grand cercle, sinon en cascade, j'ajoute la position du cercle désiré à la requête. Dans notre exemple, le dernier bit aura son itérateur i à 19 et bit vaudra '1'. Le modulo de 19 est 4 (4*4 = 16, reste 3) la bordure cible est donc celle de gauche. Le cercle vaudra 4. La requête finale vaudra donc ".small.circle.bottom.left".

Je n'ai plus qu'a créer quelques méthodes utilitaires comme le reset :

const circles = document.getElementsByClassName('circle');
function reset() {
    for (const circle of circles) {
        circle.classList.remove(...BORDERS);
    }
}

Qui utilise le spread operator ...BORDERS pour retirer toutes les classes gérant les bordures. A noter, je ne fais la requêtes des .circle qu'une fois hors de la fonctions. En effet, il n'y aura que 5 cercles que je ne recrée pas (je n'édite que leurs classes CSS). Autant épargner des cycles de recherche au moteur JS du navigateur.

A noter justement, que l'utilisation de la fonction querySelector n'est pas la plus optimale. Elle effectue une requête DOM via le moteur CSS. Dans le pire cas les 20 bordures sont actives, la requête est donc exécutées 20 fois différentes ! Une autre approche aurait été de charger en mémoire les différents HTMLElement des arcs de cercle une fois. Et de les assigner dans une tableau dans le bon ordre. Il suffirait ensuite d'itérer sur la seed et d'ajouter la classe si le bit vaut 1.

UI et contrôles

La fin de ce petit projet est consacrée à la création d'une GUI simple avec 2 possibilités pour l'utilisateur :

  • au clic sur le glyphe, une nouvelle configuration est générée.
    • ce qui affiche la "seed" (le nombre aléatoire) dans une boite de saisie (pour la sauvegarder par exemple)
  • si l'utilisateur saisi une seed manuellement, alors cette seed sera utilisée pour générer le glyphe.
  • pour garantir une bonne lisibilité, la boite de saisie peut être masquée
const seedInput = document.getElementById("seed");
function update(seed) {
    reset();
    generate(seed);
    seedInput.value = seed;
}

Une petite fonction simple va permettre de mettre à jour le glyphe, quelle que soit l'origine de la seed (click aléatoire ou saisie dans la boite de dialogue). Les cercles sont réinitialisés, puis l'ensemble de l'algorithme plus haut est appelé via la fonction generate. Enfin on affiche la seed dans la boite de dialogue .

const MAXFIGURE = 1048575;
context.addEventListener("click", e => {
    e.preventDefault();
    let seed = getRandomIntInclusive(1, MAXFIGURE);
    update(seed);
});

Le context est la div qui contient les 5 cercles. On y ajoute un simple EventListner qui va appeler update avec un nombre aléatoire généré par notre fonction du début. A noter la constante MAXFIGURE qui contient le nombre maximal de glyphes.

seedInput.addEventListener("change", e => {
    e.preventDefault();
    let seed = parseInt( seedInput.value,10);
    if(seed > MAXFIGURE) seed = MAXFIGURE;
    update(seed);
});

Et enfin, sur la boite de dialogue, sur l'évènement change, on parse le texte en nombre (et on contrôle sa valeur par rapport à MAXFIGURE) et de nouveau on appelle update.

Bilan

Ce petit projet d'été était passionnant pour moi et m'as permis d'écrire du CSS graphique de manière plus efficace. En outre, le JS était une bonne occasion de construire un algorithme dédié et assez performant. Il y aurai sans doute quelques mesures à prendre pour voir si les performances sont au rendez-vous. En outre, l'utilisation de querySelector est plus que discutable. J'espère que vous aurez autant appris que moi sur ce petit projet.