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 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)