1
0
forked from ScoDoc/ScoDoc
ScoDoc/app/templates/assiduites/widgets/timeline.j2

487 lines
17 KiB
Django/Jinja

<div id="timeline">
<div class="inputs">
<input type="text" name="deb" id="deb" class="timepicker">
<input type="text" name="fin" id="fin" class="timepicker">
</div>
<div class="timeline-container" desktop="true">
<div class="period" style="left: 0%; width: 20%">
<div class="period-handle left"></div>
<div class="period-handle right"></div>
<div class="period-time">Time</div>
</div>
</div>
</div>
<script>
const SERVER_TIMEZONE_OFFSET = "{{ scu.get_local_timezone_offset() }}";
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }};
const t_mid = {{ t_mid }};
const t_end = {{ t_end }};
const tick_time = 60 / {{ tick_time }};
const tick_delay = 1 / tick_time;
const period_default = 2; // durée créneau par défaut: 2 heures
let handleMoving = false;
const isMobile = window.matchMedia("(max-width: 768px)").matches;
// Création des graduations de la timeline
// On créé des grandes graduations pour les heures
// On créé des petites graduations pour les "tick"
function createTicks() {
let i = t_start;
while (i <= t_end) {
// création d'un tick Heure (grand)
const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
timelineContainer.appendChild(hourTick);
// on ajoute un label pour l'heure (ex : 12:00)
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
tickLabel.textContent = numberToTime(i);
// on retire ce qu'il y a après les : sur mobile
if (isMobile) {
tickLabel.textContent = tickLabel.textContent.split(":")[0];
}
timelineContainer.appendChild(tickLabel);
// Si on est pas à la fin, on ajoute les graduations intermédiaires
if (i < t_end) {
let j = Math.floor(i + 1);
while (i < j) {
i += tick_delay;
if (i <= t_end) {
// création d'un tick (petit)
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${computePercentage(i, t_start)}%`;
timelineContainer.appendChild(quarterTick);
}
}
i = j;
} else {
i++;
}
}
}
// Convertit un nombre en heure
// ex : 12.5 => "12:30"
function numberToTime(num) {
const integer = Math.floor(num);
const decimal = Math.round((num % 1) * 60);
let dec = `:${decimal}`;
if (decimal < 10) {
dec = `:0${decimal}`;
}
let int = `${integer}`;
if (integer < 10) {
int = `0${integer}`;
}
return int + dec;
}
// Arrondi un nombre au tick le plus proche
function snapToQuarter(value) {
return Math.round(value * tick_time) / tick_time;
}
// Mise à jour des valeurs des timepickers
// En fonction des valeurs de la timeline
function updatePeriodTimeLabel() {
const values = getPeriodValues();
const deb = numberToTime(values[0])
const fin = numberToTime(values[1])
const text = `${deb} - ${fin}`
periodTimeLine.querySelector('.period-time').textContent = text;
//Mise à jour des inputs
try{
$('#deb').val(deb);
$('#fin').val(fin);
}catch{}
}
// Gestion des évènements de la timeline
// - Déplacement des poignées
// - Déplacement de la période
function timelineMainEvent(event) {
// Position de départ de l'événement (souris ou tactile)
const startX = (event.clientX || event.changedTouches[0].clientX);
// Vérifie si l'événement concerne une poignée de période
if (event.target.classList.contains("period-handle")) {
// Initialisation des valeurs de départ
const startWidth = parseFloat(periodTimeLine.style.width);
const startLeft = parseFloat(periodTimeLine.style.left);
const isLeftHandle = event.target.classList.contains("left");
handleMoving = true;
// Fonction de déplacement de la poignée
const onMouseMove = (moveEvent) => {
if (!handleMoving) return;
// Calcul du déplacement en pixels
const deltaX = (moveEvent.clientX || moveEvent.changedTouches[0].clientX) - startX;
const containerWidth = timelineContainer.clientWidth;
// Calcul de la nouvelle largeur en pourcentage
const newWidth = startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
if (isLeftHandle) {
// Si la poignée gauche est déplacée, ajuste également la position gauche
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, newWidth);
} else {
adjustPeriodPosition(parseFloat(periodTimeLine.style.left), newWidth);
}
// Met à jour l'étiquette de temps de la période
updatePeriodTimeLabel();
};
// Fonction de relâchement de la souris ou du tactile
// - Alignement des poignées sur les ticks
// - Appel des callbacks
// - Sauvegarde des valeurs dans le local storage
// - Réinitialisation de la variable de déplacement des poignées
const mouseUp = () => {
snapHandlesToQuarters();
timelineContainer.removeEventListener("mousemove", onMouseMove);
handleMoving = false;
func_call();
savePeriodInLocalStorage();
};
// Ajoute les écouteurs d'événement pour le déplacement et le relâchement
timelineContainer.addEventListener("mousemove", onMouseMove);
timelineContainer.addEventListener("touchmove", onMouseMove);
document.addEventListener("mouseup", mouseUp, { once: true });
document.addEventListener("touchend", mouseUp, { once: true });
// Vérifie si l'événement concerne la période elle-même
} else if (event.target === periodTimeLine) {
const startLeft = parseFloat(periodTimeLine.style.left);
// Fonction de déplacement de la période
const onMouseMove = (moveEvent) => {
if (handleMoving) return;
const deltaX = (moveEvent.clientX || moveEvent.changedTouches[0].clientX) - startX;
const containerWidth = timelineContainer.clientWidth;
// Calcul de la nouvelle position gauche en pourcentage
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, parseFloat(periodTimeLine.style.width));
updatePeriodTimeLabel();
};
// Fonction de relâchement de la souris ou du tactile
// - Alignement des poignées sur les ticks
// - Appel des callbacks
// - Sauvegarde des valeurs dans le local storage
const mouseUp = () => {
snapHandlesToQuarters();
timelineContainer.removeEventListener("mousemove", onMouseMove);
func_call();
savePeriodInLocalStorage();
};
// Ajoute les écouteurs d'événement pour le déplacement et le relâchement
timelineContainer.addEventListener("mousemove", onMouseMove);
timelineContainer.addEventListener("touchmove", onMouseMove);
document.addEventListener("mouseup", mouseUp, { once: true });
document.addEventListener("touchend", mouseUp, { once: true });
}
}
let func_call = () => { };
// Fonction initialisant la timeline
// La fonction "callback" est appelée à chaque modification de la période
function setupTimeLine(callback) {
func_call = callback;
timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e) });
timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e) });
// Initialisation des timepickers (à gauche de la timeline)
// lors d'un changement, cela met à jour la timeline
const updateFromInputs = ()=>{
let deb = $('#deb').val();
let fin = $('#fin').val();
if (deb != '' && fin != '') {
deb = fromTime(deb);
fin = fromTime(fin);
try {
setPeriodValues(deb, fin);
} catch {
setPeriodValues(...getPeriodValues());
}
}
}
$('#deb').data('TimePicker').options.change = updateFromInputs;
$('#fin').data('TimePicker').options.change = updateFromInputs;
// actualise l'affichage des inputs avec les valeurs de la timeline
updatePeriodTimeLabel();
}
// Ajuste la position de la période en fonction de la nouvelle position et largeur
// Vérifie que la période ne dépasse pas les limites de la timeline
function adjustPeriodPosition(newLeft, newWidth) {
const snappedLeft = snapToQuarter(newLeft);
const snappedWidth = snapToQuarter(newWidth);
const minLeft = 0;
const maxLeft = 100 - snappedWidth;
const clampedLeft = Math.min(Math.max(snappedLeft, minLeft), maxLeft);
periodTimeLine.style.left = `${clampedLeft}%`;
periodTimeLine.style.width = `${snappedWidth}%`;
}
// Récupère les valeurs de la période
function getPeriodValues() {
// On prend les pourcentages
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
// On calcule l'inverse des pourcentages pour obtenir les heures
const startHour = (leftPercentage / 100) * (t_end - t_start) + t_start;
const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start;
// On les arrondit aux ticks les plus proches
const startValue = snapToQuarter(startHour);
const endValue = snapToQuarter(endHour);
// on verifie que les valeurs sont bien dans les bornes
const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)];
// si les valeurs sont hors des bornes, on les ajuste
if (computedValues[0] > t_end || computedValues[1] < t_start) {
return [t_start, Math.min(t_end, t_start + period_default)];
}
// Si la période est trop petite, on l'agrandit artificiellement (il faut au moins 1 tick de largeur)
if (computedValues[1] - computedValues[0] <= tick_delay && computedValues[1] < t_end - tick_delay) {
computedValues[1] += tick_delay;
}
return computedValues;
}
// Met à jour les valeurs de la période
// Met à jour l'affichage de la timeline
// Appelle les callbacks associés
function setPeriodValues(deb, fin) {
if (fin < deb) {
throw new RangeError(`le paramètre 'deb' doit être inférieur au paramètre 'fin' ([${deb};${fin}])`)
}
if (deb < 0 || fin < 0) {
throw new RangeError(`Les paramètres doivent être des entiers positifis ([${deb};${fin}])`)
}
deb = snapToQuarter(deb);
fin = snapToQuarter(fin);
let leftPercentage = computePercentage(deb, t_start);
let widthPercentage = computePercentage(fin, deb);
periodTimeLine.style.left = `${leftPercentage}%`;
periodTimeLine.style.width = `${widthPercentage}%`;
snapHandlesToQuarters();
updatePeriodTimeLabel()
func_call();
savePeriodInLocalStorage();
}
// Aligne les poignées de la période sur les ticks les plus proches
// ex : 12h39 => 12h45 (si les ticks sont à 15min)
// evite aussi les dépassements de la timeline (max et min)
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min(computePercentage(Math.abs(periodValues[0]), t_start), computePercentage(Math.abs(t_end), tick_delay));
if (lef < 0) {
lef = 0;
}
const left = `${lef}%`;
let wid = Math.max(computePercentage(Math.abs(periodValues[1]), Math.abs(periodValues[0])), computePercentage(tick_delay, 0));
if (wid > 100) {
wid = 100;
}
const width = `${wid}%`
periodTimeLine.style.left = left;
periodTimeLine.style.width = width;
updatePeriodTimeLabel()
}
// Retourne le pourcentage d'une valeur par rapport à t_start et t_end
// ex : 12h par rapport à 8h et 20h => 25%
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100;
}
// Convertit une heure (string) en nombre
// ex : "12:30" => 12.5
function fromTime(time, separator = ":") {
const [hours, minutes] = time.split(separator).map((el) => Number(el))
return hours + minutes / 60
}
// Renvoie les valeurs de la période sous forme de date
// Les heures sont récupérées depuis la timeline
// la date est récupérée depuis un champ "#date" (datepicker)
function getPeriodAsDate(add_server_tz = false) {
let [deb, fin] = getPeriodValues();
deb = numberToTime(deb);
fin = numberToTime(fin);
const dateStr = $("#date")
.datepicker("getDate")
.format("yyyy-mm-dd")
.substring(0, 10); // récupération que de la date, pas des heures
// Les heures deb et fin sont telles qu'affichées, c'est à dire
// en heure locale DU SERVEUR (des étudiants donc)
let offset = add_server_tz ? SERVER_TIMEZONE_OFFSET : "";
return {
deb: new Date(`${dateStr}T${deb}${offset}`),
fin: new Date(`${dateStr}T${fin}${offset}`)
}
}
// Sauvegarde les valeurs de la période dans le local storage
function savePeriodInLocalStorage(){
const dates = getPeriodValues();
localStorage.setItem("sco-timeline-values", JSON.stringify(dates));
}
// Récupère les valeurs de la période depuis le local storage
// Si elles n'existent pas, on les initialise avec les valeurs par défaut
function loadPeriodFromLocalStorage(){
const dates = JSON.parse(localStorage.getItem("sco-timeline-values"));
if(dates){
setPeriodValues(...dates);
}else{
setPeriodValues(t_start, t_start + period_default);
}
}
// == Initialisation par défaut de la timeline ==
createTicks(); // création des graduations
loadPeriodFromLocalStorage(); // chargement des valeurs si disponible
// Si on donne les heures en appelant le template alors on met à jour la timeline
{% if heures %}
let [heure_deb, heure_fin] = [{{ heures | safe }}]
if (heure_deb != '' && heure_fin != '') {
heure_deb = fromTime(heure_deb);
heure_fin = fromTime(heure_fin);
setPeriodValues(heure_deb, heure_fin)
}
{% endif %}
</script>
<style>
#timeline {
display: flex;
justify-content: start;
}
.inputs {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
margin-bottom: 10px;
width: 5em;
}
.timeline-container {
width: 75%;
margin-left: 25px;
background-color: white;
border-radius: 15px;
position: relative;
height: 40px;
margin-bottom: 25px;
}
/* ... */
.tick {
position: absolute;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
}
.tick.hour {
height: 100%;
}
.tick.quarter {
height: 50%;
}
.tick-label {
position: absolute;
bottom: 0;
font-size: 12px;
text-align: center;
transform: translateY(100%) translateX(-50%);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.period {
position: absolute;
height: 100%;
background-color: var(--color-secondary);
border-radius: 15px;
}
.period-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
border-radius: 0 4px 4px 0;
cursor: col-resize;
}
.period-handle.right {
right: 0;
border-radius: 4px 0 0 4px;
}
.period .period-time {
display: none;
position: absolute;
left: calc(50% - var(--w)/2 - 5px);
justify-content: center;
align-content: center;
top: calc(-60% - 10px);
--w: 10em;
width: var(--w);
}
.period:hover .period-time {
display: flex;
background-color: var(--color-secondary);
border-radius: 15px;
padding: 5px;
}
</style>