wip: planning-slot test
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { eventPlanningQuery } from "@/queries";
|
||||
import type { PlanningSlotOut } from "./api/types.gen";
|
||||
import PlanningCover from "./components/PlanningCover.vue";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { t } = useI18n();
|
||||
useTitle(computed(() => t("planning.title")));
|
||||
|
||||
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
|
||||
|
||||
const HOUR = 3600e3;
|
||||
const HOUR_PX = 104; // a 2h slot ends up close to the 2:3 cover ratio
|
||||
const BREAK_PX = 34;
|
||||
const DAY_PX = 32;
|
||||
const AXIS_PX = 56;
|
||||
const COL_PX = 150;
|
||||
const GAP_PX = 10;
|
||||
// Empty runs at least this long (nights) get squashed into a thin break
|
||||
const MIN_BREAK_HOURS = 3;
|
||||
|
||||
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
|
||||
|
||||
interface Segment {
|
||||
from: number;
|
||||
to: number;
|
||||
y: number;
|
||||
h: number;
|
||||
kind: "hour" | "idle" | "break" | "day";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
slot: PlanningSlotOut;
|
||||
top: number;
|
||||
height: number;
|
||||
when: string;
|
||||
}
|
||||
|
||||
const layout = computed(() => {
|
||||
const list = (slots.value ?? []).map((slot) => ({
|
||||
slot,
|
||||
b: Date.parse(slot.begin),
|
||||
e: Date.parse(slot.end),
|
||||
}));
|
||||
if (!list.length) return null;
|
||||
|
||||
// Multi-anims run the whole event, only the other slots decide what's busy
|
||||
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
|
||||
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
|
||||
|
||||
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
|
||||
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
|
||||
|
||||
const segments: Segment[] = [];
|
||||
let y = 0;
|
||||
let lastDay = "";
|
||||
for (let t = start; t < end;) {
|
||||
if (busy(t)) {
|
||||
// Each day gets its own header row, takes no time
|
||||
const day = fmtDay.format(t);
|
||||
if (day !== lastDay) {
|
||||
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
|
||||
y += DAY_PX;
|
||||
lastDay = day;
|
||||
}
|
||||
segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" });
|
||||
y += HOUR_PX;
|
||||
t += HOUR;
|
||||
continue;
|
||||
}
|
||||
let u = t;
|
||||
while (u < end && !busy(u)) u += HOUR;
|
||||
const hours = (u - t) / HOUR;
|
||||
const isBreak = hours >= MIN_BREAK_HOURS;
|
||||
const h = isBreak ? BREAK_PX : HOUR_PX * hours;
|
||||
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
|
||||
y += h;
|
||||
t = u;
|
||||
}
|
||||
|
||||
// Day headers have no duration, they never contain a time
|
||||
const timed = segments.filter((s) => s.kind !== "day");
|
||||
const yOf = (t: number) => {
|
||||
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
|
||||
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||
};
|
||||
|
||||
const days = segments
|
||||
.filter((s) => s.kind === "day")
|
||||
.map((s) => ({ y: s.y, label: s.label! }));
|
||||
|
||||
// No tick right under a day header, it would sit on its line
|
||||
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
|
||||
const ticks = timed
|
||||
.filter((s) => s.kind !== "break")
|
||||
.flatMap((s) => {
|
||||
const out = [];
|
||||
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
|
||||
return out;
|
||||
})
|
||||
.filter((tick) => !dayEnds.has(tick.y));
|
||||
|
||||
const breaks = segments
|
||||
.filter((s) => s.kind === "break")
|
||||
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
|
||||
|
||||
// Unused columns are dropped
|
||||
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
|
||||
const columns = columnIds.map((id) =>
|
||||
list
|
||||
.filter(({ slot }) => slot.column === id)
|
||||
.map<Placed>(({ slot, b, e }) => ({
|
||||
slot,
|
||||
top: yOf(b) + 3,
|
||||
height: yOf(e) - yOf(b) - 6,
|
||||
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
height: y,
|
||||
width: AXIS_PX + columns.length * (COL_PX + GAP_PX) - GAP_PX,
|
||||
days,
|
||||
ticks,
|
||||
breaks,
|
||||
columns,
|
||||
};
|
||||
});
|
||||
|
||||
const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||
<div class="w-full max-w-7xl">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
|
||||
{{ t("planning.empty") }}
|
||||
</p>
|
||||
|
||||
<div v-else class="overflow-x-auto pb-4">
|
||||
<div class="relative mx-auto" :style="{ width: `${layout.width}px`, height: `${layout.height}px` }">
|
||||
<!-- Time axis: a header row per day, hour ticks on the side -->
|
||||
<div v-for="day in layout.days" :key="`day-${day.y}`"
|
||||
class="absolute left-0 right-0 border-b border-base-content/20 flex items-end pb-1 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<template v-for="tick in layout.ticks" :key="tick.y">
|
||||
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
|
||||
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
|
||||
{{ tick.label }}
|
||||
</span>
|
||||
<div class="absolute right-0 border-t border-dashed border-base-content/5"
|
||||
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
|
||||
</template>
|
||||
|
||||
<!-- Nights -->
|
||||
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
|
||||
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
|
||||
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
|
||||
{{ b.label }}
|
||||
</div>
|
||||
|
||||
<!-- Slots -->
|
||||
<div v-for="(column, index) in layout.columns" :key="index" class="absolute top-0 h-full"
|
||||
:style="{ left: `${columnLeft(index)}px`, width: `${COL_PX}px` }">
|
||||
<template v-for="{ slot, top, height, when } in column" :key="slot.id">
|
||||
<!-- Multi-anims: one long card, its games stay in view while scrolling -->
|
||||
<div v-if="slot.is_multi_anims" class="planning-multi absolute inset-x-0 rounded-md p-2 shadow-xl"
|
||||
:style="{ top: `${top}px`, height: `${height}px`, '--slot-color': slot.color }">
|
||||
<div class="font-bold uppercase text-sm tracking-wide mb-2">{{ slot.title }}</div>
|
||||
<component :is="game.store_url ? 'a' : 'div'" v-for="game in slot.games" :key="game.name"
|
||||
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
class="sticky top-2 block aspect-[2/3] rounded overflow-hidden mb-3 shadow-lg bg-base-200">
|
||||
<PlanningCover :game="game" :color="slot.color" />
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
|
||||
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
|
||||
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
|
||||
:style="{ top: `${top}px`, height: `${height}px` }">
|
||||
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
|
||||
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
|
||||
group-hover:opacity-100 transition-opacity">
|
||||
{{ when }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.planning-break {
|
||||
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
|
||||
}
|
||||
|
||||
.planning-multi {
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--slot-color) 30%, var(--color-base-200)),
|
||||
var(--color-base-200));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user