更改了日程的样式显示,现在按照时间轴显示。

Signed-off-by: Yang Yongquan <i@yangyq.net>
This commit is contained in:
2026-09-14 21:28:51 +08:00
parent df3928c099
commit c04a9a28cf
14 changed files with 2425 additions and 781 deletions
+25
View File
@@ -16,6 +16,31 @@ export class AppSettings {
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
/** 打开 App 后默认展示的视图:'month' | 'week' | 'agenda'(默认 month */
static async getDefaultView(context: common.Context): Promise<string> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const v: string = await store.get(AppSettings.KEY_DEFAULT_VIEW, 'month') as string;
return (v === 'week' || v === 'agenda') ? v : 'month';
} catch (err) {
return 'month';
}
}
static async setDefaultView(context: common.Context, view: string): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_DEFAULT_VIEW, view);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存默认视图失败: ${e.message}`);
}
}
/**
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
+92 -28
View File
@@ -7,6 +7,7 @@ import { preferences } from '@kit.ArkData';
import { CalendarDataService, DisplayEvent } from './CalendarDataService';
import { LunarUtil } from './LunarUtil';
import { LogUtil } from './LogUtil';
import { TimelineUtil, DayTimeline } from './TimelineUtil';
/** 卡片单条日程(按天分组:组内全天事件在前、有时间的按开始时间排序) */
export class CardItem {
@@ -26,6 +27,14 @@ export class CardItem {
nowLineBelow: boolean = false; // 今天所有有时间日程已结束:红线画在最后一条下方
}
/** 卡片「全天/跨天」日程(**必须带 eventKey**,否则卡片侧 ForEach 的 key 会重复 → 只渲染出第一条) */
export class CardAllDay {
eventKey: string = '';
title: string = '';
color: string = '#007DFF';
isAllDay: boolean = true;
}
/** 卡片整体数据 */
export class CardData {
eventsJson: string = '[]';
@@ -38,6 +47,15 @@ export class CardData {
ongoingCount: number = 0; // 此刻正在进行的日程条数(2x2 卡片红点提示用)
// 2x4 卡片右侧「正在进行」列表:与 eventsJson 同结构(CardItem[]),只含此刻进行中/下一个日程
ongoingJson: string = '[]';
// 4x4 / 6x4 卡片:今日 0-23 时间轴(TimelineBlock[] JSON)与当前时间纵向比例
timelineJson: string = '[]';
nowRatio: number = 0; // 当前时间在当天时间轴上的比例(0~1),卡片画红线用
nowLabel: string = ''; // 当前时间文字(如 '14:05'
allDayJson: string = '[]'; // 全天/跨天日程(CardAllDay[] JSON,卡片画在最上方)
// ===== 卡片翻页(上一天 / 下一天 / 回到今天)=====
dayOffset: number = 0; // 相对今天的天数偏移(0=今天)
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
dayCount: number = 0; // 当前显示日的日程条数(含全天)
}
export class CardDataService {
@@ -47,6 +65,19 @@ export class CardDataService {
// formIds 持久化(App 重启后内存注册表会清空,从 preferences 恢复,保证推送不丢卡片)
private static readonly PREF_STORE: string = 'card_form_ids';
private static readonly PREF_KEY: string = 'form_ids';
// 每张卡片的"翻页偏移"(formId → 相对今天的天数)。卡片不能滚动,靠翻页看别的日期。
// 放在这里是为了让 FormExtension(按钮翻页)与 App 内刷新共用同一份状态。
private static offsets: Map<string, number> = new Map<string, number>();
static setDayOffset(formId: string, off: number): void {
CardDataService.offsets.set(formId, off);
}
static getDayOffset(formId: string): number {
return CardDataService.offsets.get(formId) ?? 0;
}
static clearDayOffset(formId: string): void {
CardDataService.offsets.delete(formId);
}
/** 注册卡片:内存 + 持久化 */
static async registerForm(context: common.Context, formId: string): Promise<void> {
@@ -96,11 +127,21 @@ export class CardDataService {
if (CardDataService.formIds.length === 0) {
return;
}
const data: CardData = await CardDataService.buildCardData(context);
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data);
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
const cache: Map<number, formBindingData.FormBindingData> =
new Map<number, formBindingData.FormBindingData>();
const fallback: formBindingData.FormBindingData =
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0));
cache.set(0, fallback);
const stale: string[] = [];
for (const formId of CardDataService.formIds) {
const off: number = CardDataService.getDayOffset(formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
if (binding === undefined) {
binding = formBindingData.createFormBindingData(
await CardDataService.buildCardData(context, off));
cache.set(off, binding);
}
try {
await formProvider.updateForm(formId, binding);
} catch (err) {
@@ -123,20 +164,29 @@ export class CardDataService {
}
/** 组装卡片数据(异步:查询本地库) */
static async buildCardData(context: common.Context): Promise<CardData> {
/** 构建卡片数据。
* @param dayOffset 相对"今天"的天数偏移(卡片翻页用:负数=过去,0=今天,正数=未来)
* 非今天时不输出 nowRatio(红线不显示)。 */
static async buildCardData(context: common.Context, dayOffset: number = 0): Promise<CardData> {
const data = new CardData();
try {
LogUtil.init(context);
const now = new Date();
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
data.dateText = `${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(now.getTime());
// 2x2 卡片强化字段:月-日(x月x日,年份冗余省略)、星期全名、今日日程条数
data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`;
data.weekday = fullWeekCn[now.getDay()];
const start: number = CardDataService.startOfDay(now.getTime());
const end: number = start + 60 * 86400000;
const todayStart: number = CardDataService.startOfDay(now.getTime());
const baseStart: number = todayStart + dayOffset * 86400000; // 当前卡片显示的"那一天"0 点
const base: Date = new Date(baseStart);
const prefix: string = dayOffset === 0 ? '今天 ' : (dayOffset === 1 ? '明天 ' :
(dayOffset === -1 ? '昨天 ' : ''));
data.dateText = `${prefix}${base.getMonth() + 1}月${base.getDate()}日 ${weekCn[base.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(baseStart);
data.dateMd = `${base.getMonth() + 1}月${base.getDate()}日`;
data.weekday = fullWeekCn[base.getDay()];
data.dayOffset = dayOffset;
data.isToday = dayOffset === 0;
const start: number = baseStart < todayStart ? baseStart : todayStart;
const end: number = (baseStart > todayStart ? baseStart : todayStart) + 60 * 86400000;
const sources = await CalendarDataService.loadSources(context);
const events: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, sources);
// 1) 过滤:保留"今天 0 点以来"的日程(今天已结束的也显示,否则重复日程的
@@ -249,22 +299,14 @@ export class CardDataService {
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
// ===== 2x4 卡片右侧:正在进行 / 下一个 日程(最多 3 条,作为 2x2 的右侧扩展)=====
// 规则:① 进行中或已开始未结束的(含全天/跨天,按 0 点起算)优先,最多 2 条;
// ② 不足时用"今天还没开始的下一个有时间日程"补 1 条。全部按开始时间升序。
const floorMs = Math.max(nowMs, todayKey);
const ongoingAll: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => e.startTime <= nowMs && e.endTime > floorMs)
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 =====
// 规则:只取"还没开始"的有时间日程(startTime >= now),
// **排除全天 / 跨天**按开始时间升序取前 2 条
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
const upcomingNext: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
let ongoingPick: DisplayEvent[] = ongoingAll.slice(0, 2);
if (ongoingPick.length < 2) {
const next: DisplayEvent | undefined = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime > nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime)[0];
if (next !== undefined) {
ongoingPick = ongoingPick.concat([next]);
}
}
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2);
const ongoingItems: CardItem[] = [];
for (const e of ongoingPick) {
const dayLong: boolean = isDayLong(e);
@@ -278,11 +320,33 @@ export class CardDataService {
oi.color = e.color;
oi.startMs = e.startTime;
oi.endMs = e.endTime;
oi.isNow = e.startTime <= nowMs && e.endTime > nowMs;
oi.isNow = false;
ongoingItems.push(oi);
}
data.ongoingJson = JSON.stringify(ongoingItems);
LogUtil.write(`卡片数据刷新:${items.length} 条,今日 ${data.todayCount} 条,进行中 ${ongoingItems.length} 条`);
// ===== 4x4 / 6x4 卡片:**当前显示日**的时间轴(色块按时间平铺,冲突分列)=====
const dayEvents: DisplayEvent[] = events.filter((e: DisplayEvent): boolean =>
e.startTime < baseStart + 86400000 && e.endTime > baseStart);
const tl: DayTimeline = TimelineUtil.build(dayEvents, baseStart);
TimelineUtil.markNow(tl.blocks, nowMs);
data.timelineJson = JSON.stringify(tl.blocks);
// 注意:不能直接 stringify(DisplayEvent) —— 它没有 eventKey 字段,
// 卡片侧 ForEach 会拿到 undefined 导致 key 重复(表现为"全天只显示一条")
const allDayItems: CardAllDay[] = [];
for (const e of tl.allDay) {
const ad = new CardAllDay();
ad.eventKey = TimelineUtil.keyOf(e);
ad.title = e.title === '' ? '(无标题)' : e.title;
ad.color = e.color;
ad.isAllDay = e.isAllDay;
allDayItems.push(ad);
}
data.allDayJson = JSON.stringify(allDayItems);
// 只有"今天"才给红线比例;翻到别的日期时给 -1(卡片侧 nowR<0 → 不画红线)
data.nowRatio = data.isToday ? TimelineUtil.nowRatio(nowMs) : -1;
data.nowLabel = TimelineUtil.nowLabel(nowMs);
data.dayCount = tl.blocks.length + tl.allDay.length;
LogUtil.write(`卡片数据刷新:${items.length} 条,今日 ${data.todayCount} 条,进行中 ${ongoingItems.length} 条,时间轴 ${tl.blocks.length} 块`);
} catch (err) {
LogUtil.write(`卡片数据刷新失败: ${JSON.stringify(err)}`);
}
+360
View File
@@ -0,0 +1,360 @@
// entry/src/main/ets/common/TimelineUtil.ets
// 时间轴布局:把"某一天"的日程按真实时间平铺到 0-23 小时的纵向格子上。
//
// 设计约定(与用户确认):
// - 时间粒度 1 小时 = 1 格(0 点到 23 点,共 24 格),色块高度按实际时长换算。
// - 同一时间重叠(冲突)的日程横向平分宽度:1 条 = 100%,2 条 = 50%3 条 ≈ 33.3%…
// - 色块背景色 = 所属日历本颜色。
// - 全天/跨天日程不占时间轴,单独返回,由视图画在最上方(0 点之前)。
// - 当前时间红线按"现在"在时间轴上的纵向比例定位(hoursFromDayStart / 24)。
//
// ArkTS 约束:所有"多字段返回值"必须是命名 class,不能用内联对象字面量类型。
import { DisplayEvent } from './CalendarDataService';
/** 时间轴上的一个日程色块(位置/尺寸都用相对比例,视图侧乘以实际高度即可) */
export class TimelineBlock {
eventKey: string = ''; // 与视图 ForEach key 一致
title: string = '';
timeText: string = ''; // '09:00 - 10:30'
color: string = '#007DFF';
topRatio: number = 0; // 距 0 点的比例 0~1(乘时间轴总高)
heightRatio: number = 0; // 高度比例(最小高度由视图侧兜底)
leftRatio: number = 0; // 横向起始比例 0~1(冲突平分)
widthRatio: number = 1; // 横向宽度比例
groupIndex: number = 0; // 冲突组序号(时间上连通重叠的一簇,视图侧按组分行)
laneIndex: number = 0; // 组内的"列号"(贪心分列结果,列号相同的日程互不重叠、可复用一列)
isNow: boolean = false; // 正在进行中(视图侧可高亮)
// 原始时间,点击/详情使用
ev: DisplayEvent | null = null;
}
/** 一天的完整时间轴布局结果 */
export class DayTimeline {
dateKey: number = 0; // 该天 0 点毫秒(ForEach key 去重 / 调试用)
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
blocks: TimelineBlock[] = []; // 时间轴色块
hasTimed: boolean = false; // 当天是否有有时间日程
}
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
class OverlapGroup {
items: DisplayEvent[] = [];
endMax: number = 0;
}
/** 视图/卡片渲染用的"冲突组":一组互相重叠的日程横向平分宽度,每条各占一"列"(lane)。
* 纵向:组本身占据 [startRatio, endRatio] 一段,组内每条按自己的 topRatio 定位。
* 不同组在时间上互不重叠(由分组算法保证),因此可以按时间顺序自上而下排列。 */
export class TimelineGroup {
startRatio: number = 0; // 组内最早开始(距 0 点比例 0~1)
endRatio: number = 0; // 组内最晚结束
laneCount: number = 1; // 列数 = 组内"最大同时重叠数"(贪心分列结果,不是组内条数)
gapBeforeRatio: number = 0; // 与上一个组(或 0 点)之间的空隙比例
blocks: TimelineBlock[] = []; // 组内全部色块(按开始时间升序)
lanes: TimelineBlock[][] = []; // 按列组织:lanes[i] = 第 i 列上的色块(同列互不重叠,纵向依次排列)
}
/** 视窗范围(小时):按当天日程的起止截断,"今天"还要容纳当前时刻 */
export class ViewRange {
startHour: number = 0;
endHour: number = 0;
}
export class TimelineUtil {
/** 单个格子的高度(vp):调用方(视图/卡片)用同一个常量换算 */
static readonly HOUR_UNIT: number = 44;
/** 0 点毫秒 */
static startOfDay(ms: number): number {
const d = new Date(ms);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
private static spansDays(e: DisplayEvent): boolean {
return TimelineUtil.startOfDay(e.endTime) > TimelineUtil.startOfDay(e.startTime);
}
/** 日程唯一 key(与列表 ForEach 保持一致) */
static keyOf(e: DisplayEvent): string {
return `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}`;
}
private static fmtTime(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
/**
* 构建某一天的时间轴布局。
* @param events 与 dateMs 这一天相交的全部日程
* @param dateMs 该天 0 点毫秒
*/
static build(events: DisplayEvent[], dateMs: number): DayTimeline {
const res = new DayTimeline();
const dayStart: number = TimelineUtil.startOfDay(dateMs);
const dayEnd: number = dayStart + 86400000;
res.dateKey = dayStart;
// 1) 分类:全天 → 顶部;有时间 → 时间轴(无论是否当天开始,都按与本日的交集定位)
// 注意:多日日程由上层"逐天展开"后传入,这里只需按交集裁切即可,不能再整条丢到顶部,
// 否则跨天日程的时间段会全部消失(曾导致"除全天外一条都看不到")。
const timed: DisplayEvent[] = [];
for (const e of events) {
if (e.isAllDay) {
res.allDay.push(e);
} else if (e.startTime <= dayStart && e.endTime >= dayEnd) {
// 覆盖整天(多日日程的中间日):占满整列会挡住别的日程 → 放顶部"跨天"条
res.allDay.push(e);
} else if (e.endTime > dayStart && e.startTime < dayEnd) {
// 与"本日 0 点~24 点"有真实时间交集 → 画在时间轴上(起始/结束按交集裁切)
timed.push(e);
}
}
res.allDay.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
res.hasTimed = timed.length > 0;
if (timed.length === 0) {
return res;
}
// 2) 时间轴色块:按开始时间排序,划分"冲突组"(互相重叠的归为一组)
timed.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
const groups: OverlapGroup[] = [];
for (const e of timed) {
const last: OverlapGroup | undefined = groups.length > 0 ? groups[groups.length - 1] : undefined;
if (last !== undefined && e.startTime < last.endMax) {
last.items.push(e);
last.endMax = Math.max(last.endMax, e.endTime);
} else {
const g = new OverlapGroup();
g.items = [e];
g.endMax = e.endTime;
groups.push(g);
}
}
// 3) 组内"贪心分列"(区间图着色):按开始时间升序,每条放进"结束时间 <= 它开始时间"的
// **编号最小**的那一列。列数 = 该组"最大同时重叠数",而不是组内条数。
// 例:A 7:30-9:30、B 8:20-9:00、C 9:15-9:55 —— B 与 C 并不重叠,可共用同一列,
// 因此只需 2 列(旧算法按"传递性重叠"把三者归为一组 → 错误地分成 3 列)。
let groupIdx: number = 0;
for (const g of groups) {
const laneEnd: number[] = []; // 每列当前的最后结束时间(毫秒)
const laneOf: number[] = []; // 每条日程分配到的列号
for (const e of g.items) {
let lane: number = -1;
for (let i = 0; i < laneEnd.length; i++) {
if (laneEnd[i] <= e.startTime) {
lane = i;
break;
}
}
if (lane < 0) {
lane = laneEnd.length;
laneEnd.push(e.endTime);
} else {
laneEnd[lane] = e.endTime;
}
laneOf.push(lane);
}
const n: number = laneEnd.length > 0 ? laneEnd.length : 1; // 列数 = 最大同时重叠数
for (let i = 0; i < g.items.length; i++) {
const e = g.items[i];
const b = new TimelineBlock();
b.eventKey = TimelineUtil.keyOf(e);
b.groupIndex = groupIdx;
b.title = e.title === '' ? '(无标题)' : e.title;
b.timeText = `${TimelineUtil.fmtTime(e.startTime)} - ${TimelineUtil.fmtTime(e.endTime)}`;
b.color = e.color;
// 与今天的交集(跨天日程已排除,这里都是日内)
const s: number = Math.max(e.startTime, dayStart);
const en: number = Math.min(e.endTime, dayEnd);
b.topRatio = (s - dayStart) / 86400000;
b.heightRatio = Math.max((en - s) / 86400000, 1 / 96); // 最短约 15 分钟,避免零高
b.laneIndex = laneOf[i];
b.widthRatio = 1 / n;
b.leftRatio = laneOf[i] / n;
b.isNow = false;
b.ev = e;
res.blocks.push(b);
}
groupIdx++;
}
// 兜底:保证 blocks 严格按 topRatio 升序,供视图侧 Blank 间隙求差无累积漂移
res.blocks.sort((a: TimelineBlock, b: TimelineBlock): number => a.topRatio - b.topRatio);
return res;
}
/**
* 由平坦的色块列表重建"冲突分组":视图 / 卡片按组分行、组内按列(lane)并排渲染。
* 依赖 TimelineBlock.groupIndex / leftRatio / topRatio / heightRatio。
* 卡片侧只拿到 timelineJsonTimelineBlock[] 序列化,含 groupIndex),因此用它同样能重建。
*/
static groupsOf(blocks: TimelineBlock[]): TimelineGroup[] {
const map: Map<number, TimelineBlock[]> = new Map<number, TimelineBlock[]>();
for (const b of blocks) {
const arr: TimelineBlock[] | undefined = map.get(b.groupIndex);
if (arr === undefined) {
map.set(b.groupIndex, [b]);
} else {
arr.push(b);
}
}
const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b);
const out: TimelineGroup[] = [];
let prevEnd: number = 0;
for (const gi of idxs) {
const bs: TimelineBlock[] = map.get(gi) ?? [];
// 按开始时间升序(同开始时间则长的在前,保证分列稳定)
bs.sort((a: TimelineBlock, b: TimelineBlock): number => {
if (a.topRatio !== b.topRatio) {
return a.topRatio - b.topRatio;
}
return b.heightRatio - a.heightRatio;
});
// 贪心分列:每条放进"结束时间 <= 它开始时间"的编号最小的列 → 列数 = 最大同时重叠数
// 这里用比例而非原始毫秒重算,卡片侧(只有序列化后的 timelineJson)也能正确分列。
const lanes: TimelineBlock[][] = [];
const laneEnd: number[] = [];
for (const b of bs) {
let li: number = -1;
for (let i = 0; i < laneEnd.length; i++) {
if (laneEnd[i] <= b.topRatio + 0.0000001) {
li = i;
break;
}
}
const be: number = b.topRatio + b.heightRatio;
if (li < 0) {
li = lanes.length;
lanes.push([b]);
laneEnd.push(be);
} else {
lanes[li].push(b);
laneEnd[li] = be;
}
b.laneIndex = li;
}
const lc: number = lanes.length > 0 ? lanes.length : 1;
for (const b of bs) {
b.widthRatio = 1 / lc;
b.leftRatio = b.laneIndex / lc;
}
const g = new TimelineGroup();
g.blocks = bs;
g.lanes = lanes;
g.laneCount = lc;
let s: number = 1;
let e: number = 0;
for (const b of bs) {
if (b.topRatio < s) {
s = b.topRatio;
}
const be: number = b.topRatio + b.heightRatio;
if (be > e) {
e = be;
}
}
g.startRatio = s;
g.endRatio = e;
g.gapBeforeRatio = s - prevEnd < 0 ? 0 : s - prevEnd;
prevEnd = e;
out.push(g);
}
return out;
}
/**
* 计算"需要显示的小时区间"(视窗截断):
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点)
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
*/
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
const rg = new ViewRange();
let s: number = -1;
let e: number = -1;
for (const b of blocks) {
const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) {
s = bh;
}
if (e < 0 || eh > e) {
e = eh;
}
}
if (s < 0) {
s = 8;
}
if (e < 0) {
e = 20;
}
if (includeNowHour) {
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
if (nh < s) {
s = nh;
}
if (nh + 1 > e) {
e = nh + 1;
}
}
if (s < 0) {
s = 0;
}
if (s > 23) {
s = 23;
}
if (e > 24) {
e = 24;
}
if (e <= s) {
e = s + 1 > 24 ? 24 : s + 1;
}
rg.startHour = s;
rg.endHour = e;
return rg;
}
/** 某天"有时间"的日程是否已全部结束:最晚结束比例 <= 当前时刻比例。
* (全天/跨天色块不参与 —— 它们没有"结束时刻"概念。)
* 当只剩全天日程、或当天无任何定时日程时,同样返回 true(视为"已无进行中的日程")。
* 用于:决定是否显示"当前时间红线",以及是否把"当前小时"并入时间轴视窗。 */
static allTimedEnded(blocks: TimelineBlock[], nowMs: number): boolean {
let maxEnd: number = 0;
for (const b of blocks) {
const e: number = b.topRatio + b.heightRatio;
if (e > maxEnd) {
maxEnd = e;
}
}
return TimelineUtil.nowRatio(nowMs) >= maxEnd - 0.0001;
}
/** 标记"正在进行"的色块(start<=now<end),供视图高亮 */
static markNow(blocks: TimelineBlock[], nowMs: number): void {
for (const b of blocks) {
if (b.ev !== null) {
b.isNow = b.ev.startTime <= nowMs && nowMs < b.ev.endTime;
}
}
}
/** 当前时间在当天时间轴上的纵向比例(0~1,超出 0 点/24 点会被夹紧) */
static nowRatio(nowMs: number): number {
const dayStart: number = TimelineUtil.startOfDay(nowMs);
let r: number = (nowMs - dayStart) / 86400000;
if (r < 0) {
r = 0;
}
if (r > 1) {
r = 1;
}
return r;
}
/** 当前时间的小时刻度文字(画在红线左侧),如 '14:05' */
static nowLabel(ms: number): string {
return TimelineUtil.fmtTime(ms);
}
}