violit 0.0.4__py3-none-any.whl → 0.0.5__py3-none-any.whl
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.
- violit/app.py +2229 -1988
- violit/context.py +1 -0
- violit/state.py +33 -0
- violit/widgets/__init__.py +30 -30
- violit/widgets/card_widgets.py +595 -595
- violit/widgets/data_widgets.py +529 -529
- violit/widgets/layout_widgets.py +419 -419
- violit/widgets/text_widgets.py +413 -413
- {violit-0.0.4.dist-info → violit-0.0.5.dist-info}/METADATA +3 -2
- {violit-0.0.4.dist-info → violit-0.0.5.dist-info}/RECORD +13 -13
- {violit-0.0.4.dist-info → violit-0.0.5.dist-info}/WHEEL +0 -0
- {violit-0.0.4.dist-info → violit-0.0.5.dist-info}/licenses/LICENSE +0 -0
- {violit-0.0.4.dist-info → violit-0.0.5.dist-info}/top_level.txt +0 -0
violit/widgets/data_widgets.py
CHANGED
|
@@ -1,529 +1,529 @@
|
|
|
1
|
-
"""Data Widgets Mixin for Violit"""
|
|
2
|
-
|
|
3
|
-
from typing import Union, Callable, Optional, Any
|
|
4
|
-
import json
|
|
5
|
-
import pandas as pd
|
|
6
|
-
from ..component import Component
|
|
7
|
-
from ..context import rendering_ctx
|
|
8
|
-
from ..state import State
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class DataWidgetsMixin:
|
|
12
|
-
"""Data display widgets (dataframe, table, data_editor, metric, json)"""
|
|
13
|
-
|
|
14
|
-
def dataframe(self, df: Union[pd.DataFrame, Callable, State], height=400,
|
|
15
|
-
column_defs=None, grid_options=None, on_cell_clicked=None, **props):
|
|
16
|
-
"""Display interactive dataframe with AG Grid"""
|
|
17
|
-
cid = self._get_next_cid("df")
|
|
18
|
-
|
|
19
|
-
def action(v):
|
|
20
|
-
"""Handle cell click events"""
|
|
21
|
-
if on_cell_clicked and callable(on_cell_clicked):
|
|
22
|
-
on_cell_clicked(v)
|
|
23
|
-
|
|
24
|
-
def builder():
|
|
25
|
-
# Handle Signal
|
|
26
|
-
current_df = df
|
|
27
|
-
if isinstance(df, State):
|
|
28
|
-
token = rendering_ctx.set(cid)
|
|
29
|
-
current_df = df.value
|
|
30
|
-
rendering_ctx.reset(token)
|
|
31
|
-
elif callable(df):
|
|
32
|
-
token = rendering_ctx.set(cid)
|
|
33
|
-
current_df = df()
|
|
34
|
-
rendering_ctx.reset(token)
|
|
35
|
-
|
|
36
|
-
if not isinstance(current_df, pd.DataFrame):
|
|
37
|
-
# Fallback or try to convert
|
|
38
|
-
try: current_df = pd.DataFrame(current_df)
|
|
39
|
-
except: return Component("div", id=cid, content="Invalid data format")
|
|
40
|
-
|
|
41
|
-
data = current_df.to_dict('records')
|
|
42
|
-
|
|
43
|
-
# Use custom column_defs or generate defaults
|
|
44
|
-
if column_defs:
|
|
45
|
-
cols = column_defs
|
|
46
|
-
else:
|
|
47
|
-
cols = [{"field": c, "sortable": True, "filter": True} for c in current_df.columns]
|
|
48
|
-
|
|
49
|
-
# Merge grid_options
|
|
50
|
-
extra_options = grid_options or {}
|
|
51
|
-
|
|
52
|
-
# Add cell click handler if provided
|
|
53
|
-
cell_click_handler = ""
|
|
54
|
-
if on_cell_clicked:
|
|
55
|
-
cell_click_handler = f'''
|
|
56
|
-
onCellClicked: (params) => {{
|
|
57
|
-
const cellData = {{
|
|
58
|
-
value: params.value,
|
|
59
|
-
field: params.colDef.field,
|
|
60
|
-
rowData: params.data,
|
|
61
|
-
rowIndex: params.rowIndex
|
|
62
|
-
}};
|
|
63
|
-
{f"window.sendAction('{cid}', cellData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(cellData)}}, swap: 'none'}});"}
|
|
64
|
-
}},
|
|
65
|
-
'''
|
|
66
|
-
|
|
67
|
-
html = f'''
|
|
68
|
-
<div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
|
|
69
|
-
<script>(function(){{
|
|
70
|
-
const opt = {{
|
|
71
|
-
columnDefs: {json.dumps(cols, default=str)},
|
|
72
|
-
rowData: {json.dumps(data, default=str)},
|
|
73
|
-
defaultColDef: {{flex: 1, minWidth: 100, resizable: true}},
|
|
74
|
-
{cell_click_handler}
|
|
75
|
-
...{json.dumps(extra_options)}
|
|
76
|
-
}};
|
|
77
|
-
const el = document.querySelector('#{cid}');
|
|
78
|
-
if (el && window.agGrid) {{
|
|
79
|
-
const grid = new agGrid.Grid(el, opt);
|
|
80
|
-
window['grid_{cid}'] = grid;
|
|
81
|
-
}}
|
|
82
|
-
else {{ console.error("agGrid not found"); }}
|
|
83
|
-
}})();</script>
|
|
84
|
-
'''
|
|
85
|
-
return Component("div", id=f"{cid}_wrapper", content=html)
|
|
86
|
-
|
|
87
|
-
self._register_component(cid, builder, action=action if on_cell_clicked else None)
|
|
88
|
-
|
|
89
|
-
def table(self, df: Union[pd.DataFrame, Callable, State], **props):
|
|
90
|
-
"""Display static HTML table (Signal support)"""
|
|
91
|
-
cid = self._get_next_cid("table")
|
|
92
|
-
def builder():
|
|
93
|
-
# Handle Signal
|
|
94
|
-
current_df = df
|
|
95
|
-
if isinstance(df, State):
|
|
96
|
-
token = rendering_ctx.set(cid)
|
|
97
|
-
current_df = df.value
|
|
98
|
-
rendering_ctx.reset(token)
|
|
99
|
-
elif callable(df):
|
|
100
|
-
token = rendering_ctx.set(cid)
|
|
101
|
-
current_df = df()
|
|
102
|
-
rendering_ctx.reset(token)
|
|
103
|
-
|
|
104
|
-
if not isinstance(current_df, pd.DataFrame):
|
|
105
|
-
try: current_df = pd.DataFrame(current_df)
|
|
106
|
-
except: return Component("div", id=cid, content="Invalid data format")
|
|
107
|
-
|
|
108
|
-
# Convert dataframe to HTML table
|
|
109
|
-
html_table = current_df.to_html(index=False, border=0, classes=['data-table'])
|
|
110
|
-
styled_html = f'''
|
|
111
|
-
<div style="overflow-x:auto;border:1px solid var(--sl-border);border-radius:0.5rem;">
|
|
112
|
-
<style>
|
|
113
|
-
.data-table {{
|
|
114
|
-
width: 100%;
|
|
115
|
-
border-collapse: collapse;
|
|
116
|
-
background: var(--sl-bg-card);
|
|
117
|
-
color: var(--sl-text);
|
|
118
|
-
}}
|
|
119
|
-
.data-table thead {{
|
|
120
|
-
background: var(--sl-primary);
|
|
121
|
-
color: white;
|
|
122
|
-
}}
|
|
123
|
-
.data-table th, .data-table td {{
|
|
124
|
-
padding: 0.75rem;
|
|
125
|
-
text-align: left;
|
|
126
|
-
border-bottom: 1px solid var(--sl-border);
|
|
127
|
-
}}
|
|
128
|
-
.data-table tbody tr:hover {{
|
|
129
|
-
background: color-mix(in srgb, var(--sl-bg-card), var(--sl-primary) 5%);
|
|
130
|
-
}}
|
|
131
|
-
</style>
|
|
132
|
-
{html_table}
|
|
133
|
-
</div>
|
|
134
|
-
'''
|
|
135
|
-
return Component("div", id=cid, content=styled_html)
|
|
136
|
-
self._register_component(cid, builder)
|
|
137
|
-
|
|
138
|
-
def data_editor(self, df: pd.DataFrame, num_rows="fixed", height=400, key=None, on_change=None, **props):
|
|
139
|
-
"""Interactive data editor (simplified version)"""
|
|
140
|
-
cid = self._get_next_cid("data_editor")
|
|
141
|
-
|
|
142
|
-
state_key = key or f"data_editor:{cid}"
|
|
143
|
-
s = self.state(df.to_dict('records'), key=state_key)
|
|
144
|
-
|
|
145
|
-
def action(v):
|
|
146
|
-
try:
|
|
147
|
-
new_data = json.loads(v) if isinstance(v, str) else v
|
|
148
|
-
s.set(new_data)
|
|
149
|
-
if on_change: on_change(pd.DataFrame(new_data))
|
|
150
|
-
except: pass
|
|
151
|
-
|
|
152
|
-
def builder():
|
|
153
|
-
# Subscribe to own state - client-side will handle smart updates
|
|
154
|
-
token = rendering_ctx.set(cid)
|
|
155
|
-
data = s.value
|
|
156
|
-
rendering_ctx.reset(token)
|
|
157
|
-
|
|
158
|
-
cols = [{"field": c, "sortable": True, "filter": True, "editable": True} for c in df.columns]
|
|
159
|
-
add_row_btn = '' if num_rows == "fixed" else f'''
|
|
160
|
-
<sl-button size="small" style="margin-top:0.5rem;" onclick="addDataRow_{cid}()">
|
|
161
|
-
<sl-icon slot="prefix" name="plus-circle"></sl-icon>
|
|
162
|
-
Add Row
|
|
163
|
-
</sl-button>
|
|
164
|
-
'''
|
|
165
|
-
|
|
166
|
-
html = f'''
|
|
167
|
-
<div>
|
|
168
|
-
<div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
|
|
169
|
-
{add_row_btn}
|
|
170
|
-
</div>
|
|
171
|
-
<script>(function(){{
|
|
172
|
-
const gridOptions = {{
|
|
173
|
-
columnDefs: {json.dumps(cols, default=str)},
|
|
174
|
-
rowData: {json.dumps(data, default=str)},
|
|
175
|
-
defaultColDef: {{ flex: 1, minWidth: 100, resizable: true, editable: true }},
|
|
176
|
-
onCellValueChanged: (params) => {{
|
|
177
|
-
const allData = [];
|
|
178
|
-
params.api.forEachNode(node => allData.push(node.data));
|
|
179
|
-
{f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
|
|
180
|
-
}},
|
|
181
|
-
onGridReady: (params) => {{
|
|
182
|
-
// Store API when grid is ready
|
|
183
|
-
window['gridApi_{cid}'] = params.api;
|
|
184
|
-
}}
|
|
185
|
-
}};
|
|
186
|
-
const el = document.querySelector('#{cid}');
|
|
187
|
-
if (el && window.agGrid) {{
|
|
188
|
-
new agGrid.Grid(el, gridOptions);
|
|
189
|
-
}}
|
|
190
|
-
|
|
191
|
-
window.addDataRow_{cid} = function() {{
|
|
192
|
-
// Access stored grid API
|
|
193
|
-
const api = window['gridApi_{cid}'];
|
|
194
|
-
if (api && api.applyTransaction) {{
|
|
195
|
-
// Add empty row with all column fields
|
|
196
|
-
const newRow = {{}};
|
|
197
|
-
{json.dumps([c for c in df.columns])}.forEach(col => newRow[col] = '');
|
|
198
|
-
api.applyTransaction({{add: [newRow]}});
|
|
199
|
-
// Trigger data update to sync with backend
|
|
200
|
-
const allData = [];
|
|
201
|
-
api.forEachNode(node => allData.push(node.data));
|
|
202
|
-
{f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
|
|
203
|
-
}}
|
|
204
|
-
}};
|
|
205
|
-
}})();</script>
|
|
206
|
-
'''
|
|
207
|
-
return Component("div", id=f"{cid}_wrapper", content=html)
|
|
208
|
-
|
|
209
|
-
self._register_component(cid, builder, action=action)
|
|
210
|
-
return s
|
|
211
|
-
|
|
212
|
-
def metric(self, label: str, value: Union[str, int, float, State, Callable], delta: Optional[Union[str, State, Callable]] = None, delta_color: str = "normal"):
|
|
213
|
-
"""Display metric value with Signal support"""
|
|
214
|
-
import html as html_lib
|
|
215
|
-
|
|
216
|
-
cid = self._get_next_cid("metric")
|
|
217
|
-
|
|
218
|
-
def builder():
|
|
219
|
-
# Handle value signal
|
|
220
|
-
curr_val = value
|
|
221
|
-
if isinstance(value, State):
|
|
222
|
-
token = rendering_ctx.set(cid)
|
|
223
|
-
curr_val = value.value
|
|
224
|
-
rendering_ctx.reset(token)
|
|
225
|
-
elif callable(value):
|
|
226
|
-
token = rendering_ctx.set(cid)
|
|
227
|
-
curr_val = value()
|
|
228
|
-
rendering_ctx.reset(token)
|
|
229
|
-
|
|
230
|
-
# Handle delta signal
|
|
231
|
-
curr_delta = delta
|
|
232
|
-
if isinstance(delta, State):
|
|
233
|
-
token = rendering_ctx.set(cid)
|
|
234
|
-
curr_delta = delta.value
|
|
235
|
-
rendering_ctx.reset(token)
|
|
236
|
-
elif callable(delta):
|
|
237
|
-
token = rendering_ctx.set(cid)
|
|
238
|
-
curr_delta = delta()
|
|
239
|
-
rendering_ctx.reset(token)
|
|
240
|
-
|
|
241
|
-
# XSS protection: escape all values
|
|
242
|
-
escaped_label = html_lib.escape(str(label))
|
|
243
|
-
escaped_val = html_lib.escape(str(curr_val))
|
|
244
|
-
|
|
245
|
-
delta_html = ""
|
|
246
|
-
if curr_delta:
|
|
247
|
-
escaped_delta = html_lib.escape(str(curr_delta))
|
|
248
|
-
color_map = {"positive": "#10b981", "negative": "#ef4444", "normal": "var(--sl-text-muted)"}
|
|
249
|
-
color = color_map.get(delta_color, "var(--sl-text-muted)")
|
|
250
|
-
icon = "arrow-up" if delta_color == "positive" else "arrow-down" if delta_color == "negative" else ""
|
|
251
|
-
icon_html = f'<sl-icon name="{icon}" style="font-size: 0.8em; margin-right: 2px;"></sl-icon>' if icon else ""
|
|
252
|
-
delta_html = f'<div style="color: {color}; font-size: 0.9rem; margin-top: 0.25rem; font-weight: 500;">{icon_html}{escaped_delta}</div>'
|
|
253
|
-
|
|
254
|
-
html_output = f'''
|
|
255
|
-
<div class="card" style="padding: 1.25rem;">
|
|
256
|
-
<div style="font-size: 0.875rem; color: var(--sl-text-muted); margin-bottom: 0.5rem; font-weight: 500;">{escaped_label}</div>
|
|
257
|
-
<div style="font-size: 1.75rem; font-weight: 700; color: var(--sl-text);">{escaped_val}</div>
|
|
258
|
-
{delta_html}
|
|
259
|
-
</div>
|
|
260
|
-
'''
|
|
261
|
-
return Component("div", id=cid, content=html_output)
|
|
262
|
-
|
|
263
|
-
self._register_component(cid, builder)
|
|
264
|
-
|
|
265
|
-
def json(self, body: Any, expanded=True):
|
|
266
|
-
"""Display JSON data"""
|
|
267
|
-
cid = self._get_next_cid("json")
|
|
268
|
-
json_str = json.dumps(body, indent=2, default=str)
|
|
269
|
-
html = f'''
|
|
270
|
-
<details {"open" if expanded else ""} style="background:var(--sl-bg-card);border:1px solid var(--sl-border);border-radius:0.5rem;padding:0.5rem;">
|
|
271
|
-
<summary style="cursor:pointer;font-size:0.875rem;color:var(--sl-text-muted);">JSON Data</summary>
|
|
272
|
-
<pre style="margin:0.5rem 0 0 0;font-size:0.875rem;color:var(--sl-primary);">{json_str}</pre>
|
|
273
|
-
</details>
|
|
274
|
-
'''
|
|
275
|
-
return Component("div", id=cid, content=html)
|
|
276
|
-
|
|
277
|
-
def heatmap(self, data: Union[dict, State, Callable],
|
|
278
|
-
start_date=None, end_date=None,
|
|
279
|
-
color_map=None, show_legend=True,
|
|
280
|
-
show_weekdays=True, show_months=True,
|
|
281
|
-
cell_size=12, gap=3, on_cell_clicked=None, **props):
|
|
282
|
-
"""
|
|
283
|
-
Display GitHub-style activity heatmap
|
|
284
|
-
|
|
285
|
-
Args:
|
|
286
|
-
data: Dict mapping date strings (YYYY-MM-DD) to values, or State/Callable
|
|
287
|
-
start_date: Start date (string or date object)
|
|
288
|
-
end_date: End date (string or date object)
|
|
289
|
-
color_map: Dict mapping values to colors
|
|
290
|
-
Example: {0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
|
|
291
|
-
show_legend: Show color legend
|
|
292
|
-
show_weekdays: Show weekday labels
|
|
293
|
-
show_months: Show month labels
|
|
294
|
-
cell_size: Size of each cell in pixels
|
|
295
|
-
gap: Gap between cells in pixels
|
|
296
|
-
on_cell_clicked: Callback for cell clicks
|
|
297
|
-
|
|
298
|
-
Example:
|
|
299
|
-
app.heatmap(
|
|
300
|
-
data={date: status for date, status in completions.items()},
|
|
301
|
-
start_date='2026-01-01',
|
|
302
|
-
end_date='2026-12-31',
|
|
303
|
-
color_map={0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
|
|
304
|
-
)
|
|
305
|
-
"""
|
|
306
|
-
from datetime import date as date_obj, timedelta
|
|
307
|
-
|
|
308
|
-
cid = self._get_next_cid("heatmap")
|
|
309
|
-
|
|
310
|
-
def action(v):
|
|
311
|
-
"""Handle cell click events"""
|
|
312
|
-
if on_cell_clicked and callable(on_cell_clicked):
|
|
313
|
-
on_cell_clicked(v)
|
|
314
|
-
|
|
315
|
-
def builder():
|
|
316
|
-
# Handle Signal/Callable
|
|
317
|
-
current_data = data
|
|
318
|
-
if isinstance(data, State):
|
|
319
|
-
token = rendering_ctx.set(cid)
|
|
320
|
-
current_data = data.value
|
|
321
|
-
rendering_ctx.reset(token)
|
|
322
|
-
elif callable(data):
|
|
323
|
-
token = rendering_ctx.set(cid)
|
|
324
|
-
current_data = data()
|
|
325
|
-
rendering_ctx.reset(token)
|
|
326
|
-
|
|
327
|
-
# Parse dates
|
|
328
|
-
if start_date:
|
|
329
|
-
if isinstance(start_date, str):
|
|
330
|
-
start = date_obj.fromisoformat(start_date)
|
|
331
|
-
else:
|
|
332
|
-
start = start_date
|
|
333
|
-
else:
|
|
334
|
-
start = date_obj.today().replace(month=1, day=1)
|
|
335
|
-
|
|
336
|
-
if end_date:
|
|
337
|
-
if isinstance(end_date, str):
|
|
338
|
-
end = date_obj.fromisoformat(end_date)
|
|
339
|
-
else:
|
|
340
|
-
end = end_date
|
|
341
|
-
else:
|
|
342
|
-
end = date_obj.today().replace(month=12, day=31)
|
|
343
|
-
|
|
344
|
-
# Default color map (use current_color_map to avoid variable shadowing)
|
|
345
|
-
current_color_map = color_map if color_map is not None else {
|
|
346
|
-
0: '#ebedf0',
|
|
347
|
-
1: '#10b981',
|
|
348
|
-
2: '#fbbf24'
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
# Adjust start to Sunday
|
|
352
|
-
start_day = start - timedelta(days=start.weekday() + 1 if start.weekday() != 6 else 0)
|
|
353
|
-
|
|
354
|
-
# Generate week data
|
|
355
|
-
weeks = []
|
|
356
|
-
current = start_day
|
|
357
|
-
|
|
358
|
-
while current <= end:
|
|
359
|
-
week = []
|
|
360
|
-
for _ in range(7):
|
|
361
|
-
if start <= current <= end:
|
|
362
|
-
date_str = current.isoformat()
|
|
363
|
-
value = current_data.get(date_str, 0)
|
|
364
|
-
week.append({'date': current, 'value': value, 'valid': True})
|
|
365
|
-
else:
|
|
366
|
-
week.append({'date': current, 'value': 0, 'valid': False})
|
|
367
|
-
current += timedelta(days=1)
|
|
368
|
-
weeks.append(week)
|
|
369
|
-
|
|
370
|
-
# CSS
|
|
371
|
-
css = f'''
|
|
372
|
-
<style>
|
|
373
|
-
.heatmap-{cid} {{
|
|
374
|
-
background: white;
|
|
375
|
-
padding: 1.5rem;
|
|
376
|
-
border-radius: 8px;
|
|
377
|
-
overflow-x: auto;
|
|
378
|
-
}}
|
|
379
|
-
.heatmap-{cid} .grid {{
|
|
380
|
-
display: flex;
|
|
381
|
-
gap: {gap}px;
|
|
382
|
-
}}
|
|
383
|
-
.heatmap-{cid} .weekdays {{
|
|
384
|
-
display: flex;
|
|
385
|
-
flex-direction: column;
|
|
386
|
-
gap: {gap}px;
|
|
387
|
-
padding-top: 20px;
|
|
388
|
-
margin-right: 4px;
|
|
389
|
-
}}
|
|
390
|
-
.heatmap-{cid} .day-label {{
|
|
391
|
-
height: {cell_size}px;
|
|
392
|
-
font-size: 9px;
|
|
393
|
-
color: #666;
|
|
394
|
-
display: flex;
|
|
395
|
-
align-items: center;
|
|
396
|
-
}}
|
|
397
|
-
.heatmap-{cid} .week {{
|
|
398
|
-
display: flex;
|
|
399
|
-
flex-direction: column;
|
|
400
|
-
gap: {gap}px;
|
|
401
|
-
}}
|
|
402
|
-
.heatmap-{cid} .month {{
|
|
403
|
-
height: 14px;
|
|
404
|
-
font-size: 9px;
|
|
405
|
-
color: #666;
|
|
406
|
-
text-align: center;
|
|
407
|
-
margin-bottom: 2px;
|
|
408
|
-
}}
|
|
409
|
-
.heatmap-{cid} .cell {{
|
|
410
|
-
width: {cell_size}px;
|
|
411
|
-
height: {cell_size}px;
|
|
412
|
-
border-radius: 2px;
|
|
413
|
-
border: 1px solid #fff;
|
|
414
|
-
cursor: pointer;
|
|
415
|
-
}}
|
|
416
|
-
.heatmap-{cid} .cell:hover {{
|
|
417
|
-
opacity: 0.8;
|
|
418
|
-
border: 1px solid #000;
|
|
419
|
-
}}
|
|
420
|
-
.heatmap-{cid} .cell.today {{
|
|
421
|
-
border: 2px solid #000;
|
|
422
|
-
}}
|
|
423
|
-
.heatmap-{cid} .legend {{
|
|
424
|
-
margin-top: 1rem;
|
|
425
|
-
display: flex;
|
|
426
|
-
gap: 1rem;
|
|
427
|
-
font-size: 11px;
|
|
428
|
-
color: #666;
|
|
429
|
-
align-items: center;
|
|
430
|
-
}}
|
|
431
|
-
.heatmap-{cid} .legend-item {{
|
|
432
|
-
display: flex;
|
|
433
|
-
align-items: center;
|
|
434
|
-
gap: 4px;
|
|
435
|
-
}}
|
|
436
|
-
</style>
|
|
437
|
-
'''
|
|
438
|
-
|
|
439
|
-
# Weekday labels
|
|
440
|
-
weekday_html = ''
|
|
441
|
-
if show_weekdays:
|
|
442
|
-
weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
443
|
-
weekday_html = f'''
|
|
444
|
-
<div class="weekdays">
|
|
445
|
-
{"".join(f'<div class="day-label">{d}</div>' for d in weekdays)}
|
|
446
|
-
</div>
|
|
447
|
-
'''
|
|
448
|
-
|
|
449
|
-
# Generate weeks HTML
|
|
450
|
-
today = date_obj.today()
|
|
451
|
-
current_month = None
|
|
452
|
-
weeks_html = []
|
|
453
|
-
|
|
454
|
-
for week in weeks:
|
|
455
|
-
# Month label
|
|
456
|
-
first_valid = next((d for d in week if d['valid']), None)
|
|
457
|
-
if first_valid and show_months:
|
|
458
|
-
month = first_valid['date'].month
|
|
459
|
-
month_label = f"{month}" if month != current_month else ""
|
|
460
|
-
current_month = month
|
|
461
|
-
else:
|
|
462
|
-
month_label = ""
|
|
463
|
-
|
|
464
|
-
# Cells
|
|
465
|
-
cells_html = []
|
|
466
|
-
for day in week:
|
|
467
|
-
if not day['valid']:
|
|
468
|
-
cells_html.append(f'<div style="width: {cell_size}px; height: {cell_size}px;"></div>')
|
|
469
|
-
else:
|
|
470
|
-
value = day['value']
|
|
471
|
-
bg_color = current_color_map.get(value, '#ebedf0')
|
|
472
|
-
is_today = day['date'] == today
|
|
473
|
-
today_class = ' today' if is_today else ''
|
|
474
|
-
date_str = day['date'].isoformat()
|
|
475
|
-
|
|
476
|
-
# Click handler
|
|
477
|
-
click_attr = ''
|
|
478
|
-
if on_cell_clicked:
|
|
479
|
-
click_js = f"window.sendAction('{cid}', {{date: '{date_str}', value: {value}}});" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify({{date: '{date_str}', value: {value}}})}}), swap: 'none'}});"
|
|
480
|
-
click_attr = f'onclick="{click_js}"'
|
|
481
|
-
|
|
482
|
-
cells_html.append(
|
|
483
|
-
f'<div class="cell{today_class}" style="background: {bg_color};" title="{date_str}" {click_attr}></div>'
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
weeks_html.append(f'''
|
|
487
|
-
<div class="week">
|
|
488
|
-
<div class="month">{month_label}</div>
|
|
489
|
-
{"".join(cells_html)}
|
|
490
|
-
</div>
|
|
491
|
-
''')
|
|
492
|
-
|
|
493
|
-
# Legend
|
|
494
|
-
legend_html = ''
|
|
495
|
-
if show_legend:
|
|
496
|
-
legend_items = [
|
|
497
|
-
f'''<div class="legend-item">
|
|
498
|
-
<div class="cell" style="background: {color}; border: 1px solid #ddd;"></div>
|
|
499
|
-
<span>{label}</span>
|
|
500
|
-
</div>'''
|
|
501
|
-
for label, color in [('None', current_color_map.get(0, '#ebedf0')),
|
|
502
|
-
('Done', current_color_map.get(1, '#10b981')),
|
|
503
|
-
('Skip', current_color_map.get(2, '#fbbf24'))]
|
|
504
|
-
if color in current_color_map.values()
|
|
505
|
-
]
|
|
506
|
-
legend_html = f'''
|
|
507
|
-
<div class="legend">
|
|
508
|
-
<span>Legend:</span>
|
|
509
|
-
{"".join(legend_items)}
|
|
510
|
-
</div>
|
|
511
|
-
'''
|
|
512
|
-
|
|
513
|
-
# Final HTML
|
|
514
|
-
html = f'''
|
|
515
|
-
{css}
|
|
516
|
-
<div class="heatmap-{cid}">
|
|
517
|
-
<div class="grid">
|
|
518
|
-
{weekday_html}
|
|
519
|
-
<div style="display: flex; gap: {gap}px;">
|
|
520
|
-
{"".join(weeks_html)}
|
|
521
|
-
</div>
|
|
522
|
-
</div>
|
|
523
|
-
{legend_html}
|
|
524
|
-
</div>
|
|
525
|
-
'''
|
|
526
|
-
|
|
527
|
-
return Component("div", id=cid, content=html)
|
|
528
|
-
|
|
529
|
-
self._register_component(cid, builder, action=action if on_cell_clicked else None)
|
|
1
|
+
"""Data Widgets Mixin for Violit"""
|
|
2
|
+
|
|
3
|
+
from typing import Union, Callable, Optional, Any
|
|
4
|
+
import json
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from ..component import Component
|
|
7
|
+
from ..context import rendering_ctx
|
|
8
|
+
from ..state import State
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataWidgetsMixin:
|
|
12
|
+
"""Data display widgets (dataframe, table, data_editor, metric, json)"""
|
|
13
|
+
|
|
14
|
+
def dataframe(self, df: Union[pd.DataFrame, Callable, State], height=400,
|
|
15
|
+
column_defs=None, grid_options=None, on_cell_clicked=None, **props):
|
|
16
|
+
"""Display interactive dataframe with AG Grid"""
|
|
17
|
+
cid = self._get_next_cid("df")
|
|
18
|
+
|
|
19
|
+
def action(v):
|
|
20
|
+
"""Handle cell click events"""
|
|
21
|
+
if on_cell_clicked and callable(on_cell_clicked):
|
|
22
|
+
on_cell_clicked(v)
|
|
23
|
+
|
|
24
|
+
def builder():
|
|
25
|
+
# Handle Signal
|
|
26
|
+
current_df = df
|
|
27
|
+
if isinstance(df, State):
|
|
28
|
+
token = rendering_ctx.set(cid)
|
|
29
|
+
current_df = df.value
|
|
30
|
+
rendering_ctx.reset(token)
|
|
31
|
+
elif callable(df):
|
|
32
|
+
token = rendering_ctx.set(cid)
|
|
33
|
+
current_df = df()
|
|
34
|
+
rendering_ctx.reset(token)
|
|
35
|
+
|
|
36
|
+
if not isinstance(current_df, pd.DataFrame):
|
|
37
|
+
# Fallback or try to convert
|
|
38
|
+
try: current_df = pd.DataFrame(current_df)
|
|
39
|
+
except: return Component("div", id=cid, content="Invalid data format")
|
|
40
|
+
|
|
41
|
+
data = current_df.to_dict('records')
|
|
42
|
+
|
|
43
|
+
# Use custom column_defs or generate defaults
|
|
44
|
+
if column_defs:
|
|
45
|
+
cols = column_defs
|
|
46
|
+
else:
|
|
47
|
+
cols = [{"field": c, "sortable": True, "filter": True} for c in current_df.columns]
|
|
48
|
+
|
|
49
|
+
# Merge grid_options
|
|
50
|
+
extra_options = grid_options or {}
|
|
51
|
+
|
|
52
|
+
# Add cell click handler if provided
|
|
53
|
+
cell_click_handler = ""
|
|
54
|
+
if on_cell_clicked:
|
|
55
|
+
cell_click_handler = f'''
|
|
56
|
+
onCellClicked: (params) => {{
|
|
57
|
+
const cellData = {{
|
|
58
|
+
value: params.value,
|
|
59
|
+
field: params.colDef.field,
|
|
60
|
+
rowData: params.data,
|
|
61
|
+
rowIndex: params.rowIndex
|
|
62
|
+
}};
|
|
63
|
+
{f"window.sendAction('{cid}', cellData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(cellData)}}, swap: 'none'}});"}
|
|
64
|
+
}},
|
|
65
|
+
'''
|
|
66
|
+
|
|
67
|
+
html = f'''
|
|
68
|
+
<div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
|
|
69
|
+
<script>(function(){{
|
|
70
|
+
const opt = {{
|
|
71
|
+
columnDefs: {json.dumps(cols, default=str)},
|
|
72
|
+
rowData: {json.dumps(data, default=str)},
|
|
73
|
+
defaultColDef: {{flex: 1, minWidth: 100, resizable: true}},
|
|
74
|
+
{cell_click_handler}
|
|
75
|
+
...{json.dumps(extra_options)}
|
|
76
|
+
}};
|
|
77
|
+
const el = document.querySelector('#{cid}');
|
|
78
|
+
if (el && window.agGrid) {{
|
|
79
|
+
const grid = new agGrid.Grid(el, opt);
|
|
80
|
+
window['grid_{cid}'] = grid;
|
|
81
|
+
}}
|
|
82
|
+
else {{ console.error("agGrid not found"); }}
|
|
83
|
+
}})();</script>
|
|
84
|
+
'''
|
|
85
|
+
return Component("div", id=f"{cid}_wrapper", content=html)
|
|
86
|
+
|
|
87
|
+
self._register_component(cid, builder, action=action if on_cell_clicked else None)
|
|
88
|
+
|
|
89
|
+
def table(self, df: Union[pd.DataFrame, Callable, State], **props):
|
|
90
|
+
"""Display static HTML table (Signal support)"""
|
|
91
|
+
cid = self._get_next_cid("table")
|
|
92
|
+
def builder():
|
|
93
|
+
# Handle Signal
|
|
94
|
+
current_df = df
|
|
95
|
+
if isinstance(df, State):
|
|
96
|
+
token = rendering_ctx.set(cid)
|
|
97
|
+
current_df = df.value
|
|
98
|
+
rendering_ctx.reset(token)
|
|
99
|
+
elif callable(df):
|
|
100
|
+
token = rendering_ctx.set(cid)
|
|
101
|
+
current_df = df()
|
|
102
|
+
rendering_ctx.reset(token)
|
|
103
|
+
|
|
104
|
+
if not isinstance(current_df, pd.DataFrame):
|
|
105
|
+
try: current_df = pd.DataFrame(current_df)
|
|
106
|
+
except: return Component("div", id=cid, content="Invalid data format")
|
|
107
|
+
|
|
108
|
+
# Convert dataframe to HTML table
|
|
109
|
+
html_table = current_df.to_html(index=False, border=0, classes=['data-table'])
|
|
110
|
+
styled_html = f'''
|
|
111
|
+
<div style="overflow-x:auto;border:1px solid var(--sl-border);border-radius:0.5rem;">
|
|
112
|
+
<style>
|
|
113
|
+
.data-table {{
|
|
114
|
+
width: 100%;
|
|
115
|
+
border-collapse: collapse;
|
|
116
|
+
background: var(--sl-bg-card);
|
|
117
|
+
color: var(--sl-text);
|
|
118
|
+
}}
|
|
119
|
+
.data-table thead {{
|
|
120
|
+
background: var(--sl-primary);
|
|
121
|
+
color: white;
|
|
122
|
+
}}
|
|
123
|
+
.data-table th, .data-table td {{
|
|
124
|
+
padding: 0.75rem;
|
|
125
|
+
text-align: left;
|
|
126
|
+
border-bottom: 1px solid var(--sl-border);
|
|
127
|
+
}}
|
|
128
|
+
.data-table tbody tr:hover {{
|
|
129
|
+
background: color-mix(in srgb, var(--sl-bg-card), var(--sl-primary) 5%);
|
|
130
|
+
}}
|
|
131
|
+
</style>
|
|
132
|
+
{html_table}
|
|
133
|
+
</div>
|
|
134
|
+
'''
|
|
135
|
+
return Component("div", id=cid, content=styled_html)
|
|
136
|
+
self._register_component(cid, builder)
|
|
137
|
+
|
|
138
|
+
def data_editor(self, df: pd.DataFrame, num_rows="fixed", height=400, key=None, on_change=None, **props):
|
|
139
|
+
"""Interactive data editor (simplified version)"""
|
|
140
|
+
cid = self._get_next_cid("data_editor")
|
|
141
|
+
|
|
142
|
+
state_key = key or f"data_editor:{cid}"
|
|
143
|
+
s = self.state(df.to_dict('records'), key=state_key)
|
|
144
|
+
|
|
145
|
+
def action(v):
|
|
146
|
+
try:
|
|
147
|
+
new_data = json.loads(v) if isinstance(v, str) else v
|
|
148
|
+
s.set(new_data)
|
|
149
|
+
if on_change: on_change(pd.DataFrame(new_data))
|
|
150
|
+
except: pass
|
|
151
|
+
|
|
152
|
+
def builder():
|
|
153
|
+
# Subscribe to own state - client-side will handle smart updates
|
|
154
|
+
token = rendering_ctx.set(cid)
|
|
155
|
+
data = s.value
|
|
156
|
+
rendering_ctx.reset(token)
|
|
157
|
+
|
|
158
|
+
cols = [{"field": c, "sortable": True, "filter": True, "editable": True} for c in df.columns]
|
|
159
|
+
add_row_btn = '' if num_rows == "fixed" else f'''
|
|
160
|
+
<sl-button size="small" style="margin-top:0.5rem;" onclick="addDataRow_{cid}()">
|
|
161
|
+
<sl-icon slot="prefix" name="plus-circle"></sl-icon>
|
|
162
|
+
Add Row
|
|
163
|
+
</sl-button>
|
|
164
|
+
'''
|
|
165
|
+
|
|
166
|
+
html = f'''
|
|
167
|
+
<div>
|
|
168
|
+
<div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
|
|
169
|
+
{add_row_btn}
|
|
170
|
+
</div>
|
|
171
|
+
<script>(function(){{
|
|
172
|
+
const gridOptions = {{
|
|
173
|
+
columnDefs: {json.dumps(cols, default=str)},
|
|
174
|
+
rowData: {json.dumps(data, default=str)},
|
|
175
|
+
defaultColDef: {{ flex: 1, minWidth: 100, resizable: true, editable: true }},
|
|
176
|
+
onCellValueChanged: (params) => {{
|
|
177
|
+
const allData = [];
|
|
178
|
+
params.api.forEachNode(node => allData.push(node.data));
|
|
179
|
+
{f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
|
|
180
|
+
}},
|
|
181
|
+
onGridReady: (params) => {{
|
|
182
|
+
// Store API when grid is ready
|
|
183
|
+
window['gridApi_{cid}'] = params.api;
|
|
184
|
+
}}
|
|
185
|
+
}};
|
|
186
|
+
const el = document.querySelector('#{cid}');
|
|
187
|
+
if (el && window.agGrid) {{
|
|
188
|
+
new agGrid.Grid(el, gridOptions);
|
|
189
|
+
}}
|
|
190
|
+
|
|
191
|
+
window.addDataRow_{cid} = function() {{
|
|
192
|
+
// Access stored grid API
|
|
193
|
+
const api = window['gridApi_{cid}'];
|
|
194
|
+
if (api && api.applyTransaction) {{
|
|
195
|
+
// Add empty row with all column fields
|
|
196
|
+
const newRow = {{}};
|
|
197
|
+
{json.dumps([c for c in df.columns])}.forEach(col => newRow[col] = '');
|
|
198
|
+
api.applyTransaction({{add: [newRow]}});
|
|
199
|
+
// Trigger data update to sync with backend
|
|
200
|
+
const allData = [];
|
|
201
|
+
api.forEachNode(node => allData.push(node.data));
|
|
202
|
+
{f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
|
|
203
|
+
}}
|
|
204
|
+
}};
|
|
205
|
+
}})();</script>
|
|
206
|
+
'''
|
|
207
|
+
return Component("div", id=f"{cid}_wrapper", content=html)
|
|
208
|
+
|
|
209
|
+
self._register_component(cid, builder, action=action)
|
|
210
|
+
return s
|
|
211
|
+
|
|
212
|
+
def metric(self, label: str, value: Union[str, int, float, State, Callable], delta: Optional[Union[str, State, Callable]] = None, delta_color: str = "normal"):
|
|
213
|
+
"""Display metric value with Signal support"""
|
|
214
|
+
import html as html_lib
|
|
215
|
+
|
|
216
|
+
cid = self._get_next_cid("metric")
|
|
217
|
+
|
|
218
|
+
def builder():
|
|
219
|
+
# Handle value signal
|
|
220
|
+
curr_val = value
|
|
221
|
+
if isinstance(value, State):
|
|
222
|
+
token = rendering_ctx.set(cid)
|
|
223
|
+
curr_val = value.value
|
|
224
|
+
rendering_ctx.reset(token)
|
|
225
|
+
elif callable(value):
|
|
226
|
+
token = rendering_ctx.set(cid)
|
|
227
|
+
curr_val = value()
|
|
228
|
+
rendering_ctx.reset(token)
|
|
229
|
+
|
|
230
|
+
# Handle delta signal
|
|
231
|
+
curr_delta = delta
|
|
232
|
+
if isinstance(delta, State):
|
|
233
|
+
token = rendering_ctx.set(cid)
|
|
234
|
+
curr_delta = delta.value
|
|
235
|
+
rendering_ctx.reset(token)
|
|
236
|
+
elif callable(delta):
|
|
237
|
+
token = rendering_ctx.set(cid)
|
|
238
|
+
curr_delta = delta()
|
|
239
|
+
rendering_ctx.reset(token)
|
|
240
|
+
|
|
241
|
+
# XSS protection: escape all values
|
|
242
|
+
escaped_label = html_lib.escape(str(label))
|
|
243
|
+
escaped_val = html_lib.escape(str(curr_val))
|
|
244
|
+
|
|
245
|
+
delta_html = ""
|
|
246
|
+
if curr_delta:
|
|
247
|
+
escaped_delta = html_lib.escape(str(curr_delta))
|
|
248
|
+
color_map = {"positive": "#10b981", "negative": "#ef4444", "normal": "var(--sl-text-muted)"}
|
|
249
|
+
color = color_map.get(delta_color, "var(--sl-text-muted)")
|
|
250
|
+
icon = "arrow-up" if delta_color == "positive" else "arrow-down" if delta_color == "negative" else ""
|
|
251
|
+
icon_html = f'<sl-icon name="{icon}" style="font-size: 0.8em; margin-right: 2px;"></sl-icon>' if icon else ""
|
|
252
|
+
delta_html = f'<div style="color: {color}; font-size: 0.9rem; margin-top: 0.25rem; font-weight: 500;">{icon_html}{escaped_delta}</div>'
|
|
253
|
+
|
|
254
|
+
html_output = f'''
|
|
255
|
+
<div class="card" style="padding: 1.25rem;">
|
|
256
|
+
<div style="font-size: 0.875rem; color: var(--sl-text-muted); margin-bottom: 0.5rem; font-weight: 500;">{escaped_label}</div>
|
|
257
|
+
<div style="font-size: 1.75rem; font-weight: 700; color: var(--sl-text);">{escaped_val}</div>
|
|
258
|
+
{delta_html}
|
|
259
|
+
</div>
|
|
260
|
+
'''
|
|
261
|
+
return Component("div", id=cid, content=html_output)
|
|
262
|
+
|
|
263
|
+
self._register_component(cid, builder)
|
|
264
|
+
|
|
265
|
+
def json(self, body: Any, expanded=True):
|
|
266
|
+
"""Display JSON data"""
|
|
267
|
+
cid = self._get_next_cid("json")
|
|
268
|
+
json_str = json.dumps(body, indent=2, default=str)
|
|
269
|
+
html = f'''
|
|
270
|
+
<details {"open" if expanded else ""} style="background:var(--sl-bg-card);border:1px solid var(--sl-border);border-radius:0.5rem;padding:0.5rem;">
|
|
271
|
+
<summary style="cursor:pointer;font-size:0.875rem;color:var(--sl-text-muted);">JSON Data</summary>
|
|
272
|
+
<pre style="margin:0.5rem 0 0 0;font-size:0.875rem;color:var(--sl-primary);">{json_str}</pre>
|
|
273
|
+
</details>
|
|
274
|
+
'''
|
|
275
|
+
return Component("div", id=cid, content=html)
|
|
276
|
+
|
|
277
|
+
def heatmap(self, data: Union[dict, State, Callable],
|
|
278
|
+
start_date=None, end_date=None,
|
|
279
|
+
color_map=None, show_legend=True,
|
|
280
|
+
show_weekdays=True, show_months=True,
|
|
281
|
+
cell_size=12, gap=3, on_cell_clicked=None, **props):
|
|
282
|
+
"""
|
|
283
|
+
Display GitHub-style activity heatmap
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
data: Dict mapping date strings (YYYY-MM-DD) to values, or State/Callable
|
|
287
|
+
start_date: Start date (string or date object)
|
|
288
|
+
end_date: End date (string or date object)
|
|
289
|
+
color_map: Dict mapping values to colors
|
|
290
|
+
Example: {0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
|
|
291
|
+
show_legend: Show color legend
|
|
292
|
+
show_weekdays: Show weekday labels
|
|
293
|
+
show_months: Show month labels
|
|
294
|
+
cell_size: Size of each cell in pixels
|
|
295
|
+
gap: Gap between cells in pixels
|
|
296
|
+
on_cell_clicked: Callback for cell clicks
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
app.heatmap(
|
|
300
|
+
data={date: status for date, status in completions.items()},
|
|
301
|
+
start_date='2026-01-01',
|
|
302
|
+
end_date='2026-12-31',
|
|
303
|
+
color_map={0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
|
|
304
|
+
)
|
|
305
|
+
"""
|
|
306
|
+
from datetime import date as date_obj, timedelta
|
|
307
|
+
|
|
308
|
+
cid = self._get_next_cid("heatmap")
|
|
309
|
+
|
|
310
|
+
def action(v):
|
|
311
|
+
"""Handle cell click events"""
|
|
312
|
+
if on_cell_clicked and callable(on_cell_clicked):
|
|
313
|
+
on_cell_clicked(v)
|
|
314
|
+
|
|
315
|
+
def builder():
|
|
316
|
+
# Handle Signal/Callable
|
|
317
|
+
current_data = data
|
|
318
|
+
if isinstance(data, State):
|
|
319
|
+
token = rendering_ctx.set(cid)
|
|
320
|
+
current_data = data.value
|
|
321
|
+
rendering_ctx.reset(token)
|
|
322
|
+
elif callable(data):
|
|
323
|
+
token = rendering_ctx.set(cid)
|
|
324
|
+
current_data = data()
|
|
325
|
+
rendering_ctx.reset(token)
|
|
326
|
+
|
|
327
|
+
# Parse dates
|
|
328
|
+
if start_date:
|
|
329
|
+
if isinstance(start_date, str):
|
|
330
|
+
start = date_obj.fromisoformat(start_date)
|
|
331
|
+
else:
|
|
332
|
+
start = start_date
|
|
333
|
+
else:
|
|
334
|
+
start = date_obj.today().replace(month=1, day=1)
|
|
335
|
+
|
|
336
|
+
if end_date:
|
|
337
|
+
if isinstance(end_date, str):
|
|
338
|
+
end = date_obj.fromisoformat(end_date)
|
|
339
|
+
else:
|
|
340
|
+
end = end_date
|
|
341
|
+
else:
|
|
342
|
+
end = date_obj.today().replace(month=12, day=31)
|
|
343
|
+
|
|
344
|
+
# Default color map (use current_color_map to avoid variable shadowing)
|
|
345
|
+
current_color_map = color_map if color_map is not None else {
|
|
346
|
+
0: '#ebedf0',
|
|
347
|
+
1: '#10b981',
|
|
348
|
+
2: '#fbbf24'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# Adjust start to Sunday
|
|
352
|
+
start_day = start - timedelta(days=start.weekday() + 1 if start.weekday() != 6 else 0)
|
|
353
|
+
|
|
354
|
+
# Generate week data
|
|
355
|
+
weeks = []
|
|
356
|
+
current = start_day
|
|
357
|
+
|
|
358
|
+
while current <= end:
|
|
359
|
+
week = []
|
|
360
|
+
for _ in range(7):
|
|
361
|
+
if start <= current <= end:
|
|
362
|
+
date_str = current.isoformat()
|
|
363
|
+
value = current_data.get(date_str, 0)
|
|
364
|
+
week.append({'date': current, 'value': value, 'valid': True})
|
|
365
|
+
else:
|
|
366
|
+
week.append({'date': current, 'value': 0, 'valid': False})
|
|
367
|
+
current += timedelta(days=1)
|
|
368
|
+
weeks.append(week)
|
|
369
|
+
|
|
370
|
+
# CSS
|
|
371
|
+
css = f'''
|
|
372
|
+
<style>
|
|
373
|
+
.heatmap-{cid} {{
|
|
374
|
+
background: white;
|
|
375
|
+
padding: 1.5rem;
|
|
376
|
+
border-radius: 8px;
|
|
377
|
+
overflow-x: auto;
|
|
378
|
+
}}
|
|
379
|
+
.heatmap-{cid} .grid {{
|
|
380
|
+
display: flex;
|
|
381
|
+
gap: {gap}px;
|
|
382
|
+
}}
|
|
383
|
+
.heatmap-{cid} .weekdays {{
|
|
384
|
+
display: flex;
|
|
385
|
+
flex-direction: column;
|
|
386
|
+
gap: {gap}px;
|
|
387
|
+
padding-top: 20px;
|
|
388
|
+
margin-right: 4px;
|
|
389
|
+
}}
|
|
390
|
+
.heatmap-{cid} .day-label {{
|
|
391
|
+
height: {cell_size}px;
|
|
392
|
+
font-size: 9px;
|
|
393
|
+
color: #666;
|
|
394
|
+
display: flex;
|
|
395
|
+
align-items: center;
|
|
396
|
+
}}
|
|
397
|
+
.heatmap-{cid} .week {{
|
|
398
|
+
display: flex;
|
|
399
|
+
flex-direction: column;
|
|
400
|
+
gap: {gap}px;
|
|
401
|
+
}}
|
|
402
|
+
.heatmap-{cid} .month {{
|
|
403
|
+
height: 14px;
|
|
404
|
+
font-size: 9px;
|
|
405
|
+
color: #666;
|
|
406
|
+
text-align: center;
|
|
407
|
+
margin-bottom: 2px;
|
|
408
|
+
}}
|
|
409
|
+
.heatmap-{cid} .cell {{
|
|
410
|
+
width: {cell_size}px;
|
|
411
|
+
height: {cell_size}px;
|
|
412
|
+
border-radius: 2px;
|
|
413
|
+
border: 1px solid #fff;
|
|
414
|
+
cursor: pointer;
|
|
415
|
+
}}
|
|
416
|
+
.heatmap-{cid} .cell:hover {{
|
|
417
|
+
opacity: 0.8;
|
|
418
|
+
border: 1px solid #000;
|
|
419
|
+
}}
|
|
420
|
+
.heatmap-{cid} .cell.today {{
|
|
421
|
+
border: 2px solid #000;
|
|
422
|
+
}}
|
|
423
|
+
.heatmap-{cid} .legend {{
|
|
424
|
+
margin-top: 1rem;
|
|
425
|
+
display: flex;
|
|
426
|
+
gap: 1rem;
|
|
427
|
+
font-size: 11px;
|
|
428
|
+
color: #666;
|
|
429
|
+
align-items: center;
|
|
430
|
+
}}
|
|
431
|
+
.heatmap-{cid} .legend-item {{
|
|
432
|
+
display: flex;
|
|
433
|
+
align-items: center;
|
|
434
|
+
gap: 4px;
|
|
435
|
+
}}
|
|
436
|
+
</style>
|
|
437
|
+
'''
|
|
438
|
+
|
|
439
|
+
# Weekday labels
|
|
440
|
+
weekday_html = ''
|
|
441
|
+
if show_weekdays:
|
|
442
|
+
weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
443
|
+
weekday_html = f'''
|
|
444
|
+
<div class="weekdays">
|
|
445
|
+
{"".join(f'<div class="day-label">{d}</div>' for d in weekdays)}
|
|
446
|
+
</div>
|
|
447
|
+
'''
|
|
448
|
+
|
|
449
|
+
# Generate weeks HTML
|
|
450
|
+
today = date_obj.today()
|
|
451
|
+
current_month = None
|
|
452
|
+
weeks_html = []
|
|
453
|
+
|
|
454
|
+
for week in weeks:
|
|
455
|
+
# Month label
|
|
456
|
+
first_valid = next((d for d in week if d['valid']), None)
|
|
457
|
+
if first_valid and show_months:
|
|
458
|
+
month = first_valid['date'].month
|
|
459
|
+
month_label = f"{month}" if month != current_month else ""
|
|
460
|
+
current_month = month
|
|
461
|
+
else:
|
|
462
|
+
month_label = ""
|
|
463
|
+
|
|
464
|
+
# Cells
|
|
465
|
+
cells_html = []
|
|
466
|
+
for day in week:
|
|
467
|
+
if not day['valid']:
|
|
468
|
+
cells_html.append(f'<div style="width: {cell_size}px; height: {cell_size}px;"></div>')
|
|
469
|
+
else:
|
|
470
|
+
value = day['value']
|
|
471
|
+
bg_color = current_color_map.get(value, '#ebedf0')
|
|
472
|
+
is_today = day['date'] == today
|
|
473
|
+
today_class = ' today' if is_today else ''
|
|
474
|
+
date_str = day['date'].isoformat()
|
|
475
|
+
|
|
476
|
+
# Click handler
|
|
477
|
+
click_attr = ''
|
|
478
|
+
if on_cell_clicked:
|
|
479
|
+
click_js = f"window.sendAction('{cid}', {{date: '{date_str}', value: {value}}});" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify({{date: '{date_str}', value: {value}}})}}), swap: 'none'}});"
|
|
480
|
+
click_attr = f'onclick="{click_js}"'
|
|
481
|
+
|
|
482
|
+
cells_html.append(
|
|
483
|
+
f'<div class="cell{today_class}" style="background: {bg_color};" title="{date_str}" {click_attr}></div>'
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
weeks_html.append(f'''
|
|
487
|
+
<div class="week">
|
|
488
|
+
<div class="month">{month_label}</div>
|
|
489
|
+
{"".join(cells_html)}
|
|
490
|
+
</div>
|
|
491
|
+
''')
|
|
492
|
+
|
|
493
|
+
# Legend
|
|
494
|
+
legend_html = ''
|
|
495
|
+
if show_legend:
|
|
496
|
+
legend_items = [
|
|
497
|
+
f'''<div class="legend-item">
|
|
498
|
+
<div class="cell" style="background: {color}; border: 1px solid #ddd;"></div>
|
|
499
|
+
<span>{label}</span>
|
|
500
|
+
</div>'''
|
|
501
|
+
for label, color in [('None', current_color_map.get(0, '#ebedf0')),
|
|
502
|
+
('Done', current_color_map.get(1, '#10b981')),
|
|
503
|
+
('Skip', current_color_map.get(2, '#fbbf24'))]
|
|
504
|
+
if color in current_color_map.values()
|
|
505
|
+
]
|
|
506
|
+
legend_html = f'''
|
|
507
|
+
<div class="legend">
|
|
508
|
+
<span>Legend:</span>
|
|
509
|
+
{"".join(legend_items)}
|
|
510
|
+
</div>
|
|
511
|
+
'''
|
|
512
|
+
|
|
513
|
+
# Final HTML
|
|
514
|
+
html = f'''
|
|
515
|
+
{css}
|
|
516
|
+
<div class="heatmap-{cid}">
|
|
517
|
+
<div class="grid">
|
|
518
|
+
{weekday_html}
|
|
519
|
+
<div style="display: flex; gap: {gap}px;">
|
|
520
|
+
{"".join(weeks_html)}
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
{legend_html}
|
|
524
|
+
</div>
|
|
525
|
+
'''
|
|
526
|
+
|
|
527
|
+
return Component("div", id=cid, content=html)
|
|
528
|
+
|
|
529
|
+
self._register_component(cid, builder, action=action if on_cell_clicked else None)
|