termcast 1.3.49 → 1.3.51

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.
Files changed (164) hide show
  1. package/dist/apis/environment.d.ts +1 -0
  2. package/dist/apis/environment.d.ts.map +1 -1
  3. package/dist/apis/environment.js +5 -0
  4. package/dist/apis/environment.js.map +1 -1
  5. package/dist/app.d.ts +33 -0
  6. package/dist/app.d.ts.map +1 -0
  7. package/dist/app.js +1125 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/cli.js +80 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/components/detail.d.ts.map +1 -1
  12. package/dist/components/detail.js +20 -17
  13. package/dist/components/detail.js.map +1 -1
  14. package/dist/components/dropdown.d.ts.map +1 -1
  15. package/dist/components/dropdown.js +3 -2
  16. package/dist/components/dropdown.js.map +1 -1
  17. package/dist/components/footer.d.ts +6 -0
  18. package/dist/components/footer.d.ts.map +1 -1
  19. package/dist/components/footer.js +15 -6
  20. package/dist/components/footer.js.map +1 -1
  21. package/dist/components/form/checkbox.d.ts.map +1 -1
  22. package/dist/components/form/checkbox.js +1 -13
  23. package/dist/components/form/checkbox.js.map +1 -1
  24. package/dist/components/form/date-picker.js +2 -2
  25. package/dist/components/form/date-picker.js.map +1 -1
  26. package/dist/components/form/description.js +1 -1
  27. package/dist/components/form/description.js.map +1 -1
  28. package/dist/components/form/dropdown.d.ts.map +1 -1
  29. package/dist/components/form/dropdown.js +19 -3
  30. package/dist/components/form/dropdown.js.map +1 -1
  31. package/dist/components/form/file-picker.d.ts.map +1 -1
  32. package/dist/components/form/file-picker.js +22 -4
  33. package/dist/components/form/file-picker.js.map +1 -1
  34. package/dist/components/form/index.d.ts +3 -1
  35. package/dist/components/form/index.d.ts.map +1 -1
  36. package/dist/components/form/index.js +6 -4
  37. package/dist/components/form/index.js.map +1 -1
  38. package/dist/components/form/password-field.js +3 -3
  39. package/dist/components/form/password-field.js.map +1 -1
  40. package/dist/components/form/text-area.d.ts.map +1 -1
  41. package/dist/components/form/text-area.js +29 -6
  42. package/dist/components/form/text-area.js.map +1 -1
  43. package/dist/components/form/text-field.js +3 -3
  44. package/dist/components/form/text-field.js.map +1 -1
  45. package/dist/components/heatmap.d.ts +80 -0
  46. package/dist/components/heatmap.d.ts.map +1 -0
  47. package/dist/components/heatmap.js +405 -0
  48. package/dist/components/heatmap.js.map +1 -0
  49. package/dist/components/list.d.ts +2 -0
  50. package/dist/components/list.d.ts.map +1 -1
  51. package/dist/components/list.js +80 -52
  52. package/dist/components/list.js.map +1 -1
  53. package/dist/components/markdown.d.ts +7 -0
  54. package/dist/components/markdown.d.ts.map +1 -0
  55. package/dist/components/markdown.js +19 -0
  56. package/dist/components/markdown.js.map +1 -0
  57. package/dist/components/metadata.d.ts.map +1 -1
  58. package/dist/components/metadata.js +4 -1
  59. package/dist/components/metadata.js.map +1 -1
  60. package/dist/components/progress-bar.d.ts +37 -0
  61. package/dist/components/progress-bar.d.ts.map +1 -0
  62. package/dist/components/progress-bar.js +34 -0
  63. package/dist/components/progress-bar.js.map +1 -0
  64. package/dist/components/table.d.ts +3 -2
  65. package/dist/components/table.d.ts.map +1 -1
  66. package/dist/components/table.js +78 -63
  67. package/dist/components/table.js.map +1 -1
  68. package/dist/diagram-parser.d.ts +17 -3
  69. package/dist/diagram-parser.d.ts.map +1 -1
  70. package/dist/diagram-parser.js +17 -3
  71. package/dist/diagram-parser.js.map +1 -1
  72. package/dist/examples/list-slot.d.ts +2 -0
  73. package/dist/examples/list-slot.d.ts.map +1 -0
  74. package/dist/examples/list-slot.js +14 -0
  75. package/dist/examples/list-slot.js.map +1 -0
  76. package/dist/examples/list-with-dropdown.js +2 -4
  77. package/dist/examples/list-with-dropdown.js.map +1 -1
  78. package/dist/examples/simple-heatmap.d.ts +2 -0
  79. package/dist/examples/simple-heatmap.d.ts.map +1 -0
  80. package/dist/examples/simple-heatmap.js +37 -0
  81. package/dist/examples/simple-heatmap.js.map +1 -0
  82. package/dist/examples/simple-progress-bar.d.ts +2 -0
  83. package/dist/examples/simple-progress-bar.d.ts.map +1 -0
  84. package/dist/examples/simple-progress-bar.js +36 -0
  85. package/dist/examples/simple-progress-bar.js.map +1 -0
  86. package/dist/index.d.ts +6 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +6 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/internal/date-picker-widget.d.ts.map +1 -1
  91. package/dist/internal/date-picker-widget.js +5 -4
  92. package/dist/internal/date-picker-widget.js.map +1 -1
  93. package/dist/internal/navigation.d.ts.map +1 -1
  94. package/dist/internal/navigation.js +7 -2
  95. package/dist/internal/navigation.js.map +1 -1
  96. package/dist/internal/providers.d.ts.map +1 -1
  97. package/dist/internal/providers.js +42 -4
  98. package/dist/internal/providers.js.map +1 -1
  99. package/dist/logger.js +6 -1
  100. package/dist/logger.js.map +1 -1
  101. package/dist/state.d.ts +2 -0
  102. package/dist/state.d.ts.map +1 -1
  103. package/dist/state.js +31 -2
  104. package/dist/state.js.map +1 -1
  105. package/dist/theme.d.ts +1 -0
  106. package/dist/theme.d.ts.map +1 -1
  107. package/dist/theme.js +23 -1
  108. package/dist/theme.js.map +1 -1
  109. package/dist/utils.d.ts.map +1 -1
  110. package/dist/utils.js +6 -1
  111. package/dist/utils.js.map +1 -1
  112. package/package.json +4 -4
  113. package/src/apis/environment.tsx +6 -0
  114. package/src/app.tsx +1487 -0
  115. package/src/assets/default-app-icon.png +0 -0
  116. package/src/cli.tsx +105 -0
  117. package/src/components/detail.tsx +32 -22
  118. package/src/components/dropdown.tsx +3 -2
  119. package/src/components/footer.tsx +37 -7
  120. package/src/components/form/checkbox.tsx +2 -17
  121. package/src/components/form/date-picker.tsx +2 -2
  122. package/src/components/form/description.tsx +1 -1
  123. package/src/components/form/dropdown.tsx +22 -3
  124. package/src/components/form/file-picker.tsx +33 -10
  125. package/src/components/form/index.tsx +10 -6
  126. package/src/components/form/password-field.tsx +3 -3
  127. package/src/components/form/text-area.tsx +31 -6
  128. package/src/components/form/text-field.tsx +3 -3
  129. package/src/components/heatmap.tsx +584 -0
  130. package/src/components/list.tsx +135 -72
  131. package/src/components/markdown.tsx +30 -0
  132. package/src/components/metadata.tsx +9 -2
  133. package/src/components/progress-bar.tsx +112 -0
  134. package/src/components/table.tsx +88 -71
  135. package/src/diagram-parser.tsx +17 -3
  136. package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
  137. package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
  138. package/src/examples/form-basic.vitest.tsx +117 -16
  139. package/src/examples/graph-bar-chart.vitest.tsx +2 -2
  140. package/src/examples/graph-row.vitest.tsx +10 -10
  141. package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
  142. package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
  143. package/src/examples/list-dropdown-default.vitest.tsx +78 -58
  144. package/src/examples/list-slot.tsx +38 -0
  145. package/src/examples/list-with-detail.vitest.tsx +8 -8
  146. package/src/examples/list-with-dropdown.tsx +2 -2
  147. package/src/examples/list-with-dropdown.vitest.tsx +16 -16
  148. package/src/examples/list-with-sections.vitest.tsx +45 -32
  149. package/src/examples/simple-detail-table.vitest.tsx +2 -2
  150. package/src/examples/simple-file-picker.vitest.tsx +1 -1
  151. package/src/examples/simple-grid.vitest.tsx +27 -53
  152. package/src/examples/simple-heatmap.tsx +63 -0
  153. package/src/examples/simple-heatmap.vitest.tsx +88 -0
  154. package/src/examples/simple-progress-bar.tsx +82 -0
  155. package/src/examples/simple-progress-bar.vitest.tsx +72 -0
  156. package/src/examples/table-edge-cases.vitest.tsx +1 -1
  157. package/src/index.tsx +19 -0
  158. package/src/internal/date-picker-widget.tsx +23 -12
  159. package/src/internal/navigation.tsx +7 -2
  160. package/src/internal/providers.tsx +48 -3
  161. package/src/logger.tsx +6 -1
  162. package/src/state.tsx +38 -2
  163. package/src/theme.tsx +26 -2
  164. package/src/utils.tsx +6 -1
@@ -1,12 +1,14 @@
1
1
  import React, { useRef, useCallback } from 'react'
2
2
  import { BoxRenderable, TextareaRenderable } from '@opentui/core'
3
+ import { useKeyboard } from '@opentui/react'
3
4
  import { useFormContext } from 'react-hook-form'
4
5
  import { useFocusContext, useFormFieldDescendant } from './index'
5
6
  import { FormItemProps, FormItemRef } from './types'
6
7
  import { useTheme } from 'termcast/src/theme'
7
8
  import { WithLeftBorder, TitleIndicator } from './with-left-border'
8
- import { useFormNavigation } from './use-form-navigation'
9
+ import { useFormNavigation, useFormNavigationHelpers } from './use-form-navigation'
9
10
  import { createTextareaFormRef } from './form-ref'
11
+ import { useIsInFocus } from 'termcast/src/internal/focus-context'
10
12
  import { LoadingText } from 'termcast/src/components/loading-text'
11
13
 
12
14
  export interface TextAreaProps extends FormItemProps<string> {
@@ -21,7 +23,9 @@ export const TextArea = (props: TextAreaProps): any => {
21
23
  const { register, formState } = useFormContext()
22
24
  const focusContext = useFocusContext()
23
25
  const { focusedField, setFocusedField } = focusContext
26
+ const isInFocus = useIsInFocus()
24
27
  const isFocused = focusedField === props.id
28
+ const { navigateToPrevious, navigateToNext } = useFormNavigationHelpers(props.id)
25
29
 
26
30
  const elementRef = useRef<BoxRenderable>(null)
27
31
  const textareaRef = useRef<TextareaRenderable>(null)
@@ -32,8 +36,29 @@ export const TextArea = (props: TextAreaProps): any => {
32
36
  elementRef: elementRef.current,
33
37
  })
34
38
 
35
- // TODO in textarea arrows should probably go to lines instead of other forms
36
- useFormNavigation(props.id)
39
+ useFormNavigation(props.id, { handleArrows: false })
40
+
41
+ useKeyboard((evt) => {
42
+ if (!isFocused || !isInFocus) return
43
+ if (evt.name !== 'up' && evt.name !== 'down') return
44
+
45
+ const textarea = textareaRef.current
46
+ if (!textarea) return
47
+
48
+ const cursorLine = textarea.logicalCursor.row
49
+ const lastLine = textarea.lineCount - 1
50
+
51
+ if (evt.name === 'up' && cursorLine <= 0) {
52
+ navigateToPrevious()
53
+ evt.stopPropagation()
54
+ return
55
+ }
56
+
57
+ if (evt.name === 'down' && cursorLine >= lastLine) {
58
+ navigateToNext()
59
+ evt.stopPropagation()
60
+ }
61
+ })
37
62
 
38
63
  // Get register props
39
64
  const registration = register(props.id)
@@ -60,12 +85,12 @@ export const TextArea = (props: TextAreaProps): any => {
60
85
  const fieldError = formState.errors[props.id]
61
86
 
62
87
  return (
63
- <box ref={elementRef} flexDirection="column">
88
+ <box ref={elementRef} flexDirection="column" onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
64
89
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
65
90
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
66
91
  <box
67
92
  onMouseDown={() => {
68
- setFocusedField(props.id)
93
+ setFocusedField(props.id, { skipScroll: true })
69
94
  }}
70
95
  >
71
96
  <LoadingText
@@ -86,7 +111,7 @@ export const TextArea = (props: TextAreaProps): any => {
86
111
  placeholder={props.placeholder}
87
112
  focused={isFocused}
88
113
  onMouseDown={() => {
89
- setFocusedField(props.id)
114
+ setFocusedField(props.id, { skipScroll: true })
90
115
  }}
91
116
  />
92
117
  </box>
@@ -61,12 +61,12 @@ export const TextField = (props: TextFieldProps): any => {
61
61
  const fieldError = formState.errors[props.id]
62
62
 
63
63
  return (
64
- <box ref={elementRef} flexDirection="column">
64
+ <box ref={elementRef} flexDirection="column" onMouseDown={() => { setFocusedField(props.id, { skipScroll: true }) }}>
65
65
  <WithLeftBorder isFocused={isFocused} paddingBottom={1}>
66
66
  <TitleIndicator isFocused={isFocused} isLoading={focusContext.isLoading}>
67
67
  <box
68
68
  onMouseDown={() => {
69
- setFocusedField(props.id)
69
+ setFocusedField(props.id, { skipScroll: true })
70
70
  }}
71
71
  >
72
72
  <LoadingText
@@ -90,7 +90,7 @@ export const TextField = (props: TextFieldProps): any => {
90
90
  placeholder={props.placeholder}
91
91
  focused={isFocused}
92
92
  onMouseDown={() => {
93
- setFocusedField(props.id)
93
+ setFocusedField(props.id, { skipScroll: true })
94
94
  }}
95
95
  />
96
96
  {(fieldError || props.error || props.info) && <box height={1} />}
@@ -0,0 +1,584 @@
1
+ /**
2
+ * CalendarHeatmap component for rendering GitHub-style contribution/journal grids.
3
+ *
4
+ * Uses a custom opentui Renderable for performance: cells are drawn directly
5
+ * to OptimizedBuffer with month grouping, day labels, and legend support.
6
+ *
7
+ * Cell intensity is encoded by mixing a primary and secondary color.
8
+ */
9
+
10
+ import React from 'react'
11
+ import { Renderable, RGBA } from '@opentui/core'
12
+ import type { OptimizedBuffer, RenderContext, RenderableOptions } from '@opentui/core'
13
+ import { extend } from '@opentui/react'
14
+ import { Color, resolveColor } from 'termcast/src/colors'
15
+ import { useTheme } from 'termcast/src/theme'
16
+
17
+ const GRID_ROWS = 7
18
+ const DEFAULT_CELL_CHAR = '◼'
19
+ const CELL_STRIDE = 2
20
+ const MONTH_GAP = 1
21
+
22
+ const MONDAY_ROW = 1
23
+ const WEDNESDAY_ROW = 3
24
+ const FRIDAY_ROW = 5
25
+
26
+ const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
27
+
28
+ interface NormalizedPoint {
29
+ date: Date
30
+ value: number
31
+ }
32
+
33
+ interface PreparedWeek {
34
+ weekStart: Date
35
+ monthKey: string
36
+ monthLabel: string
37
+ values: [number, number, number, number, number, number, number]
38
+ }
39
+
40
+ interface MonthSection {
41
+ key: string
42
+ label: string
43
+ weeks: PreparedWeek[]
44
+ }
45
+
46
+ export interface CalendarHeatmapData {
47
+ date: Date | string
48
+ value: number
49
+ }
50
+
51
+ export type CalendarHeatmapCellChar = '◼' | '■' | '█' | '▪'
52
+
53
+ export interface CalendarHeatmapGridOptions extends RenderableOptions {
54
+ data?: CalendarHeatmapData[]
55
+ cellChar?: CalendarHeatmapCellChar
56
+ cellColor?: string
57
+ backgroundColor?: string
58
+ emptyColor?: string
59
+ labelColor?: string
60
+ showMonthLabels?: boolean
61
+ showDayLabels?: boolean
62
+ showLegend?: boolean
63
+ }
64
+
65
+ function normalizeDate(input: Date | string): Date | null {
66
+ if (input instanceof Date) {
67
+ if (Number.isNaN(input.getTime())) {
68
+ return null
69
+ }
70
+ return new Date(input.getFullYear(), input.getMonth(), input.getDate())
71
+ }
72
+
73
+ if (typeof input !== 'string') {
74
+ return null
75
+ }
76
+
77
+ const dateOnlyMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/)
78
+ if (dateOnlyMatch) {
79
+ const year = Number(dateOnlyMatch[1])
80
+ const month = Number(dateOnlyMatch[2]) - 1
81
+ const day = Number(dateOnlyMatch[3])
82
+ return new Date(year, month, day)
83
+ }
84
+
85
+ const parsed = new Date(input)
86
+ if (Number.isNaN(parsed.getTime())) {
87
+ return null
88
+ }
89
+
90
+ return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
91
+ }
92
+
93
+ function normalizePoint(point: CalendarHeatmapData): NormalizedPoint | null {
94
+ const normalizedDate = normalizeDate(point.date)
95
+ if (!normalizedDate) {
96
+ return null
97
+ }
98
+
99
+ if (!Number.isFinite(point.value)) {
100
+ return null
101
+ }
102
+
103
+ const normalizedValue = Math.max(0, point.value)
104
+ return {
105
+ date: normalizedDate,
106
+ value: normalizedValue,
107
+ }
108
+ }
109
+
110
+ function toDateKey(date: Date): string {
111
+ const year = String(date.getFullYear())
112
+ const month = String(date.getMonth() + 1).padStart(2, '0')
113
+ const day = String(date.getDate()).padStart(2, '0')
114
+ return `${year}-${month}-${day}`
115
+ }
116
+
117
+ function getWeekStart(date: Date): Date {
118
+ const day = date.getDay()
119
+ const weekStart = new Date(date)
120
+ weekStart.setDate(date.getDate() - day)
121
+ return new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate())
122
+ }
123
+
124
+ function createEmptyWeekValues(): [number, number, number, number, number, number, number] {
125
+ return [0, 0, 0, 0, 0, 0, 0]
126
+ }
127
+
128
+ function getSectionGridWidth(section: MonthSection): number {
129
+ return section.weeks.length * CELL_STRIDE
130
+ }
131
+
132
+ function getSectionsGridWidth(sections: MonthSection[]): number {
133
+ if (sections.length === 0) {
134
+ return 0
135
+ }
136
+
137
+ return sections.reduce((total, section, index) => {
138
+ const sectionWidth = getSectionGridWidth(section)
139
+ if (index === 0) {
140
+ return sectionWidth
141
+ }
142
+ return total + MONTH_GAP + sectionWidth
143
+ }, 0)
144
+ }
145
+
146
+ export class CalendarHeatmapRenderable extends Renderable {
147
+ private _data: CalendarHeatmapData[] = []
148
+ private _cellChar: CalendarHeatmapCellChar = DEFAULT_CELL_CHAR
149
+ private _cellColor = '#00AA55'
150
+ private _backgroundColor = '#000000'
151
+ private _emptyColor = '#2B2B2B'
152
+ private _labelColor = '#888888'
153
+ private _showMonthLabels = true
154
+ private _showDayLabels = true
155
+ private _showLegend = true
156
+
157
+ private _sections: MonthSection[] = []
158
+ private _maxValue = 0
159
+
160
+ constructor(ctx: RenderContext, options: CalendarHeatmapGridOptions) {
161
+ super(ctx, options)
162
+
163
+ if (options.data) {
164
+ this._data = options.data
165
+ }
166
+ if (options.cellChar) {
167
+ this._cellChar = options.cellChar
168
+ }
169
+ if (options.cellColor) {
170
+ this._cellColor = options.cellColor
171
+ }
172
+ if (options.backgroundColor) {
173
+ this._backgroundColor = options.backgroundColor
174
+ }
175
+ if (options.emptyColor) {
176
+ this._emptyColor = options.emptyColor
177
+ }
178
+ if (options.labelColor) {
179
+ this._labelColor = options.labelColor
180
+ }
181
+ if (options.showMonthLabels !== undefined) {
182
+ this._showMonthLabels = options.showMonthLabels
183
+ }
184
+ if (options.showDayLabels !== undefined) {
185
+ this._showDayLabels = options.showDayLabels
186
+ }
187
+ if (options.showLegend !== undefined) {
188
+ this._showLegend = options.showLegend
189
+ }
190
+
191
+ this.recomputeData()
192
+ }
193
+
194
+ set data(value: CalendarHeatmapData[]) {
195
+ this._data = value
196
+ this.recomputeData()
197
+ this.requestRender()
198
+ }
199
+
200
+ set cellColor(value: string) {
201
+ this._cellColor = value
202
+ this.requestRender()
203
+ }
204
+
205
+ set cellChar(value: CalendarHeatmapCellChar) {
206
+ this._cellChar = value
207
+ this.requestRender()
208
+ }
209
+
210
+ set backgroundColor(value: string) {
211
+ this._backgroundColor = value
212
+ this.requestRender()
213
+ }
214
+
215
+ set emptyColor(value: string) {
216
+ this._emptyColor = value
217
+ this.requestRender()
218
+ }
219
+
220
+ set labelColor(value: string) {
221
+ this._labelColor = value
222
+ this.requestRender()
223
+ }
224
+
225
+ set showMonthLabels(value: boolean) {
226
+ this._showMonthLabels = value
227
+ this.requestRender()
228
+ }
229
+
230
+ set showDayLabels(value: boolean) {
231
+ this._showDayLabels = value
232
+ this.requestRender()
233
+ }
234
+
235
+ set showLegend(value: boolean) {
236
+ this._showLegend = value
237
+ this.requestRender()
238
+ }
239
+
240
+ private recomputeData(): void {
241
+ const normalizedPoints: NormalizedPoint[] = this._data.reduce<NormalizedPoint[]>((result, point) => {
242
+ const normalized = normalizePoint(point)
243
+ if (!normalized) {
244
+ return result
245
+ }
246
+ result.push(normalized)
247
+ return result
248
+ }, [])
249
+
250
+ normalizedPoints.sort((a, b) => {
251
+ return a.date.getTime() - b.date.getTime()
252
+ })
253
+
254
+ this._maxValue = normalizedPoints.reduce((maxValue, point) => {
255
+ return Math.max(maxValue, point.value)
256
+ }, 0)
257
+
258
+ const valueByDay = new Map<string, number>()
259
+ const dateByDay = new Map<string, Date>()
260
+
261
+ normalizedPoints.forEach((point) => {
262
+ const dayKey = toDateKey(point.date)
263
+ const currentValue = valueByDay.get(dayKey) || 0
264
+ valueByDay.set(dayKey, currentValue + point.value)
265
+ dateByDay.set(dayKey, point.date)
266
+ })
267
+
268
+ const weekMap = new Map<string, PreparedWeek>()
269
+
270
+ const dayEntries = Array.from(valueByDay.entries())
271
+ .reduce<Array<{ date: Date; value: number }>>((result, [dayKey, value]) => {
272
+ const date = dateByDay.get(dayKey)
273
+ if (!date) {
274
+ return result
275
+ }
276
+ result.push({ date, value })
277
+ return result
278
+ }, [])
279
+ .sort((a, b) => {
280
+ return a.date.getTime() - b.date.getTime()
281
+ })
282
+
283
+ dayEntries.forEach((entry) => {
284
+ const date = entry.date
285
+ const weekStart = getWeekStart(date)
286
+ const weekKey = toDateKey(weekStart)
287
+ const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}`
288
+ const monthLabel = MONTH_LABELS[weekStart.getMonth()] || ''
289
+
290
+ const existingWeek = weekMap.get(weekKey)
291
+ if (existingWeek) {
292
+ const dayIndex = date.getDay()
293
+ existingWeek.values[dayIndex] = existingWeek.values[dayIndex] + entry.value
294
+ return
295
+ }
296
+
297
+ const values = createEmptyWeekValues()
298
+ values[date.getDay()] = entry.value
299
+ weekMap.set(weekKey, {
300
+ weekStart,
301
+ monthKey,
302
+ monthLabel,
303
+ values,
304
+ })
305
+ })
306
+
307
+ const sortedWeeks = Array.from(weekMap.values()).sort((a, b) => {
308
+ return a.weekStart.getTime() - b.weekStart.getTime()
309
+ })
310
+
311
+ const sections: MonthSection[] = []
312
+ sortedWeeks.forEach((week) => {
313
+ const lastSection = sections[sections.length - 1]
314
+ if (!lastSection || lastSection.key !== week.monthKey) {
315
+ sections.push({
316
+ key: week.monthKey,
317
+ label: week.monthLabel,
318
+ weeks: [week],
319
+ })
320
+ return
321
+ }
322
+
323
+ lastSection.weeks.push(week)
324
+ })
325
+
326
+ this._sections = sections
327
+ }
328
+
329
+ private resolveVisibleSections(availableGridWidth: number): MonthSection[] {
330
+ if (availableGridWidth <= 0 || this._sections.length === 0) {
331
+ return []
332
+ }
333
+
334
+ const visibleFromEnd: MonthSection[] = []
335
+ let usedWidth = 0
336
+
337
+ for (let i = this._sections.length - 1; i >= 0; i--) {
338
+ const section = this._sections[i]!
339
+ const sectionWidth = getSectionGridWidth(section)
340
+ const gapWidth = visibleFromEnd.length > 0 ? MONTH_GAP : 0
341
+ const nextWidth = usedWidth + gapWidth + sectionWidth
342
+ if (nextWidth > availableGridWidth) {
343
+ break
344
+ }
345
+ visibleFromEnd.unshift(section)
346
+ usedWidth = nextWidth
347
+ }
348
+
349
+ if (visibleFromEnd.length > 0) {
350
+ return visibleFromEnd
351
+ }
352
+
353
+ const latestSection = this._sections[this._sections.length - 1]
354
+ if (!latestSection) {
355
+ return []
356
+ }
357
+
358
+ const maxWeeks = Math.max(1, Math.floor(availableGridWidth / CELL_STRIDE))
359
+ const weeks = latestSection.weeks.slice(Math.max(0, latestSection.weeks.length - maxWeeks))
360
+
361
+ return [{
362
+ key: latestSection.key,
363
+ label: latestSection.label,
364
+ weeks,
365
+ }]
366
+ }
367
+
368
+ private valueToLevel(value: number): 0 | 1 | 2 | 3 | 4 {
369
+ if (value <= 0 || this._maxValue <= 0) {
370
+ return 0
371
+ }
372
+
373
+ const normalized = value / this._maxValue
374
+ if (normalized <= 0.25) {
375
+ return 1
376
+ }
377
+ if (normalized <= 0.5) {
378
+ return 2
379
+ }
380
+ if (normalized <= 0.75) {
381
+ return 3
382
+ }
383
+ return 4
384
+ }
385
+
386
+ private mixColors(a: RGBA, b: RGBA, ratio: number): RGBA {
387
+ const clampedRatio = Math.max(0, Math.min(1, ratio))
388
+ return RGBA.fromValues(
389
+ a.r + (b.r - a.r) * clampedRatio,
390
+ a.g + (b.g - a.g) * clampedRatio,
391
+ a.b + (b.b - a.b) * clampedRatio,
392
+ 1,
393
+ )
394
+ }
395
+
396
+ private buildLevelColors(): [RGBA, RGBA, RGBA, RGBA, RGBA] {
397
+ const primary = RGBA.fromHex(this._cellColor)
398
+ const secondary = RGBA.fromHex(this._emptyColor)
399
+
400
+ return [
401
+ secondary,
402
+ this.mixColors(secondary, primary, 0.25),
403
+ this.mixColors(secondary, primary, 0.5),
404
+ this.mixColors(secondary, primary, 0.75),
405
+ primary,
406
+ ]
407
+ }
408
+
409
+ private clearArea(buffer: OptimizedBuffer): void {
410
+ const bg = RGBA.fromHex(this._backgroundColor)
411
+ for (let row = 0; row < this.height; row++) {
412
+ for (let col = 0; col < this.width; col++) {
413
+ buffer.setCell(this.x + col, this.y + row, ' ', bg, bg)
414
+ }
415
+ }
416
+ }
417
+
418
+ protected renderSelf(buffer: OptimizedBuffer): void {
419
+ this.clearArea(buffer)
420
+
421
+ if (this.width <= 0 || this.height <= 0) {
422
+ return
423
+ }
424
+
425
+ if (this._sections.length === 0) {
426
+ return
427
+ }
428
+
429
+ let showMonthLabels = this._showMonthLabels
430
+ let showLegend = this._showLegend
431
+ const showDayLabels = this._showDayLabels
432
+
433
+ let requiredHeight = GRID_ROWS + (showMonthLabels ? 1 : 0) + (showLegend ? 1 : 0)
434
+ if (requiredHeight > this.height && showLegend) {
435
+ showLegend = false
436
+ requiredHeight = GRID_ROWS + (showMonthLabels ? 1 : 0)
437
+ }
438
+ if (requiredHeight > this.height && showMonthLabels) {
439
+ showMonthLabels = false
440
+ requiredHeight = GRID_ROWS
441
+ }
442
+ if (requiredHeight > this.height) {
443
+ return
444
+ }
445
+
446
+ const rightDayLabelsWidth = showDayLabels ? 5 : 0
447
+ const availableGridWidth = this.width - rightDayLabelsWidth
448
+ if (availableGridWidth <= 0) {
449
+ return
450
+ }
451
+
452
+ const visibleSections = this.resolveVisibleSections(availableGridWidth)
453
+ if (visibleSections.length === 0) {
454
+ return
455
+ }
456
+
457
+ const gridWidth = getSectionsGridWidth(visibleSections)
458
+ const gridStartX = this.x
459
+ const monthLabelsRow = this.y
460
+ const gridStartY = this.y + (showMonthLabels ? 1 : 0)
461
+
462
+ const levelColors = this.buildLevelColors()
463
+ const cellBackground = RGBA.fromHex(this._backgroundColor)
464
+ const labelColor = RGBA.fromHex(this._labelColor)
465
+
466
+ let cursorX = gridStartX
467
+ visibleSections.forEach((section, sectionIndex) => {
468
+ const sectionStartX = cursorX
469
+
470
+ section.weeks.forEach((week) => {
471
+ week.values.forEach((value, rowIndex) => {
472
+ const level = this.valueToLevel(value)
473
+ const color = levelColors[level]
474
+ const y = gridStartY + rowIndex
475
+ buffer.setCell(cursorX, y, this._cellChar, color, cellBackground)
476
+ })
477
+ cursorX += CELL_STRIDE
478
+ })
479
+
480
+ if (showMonthLabels) {
481
+ const sectionWidth = getSectionGridWidth(section)
482
+ const label = section.label.slice(0, sectionWidth)
483
+ buffer.drawText(label, sectionStartX, monthLabelsRow, labelColor)
484
+ }
485
+
486
+ if (sectionIndex < visibleSections.length - 1) {
487
+ cursorX += MONTH_GAP
488
+ }
489
+ })
490
+
491
+ if (showDayLabels) {
492
+ const labelsX = gridStartX + gridWidth + 1
493
+ buffer.drawText('Mon', labelsX, gridStartY + MONDAY_ROW, labelColor)
494
+ buffer.drawText('Wed', labelsX, gridStartY + WEDNESDAY_ROW, labelColor)
495
+ buffer.drawText('Fri', labelsX, gridStartY + FRIDAY_ROW, labelColor)
496
+ }
497
+
498
+ if (!showLegend) {
499
+ return
500
+ }
501
+
502
+ const legendY = gridStartY + GRID_ROWS
503
+ const legendPrefix = 'Less '
504
+ const legendSuffix = ' More'
505
+ const legendSquaresWidth = 9
506
+ const legendWidth = legendPrefix.length + legendSquaresWidth + legendSuffix.length
507
+ const legendX = Math.max(gridStartX, gridStartX + gridWidth - legendWidth)
508
+
509
+ buffer.drawText(legendPrefix, legendX, legendY, labelColor)
510
+
511
+ let legendCursorX = legendX + legendPrefix.length
512
+ for (let level = 0; level <= 4; level++) {
513
+ const color = levelColors[level]
514
+ buffer.setCell(legendCursorX, legendY, this._cellChar, color, cellBackground)
515
+ legendCursorX += 1
516
+ if (level < 4) {
517
+ legendCursorX += 1
518
+ }
519
+ }
520
+
521
+ buffer.drawText(legendSuffix, legendCursorX, legendY, labelColor)
522
+ }
523
+ }
524
+
525
+ extend({ 'heatmap-grid': CalendarHeatmapRenderable })
526
+
527
+ declare module '@opentui/react' {
528
+ interface OpenTUIComponents {
529
+ 'heatmap-grid': typeof CalendarHeatmapRenderable
530
+ }
531
+ }
532
+
533
+ export interface CalendarHeatmapProps {
534
+ data: CalendarHeatmapData[]
535
+ cellChar?: CalendarHeatmapCellChar
536
+ color?: Color.ColorLike
537
+ emptyColor?: Color.ColorLike
538
+ showMonthLabels?: boolean
539
+ showDayLabels?: boolean
540
+ showLegend?: boolean
541
+ }
542
+
543
+ interface CalendarHeatmapType {
544
+ (props: CalendarHeatmapProps): any
545
+ }
546
+
547
+ const CalendarHeatmap: CalendarHeatmapType = (props) => {
548
+ const theme = useTheme()
549
+ const {
550
+ data,
551
+ cellChar = DEFAULT_CELL_CHAR,
552
+ color,
553
+ emptyColor,
554
+ showMonthLabels = true,
555
+ showDayLabels = true,
556
+ showLegend = true,
557
+ } = props
558
+
559
+ const resolvedColor = resolveColor(color) || theme.primary
560
+ const resolvedEmptyColor = resolveColor(emptyColor) || theme.background
561
+ const computedHeight = GRID_ROWS + (showMonthLabels ? 1 : 0) + (showLegend ? 1 : 0)
562
+
563
+ return (
564
+ <heatmap-grid
565
+ width="100%"
566
+ height={computedHeight}
567
+ data={data}
568
+ cellChar={cellChar}
569
+ cellColor={resolvedColor}
570
+ backgroundColor={theme.background}
571
+ emptyColor={resolvedEmptyColor}
572
+ labelColor={theme.textMuted}
573
+ showMonthLabels={showMonthLabels}
574
+ showDayLabels={showDayLabels}
575
+ showLegend={showLegend}
576
+ />
577
+ )
578
+ }
579
+
580
+ export type HeatmapData = CalendarHeatmapData
581
+ export type HeatmapCellChar = CalendarHeatmapCellChar
582
+ export type HeatmapProps = CalendarHeatmapProps
583
+
584
+ export { CalendarHeatmap, CalendarHeatmap as Heatmap }