@luckydye/calendar 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ import { CalendarInternal } from "../CalendarInternal.js";
2
+ import type { CalendarLayer, LayerContext } from "../CalendarLayer.js";
3
+
4
+ export function createGridLayer(): CalendarLayer {
5
+ return {
6
+ name: "grid",
7
+ enabled: true,
8
+ render(lc: LayerContext): void {
9
+ const {
10
+ ctx,
11
+ width,
12
+ height,
13
+ scrollTop,
14
+ dayWidth,
15
+ dayHeight,
16
+ leftGutterWidth,
17
+ columnsPerRow,
18
+ rowsPerWeek,
19
+ visibleWeeks,
20
+ allWeeks,
21
+ fontFamily,
22
+ styles,
23
+ getDayVisualPosition,
24
+ filter,
25
+ } = lc;
26
+
27
+ const today = new Date();
28
+ const showTimeScale = dayHeight >= 300;
29
+
30
+ // Draw today highlight and current time indicator
31
+ for (const week of visibleWeeks) {
32
+ const todayIndex = week.days.findIndex((d) =>
33
+ CalendarInternal.isSameDay(d, today),
34
+ );
35
+ if (todayIndex >= 0) {
36
+ const { row, col } = getDayVisualPosition(todayIndex);
37
+ const x = leftGutterWidth + col * dayWidth;
38
+ const dayY = week.yOffset + row * dayHeight - scrollTop;
39
+ ctx.fillStyle =
40
+ styles["--bg-today"] || "rgba(255, 255, 255, 0.05)";
41
+ ctx.fillRect(x, dayY, dayWidth, dayHeight);
42
+
43
+ if (showTimeScale) {
44
+ const now = new Date();
45
+ const currentMinutes =
46
+ now.getHours() * 60 + now.getMinutes();
47
+ const timeY =
48
+ dayY + (currentMinutes / 1440) * dayHeight;
49
+ if (timeY >= 0 && timeY <= height) {
50
+ ctx.strokeStyle =
51
+ styles["--accent-current-time"] ||
52
+ "rgba(255, 0, 0, 0.8)";
53
+ ctx.lineWidth = 1;
54
+ ctx.beginPath();
55
+ ctx.moveTo(x, timeY);
56
+ ctx.lineTo(x + dayWidth, timeY);
57
+ ctx.stroke();
58
+ ctx.lineWidth = 1;
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // Draw grid lines
65
+ const gridColor =
66
+ styles["--grid-color"] || "rgba(255, 255, 255, 0.1)";
67
+ ctx.strokeStyle = gridColor;
68
+ ctx.lineWidth = 1;
69
+
70
+ // Vertical lines (day separators)
71
+ for (let i = 1; i <= columnsPerRow; i++) {
72
+ const x = leftGutterWidth + i * dayWidth;
73
+ ctx.beginPath();
74
+ ctx.moveTo(x, 0);
75
+ ctx.lineTo(x, height);
76
+ ctx.stroke();
77
+ }
78
+
79
+ // Horizontal lines (week separators) and left gutter content
80
+ for (let i = 0; i < visibleWeeks.length; i++) {
81
+ const week = visibleWeeks[i];
82
+ if (!week) continue;
83
+ const y = week.yOffset - scrollTop;
84
+
85
+ // Draw gap indicator if there are hidden weeks before this one
86
+ if (filter && i > 0) {
87
+ const prevWeek = visibleWeeks[i - 1];
88
+ if (!prevWeek) continue;
89
+ const prevWeekIndex = allWeeks.indexOf(prevWeek);
90
+ const currentWeekIndex = allWeeks.indexOf(week);
91
+ const hiddenWeeks = currentWeekIndex - prevWeekIndex - 1;
92
+
93
+ if (hiddenWeeks > 0) {
94
+ const gapY = y;
95
+ const gridColorStrong =
96
+ styles["--grid-color-strong"] ||
97
+ "rgba(255, 255, 255, 0.3)";
98
+ ctx.strokeStyle = gridColorStrong;
99
+ ctx.lineWidth = 1;
100
+ ctx.setLineDash([4, 4]);
101
+ ctx.beginPath();
102
+ ctx.moveTo(leftGutterWidth, gapY);
103
+ ctx.lineTo(width, gapY);
104
+ ctx.stroke();
105
+ ctx.setLineDash([]);
106
+
107
+ const textMuted =
108
+ styles["--text-muted"] ||
109
+ "rgba(255, 255, 255, 0.4)";
110
+ ctx.fillStyle = textMuted;
111
+ ctx.textAlign = "center";
112
+ const ellipsisText = `⋯ ${hiddenWeeks} week${hiddenWeeks > 1 ? "s" : ""}`;
113
+
114
+ const textWidth =
115
+ ctx.measureText(ellipsisText).width;
116
+ const pillPadding = 8;
117
+ const pillX =
118
+ (leftGutterWidth + width) / 2 -
119
+ textWidth / 2 -
120
+ pillPadding;
121
+ const pillY = gapY - 8;
122
+ const pillWidth = textWidth + pillPadding * 2;
123
+ const pillHeight = 16;
124
+
125
+ const bgPrimary =
126
+ styles["--bg-primary"] ||
127
+ "rgba(30, 30, 30, 0.9)";
128
+ ctx.fillStyle = bgPrimary;
129
+ ctx.beginPath();
130
+ ctx.roundRect(
131
+ pillX,
132
+ pillY,
133
+ pillWidth,
134
+ pillHeight,
135
+ 8,
136
+ );
137
+ ctx.fill();
138
+
139
+ ctx.fillStyle = textMuted;
140
+ ctx.fillText(
141
+ ellipsisText,
142
+ (leftGutterWidth + width) / 2,
143
+ gapY + 4,
144
+ );
145
+ }
146
+ }
147
+
148
+ // Week separator line
149
+ ctx.strokeStyle = gridColor;
150
+ ctx.beginPath();
151
+ ctx.moveTo(leftGutterWidth, y);
152
+ ctx.lineTo(width, y);
153
+ ctx.stroke();
154
+
155
+ // Horizontal lines between visual rows within this week
156
+ for (let row = 1; row < rowsPerWeek; row++) {
157
+ const rowY = y + row * dayHeight;
158
+ if (rowY >= 0 && rowY <= height) {
159
+ ctx.beginPath();
160
+ ctx.moveTo(leftGutterWidth, rowY);
161
+ ctx.lineTo(width, rowY);
162
+ ctx.stroke();
163
+ }
164
+ }
165
+
166
+ // Left gutter: hour lines and labels
167
+ const hourLabelOpacity = Math.max(
168
+ 0,
169
+ Math.min(1, (dayHeight - 300) / 300),
170
+ );
171
+
172
+ ctx.font = `500 11px ${fontFamily}`;
173
+ ctx.textBaseline = "bottom";
174
+ ctx.textAlign = "right";
175
+
176
+ const textMuted =
177
+ styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
178
+ ctx.strokeStyle = gridColor.replace(
179
+ /[\d.]+\)$/,
180
+ `${0.05 * hourLabelOpacity})`,
181
+ );
182
+ ctx.fillStyle = textMuted.replace(
183
+ /[\d.]+\)$/,
184
+ `${0.4 * hourLabelOpacity})`,
185
+ );
186
+
187
+ for (let row = 0; row < rowsPerWeek; row++) {
188
+ const rowY = y + row * dayHeight;
189
+ for (let hour = 0; hour < 24; hour++) {
190
+ const hourY = rowY + (hour / 24) * dayHeight;
191
+ if (hourY >= 0 && hourY <= height) {
192
+ ctx.beginPath();
193
+ ctx.moveTo(leftGutterWidth, hourY);
194
+ ctx.lineTo(width, hourY);
195
+ ctx.stroke();
196
+
197
+ if (hourLabelOpacity > 0.1) {
198
+ const label = `${hour.toString().padStart(2, "0")}:00`;
199
+ ctx.fillText(label, 48, hourY + 4);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // Current time indicator in left gutter
206
+ if (hourLabelOpacity > 0.1) {
207
+ const todayIndex = week.days.findIndex((d) =>
208
+ CalendarInternal.isSameDay(d, today),
209
+ );
210
+
211
+ if (todayIndex >= 0) {
212
+ const { row } = getDayVisualPosition(todayIndex);
213
+ const currentMinutes =
214
+ today.getHours() * 60 + today.getMinutes();
215
+ const timeY =
216
+ y +
217
+ row * dayHeight +
218
+ (currentMinutes / 1440) * dayHeight;
219
+
220
+ if (timeY >= 0 && timeY <= height) {
221
+ const hours = today
222
+ .getHours()
223
+ .toString()
224
+ .padStart(2, "0");
225
+ const minutes = today
226
+ .getMinutes()
227
+ .toString()
228
+ .padStart(2, "0");
229
+ const timeText = `${hours}:${minutes}`;
230
+
231
+ ctx.save();
232
+ ctx.textAlign = "right";
233
+ ctx.textBaseline = "middle";
234
+
235
+ const textWidth =
236
+ ctx.measureText(timeText).width;
237
+ const bgPaddingX = 6;
238
+ const textX = 48;
239
+ const textY = timeY;
240
+
241
+ const bgElevated =
242
+ styles["--bg-elevated"] ||
243
+ "rgba(0, 0, 0, 0.7)";
244
+ ctx.fillStyle = bgElevated;
245
+ ctx.beginPath();
246
+ ctx.roundRect(
247
+ textX - textWidth - bgPaddingX,
248
+ textY - 8,
249
+ textWidth + bgPaddingX * 2,
250
+ 16,
251
+ 4,
252
+ );
253
+ ctx.fill();
254
+
255
+ ctx.fillStyle =
256
+ styles["--text-primary"] ||
257
+ "rgba(255, 255, 255, 1)";
258
+ ctx.fillText(timeText, textX, textY);
259
+ ctx.restore();
260
+ }
261
+ }
262
+ }
263
+
264
+ // Week number - sticky within visible portion
265
+ const weekNumberOpacity = Math.max(
266
+ 0,
267
+ Math.min(1, 1 - (dayHeight - 300) / 50),
268
+ );
269
+ const textMutedForWeek =
270
+ styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
271
+ ctx.fillStyle = textMutedForWeek.replace(
272
+ /[\d.]+\)$/,
273
+ `${0.4 * weekNumberOpacity})`,
274
+ );
275
+ ctx.textAlign = "center";
276
+ const label = `W${week.weekNumber}`;
277
+ const weekTop = y;
278
+ const weekBottom = y + week.height;
279
+ const labelY = Math.max(
280
+ 14,
281
+ Math.min(
282
+ weekTop + week.height / 2 + 4,
283
+ weekBottom - 4,
284
+ ),
285
+ );
286
+ if (
287
+ labelY >= Math.max(0, weekTop + 4) &&
288
+ labelY <= Math.min(height, weekBottom) &&
289
+ weekNumberOpacity > 0.1
290
+ ) {
291
+ ctx.fillText(label, 30, labelY);
292
+ }
293
+ }
294
+ },
295
+ };
296
+ }
@@ -0,0 +1,132 @@
1
+ import type { CalendarEvent, WeekInfo } from "../CalendarInternal.js";
2
+ import type { CalendarLayer, LayerContext } from "../CalendarLayer.js";
3
+
4
+ export interface HeatmapState {
5
+ events: CalendarEvent[];
6
+ }
7
+
8
+ function getBucketMinutes(dayHeight: number): number {
9
+ if (dayHeight >= 320) return 15;
10
+ if (dayHeight >= 240) return 30;
11
+ if (dayHeight >= 160) return 60;
12
+ return 120;
13
+ }
14
+
15
+ export function createTimeseriesHeatmapLayer(
16
+ state: HeatmapState,
17
+ ): CalendarLayer {
18
+ return {
19
+ name: "timeseries-heatmap",
20
+ enabled: true,
21
+ render(lc: LayerContext): void {
22
+ const {
23
+ ctx,
24
+ height,
25
+ scrollTop,
26
+ dayWidth,
27
+ dayHeight,
28
+ leftGutterWidth,
29
+ visibleWeeks,
30
+ allWeeks,
31
+ getDayVisualPosition,
32
+ } = lc;
33
+
34
+ const events = state.events.filter(
35
+ (event) => event.visualStyle === "heatmap",
36
+ );
37
+ if (events.length === 0 || visibleWeeks.length === 0) return;
38
+
39
+ const dayInfoMap = new Map<
40
+ number,
41
+ {
42
+ week: WeekInfo;
43
+ weekIndex: number;
44
+ dayIndex: number;
45
+ row: number;
46
+ col: number;
47
+ dayStartMs: number;
48
+ }
49
+ >();
50
+
51
+ for (const week of visibleWeeks) {
52
+ const weekIndex = allWeeks.indexOf(week);
53
+ if (weekIndex < 0) continue;
54
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
55
+ const day = week.days[dayIndex];
56
+ if (!day) continue;
57
+ const dayStart = new Date(day);
58
+ dayStart.setHours(0, 0, 0, 0);
59
+ const { row, col } = getDayVisualPosition(dayIndex);
60
+ dayInfoMap.set(dayStart.getTime(), {
61
+ week,
62
+ weekIndex,
63
+ dayIndex,
64
+ row,
65
+ col,
66
+ dayStartMs: dayStart.getTime(),
67
+ });
68
+ }
69
+ }
70
+
71
+ if (dayInfoMap.size === 0) return;
72
+
73
+ const bucketMinutes = getBucketMinutes(dayHeight);
74
+ const bucketCounts = new Map<
75
+ string,
76
+ { count: number; color: string; dayKey: number; bucket: number }
77
+ >();
78
+ let maxCount = 0;
79
+
80
+ for (const event of events) {
81
+ const start = event.start;
82
+ const dayStart = new Date(start);
83
+ dayStart.setHours(0, 0, 0, 0);
84
+ const dayKey = dayStart.getTime();
85
+ const dayInfo = dayInfoMap.get(dayKey);
86
+ if (!dayInfo) continue;
87
+
88
+ const minutesIntoDay = (start.getTime() - dayInfo.dayStartMs) / 60000;
89
+ if (minutesIntoDay < 0 || minutesIntoDay >= 1440) continue;
90
+
91
+ const bucket = Math.floor(minutesIntoDay / bucketMinutes);
92
+ const color = event.color || "rgba(255, 255, 255, 0.6)";
93
+ const key = `${dayKey}-${bucket}-${color}`;
94
+ const entry =
95
+ bucketCounts.get(key) ||
96
+ { count: 0, color, dayKey, bucket };
97
+ entry.count += 1;
98
+ bucketCounts.set(key, entry);
99
+ if (entry.count > maxCount) maxCount = entry.count;
100
+ }
101
+
102
+ if (maxCount === 0) return;
103
+
104
+ const bucketHeight =
105
+ (bucketMinutes / 1440) * dayHeight;
106
+ const minHeight = Math.max(2, bucketHeight);
107
+
108
+ for (const entry of bucketCounts.values()) {
109
+ const dayInfo = dayInfoMap.get(entry.dayKey);
110
+ if (!dayInfo) continue;
111
+
112
+ const intensity = Math.log(entry.count + 1) / Math.log(maxCount + 1);
113
+ const alpha = 0.08 + intensity * 0.22;
114
+
115
+ const x = leftGutterWidth + dayInfo.col * dayWidth + 1;
116
+ const y =
117
+ dayInfo.week.yOffset +
118
+ dayInfo.row * dayHeight +
119
+ (entry.bucket * bucketMinutes * dayHeight) / 1440 -
120
+ scrollTop;
121
+
122
+ if (y > height || y + minHeight < 0) continue;
123
+
124
+ ctx.globalAlpha = alpha;
125
+ ctx.fillStyle = entry.color;
126
+ ctx.fillRect(x, y, dayWidth - 2, minHeight);
127
+ }
128
+
129
+ ctx.globalAlpha = 1;
130
+ },
131
+ };
132
+ }
package/src/lib.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./CalendarView.js";
2
2
  export * from "./InMemorySource.js";
3
3
  export * from "./CalendarInternal.js";
4
4
  export * from "./CalendarIntegration.js";
5
+ export * from "./ICal.js";