219 lines
4.7 KiB
Svelte
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>
|