diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a35ea96..d5b99a95 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,8 @@ "countup", "daterange", "echarts", + "emsp", + "ensp", "filemanager", "Hengyang", "Kanban", @@ -38,4 +40,4 @@ // Ignore i18n resources "src/i18n/message/**/*.json", ], -} +} \ No newline at end of file diff --git a/src/app/components/Analysis/components/Trend/Dimension/Wrapper.ts b/src/app/components/Analysis/components/Trend/Dimension/Wrapper.ts index 4a26df7e..65087750 100644 --- a/src/app/components/Analysis/components/Trend/Dimension/Wrapper.ts +++ b/src/app/components/Analysis/components/Trend/Dimension/Wrapper.ts @@ -18,11 +18,11 @@ import { use } from "echarts/core" import { LineChart } from "echarts/charts" import { SVGRenderer } from "echarts/renderers" import { TitleComponent, TooltipComponent, GridComponent } from "echarts/components" - import { ValueFormatter } from "@app/components/Analysis/util" import { getSecondaryTextColor } from "@util/style" import { EchartsWrapper } from "@hooks" -import { ZRColor } from "echarts/types/dist/shared" +import { getLineSeriesPalette, tooltipDot, tooltipFlexLine } from "@app/util/echarts" +import { TopLevelFormatterParams } from "echarts/types/dist/shared" use([ LineChart, @@ -50,67 +50,25 @@ type ValueItem = LineSeriesOption["data"][0] & { _data: DimensionEntry } -const THIS_COLOR: ZRColor = { - type: "linear", - x: 0, y: 0, - x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: 'rgb(55, 162, 255)' }, - { offset: 1, color: 'rgb(116, 21, 219)' }, - ], -} -const PREV_COLOR: ZRColor = { - type: "linear", - x: 0, y: 0, - x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: 'rgb(255, 0, 135)' }, - { offset: 1, color: 'rgb(135, 0, 157)' }, - ], -} +const [THIS_COLOR, PREV_COLOR] = getLineSeriesPalette() -const createTooltipLine = (param: any, valueFormatter: ValueFormatter) => { +const createTooltipLine = (param: any, valueFormatter: ValueFormatter): string => { const data = param.data as ValueItem const { _data: { value, date } } = data - const color = param.color as string - const p = document.createElement('p') - p.style.margin = "0" - p.style.padding = "0" - p.style.alignItems = "center" - p.style.display = "flex" - - const dotEl = document.createElement('div') - dotEl.style.width = '8px' - dotEl.style.height = '8px' - dotEl.style.display = 'inline-flex' - dotEl.style.borderRadius = '4px' - dotEl.style.backgroundColor = color - dotEl.style.marginRight = '7px' - p.append(dotEl) - - const dateEl = document.createElement('span') - dateEl.innerText = date - dateEl.style.marginRight = "7px" - p.appendChild(dateEl) const valStr = valueFormatter?.(value) || value?.toString() || "NaN" - const valEL = document.createElement('span') - valEL.innerText = valStr - valEL.style.fontWeight = "500" - p.appendChild(valEL) - return p + + return tooltipFlexLine( + `${tooltipDot(param.color)} ${date}`, + `${valStr}` + ) } -const formatTooltip = (params: any[], valueFormatter: ValueFormatter) => { - const container = document.createElement('div') - container.style.height = "50px" - container.style.display = "flex" - container.style.flexDirection = "column" - container.style.justifyContent = "space-around" +const formatTooltip = (params: TopLevelFormatterParams, valueFormatter: ValueFormatter) => { + if (!Array.isArray(params)) return '' const lines = params.map(param => createTooltipLine(param, valueFormatter)) - lines.forEach(l => container.append(l)) - return container + return lines.join('') } const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOption) => { @@ -141,7 +99,7 @@ const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOptio tooltip: { trigger: 'axis', axisPointer: { type: "line" }, - formatter: (params: any[]) => formatTooltip(params, valueFormatter), + formatter: (params: TopLevelFormatterParams) => formatTooltip(params, valueFormatter), }, xAxis: { type: 'category', diff --git a/src/app/components/Analysis/components/Trend/Filter.tsx b/src/app/components/Analysis/components/Trend/Filter.tsx index 9bc0f86d..2c7c1600 100644 --- a/src/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/app/components/Analysis/components/Trend/Filter.tsx @@ -6,31 +6,25 @@ */ import type { ElementDatePickerShortcut } from "@src/element-ui/date" -import type { CalendarMessage } from "@i18n/message/common/calendar" import { t } from "@app/locale" import { ElDatePicker } from "element-plus" import { defineComponent, ref, type PropType } from "vue" import { daysAgo } from "@util/time" +import { EL_DATE_FORMAT } from "@i18n/element" -function datePickerShortcut(msgKey: keyof CalendarMessage['range'], agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { +function datePickerShortcut(agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { return { - text: t(msg => msg.calendar.range[msgKey]), - value: daysAgo(agoOfStart - 1 || 0, agoOfEnd || 0) + text: t(msg => msg.calendar.range.lastDays, { n: agoOfStart }), + value: daysAgo(agoOfStart - 1 || 0, agoOfEnd || 0), } } -const DATE_FORMAT = t(msg => msg.calendar.dateFormat, { - y: 'YYYY', - m: 'MM', - d: 'DD' -}) - const SHORTCUTS = [ - datePickerShortcut('last7Days', 7), - datePickerShortcut('last15Days', 15), - datePickerShortcut('last30Days', 30), - datePickerShortcut("last90Days", 90) + datePickerShortcut(7), + datePickerShortcut(15), + datePickerShortcut(30), + datePickerShortcut(90), ] const _default = defineComponent({ @@ -47,7 +41,7 @@ const _default = defineComponent({ date.getTime() > new Date().getTime()} - format={DATE_FORMAT} + format={EL_DATE_FORMAT} type="daterange" shortcuts={SHORTCUTS} rangeSeparator="-" diff --git a/src/app/components/Dashboard/DashboardCard.tsx b/src/app/components/Dashboard/DashboardCard.tsx index 38442d86..f824b2dd 100644 --- a/src/app/components/Dashboard/DashboardCard.tsx +++ b/src/app/components/Dashboard/DashboardCard.tsx @@ -8,8 +8,15 @@ import { ElCard, ElCol } from "element-plus" import { defineComponent } from "vue" +const clzName = (noPadding: boolean) => { + const names = ['dashboard-card'] + noPadding && names.push('no-padding') + return names.join(' ') +} + const _default = defineComponent({ props: { + noPadding: Boolean, span: { type: Number, required: true @@ -18,7 +25,7 @@ const _default = defineComponent({ setup(props, ctx) { return () => ( - + ) } diff --git a/src/app/components/Dashboard/components/Calendar/Wrapper.ts b/src/app/components/Dashboard/components/Calendar/Wrapper.ts index 7f548f73..e0586d7e 100644 --- a/src/app/components/Dashboard/components/Calendar/Wrapper.ts +++ b/src/app/components/Dashboard/components/Calendar/Wrapper.ts @@ -6,14 +6,14 @@ */ import type { TitleComponentOption, TooltipComponentOption, GridComponentOption, VisualMapComponentOption } from "echarts/components" import { TitleComponent, TooltipComponent, GridComponent, VisualMapComponent } from "echarts/components" -import { HeatmapChart, type HeatmapSeriesOption } from "echarts/charts" +import { ScatterChart, ScatterSeriesOption, type HeatmapSeriesOption } from "echarts/charts" import { use, type ComposeOption } from "echarts/core" import { SVGRenderer } from "echarts/renderers" // Register echarts use([ SVGRenderer, - HeatmapChart, + ScatterChart, TooltipComponent, GridComponent, VisualMapComponent, @@ -30,6 +30,7 @@ import { BASE_TITLE_OPTION } from "../../common" import { getAppPageUrl } from "@util/constant/url" import { REPORT_ROUTE } from "@app/router/constants" import { createTabAfterCurrent } from "@api/chrome/tab" +import { getStepColors } from "@app/util/echarts" type _Value = [ x: number, @@ -39,7 +40,7 @@ type _Value = [ ] type EcOption = ComposeOption< - | HeatmapSeriesOption + | ScatterSeriesOption | TitleComponentOption | TooltipComponentOption | GridComponentOption @@ -58,11 +59,7 @@ function formatTooltip(mills: number, date: string): string { const d = date.substring(6, 8) const dateStr = t(msg => msg.calendar.dateFormat, { y, m, d }) const timeStr = formatPeriodCommon(mills) - return `${dateStr}
${timeStr}` -} - -function getGridColors() { - return ['#9be9a8', '#40c263', '#30a04e', '#216039'] + return `${dateStr}
${timeStr}` } function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { @@ -105,11 +102,48 @@ const cvtHeatmapItem = (d: _Value): HeatmapItem => { return item } -function optionOf(data: _Value[], weekDays: string[]): EcOption { +type Piece = { + label: string + min: number + max: number + color?: string +} + +const minOf = (min: number) => min * 60 * 1000 +const hourOf = (hour: number) => hour * 60 * 60 * 1000 + +const ALL_PIECES: Piece[] = [ + { min: 1, max: minOf(10), label: "<10m" }, + { min: minOf(10), max: minOf(30), label: "<30m" }, + { min: minOf(30), max: hourOf(1), label: "<1h" }, + { min: hourOf(1), max: hourOf(2), label: "<2h" }, + { min: hourOf(2), max: hourOf(4), label: "<4h" }, + { min: hourOf(4), max: hourOf(7), label: "<7h" }, + { min: hourOf(7), max: hourOf(12), label: "<12h" }, + { min: hourOf(12), max: hourOf(18), label: "<18h" }, + { min: hourOf(18), max: hourOf(24), label: ">=18h" }, +] + +const computePieces = (min: number, max: number): Piece[] => { + let pieces = ALL_PIECES.filter((p, i) => i === 0 || p.min <= max) + pieces = pieces.filter((p, i) => p.max > min || i === pieces.length - 1) + + const colors = getStepColors(pieces.length) + return pieces.map((p, idx) => ({ ...p, color: colors[idx] })) +} + +function optionOf(data: _Value[], weekDays: string[], dom: HTMLElement): EcOption { const totalMills = sum(data?.map(d => d[2] ?? 0)) const totalHours = Math.floor(totalMills / MILL_PER_HOUR) const xAxisLabelMap = getXAxisLabelMap(data) const textColor = getPrimaryTextColor() + const w = dom?.getBoundingClientRect?.()?.width + const gridWidth = 0.85 + const colCount = new Set(data.map(v => v[0])).size + const gridCellSize = colCount ? w * gridWidth / colCount * 0.75 : 0 + + const maxVal = Math.max(...data.map(a => a[2])) + const minVal = Math.min(...data.map(a => a[2]).filter(v => v)) return { title: { ...BASE_TITLE_OPTION, @@ -125,7 +159,7 @@ function optionOf(data: _Value[], weekDays: string[]): EcOption { return mills ? formatTooltip(mills as number, date) : undefined }, }, - grid: { height: '70%', width: '82%', left: '8%', top: '18%', }, + grid: { height: '70%', left: '7%', width: `${gridWidth * 100}%`, top: '18%', }, xAxis: { type: 'category', axisLine: { show: false }, @@ -145,22 +179,23 @@ function optionOf(data: _Value[], weekDays: string[]): EcOption { axisTick: { show: false, alignWithLabel: true }, }, visualMap: { - min: 0, - max: Math.max(...data.map(a => a[2])), - inRange: { color: getGridColors() }, + type: 'piecewise', realtime: true, calculable: true, orient: 'vertical', right: '2%', top: 'center', dimension: 2, - textStyle: { color: textColor }, + splitNumber: 6, + showLabel: true, + pieces: computePieces(minVal, maxVal), + textStyle: { color: getPrimaryTextColor() }, }, series: { - type: 'heatmap', + type: 'scatter', data: data.map(cvtHeatmapItem), - progressive: 5, - progressiveThreshold: 10, + symbol: 'circle', + symbolSize: gridCellSize, }, } } @@ -203,7 +238,7 @@ class Wrapper extends EchartsWrapper { // Saturday to Sunday rotate(weekDays, 1) } - return optionOf(data, weekDays) + return optionOf(data, weekDays, this.getDom()) } protected afterInit(): void { diff --git a/src/app/components/Dashboard/components/MonthOnMonth.tsx b/src/app/components/Dashboard/components/MonthOnMonth.tsx new file mode 100644 index 00000000..61f31f84 --- /dev/null +++ b/src/app/components/Dashboard/components/MonthOnMonth.tsx @@ -0,0 +1,166 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ComposeOption } from "echarts/core" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" + +import { use } from "echarts/core" +import { BarChart, BarSeriesOption } from "echarts/charts" +import { + GridComponent, type GridComponentOption, + TitleComponent, type TitleComponentOption, + TooltipComponent, type TooltipComponentOption, + LegendComponent, type LegendComponentOption, +} from "echarts/components" + +use([BarChart, GridComponent, TitleComponent, TooltipComponent, LegendComponent]) + +import { formatPeriodCommon, MILL_PER_DAY } from "@util/time" +import { defineComponent } from "vue" +import statService from "@service/stat-service" +import { groupBy, sum } from "@util/array" +import { BASE_TITLE_OPTION } from "../common" +import { t } from "@app/locale" +import { getPrimaryTextColor } from "@util/style" +import DateIterator from "@util/date-iterator" +import { cvt2LocaleTime } from "@app/util/time" +import { getCompareColor, getDiffColor, tooltipDot } from "@app/util/echarts" +import { EchartsWrapper, useEcharts } from "@hooks" + +type EcOption = ComposeOption< + | BarSeriesOption + | GridComponentOption + | TitleComponentOption + | TooltipComponentOption + | LegendComponentOption +> + +const PERIOD_WIDTH = 30 +const TOP_NUM = 15 + +type _Value = { + value: number + row: Row +} + +function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[]): EcOption { + const textColor = getPrimaryTextColor() + + const [color1, color2] = getCompareColor() + const [incColor, decColor] = getDiffColor() + + return { + title: { + ...BASE_TITLE_OPTION, + text: t(msg => msg.dashboard.monthOnMonth.title, { k: TOP_NUM }), + textStyle: { + color: textColor, + fontSize: '14px', + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter(params: TopLevelFormatterParams) { + if (!Array.isArray(params)) return '' + const [thisItem, lastItem] = params.map(v => v.data as _Value).map(v => v.row) || [] + const [thisColor, lastColor] = params.map(v => v.color) + const { date: thisDate, total: thisVal } = thisItem || {} + const { date: lastDate, total: lastVal } = lastItem || {} + const lastStr = `${tooltipDot(lastColor as string)} ${cvt2LocaleTime(lastDate)} ${formatPeriodCommon(lastVal)}` + let thisStr = `${tooltipDot(thisColor as string)} ${cvt2LocaleTime(thisDate)} ${formatPeriodCommon(thisVal)}` + if (lastVal) { + const delta = (thisVal - lastVal) / lastVal * 100 + let deltaStr = delta.toFixed(1) + '%' + if (delta >= 0) deltaStr = '+' + deltaStr + const fontColor = delta >= 0 ? incColor : decColor + thisStr += ` ${deltaStr}` + } + return `${thisStr}
${lastStr}` + }, + }, + grid: { + left: '5%', + right: '5%', + bottom: '3%', + top: '11%', + }, + xAxis: { + type: 'category', + splitLine: { show: false }, + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { show: false }, + }, + yAxis: [ + { + type: 'value', + axisLabel: { show: false }, + splitLine: { show: false }, + } + ], + series: [ + { + name: "This Month", + stack: "one", + type: 'bar', + barCategoryGap: '55%', + itemStyle: { color: color1, borderRadius: [10, 10, 0, 0] }, + data: thisPeriodItems.map(row => ({ value: row.total, row })), + }, { + name: "Last Month", + stack: "one", + type: 'bar', + itemStyle: { color: color2, borderRadius: [0, 0, 10, 10] }, + data: lastPeriodItems.map(row => ({ value: -row.total, row })), + } + ], + } +} + +class ChartWrapper extends EchartsWrapper<[Row[], Row[]], EcOption> { + generateOption = ([lastPeriodItems, thisPeriodItems]) => optionOf(lastPeriodItems, thisPeriodItems) +} + +type Row = { + date: string + total: number +} + +const cvtRow = (rows: timer.stat.Row[], start: Date, end: Date): Row[] => { + const groupByDate = groupBy(rows, r => r.date, l => sum(l.map(e => e.focus ?? 0))) + const iterator = new DateIterator(start, end) + const result: Row[] = [] + iterator.forEach(yearMonthDate => { + const total = groupByDate[yearMonthDate] ?? 0 + result.push({ total, date: yearMonthDate }) + }) + return result +} + +const fetchData = async (): Promise<[thisMonth: Row[], lastMonth: Row[]]> => { + const now = new Date() + const lastPeriodStart = new Date(now.getTime() - MILL_PER_DAY * (PERIOD_WIDTH * 2 - 1)) + const lastPeriodEnd = new Date(now.getTime() - MILL_PER_DAY * PERIOD_WIDTH) + const thisPeriodStart = new Date(now.getTime() - MILL_PER_DAY * (PERIOD_WIDTH - 1)) + const thisPeriodEnd = now + + // Query with alias + // @since 1.1.8 + const lastPeriodItems: timer.stat.Row[] = await statService.select({ date: [lastPeriodStart, lastPeriodEnd] }, true) + const lastRows = cvtRow(lastPeriodItems, lastPeriodStart, lastPeriodEnd) + const thisPeriodItems: timer.stat.Row[] = await statService.select({ date: [thisPeriodStart, thisPeriodEnd] }, true) + const thisRows = cvtRow(thisPeriodItems, thisPeriodStart, thisPeriodEnd) + return [lastRows, thisRows] +} + +const _default = defineComponent(() => { + const { elRef } = useEcharts(ChartWrapper, fetchData) + return () =>
+}) + +export default _default \ No newline at end of file diff --git a/src/app/components/Dashboard/components/TopKVisit/Wrapper.ts b/src/app/components/Dashboard/components/TopKVisit/Wrapper.ts index 2e28b03e..9652ef8d 100644 --- a/src/app/components/Dashboard/components/TopKVisit/Wrapper.ts +++ b/src/app/components/Dashboard/components/TopKVisit/Wrapper.ts @@ -17,7 +17,7 @@ import { getPrimaryTextColor } from "@util/style" import { BASE_TITLE_OPTION } from "../../common" import { t } from "@app/locale" import { generateSiteLabel } from "@util/site" -import { echartsPalette } from "@util/echarts" +import { getSeriesPalette } from "@app/util/echarts" use([PieChart, TitleComponent, TooltipComponent, SVGRenderer]) @@ -56,7 +56,7 @@ function generateOption(data: BizOption[]): EcOption { const host = params.data?.host || '' const alias = params.data?.alias || '' const hostLabel = generateSiteLabel(host, alias) - return `${hostLabel}
${visit}` + return `${hostLabel}
${visit}` } }, series: { @@ -66,7 +66,7 @@ function generateOption(data: BizOption[]): EcOption { radius: [20, 80], center: ['50%', '50%'], roseType: 'area', - color: echartsPalette(), + color: getSeriesPalette(), itemStyle: { borderRadius: 7 }, diff --git a/src/app/components/Dashboard/components/WeekOnWeek/Wrapper.ts b/src/app/components/Dashboard/components/WeekOnWeek/Wrapper.ts deleted file mode 100644 index 9fcd3ab4..00000000 --- a/src/app/components/Dashboard/components/WeekOnWeek/Wrapper.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { EchartsWrapper } from "@hooks" -import { use, type ComposeOption } from "echarts/core" -import { CandlestickChart, type CandlestickSeriesOption } from "echarts/charts" -import type { GridComponentOption, TitleComponentOption, TooltipComponentOption } from "echarts/components" -import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components" -import { getPrimaryTextColor } from "@util/style" -import { groupBy, sum } from "@util/array" -import { formatPeriodCommon } from "@util/time" -import { t } from "@app/locale" -import { generateSiteLabel } from "@util/site" -import { BASE_TITLE_OPTION } from "../../common" - -use([CandlestickChart, GridComponent, TitleComponent, TooltipComponent]) - -type EcOption = ComposeOption< - | CandlestickSeriesOption - | GridComponentOption - | TitleComponentOption - | TooltipComponentOption -> - -type _Value = { - lastPeriod: number - thisPeriod: number - delta: number - host: string -} - -const TOP_NUM = 5 -const X_AXIS_LABEL_MAX_LENGTH = 16 - -function calculateXAxisLabel(host: string, hostAliasMap: Record) { - const originLabel = hostAliasMap[host] || host - const originLength = originLabel?.length - if (!originLength || originLength <= X_AXIS_LABEL_MAX_LENGTH) { - return originLabel - } - return originLabel.substring(0, X_AXIS_LABEL_MAX_LENGTH - 3) + '...' -} - -function optionOf(lastPeriodItems: timer.stat.Row[], thisPeriodItems: timer.stat.Row[]): EcOption { - const textColor = getPrimaryTextColor() - - const hostAliasMap: { [host: string]: string } = { - ...groupBy(lastPeriodItems, item => item.host, grouped => grouped?.[0]?.alias), - ...groupBy(thisPeriodItems, item => item.host, grouped => grouped?.[0]?.alias) - } - - const lastPeriodMap: { [host: string]: number } = groupBy(lastPeriodItems, - item => item.host, - grouped => Math.floor(sum(grouped.map(item => item.focus)) / 1000) - ) - - const thisPeriodMap: { [host: string]: number } = groupBy(thisPeriodItems, - item => item.host, - grouped => Math.floor(sum(grouped.map(item => item.focus)) / 1000) - ) - const values: { [host: string]: _Value } = {} - // 1st, iterate this period - Object.entries(thisPeriodMap) - .forEach(([host, thisPeriod]) => { - const lastPeriod = lastPeriodMap[host] || 0 - const delta = thisPeriod - lastPeriod - values[host] = { thisPeriod, lastPeriod, delta, host } - }) - // 2nd, iterate last period - Object.entries(lastPeriodMap) - .filter(([host]) => !values[host]) - .forEach(([host, lastPeriod]) => { - const thisPeriod = thisPeriodMap[host] || 0 - const delta = thisPeriod - lastPeriod - values[host] = { thisPeriod, lastPeriod, delta, host } - }) - // 3rd, sort by delta - const sortedValues = Object.values(values) - .sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta)) - .reverse() - const topK = sortedValues.slice(0, TOP_NUM) - // 4th, sort by max value - topK.sort((a, b) => Math.max(a.lastPeriod, a.thisPeriod) - Math.max(b.lastPeriod, b.thisPeriod)) - - const positiveColor = getComputedStyle(document.body).getPropertyValue('--timer-chart-increase-color') - const negativeColor = getComputedStyle(document.body).getPropertyValue('--timer-chart-decrease-color') - return { - title: { - ...BASE_TITLE_OPTION, - text: t(msg => msg.dashboard.weekOnWeek.title, { k: TOP_NUM }), - textStyle: { - color: textColor, - fontSize: '14px', - } - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow' - }, - formatter(params: any) { - const data = params?.[0]?.data - const host = params?.[0]?.axisValue - const lastPeriod = data[1] || 0 - const thisPeriod = data[2] || 0 - const lastLabel = t(msg => msg.dashboard.weekOnWeek.lastBrowse, { time: formatPeriodCommon(lastPeriod * 1000) }) - const thisLabel = t(msg => msg.dashboard.weekOnWeek.thisBrowse, { time: formatPeriodCommon(thisPeriod * 1000) }) - const deltaLabel = t(msg => msg.dashboard.weekOnWeek.wow, { - delta: formatPeriodCommon(Math.abs(thisPeriod - lastPeriod) * 1000), - state: t(msg => msg.dashboard.weekOnWeek[thisPeriod < lastPeriod ? 'decline' : 'increase']) - }) - const siteLabel = generateSiteLabel(host, hostAliasMap[host]) - return `${siteLabel}
${lastLabel}
${thisLabel}
${deltaLabel}` - } - }, - grid: { - left: '7%', - right: '2%', - bottom: '10%', - }, - xAxis: { - type: 'category', - splitLine: { show: false }, - data: topK.map(a => a.host), - axisLabel: { - interval: 0, - color: textColor, - formatter: (host: string) => calculateXAxisLabel(host, hostAliasMap) - }, - axisTick: { - show: false, - } - }, - yAxis: { - type: 'value', - splitLine: { - show: false, - }, - axisLabel: { - color: textColor, - }, - axisLine: { - show: true, - } - }, - series: [{ - type: 'candlestick', - barMaxWidth: '40px', - itemStyle: { - color: positiveColor, - borderColor: positiveColor, - borderColor0: negativeColor, - color0: negativeColor, - }, - data: topK.map(a => [a.lastPeriod, a.thisPeriod, a.lastPeriod, a.thisPeriod]) - }] - } -} - - -export default class WeekOnWeekWrapper extends EchartsWrapper { - generateOption = ([lastPeriodItems, thisPeriodItems]) => optionOf(lastPeriodItems, thisPeriodItems) -} diff --git a/src/app/components/Dashboard/components/WeekOnWeek/index.tsx b/src/app/components/Dashboard/components/WeekOnWeek/index.tsx deleted file mode 100644 index 9c1d8fb1..00000000 --- a/src/app/components/Dashboard/components/WeekOnWeek/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import type { StatQueryParam } from "@service/stat-service" -import { MILL_PER_DAY } from "@util/time" -import { defineComponent } from "vue" -import statService from "@service/stat-service" -import { useEcharts } from "@hooks" -import Wrapper from "./Wrapper" - -const PERIOD_WIDTH = 7 - -const fetchData = async (): Promise => { - const now = new Date() - const lastPeriodStart = new Date(now.getTime() - MILL_PER_DAY * PERIOD_WIDTH * 2) - const lastPeriodEnd = new Date(lastPeriodStart.getTime() + MILL_PER_DAY * (PERIOD_WIDTH - 1)) - const thisPeriodStart = new Date(now.getTime() - MILL_PER_DAY * PERIOD_WIDTH) - // Not includes today - const thisPeriodEnd = new Date(now.getTime() - MILL_PER_DAY) - const query: StatQueryParam = { - date: [lastPeriodStart, lastPeriodEnd], - mergeDate: true, - } - // Query with alias - // @since 1.1.8 - const lastPeriodItems: timer.stat.Row[] = await statService.select(query, true) - query.date = [thisPeriodStart, thisPeriodEnd] - const thisPeriodItems: timer.stat.Row[] = await statService.select(query, true) - return [lastPeriodItems, thisPeriodItems] -} - -const _default = defineComponent(() => { - const { elRef } = useEcharts(Wrapper, fetchData) - return () =>
-}) - -export default _default \ No newline at end of file diff --git a/src/app/components/Dashboard/index.tsx b/src/app/components/Dashboard/index.tsx index df0746ae..706e8035 100644 --- a/src/app/components/Dashboard/index.tsx +++ b/src/app/components/Dashboard/index.tsx @@ -12,7 +12,7 @@ import "./style" import { isTranslatingLocale, locale } from "@i18n" import { ElRow } from "element-plus" import Indicator from "./components/Indicator" -import WeekOnWeek from "./components/WeekOnWeek" +import MonthOnMonth from "./components/MonthOnMonth" import TopKVisit from "./components/TopKVisit" import Calendar from "./components/Calendar" import { useRouter } from "vue-router" @@ -21,6 +21,8 @@ import metaService from "@service/meta-service" import { t } from "@app/locale" import { REVIEW_PAGE } from "@util/constant/url" +const ROW_GUTTER = 15 + const _default = defineComponent(() => { const router = useRouter() const jump2Help = () => router.push({ path: "/other/help" }) @@ -35,18 +37,18 @@ const _default = defineComponent(() => { return () => ( - + - + - + diff --git a/src/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index 15994c7d..d8cc12d6 100644 --- a/src/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -11,6 +11,7 @@ import { formatTime, MILL_PER_DAY } from "@util/time" import { t } from "@app/locale" import { DataManageMessage } from "@i18n/message/app/data-manage" import I18nNode from "@app/components/common/I18nNode" +import { EL_DATE_FORMAT } from "@i18n/element" const yesterday = new Date().getTime() - MILL_PER_DAY const daysBefore = (days: number) => new Date().getTime() - days * MILL_PER_DAY @@ -32,7 +33,6 @@ const pickerShortcuts = [ datePickerShortcut('till30DaysAgo', 30) ] -const dateFormat = t(msg => msg.calendar.dateFormat, { y: 'YYYY', m: 'MM', d: 'DD' }) // The birthday of browser const startPlaceholder = t(msg => msg.calendar.dateFormat, { y: '1994', m: '12', d: '15' }) const endPlaceholder = formatTime(yesterday, t(msg => msg.calendar.dateFormat)) @@ -58,7 +58,7 @@ const _default = defineComponent({ style={{ width: "250px" }} startPlaceholder={startPlaceholder} endPlaceholder={endPlaceholder} - dateFormat={dateFormat} + dateFormat={EL_DATE_FORMAT} type="daterange" disabledDate={(date: Date) => date.getTime() > yesterday} shortcuts={pickerShortcuts} diff --git a/src/app/components/DataManage/ClearPanel/index.tsx b/src/app/components/DataManage/ClearPanel/index.tsx index c3eb06f0..5615da8f 100644 --- a/src/app/components/DataManage/ClearPanel/index.tsx +++ b/src/app/components/DataManage/ClearPanel/index.tsx @@ -43,10 +43,9 @@ function generateParamAndSelect(option: FilterOption): Promise * @param mustInteger must be integer? * @returns true when has error, or false */ -function assertQueryParam(range: number[], mustInteger?: boolean): boolean { +function assertQueryParam(range: Vector<2>, mustInteger?: boolean): boolean { const reg = mustInteger ? /^[0-9]+$/ : /^[0-9]+.?[0-9]*$/ - const start = range[0] - const end = range[1] + const [start, end] = range || [] const noStart = start !== undefined && start !== null const noEnd = end !== undefined && end !== null return (noStart && !reg.test(start.toString())) @@ -73,7 +72,7 @@ function checkParam(option: FilterOption): StatQueryParam | undefined { return condition } -function str2Range(startAndEnd: [string, string], numAmplifier?: (origin: number) => number): [number, number] { +function str2Range(startAndEnd: [string, string], numAmplifier?: (origin: number) => number): Vector<2> { const startStr = startAndEnd[0] const endStr = startAndEnd[1] let start = str2Num(startStr, 0) diff --git a/src/app/components/Habit/components/HabitFilter.tsx b/src/app/components/Habit/components/HabitFilter.tsx index 08d19706..5a449fe4 100644 --- a/src/app/components/Habit/components/HabitFilter.tsx +++ b/src/app/components/Habit/components/HabitFilter.tsx @@ -5,36 +5,31 @@ * https://opensource.org/licenses/MIT */ -import type { CalendarMessage } from "@i18n/message/common/calendar" - import { defineComponent, watch, type PropType } from "vue" import { daysAgo } from "@util/time" import { t } from "@app/locale" import { ElementDatePickerShortcut } from "@src/element-ui/date" import DateRangeFilterItem from "@app/components/common/DateRangeFilterItem" import TimeFormatFilterItem from "@app/components/common/TimeFormatFilterItem" -import { FilterOption } from "../type" import { useState } from "@hooks" -type ShortCutProp = [label: keyof CalendarMessage['range'], dayAgo: number] +export type FilterOption = { + timeFormat: timer.app.TimeFormat + dateRange: [Date, Date] +} + +type ShortCutProp = [label: string, dayAgo: number] const shortcutProps: ShortCutProp[] = [ - ["last24Hours", 1], - ["last3Days", 3], - ["last7Days", 7], - ["last15Days", 15], - ["last30Days", 30], - ["last60Days", 60] + [t(msg => msg.calendar.range.today), 0], + [t(msg => msg.calendar.range.lastDays, { n: 3 }), 3], + [t(msg => msg.calendar.range.lastDays, { n: 7 }), 7], + [t(msg => msg.calendar.range.lastDays, { n: 15 }), 15], + [t(msg => msg.calendar.range.lastDays, { n: 30 }), 30], + [t(msg => msg.calendar.range.lastDays, { n: 60 }), 60], ] -function datePickerShortcut(msg: keyof CalendarMessage['range'], agoOfStart: number): ElementDatePickerShortcut { - return { - text: t(messages => messages.calendar.range[msg]), - value: daysAgo(agoOfStart, 0) - } -} - -const SHORTCUTS: ElementDatePickerShortcut[] = shortcutProps.map(([label, dayAgo]) => datePickerShortcut(label, dayAgo)) +const SHORTCUTS: ElementDatePickerShortcut[] = shortcutProps.map(([text, agoOfStart]) => ({ text, value: daysAgo(agoOfStart, 0) })) const _default = defineComponent({ props: { @@ -55,7 +50,6 @@ const _default = defineComponent({ return () => <> date.getTime() > new Date().getTime()} defaultRange={dateRange.value} shortcuts={SHORTCUTS} onChange={setDateRange} diff --git a/src/app/components/Habit/components/Period/Average/Wrapper.tsx b/src/app/components/Habit/components/Period/Average/Wrapper.tsx new file mode 100644 index 00000000..93a1dddb --- /dev/null +++ b/src/app/components/Habit/components/Period/Average/Wrapper.tsx @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { getCompareColor, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" +import { EchartsWrapper } from "@hooks" +import { averageByDay, MINUTE_PER_PERIOD } from "@util/period" +import { getPrimaryTextColor } from "@util/style" +import { formatPeriodCommon, MILL_PER_MINUTE } from "@util/time" +import { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" +import { BarChart } from "echarts/charts" +import { GridComponent, TooltipComponent } from "echarts/components" +import { use } from "echarts/core" +import { SVGRenderer } from "echarts/renderers" +import { TopLevelFormatterParams } from "echarts/types/dist/shared" +import { generateGridOption } from "../common" + +use([SVGRenderer, BarChart, GridComponent, TooltipComponent]) + +type EcOption = ComposeOption< + | BarSeriesOption + | GridComponentOption + | TooltipComponentOption +> + +export type BizOption = { + currRange: timer.period.KeyRange + prevRange: timer.period.KeyRange + curr: timer.period.Row[] + prev: timer.period.Row[] + periodSize: number +} + +const [CURR_COLOR, PREV_COLOR] = getCompareColor() + +const cvt2Item = (row: timer.period.Row): number => { + const milliseconds = row.milliseconds + return milliseconds +} + +const formatXAxis = (idx: number, periodSize: number) => { + let min = idx * periodSize * MINUTE_PER_PERIOD + const hour = Math.floor(min / 60) + min = min - hour * 60 + return hour.toString().padStart(2, '0') + ':' + min.toString().padStart(2, '0') +} + +const key2Str = (key: timer.period.Key) => { + const { month, date } = key + return `${month?.toString?.()?.padStart(2, '0')}/${date?.toString?.()?.padStart(2, '0')}` +} + +const isSameDay = (keyRange: timer.period.KeyRange): boolean => { + const [start, end] = keyRange || [] + return start?.year === end?.year + && start?.month === end?.month + && start?.date === end?.date +} + +const range2Str = (keyRange: timer.period.KeyRange) => { + const [start, end] = keyRange + return isSameDay(keyRange) ? key2Str(start) : `${key2Str(start)}-${key2Str(end)}` +} + +const formatValueLine = (mill: number, range: timer.period.KeyRange, color: string): string => { + return tooltipFlexLine( + `${tooltipDot(color)} ${formatPeriodCommon(mill ?? 0)}`, + range2Str(range), + ) +} + +const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string => { + const { periodSize, prevRange, currRange } = biz + if (!Array.isArray(params)) return '' + const [curr, prev] = params || [] + + const idx = curr.dataIndex + const start = formatXAxis(idx, periodSize) + const end = formatXAxis(idx + 1, periodSize) + + const periodStr = `${start}-${end}` + const timeLine = isSameDay(currRange) + ? periodStr + : tooltipFlexLine( + t(msg => msg.habit.period.chartType.average), + periodStr, + ) + + const currLine = formatValueLine(curr.value as number, currRange, CURR_COLOR) + const prevLine = formatValueLine(-(prev?.value ?? 0 as number), prevRange, PREV_COLOR) + + return `${timeLine}${tooltipSpaceLine()}${currLine}${prevLine}` +} + +const generateOption = (biz: BizOption): EcOption => { + let { curr, prev, periodSize } = biz + + curr = averageByDay(curr, periodSize) + prev = averageByDay(prev, periodSize) + + const currData = curr.map(r => cvt2Item(r)) + const prevData = prev.map(r => cvt2Item(({ ...r, milliseconds: -r.milliseconds }))) + + const textColor = getPrimaryTextColor() + const borderRadius = 5 * periodSize + + return { + tooltip: { + trigger: 'axis', + formatter: (params: TopLevelFormatterParams) => formatTooltip(params, biz), + }, + grid: generateGridOption(), + xAxis: { + type: 'category', + axisLabel: { + color: textColor, + interval: (16 / periodSize - 1), + formatter: (_, index) => formatXAxis(index, periodSize), + }, + axisLine: { show: false }, + axisTick: { show: false }, + min: 0, + max: currData.length, + offset: -borderRadius * 2, + }, + yAxis: { + type: 'value', + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + max: Math.max(...currData) + MILL_PER_MINUTE, + min: Math.min(...prevData) - MILL_PER_MINUTE, + }, + series: [ + { + type: "bar", + stack: 'one', + large: true, + data: currData, + barCategoryGap: '50%', + color: CURR_COLOR, + itemStyle: { borderRadius: [borderRadius, borderRadius, 0, 0] }, + }, { + type: "bar", + stack: 'one', + large: true, + data: prevData, + color: PREV_COLOR, + itemStyle: { borderRadius: [0, 0, borderRadius, borderRadius] }, + } + ], + } +} + +export default class Wrapper extends EchartsWrapper { + generateOption = generateOption +} \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/Average/index.tsx b/src/app/components/Habit/components/Period/Average/index.tsx new file mode 100644 index 00000000..98d7ea5e --- /dev/null +++ b/src/app/components/Habit/components/Period/Average/index.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { StyleValue } from "vue" +import Wrapper, { BizOption } from "./Wrapper" +import { computed, defineComponent } from "vue" +import { usePeriodFilter, usePeriodRange, usePeriodValue } from "../context" +import { useEcharts } from "@hooks" + +const CONTAINER_STYLE: StyleValue = { + width: "100%", + height: "100%", +} + +const _default = defineComponent(() => { + const value = usePeriodValue() + const filter = usePeriodFilter() + const periodRange = usePeriodRange() + const bizOption = computed(() => { + const { curr, prev } = value.value || {} + const { curr: currRange, prev: prevRange } = periodRange.value || {} + const { periodSize } = filter.value || {} + return { + curr, prev, + currRange, prevRange, + periodSize, + } + }) + const { elRef } = useEcharts(Wrapper, bizOption, { manual: true }) + return () =>
+}) + +export default _default \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/BarWrapper.ts b/src/app/components/Habit/components/Period/BarWrapper.ts deleted file mode 100644 index 4ec92930..00000000 --- a/src/app/components/Habit/components/Period/BarWrapper.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { ComposeOption } from "echarts/core" -import type { BarSeriesOption } from "echarts/charts" -import type { GridComponentOption, TooltipComponentOption } from "echarts/components" - -import { use } from "echarts/core" -import { BarChart } from "echarts/charts" -import { SVGRenderer } from "echarts/renderers" -import { TooltipComponent, GridComponent } from "echarts/components" -import { formatPeriodCommon, formatTime } from "@util/time" -import { t } from "@app/locale" -import { getPrimaryTextColor } from "@util/style" -import { averageByDay } from "./common" -import { EchartsWrapper } from "@hooks" - -use([BarChart, SVGRenderer, TooltipComponent, GridComponent]) - -type EcOption = ComposeOption< - | BarSeriesOption - | GridComponentOption - | TooltipComponentOption -> - -type BizOption = { - data: timer.period.Row[] - averageByDate: boolean - periodSize: number -} - -function formatXAxis(ts: number) { - const date = new Date(ts) - if (date.getHours() === 0 && date.getMinutes() === 0) { - return formatTime(date, '{m}-{d}') - } else { - return formatTime(date, '{h}:{i}') - } -} - -function formatTimeOfEcharts(params: any, averageByDate: boolean): string { - const format = params instanceof Array ? params[0] : params - const { value, data } = format - const milliseconds = data?.[4] || 0 - // If average, don't show the date - const start = formatTime(value[2], averageByDate ? '{h}:{i}' : '{m}-{d} {h}:{i}') - const end = formatTime(value[3], '{h}:{i}') - return `${formatPeriodCommon(Math.floor(milliseconds))}
${start}-${end}` -} - -const Y_AXIS_MIN = t(msg => msg.habit.period.yAxisMin) -const Y_AXIS_HOUR = t(msg => msg.habit.period.yAxisHour) - -function getYAxisName(periodSize: number) { - return periodSize === 8 ? Y_AXIS_HOUR : Y_AXIS_MIN -} - -function getYAxisValue(milliseconds: number, periodSize: number) { - const seconds = Math.floor(milliseconds / 1000) - const minutes = Number.parseFloat((seconds / 60).toFixed(1)) - const hours = Number.parseFloat((minutes / 60).toFixed(1)) - return periodSize === 8 ? hours : minutes -} - -type BarItem = BarSeriesOption["data"][number] - -const cvt2Item = (row: timer.period.Row, periodSize: number): BarItem => { - const startTime = row.startTime.getTime() - const endTime = row.endTime.getTime() - const x = (startTime + endTime) / 2 - const milliseconds = row.milliseconds - return [x, getYAxisValue(milliseconds, periodSize), startTime, endTime, milliseconds] -} - -function generateOption({ data, averageByDate, periodSize }: BizOption): EcOption { - const periodData: timer.period.Row[] = averageByDate ? averageByDay(data, periodSize) : data - const valueData: BarItem[] = periodData.map(i => cvt2Item(i, periodSize)) - const xAxisMin = periodData[0]?.startTime?.getTime() - const xAxisMax = periodData[periodData.length - 1]?.endTime?.getTime() - const xAxisAxisLabelFormatter = averageByDate ? '{HH}:{mm}' : formatXAxis - const textColor = getPrimaryTextColor() - - return { - tooltip: { - formatter: (params: any) => formatTimeOfEcharts(params, averageByDate) - }, - grid: { - top: 60, - bottom: 30, - left: 100, - right: 80, - }, - xAxis: { - axisLabel: { formatter: xAxisAxisLabelFormatter, color: textColor }, - type: 'time', - axisLine: { show: false }, - min: xAxisMin, - max: xAxisMax - }, - yAxis: { - type: 'value', - name: getYAxisName(periodSize), - nameTextStyle: { color: textColor, lineHeight: 40 }, - axisLabel: { color: textColor }, - }, - series: [{ - type: "bar", - large: true, - data: valueData, - barGap: '0%', // Make series be overlap - barCategoryGap: '0%' - }] - } -} -export default class ChartWrapper extends EchartsWrapper { - generateOption = generateOption -} \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/Filter.tsx b/src/app/components/Habit/components/Period/Filter.tsx index d647c2aa..5c0005f0 100644 --- a/src/app/components/Habit/components/Period/Filter.tsx +++ b/src/app/components/Habit/components/Period/Filter.tsx @@ -6,10 +6,12 @@ */ import SelectFilterItem from '@app/components/common/SelectFilterItem' -import SwitchFilterItem from '@app/components/common/SwitchFilterItem' import { t } from '@app/locale' +import { useCached } from '@hooks' import { HabitMessage } from '@i18n/message/app/habit' +import { ElRadioButton, ElRadioGroup } from 'element-plus' import { PropType, defineComponent, ref, watch } from 'vue' +import { ChartType, FilterOption } from './common' // [value, label] type _SizeOption = [number, keyof HabitMessage['period']['sizes']] @@ -28,9 +30,10 @@ function allOptions(): Record { return allOptions } -export type FilterOption = { - periodSize: number - average: boolean +const CHART_CONFIG: { [type in ChartType]: string } = { + average: t(msg => msg.habit.period.chartType.average), + trend: t(msg => msg.habit.period.chartType.trend), + stack: t(msg => msg.habit.period.chartType.stack), } const _default = defineComponent({ @@ -42,31 +45,43 @@ const _default = defineComponent({ }, setup(prop, ctx) { const periodSize = ref(prop.defaultValue?.periodSize || 1) - const average = ref(prop.defaultValue?.average || false) - watch([periodSize, average], () => + const { data: chartType, setter: setChartType } = useCached('habit-period-chart-type', prop.defaultValue?.chartType) + watch([periodSize, chartType], () => ctx.emit('change', { periodSize: periodSize.value, - average: average.value, + chartType: chartType.value, }) ) - return () => <> - { - const newPeriodSize = parseInt(val) - if (isNaN(newPeriodSize)) return - periodSize.value = newPeriodSize + return () => ( +
- msg.habit.period.averageLabel)} - defaultValue={average.value} - onChange={(val: boolean) => average.value = val} - /> - + > + { + const newPeriodSize = parseInt(val) + if (isNaN(newPeriodSize)) return + periodSize.value = newPeriodSize + }} + /> + val && setChartType(val as ChartType)} + > + {Object.entries(CHART_CONFIG).map(([type, name]) => ( + + {name} + + ))} + +
+ ) }, }) diff --git a/src/app/components/Habit/components/Period/Stack/Wrapper.ts b/src/app/components/Habit/components/Period/Stack/Wrapper.ts new file mode 100644 index 00000000..5cdbbdf9 --- /dev/null +++ b/src/app/components/Habit/components/Period/Stack/Wrapper.ts @@ -0,0 +1,81 @@ +import { getLineSeriesPalette } from "@app/util/echarts" +import { EchartsWrapper } from "@hooks" +import { ComposeOption, GridComponentOption, LineSeriesOption, TooltipComponentOption } from "echarts" +import { LineChart } from "echarts/charts" +import { GridComponent, TooltipComponent } from "echarts/components" +import { use } from "echarts/core" +import { SVGRenderer } from "echarts/renderers" +import { formatXAxisTime, generateGridOption } from "../common" +import { TopLevelFormatterParams } from "echarts/types/dist/shared" +import { formatTime } from "@util/time" +import { t } from "@app/locale" +import { periodFormatter } from "@app/util/time" + +use([LineChart, SVGRenderer, TooltipComponent, GridComponent]) + +type EcOption = ComposeOption< + | LineSeriesOption + | TooltipComponentOption + | GridComponentOption +> + +export type BizOption = { + data: timer.period.Row[] + timeFormat: timer.app.TimeFormat +} + +const [COLOR] = getLineSeriesPalette() + +const formatTooltip = (params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat) => { + const param = Array.isArray(params) ? params[0] : params + const [, total, , end] = param.data as number[] + return ` +
${formatTime(end, t(msg => msg.calendar.timeFormat))}
+
${periodFormatter(total, { format: timeFormat })}
+ ` +} + +const generateOption = (biz: BizOption): EcOption => { + const { data, timeFormat } = biz || {} + let stackVal: number = 0 + const seriesData = data.map(row => { + const startTime = row.startTime.getTime() + const endTime = row.endTime.getTime() + const time = (startTime + endTime) / 2 + const delta = row.milliseconds ?? 0 + stackVal += delta + return [time, stackVal, startTime, endTime, delta] + }) + + return { + grid: generateGridOption(), + tooltip: { + trigger: 'axis', + axisPointer: { type: "line" }, + formatter: params => formatTooltip(params, timeFormat), + }, + xAxis: { + type: 'time', + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { formatter: formatXAxisTime } + }, + yAxis: { + type: 'value', + axisLine: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + series: { + type: 'line', + data: seriesData, + areaStyle: { color: COLOR }, + showSymbol: false, + lineStyle: { width: 0 }, + } + } +} + +export default class Wrapper extends EchartsWrapper { + generateOption = generateOption +} \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/Stack/index.tsx b/src/app/components/Habit/components/Period/Stack/index.tsx new file mode 100644 index 00000000..693e68c5 --- /dev/null +++ b/src/app/components/Habit/components/Period/Stack/index.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { StyleValue } from "vue" +import Wrapper, { BizOption } from "./Wrapper" +import { computed, defineComponent } from "vue" +import { usePeriodValue } from "../context" +import { useEcharts } from "@hooks" +import { useHabitFilter } from "../../context" + +const CONTAINER_STYLE: StyleValue = { + width: "100%", + height: "100%", +} + +const _default = defineComponent(() => { + const value = usePeriodValue() + const globalFilter = useHabitFilter() + const bizOption = computed(() => ({ + data: value.value?.curr, + timeFormat: globalFilter.value?.timeFormat, + })) + const { elRef } = useEcharts(Wrapper, bizOption, { manual: true }) + return () =>
+}) + +export default _default \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/Summary.tsx b/src/app/components/Habit/components/Period/Summary.tsx new file mode 100644 index 00000000..afd90ffb --- /dev/null +++ b/src/app/components/Habit/components/Period/Summary.tsx @@ -0,0 +1,107 @@ +import { computed, defineComponent } from "vue" +import { usePeriodFilter, usePeriodValue } from "./context" +import { KanbanIndicatorCell } from "@app/components/common/kanban" +import { periodFormatter } from "@app/util/time" +import { t } from "@app/locale" +import { averageByDay } from "@util/period" +import { formatTime } from "@util/time" +import { useHabitFilter } from "../context" + +type Result = { + favorite: { + period: string + average: number + } + longestIdle: { + length: string + period: string + } +} + +const renderIndicator = (summary: Result, format: timer.app.TimeFormat) => { + const { + favorite: { period: favoritePeriod = null, average = null }, + longestIdle: { period: idlePeriod, length: idleLength }, + } = summary || {} + return <> +
+ msg.habit.period.busiest)} + mainValue={favoritePeriod} + subTips={msg => msg.habit.common.focusAverage} + subValue={periodFormatter(average, { format })} + /> +
+
+ msg.habit.period.idle)} + mainValue={idleLength} + subTips={() => idlePeriod} + /> +
+ +} + +const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => { + const averaged = averageByDay(rows, periodSize) + const favoriteRow = averaged.sort((b, a) => a.milliseconds - b.milliseconds)[0] + let favoritePeriod = '-' + if (favoriteRow) { + const start = favoriteRow.startTime + const end = favoriteRow.endTime + favoritePeriod = `${formatTime(start, "{h}:{i}")}-${formatTime(end, "{h}:{i}")}` + } + + let maxIdle: [timer.period.Row, timer.period.Row, number] = [, , 0] + + let idleStart: timer.period.Row = null, idleEnd: timer.period.Row = null + rows.forEach(r => { + if (r.milliseconds) { + if (!idleStart) return + const newEmptyTs = idleEnd.endTime.getTime() - idleStart.endTime.getTime() + if (newEmptyTs > maxIdle[2]) { + maxIdle = [idleStart, idleEnd, newEmptyTs] + } + idleStart = idleEnd = null + } else { + idleEnd = r + !idleStart && (idleStart = idleEnd) + } + }) + + const [start, end] = maxIdle + + let idleLength = '-' + let idlePeriod = null + if (start && end) { + idleLength = periodFormatter(end.endTime.getTime() - start.startTime.getTime(), { format: 'hour' }) + const format = t(msg => msg.calendar.simpleTimeFormat) + const startTime = formatTime(start.startTime, format) + const endTime = formatTime(end.endTime, format) + idlePeriod = startTime + '-' + endTime + } + + return { + favorite: { + period: favoritePeriod, + average: favoriteRow?.milliseconds, + }, + longestIdle: { + length: idleLength, + period: idlePeriod, + } + } +} + +const _default = defineComponent({ + setup() { + const data = usePeriodValue() + const filter = usePeriodFilter() + const globalFilter = useHabitFilter() + const summary = computed(() => computeSummary(data.value?.curr, filter.value?.periodSize)) + + return () => renderIndicator(summary.value, globalFilter.value?.timeFormat) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/Trend/Wrapper.ts b/src/app/components/Habit/components/Period/Trend/Wrapper.ts new file mode 100644 index 00000000..6e6e8b7d --- /dev/null +++ b/src/app/components/Habit/components/Period/Trend/Wrapper.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ComposeOption } from "echarts/core" +import type { BarSeriesOption } from "echarts/charts" +import type { GridComponentOption, TooltipComponentOption } from "echarts/components" + +import { use } from "echarts/core" +import { BarChart } from "echarts/charts" +import { SVGRenderer } from "echarts/renderers" +import { TooltipComponent, GridComponent } from "echarts/components" +import { formatTime } from "@util/time" +import { getPrimaryTextColor } from "@util/style" +import { EchartsWrapper } from "@hooks" +import { getSeriesPalette } from "@app/util/echarts" +import { formatXAxisTime, generateGridOption } from "../common" +import { periodFormatter } from "@app/util/time" + +use([BarChart, SVGRenderer, TooltipComponent, GridComponent]) + +type EcOption = ComposeOption< + | BarSeriesOption + | GridComponentOption + | TooltipComponentOption +> + +export type BizOption = { + data: timer.period.Row[] + timeFormat: timer.app.TimeFormat +} + + +function formatTimeOfEcharts(params: any, timeFormat: timer.app.TimeFormat): string { + const format = Array.isArray(params) ? params[0] : params + const { value } = format + const milliseconds = value[1] ?? 0 + const start = formatTime(value[2], '{m}-{d} {h}:{i}') + const end = formatTime(value[3], '{h}:{i}') + return ` +
${start}-${end}
+
+ + ${periodFormatter(milliseconds, { format: timeFormat })} + +
+ ` +} + +type BarItem = BarSeriesOption["data"][number] + +const cvt2Item = (row: timer.period.Row): BarItem => { + const startTime = row.startTime.getTime() + const endTime = row.endTime.getTime() + const time = (startTime + endTime) / 2 + const milliseconds = row.milliseconds + return [time, milliseconds, startTime, endTime] +} + +function generateOption({ data, timeFormat }: BizOption): EcOption { + const seriesData: BarItem[] = data.map(r => cvt2Item(r)) + const color = getSeriesPalette()?.[3] + + const textColor = getPrimaryTextColor() + + return { + tooltip: { + formatter: (params: any) => formatTimeOfEcharts(params, timeFormat), + borderColor: null, + }, + grid: generateGridOption(), + xAxis: { + type: 'time', + axisLabel: { formatter: formatXAxisTime, color: textColor }, + axisLine: { show: false }, + axisTick: { show: false }, + min: seriesData[0]?.[0], + max: seriesData[seriesData.length - 1]?.[0], + }, + yAxis: { + type: 'value', + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + series: { + type: "bar", + large: true, + data: seriesData, + barCategoryGap: 0, + itemStyle: { borderWidth: 0 }, + color, + }, + } +} +export default class Wrapper extends EchartsWrapper { + generateOption = generateOption +} \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/BarChart.tsx b/src/app/components/Habit/components/Period/Trend/index.tsx similarity index 52% rename from src/app/components/Habit/components/Period/BarChart.tsx rename to src/app/components/Habit/components/Period/Trend/index.tsx index 8a86d15b..9c3308a4 100644 --- a/src/app/components/Habit/components/Period/BarChart.tsx +++ b/src/app/components/Habit/components/Period/Trend/index.tsx @@ -6,10 +6,11 @@ */ import type { StyleValue } from "vue" -import BarWrapper from "./BarWrapper" +import Wrapper, { BizOption } from "./Wrapper" import { computed, defineComponent } from "vue" -import { usePeriodFilter, usePeriodRows } from "./context" +import { usePeriodValue } from "../context" import { useEcharts } from "@hooks" +import { useHabitFilter } from "../../context" const CONTAINER_STYLE: StyleValue = { width: "100%", @@ -17,13 +18,13 @@ const CONTAINER_STYLE: StyleValue = { } const _default = defineComponent(() => { - const rows = usePeriodRows() - const filter = usePeriodFilter() - const bizOption = computed(() => { - const { periodSize, average } = filter.value || {} - return { data: rows.value, averageByDate: average, periodSize } - }) - const { elRef } = useEcharts(BarWrapper, bizOption, { manual: true }) + const value = usePeriodValue() + const globalFilter = useHabitFilter() + const bizOption = computed(() => ({ + data: value.value?.curr, + timeFormat: globalFilter.value?.timeFormat, + })) + const { elRef } = useEcharts(Wrapper, bizOption, { manual: true }) return () =>
}) diff --git a/src/app/components/Habit/components/Period/common.ts b/src/app/components/Habit/components/Period/common.ts index 255b0f2a..b8f739c6 100644 --- a/src/app/components/Habit/components/Period/common.ts +++ b/src/app/components/Habit/components/Period/common.ts @@ -5,28 +5,38 @@ * https://opensource.org/licenses/MIT */ -import { PERIOD_PER_DATE, after, keyOf, rowOf, startOrderOfRow } from "@util/period" -import { MILL_PER_DAY } from "@util/time" +import { t } from "@app/locale" +import { formatTime } from "@util/time" +import type { GridComponentOption } from "echarts/components" -export function averageByDay(data: timer.period.Row[], periodSize: number): timer.period.Row[] { - if (!data?.length) return [] - const rangeStart = data[0]?.startTime - const rangeEnd = data[data.length - 1]?.endTime - const dateNum = (rangeEnd.getTime() - rangeStart.getTime()) / MILL_PER_DAY - const map: Map = new Map() - data.forEach(item => { - const key = Math.floor(startOrderOfRow(item) / periodSize) - const val = map.get(key) || 0 - map.set(key, val + item.milliseconds) - }) - const result = [] - let period = keyOf(new Date(), 0) - for (let i = 0; i < PERIOD_PER_DATE / periodSize; i++) { - const key = period.order / periodSize - const val = map.get(key) || 0 - const averageMill = Math.round(val / dateNum) - result.push(rowOf(after(period, periodSize - 1), periodSize, averageMill)) - period = after(period, periodSize) +export const generateGridOption = (): GridComponentOption => { + return { + top: 30, + bottom: 40, + left: 40, + right: 20, + } +} + +export type ChartType = 'average' | 'trend' | 'stack' + +export type FilterOption = { + periodSize: number + chartType: ChartType +} + +const MONTHS = t(msg => msg.calendar.months).split('|') + +export const formatXAxisTime = (time: number, idx: number): string => { + const date = new Date(time) + const dateStr = formatTime(date, '{d}{h}{i}{s}') + const isStartOfMonth = dateStr === '01000000' + const isStartOfDate = dateStr.endsWith('000000') + if (idx === 0 || isStartOfMonth) { + return MONTHS[date.getMonth()] + } else if (isStartOfDate) { + return date.getDate()?.toString?.()?.padStart(2, '0') + } else { + return formatTime(date, "{h}:{i}") } - return result } \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/context.ts b/src/app/components/Habit/components/Period/context.ts index 7ad6eec9..ac1f5318 100644 --- a/src/app/components/Habit/components/Period/context.ts +++ b/src/app/components/Habit/components/Period/context.ts @@ -7,26 +7,34 @@ import { useProvide, useProvider } from "@hooks" import { Ref } from "vue" -import { FilterOption } from "./Filter" +import { FilterOption } from "./common" + +type Value = { + curr: timer.period.Row[] + prev: timer.period.Row[] +} + +export type PeriodRange = { + curr: timer.period.KeyRange + prev: timer.period.KeyRange +} type Context = { - keyRange: Ref - rows: Ref + value: Ref filter: Ref + periodRange: Ref } const NAMESPACE = 'habitPeriod' export const initProvider = ( - keyRange: Ref, - rows: Ref, + value: Ref, filter: Ref, -) => useProvide(NAMESPACE, { - keyRange, rows, filter -}) - -export const usePeriodRange = (): Ref => useProvider(NAMESPACE, "keyRange").keyRange + periodRange: Ref, +) => useProvide(NAMESPACE, { value, filter, periodRange }) -export const usePeriodRows = (): Ref => useProvider(NAMESPACE, "rows").rows +export const usePeriodValue = (): Ref => useProvider(NAMESPACE, "value").value export const usePeriodFilter = (): Ref => useProvider(NAMESPACE, "filter").filter + +export const usePeriodRange = (): Ref => useProvider(NAMESPACE, "periodRange").periodRange \ No newline at end of file diff --git a/src/app/components/Habit/components/Period/index.tsx b/src/app/components/Habit/components/Period/index.tsx index 9155fe96..44598858 100644 --- a/src/app/components/Habit/components/Period/index.tsx +++ b/src/app/components/Habit/components/Period/index.tsx @@ -5,124 +5,39 @@ * https://opensource.org/licenses/MIT */ -import { KanbanCard, KanbanIndicatorCell } from "@app/components/common/kanban" +import { KanbanCard } from "@app/components/common/kanban" import { t } from "@app/locale" -import { PropType, Ref, defineComponent, watch, ref, computed, onMounted } from "vue" -import Filter, { FilterOption } from "./Filter" -import BarChart from "./BarChart" +import { PropType, defineComponent, ref, computed } from "vue" +import Filter from "./Filter" +import Summary from "./Summary" +import Trend from "./Trend" +import Average from "./Average" +import Stack from "./Stack" import "./style.sass" import { merge } from "@service/components/period-calculator" -import { periodFormatter } from "@app/util/time" import periodService from "@service/period-service" import { useHabitFilter } from "../context" -import { daysAgo, formatTime, isSameDay } from "@util/time" -import { MAX_PERIOD_ORDER, keyBefore, keyOf } from "@util/period" -import { initProvider } from "./context" -import { averageByDay } from "./common" - -type Summary = { - favorite: { - period: string - average: number - } - longestIdle: { - length: string - period: string - } -} - -const computeSummary = (rows: timer.period.Row[], periodSize: number): Summary => { - const averaged = averageByDay(rows, periodSize) - const favoriteRow = averaged.sort((b, a) => a.milliseconds - b.milliseconds)[0] - let favoritePeriod = '-' - if (favoriteRow) { - const start = favoriteRow.startTime - const end = favoriteRow.endTime - favoritePeriod = `${formatTime(start, "{h}:{i}")}-${formatTime(end, "{h}:{i}")}` - } - - let maxIdle: [timer.period.Row, timer.period.Row, number] = [, , 0] - - let idleStart: timer.period.Row = null, idleEnd: timer.period.Row = null - rows.forEach(r => { - if (r.milliseconds) { - if (!idleStart) return - const newEmptyTs = idleEnd.endTime.getTime() - idleStart.endTime.getTime() - if (newEmptyTs > maxIdle[2]) { - maxIdle = [idleStart, idleEnd, newEmptyTs] - } - idleStart = idleEnd = null - } else { - idleEnd = r - !idleStart && (idleStart = idleEnd) - } - }) - - const [start, end] = maxIdle - - let idleLength = '-' - let idlePeriod = null - if (start && end) { - idleLength = periodFormatter(end.endTime.getTime() - start.startTime.getTime(), { format: 'hour' }) - const format = t(msg => msg.calendar.simpleTimeFormat) - const startTime = formatTime(start.startTime, format) - const endTime = formatTime(end.endTime, format) - idlePeriod = startTime + '-' + endTime - } - +import { getDayLength, MILL_PER_DAY } from "@util/time" +import { MAX_PERIOD_ORDER, keyOf } from "@util/period" +import { initProvider, PeriodRange } from "./context" +import { useRequest } from "@src/hooks/useRequest" +import { FilterOption } from "./common" + +const computeRange = (filterDateRange: [Date, Date]): PeriodRange => { + const [startDate, endDate] = filterDateRange || [] + const dateLength = getDayLength(startDate, endDate) + const prevStartDate = new Date(startDate.getTime() - MILL_PER_DAY * dateLength) + const prevEndDate = new Date(startDate.getTime() - MILL_PER_DAY) return { - favorite: { - period: favoritePeriod, - average: favoriteRow?.milliseconds, - }, - longestIdle: { - length: idleLength, - period: idlePeriod, - } + curr: [keyOf(startDate, 0), keyOf(endDate, MAX_PERIOD_ORDER)], + prev: [keyOf(prevStartDate, 0), keyOf(prevEndDate, MAX_PERIOD_ORDER)], } } -const renderIndicator = (summary: Summary, format: timer.app.TimeFormat) => { - const { - favorite: { period: favoritePeriod = null, average = null }, - longestIdle: { period: idlePeriod, length: idleLength }, - } = summary || {} - return <> -
- msg.habit.period.busiest)} - mainValue={favoritePeriod} - subTips={msg => msg.habit.common.focusAverage} - subValue={periodFormatter(average, { format })} - /> -
-
- msg.habit.period.idle)} - mainValue={idleLength} - subTips={() => idlePeriod} - /> -
- -} - -function computeParam(dateRange: [Date, Date]): timer.period.KeyRange { - if (dateRange.length !== 2) dateRange = daysAgo(1, 0) - const endDate = typeof dateRange[1] === 'object' ? dateRange[1] : null - const startDate = typeof dateRange[0] === 'object' ? dateRange[0] : null - const now = new Date() - const endIsToday = isSameDay(now, endDate) - - let periodEnd: timer.period.Key, periodStart: timer.period.Key - if (endIsToday) { - periodEnd = keyOf(now) - periodStart = keyOf(startDate, periodEnd.order) - periodEnd = keyBefore(periodEnd, 1) - } else { - periodEnd = keyOf(endDate, MAX_PERIOD_ORDER) - periodStart = keyOf(startDate, 0) - } - return [periodStart, periodEnd] +const fetchRows = async (range: timer.period.KeyRange, periodSize: number) => { + const results = await periodService.listBetween({ periodRange: range }) + const [start, end] = range || [] + return merge(results, { start, end, periodSize }) } const _default = defineComponent({ @@ -132,33 +47,33 @@ const _default = defineComponent({ }, setup: () => { const globalFilter = useHabitFilter() - const periodRange = computed(() => computeParam(globalFilter.value?.dateRange)) - const rows: Ref = ref([]) - const filter: Ref = ref({ periodSize: 1, average: false }) - - initProvider(periodRange, rows, filter) + const periodRange = computed(() => computeRange(globalFilter.value?.dateRange)) + const filter = ref({ periodSize: 1, chartType: 'average' }) - const fetchRows = async () => { - const results = await periodService.list({ periodRange: periodRange.value }) - const [start, end] = periodRange.value || [] + const { data } = useRequest(async () => { + const { curr: currRange, prev: prevRange } = periodRange.value || {} const periodSize = filter.value?.periodSize - rows.value = merge(results, { start, end, periodSize }) - } + const [curr, prev] = await Promise.all([ + fetchRows(currRange, periodSize), + fetchRows(prevRange, periodSize), + ]) + return { curr, prev } + }, { deps: [periodRange, filter], defaultValue: { curr: [], prev: [] } }) - watch([periodRange, filter], fetchRows) - onMounted(fetchRows) + initProvider(data, filter, periodRange) - const summary = computed(() => computeSummary(rows.value, filter.value?.periodSize)) return () => msg.habit.period.title)} v-slots={{ filter: () => filter.value = val} />, default: () =>
- {renderIndicator(summary.value, globalFilter.value?.timeFormat)} +
- + {filter.value?.chartType === 'average' && } + {filter.value?.chartType === 'trend' && } + {filter.value?.chartType === 'stack' && }
}} diff --git a/src/app/components/Habit/components/Site/DailyTrend/Wrapper.ts b/src/app/components/Habit/components/Site/DailyTrend/Wrapper.ts new file mode 100644 index 00000000..9bd415cf --- /dev/null +++ b/src/app/components/Habit/components/Site/DailyTrend/Wrapper.ts @@ -0,0 +1,184 @@ +import { EchartsWrapper } from "@hooks" +import { + ComposeOption, + GridComponentOption, + TitleComponentOption, + TooltipComponentOption, + LegendComponentOption, + LineSeriesOption, + LinearGradientObject, +} from "echarts" +import { getAllDatesBetween } from "@util/time" +import { groupBy, sum } from "@util/array" +import { t } from "@app/locale" +import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components" +import { BarChart, LineChart, PieChart } from "echarts/charts" +import { use } from "echarts/core" +import { SVGRenderer } from "echarts/renderers" +import { TopLevelFormatterParams, YAXisOption } from "echarts/types/dist/shared" +import { generateTitleOption } from "../common" +import { getLineSeriesPalette, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" +import { cvt2LocaleTime, periodFormatter } from "@app/util/time" + +use([SVGRenderer, GridComponent, LegendComponent, TooltipComponent, TitleComponent, LineChart, PieChart, BarChart]) + +type EcOption = ComposeOption< + | GridComponentOption + | TooltipComponentOption + | TitleComponentOption + | LineSeriesOption + | LegendComponentOption +> + +const [FOCUS_COLORS, VISIT_COLORS, COUNT_COLORS] = getLineSeriesPalette() +const FOCUS_LEGEND = t(msg => msg.item.focus) +const VISIT_LEGEND = t(msg => msg.item.time) +const COUNT_LEGEND = t(msg => msg.habit.site.trend.siteCount) +const LEGEND_COLOR_MAP = { + [FOCUS_LEGEND]: FOCUS_COLORS, + [VISIT_LEGEND]: VISIT_COLORS, + [COUNT_LEGEND]: COUNT_COLORS, +} + +const TITLE = t(msg => msg.habit.site.trend.title) + +export type BizOption = { + rows: timer.stat.Row[] + dateRange?: [Date, Date] + timeFormat?: timer.app.TimeFormat +} + +const valueYAxis = (): YAXisOption => ({ + type: 'value', + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, +}) + +const formatTimeTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat) => { + if (!Array.isArray(params)) return '' + const date = params?.[0]?.name + if (!date) return '' + const dateLine = tooltipFlexLine( + cvt2LocaleTime(date), + TITLE, + ) + const valueLines = params?.reverse?.()?.map(param => { + const { value, seriesName } = param + const color = LEGEND_COLOR_MAP[seriesName] + if (!color) return '' + let valueStr: string | number = seriesName === FOCUS_LEGEND + ? periodFormatter(value as number, { format }) + : (value as number) + return tooltipFlexLine( + `${tooltipDot(color?.colorStops?.[0]?.color)} ${valueStr}`, + seriesName, + ) + }).join('') + + return `${dateLine}${tooltipSpaceLine()}${valueLines}` +} + +const lineOptionOf = ( + areaColor: LinearGradientObject, + baseOption: Required> +): LineSeriesOption => { + return { + type: 'line', + areaStyle: { color: areaColor }, + showSymbol: false, + lineStyle: { width: 0 }, + emphasis: { focus: "self" }, + ...baseOption, + } +} + +const legendOptionOf = (color: LinearGradientObject, name: string): LegendComponentOption['data'][0] => { + return { name, itemStyle: { color } } +} + +function generateOption(bizOption: BizOption): EcOption { + const { dateRange, rows, timeFormat } = bizOption || {} + + const [start, end] = dateRange || [] + const allDates = getAllDatesBetween(start, end) + const focusMap = groupBy(rows, r => r.date, l => sum(l.map(e => e.focus))) + const visitMap = groupBy(rows, r => r.date, l => sum(l.map(e => e.time))) + const siteMap = groupBy(rows, r => r.date, l => new Set(l.map(e => e.host)).size) + const countData = allDates.map(date => ({ date, value: siteMap[date] ?? 0 })) + const visitData = allDates.map(date => ({ date, value: visitMap[date] ?? 0 })) + const focusData = allDates.map(date => ({ date, value: focusMap[date] ?? 0 })) + + return { + title: generateTitleOption(TITLE), + grid: { + bottom: '2%', + top: '26%', + right: 0, + left: 20, + }, + series: [ + lineOptionOf(COUNT_COLORS, { + name: COUNT_LEGEND, + data: countData, + yAxisIndex: 0 + }), + lineOptionOf(VISIT_COLORS, { + name: VISIT_LEGEND, + data: visitData, + yAxisIndex: 1 + }), + lineOptionOf(FOCUS_COLORS, { + name: FOCUS_LEGEND, + data: focusData, + yAxisIndex: 2 + }), + ], + tooltip: { + trigger: 'axis', + axisPointer: { type: "line" }, + formatter: (params: TopLevelFormatterParams) => formatTimeTooltip(params, timeFormat), + }, + xAxis: { + type: 'category', + data: allDates, + axisLabel: { show: false }, + axisTick: { show: false }, + axisLine: { show: false }, + }, + yAxis: [ + valueYAxis(), + valueYAxis(), + valueYAxis(), + ], + legend: { + right: '2%', + top: '16%', + icon: 'roundRect', + itemWidth: 20, + itemGap: 5, + textStyle: { + // Hide text + fontSize: 0, + }, + tooltip: { + show: true, + formatter: (params: any) => (params?.name as string), + }, + data: [ + legendOptionOf(FOCUS_COLORS, FOCUS_LEGEND), + legendOptionOf(VISIT_COLORS, VISIT_LEGEND), + legendOptionOf(COUNT_COLORS, COUNT_LEGEND), + ] + }, + } +} + +export default class Wrapper extends EchartsWrapper { + generateOption = generateOption + + protected rewrite(): boolean { + return true + } +} diff --git a/src/app/components/Habit/components/Site/DailyTrend/index.tsx b/src/app/components/Habit/components/Site/DailyTrend/index.tsx new file mode 100644 index 00000000..976374fd --- /dev/null +++ b/src/app/components/Habit/components/Site/DailyTrend/index.tsx @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { StyleValue, computed, defineComponent } from "vue" +import { useEcharts } from "@hooks" +import TimelineWrapper, { BizOption } from "./Wrapper" +import { useHabitFilter } from "../../context" +import { useRows } from "../context" + +const CONTAINER_STYLE: StyleValue = { + width: "100%", + height: "100%", +} + +const _default = defineComponent(() => { + const rows = useRows() + const filter = useHabitFilter() + const bizOption = computed(() => { + return { + rows: rows.value, + timeFormat: filter.value?.timeFormat, + dateRange: filter.value?.dateRange, + } + }) + const { elRef } = useEcharts(TimelineWrapper, bizOption, { manual: true }) + + return () =>
+}) + +export default _default diff --git a/src/app/components/Habit/components/Site/Distribution/Wrapper.ts b/src/app/components/Habit/components/Site/Distribution/Wrapper.ts new file mode 100644 index 00000000..cdd9afa0 --- /dev/null +++ b/src/app/components/Habit/components/Site/Distribution/Wrapper.ts @@ -0,0 +1,205 @@ +import { EchartsWrapper } from "@src/hooks/useEcharts" +import { ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption } from "echarts" +import { getPieBorderColor, getSeriesPalette } from "@app/util/echarts" +import { groupBy, sum } from "@util/array" +import { MILL_PER_MINUTE, MILL_PER_SECOND, MILL_PER_HOUR } from "@util/time" +import { computeAverageLen, generateTitleOption } from "../common" +import { GridOption, TooltipOption } from "echarts/types/dist/shared" +import { t } from "@app/locale" +import { getPrimaryTextColor, getRegularTextColor } from "@util/style" + +export type BizOption = { + rows: timer.stat.Row[] + dateRange: [Date, Date] +} + +type EcOption = ComposeOption< + | GridOption + | PieSeriesOption + | TooltipOption + | LegendComponentOption +> + +type FocusUnit = 'm' | 'h' | 's' +const UNIT_CHANGE: { [unit in FocusUnit]: number } = { + m: MILL_PER_MINUTE, + s: MILL_PER_SECOND, + h: MILL_PER_HOUR, +} +type FocusBound = [val: number, unit: FocusUnit] + +const focusBoundMill = ([val, unit]: FocusBound) => (val ?? 0) * (UNIT_CHANGE[unit] ?? 0) + +const FOCUS_COUNT_CATEGORIES: Tuple[] = [ + [, [5, 's']], + [[5, 's'], [20, 's']], + [[20, 's'], [60, 's']], + [[1, 'm'], [10, 'm']], + [[10, 'm'], [30, 'm']], + [[30, 'm'], [60, 'm']], + [[1, 'h'], [2, 'h']], + [[2, 'h'], null] +] + +const VISIT_COUNT_CATEGORIES: Vector<2>[] = [ + [, 1], + [1, 3], + [3, 10], + [10, 20], + [20, 50], + [50, 100], + [100, 200], + [200, null] +] + +const PALETTE_COLOR = getSeriesPalette() + +const formatFocusLegend = (range: Tuple) => { + const [start, end] = range || [] + if (!start && !end) { + return 'NaN' + } else if (start && !end) { + return `>=${start[0]}${start[1]}` + } else if (!start && end) { + return `<${end[0]}${end[1]}` + } else { + return start[1] === end[1] + ? `${start[0]}-${end[0]}${start[1]}` + : `${start[0]}${start[1]}-${end[0]}${end[1]}` + } +} + +const formatVisitLegend = (range: Vector<2>) => { + const [start, end] = range || [] + if (!start && !end) { + return 'NaN' + } else if (start && !end) { + return `>=${start}` + } else if (!start && end) { + return `<${end}` + } else { + return `${start}-${end}` + } +} + +const pieOptionOf = (centerX: string, data: PieSeriesOption['data']): PieSeriesOption => { + return { + type: 'pie', + center: [centerX, '55%'], + top: '16%', + radius: ['45%', '80%'], + label: { + show: false, + position: 'center', + color: getRegularTextColor(), + }, + itemStyle: { + borderRadius: 4, + borderColor: getPieBorderColor(), + borderWidth: 0.5, + }, + emphasis: { + label: { + show: true, + fontSize: 17, + }, + }, + tooltip: { + formatter(params: any): string { + const data: PieSeriesOption['data'][number] = params?.data || {} + const { value } = data as { value: number } + return `${t(msg => msg.habit.site.distribution.tooltip, { value })}` + } + }, + data, + color: PALETTE_COLOR, + } +} + + + +const BASE_LEGEND_TITLE: TitleComponentOption = { + textStyle: { + fontSize: 12, + color: getPrimaryTextColor(), + fontWeight: 'normal', + }, + top: '18%', +} + +function generateOption(bizOption: BizOption): EcOption { + let { rows, dateRange } = bizOption || {} + const [averageLen, _, exclusiveDate] = computeAverageLen(dateRange) + if (exclusiveDate) { + rows = rows.filter(r => r.date !== exclusiveDate) + } + + const focusAve = groupBy(rows, r => r.host, l => sum(l.map(e => e.focus ?? 0)) / averageLen) + const visitAve = groupBy(rows, r => r.host, l => sum(l.map(e => e.time ?? 0)) / averageLen) + + const focusGroup = groupBy( + Object.entries(focusAve), + ([_, ave]) => FOCUS_COUNT_CATEGORIES.findIndex(([start, end]) => (!start || focusBoundMill(start) <= ave) && (!end || focusBoundMill(end) > ave)), + list => list, + ) + const visitGroup = groupBy( + Object.entries(visitAve), + ([_, ave]) => VISIT_COUNT_CATEGORIES.findIndex(([start, end]) => (!start || start <= ave) && (!end || end > ave)), + list => list, + ) + const focusData: PieSeriesOption['data'] = FOCUS_COUNT_CATEGORIES.map((range, idx) => { + const list = focusGroup[idx] || [] + return { range, list, value: list?.length ?? 0, name: formatFocusLegend(range) } + }) + const visitData: PieSeriesOption['data'] = VISIT_COUNT_CATEGORIES.map((range, idx) => { + const list = visitGroup[idx] || [] + return { range, list, value: list?.length ?? 0, name: formatVisitLegend(range) } + }) + const primaryColor = getPrimaryTextColor() + return { + title: [ + // Main title + generateTitleOption(t(msg => msg.habit.site.distribution.title)), + // Legend title + { + text: t(msg => msg.habit.site.distribution.aveVisit), + left: '6%', + ...BASE_LEGEND_TITLE, + }, { + text: t(msg => msg.habit.site.distribution.aveTime), + right: '4%', + ...BASE_LEGEND_TITLE, + }, + ], + tooltip: { show: true }, + legend: [ + { + type: 'scroll', + left: '6%', + top: '30%', + orient: 'vertical', + borderColor: getPieBorderColor(), + textStyle: { color: primaryColor }, + data: VISIT_COUNT_CATEGORIES.filter((_, idx) => visitGroup[idx]).map(range => formatVisitLegend(range)), + }, + { + right: '4%', + align: 'right', + top: '30%', + orient: 'vertical', + borderColor: getPieBorderColor(), + textStyle: { color: primaryColor }, + data: FOCUS_COUNT_CATEGORIES.filter((_, idx) => focusGroup[idx]).map(range => formatFocusLegend(range)), + } + ], + grid: { top: '20%', bottom: '10%', left: 80, right: 50 }, + series: [ + pieOptionOf('35%', visitData), + pieOptionOf('68%', focusData), + ], + } +} + +export default class Wrapper extends EchartsWrapper { + generateOption = generateOption +} diff --git a/src/app/components/Habit/components/Site/Distribution/index.tsx b/src/app/components/Habit/components/Site/Distribution/index.tsx new file mode 100644 index 00000000..17b7abd4 --- /dev/null +++ b/src/app/components/Habit/components/Site/Distribution/index.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { StyleValue, computed, defineComponent } from "vue" +import Wrapper, { BizOption } from "./Wrapper" +import { useEcharts } from "@hooks" +import { useRows } from "../context" +import { useHabitFilter } from "../../context" + +const CONTAINER_STYLE: StyleValue = { + width: "100%", + height: "100%", +} + +const _default = defineComponent(() => { + const rows = useRows() + const filter = useHabitFilter() + const bizOption = computed(() => ({ rows: rows.value, dateRange: filter.value?.dateRange } as BizOption)) + const { elRef } = useEcharts(Wrapper, bizOption, { manual: true }) + + return () =>
+}) + +export default _default diff --git a/src/app/components/Habit/components/Site/FocusPieChart.tsx b/src/app/components/Habit/components/Site/FocusPieChart.tsx deleted file mode 100644 index bded6a15..00000000 --- a/src/app/components/Habit/components/Site/FocusPieChart.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { StyleValue, computed, defineComponent } from "vue" -import FocusPieWrapper from "./FocusPieWrapper" -import { useHabitFilter } from "../context" -import { useRows } from "./context" -import { useEcharts } from "@hooks" - -const CONTAINER_STYLE: StyleValue = { - width: "100%", - height: "100%", -} - -const _default = defineComponent({ - setup() { - const filter = useHabitFilter() - const rows = useRows() - const bizOption = computed(() => ({ rows: rows.value, timeFormat: filter.value?.timeFormat })) - const { elRef } = useEcharts(FocusPieWrapper, bizOption, { manual: true }) - - return () =>
- }, -}) - -export default _default diff --git a/src/app/components/Habit/components/Site/FocusPieWrapper.ts b/src/app/components/Habit/components/Site/FocusPieWrapper.ts deleted file mode 100644 index a3737480..00000000 --- a/src/app/components/Habit/components/Site/FocusPieWrapper.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { ComposeOption } from "echarts/core" -import type { GridComponentOption, TooltipComponentOption, TitleComponentOption } from "echarts/components" -import type { PieSeriesOption } from "echarts/charts" - -import { use } from "echarts/core" -import { PieChart } from "echarts/charts" -import { SVGRenderer } from "echarts/renderers" -import { TooltipComponent, GridComponent, TitleComponent } from "echarts/components" -import { mergeDate } from "@service/stat-service/merge" -import { t } from "@app/locale" -import { MIN_PERCENT_OF_PIE, formatFocusTooltip, generateTitleOption } from "./common" -import { sum } from "@util/array" -import { EchartsWrapper } from "@hooks" -import { echartsPalette } from "@util/echarts" - -use([PieChart, SVGRenderer, TooltipComponent, GridComponent, TitleComponent]) - -type EcOption = ComposeOption< - | GridComponentOption - | TooltipComponentOption - | TitleComponentOption - | PieSeriesOption -> - -type BizOption = { - rows: timer.stat.Row[] - timeFormat: timer.app.TimeFormat -} - -const generateOption = ({ rows, timeFormat }: BizOption): EcOption => { - rows = mergeDate(rows).sort((a, b) => b.focus - a.focus) - const total = sum(rows.map(r => r.focus)) - const realRows: timer.stat.Row[] = [] - const tailRows: timer.stat.Row[] = [] - rows.forEach(r => { - if (r.focus > MIN_PERCENT_OF_PIE * total) { - realRows.push(r) - } else { - tailRows.push(r) - } - }) - const leftFocus = sum(tailRows.map(r => r.focus)) - if (leftFocus) { - const alias = t(msg => msg.habit.site.otherLabel, { count: tailRows.length || 0 }) - realRows.push({ host: null, alias, focus: leftFocus, time: 0, mergedHosts: [], virtual: false }) - } - const title = t(msg => msg.habit.site.focusPieTitle) - return { - title: generateTitleOption(title), - series: [{ - type: "pie", - center: ["50%", "60%"], - startAngle: 90, - data: realRows.map(row => ({ value: row.focus, row })), - label: { - show: false, - }, - color: echartsPalette(), - }], - tooltip: { - show: true, - formatter: (params: [any]) => formatFocusTooltip(params, timeFormat, { splitLine: true }) - }, - } -} - -export default class FocusPieWrapper extends EchartsWrapper { - generateOption = generateOption -} diff --git a/src/app/components/Habit/components/Site/Summary.tsx b/src/app/components/Habit/components/Site/Summary.tsx new file mode 100644 index 00000000..1764b71d --- /dev/null +++ b/src/app/components/Habit/components/Site/Summary.tsx @@ -0,0 +1,84 @@ +import { KanbanIndicatorCell } from "@app/components/common/kanban" +import { t } from "@app/locale" +import { periodFormatter } from "@app/util/time" +import { computed, defineComponent } from "vue" +import { useHabitFilter } from "../context" +import { useRows } from "./context" +import { computeAverageLen } from "./common" +import { sum } from "@util/array" +import { FilterOption } from "../HabitFilter" + +type Result = { + focus: { + total: number + average: number + } + count: { + site: number + time: number + siteAverage: number + } + exclusiveToday4Average: boolean +} + +const computeSummary = (rows: timer.stat.Row[] = [], filter: FilterOption): Result => { + const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter?.dateRange) + const totalFocus = sum(rows.map(r => r.focus)) + const totalFocus4Average = exclusiveDate ? sum(rows.filter(r => r.date !== exclusiveDate).map(r => r.focus)) : totalFocus + const totalTime = sum(rows.map(r => r.time)) + const totalSite = new Set(rows.map(row => row.host)).size + const totalSite4Average = exclusiveDate ? rows.filter(r => r.date !== exclusiveDate).length : rows.length + + return { + focus: { + total: totalFocus, + average: averageLen ? totalFocus4Average / averageLen : 0, + }, + count: { + time: totalTime, + site: totalSite, + siteAverage: averageLen ? totalSite4Average / averageLen : 0, + }, + exclusiveToday4Average, + } +} + +const renderIndicator = (summary: Result, format: timer.app.TimeFormat) => { + const { + focus: { total: focusTotal, average: focusAverage } = {}, + count: { time, site, siteAverage }, + exclusiveToday4Average, + } = summary + return <> +
+ msg.analysis.common.focusTotal)} + mainValue={periodFormatter(focusTotal, { format })} + subTips={msg => msg.habit.common.focusAverage} + subValue={periodFormatter(focusAverage, { format })} + subInfo={exclusiveToday4Average ? t(msg => msg.habit.site.exclusiveToday) : null} + /> +
+
+ msg.habit.site.countTotal)} + mainValue={[time ? `${time}` : '-', site ? `${site}` : '-'].join(" / ")} + subTips={msg => msg.habit.site.siteAverage} + subValue={siteAverage?.toFixed(0) || '-'} + subInfo={exclusiveToday4Average ? t(msg => msg.habit.site.exclusiveToday) : null} + /> +
+ +} + +const _default = defineComponent({ + setup: () => { + const filter = useHabitFilter() + const rows = useRows() + const summary = computed(() => computeSummary(rows.value, filter.value)) + + return () => renderIndicator(summary.value, filter.value?.timeFormat) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/Habit/components/Site/TimePieChart.tsx b/src/app/components/Habit/components/Site/TimePieChart.tsx deleted file mode 100644 index 24728f6b..00000000 --- a/src/app/components/Habit/components/Site/TimePieChart.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { StyleValue, defineComponent } from "vue" -import TimePieWrapper from "./TimePieWrapper" -import { useRows } from "./context" -import { useEcharts } from "@hooks" - -const CONTAINER_STYLE: StyleValue = { - width: "100%", - height: "100%", -} - -const _default = defineComponent({ - setup() { - const rows = useRows() - const { elRef } = useEcharts(TimePieWrapper, rows, { manual: true }) - - return () =>
- }, -}) - -export default _default diff --git a/src/app/components/Habit/components/Site/TimePieWrapper.ts b/src/app/components/Habit/components/Site/TimePieWrapper.ts deleted file mode 100644 index 37d9e26f..00000000 --- a/src/app/components/Habit/components/Site/TimePieWrapper.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { ComposeOption } from "echarts/core" -import type { GridComponentOption, TooltipComponentOption, TitleComponentOption } from "echarts/components" -import type { PieSeriesOption } from "echarts/charts" - -import { use } from "echarts/core" -import { PieChart } from "echarts/charts" -import { SVGRenderer } from "echarts/renderers" -import { TooltipComponent, GridComponent, TitleComponent } from "echarts/components" -import { mergeDate } from "@service/stat-service/merge" -import { t } from "@app/locale" -import { MIN_PERCENT_OF_PIE, formatTimeTooltip, generateTitleOption } from "./common" -import { sum } from "@util/array" -import { EchartsWrapper } from "@hooks" -import { echartsPalette } from "@util/echarts" - -use([PieChart, SVGRenderer, TooltipComponent, GridComponent, TitleComponent]) - -type EcOption = ComposeOption< - | GridComponentOption - | TooltipComponentOption - | TitleComponentOption - | PieSeriesOption -> - -const generateOption = (rows: timer.stat.Row[]): EcOption => { - rows = mergeDate(rows).sort((a, b) => b.time - a.time) - const total = sum(rows.map(r => r.time)) - const realRows: timer.stat.Row[] = [] - const tailRows: timer.stat.Row[] = [] - rows.forEach(r => { - if (r.time > MIN_PERCENT_OF_PIE * total) { - realRows.push(r) - } else { - tailRows.push(r) - } - }) - const leftTime = sum(tailRows.map(r => r.time)) - if (leftTime) { - const alias = t(msg => msg.habit.site.otherLabel, { count: tailRows.length || 0 }) - realRows.push({ host: null, alias, focus: 0, time: leftTime, mergedHosts: [], virtual: false }) - } - const title = t(msg => msg.habit.site.visitPieTitle) - return { - title: generateTitleOption(title), - series: [{ - type: "pie", - center: ["50%", "60%"], - startAngle: 90, - data: realRows.map(row => ({ value: row.time, row })), - label: { - show: false, - }, - color: echartsPalette(), - }], - tooltip: { - show: true, - formatter: (params: [any]) => formatTimeTooltip(params), - }, - } -} - -export default class TimePieWrapper extends EchartsWrapper { - generateOption = generateOption -} diff --git a/src/app/components/Habit/components/Site/HistogramWrapper.ts b/src/app/components/Habit/components/Site/TopK/Wrapper.ts similarity index 64% rename from src/app/components/Habit/components/Site/HistogramWrapper.ts rename to src/app/components/Habit/components/Site/TopK/Wrapper.ts index d711ed3d..84a0bb5b 100644 --- a/src/app/components/Habit/components/Site/HistogramWrapper.ts +++ b/src/app/components/Habit/components/Site/TopK/Wrapper.ts @@ -15,8 +15,12 @@ import { SVGRenderer } from "echarts/renderers" import { TooltipComponent, GridComponent, TitleComponent } from "echarts/components" import { mergeDate } from "@service/stat-service/merge" import { t } from "@app/locale" -import { SeriesDataItem, formatFocusTooltip, generateTitleOption } from "./common" +import { SeriesDataItem, generateTitleOption } from "../common" import { EchartsWrapper } from "@hooks" +import { getStepColors } from "@app/util/echarts" +import { TopLevelFormatterParams } from "echarts/types/dist/shared" +import { generateSiteLabel } from "@util/site" +import { periodFormatter } from "@app/util/time" use([BarChart, SVGRenderer, TooltipComponent, GridComponent, TitleComponent]) @@ -32,15 +36,30 @@ type BizOption = { timeFormat: timer.app.TimeFormat } +const TOP_NUM = 8 + const MARGIN_LEFT_P = 8 -const MARGIN_RIGHT_P = 4 -const TOP_NUM = 7 +const MARGIN_RIGHT_P = 8 + +const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat): string => { + const param = Array.isArray(params) ? params[0] : params + const { data } = param || {} + const { row } = (data as any) || {} + const { host, alias, focus = 0 } = row || {} + const siteLabel = host ? generateSiteLabel(host, alias) : (alias || 'Unknown') + return ` +
${siteLabel}
+
+ ${periodFormatter(focus, { format })} +
+ ` +} async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app.TimeFormat, dom: HTMLElement): Promise { const merged = mergeDate(rows) - const top10 = merged.sort((a, b) => b.focus - a.focus).splice(0, TOP_NUM).reverse() - const max = top10[top10.length - 1]?.focus ?? 0 - const hosts = top10.map(r => r.alias || r.host) + const topList = merged.sort((a, b) => b.focus - a.focus).splice(0, TOP_NUM).reverse() + const max = topList[topList.length - 1]?.focus ?? 0 + const hosts = topList.map(r => r.alias || r.host) const domW = dom.getBoundingClientRect().width const chartW = domW * (100 - MARGIN_LEFT_P - MARGIN_RIGHT_P) / 100 @@ -53,13 +72,13 @@ async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app left: `${MARGIN_LEFT_P}%`, containLabel: true, right: `${MARGIN_RIGHT_P}%`, - top: "12%", + top: "16%", bottom: '4%', }, tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, - formatter: (val: [any]) => formatFocusTooltip(val, timeFormat, { splitLine: true, ignorePercentage: true }), + formatter: (params: TopLevelFormatterParams) => formatFocusTooltip(params, timeFormat), }, xAxis: { type: "value", @@ -84,17 +103,22 @@ async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app }, series: [{ type: "bar", - data: top10.map(row => { + barWidth: '100%', + data: topList.map((row, idx) => { + const isBottom = idx === 0 + const isTop = idx === topList.length - 1 const { focus: value = 0 } = row || {} const labelW = (value / max) * chartW - 8 return { value, row, label: { show: labelW >= 50, width: labelW }, + itemStyle: { + borderRadius: [ + isTop ? 5 : 0, isTop ? 5 : 0, 5, isBottom ? 5 : 0 + ] + } } }), - itemStyle: { - borderRadius: [0, 12, 12, 0], - }, label: { position: 'insideRight', overflow: "truncate", @@ -106,11 +130,13 @@ async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app const { host, alias } = row return alias || host }, - } - }] + }, + colorBy: 'data', + color: getStepColors(topList.length, 1.5), + }], } } -export default class HistogramWrapper extends EchartsWrapper { +export default class Wrapper extends EchartsWrapper { generateOption = ({ rows, timeFormat }: BizOption) => generateOption(rows, timeFormat, this.getDom()) } diff --git a/src/app/components/Habit/components/Site/HistogramChart.tsx b/src/app/components/Habit/components/Site/TopK/index.tsx similarity index 74% rename from src/app/components/Habit/components/Site/HistogramChart.tsx rename to src/app/components/Habit/components/Site/TopK/index.tsx index d732a39e..ed0f9dac 100644 --- a/src/app/components/Habit/components/Site/HistogramChart.tsx +++ b/src/app/components/Habit/components/Site/TopK/index.tsx @@ -6,10 +6,10 @@ */ import { StyleValue, computed, defineComponent } from "vue" -import HistogramWrapper from "./HistogramWrapper" -import { useRows } from "./context" -import { useHabitFilter } from "../context" +import Wrapper from "./Wrapper" import { useEcharts } from "@hooks" +import { useRows } from "../context" +import { useHabitFilter } from "../../context" const CONTAINER_STYLE: StyleValue = { width: "100%", @@ -20,7 +20,7 @@ const _default = defineComponent(() => { const rows = useRows() const filter = useHabitFilter() const bizOption = computed(() => ({ rows: rows.value, timeFormat: filter.value?.timeFormat })) - const { elRef } = useEcharts(HistogramWrapper, bizOption, { manual: true }) + const { elRef } = useEcharts(Wrapper, bizOption, { manual: true }) return () =>
}) diff --git a/src/app/components/Habit/components/Site/common.ts b/src/app/components/Habit/components/Site/common.ts index 22b56d14..4149b06d 100644 --- a/src/app/components/Habit/components/Site/common.ts +++ b/src/app/components/Habit/components/Site/common.ts @@ -7,12 +7,13 @@ import type { TitleComponentOption } from "echarts/components" -import { getSecondaryTextColor } from "@util/style" +import { getRegularTextColor } from "@util/style" import { generateSiteLabel } from "@util/site" import { periodFormatter } from "@app/util/time" +import { formatTime, getDayLength, isSameDay } from "@util/time" export const generateTitleOption = (text: string): TitleComponentOption => { - const secondaryTextColor = getSecondaryTextColor() + const secondaryTextColor = getRegularTextColor() return { text, textStyle: { @@ -30,35 +31,19 @@ export type SeriesDataItem = { row: timer.stat.Row } -export const formatFocusTooltip = ( - params: [any] | any, - format: timer.app.TimeFormat, - option?: { - splitLine?: boolean - ignorePercentage?: boolean +/** + * @param dateRange date range of filter + * @returns [averageLen, exclusiveToday4Average, exclusiveDate] + */ +export const computeAverageLen = (dateRange: [Date, Date] = [null, null]): [number, boolean, string] => { + const [start, end] = dateRange + if (!end) return [0, false, null] + if (isSameDay(start, end)) return [1, false, null] + const dateDiff = getDayLength(start, end) + const endIsTody = isSameDay(end, new Date()) + if (endIsTody) { + return [dateDiff - 1, true, formatTime(end, "{y}{m}{d}")] + } else { + return [dateDiff, false, null] } -): string => { - const { splitLine, ignorePercentage } = option || {} - const param = (Array.isArray(params) ? params[0] : params) as { data: SeriesDataItem, percent: number } - const { data, percent } = param || {} - const { row } = data || {} - const { host, alias, focus = 0 } = row || {} - const siteLabel = host ? generateSiteLabel(host, alias) : (alias || 'Unknown') - const builder: string[] = [siteLabel] - builder.push(splitLine ? '
' : ' ') - builder.push(periodFormatter(focus, { format })) - !ignorePercentage && builder.push(` (${(percent ?? 0).toFixed(2)}%)`) - return builder.join('') -} - -export const formatTimeTooltip = (params: [any] | any): string => { - const param = (Array.isArray(params) ? params[0] : params) as { data: SeriesDataItem, percent: number } - const { data, percent } = param || {} - const { row, value } = data || {} - const { host, alias } = row || {} - const siteLabel = host ? generateSiteLabel(host, alias) : (alias || 'Unknown') - return `${siteLabel}
${value} (${(percent ?? 0).toFixed(2)}%)` -} - -// Not show percentages less than 3 degrees -export const MIN_PERCENT_OF_PIE = 3 / 360 \ No newline at end of file +} \ No newline at end of file diff --git a/src/app/components/Habit/components/Site/index.tsx b/src/app/components/Habit/components/Site/index.tsx index 39356164..6a82bf86 100644 --- a/src/app/components/Habit/components/Site/index.tsx +++ b/src/app/components/Habit/components/Site/index.tsx @@ -7,116 +7,40 @@ import { computed, defineComponent } from "vue" import { t } from "@app/locale" -import { sum } from "@util/array" -import { KanbanCard, KanbanIndicatorCell } from "@app/components/common/kanban" -import HistogramChart from "./HistogramChart" -import FocusPieChart from "./FocusPieChart" -import TimePieChart from "./TimePieChart" +import { KanbanCard } from "@app/components/common/kanban" +import Summary from "./Summary" +import TopK from "./TopK" +import Distribution from "./Distribution" +import DailyTrend from "./DailyTrend" import "./style.sass" import { useHabitFilter } from "../context" -import { FilterOption } from "../../type" -import { formatTime, getDayLength, isSameDay } from "@util/time" -import { periodFormatter } from "@app/util/time" import statService from "@service/stat-service" import { initProvider } from "./context" import { computedAsync } from "@vueuse/core" +import { getDayLength } from "@util/time" -type Summary = { - focus: { - total: number - average: number - } - count: { - site: number - time: number - siteAverage: number - } - exclusiveToday4Average: boolean -} - -const computeAverageLen = (dateRange: [Date, Date] = [null, null]): [number, boolean, string] => { - const [start, end] = dateRange - if (!end) return [0, false, null] - if (isSameDay(start, end)) return [1, false, null] - const dateDiff = getDayLength(start, end) - const endIsTody = isSameDay(end, new Date()) - if (endIsTody) { - return [dateDiff - 1, true, formatTime(end, "{y}{m}{d}")] - } else { - return [dateDiff, false, null] - } -} - -const computeSummary = (rows: timer.stat.Row[] = [], filter: FilterOption): Summary => { - const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter?.dateRange) - const totalFocus = sum(rows.map(r => r.focus)) - const totalFocus4Average = exclusiveDate ? sum(rows.filter(r => r.date !== exclusiveDate).map(r => r.focus)) : totalFocus - const totalTime = sum(rows.map(r => r.time)) - const totalSite = new Set(rows.map(row => row.host)).size - const totalSite4Average = exclusiveDate ? rows.filter(r => r.date !== exclusiveDate).length : rows.length - - return { - focus: { - total: totalFocus, - average: averageLen ? totalFocus4Average / averageLen : 0, - }, - count: { - time: totalTime, - site: totalSite, - siteAverage: averageLen ? totalSite4Average / averageLen : 0, - }, - exclusiveToday4Average, - } -} - -const renderIndicator = (summary: Summary, format: timer.app.TimeFormat) => { - const { - focus: { total: focusTotal, average: focusAverage } = {}, - count: { time, site, siteAverage }, - exclusiveToday4Average, - } = summary - return <> -
- msg.analysis.common.focusTotal)} - mainValue={periodFormatter(focusTotal, { format })} - subTips={msg => msg.habit.common.focusAverage} - subValue={periodFormatter(focusAverage, { format })} - subInfo={exclusiveToday4Average ? t(msg => msg.habit.site.exclusiveToday) : null} - /> -
-
- msg.habit.site.countTotal)} - mainValue={[time ? `${time}` : '-', site ? `${site}` : '-'].join(" / ")} - subTips={msg => msg.habit.site.siteAverage} - subValue={siteAverage?.toFixed(0) || '-'} - subInfo={exclusiveToday4Average ? t(msg => msg.habit.site.exclusiveToday) : null} - /> -
- -} +const DISTRIBUTION_MIN_DAY_LENGTH = 15 const _default = defineComponent({ setup: () => { const filter = useHabitFilter() const rows = computedAsync(() => statService.select({ exclusiveVirtual: true, date: filter.value?.dateRange }, true)) initProvider(rows) - const summary = computed(() => computeSummary(rows.value, filter.value)) + const dateRangeLength = computed(() => getDayLength(filter.value?.dateRange?.[0], filter?.value?.dateRange?.[1])) return () => ( msg.habit.site.title)}>
- {renderIndicator(summary.value, filter.value?.timeFormat)} +
- +
- -
-
- + {dateRangeLength.value >= DISTRIBUTION_MIN_DAY_LENGTH + ? + : + }
diff --git a/src/app/components/Habit/components/Site/style.sass b/src/app/components/Habit/components/Site/style.sass index c9d8cc36..4fe1d5fa 100644 --- a/src/app/components/Habit/components/Site/style.sass +++ b/src/app/components/Habit/components/Site/style.sass @@ -6,7 +6,7 @@ >div:not(:first-child) border-left: var(--timer-kanban-border) .col0 - flex: 4 1 0 + flex: 4 flex-direction: column .indicator-wrapper flex: 1 @@ -14,8 +14,6 @@ .indicator-wrapper:not(:first-child) border-top: var(--timer-kanban-border) .col1 - flex: 6 1 0 + flex: 4 .col2 - flex: 3 1 0 - .col3 - flex: 3 1 0 + flex: 8 diff --git a/src/app/components/Habit/components/context.ts b/src/app/components/Habit/components/context.ts index 42840ea2..2341214d 100644 --- a/src/app/components/Habit/components/context.ts +++ b/src/app/components/Habit/components/context.ts @@ -6,8 +6,8 @@ */ import { Ref } from "vue" -import { FilterOption } from "../type" import { useProvide, useProvider } from "@hooks" +import { FilterOption } from "./HabitFilter" type Context = { filter: Ref diff --git a/src/app/components/Habit/index.tsx b/src/app/components/Habit/index.tsx index 28a846f1..797eb2f6 100644 --- a/src/app/components/Habit/index.tsx +++ b/src/app/components/Habit/index.tsx @@ -4,12 +4,10 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import type { FilterOption } from "./type" - import { defineComponent } from "vue" import { daysAgo } from "@util/time" import ContentContainer from "@app/components/common/ContentContainer" -import HabitFilter from "./components/HabitFilter" +import HabitFilter, { FilterOption } from "./components/HabitFilter" import Site from "./components/Site" import Period from "./components/Period" import { initProvider } from "./components/context" diff --git a/src/app/components/Habit/type.d.ts b/src/app/components/Habit/type.d.ts deleted file mode 100644 index 15df48fb..00000000 --- a/src/app/components/Habit/type.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type FilterOption = { - timeFormat: timer.app.TimeFormat - dateRange: [Date, Date] -} \ No newline at end of file diff --git a/src/app/components/Limit/LimitModify/Sop/Step3/index.tsx b/src/app/components/Limit/LimitModify/Sop/Step3/index.tsx index 86f89b55..5d19fdb7 100644 --- a/src/app/components/Limit/LimitModify/Sop/Step3/index.tsx +++ b/src/app/components/Limit/LimitModify/Sop/Step3/index.tsx @@ -21,7 +21,7 @@ const _default = defineComponent({ periods: Array as PropType, }, emits: { - change: (_time: number, _visitTime: number, _periods: [number, number][]) => true, + change: (_time: number, _visitTime: number, _periods: Vector<2>[]) => true, }, setup(props, ctx) { const [time, setTime] = useShadow(() => props.time) diff --git a/src/app/components/Limit/LimitTable/index.tsx b/src/app/components/Limit/LimitTable/index.tsx index 1fb79894..8f7988da 100644 --- a/src/app/components/Limit/LimitTable/index.tsx +++ b/src/app/components/Limit/LimitTable/index.tsx @@ -7,7 +7,7 @@ import { ElTable, ElTableColumn, ElTag } from "element-plus" import { defineComponent, PropType } from "vue" import { t } from "@app/locale" -import { formatPeriod, formatPeriodCommon } from "@util/time" +import { formatPeriod, formatPeriodCommon, MILL_PER_SECOND } from "@util/time" import { ElTableRowScope } from "@src/element-ui/table" import { period2Str } from "@util/limit" import LimitDelayColumn from "./column/LimitDelayColumn" @@ -42,12 +42,12 @@ const renderDetail = (row: timer.limit.Item) => { return
{!!time &&
- {t(msg => msg.limit.item.time)}: {formatPeriod(time * 1000, timeMsg)} + {t(msg => msg.limit.item.time)}: {formatPeriod(time * MILL_PER_SECOND, timeMsg)}
} {!!visitTime &&
- {t(msg => msg.limit.item.visitTime)}: {formatPeriod(visitTime * 1000, timeMsg)} + {t(msg => msg.limit.item.visitTime)}: {formatPeriod(visitTime * MILL_PER_SECOND, timeMsg)}
} {!!periods?.length &&
diff --git a/src/app/components/Report/ReportFilter/index.tsx b/src/app/components/Report/ReportFilter/index.tsx index 5143f2f2..4f36cac4 100644 --- a/src/app/components/Report/ReportFilter/index.tsx +++ b/src/app/components/Report/ReportFilter/index.tsx @@ -6,7 +6,6 @@ */ import type { ElementDatePickerShortcut } from "@src/element-ui/date" -import type { CalendarMessage } from "@i18n/message/common/calendar" import DownloadFile from "./DownloadFile" import RemoteClient from "./RemoteClient" @@ -21,18 +20,17 @@ import { ElButton } from "element-plus" import { DeleteFilled } from "@element-plus/icons-vue" import { useState } from "@hooks" -function datePickerShortcut(msg: keyof CalendarMessage['range'], agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { - const text = t(messages => messages.calendar.range[msg]) +function datePickerShortcut(text: string, agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { const value = daysAgo(agoOfStart || 0, agoOfEnd || 0) return { text, value } } const dateShortcuts: ElementDatePickerShortcut[] = [ - datePickerShortcut('today'), - datePickerShortcut('yesterday', 1, 1), - datePickerShortcut('last7Days', 7), - datePickerShortcut('last30Days', 30), - datePickerShortcut('last60Days', 60), + datePickerShortcut(t(msg => msg.calendar.range.today)), + datePickerShortcut(t(msg => msg.calendar.range.yesterday), 1, 1), + datePickerShortcut(t(msg => msg.calendar.range.lastDays, { n: 7 }), 7), + datePickerShortcut(t(msg => msg.calendar.range.lastDays, { n: 30 }), 30), + datePickerShortcut(t(msg => msg.calendar.range.lastDays, { n: 60 }), 60), ] const _default = defineComponent({ diff --git a/src/app/components/common/DateRangeFilterItem.tsx b/src/app/components/common/DateRangeFilterItem.tsx index 4d32e99c..49333afe 100644 --- a/src/app/components/common/DateRangeFilterItem.tsx +++ b/src/app/components/common/DateRangeFilterItem.tsx @@ -9,6 +9,7 @@ import { ElDatePicker } from "element-plus" import { defineComponent, PropType, ref, Ref } from "vue" import { ElementDatePickerShortcut } from "@src/element-ui/date" import { t } from "@app/locale" +import { EL_DATE_FORMAT } from "@i18n/element" const _default = defineComponent({ props: { @@ -30,7 +31,7 @@ const _default = defineComponent({ return () => msg.calendar.dateFormat, { y: 'YYYY', m: 'MM', d: 'DD' })} + format={EL_DATE_FORMAT} type="daterange" rangeSeparator="-" disabledDate={props.disabledDate} diff --git a/src/app/styles/echarts.sass b/src/app/styles/echarts.sass new file mode 100644 index 00000000..22881641 --- /dev/null +++ b/src/app/styles/echarts.sass @@ -0,0 +1,18 @@ +\:root + --echarts-series-color-1: #00C5C9 + --echarts-series-color-2: #F72585 + --echarts-series-color-3: #FFD600 + --echarts-series-color-4: #3A0CA3 + + --echarts-step-color-1: #4361EE + --echarts-step-color-2: var(--echarts-series-color-2) + + --echarts-compare-color-1: var(--echarts-series-color-2) + --echarts-compare-color-2: #7209B7 + + --echarts-increase-color: #00FF29 + --echarts-decrease-color: #E80054 + --echarts-pie-border-color: #ffffff + +html[data-theme='dark']:root + --echarts-pie-border-color: var(--el-border-color-light) diff --git a/src/app/styles/index.sass b/src/app/styles/index.sass index 044e93a2..4ff37a00 100644 --- a/src/app/styles/index.sass +++ b/src/app/styles/index.sass @@ -7,6 +7,7 @@ @import "../../element-ui/dark-theme" @import "./compatible" +@import "./echarts" body height: 100vh @@ -171,4 +172,4 @@ a html[data-theme='dark']:root // timer - --timer-app-container-bg-color: var(--el-fill-color-dark) + --timer-app-container-bg-color: var(--el-fill-color-lighter) diff --git a/src/app/util/echarts.ts b/src/app/util/echarts.ts new file mode 100644 index 00000000..b9bde7bc --- /dev/null +++ b/src/app/util/echarts.ts @@ -0,0 +1,103 @@ +import { range } from "@util/array" +import { getCssVariable } from "@util/style" +import { addVector, multiTuple, subVector } from "@util/tuple" +import { LinearGradientObject } from "echarts" + +const splitVectors = (vectorRange: Tuple, 2>, count: number, gradientFactor?: number): Vector[] => { + gradientFactor = gradientFactor ?? 1.3 + const segmentCount = count - 1 + const [v1, v2] = vectorRange + const delta = subVector(v2, v1) + const allVectors = range(segmentCount).map(idx => { + const growth = Math.pow(idx / count, gradientFactor) + return addVector(v1, multiTuple(delta, growth)) + }) + allVectors.push(v2) + return allVectors +} + +export const getStepColors = (count: number, gradientFactor?: number): string[] => { + const p1 = getCssVariable('--echarts-step-color-1') + const p2 = getCssVariable('--echarts-step-color-2') + if (count <= 0) return [] + if (count === 1) return [p1] + if (count === 2) return [p1, p2] + + const c1 = cvtColor2Vector(p1) + const c2 = cvtColor2Vector(p2) + const allVectors = splitVectors([c1, c2], count, gradientFactor) + return allVectors.map(([r, g, b]) => `rgb(${r.toFixed(1)}, ${g.toFixed(1)}, ${b.toFixed(1)})`) +} + +/** + * #ffffff => [255,255,255] + */ +const cvtColor2Vector = (color: string): Vector<3> => { + return [color.substring(1, 3), color.substring(3, 5), color.substring(5, 7)] + .map(c => parseInt('0x' + c)) as [number, number, number] +} + +export const getSeriesPalette = (): string[] => { + return range(4) + .map(idx => `--echarts-series-color-${idx + 1}`) + .map(val => getCssVariable(val)) +} + +const linearGradientColor = (color1: string, color2: string): LinearGradientObject => ({ + type: "linear", + x: 0, y: 0, + x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: color1 }, + { offset: 1, color: color2 }, + ], +}) + +export const getLineSeriesPalette = (): Tuple => { + return [ + linearGradientColor('#37A2FF', '#7415DB'), + linearGradientColor('#FF0087', '#87009D'), + linearGradientColor('#FFD600', '#DEAD00'), + ] +} + +export const getCompareColor = (): [string, string] => { + return [ + getCssVariable('--echarts-compare-color-1'), + getCssVariable('--echarts-compare-color-2'), + ] +} + +export const getDiffColor = (): [incColor: string, decColor: string] => { + return [ + getCssVariable('--echarts-increase-color'), + getCssVariable('--echarts-decrease-color'), + ] +} + +export const tooltipDot = (color: string) => { + return `
` +} + +export const tooltipFlexLine = (left: string, right: string, gap?: number): string => { + gap = gap ?? 20 + return ` +
+ + ${left} + + + ${right} + +
+ ` +} + +export const tooltipSpaceLine = (height?: number): string => { + height = height ?? 4 + return `
` +} + +export const getPieBorderColor = (): string => { + return getCssVariable('--echarts-pie-border-color') +} \ No newline at end of file diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index ba1848b7..8e20adc4 100644 --- a/src/database/limit-database.ts +++ b/src/database/limit-database.ts @@ -34,7 +34,7 @@ type ItemValue = { /** * Forbidden periods */ - p?: [number, number][] + p?: Vector<2>[] /** * Enabled flag */ diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts index d15b7071..8639d596 100644 --- a/src/database/stat-database/filter.ts +++ b/src/database/stat-database/filter.ts @@ -95,13 +95,13 @@ function processDateCondition(cond: _StatCondition, paramDate: Date | [Date, Dat } } -function processParamTimeCondition(cond: _StatCondition, paramTime: number[]) { +function processParamTimeCondition(cond: _StatCondition, paramTime: Vector<2>) { if (!paramTime) return paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) paramTime.length >= 1 && (cond.timeStart = paramTime[0]) } -function processParamFocusCondition(cond: _StatCondition, paramFocus: number[]) { +function processParamFocusCondition(cond: _StatCondition, paramFocus: Vector<2>) { if (!paramFocus) return paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts index b7f468da..0f4f75d4 100644 --- a/src/database/stat-database/index.ts +++ b/src/database/stat-database/index.ts @@ -28,13 +28,13 @@ export type StatCondition = { * * @since 0.0.9 */ - focusRange?: number[] + focusRange?: Vector<2> /** * Time range * * @since 0.0.9 */ - timeRange?: number[] + timeRange?: Vector<2> /** * Whether to enable full host search, default is false * diff --git a/src/element-ui/dark-theme.sass b/src/element-ui/dark-theme.sass index d1ca71b0..995a58e3 100644 --- a/src/element-ui/dark-theme.sass +++ b/src/element-ui/dark-theme.sass @@ -65,7 +65,9 @@ html[data-theme='dark'] --el-fill-color-light: #262727 --el-fill-color-lighter: #1D1D1D --el-fill-color-extra-light: #191919 - --el-fill-color-blank: var(--el-fill-color-darker) + --el-fill-color-blank: var(--el-fill-color-light) --el-mask-color: rgba(0 0 0 .8) - --el-mask-color-extra-light: rgba(0 0 0 .3) - --el-menu-bg-color: var(--el-fill-color-light) + --el-mask-color-extra-light: rgba(0 0 0 .05) + --el-menu-bg-color: var(--el-fill-color-extra-light) + .el-card + border: none diff --git a/src/hooks/useEcharts.ts b/src/hooks/useEcharts.ts index 3215d886..ef957e2c 100644 --- a/src/hooks/useEcharts.ts +++ b/src/hooks/useEcharts.ts @@ -21,7 +21,8 @@ export abstract class EchartsWrapper { async render(biz: BizOption) { if (!this.instance) return const option = await this.generateOption(biz) - option && this.instance.setOption(option, { notMerge: false }) + if (!option) return + this.instance.setOption(option, { notMerge: false }) } protected getDom(): HTMLElement { diff --git a/src/i18n/element.ts b/src/i18n/element.ts index 0a9193e6..803c4be6 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -1,7 +1,8 @@ import type { Language } from "element-plus/lib/locale" import type { App } from "vue" import ElementPlus from 'element-plus' -import { FEEDBACK_LOCALE, locale } from "." +import { FEEDBACK_LOCALE, locale, t } from "." +import calendarMessages from "./message/common/calendar" const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> } = { zh_CN: () => import('element-plus/lib/locale/lang/zh-cn'), @@ -25,9 +26,4 @@ export const initElementLocale = async (app: App) => { EL_LOCALE_FALLBACK = (await LOCALES[FEEDBACK_LOCALE]?.())?.default } -type I18nKey = (lang: Language['el']) => string - -export const tEl = (key: I18nKey): string => { - const locale = EL_LOCALE || EL_LOCALE_FALLBACK - return key?.(locale?.el) -} \ No newline at end of file +export const EL_DATE_FORMAT = t(calendarMessages, { key: msg => msg.dateFormat, param: { y: 'YYYY', m: 'MM', d: 'DD' } }) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index a364f331..ae47e862 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -13,13 +13,8 @@ "browsingTime": "浏览时长超过 {minute} 分钟", "mostUse": "最喜欢在 {start} 点至 {end} 点之间打开浏览器" }, - "weekOnWeek": { - "title": "近一周浏览时长环比变化 TOP {k}", - "lastBrowse": "上周浏览 {time}", - "thisBrowse": "本周浏览 {time}", - "wow": "环比{state} {delta}", - "increase": "增长", - "decline": "减少" + "monthOnMonth": { + "title": "浏览时间环比趋势" } }, "zh_TW": { @@ -38,13 +33,8 @@ "browsingTime": "瀏覽網頁超過 {minute} 分鐘", "mostUse": "最喜歡在 {start} 點至 {end} 點之間上網" }, - "weekOnWeek": { - "title": "瀏覽時間週環比變化 TOP {k}", - "lastBrowse": "上週瀏覽 {time}", - "thisBrowse": "本週瀏覽 {time}", - "wow": "環比{state} {delta}", - "increase": "增長", - "decline": "減少" + "monthOnMonth": { + "title": "瀏覽時間環比趨勢" } }, "en": { @@ -61,13 +51,8 @@ "browsingTime": "Browsed over {minute} minutes", "mostUse": "Favorite browsing between {start} and {end} o'clock" }, - "weekOnWeek": { - "title": "TOP {k} week-on-week change of browsing time", - "lastBrowse": "Browsed {time} last week", - "thisBrowse": "Browsed {time} this week", - "wow": "{delta} {state}", - "increase": "increased", - "decline": "decreased" + "monthOnMonth": { + "title": "Browsing time month-on-month trend" } }, "ja": { @@ -83,14 +68,6 @@ "visitCount": "{site} つのサイトへの合計 {visit} 回の拜訪", "browsingTime": "{minute} 分以上ウェブを閲覧する", "mostUse": "{start}:00 から {end}:00 までのお気に入りのインターネットアクセス" - }, - "weekOnWeek": { - "title": "週ごとの変更 TOP {k}", - "lastBrowse": "先週 {time} 閲覧", - "thisBrowse": "今週は {time} で閲覧", - "wow": "毎週 {delta} の {state}", - "increase": "増加", - "decline": "減らす" } }, "pt_PT": { @@ -106,14 +83,6 @@ "visitCount": "Visite {site} sites {visit} vezes", "browsingTime": "Navegado por {minute} minutos", "mostUse": "Navegação favorita entre {start} e {end} horas" - }, - "weekOnWeek": { - "title": "TOP {k} semana após semana mudança no tempo de navegação", - "lastBrowse": "Navegou {time} na última semana", - "thisBrowse": "Navegou {time} nesta semana", - "wow": "{delta} {state}", - "increase": "aumentou", - "decline": "diminuiu" } }, "uk": { @@ -129,14 +98,6 @@ "visitCount": "Відвідано {site} вебсайтів {visit} разів", "browsingTime": "Перегляд понад {minute} хвилин", "mostUse": "Улюблений перегляд між {start} і {end} годинами" - }, - "weekOnWeek": { - "title": "Найбільші {k} щотижневих змін часу перегляду", - "lastBrowse": "Перегляд {time} минулого тижня", - "thisBrowse": "Перегляд {time} цього тижня", - "wow": "{delta} {state}", - "increase": "збільшилося", - "decline": "зменшилося" } }, "es": { @@ -152,14 +113,6 @@ "visitCount": "Visitó {site} sitios web {visit} veces", "browsingTime": "Navegó más de {minute} minutos", "mostUse": "Prefiere navegar entre las {start} y las {end} horas" - }, - "weekOnWeek": { - "title": "TOP {k} cambios semanales del tiempo de navegación", - "lastBrowse": "Navegó {time} la semana pasada", - "thisBrowse": "Navegó {time} esta semana", - "wow": "{delta} {state}", - "increase": "más", - "decline": "menos" } }, "de": { @@ -175,14 +128,6 @@ "visitCount": "Besuchte {site} Websites {visit} mal", "browsingTime": "Durchsucht über {minute} Minuten", "mostUse": "Favoriten zwischen {start} und {end} Uhr" - }, - "weekOnWeek": { - "title": "Top {k} der wöchentlichen Veränderungen der Surfzeit", - "lastBrowse": "Letzte Woche {time} lang durchsucht", - "thisBrowse": "Letzte Woche {time} durchsucht", - "wow": "{delta} {state}", - "increase": "erhöht", - "decline": "verringert" } }, "fr": { diff --git a/src/i18n/message/app/dashboard.ts b/src/i18n/message/app/dashboard.ts index 435af01a..8f9a99e3 100644 --- a/src/i18n/message/app/dashboard.ts +++ b/src/i18n/message/app/dashboard.ts @@ -21,13 +21,8 @@ export type DashboardMessage = { browsingTime: string mostUse: string } - weekOnWeek: { + monthOnMonth: { title: string - lastBrowse: string - thisBrowse: string - wow: string - increase: string - decline: string } } diff --git a/src/i18n/message/app/habit-resource.json b/src/i18n/message/app/habit-resource.json index 478dec6d..5fd0d47c 100644 --- a/src/i18n/message/app/habit-resource.json +++ b/src/i18n/message/app/habit-resource.json @@ -7,9 +7,11 @@ "title": "不同时段的访问习惯", "busiest": "每天最繁忙时段", "idle": "最长空闲时段", - "yAxisMin": "浏览时长 / 分钟", - "yAxisHour": "浏览时长 / 小时", - "averageLabel": "平均每天", + "chartType": { + "average": "日均", + "trend": "趋势", + "stack": "累积" + }, "sizes": { "fifteen": "每十五分钟统计一次", "halfHour": "每半小时统计一次", @@ -19,20 +21,24 @@ }, "site": { "title": "网站访问习惯", - "focusPieTitle": "浏览时间占比", - "visitPieTitle": "访问次数占比", - "otherLabel": "其他{count}个网站", "histogramTitle": "最长访问 TOP {n}", "exclusiveToday": "今天的数据不计入平均值", "countTotal": "总计访问次数/网站数", - "siteAverage": "平均每天访问 {value} 个网站" + "siteAverage": "平均每天访问 {value} 个网站", + "distribution": { + "title": "日均访问频率分布", + "aveTime": "日平均浏览时间", + "aveVisit": "日平均访问次数", + "tooltip": "共 {value} 个网站" + }, + "trend": { + "title": "访问日趋势", + "siteCount": "网站数" + } } }, "zh_TW": { "period": { - "yAxisMin": "瀏覽時長 / 分鐘", - "yAxisHour": "瀏覽時長 / 小時", - "averageLabel": "平均每天", "sizes": { "fifteen": "按十五分鐘統計", "halfHour": "按半小時統計", @@ -48,9 +54,6 @@ }, "site": { "title": "網站造訪習慣", - "focusPieTitle": "瀏覽時間佔比", - "visitPieTitle": "造訪次數佔比", - "otherLabel": "其他 {count} 個網站", "histogramTitle": "最長造訪 TOP {n}", "exclusiveToday": "今天的數據不計入平均值", "countTotal": "總計造訪次數/網站數", @@ -65,9 +68,11 @@ "title": "Habit of time periods", "busiest": "Busiest time of day", "idle": "Longest idle period", - "yAxisMin": "Browsing Time / minute", - "yAxisHour": "Browsing Time / hour", - "averageLabel": "Daily average", + "chartType": { + "average": "Daily average", + "trend": "Trend", + "stack": "Stack" + }, "sizes": { "fifteen": "Per 15 minutes", "halfHour": "Per half hour", @@ -77,20 +82,24 @@ }, "site": { "title": "Habit of websites", - "focusPieTitle": "Percentage of browsing time", - "visitPieTitle": "Percentage of visits", - "otherLabel": "Other {count} sites", "histogramTitle": "TOP {n} most visited", "exclusiveToday": "Today's data is not included in the average", "countTotal": "Total visits/sites", - "siteAverage": "Average visits to {value} websites per day" + "siteAverage": "Average visits to {value} websites per day", + "distribution": { + "title": "Daily average frequency distribution", + "aveTime": "Average daily browsing time", + "aveVisit": "Average daily visit count", + "tooltip": "Total {value} websites" + }, + "trend": { + "title": "Daily trends", + "siteCount": "Website count" + } } }, "ja": { "period": { - "yAxisMin": "閲覧時間/分", - "yAxisHour": "閲覧時間/時間", - "averageLabel": "1日平均", "sizes": { "fifteen": "15分で統計", "halfHour": "30分で統計", @@ -106,9 +115,6 @@ }, "site": { "title": "ウェブサイトの習慣について", - "focusPieTitle": "閲覧時間の割合", - "visitPieTitle": "訪問の割合", - "otherLabel": "他 {count} サイト", "histogramTitle": "トップ {n} が最も多く訪れた", "exclusiveToday": "今日のデータは平均に含まれていません", "countTotal": "総訪問数/サイト", @@ -117,9 +123,6 @@ }, "pt_PT": { "period": { - "yAxisMin": "Tempo de Navegação / Minutos", - "yAxisHour": "Tempo de Navegação / Hora", - "averageLabel": "Média diária", "sizes": { "fifteen": "Por 15 minutos", "halfHour": "Por meia hora", @@ -135,9 +138,6 @@ }, "site": { "title": "Hábito de sites", - "focusPieTitle": "Percentagem de navegação", - "visitPieTitle": "Porcentagem de visitas", - "otherLabel": "Outros {count} sites", "histogramTitle": "TOP {n} mais visitados", "exclusiveToday": "Os dados de hoje não estão incluídos na média", "countTotal": "Total de visitas/sites", @@ -152,9 +152,6 @@ "hour": "Кожну годину", "twoHour": "Кожні 2 години" }, - "averageLabel": "Середнє за день", - "yAxisMin": "Час перегляду за хвилину", - "yAxisHour": "Час перегляду за годину", "title": "Проміжки часу", "busiest": "Найзавантаженіший час доби", "idle": "Найдовший період бездіяльності" @@ -164,9 +161,6 @@ }, "site": { "title": "Вебсайти", - "focusPieTitle": "Відсоток часу перегляду", - "visitPieTitle": "Відсоток відвідувань", - "otherLabel": "Інші сайти: {count}", "histogramTitle": "{n} найвідвідуваніших сайтів", "exclusiveToday": "Сьогоднішні дані не включені в середній показник", "countTotal": "Всього відвідувань/сайтів", @@ -181,9 +175,6 @@ "title": "Hábito de períodos de tiempo", "busiest": "Hora más ocupada del día", "idle": "Periodo inactivo más largo", - "yAxisMin": "Tiempo de navegación / minuto", - "yAxisHour": "Tiempo de navegación / hora", - "averageLabel": "Promedio diario", "sizes": { "fifteen": "Por 15 minutos", "halfHour": "Por media hora", @@ -193,9 +184,6 @@ }, "site": { "title": "Hábito de sitios web", - "focusPieTitle": "Porcentaje de tiempo de navegación", - "visitPieTitle": "Porcentaje de visitas", - "otherLabel": "Otros {count} sitios", "histogramTitle": "TOP {n} más visitados", "exclusiveToday": "Los datos de hoy no están incluidos en el promedio", "countTotal": "Total de visitas/sitios", @@ -210,9 +198,6 @@ "title": "Gewohnheiten jeden Augenblick", "busiest": "Die geschäftigste Zeit des Tages", "idle": "Längste Leerlaufzeit", - "yAxisMin": "Browsing-Zeit / Minute", - "yAxisHour": "Browsing-Zeit / Stunde", - "averageLabel": "Täglicher Durchschnitt", "sizes": { "fifteen": "Pro 15 Minuten", "halfHour": "Pro halbe Stunde", @@ -222,9 +207,6 @@ }, "site": { "title": "Gewohnheit von Websites", - "focusPieTitle": "Prozentsatz der Anzeigezeit", - "visitPieTitle": "Prozentsatz der Besuche", - "otherLabel": "{count} andere Websites", "histogramTitle": "TOP {n} der meistbesuchten", "exclusiveToday": "Der Durchschnitt berücksichtigt nicht die heutigen Daten", "countTotal": "Gesamtzahl der Besuche/Websites", @@ -239,9 +221,6 @@ "title": "Habitude des périodes de temps", "busiest": "Période de la journée la plus chargée", "idle": "La plus longue période d'inactivité", - "yAxisMin": "Temps / minute de navigation", - "yAxisHour": "Temps / heure de navigation", - "averageLabel": "Moyenne quotidienne", "sizes": { "fifteen": "Par tranche de 15 minutes", "halfHour": "Par demi-heure", @@ -251,9 +230,6 @@ }, "site": { "title": "Habitude des sites web", - "focusPieTitle": "Pourcentage de temps de navigation", - "visitPieTitle": "Pourcentage des visites", - "otherLabel": "{count} autres sites", "histogramTitle": "Top {n} des plus visités", "exclusiveToday": "Les données d'aujourd'hui ne sont pas incluses dans la moyenne", "countTotal": "Nombre total de visites/sites", diff --git a/src/i18n/message/app/habit.ts b/src/i18n/message/app/habit.ts index 19e03e08..25282083 100644 --- a/src/i18n/message/app/habit.ts +++ b/src/i18n/message/app/habit.ts @@ -15,9 +15,11 @@ export type HabitMessage = { title: string busiest: string idle: string - yAxisMin: string - yAxisHour: string - averageLabel: string + chartType: { + average: string + trend: string + stack: string + } sizes: { fifteen: string halfHour: string @@ -27,13 +29,20 @@ export type HabitMessage = { } site: { title: string - focusPieTitle: string - visitPieTitle: string - otherLabel: string histogramTitle: string exclusiveToday: string countTotal: string siteAverage: string + distribution: { + title: string + aveTime: string + aveVisit: string + tooltip: string + } + trend: { + siteCount: string + title: string + } } } diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 5e396200..6736cad7 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -13,13 +13,7 @@ "today": "今天", "yesterday": "昨天", "everyday": "每天", - "last24Hours": "最近 24 小时", - "last3Days": "最近 3 天", - "last7Days": "最近 7 天", - "last15Days": "最近 15 天", - "last30Days": "最近 30 天", - "last60Days": "最近 60 天", - "last90Days": "最近 90 天" + "lastDays": "最近 {n} 天" } }, "zh_TW": { @@ -35,14 +29,8 @@ "range": { "today": "今天", "yesterday": "昨天", - "last24Hours": "最近 24 小時", - "last3Days": "最近 3 天", - "last7Days": "最近 7 天", - "last15Days": "最近 15 天", - "last30Days": "最近 30 天", - "last60Days": "最近 60 天", - "last90Days": "最近 90 天", - "everyday": "每天" + "everyday": "每天", + "lastDays": "最近 {n} 天" } }, "en": { @@ -59,13 +47,7 @@ "today": "Today", "yesterday": "Yesterday", "everyday": "Everyday", - "last24Hours": "Last 24 hours", - "last3Days": "Last 3 days", - "last7Days": "Last 7 days", - "last15Days": "Last 15 days", - "last30Days": "Last 30 days", - "last60Days": "Last 60 days", - "last90Days": "Last 90 days" + "lastDays": "Last {n} days" } }, "ja": { @@ -81,14 +63,8 @@ "range": { "today": "今日", "yesterday": "昨日", - "last24Hours": "過去 24 時間", - "last3Days": "過去 3 日間", - "last7Days": "過去 7 日間", - "last15Days": "過去 15 日間", - "last30Days": "過去 30 日間", - "last60Days": "過去 60 日間", - "last90Days": "過去 90 日間", - "everyday": "毎日" + "everyday": "毎日", + "lastDays": "過去 {n} 日間" } }, "pt_PT": { @@ -104,14 +80,8 @@ "range": { "today": "Hoje", "yesterday": "Ontem", - "last3Days": "Últimos 3 dias", - "last7Days": "Últimos 7 dias", - "last15Days": "Últimos 15 dias", - "last30Days": "Últimos 30 dias", - "last60Days": "Últimos 60 dias", - "last90Days": "Últimos 90 dias", - "last24Hours": "Últimas 24 horas", - "everyday": "Diariamente" + "everyday": "Diariamente", + "lastDays": "Últimos {n} dias" } }, "uk": { @@ -126,14 +96,8 @@ "range": { "today": "Сьогодні", "yesterday": "Учора", - "last24Hours": "Минулі 24 години", - "last3Days": "Минулі 3 дні", - "last7Days": "Минулих 7 днів", - "last15Days": "Минулих 15 днів", - "last30Days": "Минулих 30 днів", - "last60Days": "Минулих 60 днів", - "last90Days": "Минулих 90 днів", - "everyday": "Щодня" + "everyday": "Щодня", + "lastDays": "Минулі {n} дні" }, "simpleTimeFormat": "{d}.{m} {h}:{i}" }, @@ -149,14 +113,8 @@ "range": { "today": "Hoy", "yesterday": "Ayer", - "last24Hours": "Últimas 24 horas", - "last3Days": "Últimos 3 días", - "last7Days": "Últimos 7 días", - "last15Days": "Últimos 15 días", - "last30Days": "Últimos 30 días", - "last60Days": "Últimos 60 días", - "last90Days": "Últimos 90 días", - "everyday": "Cada día" + "everyday": "Cada día", + "lastDays": "Últimos {n} días" }, "simpleTimeFormat": "{m}/{d} {h}:{i}" }, @@ -171,14 +129,8 @@ "range": { "today": "Heute", "yesterday": "Gestern", - "last24Hours": "Letzte 24 Stunden", - "last3Days": "Letzte 3 Tage", - "last7Days": "Letzte 7 Tage", - "last15Days": "Letzte 15 Tage", - "last30Days": "Letzte 30 Tage", - "last60Days": "Letzte 60 Tage", - "last90Days": "Letzte 90 Tage", - "everyday": "Täglich" + "everyday": "Täglich", + "lastDays": "Letzte {n} Tage" }, "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", "simpleTimeFormat": "{d}/{m} {h}:{i}" diff --git a/src/i18n/message/common/calendar.ts b/src/i18n/message/common/calendar.ts index da7d4875..af797ace 100644 --- a/src/i18n/message/common/calendar.ts +++ b/src/i18n/message/common/calendar.ts @@ -18,16 +18,10 @@ export type CalendarMessage = { endDate: string } range: { + everyday: string today: string yesterday: string - everyday: string - last24Hours: string - last3Days: string - last7Days: string - last15Days: string - last30Days: string - last60Days: string - last90Days: string + lastDays: string } } diff --git a/src/service/components/period-calculator.ts b/src/service/components/period-calculator.ts index 1f554645..cc072227 100644 --- a/src/service/components/period-calculator.ts +++ b/src/service/components/period-calculator.ts @@ -66,6 +66,7 @@ export type MergeConfig = { } export function merge(periods: timer.period.Result[], config: MergeConfig): timer.period.Row[] { + if (!periods?.length) return [] const result: timer.period.Row[] = [] let { start, end, periodSize } = config const map: Map = new Map() diff --git a/src/service/period-service.ts b/src/service/period-service.ts index 3cd886b6..4a6d26b6 100644 --- a/src/service/period-service.ts +++ b/src/service/period-service.ts @@ -33,15 +33,18 @@ function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): } -async function list(param?: PeriodQueryParam): Promise { +async function listBetween(param?: PeriodQueryParam): Promise { const [start, end] = param?.periodRange || [] const allDates = dateStrBetween(start, end) return periodDatabase.getBatch(allDates) } + + class PeriodService { add = add - list = list + listBetween = listBetween + listAll = () => periodDatabase.getAll() } export default new PeriodService() diff --git a/src/util/constant/limit.ts b/src/util/constant/limit.ts new file mode 100644 index 00000000..c9709502 --- /dev/null +++ b/src/util/constant/limit.ts @@ -0,0 +1 @@ +export const DELAY_MILL = 5 * 60 * 1000 \ No newline at end of file diff --git a/src/util/date-iterator.ts b/src/util/date-iterator.ts index 44ec747d..8934fe81 100644 --- a/src/util/date-iterator.ts +++ b/src/util/date-iterator.ts @@ -45,7 +45,7 @@ export default class DateIterator { } } - forEach(callback: (yearMonth: string) => void) { + forEach(callback: (yearMonthDate: string) => void) { while (this.hasNext()) { callback(this.next().value) } diff --git a/src/util/echarts.ts b/src/util/echarts.ts deleted file mode 100644 index fd567f02..00000000 --- a/src/util/echarts.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getCssVariable } from "@util/style" -import { ZRColor } from "echarts/types/dist/shared" - -export const echartsPalette: () => ZRColor[] = () => [ - getCssVariable("--el-color-primary"), - getCssVariable("--el-color-success"), - getCssVariable("--el-color-warning"), - getCssVariable("--el-color-danger"), -] diff --git a/src/util/period.ts b/src/util/period.ts index d1be5513..02322fbe 100644 --- a/src/util/period.ts +++ b/src/util/period.ts @@ -30,6 +30,7 @@ export function copyKeyWith(old: timer.period.Key, newOrder: number): timer.peri } export function indexOf(key: timer.period.Key): number { + if (!key) return 0 const { year, month, date, order } = key return (year << 18) | (month << 14) @@ -118,4 +119,36 @@ export function calcMostPeriodOf2Hours(rows: timer.period.Result[]): number { .reverse()[0]?.[0] ) return most2Hour -} \ No newline at end of file +} + +function generateOrderMap(data: timer.period.Row[], periodSize: number): Map { + const map: Map = new Map() + data.forEach(item => { + const key = Math.floor(startOrderOfRow(item) / periodSize) + const val = map.get(key) || 0 + map.set(key, val + item.milliseconds) + }) + return map +} + +function cvt2AverageResult(map: Map, periodSize: number, dateNum: number): timer.period.Row[] { + const result = [] + let period = keyOf(new Date(), 0) + for (let i = 0; i < PERIOD_PER_DATE / periodSize; i++) { + const key = period.order / periodSize + const val = map.get(key) ?? 0 + const averageMill = Math.round(val / dateNum) + result.push(rowOf(after(period, periodSize - 1), periodSize, averageMill)) + period = after(period, periodSize) + } + return result +} + +export function averageByDay(data: timer.period.Row[], periodSize: number): timer.period.Row[] { + if (!data?.length) return [] + const rangeStart = data[0]?.startTime + const rangeEnd = data[data.length - 1]?.endTime + const dateNum = (rangeEnd.getTime() - rangeStart.getTime()) / MILL_PER_DAY + const map = generateOrderMap(data, periodSize) + return cvt2AverageResult(map, periodSize, dateNum) +} diff --git a/src/util/style.ts b/src/util/style.ts index 4cd02a6d..890750b8 100644 --- a/src/util/style.ts +++ b/src/util/style.ts @@ -17,6 +17,10 @@ export function getPrimaryTextColor(): string { return getCssVariable("--el-text-color-primary") } +export function getRegularTextColor(): string { + return getCssVariable("--el-text-color-regular") +} + export function getSecondaryTextColor(): string { return getCssVariable("--el-text-color-secondary") } diff --git a/src/util/time.ts b/src/util/time.ts index 9d3c824b..3ccc2193 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -95,16 +95,18 @@ export function formatPeriodCommon(milliseconds: number): string { return formatPeriod(milliseconds, defaultMessage) } +export const MILL_PER_SECOND = 1000 + +export const MILL_PER_MINUTE = MILL_PER_SECOND * 60 + +export const MILL_PER_HOUR = MILL_PER_MINUTE * 60 + /** * Milliseconds per day * * @since 0.0.8 */ -export const MILL_PER_DAY = 3600 * 1000 * 24 - -export const MILL_PER_MINUTE = 1000 * 60 - -export const MILL_PER_HOUR = MILL_PER_MINUTE * 60 +export const MILL_PER_DAY = MILL_PER_HOUR * 24 /** * Date range between {start} days ago and {end} days ago diff --git a/src/util/tuple.ts b/src/util/tuple.ts new file mode 100644 index 00000000..6de10731 --- /dev/null +++ b/src/util/tuple.ts @@ -0,0 +1,47 @@ +import { range } from "./array" + +const isTuple = (arg: unknown): arg is Tuple => { + if (Array.isArray(arg)) return true + if (!arg.hasOwnProperty?.("get")) return false + const predicate = arg as Tuple + const len = predicate?.length + return typeof len === 'number' && !isNaN(len) && isFinite(len) && len >= 0 && Number.isInteger(len) +} + +/** + * Add tuple + */ +export const addVector = (a: Vector, toAdd: Vector | number): Vector => { + const l: L = a.length ?? 0 as L + if (isTuple(toAdd)) { + return range(l).map(idx => (a?.[idx] ?? 0) + (toAdd?.[idx] ?? 0)) as unknown as Vector + } else { + return a?.map(v => (v ?? 0) + ((toAdd as number) ?? 0)) as unknown as Vector + } +} + +/** + * Subtract tuple + */ +export const subVector = (a: Vector, toSub: Vector | number): Vector => { + const l: L = a.length ?? 0 as L + if (isTuple(toSub)) { + return range(l).map(idx => (a?.[idx] ?? 0) - (toSub?.[idx] ?? 0)) as unknown as Vector + } else { + return a?.map(v => (v ?? 0) + ((toSub as number) ?? 0)) as unknown as Vector + } +} + +/** + * Multiple tuple + */ +export const multiTuple = (a: Vector, multiFactor: number): Vector => { + return a?.map(v => (v ?? 0) * (multiFactor ?? 0)) as unknown as Vector +} + +/** + * Divide tuple + */ +export const divideTuple = (a: Vector, divideFactor: number): Vector => { + return a?.map(v => (v ?? 0) / (divideFactor ?? 0)) as unknown as Vector +} diff --git a/test/database/stat-database.test.ts b/test/database/stat-database.test.ts index 60299019..917ae75c 100644 --- a/test/database/stat-database.test.ts +++ b/test/database/stat-database.test.ts @@ -97,7 +97,7 @@ describe('stat-database', () => { // time [2, 3] cond.timeRange = [2, 3] - cond.focusRange = [] + cond.focusRange = [, null] expect((await db.select(cond)).length).toEqual(2) }) diff --git a/types/common.d.ts b/types/common.d.ts index 7e6640f4..9b41484b 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -6,3 +6,23 @@ declare type EmbeddedPartial = { ? ReadonlyArray> : EmbeddedPartial; } + +/** + * Tuple with length + * + * @param E element + * @param L length of tuple + */ +declare type Tuple]> = + Pick> + & { + readonly length: L + [I: number]: E + } + +/** + * Vector + * + * @param D dimension of vector + */ +declare type Vector = Tuple \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts index 62e5e8e0..7c3996b7 100644 --- a/types/timer/limit.d.ts +++ b/types/timer/limit.d.ts @@ -5,7 +5,7 @@ declare namespace timer.limit { * [0, 120] means from 00:00 to 02:00 * @since 2.0.0 */ - type Period = [number, number] + type Period = Vector<2> /** * Limit rule in runtime *