tkwebui 0.3.2__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.
- tkwebui/__init__.py +53 -0
- tkwebui/charts.py +496 -0
- tkwebui/core.py +1128 -0
- tkwebui/dataview.py +185 -0
- tkwebui/extras.py +525 -0
- tkwebui/feedback.py +179 -0
- tkwebui/forms.py +324 -0
- tkwebui/media.py +336 -0
- tkwebui/placeholder.py +174 -0
- tkwebui/popups.py +173 -0
- tkwebui/render.py +198 -0
- tkwebui/scrolling.py +73 -0
- tkwebui/table.py +369 -0
- tkwebui/tcl_bridge.py +23 -0
- tkwebui/widgets.py +301 -0
- tkwebui/window_chrome.py +494 -0
- tkwebui-0.3.2.dist-info/METADATA +75 -0
- tkwebui-0.3.2.dist-info/RECORD +20 -0
- tkwebui-0.3.2.dist-info/WHEEL +4 -0
- tkwebui-0.3.2.dist-info/licenses/LICENSE +21 -0
tkwebui/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""TkWebUI: modern Web-style theme and widgets for standard tkinter/ttk.
|
|
2
|
+
|
|
3
|
+
Standard library only. Call enable_dpi_awareness() before Tk(), then
|
|
4
|
+
apply_theme(root) before creating widgets:
|
|
5
|
+
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
from tkwebui import enable_dpi_awareness, apply_theme
|
|
8
|
+
|
|
9
|
+
enable_dpi_awareness()
|
|
10
|
+
root = tk.Tk()
|
|
11
|
+
apply_theme(root)
|
|
12
|
+
|
|
13
|
+
Every component can also be imported from its own module, e.g.
|
|
14
|
+
``from tkwebui.charts import LineChart``.
|
|
15
|
+
"""
|
|
16
|
+
__version__ = '0.3.2'
|
|
17
|
+
|
|
18
|
+
from .scrolling import AutoScrollbar
|
|
19
|
+
from .placeholder import set_placeholder, PlaceholderEntry
|
|
20
|
+
from .core import (PALETTE, THEME, enable_dpi_awareness, icon, apply_theme, Card, SegmentedControl,
|
|
21
|
+
Sidebar, ScrollArea, Tooltip, Dropdown, Accordion, SettingRow, BarChart, CardList,
|
|
22
|
+
ProgressSlider, Modal)
|
|
23
|
+
from .widgets import (RoundedText, SectionCard, ResponsiveTable, configure_table, auto_hide_text_scrollbar,
|
|
24
|
+
focus_when_mapped, initial_window_bounds, align_tree_scrollbar)
|
|
25
|
+
from .popups import ChoicePopup, ThemedCombobox, ThemedMenu
|
|
26
|
+
from .extras import SquareSwitch, ToggleButton, ToolButton, MenuButton, OptionMenu, RoundedListbox
|
|
27
|
+
from .table import SortableTreeview, enable_sorting, Spreadsheet
|
|
28
|
+
from .charts import LineChart, ColumnChart, ScatterChart, DonutChart, PieChart
|
|
29
|
+
from .forms import (Badge, ValidatedEntry, ResultDialog, MessageDialog, InputDialog, Dialogs, DateEntry,
|
|
30
|
+
CalendarPopup, ValueSlider)
|
|
31
|
+
from .feedback import ToastNotification, notify, TextProgress, Meter
|
|
32
|
+
from .dataview import DataTable
|
|
33
|
+
from .media import ColorPicker, ColorDialog, FontPicker, FontDialog, ImageViewer
|
|
34
|
+
from .window_chrome import WindowChrome
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
'AutoScrollbar', 'set_placeholder', 'PlaceholderEntry',
|
|
38
|
+
'PALETTE', 'THEME', 'enable_dpi_awareness', 'icon', 'apply_theme',
|
|
39
|
+
'Card', 'SegmentedControl', 'Sidebar', 'ScrollArea', 'Tooltip', 'Dropdown', 'Accordion', 'SettingRow',
|
|
40
|
+
'BarChart', 'CardList', 'ProgressSlider', 'Modal',
|
|
41
|
+
'RoundedText', 'SectionCard', 'ResponsiveTable', 'configure_table', 'auto_hide_text_scrollbar',
|
|
42
|
+
'focus_when_mapped', 'initial_window_bounds', 'align_tree_scrollbar',
|
|
43
|
+
'ChoicePopup', 'ThemedCombobox', 'ThemedMenu',
|
|
44
|
+
'SquareSwitch', 'ToggleButton', 'ToolButton', 'MenuButton', 'OptionMenu', 'RoundedListbox',
|
|
45
|
+
'SortableTreeview', 'enable_sorting', 'Spreadsheet',
|
|
46
|
+
'LineChart', 'ColumnChart', 'ScatterChart', 'DonutChart', 'PieChart',
|
|
47
|
+
'Badge', 'ValidatedEntry', 'ResultDialog', 'MessageDialog', 'InputDialog', 'Dialogs', 'DateEntry',
|
|
48
|
+
'CalendarPopup', 'ValueSlider',
|
|
49
|
+
'ToastNotification', 'notify', 'TextProgress', 'Meter',
|
|
50
|
+
'DataTable',
|
|
51
|
+
'ColorPicker', 'ColorDialog', 'FontPicker', 'FontDialog', 'ImageViewer',
|
|
52
|
+
'WindowChrome',
|
|
53
|
+
]
|
tkwebui/charts.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Interactive Canvas charts for small Tkinter tools. No third-party dependencies.
|
|
2
|
+
|
|
3
|
+
LineChart, ColumnChart, ScatterChart, DonutChart and PieChart accept Python lists.
|
|
4
|
+
Hover changes only overlay items. Data, view and resize redraws are coalesced.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import bisect
|
|
8
|
+
import copy
|
|
9
|
+
import math
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
from tkinter import ttk, font as tkfont
|
|
12
|
+
from .core import PALETTE
|
|
13
|
+
from .render import SmoothImage
|
|
14
|
+
|
|
15
|
+
COLORS = ('#087DFF', '#19A58B', '#E9A13B', '#8B68D9', '#DC708E', '#269DB2')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _number(value):
|
|
19
|
+
if value is None:return None
|
|
20
|
+
try:value=float(value)
|
|
21
|
+
except (TypeError,ValueError) as error:raise ValueError(f'图表需要数值,收到 {value!r}') from error
|
|
22
|
+
return value if math.isfinite(value) else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format(value):
|
|
26
|
+
if value==0:return '0'
|
|
27
|
+
if abs(value)>=1e6 or abs(value)<.001:return f'{value:.3g}'
|
|
28
|
+
return f'{value:,.3f}'.rstrip('0').rstrip('.') if value!=int(value) else f'{value:,.0f}'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ticks(low,high,count=5):
|
|
32
|
+
raw=(high-low)/max(1,count-1)
|
|
33
|
+
if not math.isfinite(raw) or raw<=0:return [low]
|
|
34
|
+
power=10**math.floor(math.log10(raw))
|
|
35
|
+
step=next((n*power for n in (1,2,2.5,5,10) if n*power>=raw),10*power)
|
|
36
|
+
first=math.ceil(low/step)*step
|
|
37
|
+
return [0.0 if abs(first+i*step)<step*1e-9 else first+i*step
|
|
38
|
+
for i in range(min(20,max(0,int((high-first)/step)+1)))]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clip_segment(x1,y1,x2,y2,rect):
|
|
42
|
+
left,top,right,bottom=rect;dx=x2-x1;dy=y2-y1;start,end=0.,1.
|
|
43
|
+
for p,q in ((-dx,x1-left),(dx,right-x1),(-dy,y1-top),(dy,bottom-y1)):
|
|
44
|
+
if p==0:
|
|
45
|
+
if q<0:return None
|
|
46
|
+
else:
|
|
47
|
+
value=q/p
|
|
48
|
+
if p<0:start=max(start,value)
|
|
49
|
+
else:end=min(end,value)
|
|
50
|
+
if start>end:return None
|
|
51
|
+
return x1+start*dx,y1+start*dy,x1+end*dx,y1+end*dy
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Chart(ttk.Frame):
|
|
55
|
+
def __init__(self,master,*,height=230,on_click=None,tooltip_formatter=None,**kwargs):
|
|
56
|
+
super().__init__(master,**kwargs)
|
|
57
|
+
self.scale=self.winfo_fpixels('1i')/96;self.px=lambda value:max(1,round(value*self.scale))
|
|
58
|
+
self.on_click=on_click;self.tooltip_formatter=tooltip_formatter
|
|
59
|
+
self.last_selection=None;self.hidden=set();self._closed=False
|
|
60
|
+
self._draw_job=None;self._hover_job=None;self._pointer=None;self._hover=None
|
|
61
|
+
self._highlighted=None;self._drag_start=None;self._previous_grab=None
|
|
62
|
+
self._aa_photo=None;self._marker_images={}
|
|
63
|
+
self.font=tkfont.Font(self,font='TkDefaultFont');self.font.configure(size=max(8,int(self.font.cget('size'))-1))
|
|
64
|
+
self.toolbar=ttk.Frame(self);self.toolbar.pack(fill='x',pady=(0,self.px(4)))
|
|
65
|
+
self.legend=ttk.Frame(self.toolbar);self.legend.pack(side='left',fill='x',expand=True)
|
|
66
|
+
self.legend.bind('<Configure>',self._layout_legend)
|
|
67
|
+
self._legend_labels=[]
|
|
68
|
+
self.canvas=tk.Canvas(self,height=self.px(height),width=self.px(240),background=PALETTE['bg'],
|
|
69
|
+
highlightthickness=0,borderwidth=0,takefocus=True)
|
|
70
|
+
self.canvas.pack(fill='both',expand=True)
|
|
71
|
+
self.canvas.bind('<Configure>',lambda e:self.schedule())
|
|
72
|
+
self.canvas.bind('<Motion>',self._motion)
|
|
73
|
+
self.canvas.bind('<Leave>',lambda e:self._clear_hover())
|
|
74
|
+
self.canvas.bind('<ButtonPress-1>',self._press)
|
|
75
|
+
self.canvas.bind('<ButtonRelease-1>',self._release)
|
|
76
|
+
self.canvas.bind('<B1-Motion>',self._drag)
|
|
77
|
+
self.bind('<Destroy>',self._cleanup,add='+')
|
|
78
|
+
|
|
79
|
+
def _set_legend(self,items):
|
|
80
|
+
for label in self._legend_labels:label.destroy()
|
|
81
|
+
self._legend_labels=[]
|
|
82
|
+
for name,color in items:
|
|
83
|
+
label=tk.Label(self.legend,text='● '+name,font=self.font,padx=self.px(5),pady=self.px(3),
|
|
84
|
+
background=PALETTE['bg'],foreground=PALETTE['muted'] if name in self.hidden else color,
|
|
85
|
+
cursor='hand2',takefocus=True,highlightthickness=1,
|
|
86
|
+
highlightbackground=PALETTE['bg'],highlightcolor=PALETTE['blue'])
|
|
87
|
+
label._series_name=name;label._series_color=color
|
|
88
|
+
label.bind('<Button-1>',lambda e,n=name:self.toggle_series(n))
|
|
89
|
+
label.bind('<space>',lambda e,n=name:self.toggle_series(n))
|
|
90
|
+
label.bind('<Return>',lambda e,n=name:self.toggle_series(n))
|
|
91
|
+
self._legend_labels.append(label)
|
|
92
|
+
self._layout_legend()
|
|
93
|
+
def _layout_legend(self,event=None):
|
|
94
|
+
available=max(self.px(120),self.legend.winfo_width());row=col=used=0
|
|
95
|
+
for label in self._legend_labels:
|
|
96
|
+
width=label.winfo_reqwidth()+self.px(4)
|
|
97
|
+
if used and used+width>available:row+=1;col=used=0
|
|
98
|
+
label.grid(row=row,column=col,sticky='w',padx=(0,self.px(4)))
|
|
99
|
+
col+=1;used+=width
|
|
100
|
+
def toggle_series(self,name):
|
|
101
|
+
if name not in {label._series_name for label in self._legend_labels}:raise KeyError(name)
|
|
102
|
+
if name in self.hidden:self.hidden.remove(name)
|
|
103
|
+
else:self.hidden.add(name)
|
|
104
|
+
for label in self._legend_labels:
|
|
105
|
+
label.configure(foreground=PALETTE['disabled'] if label._series_name in self.hidden else label._series_color)
|
|
106
|
+
self.schedule();self.event_generate('<<ChartSeriesChanged>>',when='tail')
|
|
107
|
+
return 'break'
|
|
108
|
+
def schedule(self):
|
|
109
|
+
if not self._closed and self._draw_job is None:self._draw_job=self.after(16,self._redraw)
|
|
110
|
+
def redraw(self):
|
|
111
|
+
if self._draw_job is not None:self.after_cancel(self._draw_job);self._draw_job=None
|
|
112
|
+
self._redraw()
|
|
113
|
+
def _redraw(self):
|
|
114
|
+
self._draw_job=None
|
|
115
|
+
if self._closed:return
|
|
116
|
+
self._clear_hover();self.canvas.delete('plot')
|
|
117
|
+
self._draw()
|
|
118
|
+
def _motion(self,event):
|
|
119
|
+
self._pointer=(event.x,event.y)
|
|
120
|
+
if self._hover_job is None:self._hover_job=self.after(16,self._update_hover)
|
|
121
|
+
def _update_hover(self):
|
|
122
|
+
self._hover_job=None
|
|
123
|
+
if self._closed or self._pointer is None:return
|
|
124
|
+
x,y=self._pointer;hit=self._hit(x,y)
|
|
125
|
+
if hit is None:self._clear_hover();return
|
|
126
|
+
self._clear_hover(cancel_job=False)
|
|
127
|
+
self._hover=hit
|
|
128
|
+
self._draw_highlight(hit)
|
|
129
|
+
if self.tooltip_formatter:text=str(self.tooltip_formatter(dict(hit['data'])))
|
|
130
|
+
else:text=self._tooltip_text(hit['data'])
|
|
131
|
+
# Keep the tooltip in this canvas; no Toplevel, images or global grab.
|
|
132
|
+
text_item=self.canvas.create_text(0,0,text=text,font=self.font,anchor='nw',fill=PALETTE['fg'],
|
|
133
|
+
width=max(self.px(60),self.canvas.winfo_width()-self.px(36)),tags='overlay')
|
|
134
|
+
bounds=self.canvas.bbox(text_item)
|
|
135
|
+
pad=self.px(8);width=bounds[2]-bounds[0]+2*pad;height=bounds[3]-bounds[1]+2*pad
|
|
136
|
+
left=max(2,min(x+self.px(14),self.canvas.winfo_width()-width-2))
|
|
137
|
+
top=max(2,min(y+self.px(16),self.canvas.winfo_height()-height-2))
|
|
138
|
+
box=self.canvas.create_rectangle(left,top,left+width,top+height,fill=PALETTE['bg'],outline='#CCD1DC',tags='overlay')
|
|
139
|
+
self.canvas.coords(text_item,left+pad,top+pad);self.canvas.tag_raise(text_item,box)
|
|
140
|
+
def _clear_hover(self,cancel_job=True):
|
|
141
|
+
if cancel_job and self._hover_job is not None:self.after_cancel(self._hover_job);self._hover_job=None
|
|
142
|
+
if self._highlighted is not None:
|
|
143
|
+
item,color=self._highlighted;self.canvas.itemconfigure(item,fill=color);self._highlighted=None
|
|
144
|
+
self.canvas.delete('overlay');self._hover=None
|
|
145
|
+
def _draw_highlight(self,hit):
|
|
146
|
+
if 'item' in hit:
|
|
147
|
+
item=hit['item'];self._highlighted=(item,hit['color'])
|
|
148
|
+
channels=self.winfo_rgb(hit['color'])
|
|
149
|
+
color='#'+''.join(f'{round(v/257*.84):02x}' for v in channels)
|
|
150
|
+
self.canvas.itemconfigure(item,fill=color)
|
|
151
|
+
def _tooltip_text(self,data):return str(data)
|
|
152
|
+
def _press(self,event):
|
|
153
|
+
self.canvas.focus_set();self._press_position=(event.x,event.y)
|
|
154
|
+
def _drag(self,event):pass
|
|
155
|
+
def _release(self,event):
|
|
156
|
+
hit=self._hit(event.x,event.y)
|
|
157
|
+
if hit is not None:self._select_hit(hit)
|
|
158
|
+
def _select_hit(self,hit):
|
|
159
|
+
self.last_selection=dict(hit['data'])
|
|
160
|
+
if self.on_click:self.on_click(dict(self.last_selection))
|
|
161
|
+
self.event_generate('<<ChartSelected>>',when='tail')
|
|
162
|
+
def _empty(self,text='暂无数据'):
|
|
163
|
+
self.canvas.create_text(self.canvas.winfo_width()/2,self.canvas.winfo_height()/2,
|
|
164
|
+
text=text,font=self.font,fill=PALETTE['muted'],tags='plot')
|
|
165
|
+
def _cleanup(self,event):
|
|
166
|
+
if event.widget is not self:return
|
|
167
|
+
self._closed=True
|
|
168
|
+
for job in (self._draw_job,self._hover_job):
|
|
169
|
+
if job is not None:self.after_cancel(job)
|
|
170
|
+
try:
|
|
171
|
+
if self._previous_grab is not None and self._previous_grab.winfo_exists():self._previous_grab.grab_set()
|
|
172
|
+
except tk.TclError:pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _CartesianChart(_Chart):
|
|
176
|
+
kind='line'
|
|
177
|
+
def __init__(self,master,*,series=None,values=None,labels=None,height=230,y_formatter=None,x_formatter=None,**kwargs):
|
|
178
|
+
super().__init__(master,height=height,**kwargs)
|
|
179
|
+
self.y_formatter=y_formatter or _format;self.x_formatter=x_formatter or _format
|
|
180
|
+
self.series=[];self.labels=[];self._view=None;self._plot=None
|
|
181
|
+
self._hits=[];self._line_index=[];self._buckets={};self._full_bounds=(0.,1.,0.,1.)
|
|
182
|
+
if self.kind!='bar':
|
|
183
|
+
ttk.Button(self.toolbar,text='重置',style='Quiet.TButton',command=self.reset_view).pack(side='right')
|
|
184
|
+
self.canvas.bind('<MouseWheel>',self._wheel)
|
|
185
|
+
self.canvas.bind('<Double-Button-1>',lambda e:self.reset_view())
|
|
186
|
+
self.canvas.bind('<Escape>',lambda e:self.reset_view())
|
|
187
|
+
self.set_data(series=series,values=values,labels=labels)
|
|
188
|
+
|
|
189
|
+
def set_data(self,series=None,*,values=None,labels=None,reset_view=True):
|
|
190
|
+
if series is not None and values is not None:raise ValueError('请只传 series 或 values 其中之一')
|
|
191
|
+
if series is None:series=[{'name':'数据','values':list(values or [])}]
|
|
192
|
+
parsed=[];names=set()
|
|
193
|
+
for index,item in enumerate(series):
|
|
194
|
+
name=str(item.get('name',f'系列 {index+1}'))
|
|
195
|
+
if name in names:raise ValueError('系列名称不能重复:'+name)
|
|
196
|
+
names.add(name)
|
|
197
|
+
color=item.get('color',PALETTE['accent'] if index==0 else COLORS[index%len(COLORS)])
|
|
198
|
+
self.winfo_rgb(color)
|
|
199
|
+
if 'points' in item:
|
|
200
|
+
if self.kind=='bar':raise ValueError('柱状图使用 values 和 labels,而不是 points')
|
|
201
|
+
points=[]
|
|
202
|
+
for point in item['points']:
|
|
203
|
+
if point is None:points.append(None);continue
|
|
204
|
+
if len(point)!=2:raise ValueError('points 中每个点都应是 (x, y)')
|
|
205
|
+
x,y=map(_number,point);points.append(None if x is None or y is None else (x,y))
|
|
206
|
+
else:points=[None if (y:=_number(value)) is None else (float(i),y) for i,value in enumerate(item.get('values',[]))]
|
|
207
|
+
parsed.append({'name':name,'color':color,'points':points})
|
|
208
|
+
self.series=parsed
|
|
209
|
+
if labels is not None:self.labels=[str(value) for value in labels]
|
|
210
|
+
self.hidden.intersection_update(names)
|
|
211
|
+
if reset_view:self._view=None
|
|
212
|
+
self._set_legend([(item['name'],item['color']) for item in self.series])
|
|
213
|
+
self.schedule()
|
|
214
|
+
def get_data(self):
|
|
215
|
+
if self.kind=='bar':
|
|
216
|
+
return [{'name':series['name'],'color':series['color'],
|
|
217
|
+
'values':[point[1] if point is not None else None for point in series['points']]} for series in self.series]
|
|
218
|
+
return copy.deepcopy(self.series)
|
|
219
|
+
def _bounds(self):
|
|
220
|
+
points=[point for series in self.series if series['name'] not in self.hidden for point in series['points'] if point is not None]
|
|
221
|
+
if not points:return None
|
|
222
|
+
xs,ys=zip(*points);xmin,xmax=min(xs),max(xs);ymin,ymax=min(ys),max(ys)
|
|
223
|
+
if self.kind=='bar':
|
|
224
|
+
xmin=-.5;xmax=max(len(series['points']) for series in self.series)-.5
|
|
225
|
+
ymin=min(0,ymin);ymax=max(0,ymax)
|
|
226
|
+
else:
|
|
227
|
+
xpad=(xmax-xmin)*.03 if xmax>xmin else max(1,abs(xmin)*.1)
|
|
228
|
+
xmin-=xpad;xmax+=xpad
|
|
229
|
+
ypad=(ymax-ymin)*.08 if ymax>ymin else max(1,abs(ymin)*.1)
|
|
230
|
+
return xmin,xmax,ymin-ypad,ymax+ypad
|
|
231
|
+
def _draw(self):
|
|
232
|
+
self._hits=[];self._line_index=[];self._buckets={}
|
|
233
|
+
bounds=self._bounds()
|
|
234
|
+
if bounds is None:
|
|
235
|
+
self._plot=None;self._empty('点击图例显示数据' if self.hidden else '暂无数据');return
|
|
236
|
+
self._full_bounds=bounds
|
|
237
|
+
if self._view is None:view=bounds
|
|
238
|
+
else:view=self._clamp_view(self._view);self._view=view
|
|
239
|
+
self._draw_bounds=view
|
|
240
|
+
xmin,xmax,ymin,ymax=view
|
|
241
|
+
yticks=_ticks(ymin,ymax)
|
|
242
|
+
left=max(self.px(42),max((self.font.measure(str(self.y_formatter(y))) for y in yticks),default=0)+self.px(10))
|
|
243
|
+
right=self.canvas.winfo_width()-self.px(12);top=self.px(12);bottom=self.canvas.winfo_height()-self.px(32)
|
|
244
|
+
if right-left<self.px(35) or bottom-top<self.px(35):self._plot=None;return
|
|
245
|
+
self._plot=(left,top,right,bottom)
|
|
246
|
+
for y in yticks:
|
|
247
|
+
sy=self._xy(xmin,y)[1]
|
|
248
|
+
self.canvas.create_line(left,sy,right,sy,fill=PALETTE['border'],dash=(2,4),tags='plot')
|
|
249
|
+
self.canvas.create_text(left-self.px(8),sy,text=self.y_formatter(y),anchor='e',font=self.font,fill=PALETTE['muted'],tags='plot')
|
|
250
|
+
if self.labels:
|
|
251
|
+
first=max(0,math.ceil(xmin));last=min(len(self.labels)-1,math.floor(xmax))
|
|
252
|
+
capacity=max(1,int((right-left)/self.px(64)));step=max(1,math.ceil((last-first+1)/capacity))
|
|
253
|
+
xticks=list(range(first,last+1,step))
|
|
254
|
+
else:xticks=_ticks(xmin,xmax,max(2,int((right-left)/self.px(80))))
|
|
255
|
+
for x in xticks:
|
|
256
|
+
sx=self._xy(x,ymin)[0]
|
|
257
|
+
label=self.labels[int(x)] if self.labels and int(x)==x and 0<=int(x)<len(self.labels) else str(self.x_formatter(x))
|
|
258
|
+
while self.font.measure(label)>self.px(85) and len(label)>3:label=label[:-2]+'…'
|
|
259
|
+
self.canvas.create_text(sx,bottom+self.px(16),text=label,font=self.font,fill=PALETTE['muted'],tags='plot')
|
|
260
|
+
self.canvas.create_line(left,bottom,right,bottom,fill=PALETTE['border'],tags='plot')
|
|
261
|
+
if ymin<0<ymax:
|
|
262
|
+
sy=self._xy(xmin,0)[1];self.canvas.create_line(left,sy,right,sy,fill='#BFC7D3',tags='plot')
|
|
263
|
+
visible=[item for item in self.series if item['name'] not in self.hidden]
|
|
264
|
+
if self.kind=='bar':
|
|
265
|
+
for series_index,series in enumerate(visible):self._draw_bars(series,series_index,len(visible))
|
|
266
|
+
else:
|
|
267
|
+
with SmoothImage(self,self.canvas.winfo_width(),self.canvas.winfo_height()) as surface:
|
|
268
|
+
self._smooth=surface
|
|
269
|
+
for series in visible:self._draw_points(series)
|
|
270
|
+
self._aa_photo=surface.photo()
|
|
271
|
+
self._smooth=None
|
|
272
|
+
self.canvas.create_image(0,0,image=self._aa_photo,anchor='nw',tags='plot')
|
|
273
|
+
def _xy(self,x,y):
|
|
274
|
+
left,top,right,bottom=self._plot;xmin,xmax,ymin,ymax=self._draw_bounds
|
|
275
|
+
return left+(x-xmin)/(xmax-xmin)*(right-left),bottom-(y-ymin)/(ymax-ymin)*(bottom-top)
|
|
276
|
+
def _data_at(self,x,y):
|
|
277
|
+
left,top,right,bottom=self._plot;xmin,xmax,ymin,ymax=self._draw_bounds
|
|
278
|
+
return xmin+(x-left)/(right-left)*(xmax-xmin),ymax-(y-top)/(bottom-top)*(ymax-ymin)
|
|
279
|
+
def _payload(self,series,index,point):
|
|
280
|
+
x,y=point
|
|
281
|
+
label=self.labels[int(x)] if int(x)==x and 0<=int(x)<len(self.labels) else str(self.x_formatter(x))
|
|
282
|
+
return {'series':series['name'],'index':index,'x':x,'y':y,'label':label,'kind':self.kind}
|
|
283
|
+
def _draw_points(self,series):
|
|
284
|
+
color=series['color'];last=None;path=[];lookup=[]
|
|
285
|
+
left,top,right,bottom=self._plot
|
|
286
|
+
show_markers=self.kind=='scatter' or len(series['points'])<=60
|
|
287
|
+
def flush():
|
|
288
|
+
if len(path)>=4:self._smooth.line(path,color,width=self.px(2))
|
|
289
|
+
path.clear()
|
|
290
|
+
for index,point in enumerate(series['points']):
|
|
291
|
+
if point is None:flush();last=None;continue
|
|
292
|
+
x,y=self._xy(*point)
|
|
293
|
+
if self.kind=='line' and last is not None:
|
|
294
|
+
clipped=_clip_segment(*last,x,y,self._plot)
|
|
295
|
+
if clipped:
|
|
296
|
+
if path and (abs(path[-2]-clipped[0])>1e-6 or abs(path[-1]-clipped[1])>1e-6):flush()
|
|
297
|
+
if not path:path.extend(clipped[:2])
|
|
298
|
+
path.extend(clipped[2:])
|
|
299
|
+
else:flush()
|
|
300
|
+
last=(x,y)
|
|
301
|
+
if not (left<=x<=right and top<=y<=bottom):continue
|
|
302
|
+
hit={'x':x,'y':y,'color':color,'data':self._payload(series,index,point)}
|
|
303
|
+
lookup.append(hit)
|
|
304
|
+
if show_markers:
|
|
305
|
+
radius=self.px(3 if self.kind=='line' else 3.5)
|
|
306
|
+
self._smooth.ellipse((x-radius,y-radius,x+radius,y+radius),fill=color,outline=PALETTE['bg'],width=1)
|
|
307
|
+
if self.kind=='scatter':
|
|
308
|
+
key=(int(x//self.px(24)),int(y//self.px(24)))
|
|
309
|
+
self._buckets.setdefault(key,[]).append(hit)
|
|
310
|
+
flush()
|
|
311
|
+
lookup.sort(key=lambda hit:hit['x'])
|
|
312
|
+
self._line_index.append(([hit['x'] for hit in lookup],lookup))
|
|
313
|
+
def _draw_bars(self,series,index,count):
|
|
314
|
+
left,top,right,bottom=self._plot
|
|
315
|
+
zero=self._xy(0,0)[1];slot=(right-left)/(self._draw_bounds[1]-self._draw_bounds[0])
|
|
316
|
+
group_width=slot*.72;bar_width=max(1,group_width/count-self.px(2))
|
|
317
|
+
for i,point in enumerate(series['points']):
|
|
318
|
+
if point is None:continue
|
|
319
|
+
x,y=self._xy(*point);x=x-group_width/2+index*group_width/count
|
|
320
|
+
y1=max(top,min(y,zero));y2=min(bottom,max(y,zero))
|
|
321
|
+
if y2-y1<1:y2=y1+1
|
|
322
|
+
item=self.canvas.create_rectangle(x,y1,x+bar_width,y2,fill=series['color'],outline='',tags='plot')
|
|
323
|
+
self._hits.append({'bbox':(x,y1,x+bar_width,y2),'x':x+bar_width/2,'y':y,
|
|
324
|
+
'item':item,'color':series['color'],'data':self._payload(series,i,point)})
|
|
325
|
+
def _inside(self,x,y):
|
|
326
|
+
return self._plot is not None and self._plot[0]<=x<=self._plot[2] and self._plot[1]<=y<=self._plot[3]
|
|
327
|
+
def _hit(self,x,y):
|
|
328
|
+
if not self._inside(x,y):return None
|
|
329
|
+
if self.kind=='bar':
|
|
330
|
+
return next((hit for hit in self._hits if hit['bbox'][0]<=x<=hit['bbox'][2] and hit['bbox'][1]-2<=y<=hit['bbox'][3]+2),None)
|
|
331
|
+
if self.kind=='scatter':
|
|
332
|
+
col,row=int(x//self.px(24)),int(y//self.px(24))
|
|
333
|
+
hits=[hit for dx in (-1,0,1) for dy in (-1,0,1) for hit in self._buckets.get((col+dx,row+dy),[])]
|
|
334
|
+
hit=min(hits,key=lambda h:(h['x']-x)**2+(h['y']-y)**2,default=None)
|
|
335
|
+
return hit if hit and math.hypot(hit['x']-x,hit['y']-y)<=self.px(14) else None
|
|
336
|
+
candidates=[]
|
|
337
|
+
for xs,hits in self._line_index:
|
|
338
|
+
index=bisect.bisect_left(xs,x)
|
|
339
|
+
candidates.extend(hits[i] for i in (index-1,index) if 0<=i<len(hits))
|
|
340
|
+
return min(candidates,key=lambda h:(abs(h['x']-x),abs(h['y']-y)),default=None)
|
|
341
|
+
def _draw_highlight(self,hit):
|
|
342
|
+
if self.kind=='bar':super()._draw_highlight(hit);return
|
|
343
|
+
left,top,right,bottom=self._plot;x,y=hit['x'],hit['y'];radius=self.px(5)
|
|
344
|
+
self.canvas.create_line(x,top,x,bottom,fill='#A9B4C4',dash=(3,3),tags='overlay')
|
|
345
|
+
self.canvas.create_line(left,y,right,y,fill='#CDD3DE',dash=(3,3),tags='overlay')
|
|
346
|
+
key=(hit['color'],radius)
|
|
347
|
+
if key not in self._marker_images:
|
|
348
|
+
size=2*radius+self.px(6);center=size/2
|
|
349
|
+
with SmoothImage(self,size,size) as surface:
|
|
350
|
+
surface.ellipse((center-radius,center-radius,center+radius,center+radius),fill=PALETTE['bg'],outline=hit['color'],width=self.px(2))
|
|
351
|
+
self._marker_images[key]=surface.photo()
|
|
352
|
+
self.canvas.create_image(x,y,image=self._marker_images[key],tags='overlay')
|
|
353
|
+
def _tooltip_text(self,data):
|
|
354
|
+
return f"{data['series']}\n{data['label']} · {self.y_formatter(data['y'])}"
|
|
355
|
+
def reset_view(self):
|
|
356
|
+
self._view=None;self.schedule();self.event_generate('<<ChartViewChanged>>',when='tail');return 'break'
|
|
357
|
+
def _clamp_view(self,view):
|
|
358
|
+
full=self._full_bounds;result=[]
|
|
359
|
+
for low,high,base_low,base_high in ((view[0],view[1],full[0],full[1]),(view[2],view[3],full[2],full[3])):
|
|
360
|
+
span=min(high-low,base_high-base_low)
|
|
361
|
+
low=max(base_low,min(low,base_high-span));result.extend((low,low+span))
|
|
362
|
+
return tuple(result)
|
|
363
|
+
def _wheel(self,event):
|
|
364
|
+
if not self._inside(event.x,event.y) or not event.delta:return
|
|
365
|
+
old=self._view or self._full_bounds;center=self._data_at(event.x,event.y)
|
|
366
|
+
factor=.82 if event.delta>0 else 1/.82
|
|
367
|
+
# Lines zoom horizontally by default; Shift zooms y. Scatter zooms both.
|
|
368
|
+
axes=(False,True) if event.state&1 else ((True,True) if self.kind=='scatter' else (True,False))
|
|
369
|
+
new=[]
|
|
370
|
+
for axis,(low,high) in enumerate(((old[0],old[1]),(old[2],old[3]))):
|
|
371
|
+
if axes[axis]:
|
|
372
|
+
span=(high-low)*factor;fullspan=self._full_bounds[axis*2+1]-self._full_bounds[axis*2]
|
|
373
|
+
span=max(fullspan/10000,min(span,fullspan));ratio=(center[axis]-low)/(high-low)
|
|
374
|
+
low=center[axis]-span*ratio;high=low+span
|
|
375
|
+
new.extend((low,high))
|
|
376
|
+
self._view=self._clamp_view(tuple(new));self.schedule();self.event_generate('<<ChartViewChanged>>',when='tail')
|
|
377
|
+
return 'break'
|
|
378
|
+
def _press(self,event):
|
|
379
|
+
super()._press(event)
|
|
380
|
+
if self.kind!='bar' and self._inside(event.x,event.y):
|
|
381
|
+
self._drag_start=(event.x,event.y,self._view or self._full_bounds)
|
|
382
|
+
self._previous_grab=self.canvas.grab_current();self.canvas.grab_set()
|
|
383
|
+
def _drag(self,event):
|
|
384
|
+
if self._drag_start is None:return
|
|
385
|
+
x,y,view=self._drag_start;left,top,right,bottom=self._plot
|
|
386
|
+
dx=(event.x-x)/(right-left)*(view[1]-view[0]);dy=(event.y-y)/(bottom-top)*(view[3]-view[2])
|
|
387
|
+
self._view=self._clamp_view((view[0]-dx,view[1]-dx,view[2]+dy,view[3]+dy))
|
|
388
|
+
self.schedule();return 'break'
|
|
389
|
+
def _release(self,event):
|
|
390
|
+
origin=getattr(self,'_press_position',(event.x,event.y));moved=math.hypot(event.x-origin[0],event.y-origin[1])>self.px(4)
|
|
391
|
+
self._drag_start=None
|
|
392
|
+
if self.canvas.grab_current() is self.canvas:
|
|
393
|
+
self.canvas.grab_release()
|
|
394
|
+
if self._previous_grab is not None and self._previous_grab.winfo_exists():self._previous_grab.grab_set()
|
|
395
|
+
self._previous_grab=None
|
|
396
|
+
if not moved:super()._release(event)
|
|
397
|
+
else:self.event_generate('<<ChartViewChanged>>',when='tail')
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class LineChart(_CartesianChart):
|
|
401
|
+
"""Line chart with gaps, multiple series, crosshair, zoom and pan."""
|
|
402
|
+
kind='line'
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class ColumnChart(_CartesianChart):
|
|
406
|
+
"""Grouped vertical bars, including negative values and multiple series."""
|
|
407
|
+
kind='bar'
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class ScatterChart(_CartesianChart):
|
|
411
|
+
"""Scatter plot with bucket-indexed hit testing, zoom and pan."""
|
|
412
|
+
kind='scatter'
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class DonutChart(_Chart):
|
|
416
|
+
def __init__(self,master,*,data=None,hole=.62,height=230,value_formatter=None,**kwargs):
|
|
417
|
+
super().__init__(master,height=height,**kwargs)
|
|
418
|
+
if not 0<=hole<=.85:raise ValueError('hole 应在 0 到 0.85 之间')
|
|
419
|
+
self.hole=hole;self.value_formatter=value_formatter or _format
|
|
420
|
+
self.data=[];self._slices=[];self._center=None;self._aa_item=None;self._lit_slice=None
|
|
421
|
+
self.set_data(data or [])
|
|
422
|
+
def set_hole(self,hole):
|
|
423
|
+
if not 0<=hole<=.85:raise ValueError('hole 应在 0 到 0.85 之间')
|
|
424
|
+
self.hole=hole;self.schedule()
|
|
425
|
+
def set_data(self,data):
|
|
426
|
+
parsed=[];names=set()
|
|
427
|
+
for index,item in enumerate(data):
|
|
428
|
+
if isinstance(item,dict):name=item['name'];value=item['value'];color=item.get('color',COLORS[index%len(COLORS)])
|
|
429
|
+
else:name,value=item;color=COLORS[index%len(COLORS)]
|
|
430
|
+
name=str(name);value=_number(value)
|
|
431
|
+
if value is None or value<0:raise ValueError('饼图 / 环形图需要有限的非负数值')
|
|
432
|
+
if name in names:raise ValueError('名称不能重复:'+name)
|
|
433
|
+
names.add(name);self.winfo_rgb(color)
|
|
434
|
+
parsed.append({'name':name,'value':value,'color':color})
|
|
435
|
+
self.data=parsed;self.hidden.intersection_update(names)
|
|
436
|
+
self._set_legend([(item['name'],item['color']) for item in parsed]);self.schedule()
|
|
437
|
+
def get_data(self):return copy.deepcopy(self.data)
|
|
438
|
+
def _draw(self):
|
|
439
|
+
self._slices=[];self._center=None
|
|
440
|
+
items=[item for item in self.data if item['name'] not in self.hidden and item['value']>0]
|
|
441
|
+
total=sum(item['value'] for item in items)
|
|
442
|
+
if not total:self._empty('点击图例显示数据' if self.hidden else '暂无数据');return
|
|
443
|
+
width,height=self.canvas.winfo_width(),self.canvas.winfo_height()
|
|
444
|
+
radius=max(1,min(width,height)/2-self.px(12));cx=width/2;cy=height/2
|
|
445
|
+
self._center=(cx,cy,radius);bounds=(cx-radius,cy-radius,cx+radius,cy+radius)
|
|
446
|
+
angle=0.
|
|
447
|
+
for item in items:
|
|
448
|
+
extent=item['value']/total*360
|
|
449
|
+
self._slices.append({'start':angle,'end':angle+extent,'color':item['color'],
|
|
450
|
+
'data':{'name':item['name'],'value':item['value'],'percent':item['value']/total*100,'kind':'donut' if self.hole else 'pie'}})
|
|
451
|
+
angle+=extent
|
|
452
|
+
self._paint_slices()
|
|
453
|
+
if self.hole:
|
|
454
|
+
inner=radius*self.hole
|
|
455
|
+
text=str(self.value_formatter(total))
|
|
456
|
+
self.canvas.create_text(cx,cy-self.px(7),text=text,font=(self.font.actual('family'),self.font.actual('size')+4,'bold'),fill=PALETTE['fg'],tags='plot')
|
|
457
|
+
self.canvas.create_text(cx,cy+self.px(17),text='合计',font=self.font,fill=PALETTE['muted'],tags='plot')
|
|
458
|
+
def _paint_slices(self,highlight=None):
|
|
459
|
+
if not self._slices or self._center is None:return
|
|
460
|
+
cx,cy,radius=self._center;pad=self.px(3);size=math.ceil(2*(radius+pad));center=size/2
|
|
461
|
+
bounds=(center-radius,center-radius,center+radius,center+radius)
|
|
462
|
+
with SmoothImage(self,size,size) as surface:
|
|
463
|
+
for item in self._slices:
|
|
464
|
+
color=item['color']
|
|
465
|
+
if item['data']['name']==highlight:color='#'+''.join(f'{round(v/257*.84):02x}' for v in self.winfo_rgb(color))
|
|
466
|
+
surface.pie(bounds,item['start']-90,item['end']-item['start'],color)
|
|
467
|
+
if len(self._slices)>1:
|
|
468
|
+
for item in self._slices:
|
|
469
|
+
angle=math.radians(item['start']-90)
|
|
470
|
+
surface.line([center,center,center+radius*math.cos(angle),center+radius*math.sin(angle)],PALETTE['bg'],self.px(2))
|
|
471
|
+
if self.hole:
|
|
472
|
+
inner=radius*self.hole;surface.ellipse((center-inner,center-inner,center+inner,center+inner),fill=PALETTE['bg'])
|
|
473
|
+
self._aa_photo=surface.photo()
|
|
474
|
+
if self._aa_item is not None and self.canvas.type(self._aa_item)=='image':
|
|
475
|
+
self.canvas.itemconfigure(self._aa_item,image=self._aa_photo);self.canvas.coords(self._aa_item,cx,cy)
|
|
476
|
+
else:self._aa_item=self.canvas.create_image(cx,cy,image=self._aa_photo,tags='plot')
|
|
477
|
+
self.canvas.tag_lower(self._aa_item)
|
|
478
|
+
def _draw_highlight(self,hit):
|
|
479
|
+
self._lit_slice=hit['data']['name'];self._paint_slices(self._lit_slice)
|
|
480
|
+
def _clear_hover(self,cancel_job=True):
|
|
481
|
+
if getattr(self,'_lit_slice',None) is not None:
|
|
482
|
+
self._lit_slice=None;self._paint_slices()
|
|
483
|
+
super()._clear_hover(cancel_job)
|
|
484
|
+
def _hit(self,x,y):
|
|
485
|
+
if self._center is None:return None
|
|
486
|
+
cx,cy,radius=self._center;distance=math.hypot(x-cx,y-cy)
|
|
487
|
+
if not radius*self.hole<=distance<=radius:return None
|
|
488
|
+
angle=(90-math.degrees(math.atan2(cy-y,x-cx)))%360
|
|
489
|
+
return next((item for item in self._slices if item['start']<=angle<item['end']),None)
|
|
490
|
+
def _tooltip_text(self,data):return f"{data['name']}\n{self.value_formatter(data['value'])} · {data['percent']:.1f}%"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class PieChart(DonutChart):
|
|
494
|
+
def __init__(self,master,**kwargs):
|
|
495
|
+
kwargs.setdefault('hole',0)
|
|
496
|
+
super().__init__(master,**kwargs)
|