117 lines
2.6 KiB
Svelte
117 lines
2.6 KiB
Svelte
<script lang="ts">
|
|
import type { PresenceHourly } from "#lib/api/types.js";
|
|
|
|
interface Props {
|
|
hourly: PresenceHourly[];
|
|
}
|
|
|
|
const { hourly }: Props = $props();
|
|
|
|
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
|
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
|
|
const HOUR_TICKS = new Set([0, 6, 12, 18]);
|
|
|
|
interface Cell {
|
|
online: number;
|
|
total: number;
|
|
}
|
|
|
|
const grid = $derived.by(() => {
|
|
const cells: Cell[][] = DAYS.map(() =>
|
|
HOURS.map(() => ({ online: 0, total: 0 }))
|
|
);
|
|
for (const bucket of hourly) {
|
|
const date = new Date(bucket.bucket);
|
|
const day = (date.getDay() + 6) % 7;
|
|
const cell = cells[day]?.[date.getHours()];
|
|
if (cell) {
|
|
cell.online += bucket.online_samples;
|
|
cell.total += bucket.samples;
|
|
}
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
function intensity(cell: Cell): number {
|
|
return cell.total > 0 ? cell.online / cell.total : 0;
|
|
}
|
|
|
|
function cellTitle(day: number, hour: number, cell: Cell): string {
|
|
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
|
|
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
|
|
}
|
|
</script>
|
|
|
|
<div class="heatmap">
|
|
{#each grid as row, day (day)}
|
|
<div class="heat-row">
|
|
<span class="day-label">{DAYS[day]}</span>
|
|
<div class="cells">
|
|
{#each row as cell, hour (hour)}
|
|
<span
|
|
class="cell"
|
|
title={cellTitle(day, hour, cell)}
|
|
style:--i={intensity(cell)}
|
|
class:empty={cell.total === 0}
|
|
></span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
<div class="heat-row axis">
|
|
<span class="day-label"></span>
|
|
<div class="cells">
|
|
{#each HOURS as hour (hour)}
|
|
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.heatmap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
|
|
.heat-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.day-label {
|
|
width: 1.5rem;
|
|
flex-shrink: 0;
|
|
font-size: 0.6875rem;
|
|
color: var(--color-text-secondary);
|
|
text-align: right;
|
|
}
|
|
|
|
.cells {
|
|
display: grid;
|
|
flex: 1;
|
|
grid-template-columns: repeat(24, 1fr);
|
|
gap: 3px;
|
|
}
|
|
|
|
.cell {
|
|
aspect-ratio: 1;
|
|
border-radius: 2px;
|
|
background-color: var(--color-primary);
|
|
opacity: calc(0.12 + 0.88 * var(--i));
|
|
|
|
&.empty {
|
|
background-color: var(--color-text-secondary);
|
|
opacity: 0.08;
|
|
}
|
|
}
|
|
|
|
.tick {
|
|
font-size: 0.625rem;
|
|
color: var(--color-text-secondary);
|
|
text-align: left;
|
|
}
|
|
</style>
|