통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
12 KiB
Vue
394 lines
12 KiB
Vue
<script setup>
|
||
import {
|
||
ANALYTICS_SINGLE_BAR_COLORS,
|
||
formatAnalyticsChartHeaderLabel,
|
||
getAnalyticsPeriodLabel,
|
||
getAnalyticsTrafficStackColor,
|
||
getAnalyticsTrafficStackSegments,
|
||
getAnalyticsTrendAxisPrimaryLabel,
|
||
getAnalyticsTrendAxisSecondaryLabel,
|
||
getAnalyticsTrendDelta,
|
||
getAnalyticsTrendDetailLabel,
|
||
isAnalyticsCurrentTrendRow,
|
||
paginateAnalyticsTrendRows
|
||
} from '~/lib/analytics-chart-ui.js'
|
||
import { getAnalyticsDayKey } from '~/lib/analytics-shared.js'
|
||
|
||
const props = defineProps({
|
||
rows: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
period: {
|
||
type: String,
|
||
default: 'weekly'
|
||
},
|
||
metricKey: {
|
||
type: String,
|
||
required: true
|
||
},
|
||
metricLabel: {
|
||
type: String,
|
||
required: true
|
||
},
|
||
formatValue: {
|
||
type: Function,
|
||
default: (value) => String(value ?? 0)
|
||
},
|
||
formatDelta: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
pageOffset: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
showNavigation: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
showHeader: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
compact: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
barHeightClass: {
|
||
type: String,
|
||
default: 'h-32'
|
||
},
|
||
emptyMessage: {
|
||
type: String,
|
||
default: '선택한 기간에 표시할 추이 데이터가 없습니다.'
|
||
},
|
||
clickable: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
trafficStack: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['update:pageOffset', 'bar-click'])
|
||
|
||
const todayKey = getAnalyticsDayKey()
|
||
const hoveredBarKey = ref(null)
|
||
|
||
const pagination = computed(() => {
|
||
return paginateAnalyticsTrendRows(props.rows, props.period, props.pageOffset)
|
||
})
|
||
|
||
const visibleRows = computed(() => pagination.value.rows)
|
||
|
||
const chartHeaderLabel = computed(() => {
|
||
return formatAnalyticsChartHeaderLabel(props.period, visibleRows.value, todayKey)
|
||
})
|
||
|
||
/**
|
||
* 추이 행의 고유 키를 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {string} 고유 키
|
||
*/
|
||
const getBarRowKey = (row) => {
|
||
return `${row.startDay || row.day}-${row.endDay || row.day}`
|
||
}
|
||
|
||
/**
|
||
* 막대가 강조 상태인지 확인한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {boolean} 강조 여부
|
||
*/
|
||
const isBarEmphasized = (row) => {
|
||
if (!hoveredBarKey.value) {
|
||
return false
|
||
}
|
||
|
||
return getBarRowKey(row) === hoveredBarKey.value
|
||
}
|
||
|
||
/**
|
||
* 막대가 흐리게 표시되는지 확인한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {boolean} 흐림 여부
|
||
*/
|
||
const isBarDimmed = (row) => {
|
||
return Boolean(hoveredBarKey.value && !isBarEmphasized(row))
|
||
}
|
||
|
||
/**
|
||
* 막대 높이 비율을 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {number} 높이 %
|
||
*/
|
||
const getBarHeight = (row) => {
|
||
const maxValue = Math.max(...visibleRows.value.map((item) => Number(item[props.metricKey] || 0)), 0)
|
||
|
||
if (maxValue <= 0) {
|
||
return 3
|
||
}
|
||
|
||
const value = Number(row[props.metricKey] || 0)
|
||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||
}
|
||
|
||
/**
|
||
* 단일 막대 색상을 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {string} 색상
|
||
*/
|
||
const getSingleBarColor = (row) => {
|
||
const emphasized = isBarEmphasized(row)
|
||
return emphasized ? ANALYTICS_SINGLE_BAR_COLORS.active : ANALYTICS_SINGLE_BAR_COLORS.muted
|
||
}
|
||
|
||
/**
|
||
* 유입 스택 구간 목록을 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {Array<{ key: 'search' | 'sns' | 'other', value: number }>}
|
||
*/
|
||
const getStackSegments = (row) => {
|
||
return getAnalyticsTrafficStackSegments(row).segments
|
||
}
|
||
|
||
/**
|
||
* 전체 추이 행에서 현재 행 인덱스를 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {number} 인덱스
|
||
*/
|
||
const getRowIndexInAllRows = (row) => {
|
||
const key = getBarRowKey(row)
|
||
return props.rows.findIndex((item) => getBarRowKey(item) === key)
|
||
}
|
||
|
||
/**
|
||
* 증감 표시 문자열을 반환한다.
|
||
* @param {number | null} delta - 증감값
|
||
* @returns {string} 표시 문자열
|
||
*/
|
||
const formatDeltaLabel = (delta) => {
|
||
if (delta === null) {
|
||
return ''
|
||
}
|
||
|
||
if (typeof props.formatDelta === 'function') {
|
||
return props.formatDelta(delta)
|
||
}
|
||
|
||
const absolute = Math.abs(delta)
|
||
return Number.isFinite(absolute) ? String(absolute) : '0'
|
||
}
|
||
|
||
/**
|
||
* 막대 hover 시작을 처리한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {void}
|
||
*/
|
||
const handleBarEnter = (row) => {
|
||
hoveredBarKey.value = getBarRowKey(row)
|
||
}
|
||
|
||
/**
|
||
* 막대 hover 종료를 처리한다.
|
||
* @returns {void}
|
||
*/
|
||
const handleBarLeave = () => {
|
||
hoveredBarKey.value = null
|
||
}
|
||
|
||
/**
|
||
* 이전 페이지로 이동한다.
|
||
* @returns {void}
|
||
*/
|
||
const goPrevPage = () => {
|
||
if (!pagination.value.canGoPrev) {
|
||
return
|
||
}
|
||
|
||
emit('update:pageOffset', props.pageOffset + pagination.value.pageSize)
|
||
}
|
||
|
||
/**
|
||
* 다음 페이지로 이동한다.
|
||
* @returns {void}
|
||
*/
|
||
const goNextPage = () => {
|
||
if (!pagination.value.canGoNext) {
|
||
return
|
||
}
|
||
|
||
emit('update:pageOffset', Math.max(props.pageOffset - pagination.value.pageSize, 0))
|
||
}
|
||
|
||
/**
|
||
* 막대 클릭을 처리한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {void}
|
||
*/
|
||
const handleBarClick = (row) => {
|
||
if (!props.clickable) {
|
||
return
|
||
}
|
||
|
||
emit('bar-click', row)
|
||
}
|
||
|
||
/**
|
||
* 추이 행의 증감값을 반환한다.
|
||
* @param {Object} row - 추이 행
|
||
* @returns {number | null} 증감값
|
||
*/
|
||
const getRowDelta = (row) => {
|
||
return getAnalyticsTrendDelta(props.rows, getRowIndexInAllRows(row), props.metricKey)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section
|
||
class="admin-analytics-trend-chart"
|
||
@mouseleave="handleBarLeave"
|
||
>
|
||
<header
|
||
v-if="showHeader && chartHeaderLabel"
|
||
class="admin-analytics-trend-chart__header mb-3 flex items-center justify-between gap-3"
|
||
>
|
||
<p class="admin-analytics-trend-chart__range text-sm font-semibold text-ink">
|
||
{{ chartHeaderLabel }}
|
||
</p>
|
||
</header>
|
||
|
||
<div class="admin-analytics-trend-chart__body relative" :class="showNavigation ? 'pb-8' : 'pb-0'">
|
||
<div
|
||
v-if="visibleRows.length"
|
||
class="admin-analytics-trend-chart__columns flex gap-1"
|
||
role="img"
|
||
:aria-label="`${getAnalyticsPeriodLabel(period)} ${metricLabel} 추이`"
|
||
>
|
||
<div
|
||
v-for="(row, index) in visibleRows"
|
||
:key="`${period}-${getBarRowKey(row)}`"
|
||
class="admin-analytics-trend-chart__column flex min-w-0 flex-1 flex-col transition-opacity duration-150"
|
||
:class="isBarDimmed(row) ? 'opacity-40' : 'opacity-100'"
|
||
>
|
||
<component
|
||
:is="clickable ? 'button' : 'div'"
|
||
class="admin-analytics-trend-chart__bar-wrap group relative flex w-full items-end border-b border-line"
|
||
:class="[barHeightClass, clickable ? 'cursor-pointer border-0 bg-transparent p-0' : '']"
|
||
:type="clickable ? 'button' : undefined"
|
||
@mouseenter="handleBarEnter(row)"
|
||
@focus="handleBarEnter(row)"
|
||
@click="handleBarClick(row)"
|
||
>
|
||
<div
|
||
class="admin-analytics-trend-chart__tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden min-w-max max-w-none -translate-x-1/2 whitespace-nowrap rounded border border-line bg-white px-2.5 py-1.5 text-xs shadow-lg group-hover:block group-focus-visible:block"
|
||
>
|
||
<div class="admin-analytics-trend-chart__tooltip-main flex items-center gap-2">
|
||
<span class="text-sm font-semibold leading-none text-ink">
|
||
{{ formatValue(row[metricKey]) }}
|
||
</span>
|
||
<span
|
||
v-if="getRowDelta(row) !== null"
|
||
class="inline-flex items-center gap-0.5 text-[11px] font-semibold leading-none"
|
||
:class="getRowDelta(row) >= 0 ? 'text-[#e5484d]' : 'text-[#2f6feb]'"
|
||
>
|
||
<span aria-hidden="true">{{ getRowDelta(row) >= 0 ? '▲' : '▼' }}</span>
|
||
<span>{{ formatDeltaLabel(getRowDelta(row)) }}</span>
|
||
</span>
|
||
</div>
|
||
<p
|
||
v-if="trafficStack && getStackSegments(row).length > 1"
|
||
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
|
||
>
|
||
<span class="text-[#e5484d]">검색 {{ row.trafficSearch || 0 }}</span>
|
||
·
|
||
<span class="text-[#12a594]">SNS {{ row.trafficSns || 0 }}</span>
|
||
·
|
||
<span class="text-[#8b8d98]">기타 {{ row.trafficOther || 0 }}</span>
|
||
</p>
|
||
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
|
||
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
v-if="trafficStack && metricKey === 'visitors'"
|
||
class="admin-analytics-trend-chart__stack flex w-full flex-col justify-end overflow-hidden rounded-t-sm"
|
||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||
:style="{ height: `${getBarHeight(row)}%` }"
|
||
>
|
||
<div
|
||
v-for="segment in getStackSegments(row)"
|
||
:key="`${getBarRowKey(row)}-${segment.key}`"
|
||
class="admin-analytics-trend-chart__stack-segment w-full transition-colors duration-150"
|
||
:style="{
|
||
flex: `${segment.value} 0 0`,
|
||
backgroundColor: getAnalyticsTrafficStackColor(segment.key, isBarEmphasized(row))
|
||
}"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="admin-analytics-trend-chart__bar w-full rounded-t-sm transition-colors duration-150"
|
||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||
:style="{
|
||
height: `${getBarHeight(row)}%`,
|
||
backgroundColor: getSingleBarColor(row)
|
||
}"
|
||
/>
|
||
</component>
|
||
|
||
<div class="admin-analytics-trend-chart__axis border-t border-line pt-2 text-center">
|
||
<span
|
||
class="admin-analytics-trend-chart__axis-primary inline-block text-[11px] font-medium transition-colors duration-150"
|
||
:class="isBarEmphasized(row) || (isAnalyticsCurrentTrendRow(period, row, todayKey) && !hoveredBarKey) ? 'rounded-full bg-[#15171a] px-2 py-0.5 text-[10px] text-white' : 'text-muted'"
|
||
>
|
||
{{ getAnalyticsTrendAxisPrimaryLabel(period, row, index === visibleRows.length - 1, todayKey) }}
|
||
</span>
|
||
<span
|
||
v-if="getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index)"
|
||
class="admin-analytics-trend-chart__axis-secondary mt-0.5 block text-[10px] text-muted"
|
||
>
|
||
{{ getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p
|
||
v-if="!visibleRows.length"
|
||
class="admin-analytics-trend-chart__empty mt-4 text-center text-xs text-muted"
|
||
>
|
||
{{ emptyMessage }}
|
||
</p>
|
||
|
||
<div
|
||
v-if="showNavigation && visibleRows.length"
|
||
class="admin-analytics-trend-chart__nav pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between"
|
||
>
|
||
<button
|
||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||
type="button"
|
||
aria-label="이전 기간"
|
||
:disabled="!pagination.canGoPrev"
|
||
@click="goPrevPage"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||
type="button"
|
||
aria-label="다음 기간"
|
||
:disabled="!pagination.canGoNext"
|
||
@click="goNextPage"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|