138 lines
4.7 KiB
Vue
138 lines
4.7 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const props = defineProps({ days: { type: Array, default: () => [] }, selected: String })
|
|
const emit = defineEmits(['select'])
|
|
const { locale, t } = useI18n()
|
|
const root = ref(null)
|
|
const hover = ref(null)
|
|
const cell = ref(13)
|
|
const gap = ref(3)
|
|
let ro
|
|
|
|
function ymd(d) {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
const zhMonths = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
|
const enMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
|
|
function monthText(m) {
|
|
return locale.value === 'zh-CN' ? zhMonths[m] : enMonths[m]
|
|
}
|
|
|
|
const weekdays = computed(() => [
|
|
t('weekdays.sun'),
|
|
t('weekdays.mon'),
|
|
t('weekdays.tue'),
|
|
t('weekdays.wed'),
|
|
t('weekdays.thu'),
|
|
t('weekdays.fri'),
|
|
t('weekdays.sat'),
|
|
])
|
|
|
|
const matrix = computed(() => {
|
|
const end = new Date()
|
|
end.setHours(0, 0, 0, 0)
|
|
const start = new Date(end)
|
|
start.setDate(start.getDate() - 364)
|
|
start.setDate(start.getDate() - start.getDay())
|
|
const counts = new Map(props.days.map(x => [x.date, x]))
|
|
const cells = []
|
|
let week = 0
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const date = ymd(d)
|
|
const day = counts.get(date)
|
|
const count = Number(day?.count || 0)
|
|
cells.push({ date, count, added: Number(day?.added || 0), deleted: Number(day?.deleted || 0), week, dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
|
|
if (d.getDay() === 6) week++
|
|
}
|
|
const weeks = Math.max(1, ...cells.map(x => x.week + 1))
|
|
const labels = []
|
|
let lastMonth = -1
|
|
for (const item of cells) {
|
|
const m = Number(item.date.slice(5, 7)) - 1
|
|
if (m === lastMonth) continue
|
|
const prev = labels[labels.length - 1]
|
|
if (prev && prev.week === item.week) {
|
|
prev.text = monthText(m)
|
|
} else {
|
|
if (prev) prev.span = Math.max(1, item.week - prev.week)
|
|
labels.push({ week: item.week, span: 1, text: monthText(m) })
|
|
}
|
|
lastMonth = m
|
|
}
|
|
if (labels.length) labels[labels.length - 1].span = Math.max(1, weeks - labels[labels.length - 1].week)
|
|
return { cells, labels, weeks }
|
|
})
|
|
|
|
const monthLabels = computed(() => {
|
|
const unit = cell.value + gap.value
|
|
const total = matrix.value.weeks * unit - gap.value
|
|
return matrix.value.labels.map((m, i, arr) => {
|
|
const next = arr[i + 1]
|
|
let left = m.week * unit
|
|
let width = Math.max(24, (next ? next.week * unit : total) - left)
|
|
if (left + width > total) {
|
|
width = Math.min(width, total)
|
|
left = Math.max(0, total - width)
|
|
}
|
|
return { week: m.week, text: m.text, left, width }
|
|
})
|
|
})
|
|
|
|
function fit() {
|
|
const el = root.value
|
|
if (!el) return
|
|
const weeks = matrix.value.weeks || 53
|
|
const avail = Math.max(0, el.clientWidth - 34)
|
|
const nextGap = avail < 720 ? 2 : 3
|
|
const nextCell = Math.floor((avail - (weeks - 1) * nextGap) / weeks)
|
|
gap.value = nextGap
|
|
cell.value = Math.max(4, Math.min(14, nextCell > 0 ? nextCell : 4))
|
|
}
|
|
|
|
function pick(item) {
|
|
if (!item.date) return
|
|
emit('select', item.date === props.selected ? '' : item.date)
|
|
}
|
|
|
|
onMounted(() => {
|
|
fit()
|
|
ro = new ResizeObserver(fit)
|
|
if (root.value) ro.observe(root.value)
|
|
})
|
|
onUnmounted(() => ro?.disconnect())
|
|
watch(() => matrix.value.weeks, fit)
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="heat-grid-wrap" :style="{ '--weeks': matrix.weeks, '--cell': cell + 'px', '--gap': gap + 'px' }">
|
|
<div class="heat-months">
|
|
<span v-for="m in monthLabels" :key="m.week + m.text" :style="{ left: m.left + 'px', width: m.width + 'px' }">{{ m.text }}</span>
|
|
</div>
|
|
<div class="heat-body">
|
|
<div class="heat-weekdays"><span v-for="(d, i) in weekdays" :key="i">{{ d }}</span></div>
|
|
<div class="heat-grid">
|
|
<button
|
|
v-for="item in matrix.cells"
|
|
:key="item.date"
|
|
type="button"
|
|
class="heat-cell"
|
|
:class="['l' + item.level, { selected: item.date === selected }]"
|
|
:style="{ gridColumnStart: item.week + 1, gridRowStart: item.dow + 1 }"
|
|
:title="item.date"
|
|
@mouseenter="hover = item"
|
|
@mouseleave="hover = null"
|
|
@click="pick(item)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p class="heat-tip">
|
|
<template v-if="hover">{{ hover.date }} · {{ hover.count }} {{ t('commitsUnit') }}<template v-if="hover.count"> · <b class="positive">+{{ hover.added }}</b> <b class="negative">-{{ hover.deleted }}</b></template></template>
|
|
<template v-else>{{ t('heatHint') }}</template>
|
|
</p>
|
|
</div>
|
|
</template>
|