diff options
Diffstat (limited to 'templates/stats.html')
| -rw-r--r-- | templates/stats.html | 268 |
1 files changed, 268 insertions, 0 deletions
diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..7b54634 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} + +{% block title %}Stats · Lumière{% endblock %} + +{% block content %} + <section class="page-heading"> + <p class="eyebrow">Stats</p> + <h1>Watching patterns</h1> + </section> + + <section class="stats-layout"> + <section class="stats-panel stats-panel-wide"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">World Map</p> + <h2>Films per country</h2> + </div> + </div> + <div id="world-map" class="stats-map" aria-label="World map of watched films"></div> + <div id="map-readout" class="map-readout" aria-live="polite"> + <strong>Hover a country</strong> + <span>Film count will appear here.</span> + </div> + <div id="map-tooltip" class="stats-tooltip" hidden></div> + </section> + + <section class="stats-panel stats-panel-wide"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Heatmap</p> + <h2>Past 365 days</h2> + </div> + </div> + <div id="watch-heatmap" class="heatmap-shell" aria-label="Daily watch heatmap"></div> + </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Directors</p> + <h2>Most watched</h2> + </div> + </div> + <ol id="top-directors" class="stats-list"></ol> + </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Stars</p> + <h2>Distribution</h2> + </div> + </div> + <div id="star-distribution" class="stats-bars"></div> + </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Rewatch</p> + <h2>Rate</h2> + </div> + </div> + <div id="rewatch-rate" class="stats-metric"></div> + </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Companions</p> + <h2>Watched with</h2> + </div> + </div> + <ol id="watched-with" class="stats-list"></ol> + </section> + </section> +{% endblock %} + +{% block scripts %} + <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js"></script> + <script> + const PANEL_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel").trim(); + const PANEL_SOFT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel-soft").trim(); + const ACCENT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(); + + function heatmapColor(count) { + if (count <= 0) return PANEL_SOFT_COLOR; + if (count === 1) return "rgba(240, 184, 77, 0.35)"; + if (count === 2) return "rgba(240, 184, 77, 0.65)"; + return ACCENT_COLOR; + } + + function formatMonthLabel(dateString) { + return new Date(`${dateString}T00:00:00`).toLocaleDateString(undefined, { month: "short" }); + } + + function formatCountryCode(value) { + return String(value).padStart(3, "0"); + } + + function renderLists(data) { + const topDirectors = document.getElementById("top-directors"); + topDirectors.innerHTML = data.most_watched_directors.slice(0, 8).map((item) => ` + <li><span>${item.director}</span><strong>${item.count}</strong></li> + `).join(""); + + const watchedWith = document.getElementById("watched-with"); + watchedWith.innerHTML = data.watched_with_breakdown.slice(0, 8).map((item) => ` + <li><span>${item.watched_with}</span><strong>${item.count}</strong></li> + `).join(""); + + const starDistribution = document.getElementById("star-distribution"); + const maxStars = Math.max(1, ...data.star_distribution.map((item) => item.count)); + starDistribution.innerHTML = data.star_distribution.map((item) => ` + <div class="stats-bar-row"> + <span>${item.stars} star</span> + <div class="stats-bar-track"><div class="stats-bar-fill" style="width:${(item.count / maxStars) * 100}%"></div></div> + <strong>${item.count}</strong> + </div> + `).join(""); + + const rewatchRate = document.getElementById("rewatch-rate"); + const rate = data.rewatch_rate.total_watched ? Math.round(data.rewatch_rate.rate * 100) : 0; + rewatchRate.innerHTML = ` + <strong>${rate}%</strong> + <span>${data.rewatch_rate.rewatched} of ${data.rewatch_rate.total_watched} watched films were rewatches.</span> + `; + } + + function renderHeatmap(days) { + const container = document.getElementById("watch-heatmap"); + const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const parsedDays = days.map((item) => { + const date = new Date(`${item.date}T00:00:00`); + return { ...item, dateObj: date }; + }); + + const firstWeekStart = new Date(parsedDays[0].dateObj); + const mondayOffset = (firstWeekStart.getDay() + 6) % 7; + firstWeekStart.setDate(firstWeekStart.getDate() - mondayOffset); + + const monthLabels = []; + const seenMonths = new Set(); + const cells = parsedDays.map((item) => { + const diffDays = Math.floor((item.dateObj - firstWeekStart) / 86400000); + const week = Math.floor(diffDays / 7); + const weekday = (item.dateObj.getDay() + 6) % 7; + const monthKey = item.date.slice(0, 7); + if (!seenMonths.has(monthKey)) { + seenMonths.add(monthKey); + monthLabels.push({ week, label: formatMonthLabel(item.date) }); + } + return ` + <button + class="heatmap-cell" + style="grid-column:${week + 1};grid-row:${weekday + 1};background:${heatmapColor(item.count)}" + title="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}" + aria-label="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}" + type="button" + ></button> + `; + }).join(""); + + container.innerHTML = ` + <div class="heatmap-months"> + ${monthLabels.map((item) => `<span style="grid-column:${item.week + 1}">${item.label}</span>`).join("")} + </div> + <div class="heatmap-body"> + <div class="heatmap-weekdays"> + ${weekdays.map((label) => `<span>${label}</span>`).join("")} + </div> + <div class="heatmap-grid">${cells}</div> + </div> + `; + } + + async function renderMap(data) { + const container = document.getElementById("world-map"); + const readout = document.getElementById("map-readout"); + const tooltip = document.getElementById("map-tooltip"); + const [worldResponse, namesResponse] = await Promise.all([ + fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"), + fetch("https://cdn.jsdelivr.net/gh/lukes/ISO-3166-Countries-with-Regional-Codes@master/all/all.json"), + ]); + const [world, countryRows] = await Promise.all([worldResponse.json(), namesResponse.json()]); + const counts = new Map(data.films_per_country_codes.map((item) => [String(item.code), item.count])); + const labels = data.country_labels_by_code || {}; + const names = countryRows.reduce((map, row) => { + map.set(formatCountryCode(row["country-code"]), row.name); + return map; + }, new Map()); + const maxCount = Math.max(1, ...data.films_per_country_codes.map((item) => item.count)); + + const width = container.clientWidth || 960; + const height = Math.min(520, Math.max(320, Math.round(width * 0.55))); + const projection = d3.geoNaturalEarth1().fitSize([width, height], topojson.feature(world, world.objects.countries)); + const path = d3.geoPath(projection); + const color = d3.scaleLinear().domain([0, maxCount]).range([PANEL_COLOR, ACCENT_COLOR]); + + const svg = d3.create("svg") + .attr("viewBox", `0 0 ${width} ${height}`) + .attr("role", "img") + .attr("aria-label", "World map shaded by films watched"); + let activeCountryPath = null; + + function updateReadout(name, count) { + readout.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`; + } + + function resetMapHover() { + if (activeCountryPath) { + activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7); + activeCountryPath = null; + } + readout.innerHTML = `<strong>Hover a country</strong><span>Film count will appear here.</span>`; + tooltip.hidden = true; + } + + svg.append("g") + .selectAll("path") + .data(topojson.feature(world, world.objects.countries).features) + .join("path") + .attr("d", path) + .attr("fill", (feature) => color(counts.get(String(feature.id)) || 0)) + .attr("stroke", "rgba(244, 239, 230, 0.18)") + .attr("stroke-width", 0.7) + .style("cursor", "pointer") + .on("pointerenter", function(event, feature) { + const count = counts.get(String(feature.id)) || 0; + const featureCode = formatCountryCode(feature.id); + const name = names.get(featureCode) || labels[String(feature.id)] || `Country ${feature.id}`; + if (activeCountryPath) { + activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7); + } + activeCountryPath = d3.select(this); + activeCountryPath.attr("stroke", ACCENT_COLOR).attr("stroke-width", 1.4); + updateReadout(name, count); + tooltip.hidden = false; + tooltip.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`; + tooltip.style.left = `${event.pageX + 12}px`; + tooltip.style.top = `${event.pageY + 12}px`; + }) + .on("pointermove", function(event) { + tooltip.style.left = `${event.pageX + 12}px`; + tooltip.style.top = `${event.pageY + 12}px`; + }) + .on("pointerleave", function() { + resetMapHover(); + }); + + container.innerHTML = ""; + container.appendChild(svg.node()); + svg.on("pointerleave", resetMapHover); + container.addEventListener("mouseleave", resetMapHover); + } + + async function bootStats() { + const response = await fetch("/stats/data"); + const data = await response.json(); + renderLists(data); + renderHeatmap(data.films_per_day_365); + await renderMap(data); + } + + bootStats(); + </script> +{% endblock %} |
