Add timeline
|
@ -77,6 +77,13 @@
|
|||
label="Todo List"
|
||||
href="/todo"
|
||||
/>
|
||||
<SidebarItem
|
||||
on:clicked={close}
|
||||
active={segment === 'timeline'}
|
||||
image="/images/timeline.png"
|
||||
label="Timeline"
|
||||
href="/timeline"
|
||||
/>
|
||||
<SidebarItem
|
||||
on:clicked={close}
|
||||
active={segment === 'settings'}
|
||||
|
|
64
src/data/timeline.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
export const eventsData = [
|
||||
[
|
||||
{
|
||||
name: 'Vishaps and Where to Find Them',
|
||||
image: 'vishaps_and_where_to_find_them.jpg',
|
||||
pos: '0% 30%',
|
||||
start: '2021-03-05 04:00:00',
|
||||
end: '2021-03-12 04:00:00',
|
||||
color: '#F6AD55',
|
||||
},
|
||||
{
|
||||
name: 'Update 1.4!',
|
||||
image: 'update14.png',
|
||||
pos: '0% 23%',
|
||||
start: '2021-03-17 11:00:00',
|
||||
end: '2021-03-24 04:00:00',
|
||||
startOnly: true,
|
||||
color: '#79D2EB',
|
||||
zoom: '120%',
|
||||
},
|
||||
],
|
||||
{
|
||||
name: 'Moment of Bloom - Hu Tao Banner',
|
||||
pos: '50% 20%',
|
||||
image: 'moment_of_bloom.jpg',
|
||||
start: '2021-03-02 18:00:00',
|
||||
end: '2021-03-16 15:00:00',
|
||||
color: '#FC8181',
|
||||
},
|
||||
{
|
||||
name: 'Epitome Invocation - Weapon Banner',
|
||||
image: 'epitome_invocation.jpg',
|
||||
pos: '50% 20%',
|
||||
start: '2021-02-23 18:00:00',
|
||||
end: '2021-03-16 15:00:00',
|
||||
color: '#F56565',
|
||||
},
|
||||
[
|
||||
{
|
||||
name: 'Spiral Abyss',
|
||||
image: 'spiral_abyss.jpg',
|
||||
pos: '50% 20%',
|
||||
start: '2021-03-01 04:00:00',
|
||||
end: '2021-03-16 04:00:00',
|
||||
color: '#63B3ED',
|
||||
},
|
||||
{
|
||||
name: 'Spiral Abyss',
|
||||
image: 'spiral_abyss.jpg',
|
||||
pos: '50% 20%',
|
||||
start: '2021-03-16 04:00:00',
|
||||
end: '2021-04-01 04:00:00',
|
||||
color: '#4299E1',
|
||||
},
|
||||
],
|
||||
{
|
||||
name: 'Battle Pass',
|
||||
image: 'lantern-lit_sky.jpg',
|
||||
pos: '0% 12%',
|
||||
start: '2021-02-03 11:00:00',
|
||||
end: '2021-03-15 04:00:00',
|
||||
color: '#68D391',
|
||||
},
|
||||
];
|
48
src/routes/timeline/_detail.svelte
Normal file
|
@ -0,0 +1,48 @@
|
|||
<script>
|
||||
import dayjs from 'dayjs';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let event;
|
||||
|
||||
let now = dayjs();
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = dayjs();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
$: started = now.isAfter(event.start);
|
||||
$: ended = now.isAfter(event.end);
|
||||
$: diffStart = event.start.diff(now);
|
||||
$: diffEnd = event.end.diff(now);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<img src="/images/events/{event.image}" class="w-full rounded-lg" alt={event.name} />
|
||||
<h1 class="mt-4 text-white font-display font-semibold text-xl">{event.name}</h1>
|
||||
<p class="text-gray-400 font-body flex flex-col md:flex-row">
|
||||
<span class="flex">
|
||||
<span>{event.start.format('ddd, D MMM YYYY HH:mm')}</span>
|
||||
{#if !event.startOnly}<span class="mx-2">-</span>{/if}
|
||||
</span>
|
||||
{#if !event.startOnly}
|
||||
<span>{event.end.format('ddd, D MMM YYYY HH:mm')}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-gray-400 px-4 py-1 bg-black bg-opacity-50 rounded-xl mt-2 inline-block">
|
||||
{#if !started}
|
||||
Starting in {dayjs.duration(diffStart).format(diffStart > 86400000 ? 'D[d] HH:mm:ss' : 'HH:mm:ss')}
|
||||
{:else if started && !ended && !event.startOnly}
|
||||
Ending in {dayjs.duration(diffEnd).format(diffEnd > 86400000 ? 'D[d] HH:mm:ss' : 'HH:mm:ss')}
|
||||
{:else if event.startOnly}
|
||||
Live Now!
|
||||
{:else}
|
||||
Finished
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
78
src/routes/timeline/_item.svelte
Normal file
|
@ -0,0 +1,78 @@
|
|||
<script>
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export let prev = null;
|
||||
export let next = null;
|
||||
export let event;
|
||||
export let openDetail;
|
||||
export let dayWidth;
|
||||
export let marginTop;
|
||||
export let eventHeight;
|
||||
export let eventMargin;
|
||||
export let now;
|
||||
export let i;
|
||||
|
||||
$: prevNearby = prev !== null && event.start.diff(prev.end, 'hour') < 12;
|
||||
$: nextNearby = next !== null && next.start.diff(event.end, 'hour') < 12;
|
||||
$: started = now.isAfter(event.start);
|
||||
$: ended = now.isAfter(event.end);
|
||||
$: diffStart = event.start.diff(now);
|
||||
$: diffEnd = event.end.diff(now);
|
||||
</script>
|
||||
|
||||
<div
|
||||
on:click={openDetail}
|
||||
class="flex items-center z-10 text-white cursor-pointer absolute {prevNearby ? '' : 'rounded-l-xl'} {nextNearby
|
||||
? 'border-r-4 border-white'
|
||||
: 'rounded-r-xl'}"
|
||||
style="width: {dayWidth * event.duration}px; left: {dayWidth *
|
||||
event.offset}px; background-color: {event.color};
|
||||
top: {marginTop +
|
||||
i * (eventHeight + eventMargin)}px; height: {eventHeight}px; padding-right: 10px;
|
||||
{prevNearby &&
|
||||
diffStart > 86400000
|
||||
? 'padding-left: 50px;'
|
||||
: 'padding-left: 10px;'}
|
||||
--image: url(/images/events/{event.image}); --pos: {event.pos}; --color: {event.color};
|
||||
--zoom: {event.zoom ? event.zoom : '200%'};"
|
||||
>
|
||||
<div class="event-item {nextNearby ? '' : 'rounded-xl'}" />
|
||||
<span class="event-name text sticky left-0 font-display text-base md:text-lg text-black font-bold whitespace-no-wrap">
|
||||
{event.name}
|
||||
</span>
|
||||
{#if started && !ended && !event.startOnly}
|
||||
<div class="absolute pl-3" style="top: 6px; right: -200px; width: 200px;">
|
||||
<span class="text-sm rounded-xl text-black font-semibold bg-white bg-opacity-75 px-1">
|
||||
{dayjs.duration(diffEnd).format(diffEnd > 86400000 ? 'D[d] HH:mm:ss' : 'HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
{:else if !started && !ended}
|
||||
<div class="absolute pr-3 text-right" style="top: 6px; left: {prevNearby ? '-150px' : '-200px'}; width: 200px;">
|
||||
<span class="text-sm rounded-xl text-black font-semibold bg-white bg-opacity-75 px-1">
|
||||
{dayjs.duration(diffStart).format(diffStart > 86400000 ? 'D[d] HH:mm:ss' : 'HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.event-item {
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: 100%;
|
||||
background-image: var(--image);
|
||||
background-position: var(--pos);
|
||||
background-repeat: no-repeat;
|
||||
background-size: var(--zoom);
|
||||
mask-image: linear-gradient(to left, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
span.event-name {
|
||||
text-shadow: var(--color) -1px -1px 4px, var(--color) 1px -1px 4px, var(--color) -1px 1px 4px,
|
||||
var(--color) 1px 1px 4px, var(--color) 0 0 10px;
|
||||
}
|
||||
</style>
|
259
src/routes/timeline/index.svelte
Normal file
|
@ -0,0 +1,259 @@
|
|||
<script>
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
dayjs.extend(duration);
|
||||
|
||||
import { eventsData } from '../../data/timeline';
|
||||
|
||||
import EventItem from './_item.svelte';
|
||||
import DetailModal from './_detail.svelte';
|
||||
|
||||
const { open: openModal } = getContext('simple-modal');
|
||||
|
||||
function openDetail(event) {
|
||||
openModal(
|
||||
DetailModal,
|
||||
{
|
||||
event,
|
||||
},
|
||||
{
|
||||
closeButton: false,
|
||||
styleWindow: { background: '#25294A', width: '600px' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let timelineContainer;
|
||||
|
||||
let dayWidth = 50;
|
||||
const eventHeight = 36;
|
||||
const eventMargin = 20;
|
||||
const padding = 10;
|
||||
const marginTop = 80;
|
||||
|
||||
let lastEventTime = dayjs().year(2000);
|
||||
let firstDay;
|
||||
let dates = [];
|
||||
let months = {};
|
||||
|
||||
function convertToDate(e, i) {
|
||||
const start = dayjs(e.start, 'YYYY-MM-DD HH:mm:ss');
|
||||
const end = dayjs(e.end, 'YYYY-MM-DD HH:mm:ss');
|
||||
const duration = end.diff(start, 'day', true);
|
||||
|
||||
if (lastEventTime < end) lastEventTime = end;
|
||||
|
||||
return {
|
||||
...e,
|
||||
index: i,
|
||||
start,
|
||||
end,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
let events = eventsData.map((e, i) => {
|
||||
if (Array.isArray(e)) {
|
||||
return e.map((item) => convertToDate(item, i));
|
||||
}
|
||||
|
||||
return convertToDate(e, i);
|
||||
});
|
||||
|
||||
events
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a[0].start - b[0].start;
|
||||
} else if (!Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.start - b[0].start;
|
||||
} else if (Array.isArray(a) && !Array.isArray(b)) {
|
||||
return a[0].start - b.start;
|
||||
} else {
|
||||
return a.start - b.start;
|
||||
}
|
||||
})
|
||||
.forEach((e, i) => {
|
||||
if (i === 0) {
|
||||
if (Array.isArray(e)) {
|
||||
firstDay = e[0].start.set('hour', 0).set('minute', 0).set('second', 0).subtract(padding, 'day');
|
||||
} else {
|
||||
firstDay = e.start.set('hour', 0).set('minute', 0).set('second', 0).subtract(padding, 'day');
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(e)) {
|
||||
for (let j = 0; j < e.length; j++) {
|
||||
const current = e[j];
|
||||
|
||||
const offset = Math.abs(firstDay.diff(events[current.index][j].start, 'day', true));
|
||||
events[current.index][j].offset = offset;
|
||||
}
|
||||
} else {
|
||||
const offset = Math.abs(firstDay.diff(e.start, 'day', true));
|
||||
events[e.index].offset = offset;
|
||||
}
|
||||
});
|
||||
|
||||
let today = dayjs();
|
||||
$: todayOffset = Math.abs(firstDay.diff(today, 'day', true));
|
||||
|
||||
const dayTotal = Math.abs(Math.ceil(firstDay.diff(lastEventTime, 'day', true))) + 2 * padding;
|
||||
|
||||
for (let i = 0; i < dayTotal; i++) {
|
||||
const month = firstDay.add(i, 'day').format('MMMM');
|
||||
if (months[month] === undefined) {
|
||||
months[month] = {
|
||||
total: 0,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
months[month].total++;
|
||||
}
|
||||
|
||||
const monthList = Object.entries(months);
|
||||
for (let i = 0; i < monthList.length; i++) {
|
||||
monthList[i][1].offset = i - 1 >= 0 ? monthList[i - 1][1].total + monthList[i - 1][1].offset : 0;
|
||||
}
|
||||
|
||||
dates = [...new Array(dayTotal)].map((_, i) => firstDay.add(i, 'day').date());
|
||||
|
||||
onMount(() => {
|
||||
console.log(firstDay);
|
||||
console.log(events);
|
||||
if (timelineContainer.offsetWidth < 500) {
|
||||
dayWidth = 40;
|
||||
tick();
|
||||
}
|
||||
|
||||
timelineContainer.scrollTo({
|
||||
left: todayOffset * dayWidth - timelineContainer.offsetWidth / 2 + dayWidth,
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
today = dayjs();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
function transformScroll(event) {
|
||||
if (!event.deltaY) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.scrollLeft += event.deltaY;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Timeline - Paimon.moe</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Genshin Impact event timeline calendar, view when an event and abyys order will start and end with neat timeline"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Genshin Impact event timeline calendar, view when an event and abyys order will start and end with neat timeline"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="lg:ml-64 pt-20 lg:pt-8">
|
||||
<h1 class="font-display px-4 md:px-8 font-black text-5xl text-white">Timeline</h1>
|
||||
<div class="w-full overflow-x-auto px-4 md:px-8" bind:this={timelineContainer} on:mousewheel={transformScroll}>
|
||||
<div
|
||||
style={`padding-top: 50px; width: min-content; padding-right: ${2 * padding * dayWidth}px; height: ${
|
||||
marginTop + events.length * (eventHeight + eventMargin)
|
||||
}px`}
|
||||
class="timeline flex flex-col relative content"
|
||||
>
|
||||
<!-- DATE BAR -->
|
||||
{#each dates as date, i}
|
||||
<div
|
||||
class="bg-gray-700"
|
||||
style={`width: 1px; height: calc(100% - ${eventHeight}px); position: absolute;
|
||||
left: ${i * dayWidth}px; top: ${eventHeight}px;`}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0 text-gray-200 text-center pb-1 bg-background-secondary"
|
||||
style="width: 20px; left: -10px;"
|
||||
>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- MONTH TITLE -->
|
||||
{#each monthList as [month, item]}
|
||||
<div
|
||||
class="absolute bg-background-secondary pr-4"
|
||||
style={`top: 12px; width: ${item.total * dayWidth}px; left: ${item.offset * dayWidth}px;`}
|
||||
>
|
||||
<span class="text-legendary-from font-bold sticky left-0">{month}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- EVENT STRIP -->
|
||||
{#each events as event, i}
|
||||
{#if Array.isArray(event)}
|
||||
{#each event as item, j}
|
||||
<EventItem
|
||||
prev={j > 0 ? event[j - 1] : null}
|
||||
next={j < event.length - 1 ? event[j + 1] : null}
|
||||
now={today}
|
||||
event={item}
|
||||
openDetail={() => openDetail(item)}
|
||||
{dayWidth}
|
||||
{marginTop}
|
||||
{eventHeight}
|
||||
{eventMargin}
|
||||
{i}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<EventItem
|
||||
now={today}
|
||||
openDetail={() => openDetail(event)}
|
||||
{event}
|
||||
{dayWidth}
|
||||
{marginTop}
|
||||
{eventHeight}
|
||||
{eventMargin}
|
||||
{i}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- NOW BAR -->
|
||||
<div
|
||||
class="bg-gray-200 z-20 relative opacity-75"
|
||||
style={`left: ${
|
||||
todayOffset * dayWidth
|
||||
}px; width: 2px; height: calc(100% - 10px); position: absolute; top: 10px;`}
|
||||
>
|
||||
<div class="absolute rounded-xl top-0 text-center bg-white text-black" style="width: 80px; left: -40px;">
|
||||
{today.format('HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@apply rounded-xl;
|
||||
}
|
||||
</style>
|
BIN
static/images/events/epitome_invocation.jpg
Normal file
After Width: | Height: | Size: 132 KiB |
BIN
static/images/events/lantern-lit_sky.jpg
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
static/images/events/moment_of_bloom.jpg
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
static/images/events/spiral_abyss.jpg
Normal file
After Width: | Height: | Size: 144 KiB |
BIN
static/images/events/update14.png
Normal file
After Width: | Height: | Size: 195 KiB |
BIN
static/images/events/vishaps_and_where_to_find_them.jpg
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
static/images/timeline.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
|
@ -4,3 +4,4 @@ https://paimon.moe/wish
|
|||
https://paimon.moe/calculator
|
||||
https://paimon.moe/items
|
||||
https://paimon.moe/todo
|
||||
https://paimon.moe/timeline
|
||||
|
|
|
@ -1278,9 +1278,9 @@ cssesc@^3.0.0:
|
|||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
dayjs@^1.9.4:
|
||||
version "1.9.4"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.9.4.tgz#fcde984e227f4296f04e7b05720adad2e1071f1b"
|
||||
integrity sha512-ABSF3alrldf7nM9sQ2U+Ln67NRwmzlLOqG7kK03kck0mw3wlSSEKv/XhKGGxUjQcS57QeiCyNdrFgtj9nWlrng==
|
||||
version "1.10.4"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2"
|
||||
integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
|
|