forked from ScoDoc/ScoDoc
487 lines
17 KiB
Django/Jinja
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> |