Files
beavergram/frontend/src/lib/components/presence/PresenceHeatmap.svelte
T

219 lines
4.7 KiB
Svelte

<script lang="ts">
import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
week: PresenceHour[];
}
const { week }: Props = $props();
const HOURS_IN_DAY = 24;
const SECONDS_IN_MINUTE = 60;
const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18;
const weekdays = $derived(weekdayLabels());
const rows = $derived(
weekdays.map((_, weekday) =>
week
.filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour)
)
);
const peak = $derived(
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
);
const hasGaps = $derived(week.some(missing));
function missing(cell: PresenceHour): boolean {
return cell.hours > 0 && cell.online_minutes === null;
}
function title(day: string, cell: PresenceHour): string {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return t("presence.cellNoData", { slot });
}
return t("presence.cellAverage", {
slot,
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
});
}
function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) {
return 0;
}
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
}
</script>
<div class="heatmap">
{#each rows as cells, weekday (weekday)}
<div class="row">
<span class="day">{weekdays[weekday]}</span>
<div class="cells">
{#each cells as cell (cell.hour)}
<span
class={[
"cell",
{
empty: missing(cell),
outside: cell.hours === 0,
zero: cell.online_minutes === 0,
},
]}
title={title(weekdays[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%"
></span>
{/each}
</div>
</div>
{/each}
<div class="row">
<span class="day"></span>
<div class="ticks">
{#each HOUR_TICKS as hour (hour)}
<span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
{/each}
</div>
</div>
<div class="legend">
<span class="key"><span class="swatch zero"></span>0</span>
{#if peak > 0}
<span class="key">
<span class="scale"></span>
{t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
</span>
{/if}
{#if hasGaps}
<span class="key">
<span class="swatch empty"></span>
{t("presence.noData")}
</span>
{/if}
</div>
</div>
<style lang="scss">
.heatmap {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 1rem;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.day {
flex-shrink: 0;
width: 1.75rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: color-mix(
in srgb,
var(--color-primary) var(--shade),
transparent
);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.outside {
background: none;
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
.ticks {
position: relative;
flex: 1;
height: 1rem;
span {
position: absolute;
top: 0.125rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
align-items: center;
margin: 0.375rem 0 0 2.25rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.key {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.scale {
width: 2.5rem;
height: 0.625rem;
border-radius: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
var(--color-primary)
);
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
</style>