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.
- package/dist/apis/environment.d.ts +1 -0
- package/dist/apis/environment.d.ts.map +1 -1
- package/dist/apis/environment.js +5 -0
- package/dist/apis/environment.js.map +1 -1
- package/dist/app.d.ts +33 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +1125 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.js +80 -0
- package/dist/cli.js.map +1 -1
- package/dist/components/detail.d.ts.map +1 -1
- package/dist/components/detail.js +20 -17
- package/dist/components/detail.js.map +1 -1
- package/dist/components/dropdown.d.ts.map +1 -1
- package/dist/components/dropdown.js +3 -2
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/footer.d.ts +6 -0
- package/dist/components/footer.d.ts.map +1 -1
- package/dist/components/footer.js +15 -6
- package/dist/components/footer.js.map +1 -1
- package/dist/components/form/checkbox.d.ts.map +1 -1
- package/dist/components/form/checkbox.js +1 -13
- package/dist/components/form/checkbox.js.map +1 -1
- package/dist/components/form/date-picker.js +2 -2
- package/dist/components/form/date-picker.js.map +1 -1
- package/dist/components/form/description.js +1 -1
- package/dist/components/form/description.js.map +1 -1
- package/dist/components/form/dropdown.d.ts.map +1 -1
- package/dist/components/form/dropdown.js +19 -3
- package/dist/components/form/dropdown.js.map +1 -1
- package/dist/components/form/file-picker.d.ts.map +1 -1
- package/dist/components/form/file-picker.js +22 -4
- package/dist/components/form/file-picker.js.map +1 -1
- package/dist/components/form/index.d.ts +3 -1
- package/dist/components/form/index.d.ts.map +1 -1
- package/dist/components/form/index.js +6 -4
- package/dist/components/form/index.js.map +1 -1
- package/dist/components/form/password-field.js +3 -3
- package/dist/components/form/password-field.js.map +1 -1
- package/dist/components/form/text-area.d.ts.map +1 -1
- package/dist/components/form/text-area.js +29 -6
- package/dist/components/form/text-area.js.map +1 -1
- package/dist/components/form/text-field.js +3 -3
- package/dist/components/form/text-field.js.map +1 -1
- package/dist/components/heatmap.d.ts +80 -0
- package/dist/components/heatmap.d.ts.map +1 -0
- package/dist/components/heatmap.js +405 -0
- package/dist/components/heatmap.js.map +1 -0
- package/dist/components/list.d.ts +2 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +80 -52
- package/dist/components/list.js.map +1 -1
- package/dist/components/markdown.d.ts +7 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +19 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/metadata.d.ts.map +1 -1
- package/dist/components/metadata.js +4 -1
- package/dist/components/metadata.js.map +1 -1
- package/dist/components/progress-bar.d.ts +37 -0
- package/dist/components/progress-bar.d.ts.map +1 -0
- package/dist/components/progress-bar.js +34 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/table.d.ts +3 -2
- package/dist/components/table.d.ts.map +1 -1
- package/dist/components/table.js +78 -63
- package/dist/components/table.js.map +1 -1
- package/dist/diagram-parser.d.ts +17 -3
- package/dist/diagram-parser.d.ts.map +1 -1
- package/dist/diagram-parser.js +17 -3
- package/dist/diagram-parser.js.map +1 -1
- package/dist/examples/list-slot.d.ts +2 -0
- package/dist/examples/list-slot.d.ts.map +1 -0
- package/dist/examples/list-slot.js +14 -0
- package/dist/examples/list-slot.js.map +1 -0
- package/dist/examples/list-with-dropdown.js +2 -4
- package/dist/examples/list-with-dropdown.js.map +1 -1
- package/dist/examples/simple-heatmap.d.ts +2 -0
- package/dist/examples/simple-heatmap.d.ts.map +1 -0
- package/dist/examples/simple-heatmap.js +37 -0
- package/dist/examples/simple-heatmap.js.map +1 -0
- package/dist/examples/simple-progress-bar.d.ts +2 -0
- package/dist/examples/simple-progress-bar.d.ts.map +1 -0
- package/dist/examples/simple-progress-bar.js +36 -0
- package/dist/examples/simple-progress-bar.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/date-picker-widget.d.ts.map +1 -1
- package/dist/internal/date-picker-widget.js +5 -4
- package/dist/internal/date-picker-widget.js.map +1 -1
- package/dist/internal/navigation.d.ts.map +1 -1
- package/dist/internal/navigation.js +7 -2
- package/dist/internal/navigation.js.map +1 -1
- package/dist/internal/providers.d.ts.map +1 -1
- package/dist/internal/providers.js +42 -4
- package/dist/internal/providers.js.map +1 -1
- package/dist/logger.js +6 -1
- package/dist/logger.js.map +1 -1
- package/dist/state.d.ts +2 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +31 -2
- package/dist/state.js.map +1 -1
- package/dist/theme.d.ts +1 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +23 -1
- package/dist/theme.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +6 -1
- package/dist/utils.js.map +1 -1
- package/package.json +4 -4
- package/src/apis/environment.tsx +6 -0
- package/src/app.tsx +1487 -0
- package/src/assets/default-app-icon.png +0 -0
- package/src/cli.tsx +105 -0
- package/src/components/detail.tsx +32 -22
- package/src/components/dropdown.tsx +3 -2
- package/src/components/footer.tsx +37 -7
- package/src/components/form/checkbox.tsx +2 -17
- package/src/components/form/date-picker.tsx +2 -2
- package/src/components/form/description.tsx +1 -1
- package/src/components/form/dropdown.tsx +22 -3
- package/src/components/form/file-picker.tsx +33 -10
- package/src/components/form/index.tsx +10 -6
- package/src/components/form/password-field.tsx +3 -3
- package/src/components/form/text-area.tsx +31 -6
- package/src/components/form/text-field.tsx +3 -3
- package/src/components/heatmap.tsx +584 -0
- package/src/components/list.tsx +135 -72
- package/src/components/markdown.tsx +30 -0
- package/src/components/metadata.tsx +9 -2
- package/src/components/progress-bar.tsx +112 -0
- package/src/components/table.tsx +88 -71
- package/src/diagram-parser.tsx +17 -3
- package/src/examples/bar-graph-weekly.vitest.tsx +4 -4
- package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
- package/src/examples/form-basic.vitest.tsx +117 -16
- package/src/examples/graph-bar-chart.vitest.tsx +2 -2
- package/src/examples/graph-row.vitest.tsx +10 -10
- package/src/examples/internal/descendants-rerender.vitest.tsx +94 -46
- package/src/examples/internal/simple-scrollbox.vitest.tsx +38 -14
- package/src/examples/list-dropdown-default.vitest.tsx +78 -58
- package/src/examples/list-slot.tsx +38 -0
- package/src/examples/list-with-detail.vitest.tsx +8 -8
- package/src/examples/list-with-dropdown.tsx +2 -2
- package/src/examples/list-with-dropdown.vitest.tsx +16 -16
- package/src/examples/list-with-sections.vitest.tsx +45 -32
- package/src/examples/simple-detail-table.vitest.tsx +2 -2
- package/src/examples/simple-file-picker.vitest.tsx +1 -1
- package/src/examples/simple-grid.vitest.tsx +27 -53
- package/src/examples/simple-heatmap.tsx +63 -0
- package/src/examples/simple-heatmap.vitest.tsx +88 -0
- package/src/examples/simple-progress-bar.tsx +82 -0
- package/src/examples/simple-progress-bar.vitest.tsx +72 -0
- package/src/examples/table-edge-cases.vitest.tsx +1 -1
- package/src/index.tsx +19 -0
- package/src/internal/date-picker-widget.tsx +23 -12
- package/src/internal/navigation.tsx +7 -2
- package/src/internal/providers.tsx +48 -3
- package/src/logger.tsx +6 -1
- package/src/state.tsx +38 -2
- package/src/theme.tsx +26 -2
- 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
|
-
|
|
36
|
-
|
|
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 }
|