Calcule les heures travaillées et cibles d'entrée/sortie même quand une demi-journée est en cours (pas encore de pointage de sortie), au lieu d'attendre la fin de journée. Ajoute un endpoint /refresh pour rafraîchir l'affichage sans repointer. Désambiguïse les libellés de semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
652 lines
26 KiB
HTML
652 lines
26 KiB
HTML
{% extends "base.html" %}
|
||
{% block content %}
|
||
|
||
<div class="stats-head">
|
||
<div>
|
||
<div class="stats-title">Statistiques</div>
|
||
<div class="stats-meta">
|
||
{{ stats.n_jours }} jours complets analysés
|
||
<span class="stats-sep">·</span>
|
||
{{ stats.total_weeks }} semaines
|
||
{% if stats.avg_delta_min != 0 %}
|
||
<span class="stats-sep">·</span>
|
||
solde moyen
|
||
<span class="{{ 'stat-pos' if stats.avg_delta_min > 0 else 'stat-neg' }}">{{ stats.avg_delta }}/sem</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── 3 période-conformité cards ── #}
|
||
<div class="comp-periods">
|
||
{% set periods = [
|
||
(stats.comp_globale, "Conformité globale", "Toutes périodes"),
|
||
(stats.comp_mois, "Ce mois", stats.mois_label),
|
||
(stats.comp_sem_n1, "Semaine précédente", stats.prev_week_label),
|
||
] %}
|
||
{% for comp, title, sub in periods %}
|
||
{% set pct = comp.pct %}
|
||
{% set cls = 'comp-ok' if pct is not none and pct >= 95 else ('comp-warn' if pct is not none and pct >= 80 else 'comp-bad') %}
|
||
<div class="comp-period-card">
|
||
<div class="cp-eyebrow">{{ title }}</div>
|
||
<div class="cp-sub">{{ sub }}</div>
|
||
<div class="cp-donut-wrap">
|
||
<canvas class="cp-donut" id="cpd-{{ loop.index }}" width="90" height="90"
|
||
data-pct="{{ pct if pct is not none else 0 }}"
|
||
data-has-data="{{ 'true' if pct is not none else 'false' }}"
|
||
aria-label="{{ (pct ~ '%') if pct is not none else 'pas de données' }}"></canvas>
|
||
</div>
|
||
<div class="cp-n {{ cls if pct is not none }}">
|
||
{% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes
|
||
{% else %}<span class="cp-nodata">pas de données</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{# ── Onglets de période ── #}
|
||
<div class="period-tabs-wrap">
|
||
<span class="period-tabs-label">Période d'analyse</span>
|
||
<div class="period-tabs" role="tablist">
|
||
<button class="ptab active" data-period="all" role="tab" aria-selected="true">Tout</button>
|
||
<button class="ptab" data-period="1y" role="tab">1 an</button>
|
||
<button class="ptab" data-period="3m" role="tab">3 mois</button>
|
||
<button class="ptab" data-period="1m" role="tab">1 mois</button>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── 4 time-slot cards ── #}
|
||
<div class="ts-grid">
|
||
{% set slot_meta = [
|
||
("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"),
|
||
("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"),
|
||
("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"),
|
||
("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"),
|
||
] %}
|
||
{% for slot, label, constraint, op, half in slot_meta %}
|
||
{% set ts = stats.time_stats[slot] %}
|
||
{% set comp = stats.compliance[slot] %}
|
||
<div class="ts-card ts-{{ half }}">
|
||
<div class="ts-eyebrow">{{ label }}</div>
|
||
{% if ts %}
|
||
<div class="ts-mean-row">
|
||
<div class="ts-mean" id="ts-mean-{{ slot }}">{{ ts.mean_hhmm }}</div>
|
||
{% if comp is not none %}
|
||
<div class="donut-wrap">
|
||
<canvas id="donut-{{ slot }}" class="donut-canvas" width="60" height="60"
|
||
data-pct="{{ comp }}" data-op="{{ op }}"
|
||
aria-label="{{ comp }}% {{ constraint }}"></canvas>
|
||
<div class="donut-label" id="donut-label-{{ slot }}">{{ constraint }}</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="ts-sigma" id="ts-sigma-{{ slot }}">σ = {{ ts.sigma_min }} min · n = {{ ts.n }}</div>
|
||
<canvas class="dist-canvas zoomable" id="dist-{{ slot }}" width="1" height="76"
|
||
data-chart-type="dist" data-chart-slot="{{ slot }}"
|
||
data-chart-title="{{ label }}"
|
||
aria-label="Distribution {{ label }}"></canvas>
|
||
<div class="dist-legend">
|
||
<span class="dl-item"><span class="dl-mean-line"></span>μ = <span id="dl-mean-{{ slot }}">{{ ts.mean_hhmm }}</span></span>
|
||
<span class="dl-item"><span class="dl-target-line {{ 'dl-neg' if op == 'le' else 'dl-pos' }}"></span>{{ constraint }}</span>
|
||
<span class="dl-item"><span class="dl-band ts-{{ half }}"></span>±1σ (<span id="dl-sigma-{{ slot }}">{{ ts.sigma_min }}</span> min)</span>
|
||
<span class="dl-item dl-n">n = <span id="dl-n-{{ slot }}">{{ ts.n }}</span> jours</span>
|
||
</div>
|
||
{% else %}
|
||
<div class="ts-mean ts-empty">—</div>
|
||
<div class="ts-sigma">pas de données</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{# ── Weekly balance chart ── #}
|
||
{% if stats.weekly_balances %}
|
||
<div class="wkly-section">
|
||
<div class="wkly-eyebrow">Soldes hebdomadaires <span class="wkly-note">(jours incomplets exclus)</span></div>
|
||
<div class="wkly-wrap">
|
||
<canvas id="c-weekly" class="zoomable" height="180"
|
||
data-chart-type="weekly" data-chart-title="Soldes hebdomadaires"
|
||
aria-label="Soldes hebdomadaires"></canvas>
|
||
<div class="chart-tooltip" id="wkly-tooltip"></div>
|
||
</div>
|
||
{% set has_partial = stats.weekly_balances | selectattr("partial") | list %}
|
||
{% if has_partial %}
|
||
<div class="wkly-legend">
|
||
<span class="legend-dot partial-dot"></span> semaine avec jours non renseignés
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# ── Zoom modal ── #}
|
||
<div id="chart-modal" class="chart-modal" hidden aria-modal="true" role="dialog" aria-labelledby="chart-modal-title">
|
||
<div class="chart-modal-bd" id="chart-modal-bd"></div>
|
||
<div class="chart-modal-inner">
|
||
<div class="chart-modal-header">
|
||
<span id="chart-modal-title" class="chart-modal-title"></span>
|
||
<button class="chart-modal-close" id="chart-modal-close" aria-label="Fermer">✕</button>
|
||
</div>
|
||
<div class="chart-modal-body">
|
||
<canvas id="chart-modal-canvas"></canvas>
|
||
<div class="chart-tooltip" id="modal-tooltip"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const DATA = {{ data_json | safe }};
|
||
|
||
const C = {
|
||
pos: '#2A5E42', neg: '#9B3528',
|
||
accent: '#2F5FA0', accent2: '#B86A08', warn: '#B86A08',
|
||
rule: '#D8D0C0', ruleL: '#EAE5DA',
|
||
text: '#1C1712', muted: '#6B6152',
|
||
ground: '#F4F0E8', surface: '#FDFAF4',
|
||
};
|
||
const MONO = "'DM Mono', 'Cascadia Code', ui-monospace, monospace";
|
||
const SLOTS = ['matin_entree', 'matin_sortie', 'aprem_entree', 'aprem_sortie'];
|
||
const SLOT_COLOR = { matin_entree: C.accent, matin_sortie: C.accent, aprem_entree: C.accent2, aprem_sortie: C.accent2 };
|
||
const SLOT_TARGET = { matin_entree: [9*60,'le'], matin_sortie: [12*60,'ge'], aprem_entree: [14*60,'le'], aprem_sortie: [17*60,'ge'] };
|
||
|
||
let currentPeriod = 'all';
|
||
|
||
/* ─── Period filtering ─── */
|
||
function getCutoff(period) {
|
||
if (period === 'all') return null;
|
||
const d = new Date(DATA.today);
|
||
if (period === '1m') d.setMonth(d.getMonth() - 1);
|
||
else if (period === '3m') d.setMonth(d.getMonth() - 3);
|
||
else if (period === '1y') d.setFullYear(d.getFullYear() - 1);
|
||
return d;
|
||
}
|
||
|
||
function filterVals(slot, period) {
|
||
const dv = DATA.time_stats[slot]?.dated_values;
|
||
if (!dv) return [];
|
||
const cutoff = getCutoff(period);
|
||
return cutoff
|
||
? dv.filter(x => new Date(x.d) >= cutoff).map(x => x.v)
|
||
: dv.map(x => x.v);
|
||
}
|
||
|
||
function filterWeekly(period) {
|
||
const cutoff = getCutoff(period);
|
||
if (!cutoff) return DATA.weekly_balances;
|
||
return DATA.weekly_balances.filter(b => new Date(b.date) >= cutoff);
|
||
}
|
||
|
||
/* ─── Stats helpers ─── */
|
||
function _mean(arr) { return arr.length ? arr.reduce((a,b) => a+b,0) / arr.length : 0; }
|
||
function _sigma(arr) {
|
||
if (arr.length < 2) return 0;
|
||
const m = _mean(arr);
|
||
return Math.sqrt(arr.reduce((a,b) => a+(b-m)**2, 0) / (arr.length-1));
|
||
}
|
||
function _fmtMin(m) {
|
||
const h = Math.floor(m/60), mm = m%60;
|
||
return `${h.toString().padStart(2,'0')}:${mm.toString().padStart(2,'0')}`;
|
||
}
|
||
function _compPct(vals, target, op) {
|
||
if (!vals.length) return null;
|
||
const ok = vals.filter(v => op === 'le' ? v <= target : v >= target).length;
|
||
return Math.round(100 * ok / vals.length);
|
||
}
|
||
|
||
/* ─── Rounded bar helper: flat at the baseline, rounded at the growing end ─── */
|
||
function drawBarFromBaseline(ctx, x, w, baseline, far, r) {
|
||
const grows_up = far < baseline;
|
||
const top = grows_up ? far : baseline;
|
||
const h = Math.abs(baseline - far);
|
||
r = Math.max(0, Math.min(r, w / 2, h));
|
||
if (r <= 0.5 || h <= 0) { ctx.fillRect(x, top, w, h); return; }
|
||
ctx.beginPath();
|
||
if (grows_up) {
|
||
ctx.moveTo(x, top + h);
|
||
ctx.lineTo(x, top + r);
|
||
ctx.arcTo(x, top, x + r, top, r);
|
||
ctx.lineTo(x + w - r, top);
|
||
ctx.arcTo(x + w, top, x + w, top + r, r);
|
||
ctx.lineTo(x + w, top + h);
|
||
} else {
|
||
ctx.moveTo(x, top);
|
||
ctx.lineTo(x + w, top);
|
||
ctx.lineTo(x + w, top + h - r);
|
||
ctx.arcTo(x + w, top + h, x + w - r, top + h, r);
|
||
ctx.lineTo(x + r, top + h);
|
||
ctx.arcTo(x, top + h, x, top + h - r, r);
|
||
ctx.lineTo(x, top);
|
||
}
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
|
||
/* ─── Donut ─── */
|
||
function drawDonut(canvas, pct, op, progress) {
|
||
const W = canvas.width, H = canvas.height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, W, H);
|
||
const cx = W/2, cy = H/2;
|
||
const outerR = Math.min(W,H)/2 - 2;
|
||
const lw = outerR * 0.38;
|
||
const r = outerR - lw/2;
|
||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2*Math.PI);
|
||
ctx.strokeStyle = C.ruleL; ctx.lineWidth = lw; ctx.stroke();
|
||
if (pct === null) {
|
||
ctx.fillStyle = C.muted; ctx.font = `400 ${Math.round(W*0.19)}px ${MONO}`;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('—', cx, cy);
|
||
return;
|
||
}
|
||
const color = pct >= 95 ? C.pos : (pct >= 80 ? C.warn : C.neg);
|
||
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI/2, -Math.PI/2 + 2*Math.PI*(pct/100)*progress);
|
||
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = pct >= 100 ? 'butt' : 'round'; ctx.stroke();
|
||
if (progress >= 1) {
|
||
ctx.fillStyle = color;
|
||
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(pct + '%', cx, cy);
|
||
}
|
||
}
|
||
|
||
function animateDonut(canvas, pct, op) {
|
||
if (pct === null) { drawDonut(canvas, null, op, 1); return; }
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { drawDonut(canvas, pct, op, 1); return; }
|
||
const DUR = 600; let t0 = null;
|
||
function step(ts) {
|
||
if (!t0) t0 = ts;
|
||
const p = Math.min((ts-t0)/DUR, 1);
|
||
drawDonut(canvas, pct, op, 1-Math.pow(1-p,3));
|
||
if (p < 1) requestAnimationFrame(step);
|
||
}
|
||
requestAnimationFrame(step);
|
||
}
|
||
|
||
/* ─── Distribution core ─── */
|
||
function _drawDistVals(canvas, vals, mean, sigma, target, op, color, enlarged) {
|
||
if (!vals || !vals.length) return;
|
||
const W = canvas.width, H = canvas.height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
const lo = Math.min(...vals, target) - 15;
|
||
const hi = Math.max(...vals, target) + 15;
|
||
const range = hi - lo || 1;
|
||
const toX = v => ((v - lo) / range) * W;
|
||
const PAD_T = 4, PAD_B = H > 60 ? 20 : 4, chartH = H - PAD_T - PAD_B;
|
||
const targetColor = op === 'le' ? C.neg : C.pos;
|
||
|
||
const binSize = 5;
|
||
const nBins = Math.ceil((hi-lo)/binSize) + 1;
|
||
const bins = new Array(nBins).fill(0);
|
||
vals.forEach(v => { const b = Math.min(Math.floor((v-lo)/binSize), nBins-1); bins[b]++; });
|
||
const maxBin = Math.max(...bins, 1);
|
||
|
||
// σ band
|
||
ctx.fillStyle = color === C.accent ? 'rgba(47,95,160,.09)' : 'rgba(184,106,8,.09)';
|
||
const sLo = Math.max(0, toX(mean-sigma)), sHi = Math.min(W, toX(mean+sigma));
|
||
ctx.fillRect(sLo, 0, sHi-sLo, PAD_T+chartH);
|
||
|
||
// Bars
|
||
const bw = Math.max(1, (W/nBins) - 0.5);
|
||
bins.forEach((count, i) => {
|
||
if (!count) return;
|
||
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
|
||
ctx.fillStyle = color + 'BB';
|
||
drawBarFromBaseline(ctx, x, bw, PAD_T + chartH, PAD_T + chartH - bh, 2);
|
||
});
|
||
|
||
// Baseline
|
||
ctx.strokeStyle = C.rule; ctx.lineWidth = 0.5;
|
||
ctx.beginPath(); ctx.moveTo(0, PAD_T+chartH); ctx.lineTo(W, PAD_T+chartH); ctx.stroke();
|
||
|
||
// Target line
|
||
const tx = toX(target);
|
||
if (tx >= 0 && tx <= W) {
|
||
ctx.save(); ctx.strokeStyle = targetColor; ctx.lineWidth = 1.5; ctx.setLineDash([3,3]);
|
||
ctx.beginPath(); ctx.moveTo(tx, PAD_T); ctx.lineTo(tx, PAD_T+chartH); ctx.stroke(); ctx.restore();
|
||
}
|
||
|
||
// Mean line
|
||
ctx.strokeStyle = C.text; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(toX(mean), PAD_T); ctx.lineTo(toX(mean), PAD_T+chartH); ctx.stroke();
|
||
|
||
// X axis labels
|
||
if (PAD_B > 10) {
|
||
const tickStep = enlarged ? 15 : 30;
|
||
const firstTick = Math.ceil(lo/tickStep)*tickStep;
|
||
const labelY = PAD_T+chartH+5;
|
||
const fs = Math.max(7, Math.min(9, Math.floor(W/28)));
|
||
ctx.textBaseline = 'top';
|
||
|
||
let ticks;
|
||
if (enlarged) {
|
||
ticks = new Set();
|
||
for (let t = firstTick; t <= hi; t += tickStep) {
|
||
if (toX(t) >= 2 && toX(t) <= W-2) ticks.add(t);
|
||
}
|
||
} else {
|
||
ticks = new Set([Math.round(target/tickStep)*tickStep]);
|
||
let lastX = -999;
|
||
for (let t = firstTick; t <= hi; t += tickStep) {
|
||
const x = toX(t);
|
||
if (x >= 4 && x <= W-4 && x-lastX >= 30) { ticks.add(t); lastX = x; }
|
||
}
|
||
}
|
||
|
||
ticks.forEach(t => {
|
||
const x = toX(t);
|
||
if (x < 0 || x > W) return;
|
||
const isTarget = (t === target);
|
||
const isMean = !isTarget && Math.abs(t - Math.round(mean/tickStep)*tickStep) < 2;
|
||
ctx.textAlign = x < 16 ? 'left' : (x > W-16 ? 'right' : 'center');
|
||
ctx.fillStyle = isTarget ? targetColor : (isMean ? C.text : C.muted);
|
||
ctx.font = `${isTarget||isMean?'500':'400'} ${fs}px ${MONO}`;
|
||
ctx.strokeStyle = isTarget ? targetColor : C.rule;
|
||
ctx.lineWidth = isTarget ? 1 : 0.5;
|
||
ctx.beginPath(); ctx.moveTo(x, PAD_T+chartH); ctx.lineTo(x, PAD_T+chartH+3); ctx.stroke();
|
||
ctx.fillText(_fmtMin(t), x, labelY);
|
||
});
|
||
}
|
||
}
|
||
|
||
function _drawDist(canvas, slot, enlarged) {
|
||
const stat = DATA.time_stats[slot];
|
||
if (!stat) return;
|
||
const vals = filterVals(slot, currentPeriod);
|
||
if (!vals.length) return;
|
||
const mean = _mean(vals), sigma = _sigma(vals);
|
||
const [target, op] = SLOT_TARGET[slot];
|
||
_drawDistVals(canvas, vals, mean, sigma, target, op, SLOT_COLOR[slot], enlarged);
|
||
}
|
||
|
||
function drawDist(slot) {
|
||
const c = document.getElementById('dist-'+slot);
|
||
if (!c) return;
|
||
c.width = c.parentElement.clientWidth || 240;
|
||
_drawDist(c, slot, false);
|
||
}
|
||
|
||
/* ─── Weekly bars ─── */
|
||
function _drawWeekly(canvas, balances, hoverIndex) {
|
||
if (!balances || !balances.length) return;
|
||
const W = canvas.width, H = canvas.height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
const PAD_L = 42, PAD_R = 12, PAD_T = 12, PAD_B = 42;
|
||
const chartW = W-PAD_L-PAD_R, chartH = H-PAD_T-PAD_B;
|
||
const midY = PAD_T+chartH/2;
|
||
const maxAbs = Math.max(...balances.map(b => Math.abs(b.delta_min)), 1);
|
||
const axisMax = Math.ceil(maxAbs/30)*30;
|
||
const toY = v => midY - (v/axisMax)*(chartH/2);
|
||
const barSlot = chartW/balances.length;
|
||
const barW = Math.max(4, Math.min(32, barSlot-3));
|
||
|
||
ctx.fillStyle = C.surface; ctx.fillRect(PAD_L, PAD_T, chartW, chartH);
|
||
|
||
ctx.strokeStyle = C.ruleL; ctx.lineWidth = 0.5;
|
||
for (let v = 30; v <= axisMax; v += 30) {
|
||
[toY(v), toY(-v)].forEach(y => {
|
||
ctx.beginPath(); ctx.moveTo(PAD_L,y); ctx.lineTo(PAD_L+chartW,y); ctx.stroke();
|
||
});
|
||
}
|
||
ctx.strokeStyle = C.rule; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(PAD_L,midY); ctx.lineTo(PAD_L+chartW,midY); ctx.stroke();
|
||
|
||
ctx.fillStyle = C.muted; ctx.font = `9px ${MONO}`; ctx.textAlign = 'right';
|
||
for (let v = 60; v <= axisMax; v += 60) {
|
||
const h = Math.floor(v/60);
|
||
ctx.fillText(`+${h}h`, PAD_L-5, toY(v)+3);
|
||
ctx.fillText(`-${h}h`, PAD_L-5, toY(-v)+3);
|
||
}
|
||
ctx.fillText('0', PAD_L-5, midY+3);
|
||
|
||
const layout = [];
|
||
balances.forEach((b, i) => {
|
||
const x = PAD_L+i*barSlot+(barSlot-barW)/2;
|
||
const hPx = (Math.abs(b.delta_min)/axisMax)*(chartH/2);
|
||
const far = b.delta_min >= 0 ? midY-hPx : midY+hPx;
|
||
layout.push({ x, w: barW, top: Math.min(midY, far), bottom: Math.max(midY, far), b });
|
||
const hovered = i === hoverIndex;
|
||
const baseColor = b.delta_min >= 0 ? C.pos : C.neg;
|
||
const alpha = hovered ? 'FF' : (b.partial ? '55' : 'CC');
|
||
if (b.delta_min >= 0) {
|
||
const g = ctx.createLinearGradient(x, midY-hPx, x, midY);
|
||
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(hovered ? 'AA' : (b.partial?'33':'88')));
|
||
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY-hPx, 4);
|
||
} else {
|
||
const g = ctx.createLinearGradient(x, midY, x, midY+hPx);
|
||
g.addColorStop(0, baseColor+(hovered ? 'AA' : (b.partial?'33':'88'))); g.addColorStop(1, baseColor+alpha);
|
||
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY+hPx, 4);
|
||
}
|
||
if (b.partial && hPx > 2) {
|
||
const yEdge = b.delta_min >= 0 ? midY-hPx : midY+hPx;
|
||
ctx.save(); ctx.strokeStyle = baseColor+'88'; ctx.lineWidth=1; ctx.setLineDash([2,2]);
|
||
ctx.beginPath(); ctx.moveTo(x,yEdge); ctx.lineTo(x+barW,yEdge); ctx.stroke(); ctx.restore();
|
||
}
|
||
if (hovered) {
|
||
ctx.save(); ctx.strokeStyle = baseColor; ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(x+0.75, Math.min(midY,far)+0.75, Math.max(0,barW-1.5), Math.max(0,hPx-1.5));
|
||
ctx.restore();
|
||
}
|
||
});
|
||
canvas.__wklyLayout = { layout, PAD_L, PAD_R, chartW };
|
||
|
||
ctx.font = `9px ${MONO}`; ctx.fillStyle = C.muted;
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||
balances.forEach((b, i) => {
|
||
const x = PAD_L+i*barSlot+barSlot/2, y = PAD_T+chartH+8;
|
||
ctx.save(); ctx.translate(x,y); ctx.rotate(-Math.PI/4); ctx.fillText(b.label,0,0); ctx.restore();
|
||
});
|
||
|
||
ctx.strokeStyle = C.rule; ctx.lineWidth = 1;
|
||
ctx.strokeRect(PAD_L, PAD_T, chartW, chartH);
|
||
}
|
||
|
||
function drawWeekly() {
|
||
const c = document.getElementById('c-weekly');
|
||
if (!c) return;
|
||
c.width = c.parentElement.clientWidth || 800;
|
||
const balances = filterWeekly(currentPeriod);
|
||
c.__wklyBalances = balances;
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
_drawWeekly(c, balances); return;
|
||
}
|
||
const DUR = 500; let t0 = null;
|
||
function step(ts) {
|
||
if (!t0) t0 = ts;
|
||
const p = Math.min((ts-t0)/DUR, 1);
|
||
const ease = 1-Math.pow(1-p,3);
|
||
// Animate bar heights by scaling delta_min
|
||
const scaled = balances.map(b => ({...b, delta_min: b.delta_min * ease}));
|
||
_drawWeekly(c, scaled);
|
||
if (p < 1) requestAnimationFrame(step);
|
||
}
|
||
requestAnimationFrame(step);
|
||
}
|
||
|
||
/* ─── Weekly hover: tooltip + bar lift, hit target = the bar's whole column ─── */
|
||
function attachWeeklyHover(canvas, tooltipEl) {
|
||
if (!canvas || !tooltipEl || canvas.__hoverAttached) return;
|
||
canvas.__hoverAttached = true;
|
||
let hoverIndex = -1;
|
||
|
||
function clear() {
|
||
const balances = canvas.__wklyBalances;
|
||
if (balances && hoverIndex !== -1) _drawWeekly(canvas, balances, -1);
|
||
hoverIndex = -1;
|
||
tooltipEl.classList.remove('visible');
|
||
}
|
||
|
||
canvas.addEventListener('mousemove', evt => {
|
||
const info = canvas.__wklyLayout, balances = canvas.__wklyBalances;
|
||
if (!info || !balances || !balances.length) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
|
||
const xCanvas = (evt.clientX - rect.left) * scaleX;
|
||
const { PAD_L, chartW } = info;
|
||
if (xCanvas < PAD_L || xCanvas > PAD_L + chartW) { clear(); return; }
|
||
|
||
const barSlot = chartW / balances.length;
|
||
const idx = Math.min(balances.length - 1, Math.max(0, Math.floor((xCanvas - PAD_L) / barSlot)));
|
||
if (idx !== hoverIndex) { hoverIndex = idx; _drawWeekly(canvas, balances, idx); }
|
||
|
||
const b = balances[idx];
|
||
const sign = b.delta_min > 0 ? '+' : (b.delta_min < 0 ? '−' : '');
|
||
const abs = Math.abs(b.delta_min);
|
||
tooltipEl.replaceChildren();
|
||
const valSpan = document.createElement('span');
|
||
valSpan.className = 'ct-val';
|
||
valSpan.textContent = `${sign}${Math.floor(abs/60)}h${String(abs%60).padStart(2,'0')}`;
|
||
const labelSpan = document.createElement('span');
|
||
labelSpan.className = 'ct-label';
|
||
labelSpan.textContent = b.label + (b.partial ? ' · incomplète' : '');
|
||
tooltipEl.append(valSpan, labelSpan);
|
||
tooltipEl.classList.add('visible');
|
||
|
||
const entry = canvas.__wklyLayout.layout[idx];
|
||
tooltipEl.style.left = ((entry.x + entry.w/2) / scaleX) + 'px';
|
||
tooltipEl.style.top = (entry.top / scaleY - 6) + 'px';
|
||
});
|
||
canvas.addEventListener('mouseleave', clear);
|
||
}
|
||
|
||
/* ─── Update everything for a period ─── */
|
||
function updateForPeriod(period) {
|
||
currentPeriod = period;
|
||
|
||
SLOTS.forEach(slot => {
|
||
if (!DATA.time_stats[slot]) return;
|
||
const vals = filterVals(slot, period);
|
||
const [target, op] = SLOT_TARGET[slot];
|
||
|
||
if (!vals.length) {
|
||
const meanEl = document.getElementById('ts-mean-'+slot);
|
||
if (meanEl) meanEl.textContent = '—';
|
||
const sigmaEl = document.getElementById('ts-sigma-'+slot);
|
||
if (sigmaEl) sigmaEl.textContent = 'pas de données pour cette période';
|
||
const donutEl = document.getElementById('donut-'+slot);
|
||
if (donutEl) drawDonut(donutEl, null, op, 1);
|
||
return;
|
||
}
|
||
|
||
const m = _mean(vals), s = _sigma(vals);
|
||
const pct = _compPct(vals, target, op);
|
||
|
||
// Update text
|
||
const meanEl = document.getElementById('ts-mean-'+slot);
|
||
if (meanEl) meanEl.textContent = _fmtMin(Math.round(m));
|
||
const sigmaEl = document.getElementById('ts-sigma-'+slot);
|
||
if (sigmaEl) sigmaEl.textContent = `σ = ${Math.round(s)} min · n = ${vals.length}`;
|
||
|
||
// Update legend
|
||
const dlMean = document.getElementById('dl-mean-'+slot);
|
||
if (dlMean) dlMean.textContent = _fmtMin(Math.round(m));
|
||
const dlSigma = document.getElementById('dl-sigma-'+slot);
|
||
if (dlSigma) dlSigma.textContent = Math.round(s);
|
||
const dlN = document.getElementById('dl-n-'+slot);
|
||
if (dlN) dlN.textContent = vals.length;
|
||
|
||
// Redraw distribution
|
||
const c = document.getElementById('dist-'+slot);
|
||
if (c) {
|
||
c.width = c.parentElement.clientWidth || 240;
|
||
_drawDistVals(c, vals, m, s, target, op, SLOT_COLOR[slot], false);
|
||
}
|
||
|
||
// Redraw donut
|
||
const donutEl = document.getElementById('donut-'+slot);
|
||
if (donutEl) drawDonut(donutEl, pct, op, 1);
|
||
});
|
||
|
||
drawWeekly();
|
||
}
|
||
|
||
/* ─── Zoom modal ─── */
|
||
function openModal(sourceCanvas) {
|
||
const type = sourceCanvas.dataset.chartType;
|
||
const slot = sourceCanvas.dataset.chartSlot;
|
||
const title = sourceCanvas.dataset.chartTitle || '';
|
||
const mc = document.getElementById('chart-modal-canvas');
|
||
const maxW = Math.floor(Math.min(window.innerWidth*0.88, 1050));
|
||
|
||
document.getElementById('chart-modal-title').textContent = title;
|
||
|
||
document.getElementById('modal-tooltip').classList.remove('visible');
|
||
if (type === 'dist') {
|
||
mc.width = maxW; mc.height = 180;
|
||
mc.__wklyBalances = null;
|
||
_drawDist(mc, slot, true);
|
||
} else if (type === 'weekly') {
|
||
mc.width = maxW; mc.height = 300;
|
||
const balances = filterWeekly(currentPeriod);
|
||
mc.__wklyBalances = balances;
|
||
_drawWeekly(mc, balances);
|
||
attachWeeklyHover(mc, document.getElementById('modal-tooltip'));
|
||
}
|
||
|
||
document.getElementById('chart-modal').removeAttribute('hidden');
|
||
document.body.style.overflow = 'hidden';
|
||
document.getElementById('chart-modal-close').focus();
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('chart-modal').setAttribute('hidden', '');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
/* ─── Init ─── */
|
||
function init() {
|
||
[1,2,3].forEach(i => {
|
||
const el = document.getElementById('cpd-'+i);
|
||
if (!el) return;
|
||
el.dataset.hasData === 'true'
|
||
? animateDonut(el, parseInt(el.dataset.pct), 'day')
|
||
: (function(){
|
||
const ctx = el.getContext('2d');
|
||
const W=el.width,H=el.height,cx=W/2,cy=H/2;
|
||
const r=(Math.min(W,H)/2-2)*0.69;
|
||
ctx.beginPath(); ctx.arc(cx,cy,r,0,2*Math.PI);
|
||
ctx.strokeStyle=C.ruleL; ctx.lineWidth=(Math.min(W,H)/2-2)*0.38; ctx.stroke();
|
||
ctx.fillStyle=C.muted; ctx.font=`400 13px ${MONO}`;
|
||
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('—',cx,cy);
|
||
})();
|
||
});
|
||
|
||
SLOTS.forEach(slot => {
|
||
drawDist(slot);
|
||
const el = document.getElementById('donut-'+slot);
|
||
if (el) animateDonut(el, parseInt(el.dataset.pct), el.dataset.op);
|
||
});
|
||
drawWeekly();
|
||
attachWeeklyHover(document.getElementById('c-weekly'), document.getElementById('wkly-tooltip'));
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else { init(); }
|
||
|
||
// Tabs
|
||
document.querySelectorAll('.ptab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.ptab').forEach(b => {
|
||
b.classList.remove('active'); b.setAttribute('aria-selected','false');
|
||
});
|
||
btn.classList.add('active'); btn.setAttribute('aria-selected','true');
|
||
updateForPeriod(btn.dataset.period);
|
||
});
|
||
});
|
||
|
||
// Zoom
|
||
document.addEventListener('click', e => { const c = e.target.closest('canvas.zoomable'); if (c) openModal(c); });
|
||
document.getElementById('chart-modal-close').addEventListener('click', closeModal);
|
||
document.getElementById('chart-modal-bd').addEventListener('click', closeModal);
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
||
|
||
let resizeTimer;
|
||
window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(init, 120); });
|
||
})();
|
||
</script>
|
||
|
||
{% endblock %}
|