| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185 |
- """
- GUI neutral widgets
- ===================
- Widgets that are designed to work for any of the GUI backends.
- All of these widgets require you to predefine an `~.axes.Axes`
- instance and pass that as the first parameter. Matplotlib doesn't try to
- be too smart with respect to layout -- you will have to figure out how
- wide and tall you want your Axes to be to accommodate your widget.
- """
- from contextlib import ExitStack
- import copy
- import itertools
- from numbers import Integral, Number
- from cycler import cycler
- import numpy as np
- import matplotlib as mpl
- from . import (_api, _docstring, backend_tools, cbook, collections, colors,
- text as mtext, ticker, transforms)
- from .lines import Line2D
- from .patches import Rectangle, Ellipse, Polygon
- from .transforms import TransformedPatchPath, Affine2D
- class LockDraw:
- """
- Some widgets, like the cursor, draw onto the canvas, and this is not
- desirable under all circumstances, like when the toolbar is in zoom-to-rect
- mode and drawing a rectangle. To avoid this, a widget can acquire a
- canvas' lock with ``canvas.widgetlock(widget)`` before drawing on the
- canvas; this will prevent other widgets from doing so at the same time (if
- they also try to acquire the lock first).
- """
- def __init__(self):
- self._owner = None
- def __call__(self, o):
- """Reserve the lock for *o*."""
- if not self.available(o):
- raise ValueError('already locked')
- self._owner = o
- def release(self, o):
- """Release the lock from *o*."""
- if not self.available(o):
- raise ValueError('you do not own this lock')
- self._owner = None
- def available(self, o):
- """Return whether drawing is available to *o*."""
- return not self.locked() or self.isowner(o)
- def isowner(self, o):
- """Return whether *o* owns this lock."""
- return self._owner is o
- def locked(self):
- """Return whether the lock is currently held by an owner."""
- return self._owner is not None
- class Widget:
- """
- Abstract base class for GUI neutral widgets.
- """
- drawon = True
- eventson = True
- _active = True
- def set_active(self, active):
- """Set whether the widget is active."""
- self._active = active
- def get_active(self):
- """Get whether the widget is active."""
- return self._active
- # set_active is overridden by SelectorWidgets.
- active = property(get_active, set_active, doc="Is the widget active?")
- def ignore(self, event):
- """
- Return whether *event* should be ignored.
- This method should be called at the beginning of any event callback.
- """
- return not self.active
- class AxesWidget(Widget):
- """
- Widget connected to a single `~matplotlib.axes.Axes`.
- To guarantee that the widget remains responsive and not garbage-collected,
- a reference to the object should be maintained by the user.
- This is necessary because the callback registry
- maintains only weak-refs to the functions, which are member
- functions of the widget. If there are no references to the widget
- object it may be garbage collected which will disconnect the callbacks.
- Attributes
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- canvas : `~matplotlib.backend_bases.FigureCanvasBase`
- The parent figure canvas for the widget.
- active : bool
- If False, the widget does not respond to events.
- """
- def __init__(self, ax):
- self.ax = ax
- self._cids = []
- canvas = property(lambda self: self.ax.get_figure(root=True).canvas)
- def connect_event(self, event, callback):
- """
- Connect a callback function with an event.
- This should be used in lieu of ``figure.canvas.mpl_connect`` since this
- function stores callback ids for later clean up.
- """
- cid = self.canvas.mpl_connect(event, callback)
- self._cids.append(cid)
- def disconnect_events(self):
- """Disconnect all events created by this widget."""
- for c in self._cids:
- self.canvas.mpl_disconnect(c)
- def _get_data_coords(self, event):
- """Return *event*'s data coordinates in this widget's Axes."""
- # This method handles the possibility that event.inaxes != self.ax (which may
- # occur if multiple Axes are overlaid), in which case event.xdata/.ydata will
- # be wrong. Note that we still special-case the common case where
- # event.inaxes == self.ax and avoid re-running the inverse data transform,
- # because that can introduce floating point errors for synthetic events.
- return ((event.xdata, event.ydata) if event.inaxes is self.ax
- else self.ax.transData.inverted().transform((event.x, event.y)))
- class Button(AxesWidget):
- """
- A GUI neutral button.
- For the button to remain responsive you must keep a reference to it.
- Call `.on_clicked` to connect to the button.
- Attributes
- ----------
- ax
- The `~.axes.Axes` the button renders into.
- label
- A `.Text` instance.
- color
- The color of the button when not hovering.
- hovercolor
- The color of the button when hovering.
- """
- def __init__(self, ax, label, image=None,
- color='0.85', hovercolor='0.95', *, useblit=True):
- """
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The `~.axes.Axes` instance the button will be placed into.
- label : str
- The button text.
- image : array-like or PIL Image
- The image to place in the button, if not *None*. The parameter is
- directly forwarded to `~.axes.Axes.imshow`.
- color : :mpltype:`color`
- The color of the button when not activated.
- hovercolor : :mpltype:`color`
- The color of the button when the mouse is over it.
- useblit : bool, default: True
- Use blitting for faster drawing if supported by the backend.
- See the tutorial :ref:`blitting` for details.
- .. versionadded:: 3.7
- """
- super().__init__(ax)
- if image is not None:
- ax.imshow(image)
- self.label = ax.text(0.5, 0.5, label,
- verticalalignment='center',
- horizontalalignment='center',
- transform=ax.transAxes)
- self._useblit = useblit and self.canvas.supports_blit
- self._observers = cbook.CallbackRegistry(signals=["clicked"])
- self.connect_event('button_press_event', self._click)
- self.connect_event('button_release_event', self._release)
- self.connect_event('motion_notify_event', self._motion)
- ax.set_navigate(False)
- ax.set_facecolor(color)
- ax.set_xticks([])
- ax.set_yticks([])
- self.color = color
- self.hovercolor = hovercolor
- def _click(self, event):
- if not self.eventson or self.ignore(event) or not self.ax.contains(event)[0]:
- return
- if event.canvas.mouse_grabber != self.ax:
- event.canvas.grab_mouse(self.ax)
- def _release(self, event):
- if self.ignore(event) or event.canvas.mouse_grabber != self.ax:
- return
- event.canvas.release_mouse(self.ax)
- if self.eventson and self.ax.contains(event)[0]:
- self._observers.process('clicked', event)
- def _motion(self, event):
- if self.ignore(event):
- return
- c = self.hovercolor if self.ax.contains(event)[0] else self.color
- if not colors.same_color(c, self.ax.get_facecolor()):
- self.ax.set_facecolor(c)
- if self.drawon:
- if self._useblit:
- self.ax.draw_artist(self.ax)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw()
- def on_clicked(self, func):
- """
- Connect the callback function *func* to button click events.
- Returns a connection id, which can be used to disconnect the callback.
- """
- return self._observers.connect('clicked', lambda event: func(event))
- def disconnect(self, cid):
- """Remove the callback function with connection id *cid*."""
- self._observers.disconnect(cid)
- class SliderBase(AxesWidget):
- """
- The base class for constructing Slider widgets. Not intended for direct
- usage.
- For the slider to remain responsive you must maintain a reference to it.
- """
- def __init__(self, ax, orientation, closedmin, closedmax,
- valmin, valmax, valfmt, dragging, valstep):
- if ax.name == '3d':
- raise ValueError('Sliders cannot be added to 3D Axes')
- super().__init__(ax)
- _api.check_in_list(['horizontal', 'vertical'], orientation=orientation)
- self.orientation = orientation
- self.closedmin = closedmin
- self.closedmax = closedmax
- self.valmin = valmin
- self.valmax = valmax
- self.valstep = valstep
- self.drag_active = False
- self.valfmt = valfmt
- if orientation == "vertical":
- ax.set_ylim((valmin, valmax))
- axis = ax.yaxis
- else:
- ax.set_xlim((valmin, valmax))
- axis = ax.xaxis
- self._fmt = axis.get_major_formatter()
- if not isinstance(self._fmt, ticker.ScalarFormatter):
- self._fmt = ticker.ScalarFormatter()
- self._fmt.set_axis(axis)
- self._fmt.set_useOffset(False) # No additive offset.
- self._fmt.set_useMathText(True) # x sign before multiplicative offset.
- ax.set_axis_off()
- ax.set_navigate(False)
- self.connect_event("button_press_event", self._update)
- self.connect_event("button_release_event", self._update)
- if dragging:
- self.connect_event("motion_notify_event", self._update)
- self._observers = cbook.CallbackRegistry(signals=["changed"])
- def _stepped_value(self, val):
- """Return *val* coerced to closest number in the ``valstep`` grid."""
- if isinstance(self.valstep, Number):
- val = (self.valmin
- + round((val - self.valmin) / self.valstep) * self.valstep)
- elif self.valstep is not None:
- valstep = np.asanyarray(self.valstep)
- if valstep.ndim != 1:
- raise ValueError(
- f"valstep must have 1 dimension but has {valstep.ndim}"
- )
- val = valstep[np.argmin(np.abs(valstep - val))]
- return val
- def disconnect(self, cid):
- """
- Remove the observer with connection id *cid*.
- Parameters
- ----------
- cid : int
- Connection id of the observer to be removed.
- """
- self._observers.disconnect(cid)
- def reset(self):
- """Reset the slider to the initial value."""
- if np.any(self.val != self.valinit):
- self.set_val(self.valinit)
- class Slider(SliderBase):
- """
- A slider representing a floating point range.
- Create a slider from *valmin* to *valmax* in Axes *ax*. For the slider to
- remain responsive you must maintain a reference to it. Call
- :meth:`on_changed` to connect to the slider event.
- Attributes
- ----------
- val : float
- Slider value.
- """
- def __init__(self, ax, label, valmin, valmax, *, valinit=0.5, valfmt=None,
- closedmin=True, closedmax=True, slidermin=None,
- slidermax=None, dragging=True, valstep=None,
- orientation='horizontal', initcolor='r',
- track_color='lightgrey', handle_style=None, **kwargs):
- """
- Parameters
- ----------
- ax : Axes
- The Axes to put the slider in.
- label : str
- Slider label.
- valmin : float
- The minimum value of the slider.
- valmax : float
- The maximum value of the slider.
- valinit : float, default: 0.5
- The slider initial position.
- valfmt : str, default: None
- %-format string used to format the slider value. If None, a
- `.ScalarFormatter` is used instead.
- closedmin : bool, default: True
- Whether the slider interval is closed on the bottom.
- closedmax : bool, default: True
- Whether the slider interval is closed on the top.
- slidermin : Slider, default: None
- Do not allow the current slider to have a value less than
- the value of the Slider *slidermin*.
- slidermax : Slider, default: None
- Do not allow the current slider to have a value greater than
- the value of the Slider *slidermax*.
- dragging : bool, default: True
- If True the slider can be dragged by the mouse.
- valstep : float or array-like, default: None
- If a float, the slider will snap to multiples of *valstep*.
- If an array the slider will snap to the values in the array.
- orientation : {'horizontal', 'vertical'}, default: 'horizontal'
- The orientation of the slider.
- initcolor : :mpltype:`color`, default: 'r'
- The color of the line at the *valinit* position. Set to ``'none'``
- for no line.
- track_color : :mpltype:`color`, default: 'lightgrey'
- The color of the background track. The track is accessible for
- further styling via the *track* attribute.
- handle_style : dict
- Properties of the slider handle. Default values are
- ========= ===== ======= ========================================
- Key Value Default Description
- ========= ===== ======= ========================================
- facecolor color 'white' The facecolor of the slider handle.
- edgecolor color '.75' The edgecolor of the slider handle.
- size int 10 The size of the slider handle in points.
- ========= ===== ======= ========================================
- Other values will be transformed as marker{foo} and passed to the
- `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will
- result in ``markerstyle = 'x'``.
- Notes
- -----
- Additional kwargs are passed on to ``self.poly`` which is the
- `~matplotlib.patches.Rectangle` that draws the slider knob. See the
- `.Rectangle` documentation for valid property names (``facecolor``,
- ``edgecolor``, ``alpha``, etc.).
- """
- super().__init__(ax, orientation, closedmin, closedmax,
- valmin, valmax, valfmt, dragging, valstep)
- if slidermin is not None and not hasattr(slidermin, 'val'):
- raise ValueError(
- f"Argument slidermin ({type(slidermin)}) has no 'val'")
- if slidermax is not None and not hasattr(slidermax, 'val'):
- raise ValueError(
- f"Argument slidermax ({type(slidermax)}) has no 'val'")
- self.slidermin = slidermin
- self.slidermax = slidermax
- valinit = self._value_in_bounds(valinit)
- if valinit is None:
- valinit = valmin
- self.val = valinit
- self.valinit = valinit
- defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10}
- handle_style = {} if handle_style is None else handle_style
- marker_props = {
- f'marker{k}': v for k, v in {**defaults, **handle_style}.items()
- }
- if orientation == 'vertical':
- self.track = Rectangle(
- (.25, 0), .5, 1,
- transform=ax.transAxes,
- facecolor=track_color
- )
- ax.add_patch(self.track)
- self.poly = ax.axhspan(valmin, valinit, .25, .75, **kwargs)
- # Drawing a longer line and clipping it to the track avoids
- # pixelation-related asymmetries.
- self.hline = ax.axhline(valinit, 0, 1, color=initcolor, lw=1,
- clip_path=TransformedPatchPath(self.track))
- handleXY = [[0.5], [valinit]]
- else:
- self.track = Rectangle(
- (0, .25), 1, .5,
- transform=ax.transAxes,
- facecolor=track_color
- )
- ax.add_patch(self.track)
- self.poly = ax.axvspan(valmin, valinit, .25, .75, **kwargs)
- self.vline = ax.axvline(valinit, 0, 1, color=initcolor, lw=1,
- clip_path=TransformedPatchPath(self.track))
- handleXY = [[valinit], [0.5]]
- self._handle, = ax.plot(
- *handleXY,
- "o",
- **marker_props,
- clip_on=False
- )
- if orientation == 'vertical':
- self.label = ax.text(0.5, 1.02, label, transform=ax.transAxes,
- verticalalignment='bottom',
- horizontalalignment='center')
- self.valtext = ax.text(0.5, -0.02, self._format(valinit),
- transform=ax.transAxes,
- verticalalignment='top',
- horizontalalignment='center')
- else:
- self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes,
- verticalalignment='center',
- horizontalalignment='right')
- self.valtext = ax.text(1.02, 0.5, self._format(valinit),
- transform=ax.transAxes,
- verticalalignment='center',
- horizontalalignment='left')
- self.set_val(valinit)
- def _value_in_bounds(self, val):
- """Makes sure *val* is with given bounds."""
- val = self._stepped_value(val)
- if val <= self.valmin:
- if not self.closedmin:
- return
- val = self.valmin
- elif val >= self.valmax:
- if not self.closedmax:
- return
- val = self.valmax
- if self.slidermin is not None and val <= self.slidermin.val:
- if not self.closedmin:
- return
- val = self.slidermin.val
- if self.slidermax is not None and val >= self.slidermax.val:
- if not self.closedmax:
- return
- val = self.slidermax.val
- return val
- def _update(self, event):
- """Update the slider position."""
- if self.ignore(event) or event.button != 1:
- return
- if event.name == 'button_press_event' and self.ax.contains(event)[0]:
- self.drag_active = True
- event.canvas.grab_mouse(self.ax)
- if not self.drag_active:
- return
- if (event.name == 'button_release_event'
- or event.name == 'button_press_event' and not self.ax.contains(event)[0]):
- self.drag_active = False
- event.canvas.release_mouse(self.ax)
- return
- xdata, ydata = self._get_data_coords(event)
- val = self._value_in_bounds(
- xdata if self.orientation == 'horizontal' else ydata)
- if val not in [None, self.val]:
- self.set_val(val)
- def _format(self, val):
- """Pretty-print *val*."""
- if self.valfmt is not None:
- return self.valfmt % val
- else:
- _, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
- # fmt.get_offset is actually the multiplicative factor, if any.
- return s + self._fmt.get_offset()
- def set_val(self, val):
- """
- Set slider value to *val*.
- Parameters
- ----------
- val : float
- """
- if self.orientation == 'vertical':
- self.poly.set_height(val - self.poly.get_y())
- self._handle.set_ydata([val])
- else:
- self.poly.set_width(val - self.poly.get_x())
- self._handle.set_xdata([val])
- self.valtext.set_text(self._format(val))
- if self.drawon:
- self.ax.get_figure(root=True).canvas.draw_idle()
- self.val = val
- if self.eventson:
- self._observers.process('changed', val)
- def on_changed(self, func):
- """
- Connect *func* as callback function to changes of the slider value.
- Parameters
- ----------
- func : callable
- Function to call when slider is changed.
- The function must accept a single float as its arguments.
- Returns
- -------
- int
- Connection id (which can be used to disconnect *func*).
- """
- return self._observers.connect('changed', lambda val: func(val))
- class RangeSlider(SliderBase):
- """
- A slider representing a range of floating point values. Defines the min and
- max of the range via the *val* attribute as a tuple of (min, max).
- Create a slider that defines a range contained within [*valmin*, *valmax*]
- in Axes *ax*. For the slider to remain responsive you must maintain a
- reference to it. Call :meth:`on_changed` to connect to the slider event.
- Attributes
- ----------
- val : tuple of float
- Slider value.
- """
- def __init__(
- self,
- ax,
- label,
- valmin,
- valmax,
- *,
- valinit=None,
- valfmt=None,
- closedmin=True,
- closedmax=True,
- dragging=True,
- valstep=None,
- orientation="horizontal",
- track_color='lightgrey',
- handle_style=None,
- **kwargs,
- ):
- """
- Parameters
- ----------
- ax : Axes
- The Axes to put the slider in.
- label : str
- Slider label.
- valmin : float
- The minimum value of the slider.
- valmax : float
- The maximum value of the slider.
- valinit : tuple of float or None, default: None
- The initial positions of the slider. If None the initial positions
- will be at the 25th and 75th percentiles of the range.
- valfmt : str, default: None
- %-format string used to format the slider values. If None, a
- `.ScalarFormatter` is used instead.
- closedmin : bool, default: True
- Whether the slider interval is closed on the bottom.
- closedmax : bool, default: True
- Whether the slider interval is closed on the top.
- dragging : bool, default: True
- If True the slider can be dragged by the mouse.
- valstep : float, default: None
- If given, the slider will snap to multiples of *valstep*.
- orientation : {'horizontal', 'vertical'}, default: 'horizontal'
- The orientation of the slider.
- track_color : :mpltype:`color`, default: 'lightgrey'
- The color of the background track. The track is accessible for
- further styling via the *track* attribute.
- handle_style : dict
- Properties of the slider handles. Default values are
- ========= ===== ======= =========================================
- Key Value Default Description
- ========= ===== ======= =========================================
- facecolor color 'white' The facecolor of the slider handles.
- edgecolor color '.75' The edgecolor of the slider handles.
- size int 10 The size of the slider handles in points.
- ========= ===== ======= =========================================
- Other values will be transformed as marker{foo} and passed to the
- `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will
- result in ``markerstyle = 'x'``.
- Notes
- -----
- Additional kwargs are passed on to ``self.poly`` which is the
- `~matplotlib.patches.Polygon` that draws the slider knob. See the
- `.Polygon` documentation for valid property names (``facecolor``,
- ``edgecolor``, ``alpha``, etc.).
- """
- super().__init__(ax, orientation, closedmin, closedmax,
- valmin, valmax, valfmt, dragging, valstep)
- # Set a value to allow _value_in_bounds() to work.
- self.val = (valmin, valmax)
- if valinit is None:
- # Place at the 25th and 75th percentiles
- extent = valmax - valmin
- valinit = np.array([valmin + extent * 0.25,
- valmin + extent * 0.75])
- else:
- valinit = self._value_in_bounds(valinit)
- self.val = valinit
- self.valinit = valinit
- defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10}
- handle_style = {} if handle_style is None else handle_style
- marker_props = {
- f'marker{k}': v for k, v in {**defaults, **handle_style}.items()
- }
- if orientation == "vertical":
- self.track = Rectangle(
- (.25, 0), .5, 2,
- transform=ax.transAxes,
- facecolor=track_color
- )
- ax.add_patch(self.track)
- poly_transform = self.ax.get_yaxis_transform(which="grid")
- handleXY_1 = [.5, valinit[0]]
- handleXY_2 = [.5, valinit[1]]
- else:
- self.track = Rectangle(
- (0, .25), 1, .5,
- transform=ax.transAxes,
- facecolor=track_color
- )
- ax.add_patch(self.track)
- poly_transform = self.ax.get_xaxis_transform(which="grid")
- handleXY_1 = [valinit[0], .5]
- handleXY_2 = [valinit[1], .5]
- self.poly = Polygon(np.zeros([5, 2]), **kwargs)
- self._update_selection_poly(*valinit)
- self.poly.set_transform(poly_transform)
- self.poly.get_path()._interpolation_steps = 100
- self.ax.add_patch(self.poly)
- self.ax._request_autoscale_view()
- self._handles = [
- ax.plot(
- *handleXY_1,
- "o",
- **marker_props,
- clip_on=False
- )[0],
- ax.plot(
- *handleXY_2,
- "o",
- **marker_props,
- clip_on=False
- )[0]
- ]
- if orientation == "vertical":
- self.label = ax.text(
- 0.5,
- 1.02,
- label,
- transform=ax.transAxes,
- verticalalignment="bottom",
- horizontalalignment="center",
- )
- self.valtext = ax.text(
- 0.5,
- -0.02,
- self._format(valinit),
- transform=ax.transAxes,
- verticalalignment="top",
- horizontalalignment="center",
- )
- else:
- self.label = ax.text(
- -0.02,
- 0.5,
- label,
- transform=ax.transAxes,
- verticalalignment="center",
- horizontalalignment="right",
- )
- self.valtext = ax.text(
- 1.02,
- 0.5,
- self._format(valinit),
- transform=ax.transAxes,
- verticalalignment="center",
- horizontalalignment="left",
- )
- self._active_handle = None
- self.set_val(valinit)
- def _update_selection_poly(self, vmin, vmax):
- """
- Update the vertices of the *self.poly* slider in-place
- to cover the data range *vmin*, *vmax*.
- """
- # The vertices are positioned
- # 1 ------ 2
- # | |
- # 0, 4 ---- 3
- verts = self.poly.xy
- if self.orientation == "vertical":
- verts[0] = verts[4] = .25, vmin
- verts[1] = .25, vmax
- verts[2] = .75, vmax
- verts[3] = .75, vmin
- else:
- verts[0] = verts[4] = vmin, .25
- verts[1] = vmin, .75
- verts[2] = vmax, .75
- verts[3] = vmax, .25
- def _min_in_bounds(self, min):
- """Ensure the new min value is between valmin and self.val[1]."""
- if min <= self.valmin:
- if not self.closedmin:
- return self.val[0]
- min = self.valmin
- if min > self.val[1]:
- min = self.val[1]
- return self._stepped_value(min)
- def _max_in_bounds(self, max):
- """Ensure the new max value is between valmax and self.val[0]."""
- if max >= self.valmax:
- if not self.closedmax:
- return self.val[1]
- max = self.valmax
- if max <= self.val[0]:
- max = self.val[0]
- return self._stepped_value(max)
- def _value_in_bounds(self, vals):
- """Clip min, max values to the bounds."""
- return (self._min_in_bounds(vals[0]), self._max_in_bounds(vals[1]))
- def _update_val_from_pos(self, pos):
- """Update the slider value based on a given position."""
- idx = np.argmin(np.abs(self.val - pos))
- if idx == 0:
- val = self._min_in_bounds(pos)
- self.set_min(val)
- else:
- val = self._max_in_bounds(pos)
- self.set_max(val)
- if self._active_handle:
- if self.orientation == "vertical":
- self._active_handle.set_ydata([val])
- else:
- self._active_handle.set_xdata([val])
- def _update(self, event):
- """Update the slider position."""
- if self.ignore(event) or event.button != 1:
- return
- if event.name == "button_press_event" and self.ax.contains(event)[0]:
- self.drag_active = True
- event.canvas.grab_mouse(self.ax)
- if not self.drag_active:
- return
- if (event.name == "button_release_event"
- or event.name == "button_press_event" and not self.ax.contains(event)[0]):
- self.drag_active = False
- event.canvas.release_mouse(self.ax)
- self._active_handle = None
- return
- # determine which handle was grabbed
- xdata, ydata = self._get_data_coords(event)
- handle_index = np.argmin(np.abs(
- [h.get_xdata()[0] - xdata for h in self._handles]
- if self.orientation == "horizontal" else
- [h.get_ydata()[0] - ydata for h in self._handles]))
- handle = self._handles[handle_index]
- # these checks ensure smooth behavior if the handles swap which one
- # has a higher value. i.e. if one is dragged over and past the other.
- if handle is not self._active_handle:
- self._active_handle = handle
- self._update_val_from_pos(xdata if self.orientation == "horizontal" else ydata)
- def _format(self, val):
- """Pretty-print *val*."""
- if self.valfmt is not None:
- return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
- else:
- _, s1, s2, _ = self._fmt.format_ticks(
- [self.valmin, *val, self.valmax]
- )
- # fmt.get_offset is actually the multiplicative factor, if any.
- s1 += self._fmt.get_offset()
- s2 += self._fmt.get_offset()
- # Use f string to avoid issues with backslashes when cast to a str
- return f"({s1}, {s2})"
- def set_min(self, min):
- """
- Set the lower value of the slider to *min*.
- Parameters
- ----------
- min : float
- """
- self.set_val((min, self.val[1]))
- def set_max(self, max):
- """
- Set the lower value of the slider to *max*.
- Parameters
- ----------
- max : float
- """
- self.set_val((self.val[0], max))
- def set_val(self, val):
- """
- Set slider value to *val*.
- Parameters
- ----------
- val : tuple or array-like of float
- """
- val = np.sort(val)
- _api.check_shape((2,), val=val)
- # Reset value to allow _value_in_bounds() to work.
- self.val = (self.valmin, self.valmax)
- vmin, vmax = self._value_in_bounds(val)
- self._update_selection_poly(vmin, vmax)
- if self.orientation == "vertical":
- self._handles[0].set_ydata([vmin])
- self._handles[1].set_ydata([vmax])
- else:
- self._handles[0].set_xdata([vmin])
- self._handles[1].set_xdata([vmax])
- self.valtext.set_text(self._format((vmin, vmax)))
- if self.drawon:
- self.ax.get_figure(root=True).canvas.draw_idle()
- self.val = (vmin, vmax)
- if self.eventson:
- self._observers.process("changed", (vmin, vmax))
- def on_changed(self, func):
- """
- Connect *func* as callback function to changes of the slider value.
- Parameters
- ----------
- func : callable
- Function to call when slider is changed. The function
- must accept a 2-tuple of floats as its argument.
- Returns
- -------
- int
- Connection id (which can be used to disconnect *func*).
- """
- return self._observers.connect('changed', lambda val: func(val))
- def _expand_text_props(props):
- props = cbook.normalize_kwargs(props, mtext.Text)
- return cycler(**props)() if props else itertools.repeat({})
- class CheckButtons(AxesWidget):
- r"""
- A GUI neutral set of check buttons.
- For the check buttons to remain responsive you must keep a
- reference to this object.
- Connect to the CheckButtons with the `.on_clicked` method.
- Attributes
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- labels : list of `~matplotlib.text.Text`
- The text label objects of the check buttons.
- """
- def __init__(self, ax, labels, actives=None, *, useblit=True,
- label_props=None, frame_props=None, check_props=None):
- """
- Add check buttons to `~.axes.Axes` instance *ax*.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- labels : list of str
- The labels of the check buttons.
- actives : list of bool, optional
- The initial check states of the buttons. The list must have the
- same length as *labels*. If not given, all buttons are unchecked.
- useblit : bool, default: True
- Use blitting for faster drawing if supported by the backend.
- See the tutorial :ref:`blitting` for details.
- .. versionadded:: 3.7
- label_props : dict of lists, optional
- Dictionary of `.Text` properties to be used for the labels. Each
- dictionary value should be a list of at least a single element. If
- the list is of length M, its values are cycled such that the Nth
- label gets the (N mod M) property.
- .. versionadded:: 3.7
- frame_props : dict, optional
- Dictionary of scatter `.Collection` properties to be used for the
- check button frame. Defaults (label font size / 2)**2 size, black
- edgecolor, no facecolor, and 1.0 linewidth.
- .. versionadded:: 3.7
- check_props : dict, optional
- Dictionary of scatter `.Collection` properties to be used for the
- check button check. Defaults to (label font size / 2)**2 size,
- black color, and 1.0 linewidth.
- .. versionadded:: 3.7
- """
- super().__init__(ax)
- _api.check_isinstance((dict, None), label_props=label_props,
- frame_props=frame_props, check_props=check_props)
- ax.set_xticks([])
- ax.set_yticks([])
- ax.set_navigate(False)
- if actives is None:
- actives = [False] * len(labels)
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
- ys = np.linspace(1, 0, len(labels)+2)[1:-1]
- label_props = _expand_text_props(label_props)
- self.labels = [
- ax.text(0.25, y, label, transform=ax.transAxes,
- horizontalalignment="left", verticalalignment="center",
- **props)
- for y, label, props in zip(ys, labels, label_props)]
- text_size = np.array([text.get_fontsize() for text in self.labels]) / 2
- frame_props = {
- 's': text_size**2,
- 'linewidth': 1,
- **cbook.normalize_kwargs(frame_props, collections.PathCollection),
- 'marker': 's',
- 'transform': ax.transAxes,
- }
- frame_props.setdefault('facecolor', frame_props.get('color', 'none'))
- frame_props.setdefault('edgecolor', frame_props.pop('color', 'black'))
- self._frames = ax.scatter([0.15] * len(ys), ys, **frame_props)
- check_props = {
- 'linewidth': 1,
- 's': text_size**2,
- **cbook.normalize_kwargs(check_props, collections.PathCollection),
- 'marker': 'x',
- 'transform': ax.transAxes,
- 'animated': self._useblit,
- }
- check_props.setdefault('facecolor', check_props.pop('color', 'black'))
- self._checks = ax.scatter([0.15] * len(ys), ys, **check_props)
- # The user may have passed custom colours in check_props, so we need to
- # create the checks (above), and modify the visibility after getting
- # whatever the user set.
- self._init_status(actives)
- self.connect_event('button_press_event', self._clicked)
- if self._useblit:
- self.connect_event('draw_event', self._clear)
- self._observers = cbook.CallbackRegistry(signals=["clicked"])
- def _clear(self, event):
- """Internal event handler to clear the buttons."""
- if self.ignore(event) or self.canvas.is_saving():
- return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
- self.ax.draw_artist(self._checks)
- def _clicked(self, event):
- if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
- return
- idxs = [ # Indices of frames and of texts that contain the event.
- *self._frames.contains(event)[1]["ind"],
- *[i for i, text in enumerate(self.labels) if text.contains(event)[0]]]
- if idxs:
- coords = self._frames.get_offset_transform().transform(
- self._frames.get_offsets())
- self.set_active( # Closest index, only looking in idxs.
- idxs[(((event.x, event.y) - coords[idxs]) ** 2).sum(-1).argmin()])
- def set_label_props(self, props):
- """
- Set properties of the `.Text` labels.
- .. versionadded:: 3.7
- Parameters
- ----------
- props : dict
- Dictionary of `.Text` properties to be used for the labels. Same
- format as label_props argument of :class:`CheckButtons`.
- """
- _api.check_isinstance(dict, props=props)
- props = _expand_text_props(props)
- for text, prop in zip(self.labels, props):
- text.update(prop)
- def set_frame_props(self, props):
- """
- Set properties of the check button frames.
- .. versionadded:: 3.7
- Parameters
- ----------
- props : dict
- Dictionary of `.Collection` properties to be used for the check
- button frames.
- """
- _api.check_isinstance(dict, props=props)
- if 's' in props: # Keep API consistent with constructor.
- props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels))
- self._frames.update(props)
- def set_check_props(self, props):
- """
- Set properties of the check button checks.
- .. versionadded:: 3.7
- Parameters
- ----------
- props : dict
- Dictionary of `.Collection` properties to be used for the check
- button check.
- """
- _api.check_isinstance(dict, props=props)
- if 's' in props: # Keep API consistent with constructor.
- props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels))
- actives = self.get_status()
- self._checks.update(props)
- # If new colours are supplied, then we must re-apply the status.
- self._init_status(actives)
- def set_active(self, index, state=None):
- """
- Modify the state of a check button by index.
- Callbacks will be triggered if :attr:`eventson` is True.
- Parameters
- ----------
- index : int
- Index of the check button to toggle.
- state : bool, optional
- If a boolean value, set the state explicitly. If no value is
- provided, the state is toggled.
- Raises
- ------
- ValueError
- If *index* is invalid.
- TypeError
- If *state* is not boolean.
- """
- if index not in range(len(self.labels)):
- raise ValueError(f'Invalid CheckButton index: {index}')
- _api.check_isinstance((bool, None), state=state)
- invisible = colors.to_rgba('none')
- facecolors = self._checks.get_facecolor()
- if state is None:
- state = colors.same_color(facecolors[index], invisible)
- facecolors[index] = self._active_check_colors[index] if state else invisible
- self._checks.set_facecolor(facecolors)
- if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
- self.ax.draw_artist(self._checks)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw()
- if self.eventson:
- self._observers.process('clicked', self.labels[index].get_text())
- def _init_status(self, actives):
- """
- Initialize properties to match active status.
- The user may have passed custom colours in *check_props* to the
- constructor, or to `.set_check_props`, so we need to modify the
- visibility after getting whatever the user set.
- """
- self._active_check_colors = self._checks.get_facecolor()
- if len(self._active_check_colors) == 1:
- self._active_check_colors = np.repeat(self._active_check_colors,
- len(actives), axis=0)
- self._checks.set_facecolor(
- [ec if active else "none"
- for ec, active in zip(self._active_check_colors, actives)])
- def clear(self):
- """Uncheck all checkboxes."""
- self._checks.set_facecolor(['none'] * len(self._active_check_colors))
- if hasattr(self, '_lines'):
- for l1, l2 in self._lines:
- l1.set_visible(False)
- l2.set_visible(False)
- if self.drawon:
- self.canvas.draw()
- if self.eventson:
- # Call with no label, as all checkboxes are being cleared.
- self._observers.process('clicked', None)
- def get_status(self):
- """
- Return a list of the status (True/False) of all of the check buttons.
- """
- return [not colors.same_color(color, colors.to_rgba("none"))
- for color in self._checks.get_facecolors()]
- def get_checked_labels(self):
- """Return a list of labels currently checked by user."""
- return [l.get_text() for l, box_checked in
- zip(self.labels, self.get_status())
- if box_checked]
- def on_clicked(self, func):
- """
- Connect the callback function *func* to button click events.
- Parameters
- ----------
- func : callable
- When the button is clicked, call *func* with button label.
- When all buttons are cleared, call *func* with None.
- The callback func must have the signature::
- def func(label: str | None) -> Any
- Return values may exist, but are ignored.
- Returns
- -------
- A connection id, which can be used to disconnect the callback.
- """
- return self._observers.connect('clicked', lambda text: func(text))
- def disconnect(self, cid):
- """Remove the observer with connection id *cid*."""
- self._observers.disconnect(cid)
- class TextBox(AxesWidget):
- """
- A GUI neutral text input box.
- For the text box to remain responsive you must keep a reference to it.
- Call `.on_text_change` to be updated whenever the text changes.
- Call `.on_submit` to be updated whenever the user hits enter or
- leaves the text entry field.
- Attributes
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- label : `~matplotlib.text.Text`
- color : :mpltype:`color`
- The color of the text box when not hovering.
- hovercolor : :mpltype:`color`
- The color of the text box when hovering.
- """
- def __init__(self, ax, label, initial='', *,
- color='.95', hovercolor='1', label_pad=.01,
- textalignment="left"):
- """
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The `~.axes.Axes` instance the button will be placed into.
- label : str
- Label for this text box.
- initial : str
- Initial value in the text box.
- color : :mpltype:`color`
- The color of the box.
- hovercolor : :mpltype:`color`
- The color of the box when the mouse is over it.
- label_pad : float
- The distance between the label and the right side of the textbox.
- textalignment : {'left', 'center', 'right'}
- The horizontal location of the text.
- """
- super().__init__(ax)
- self._text_position = _api.check_getitem(
- {"left": 0.05, "center": 0.5, "right": 0.95},
- textalignment=textalignment)
- self.label = ax.text(
- -label_pad, 0.5, label, transform=ax.transAxes,
- verticalalignment='center', horizontalalignment='right')
- # TextBox's text object should not parse mathtext at all.
- self.text_disp = self.ax.text(
- self._text_position, 0.5, initial, transform=self.ax.transAxes,
- verticalalignment='center', horizontalalignment=textalignment,
- parse_math=False)
- self._observers = cbook.CallbackRegistry(signals=["change", "submit"])
- ax.set(
- xlim=(0, 1), ylim=(0, 1), # s.t. cursor appears from first click.
- navigate=False, facecolor=color,
- xticks=[], yticks=[])
- self.cursor_index = 0
- self.cursor = ax.vlines(0, 0, 0, visible=False, color="k", lw=1,
- transform=mpl.transforms.IdentityTransform())
- self.connect_event('button_press_event', self._click)
- self.connect_event('button_release_event', self._release)
- self.connect_event('motion_notify_event', self._motion)
- self.connect_event('key_press_event', self._keypress)
- self.connect_event('resize_event', self._resize)
- self.color = color
- self.hovercolor = hovercolor
- self.capturekeystrokes = False
- @property
- def text(self):
- return self.text_disp.get_text()
- def _rendercursor(self):
- # this is a hack to figure out where the cursor should go.
- # we draw the text up to where the cursor should go, measure
- # and save its dimensions, draw the real text, then put the cursor
- # at the saved dimensions
- # This causes a single extra draw if the figure has never been rendered
- # yet, which should be fine as we're going to repeatedly re-render the
- # figure later anyways.
- fig = self.ax.get_figure(root=True)
- if fig._get_renderer() is None:
- fig.canvas.draw()
- text = self.text_disp.get_text() # Save value before overwriting it.
- widthtext = text[:self.cursor_index]
- bb_text = self.text_disp.get_window_extent()
- self.text_disp.set_text(widthtext or ",")
- bb_widthtext = self.text_disp.get_window_extent()
- if bb_text.y0 == bb_text.y1: # Restoring the height if no text.
- bb_text.y0 -= bb_widthtext.height / 2
- bb_text.y1 += bb_widthtext.height / 2
- elif not widthtext: # Keep width to 0.
- bb_text.x1 = bb_text.x0
- else: # Move the cursor using width of bb_widthtext.
- bb_text.x1 = bb_text.x0 + bb_widthtext.width
- self.cursor.set(
- segments=[[(bb_text.x1, bb_text.y0), (bb_text.x1, bb_text.y1)]],
- visible=True)
- self.text_disp.set_text(text)
- fig.canvas.draw()
- def _release(self, event):
- if self.ignore(event):
- return
- if event.canvas.mouse_grabber != self.ax:
- return
- event.canvas.release_mouse(self.ax)
- def _keypress(self, event):
- if self.ignore(event):
- return
- if self.capturekeystrokes:
- key = event.key
- text = self.text
- if len(key) == 1:
- text = (text[:self.cursor_index] + key +
- text[self.cursor_index:])
- self.cursor_index += 1
- elif key == "right":
- if self.cursor_index != len(text):
- self.cursor_index += 1
- elif key == "left":
- if self.cursor_index != 0:
- self.cursor_index -= 1
- elif key == "home":
- self.cursor_index = 0
- elif key == "end":
- self.cursor_index = len(text)
- elif key == "backspace":
- if self.cursor_index != 0:
- text = (text[:self.cursor_index - 1] +
- text[self.cursor_index:])
- self.cursor_index -= 1
- elif key == "delete":
- if self.cursor_index != len(self.text):
- text = (text[:self.cursor_index] +
- text[self.cursor_index + 1:])
- self.text_disp.set_text(text)
- self._rendercursor()
- if self.eventson:
- self._observers.process('change', self.text)
- if key in ["enter", "return"]:
- self._observers.process('submit', self.text)
- def set_val(self, val):
- newval = str(val)
- if self.text == newval:
- return
- self.text_disp.set_text(newval)
- self._rendercursor()
- if self.eventson:
- self._observers.process('change', self.text)
- self._observers.process('submit', self.text)
- def begin_typing(self):
- self.capturekeystrokes = True
- # Disable keypress shortcuts, which may otherwise cause the figure to
- # be saved, closed, etc., until the user stops typing. The way to
- # achieve this depends on whether toolmanager is in use.
- stack = ExitStack() # Register cleanup actions when user stops typing.
- self._on_stop_typing = stack.close
- toolmanager = getattr(
- self.ax.get_figure(root=True).canvas.manager, "toolmanager", None)
- if toolmanager is not None:
- # If using toolmanager, lock keypresses, and plan to release the
- # lock when typing stops.
- toolmanager.keypresslock(self)
- stack.callback(toolmanager.keypresslock.release, self)
- else:
- # If not using toolmanager, disable all keypress-related rcParams.
- # Avoid spurious warnings if keymaps are getting deprecated.
- with _api.suppress_matplotlib_deprecation_warning():
- stack.enter_context(mpl.rc_context(
- {k: [] for k in mpl.rcParams if k.startswith("keymap.")}))
- def stop_typing(self):
- if self.capturekeystrokes:
- self._on_stop_typing()
- self._on_stop_typing = None
- notifysubmit = True
- else:
- notifysubmit = False
- self.capturekeystrokes = False
- self.cursor.set_visible(False)
- self.ax.get_figure(root=True).canvas.draw()
- if notifysubmit and self.eventson:
- # Because process() might throw an error in the user's code, only
- # call it once we've already done our cleanup.
- self._observers.process('submit', self.text)
- def _click(self, event):
- if self.ignore(event):
- return
- if not self.ax.contains(event)[0]:
- self.stop_typing()
- return
- if not self.eventson:
- return
- if event.canvas.mouse_grabber != self.ax:
- event.canvas.grab_mouse(self.ax)
- if not self.capturekeystrokes:
- self.begin_typing()
- self.cursor_index = self.text_disp._char_index_at(event.x)
- self._rendercursor()
- def _resize(self, event):
- self.stop_typing()
- def _motion(self, event):
- if self.ignore(event):
- return
- c = self.hovercolor if self.ax.contains(event)[0] else self.color
- if not colors.same_color(c, self.ax.get_facecolor()):
- self.ax.set_facecolor(c)
- if self.drawon:
- self.ax.get_figure(root=True).canvas.draw()
- def on_text_change(self, func):
- """
- When the text changes, call this *func* with event.
- A connection id is returned which can be used to disconnect.
- """
- return self._observers.connect('change', lambda text: func(text))
- def on_submit(self, func):
- """
- When the user hits enter or leaves the submission box, call this
- *func* with event.
- A connection id is returned which can be used to disconnect.
- """
- return self._observers.connect('submit', lambda text: func(text))
- def disconnect(self, cid):
- """Remove the observer with connection id *cid*."""
- self._observers.disconnect(cid)
- class RadioButtons(AxesWidget):
- """
- A GUI neutral radio button.
- For the buttons to remain responsive you must keep a reference to this
- object.
- Connect to the RadioButtons with the `.on_clicked` method.
- Attributes
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- activecolor : :mpltype:`color`
- The color of the selected button.
- labels : list of `.Text`
- The button labels.
- value_selected : str
- The label text of the currently selected button.
- index_selected : int
- The index of the selected button.
- """
- def __init__(self, ax, labels, active=0, activecolor=None, *,
- useblit=True, label_props=None, radio_props=None):
- """
- Add radio buttons to an `~.axes.Axes`.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The Axes to add the buttons to.
- labels : list of str
- The button labels.
- active : int
- The index of the initially selected button.
- activecolor : :mpltype:`color`
- The color of the selected button. The default is ``'blue'`` if not
- specified here or in *radio_props*.
- useblit : bool, default: True
- Use blitting for faster drawing if supported by the backend.
- See the tutorial :ref:`blitting` for details.
- .. versionadded:: 3.7
- label_props : dict of lists, optional
- Dictionary of `.Text` properties to be used for the labels. Each
- dictionary value should be a list of at least a single element. If
- the list is of length M, its values are cycled such that the Nth
- label gets the (N mod M) property.
- .. versionadded:: 3.7
- radio_props : dict, optional
- Dictionary of scatter `.Collection` properties to be used for the
- radio buttons. Defaults to (label font size / 2)**2 size, black
- edgecolor, and *activecolor* facecolor (when active).
- .. note::
- If a facecolor is supplied in *radio_props*, it will override
- *activecolor*. This may be used to provide an active color per
- button.
- .. versionadded:: 3.7
- """
- super().__init__(ax)
- _api.check_isinstance((dict, None), label_props=label_props,
- radio_props=radio_props)
- radio_props = cbook.normalize_kwargs(radio_props,
- collections.PathCollection)
- if activecolor is not None:
- if 'facecolor' in radio_props:
- _api.warn_external(
- 'Both the *activecolor* parameter and the *facecolor* '
- 'key in the *radio_props* parameter has been specified. '
- '*activecolor* will be ignored.')
- else:
- activecolor = 'blue' # Default.
- self._activecolor = activecolor
- self._initial_active = active
- self.value_selected = labels[active]
- self.index_selected = active
- ax.set_xticks([])
- ax.set_yticks([])
- ax.set_navigate(False)
- ys = np.linspace(1, 0, len(labels) + 2)[1:-1]
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
- label_props = _expand_text_props(label_props)
- self.labels = [
- ax.text(0.25, y, label, transform=ax.transAxes,
- horizontalalignment="left", verticalalignment="center",
- **props)
- for y, label, props in zip(ys, labels, label_props)]
- text_size = np.array([text.get_fontsize() for text in self.labels]) / 2
- radio_props = {
- 's': text_size**2,
- **radio_props,
- 'marker': 'o',
- 'transform': ax.transAxes,
- 'animated': self._useblit,
- }
- radio_props.setdefault('edgecolor', radio_props.get('color', 'black'))
- radio_props.setdefault('facecolor',
- radio_props.pop('color', activecolor))
- self._buttons = ax.scatter([.15] * len(ys), ys, **radio_props)
- # The user may have passed custom colours in radio_props, so we need to
- # create the radios, and modify the visibility after getting whatever
- # the user set.
- self._active_colors = self._buttons.get_facecolor()
- if len(self._active_colors) == 1:
- self._active_colors = np.repeat(self._active_colors, len(labels),
- axis=0)
- self._buttons.set_facecolor(
- [activecolor if i == active else "none"
- for i, activecolor in enumerate(self._active_colors)])
- self.connect_event('button_press_event', self._clicked)
- if self._useblit:
- self.connect_event('draw_event', self._clear)
- self._observers = cbook.CallbackRegistry(signals=["clicked"])
- def _clear(self, event):
- """Internal event handler to clear the buttons."""
- if self.ignore(event) or self.canvas.is_saving():
- return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
- self.ax.draw_artist(self._buttons)
- def _clicked(self, event):
- if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
- return
- idxs = [ # Indices of buttons and of texts that contain the event.
- *self._buttons.contains(event)[1]["ind"],
- *[i for i, text in enumerate(self.labels) if text.contains(event)[0]]]
- if idxs:
- coords = self._buttons.get_offset_transform().transform(
- self._buttons.get_offsets())
- self.set_active( # Closest index, only looking in idxs.
- idxs[(((event.x, event.y) - coords[idxs]) ** 2).sum(-1).argmin()])
- def set_label_props(self, props):
- """
- Set properties of the `.Text` labels.
- .. versionadded:: 3.7
- Parameters
- ----------
- props : dict
- Dictionary of `.Text` properties to be used for the labels. Same
- format as label_props argument of :class:`RadioButtons`.
- """
- _api.check_isinstance(dict, props=props)
- props = _expand_text_props(props)
- for text, prop in zip(self.labels, props):
- text.update(prop)
- def set_radio_props(self, props):
- """
- Set properties of the `.Text` labels.
- .. versionadded:: 3.7
- Parameters
- ----------
- props : dict
- Dictionary of `.Collection` properties to be used for the radio
- buttons.
- """
- _api.check_isinstance(dict, props=props)
- if 's' in props: # Keep API consistent with constructor.
- props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels))
- self._buttons.update(props)
- self._active_colors = self._buttons.get_facecolor()
- if len(self._active_colors) == 1:
- self._active_colors = np.repeat(self._active_colors,
- len(self.labels), axis=0)
- self._buttons.set_facecolor(
- [activecolor if text.get_text() == self.value_selected else "none"
- for text, activecolor in zip(self.labels, self._active_colors)])
- @property
- def activecolor(self):
- return self._activecolor
- @activecolor.setter
- def activecolor(self, activecolor):
- colors._check_color_like(activecolor=activecolor)
- self._activecolor = activecolor
- self.set_radio_props({'facecolor': activecolor})
- def set_active(self, index):
- """
- Select button with number *index*.
- Callbacks will be triggered if :attr:`eventson` is True.
- Parameters
- ----------
- index : int
- The index of the button to activate.
- Raises
- ------
- ValueError
- If the index is invalid.
- """
- if index not in range(len(self.labels)):
- raise ValueError(f'Invalid RadioButton index: {index}')
- self.value_selected = self.labels[index].get_text()
- self.index_selected = index
- button_facecolors = self._buttons.get_facecolor()
- button_facecolors[:] = colors.to_rgba("none")
- button_facecolors[index] = colors.to_rgba(self._active_colors[index])
- self._buttons.set_facecolor(button_facecolors)
- if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
- self.ax.draw_artist(self._buttons)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw()
- if self.eventson:
- self._observers.process('clicked', self.labels[index].get_text())
- def clear(self):
- """Reset the active button to the initially active one."""
- self.set_active(self._initial_active)
- def on_clicked(self, func):
- """
- Connect the callback function *func* to button click events.
- Parameters
- ----------
- func : callable
- When the button is clicked, call *func* with button label.
- When all buttons are cleared, call *func* with None.
- The callback func must have the signature::
- def func(label: str | None) -> Any
- Return values may exist, but are ignored.
- Returns
- -------
- A connection id, which can be used to disconnect the callback.
- """
- return self._observers.connect('clicked', func)
- def disconnect(self, cid):
- """Remove the observer with connection id *cid*."""
- self._observers.disconnect(cid)
- class SubplotTool(Widget):
- """
- A tool to adjust the subplot params of a `.Figure`.
- """
- def __init__(self, targetfig, toolfig):
- """
- Parameters
- ----------
- targetfig : `~matplotlib.figure.Figure`
- The figure instance to adjust.
- toolfig : `~matplotlib.figure.Figure`
- The figure instance to embed the subplot tool into.
- """
- self.figure = toolfig
- self.targetfig = targetfig
- toolfig.subplots_adjust(left=0.2, right=0.9)
- toolfig.suptitle("Click on slider to adjust subplot param")
- self._sliders = []
- names = ["left", "bottom", "right", "top", "wspace", "hspace"]
- # The last subplot, removed below, keeps space for the "Reset" button.
- for name, ax in zip(names, toolfig.subplots(len(names) + 1)):
- ax.set_navigate(False)
- slider = Slider(ax, name, 0, 1,
- valinit=getattr(targetfig.subplotpars, name))
- slider.on_changed(self._on_slider_changed)
- self._sliders.append(slider)
- toolfig.axes[-1].remove()
- (self.sliderleft, self.sliderbottom, self.sliderright, self.slidertop,
- self.sliderwspace, self.sliderhspace) = self._sliders
- for slider in [self.sliderleft, self.sliderbottom,
- self.sliderwspace, self.sliderhspace]:
- slider.closedmax = False
- for slider in [self.sliderright, self.slidertop]:
- slider.closedmin = False
- # constraints
- self.sliderleft.slidermax = self.sliderright
- self.sliderright.slidermin = self.sliderleft
- self.sliderbottom.slidermax = self.slidertop
- self.slidertop.slidermin = self.sliderbottom
- bax = toolfig.add_axes([0.8, 0.05, 0.15, 0.075])
- self.buttonreset = Button(bax, 'Reset')
- self.buttonreset.on_clicked(self._on_reset)
- def _on_slider_changed(self, _):
- self.targetfig.subplots_adjust(
- **{slider.label.get_text(): slider.val
- for slider in self._sliders})
- if self.drawon:
- self.targetfig.canvas.draw()
- def _on_reset(self, event):
- with ExitStack() as stack:
- # Temporarily disable drawing on self and self's sliders, and
- # disconnect slider events (as the subplotparams can be temporarily
- # invalid, depending on the order in which they are restored).
- stack.enter_context(cbook._setattr_cm(self, drawon=False))
- for slider in self._sliders:
- stack.enter_context(
- cbook._setattr_cm(slider, drawon=False, eventson=False))
- # Reset the slider to the initial position.
- for slider in self._sliders:
- slider.reset()
- if self.drawon:
- event.canvas.draw() # Redraw the subplottool canvas.
- self._on_slider_changed(None) # Apply changes to the target window.
- class Cursor(AxesWidget):
- """
- A crosshair cursor that spans the Axes and moves with mouse cursor.
- For the cursor to remain responsive you must keep a reference to it.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The `~.axes.Axes` to attach the cursor to.
- horizOn : bool, default: True
- Whether to draw the horizontal line.
- vertOn : bool, default: True
- Whether to draw the vertical line.
- useblit : bool, default: False
- Use blitting for faster drawing if supported by the backend.
- See the tutorial :ref:`blitting` for details.
- Other Parameters
- ----------------
- **lineprops
- `.Line2D` properties that control the appearance of the lines.
- See also `~.Axes.axhline`.
- Examples
- --------
- See :doc:`/gallery/widgets/cursor`.
- """
- def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False,
- **lineprops):
- super().__init__(ax)
- self.connect_event('motion_notify_event', self.onmove)
- self.connect_event('draw_event', self.clear)
- self.visible = True
- self.horizOn = horizOn
- self.vertOn = vertOn
- self.useblit = useblit and self.canvas.supports_blit
- if self.useblit:
- lineprops['animated'] = True
- self.lineh = ax.axhline(ax.get_ybound()[0], visible=False, **lineprops)
- self.linev = ax.axvline(ax.get_xbound()[0], visible=False, **lineprops)
- self.background = None
- self.needclear = False
- def clear(self, event):
- """Internal event handler to clear the cursor."""
- if self.ignore(event) or self.canvas.is_saving():
- return
- if self.useblit:
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
- def onmove(self, event):
- """Internal event handler to draw the cursor when the mouse moves."""
- if self.ignore(event):
- return
- if not self.canvas.widgetlock.available(self):
- return
- if not self.ax.contains(event)[0]:
- self.linev.set_visible(False)
- self.lineh.set_visible(False)
- if self.needclear:
- self.canvas.draw()
- self.needclear = False
- return
- self.needclear = True
- xdata, ydata = self._get_data_coords(event)
- self.linev.set_xdata((xdata, xdata))
- self.linev.set_visible(self.visible and self.vertOn)
- self.lineh.set_ydata((ydata, ydata))
- self.lineh.set_visible(self.visible and self.horizOn)
- if not (self.visible and (self.vertOn or self.horizOn)):
- return
- # Redraw.
- if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
- self.ax.draw_artist(self.linev)
- self.ax.draw_artist(self.lineh)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw_idle()
- class MultiCursor(Widget):
- """
- Provide a vertical (default) and/or horizontal line cursor shared between
- multiple Axes.
- For the cursor to remain responsive you must keep a reference to it.
- Parameters
- ----------
- canvas : object
- This parameter is entirely unused and only kept for back-compatibility.
- axes : list of `~matplotlib.axes.Axes`
- The `~.axes.Axes` to attach the cursor to.
- useblit : bool, default: True
- Use blitting for faster drawing if supported by the backend.
- See the tutorial :ref:`blitting`
- for details.
- horizOn : bool, default: False
- Whether to draw the horizontal line.
- vertOn : bool, default: True
- Whether to draw the vertical line.
- Other Parameters
- ----------------
- **lineprops
- `.Line2D` properties that control the appearance of the lines.
- See also `~.Axes.axhline`.
- Examples
- --------
- See :doc:`/gallery/widgets/multicursor`.
- """
- def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
- **lineprops):
- # canvas is stored only to provide the deprecated .canvas attribute;
- # once it goes away the unused argument won't need to be stored at all.
- self._canvas = canvas
- self.axes = axes
- self.horizOn = horizOn
- self.vertOn = vertOn
- self._canvas_infos = {
- ax.get_figure(root=True).canvas:
- {"cids": [], "background": None} for ax in axes}
- xmin, xmax = axes[-1].get_xlim()
- ymin, ymax = axes[-1].get_ylim()
- xmid = 0.5 * (xmin + xmax)
- ymid = 0.5 * (ymin + ymax)
- self.visible = True
- self.useblit = (
- useblit
- and all(canvas.supports_blit for canvas in self._canvas_infos))
- if self.useblit:
- lineprops['animated'] = True
- self.vlines = [ax.axvline(xmid, visible=False, **lineprops)
- for ax in axes]
- self.hlines = [ax.axhline(ymid, visible=False, **lineprops)
- for ax in axes]
- self.connect()
- def connect(self):
- """Connect events."""
- for canvas, info in self._canvas_infos.items():
- info["cids"] = [
- canvas.mpl_connect('motion_notify_event', self.onmove),
- canvas.mpl_connect('draw_event', self.clear),
- ]
- def disconnect(self):
- """Disconnect events."""
- for canvas, info in self._canvas_infos.items():
- for cid in info["cids"]:
- canvas.mpl_disconnect(cid)
- info["cids"].clear()
- def clear(self, event):
- """Clear the cursor."""
- if self.ignore(event):
- return
- if self.useblit:
- for canvas, info in self._canvas_infos.items():
- # someone has switched the canvas on us! This happens if
- # `savefig` needs to save to a format the previous backend did
- # not support (e.g. saving a figure using an Agg based backend
- # saved to a vector format).
- if canvas is not canvas.figure.canvas:
- continue
- info["background"] = canvas.copy_from_bbox(canvas.figure.bbox)
- def onmove(self, event):
- axs = [ax for ax in self.axes if ax.contains(event)[0]]
- if self.ignore(event) or not axs or not event.canvas.widgetlock.available(self):
- return
- ax = cbook._topmost_artist(axs)
- xdata, ydata = ((event.xdata, event.ydata) if event.inaxes is ax
- else ax.transData.inverted().transform((event.x, event.y)))
- for line in self.vlines:
- line.set_xdata((xdata, xdata))
- line.set_visible(self.visible and self.vertOn)
- for line in self.hlines:
- line.set_ydata((ydata, ydata))
- line.set_visible(self.visible and self.horizOn)
- if not (self.visible and (self.vertOn or self.horizOn)):
- return
- # Redraw.
- if self.useblit:
- for canvas, info in self._canvas_infos.items():
- if info["background"]:
- canvas.restore_region(info["background"])
- if self.vertOn:
- for ax, line in zip(self.axes, self.vlines):
- ax.draw_artist(line)
- if self.horizOn:
- for ax, line in zip(self.axes, self.hlines):
- ax.draw_artist(line)
- for canvas in self._canvas_infos:
- canvas.blit()
- else:
- for canvas in self._canvas_infos:
- canvas.draw_idle()
- class _SelectorWidget(AxesWidget):
- def __init__(self, ax, onselect=None, useblit=False, button=None,
- state_modifier_keys=None, use_data_coordinates=False):
- super().__init__(ax)
- self._visible = True
- if onselect is None:
- self.onselect = lambda *args: None
- else:
- self.onselect = onselect
- self.useblit = useblit and self.canvas.supports_blit
- self.connect_default_events()
- self._state_modifier_keys = dict(move=' ', clear='escape',
- square='shift', center='control',
- rotate='r')
- self._state_modifier_keys.update(state_modifier_keys or {})
- self._use_data_coordinates = use_data_coordinates
- self.background = None
- if isinstance(button, Integral):
- self.validButtons = [button]
- else:
- self.validButtons = button
- # Set to True when a selection is completed, otherwise is False
- self._selection_completed = False
- # will save the data (position at mouseclick)
- self._eventpress = None
- # will save the data (pos. at mouserelease)
- self._eventrelease = None
- self._prev_event = None
- self._state = set()
- def set_active(self, active):
- super().set_active(active)
- if active:
- self.update_background(None)
- def _get_animated_artists(self):
- """
- Convenience method to get all animated artists of the figure containing
- this widget, excluding those already present in self.artists.
- The returned tuple is not sorted by 'z_order': z_order sorting is
- valid only when considering all artists and not only a subset of all
- artists.
- """
- return tuple(a for ax_ in self.ax.get_figure().get_axes()
- for a in ax_.get_children()
- if a.get_animated() and a not in self.artists)
- def update_background(self, event):
- """Force an update of the background."""
- # If you add a call to `ignore` here, you'll want to check edge case:
- # `release` can call a draw event even when `ignore` is True.
- if not self.useblit:
- return
- if self.canvas.is_saving():
- return # saving does not use blitting
- # Make sure that widget artists don't get accidentally included in the
- # background, by re-rendering the background if needed (and then
- # re-re-rendering the canvas with the visible widget artists).
- # We need to remove all artists which will be drawn when updating
- # the selector: if we have animated artists in the figure, it is safer
- # to redrawn by default, in case they have updated by the callback
- # zorder needs to be respected when redrawing
- artists = sorted(self.artists + self._get_animated_artists(),
- key=lambda a: a.get_zorder())
- needs_redraw = any(artist.get_visible() for artist in artists)
- with ExitStack() as stack:
- if needs_redraw:
- for artist in artists:
- stack.enter_context(artist._cm_set(visible=False))
- self.canvas.draw()
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
- if needs_redraw:
- for artist in artists:
- self.ax.draw_artist(artist)
- def connect_default_events(self):
- """Connect the major canvas events to methods."""
- self.connect_event('motion_notify_event', self.onmove)
- self.connect_event('button_press_event', self.press)
- self.connect_event('button_release_event', self.release)
- self.connect_event('draw_event', self.update_background)
- self.connect_event('key_press_event', self.on_key_press)
- self.connect_event('key_release_event', self.on_key_release)
- self.connect_event('scroll_event', self.on_scroll)
- def ignore(self, event):
- # docstring inherited
- if not self.active or not self.ax.get_visible():
- return True
- # If canvas was locked
- if not self.canvas.widgetlock.available(self):
- return True
- if not hasattr(event, 'button'):
- event.button = None
- # Only do rectangle selection if event was triggered
- # with a desired button
- if (self.validButtons is not None
- and event.button not in self.validButtons):
- return True
- # If no button was pressed yet ignore the event if it was out of the Axes.
- if self._eventpress is None:
- return not self.ax.contains(event)[0]
- # If a button was pressed, check if the release-button is the same.
- if event.button == self._eventpress.button:
- return False
- # If a button was pressed, check if the release-button is the same.
- return (not self.ax.contains(event)[0] or
- event.button != self._eventpress.button)
- def update(self):
- """Draw using blit() or draw_idle(), depending on ``self.useblit``."""
- if (not self.ax.get_visible() or
- self.ax.get_figure(root=True)._get_renderer() is None):
- return
- if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
- else:
- self.update_background(None)
- # We need to draw all artists, which are not included in the
- # background, therefore we also draw self._get_animated_artists()
- # and we make sure that we respect z_order
- artists = sorted(self.artists + self._get_animated_artists(),
- key=lambda a: a.get_zorder())
- for artist in artists:
- self.ax.draw_artist(artist)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw_idle()
- def _get_data(self, event):
- """Get the xdata and ydata for event, with limits."""
- if event.xdata is None:
- return None, None
- xdata, ydata = self._get_data_coords(event)
- xdata = np.clip(xdata, *self.ax.get_xbound())
- ydata = np.clip(ydata, *self.ax.get_ybound())
- return xdata, ydata
- def _clean_event(self, event):
- """
- Preprocess an event:
- - Replace *event* by the previous event if *event* has no ``xdata``.
- - Get ``xdata`` and ``ydata`` from this widget's Axes, and clip them to the axes
- limits.
- - Update the previous event.
- """
- if event.xdata is None:
- event = self._prev_event
- else:
- event = copy.copy(event)
- event.xdata, event.ydata = self._get_data(event)
- self._prev_event = event
- return event
- def press(self, event):
- """Button press handler and validator."""
- if not self.ignore(event):
- event = self._clean_event(event)
- self._eventpress = event
- self._prev_event = event
- key = event.key or ''
- key = key.replace('ctrl', 'control')
- # move state is locked in on a button press
- if key == self._state_modifier_keys['move']:
- self._state.add('move')
- self._press(event)
- return True
- return False
- def _press(self, event):
- """Button press event handler."""
- def release(self, event):
- """Button release event handler and validator."""
- if not self.ignore(event) and self._eventpress:
- event = self._clean_event(event)
- self._eventrelease = event
- self._release(event)
- self._eventpress = None
- self._eventrelease = None
- self._state.discard('move')
- return True
- return False
- def _release(self, event):
- """Button release event handler."""
- def onmove(self, event):
- """Cursor move event handler and validator."""
- if not self.ignore(event) and self._eventpress:
- event = self._clean_event(event)
- self._onmove(event)
- return True
- return False
- def _onmove(self, event):
- """Cursor move event handler."""
- def on_scroll(self, event):
- """Mouse scroll event handler and validator."""
- if not self.ignore(event):
- self._on_scroll(event)
- def _on_scroll(self, event):
- """Mouse scroll event handler."""
- def on_key_press(self, event):
- """Key press event handler and validator for all selection widgets."""
- if self.active:
- key = event.key or ''
- key = key.replace('ctrl', 'control')
- if key == self._state_modifier_keys['clear']:
- self.clear()
- return
- for (state, modifier) in self._state_modifier_keys.items():
- if modifier in key.split('+'):
- # 'rotate' is changing _state on press and is not removed
- # from _state when releasing
- if state == 'rotate':
- if state in self._state:
- self._state.discard(state)
- else:
- self._state.add(state)
- else:
- self._state.add(state)
- self._on_key_press(event)
- def _on_key_press(self, event):
- """Key press event handler - for widget-specific key press actions."""
- def on_key_release(self, event):
- """Key release event handler and validator."""
- if self.active:
- key = event.key or ''
- for (state, modifier) in self._state_modifier_keys.items():
- # 'rotate' is changing _state on press and is not removed
- # from _state when releasing
- if modifier in key.split('+') and state != 'rotate':
- self._state.discard(state)
- self._on_key_release(event)
- def _on_key_release(self, event):
- """Key release event handler."""
- def set_visible(self, visible):
- """Set the visibility of the selector artists."""
- self._visible = visible
- for artist in self.artists:
- artist.set_visible(visible)
- def get_visible(self):
- """Get the visibility of the selector artists."""
- return self._visible
- def clear(self):
- """Clear the selection and set the selector ready to make a new one."""
- self._clear_without_update()
- self.update()
- def _clear_without_update(self):
- self._selection_completed = False
- self.set_visible(False)
- @property
- def artists(self):
- """Tuple of the artists of the selector."""
- handles_artists = getattr(self, '_handles_artists', ())
- return (self._selection_artist,) + handles_artists
- def set_props(self, **props):
- """
- Set the properties of the selector artist.
- See the *props* argument in the selector docstring to know which properties are
- supported.
- """
- artist = self._selection_artist
- props = cbook.normalize_kwargs(props, artist)
- artist.set(**props)
- if self.useblit:
- self.update()
- def set_handle_props(self, **handle_props):
- """
- Set the properties of the handles selector artist. See the
- `handle_props` argument in the selector docstring to know which
- properties are supported.
- """
- if not hasattr(self, '_handles_artists'):
- raise NotImplementedError("This selector doesn't have handles.")
- artist = self._handles_artists[0]
- handle_props = cbook.normalize_kwargs(handle_props, artist)
- for handle in self._handles_artists:
- handle.set(**handle_props)
- if self.useblit:
- self.update()
- self._handle_props.update(handle_props)
- def _validate_state(self, state):
- supported_state = [
- key for key, value in self._state_modifier_keys.items()
- if key != 'clear' and value != 'not-applicable'
- ]
- _api.check_in_list(supported_state, state=state)
- def add_state(self, state):
- """
- Add a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
- Parameters
- ----------
- state : str
- Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
- Raises
- ------
- ValueError
- When the state is not supported by the selector.
- """
- self._validate_state(state)
- self._state.add(state)
- def remove_state(self, state):
- """
- Remove a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
- Parameters
- ----------
- state : str
- Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
- Raises
- ------
- ValueError
- When the state is not supported by the selector.
- """
- self._validate_state(state)
- self._state.remove(state)
- class SpanSelector(_SelectorWidget):
- """
- Visually select a min/max range on a single axis and call a function with
- those values.
- To guarantee that the selector remains responsive, keep a reference to it.
- In order to turn off the SpanSelector, set ``span_selector.active`` to
- False. To turn it back on, set it to True.
- Press and release events triggered at the same coordinates outside the
- selection will clear the selector, except when
- ``ignore_event_outside=True``.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- onselect : callable with signature ``func(min: float, max: float)``
- A callback function that is called after a release event and the
- selection is created, changed or removed.
- direction : {"horizontal", "vertical"}
- The direction along which to draw the span selector.
- minspan : float, default: 0
- If selection is less than or equal to *minspan*, the selection is
- removed (when already existing) or cancelled.
- useblit : bool, default: False
- If True, use the backend-dependent blitting features for faster
- canvas updates. See the tutorial :ref:`blitting` for details.
- props : dict, default: {'facecolor': 'red', 'alpha': 0.5}
- Dictionary of `.Patch` properties.
- onmove_callback : callable with signature ``func(min: float, max: float)``, optional
- Called on mouse move while the span is being selected.
- interactive : bool, default: False
- Whether to draw a set of handles that allow interaction with the
- widget after it is drawn.
- button : `.MouseButton` or list of `.MouseButton`, default: all buttons
- The mouse buttons which activate the span selector.
- handle_props : dict, default: None
- Properties of the handle lines at the edges of the span. Only used
- when *interactive* is True. See `.Line2D` for valid properties.
- grab_range : float, default: 10
- Distance in pixels within which the interactive tool handles can be activated.
- state_modifier_keys : dict, optional
- Keyboard modifiers which affect the widget's behavior. Values
- amend the defaults, which are:
- - "clear": Clear the current shape, default: "escape".
- drag_from_anywhere : bool, default: False
- If `True`, the widget can be moved by clicking anywhere within its bounds.
- ignore_event_outside : bool, default: False
- If `True`, the event triggered outside the span selector will be ignored.
- snap_values : 1D array-like, optional
- Snap the selector edges to the given values.
- Examples
- --------
- >>> import matplotlib.pyplot as plt
- >>> import matplotlib.widgets as mwidgets
- >>> fig, ax = plt.subplots()
- >>> ax.plot([1, 2, 3], [10, 50, 100])
- >>> def onselect(vmin, vmax):
- ... print(vmin, vmax)
- >>> span = mwidgets.SpanSelector(ax, onselect, 'horizontal',
- ... props=dict(facecolor='blue', alpha=0.5))
- >>> fig.show()
- See also: :doc:`/gallery/widgets/span_selector`
- """
- def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False,
- props=None, onmove_callback=None, interactive=False,
- button=None, handle_props=None, grab_range=10,
- state_modifier_keys=None, drag_from_anywhere=False,
- ignore_event_outside=False, snap_values=None):
- if state_modifier_keys is None:
- state_modifier_keys = dict(clear='escape',
- square='not-applicable',
- center='not-applicable',
- rotate='not-applicable')
- super().__init__(ax, onselect, useblit=useblit, button=button,
- state_modifier_keys=state_modifier_keys)
- if props is None:
- props = dict(facecolor='red', alpha=0.5)
- props['animated'] = self.useblit
- self.direction = direction
- self._extents_on_press = None
- self.snap_values = snap_values
- self.onmove_callback = onmove_callback
- self.minspan = minspan
- self.grab_range = grab_range
- self._interactive = interactive
- self._edge_handles = None
- self.drag_from_anywhere = drag_from_anywhere
- self.ignore_event_outside = ignore_event_outside
- self.new_axes(ax, _props=props, _init=True)
- # Setup handles
- self._handle_props = {
- 'color': props.get('facecolor', 'r'),
- **cbook.normalize_kwargs(handle_props, Line2D)}
- if self._interactive:
- self._edge_order = ['min', 'max']
- self._setup_edge_handles(self._handle_props)
- self._active_handle = None
- def new_axes(self, ax, *, _props=None, _init=False):
- """Set SpanSelector to operate on a new Axes."""
- reconnect = False
- if _init or self.canvas is not ax.get_figure(root=True).canvas:
- if self.canvas is not None:
- self.disconnect_events()
- reconnect = True
- self.ax = ax
- if reconnect:
- self.connect_default_events()
- # Reset
- self._selection_completed = False
- if self.direction == 'horizontal':
- trans = ax.get_xaxis_transform()
- w, h = 0, 1
- else:
- trans = ax.get_yaxis_transform()
- w, h = 1, 0
- rect_artist = Rectangle((0, 0), w, h, transform=trans, visible=False)
- if _props is not None:
- rect_artist.update(_props)
- elif self._selection_artist is not None:
- rect_artist.update_from(self._selection_artist)
- self.ax.add_patch(rect_artist)
- self._selection_artist = rect_artist
- def _setup_edge_handles(self, props):
- # Define initial position using the axis bounds to keep the same bounds
- if self.direction == 'horizontal':
- positions = self.ax.get_xbound()
- else:
- positions = self.ax.get_ybound()
- self._edge_handles = ToolLineHandles(self.ax, positions,
- direction=self.direction,
- line_props=props,
- useblit=self.useblit)
- @property
- def _handles_artists(self):
- if self._edge_handles is not None:
- return self._edge_handles.artists
- else:
- return ()
- def _set_cursor(self, enabled):
- """Update the canvas cursor based on direction of the selector."""
- if enabled:
- cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL
- if self.direction == 'horizontal' else
- backend_tools.Cursors.RESIZE_VERTICAL)
- else:
- cursor = backend_tools.Cursors.POINTER
- self.ax.get_figure(root=True).canvas.set_cursor(cursor)
- def connect_default_events(self):
- # docstring inherited
- super().connect_default_events()
- if getattr(self, '_interactive', False):
- self.connect_event('motion_notify_event', self._hover)
- def _press(self, event):
- """Button press event handler."""
- self._set_cursor(True)
- if self._interactive and self._selection_artist.get_visible():
- self._set_active_handle(event)
- else:
- self._active_handle = None
- if self._active_handle is None or not self._interactive:
- # Clear previous rectangle before drawing new rectangle.
- self.update()
- xdata, ydata = self._get_data_coords(event)
- v = xdata if self.direction == 'horizontal' else ydata
- if self._active_handle is None and not self.ignore_event_outside:
- # when the press event outside the span, we initially set the
- # visibility to False and extents to (v, v)
- # update will be called when setting the extents
- self._visible = False
- self._set_extents((v, v))
- # We need to set the visibility back, so the span selector will be
- # drawn when necessary (span width > 0)
- self._visible = True
- else:
- self.set_visible(True)
- return False
- @property
- def direction(self):
- """Direction of the span selector: 'vertical' or 'horizontal'."""
- return self._direction
- @direction.setter
- def direction(self, direction):
- """Set the direction of the span selector."""
- _api.check_in_list(['horizontal', 'vertical'], direction=direction)
- if hasattr(self, '_direction') and direction != self._direction:
- # remove previous artists
- self._selection_artist.remove()
- if self._interactive:
- self._edge_handles.remove()
- self._direction = direction
- self.new_axes(self.ax)
- if self._interactive:
- self._setup_edge_handles(self._handle_props)
- else:
- self._direction = direction
- def _release(self, event):
- """Button release event handler."""
- self._set_cursor(False)
- if not self._interactive:
- self._selection_artist.set_visible(False)
- if (self._active_handle is None and self._selection_completed and
- self.ignore_event_outside):
- return
- vmin, vmax = self.extents
- span = vmax - vmin
- if span <= self.minspan:
- # Remove span and set self._selection_completed = False
- self.set_visible(False)
- if self._selection_completed:
- # Call onselect, only when the span is already existing
- self.onselect(vmin, vmax)
- self._selection_completed = False
- else:
- self.onselect(vmin, vmax)
- self._selection_completed = True
- self.update()
- self._active_handle = None
- return False
- def _hover(self, event):
- """Update the canvas cursor if it's over a handle."""
- if self.ignore(event):
- return
- if self._active_handle is not None or not self._selection_completed:
- # Do nothing if button is pressed and a handle is active, which may
- # occur with drag_from_anywhere=True.
- # Do nothing if selection is not completed, which occurs when
- # a selector has been cleared
- return
- _, e_dist = self._edge_handles.closest(event.x, event.y)
- self._set_cursor(e_dist <= self.grab_range)
- def _onmove(self, event):
- """Motion notify event handler."""
- xdata, ydata = self._get_data_coords(event)
- if self.direction == 'horizontal':
- v = xdata
- vpress = self._eventpress.xdata
- else:
- v = ydata
- vpress = self._eventpress.ydata
- # move existing span
- # When "dragging from anywhere", `self._active_handle` is set to 'C'
- # (match notation used in the RectangleSelector)
- if self._active_handle == 'C' and self._extents_on_press is not None:
- vmin, vmax = self._extents_on_press
- dv = v - vpress
- vmin += dv
- vmax += dv
- # resize an existing shape
- elif self._active_handle and self._active_handle != 'C':
- vmin, vmax = self._extents_on_press
- if self._active_handle == 'min':
- vmin = v
- else:
- vmax = v
- # new shape
- else:
- # Don't create a new span if there is already one when
- # ignore_event_outside=True
- if self.ignore_event_outside and self._selection_completed:
- return
- vmin, vmax = vpress, v
- if vmin > vmax:
- vmin, vmax = vmax, vmin
- self._set_extents((vmin, vmax))
- if self.onmove_callback is not None:
- self.onmove_callback(vmin, vmax)
- return False
- def _draw_shape(self, vmin, vmax):
- if vmin > vmax:
- vmin, vmax = vmax, vmin
- if self.direction == 'horizontal':
- self._selection_artist.set_x(vmin)
- self._selection_artist.set_width(vmax - vmin)
- else:
- self._selection_artist.set_y(vmin)
- self._selection_artist.set_height(vmax - vmin)
- def _set_active_handle(self, event):
- """Set active handle based on the location of the mouse event."""
- # Note: event.xdata/ydata in data coordinates, event.x/y in pixels
- e_idx, e_dist = self._edge_handles.closest(event.x, event.y)
- # Prioritise center handle over other handles
- # Use 'C' to match the notation used in the RectangleSelector
- if 'move' in self._state:
- self._active_handle = 'C'
- elif e_dist > self.grab_range:
- # Not close to any handles
- self._active_handle = None
- if self.drag_from_anywhere and self._contains(event):
- # Check if we've clicked inside the region
- self._active_handle = 'C'
- self._extents_on_press = self.extents
- else:
- self._active_handle = None
- return
- else:
- # Closest to an edge handle
- self._active_handle = self._edge_order[e_idx]
- # Save coordinates of rectangle at the start of handle movement.
- self._extents_on_press = self.extents
- def _contains(self, event):
- """Return True if event is within the patch."""
- return self._selection_artist.contains(event, radius=0)[0]
- @staticmethod
- def _snap(values, snap_values):
- """Snap values to a given array values (snap_values)."""
- # take into account machine precision
- eps = np.min(np.abs(np.diff(snap_values))) * 1e-12
- return tuple(
- snap_values[np.abs(snap_values - v + np.sign(v) * eps).argmin()]
- for v in values)
- @property
- def extents(self):
- """
- (float, float)
- The values, in data coordinates, for the start and end points of the current
- selection. If there is no selection then the start and end values will be
- the same.
- """
- if self.direction == 'horizontal':
- vmin = self._selection_artist.get_x()
- vmax = vmin + self._selection_artist.get_width()
- else:
- vmin = self._selection_artist.get_y()
- vmax = vmin + self._selection_artist.get_height()
- return vmin, vmax
- @extents.setter
- def extents(self, extents):
- self._set_extents(extents)
- self._selection_completed = True
- def _set_extents(self, extents):
- # Update displayed shape
- if self.snap_values is not None:
- extents = tuple(self._snap(extents, self.snap_values))
- self._draw_shape(*extents)
- if self._interactive:
- # Update displayed handles
- self._edge_handles.set_data(self.extents)
- self.set_visible(self._visible)
- self.update()
- class ToolLineHandles:
- """
- Control handles for canvas tools.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- Matplotlib Axes where tool handles are displayed.
- positions : 1D array
- Positions of handles in data coordinates.
- direction : {"horizontal", "vertical"}
- Direction of handles, either 'vertical' or 'horizontal'
- line_props : dict, optional
- Additional line properties. See `.Line2D`.
- useblit : bool, default: True
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- """
- def __init__(self, ax, positions, direction, *, line_props=None,
- useblit=True):
- self.ax = ax
- _api.check_in_list(['horizontal', 'vertical'], direction=direction)
- self._direction = direction
- line_props = {
- **(line_props if line_props is not None else {}),
- 'visible': False,
- 'animated': useblit,
- }
- line_fun = ax.axvline if self.direction == 'horizontal' else ax.axhline
- self._artists = [line_fun(p, **line_props) for p in positions]
- @property
- def artists(self):
- return tuple(self._artists)
- @property
- def positions(self):
- """Positions of the handle in data coordinates."""
- method = 'get_xdata' if self.direction == 'horizontal' else 'get_ydata'
- return [getattr(line, method)()[0] for line in self.artists]
- @property
- def direction(self):
- """Direction of the handle: 'vertical' or 'horizontal'."""
- return self._direction
- def set_data(self, positions):
- """
- Set x- or y-positions of handles, depending on if the lines are
- vertical or horizontal.
- Parameters
- ----------
- positions : tuple of length 2
- Set the positions of the handle in data coordinates
- """
- method = 'set_xdata' if self.direction == 'horizontal' else 'set_ydata'
- for line, p in zip(self.artists, positions):
- getattr(line, method)([p, p])
- def set_visible(self, value):
- """Set the visibility state of the handles artist."""
- for artist in self.artists:
- artist.set_visible(value)
- def set_animated(self, value):
- """Set the animated state of the handles artist."""
- for artist in self.artists:
- artist.set_animated(value)
- def remove(self):
- """Remove the handles artist from the figure."""
- for artist in self._artists:
- artist.remove()
- def closest(self, x, y):
- """
- Return index and pixel distance to closest handle.
- Parameters
- ----------
- x, y : float
- x, y position from which the distance will be calculated to
- determinate the closest handle
- Returns
- -------
- index, distance : index of the handle and its distance from
- position x, y
- """
- if self.direction == 'horizontal':
- p_pts = np.array([
- self.ax.transData.transform((p, 0))[0] for p in self.positions
- ])
- dist = abs(p_pts - x)
- else:
- p_pts = np.array([
- self.ax.transData.transform((0, p))[1] for p in self.positions
- ])
- dist = abs(p_pts - y)
- index = np.argmin(dist)
- return index, dist[index]
- class ToolHandles:
- """
- Control handles for canvas tools.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- Matplotlib Axes where tool handles are displayed.
- x, y : 1D arrays
- Coordinates of control handles.
- marker : str, default: 'o'
- Shape of marker used to display handle. See `~.pyplot.plot`.
- marker_props : dict, optional
- Additional marker properties. See `.Line2D`.
- useblit : bool, default: True
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- """
- def __init__(self, ax, x, y, *, marker='o', marker_props=None, useblit=True):
- self.ax = ax
- props = {'marker': marker, 'markersize': 7, 'markerfacecolor': 'w',
- 'linestyle': 'none', 'alpha': 0.5, 'visible': False,
- 'label': '_nolegend_',
- **cbook.normalize_kwargs(marker_props, Line2D._alias_map)}
- self._markers = Line2D(x, y, animated=useblit, **props)
- self.ax.add_line(self._markers)
- @property
- def x(self):
- return self._markers.get_xdata()
- @property
- def y(self):
- return self._markers.get_ydata()
- @property
- def artists(self):
- return (self._markers, )
- def set_data(self, pts, y=None):
- """Set x and y positions of handles."""
- if y is not None:
- x = pts
- pts = np.array([x, y])
- self._markers.set_data(pts)
- def set_visible(self, val):
- self._markers.set_visible(val)
- def set_animated(self, val):
- self._markers.set_animated(val)
- def closest(self, x, y):
- """Return index and pixel distance to closest index."""
- pts = np.column_stack([self.x, self.y])
- # Transform data coordinates to pixel coordinates.
- pts = self.ax.transData.transform(pts)
- diff = pts - [x, y]
- dist = np.hypot(*diff.T)
- min_index = np.argmin(dist)
- return min_index, dist[min_index]
- _RECTANGLESELECTOR_PARAMETERS_DOCSTRING = \
- r"""
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- onselect : function, optional
- A callback function that is called after a release event and the
- selection is created, changed or removed.
- It must have the signature::
- def onselect(eclick: MouseEvent, erelease: MouseEvent)
- where *eclick* and *erelease* are the mouse click and release
- `.MouseEvent`\s that start and complete the selection.
- minspanx : float, default: 0
- Selections with an x-span less than or equal to *minspanx* are removed
- (when already existing) or cancelled.
- minspany : float, default: 0
- Selections with an y-span less than or equal to *minspanx* are removed
- (when already existing) or cancelled.
- useblit : bool, default: False
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- props : dict, optional
- Properties with which the __ARTIST_NAME__ is drawn. See
- `.Patch` for valid properties.
- Default:
- ``dict(facecolor='red', edgecolor='black', alpha=0.2, fill=True)``
- spancoords : {"data", "pixels"}, default: "data"
- Whether to interpret *minspanx* and *minspany* in data or in pixel
- coordinates.
- button : `.MouseButton`, list of `.MouseButton`, default: all buttons
- Button(s) that trigger rectangle selection.
- grab_range : float, default: 10
- Distance in pixels within which the interactive tool handles can be
- activated.
- handle_props : dict, optional
- Properties with which the interactive handles (marker artists) are
- drawn. See the marker arguments in `.Line2D` for valid
- properties. Default values are defined in ``mpl.rcParams`` except for
- the default value of ``markeredgecolor`` which will be the same as the
- ``edgecolor`` property in *props*.
- interactive : bool, default: False
- Whether to draw a set of handles that allow interaction with the
- widget after it is drawn.
- state_modifier_keys : dict, optional
- Keyboard modifiers which affect the widget's behavior. Values
- amend the defaults, which are:
- - "move": Move the existing shape, default: no modifier.
- - "clear": Clear the current shape, default: "escape".
- - "square": Make the shape square, default: "shift".
- - "center": change the shape around its center, default: "ctrl".
- - "rotate": Rotate the shape around its center between -45° and 45°,
- default: "r".
- "square" and "center" can be combined. The square shape can be defined
- in data or display coordinates as determined by the
- ``use_data_coordinates`` argument specified when creating the selector.
- drag_from_anywhere : bool, default: False
- If `True`, the widget can be moved by clicking anywhere within
- its bounds.
- ignore_event_outside : bool, default: False
- If `True`, the event triggered outside the span selector will be
- ignored.
- use_data_coordinates : bool, default: False
- If `True`, the "square" shape of the selector is defined in
- data coordinates instead of display coordinates.
- """
- @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace(
- '__ARTIST_NAME__', 'rectangle'))
- class RectangleSelector(_SelectorWidget):
- """
- Select a rectangular region of an Axes.
- For the cursor to remain responsive you must keep a reference to it.
- Press and release events triggered at the same coordinates outside the
- selection will clear the selector, except when
- ``ignore_event_outside=True``.
- %s
- Examples
- --------
- >>> import matplotlib.pyplot as plt
- >>> import matplotlib.widgets as mwidgets
- >>> fig, ax = plt.subplots()
- >>> ax.plot([1, 2, 3], [10, 50, 100])
- >>> def onselect(eclick, erelease):
- ... print(eclick.xdata, eclick.ydata)
- ... print(erelease.xdata, erelease.ydata)
- >>> props = dict(facecolor='blue', alpha=0.5)
- >>> rect = mwidgets.RectangleSelector(ax, onselect, interactive=True,
- ... props=props)
- >>> fig.show()
- >>> rect.add_state('square')
- See also: :doc:`/gallery/widgets/rectangle_selector`
- """
- def __init__(self, ax, onselect=None, *, minspanx=0,
- minspany=0, useblit=False,
- props=None, spancoords='data', button=None, grab_range=10,
- handle_props=None, interactive=False,
- state_modifier_keys=None, drag_from_anywhere=False,
- ignore_event_outside=False, use_data_coordinates=False):
- super().__init__(ax, onselect, useblit=useblit, button=button,
- state_modifier_keys=state_modifier_keys,
- use_data_coordinates=use_data_coordinates)
- self._interactive = interactive
- self.drag_from_anywhere = drag_from_anywhere
- self.ignore_event_outside = ignore_event_outside
- self._rotation = 0.0
- self._aspect_ratio_correction = 1.0
- # State to allow the option of an interactive selector that can't be
- # interactively drawn. This is used in PolygonSelector as an
- # interactive bounding box to allow the polygon to be easily resized
- self._allow_creation = True
- if props is None:
- props = dict(facecolor='red', edgecolor='black',
- alpha=0.2, fill=True)
- props = {**props, 'animated': self.useblit}
- self._visible = props.pop('visible', self._visible)
- to_draw = self._init_shape(**props)
- self.ax.add_patch(to_draw)
- self._selection_artist = to_draw
- self._set_aspect_ratio_correction()
- self.minspanx = minspanx
- self.minspany = minspany
- _api.check_in_list(['data', 'pixels'], spancoords=spancoords)
- self.spancoords = spancoords
- self.grab_range = grab_range
- if self._interactive:
- self._handle_props = {
- 'markeredgecolor': (props or {}).get('edgecolor', 'black'),
- **cbook.normalize_kwargs(handle_props, Line2D)}
- self._corner_order = ['SW', 'SE', 'NE', 'NW']
- xc, yc = self.corners
- self._corner_handles = ToolHandles(self.ax, xc, yc,
- marker_props=self._handle_props,
- useblit=self.useblit)
- self._edge_order = ['W', 'S', 'E', 'N']
- xe, ye = self.edge_centers
- self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s',
- marker_props=self._handle_props,
- useblit=self.useblit)
- xc, yc = self.center
- self._center_handle = ToolHandles(self.ax, [xc], [yc], marker='s',
- marker_props=self._handle_props,
- useblit=self.useblit)
- self._active_handle = None
- self._extents_on_press = None
- @property
- def _handles_artists(self):
- return (*self._center_handle.artists, *self._corner_handles.artists,
- *self._edge_handles.artists)
- def _init_shape(self, **props):
- return Rectangle((0, 0), 0, 1, visible=False,
- rotation_point='center', **props)
- def _press(self, event):
- """Button press event handler."""
- # make the drawn box/line visible get the click-coordinates, button, ...
- if self._interactive and self._selection_artist.get_visible():
- self._set_active_handle(event)
- else:
- self._active_handle = None
- if ((self._active_handle is None or not self._interactive) and
- self._allow_creation):
- # Clear previous rectangle before drawing new rectangle.
- self.update()
- if (self._active_handle is None and not self.ignore_event_outside and
- self._allow_creation):
- x, y = self._get_data_coords(event)
- self._visible = False
- self.extents = x, x, y, y
- self._visible = True
- else:
- self.set_visible(True)
- self._extents_on_press = self.extents
- self._rotation_on_press = self._rotation
- self._set_aspect_ratio_correction()
- return False
- def _release(self, event):
- """Button release event handler."""
- if not self._interactive:
- self._selection_artist.set_visible(False)
- if (self._active_handle is None and self._selection_completed and
- self.ignore_event_outside):
- return
- # update the eventpress and eventrelease with the resulting extents
- x0, x1, y0, y1 = self.extents
- self._eventpress.xdata = x0
- self._eventpress.ydata = y0
- xy0 = self.ax.transData.transform([x0, y0])
- self._eventpress.x, self._eventpress.y = xy0
- self._eventrelease.xdata = x1
- self._eventrelease.ydata = y1
- xy1 = self.ax.transData.transform([x1, y1])
- self._eventrelease.x, self._eventrelease.y = xy1
- # calculate dimensions of box or line
- if self.spancoords == 'data':
- spanx = abs(self._eventpress.xdata - self._eventrelease.xdata)
- spany = abs(self._eventpress.ydata - self._eventrelease.ydata)
- elif self.spancoords == 'pixels':
- spanx = abs(self._eventpress.x - self._eventrelease.x)
- spany = abs(self._eventpress.y - self._eventrelease.y)
- else:
- _api.check_in_list(['data', 'pixels'],
- spancoords=self.spancoords)
- # check if drawn distance (if it exists) is not too small in
- # either x or y-direction
- if spanx <= self.minspanx or spany <= self.minspany:
- if self._selection_completed:
- # Call onselect, only when the selection is already existing
- self.onselect(self._eventpress, self._eventrelease)
- self._clear_without_update()
- else:
- self.onselect(self._eventpress, self._eventrelease)
- self._selection_completed = True
- self.update()
- self._active_handle = None
- self._extents_on_press = None
- return False
- def _onmove(self, event):
- """
- Motion notify event handler.
- This can do one of four things:
- - Translate
- - Rotate
- - Re-size
- - Continue the creation of a new shape
- """
- eventpress = self._eventpress
- # The calculations are done for rotation at zero: we apply inverse
- # transformation to events except when we rotate and move
- state = self._state
- rotate = 'rotate' in state and self._active_handle in self._corner_order
- move = self._active_handle == 'C'
- resize = self._active_handle and not move
- xdata, ydata = self._get_data_coords(event)
- if resize:
- inv_tr = self._get_rotation_transform().inverted()
- xdata, ydata = inv_tr.transform([xdata, ydata])
- eventpress.xdata, eventpress.ydata = inv_tr.transform(
- (eventpress.xdata, eventpress.ydata))
- dx = xdata - eventpress.xdata
- dy = ydata - eventpress.ydata
- # refmax is used when moving the corner handle with the square state
- # and is the maximum between refx and refy
- refmax = None
- if self._use_data_coordinates:
- refx, refy = dx, dy
- else:
- # Get dx/dy in display coordinates
- refx = event.x - eventpress.x
- refy = event.y - eventpress.y
- x0, x1, y0, y1 = self._extents_on_press
- # rotate an existing shape
- if rotate:
- # calculate angle abc
- a = (eventpress.xdata, eventpress.ydata)
- b = self.center
- c = (xdata, ydata)
- angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
- np.arctan2(a[1]-b[1], a[0]-b[0]))
- self.rotation = np.rad2deg(self._rotation_on_press + angle)
- elif resize:
- size_on_press = [x1 - x0, y1 - y0]
- center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2)
- # Keeping the center fixed
- if 'center' in state:
- # hh, hw are half-height and half-width
- if 'square' in state:
- # when using a corner, find which reference to use
- if self._active_handle in self._corner_order:
- refmax = max(refx, refy, key=abs)
- if self._active_handle in ['E', 'W'] or refmax == refx:
- hw = xdata - center[0]
- hh = hw / self._aspect_ratio_correction
- else:
- hh = ydata - center[1]
- hw = hh * self._aspect_ratio_correction
- else:
- hw = size_on_press[0] / 2
- hh = size_on_press[1] / 2
- # cancel changes in perpendicular direction
- if self._active_handle in ['E', 'W'] + self._corner_order:
- hw = abs(xdata - center[0])
- if self._active_handle in ['N', 'S'] + self._corner_order:
- hh = abs(ydata - center[1])
- x0, x1, y0, y1 = (center[0] - hw, center[0] + hw,
- center[1] - hh, center[1] + hh)
- else:
- # change sign of relative changes to simplify calculation
- # Switch variables so that x1 and/or y1 are updated on move
- if 'W' in self._active_handle:
- x0 = x1
- if 'S' in self._active_handle:
- y0 = y1
- if self._active_handle in ['E', 'W'] + self._corner_order:
- x1 = xdata
- if self._active_handle in ['N', 'S'] + self._corner_order:
- y1 = ydata
- if 'square' in state:
- # when using a corner, find which reference to use
- if self._active_handle in self._corner_order:
- refmax = max(refx, refy, key=abs)
- if self._active_handle in ['E', 'W'] or refmax == refx:
- sign = np.sign(ydata - y0)
- y1 = y0 + sign * abs(x1 - x0) / self._aspect_ratio_correction
- else:
- sign = np.sign(xdata - x0)
- x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction
- elif move:
- x0, x1, y0, y1 = self._extents_on_press
- dx = xdata - eventpress.xdata
- dy = ydata - eventpress.ydata
- x0 += dx
- x1 += dx
- y0 += dy
- y1 += dy
- else:
- # Create a new shape
- self._rotation = 0
- # Don't create a new rectangle if there is already one when
- # ignore_event_outside=True
- if ((self.ignore_event_outside and self._selection_completed) or
- not self._allow_creation):
- return
- center = [eventpress.xdata, eventpress.ydata]
- dx = (xdata - center[0]) / 2
- dy = (ydata - center[1]) / 2
- # square shape
- if 'square' in state:
- refmax = max(refx, refy, key=abs)
- if refmax == refx:
- dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction
- else:
- dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction
- # from center
- if 'center' in state:
- dx *= 2
- dy *= 2
- # from corner
- else:
- center[0] += dx
- center[1] += dy
- x0, x1, y0, y1 = (center[0] - dx, center[0] + dx,
- center[1] - dy, center[1] + dy)
- self.extents = x0, x1, y0, y1
- @property
- def _rect_bbox(self):
- return self._selection_artist.get_bbox().bounds
- def _set_aspect_ratio_correction(self):
- aspect_ratio = self.ax._get_aspect_ratio()
- self._selection_artist._aspect_ratio_correction = aspect_ratio
- if self._use_data_coordinates:
- self._aspect_ratio_correction = 1
- else:
- self._aspect_ratio_correction = aspect_ratio
- def _get_rotation_transform(self):
- aspect_ratio = self.ax._get_aspect_ratio()
- return Affine2D().translate(-self.center[0], -self.center[1]) \
- .scale(1, aspect_ratio) \
- .rotate(self._rotation) \
- .scale(1, 1 / aspect_ratio) \
- .translate(*self.center)
- @property
- def corners(self):
- """
- Corners of rectangle in data coordinates from lower left,
- moving clockwise.
- """
- x0, y0, width, height = self._rect_bbox
- xc = x0, x0 + width, x0 + width, x0
- yc = y0, y0, y0 + height, y0 + height
- transform = self._get_rotation_transform()
- coords = transform.transform(np.array([xc, yc]).T).T
- return coords[0], coords[1]
- @property
- def edge_centers(self):
- """
- Midpoint of rectangle edges in data coordinates from left,
- moving anti-clockwise.
- """
- x0, y0, width, height = self._rect_bbox
- w = width / 2.
- h = height / 2.
- xe = x0, x0 + w, x0 + width, x0 + w
- ye = y0 + h, y0, y0 + h, y0 + height
- transform = self._get_rotation_transform()
- coords = transform.transform(np.array([xe, ye]).T).T
- return coords[0], coords[1]
- @property
- def center(self):
- """Center of rectangle in data coordinates."""
- x0, y0, width, height = self._rect_bbox
- return x0 + width / 2., y0 + height / 2.
- @property
- def extents(self):
- """
- Return (xmin, xmax, ymin, ymax) in data coordinates as defined by the
- bounding box before rotation.
- """
- x0, y0, width, height = self._rect_bbox
- xmin, xmax = sorted([x0, x0 + width])
- ymin, ymax = sorted([y0, y0 + height])
- return xmin, xmax, ymin, ymax
- @extents.setter
- def extents(self, extents):
- # Update displayed shape
- self._draw_shape(extents)
- if self._interactive:
- # Update displayed handles
- self._corner_handles.set_data(*self.corners)
- self._edge_handles.set_data(*self.edge_centers)
- x, y = self.center
- self._center_handle.set_data([x], [y])
- self.set_visible(self._visible)
- self.update()
- @property
- def rotation(self):
- """
- Rotation in degree in interval [-45°, 45°]. The rotation is limited in
- range to keep the implementation simple.
- """
- return np.rad2deg(self._rotation)
- @rotation.setter
- def rotation(self, value):
- # Restrict to a limited range of rotation [-45°, 45°] to avoid changing
- # order of handles
- if -45 <= value and value <= 45:
- self._rotation = np.deg2rad(value)
- # call extents setter to draw shape and update handles positions
- self.extents = self.extents
- def _draw_shape(self, extents):
- x0, x1, y0, y1 = extents
- xmin, xmax = sorted([x0, x1])
- ymin, ymax = sorted([y0, y1])
- xlim = sorted(self.ax.get_xlim())
- ylim = sorted(self.ax.get_ylim())
- xmin = max(xlim[0], xmin)
- ymin = max(ylim[0], ymin)
- xmax = min(xmax, xlim[1])
- ymax = min(ymax, ylim[1])
- self._selection_artist.set_x(xmin)
- self._selection_artist.set_y(ymin)
- self._selection_artist.set_width(xmax - xmin)
- self._selection_artist.set_height(ymax - ymin)
- self._selection_artist.set_angle(self.rotation)
- def _set_active_handle(self, event):
- """Set active handle based on the location of the mouse event."""
- # Note: event.xdata/ydata in data coordinates, event.x/y in pixels
- c_idx, c_dist = self._corner_handles.closest(event.x, event.y)
- e_idx, e_dist = self._edge_handles.closest(event.x, event.y)
- m_idx, m_dist = self._center_handle.closest(event.x, event.y)
- if 'move' in self._state:
- self._active_handle = 'C'
- # Set active handle as closest handle, if mouse click is close enough.
- elif m_dist < self.grab_range * 2:
- # Prioritise center handle over other handles
- self._active_handle = 'C'
- elif c_dist > self.grab_range and e_dist > self.grab_range:
- # Not close to any handles
- if self.drag_from_anywhere and self._contains(event):
- # Check if we've clicked inside the region
- self._active_handle = 'C'
- else:
- self._active_handle = None
- return
- elif c_dist < e_dist:
- # Closest to a corner handle
- self._active_handle = self._corner_order[c_idx]
- else:
- # Closest to an edge handle
- self._active_handle = self._edge_order[e_idx]
- def _contains(self, event):
- """Return True if event is within the patch."""
- return self._selection_artist.contains(event, radius=0)[0]
- @property
- def geometry(self):
- """
- Return an array of shape (2, 5) containing the
- x (``RectangleSelector.geometry[1, :]``) and
- y (``RectangleSelector.geometry[0, :]``) data coordinates of the four
- corners of the rectangle starting and ending in the top left corner.
- """
- if hasattr(self._selection_artist, 'get_verts'):
- xfm = self.ax.transData.inverted()
- y, x = xfm.transform(self._selection_artist.get_verts()).T
- return np.array([x, y])
- else:
- return np.array(self._selection_artist.get_data())
- @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace(
- '__ARTIST_NAME__', 'ellipse'))
- class EllipseSelector(RectangleSelector):
- """
- Select an elliptical region of an Axes.
- For the cursor to remain responsive you must keep a reference to it.
- Press and release events triggered at the same coordinates outside the
- selection will clear the selector, except when
- ``ignore_event_outside=True``.
- %s
- Examples
- --------
- :doc:`/gallery/widgets/rectangle_selector`
- """
- def _init_shape(self, **props):
- return Ellipse((0, 0), 0, 1, visible=False, **props)
- def _draw_shape(self, extents):
- x0, x1, y0, y1 = extents
- xmin, xmax = sorted([x0, x1])
- ymin, ymax = sorted([y0, y1])
- center = [x0 + (x1 - x0) / 2., y0 + (y1 - y0) / 2.]
- a = (xmax - xmin) / 2.
- b = (ymax - ymin) / 2.
- self._selection_artist.center = center
- self._selection_artist.width = 2 * a
- self._selection_artist.height = 2 * b
- self._selection_artist.angle = self.rotation
- @property
- def _rect_bbox(self):
- x, y = self._selection_artist.center
- width = self._selection_artist.width
- height = self._selection_artist.height
- return x - width / 2., y - height / 2., width, height
- class LassoSelector(_SelectorWidget):
- """
- Selection curve of an arbitrary shape.
- For the selector to remain responsive you must keep a reference to it.
- The selected path can be used in conjunction with `~.Path.contains_point`
- to select data points from an image.
- In contrast to `Lasso`, `LassoSelector` is written with an interface
- similar to `RectangleSelector` and `SpanSelector`, and will continue to
- interact with the Axes until disconnected.
- Example usage::
- ax = plt.subplot()
- ax.plot(x, y)
- def onselect(verts):
- print(verts)
- lasso = LassoSelector(ax, onselect)
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- onselect : function, optional
- Whenever the lasso is released, the *onselect* function is called and
- passed the vertices of the selected path.
- useblit : bool, default: True
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- props : dict, optional
- Properties with which the line is drawn, see `.Line2D`
- for valid properties. Default values are defined in ``mpl.rcParams``.
- button : `.MouseButton` or list of `.MouseButton`, optional
- The mouse buttons used for rectangle selection. Default is ``None``,
- which corresponds to all buttons.
- """
- def __init__(self, ax, onselect=None, *, useblit=True, props=None, button=None):
- super().__init__(ax, onselect, useblit=useblit, button=button)
- self.verts = None
- props = {
- **(props if props is not None else {}),
- # Note that self.useblit may be != useblit, if the canvas doesn't
- # support blitting.
- 'animated': self.useblit, 'visible': False,
- }
- line = Line2D([], [], **props)
- self.ax.add_line(line)
- self._selection_artist = line
- def _press(self, event):
- self.verts = [self._get_data(event)]
- self._selection_artist.set_visible(True)
- def _release(self, event):
- if self.verts is not None:
- self.verts.append(self._get_data(event))
- self.onselect(self.verts)
- self._selection_artist.set_data([[], []])
- self._selection_artist.set_visible(False)
- self.verts = None
- def _onmove(self, event):
- if self.verts is None:
- return
- self.verts.append(self._get_data(event))
- self._selection_artist.set_data(list(zip(*self.verts)))
- self.update()
- class PolygonSelector(_SelectorWidget):
- """
- Select a polygon region of an Axes.
- Place vertices with each mouse click, and make the selection by completing
- the polygon (clicking on the first vertex). Once drawn individual vertices
- can be moved by clicking and dragging with the left mouse button, or
- removed by clicking the right mouse button.
- In addition, the following modifier keys can be used:
- - Hold *ctrl* and click and drag a vertex to reposition it before the
- polygon has been completed.
- - Hold the *shift* key and click and drag anywhere in the Axes to move
- all vertices.
- - Press the *esc* key to start a new polygon.
- For the selector to remain responsive you must keep a reference to it.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- onselect : function, optional
- When a polygon is completed or modified after completion,
- the *onselect* function is called and passed a list of the vertices as
- ``(xdata, ydata)`` tuples.
- useblit : bool, default: False
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- props : dict, optional
- Properties with which the line is drawn, see `.Line2D` for valid properties.
- Default::
- dict(color='k', linestyle='-', linewidth=2, alpha=0.5)
- handle_props : dict, optional
- Artist properties for the markers drawn at the vertices of the polygon.
- See the marker arguments in `.Line2D` for valid
- properties. Default values are defined in ``mpl.rcParams`` except for
- the default value of ``markeredgecolor`` which will be the same as the
- ``color`` property in *props*.
- grab_range : float, default: 10
- A vertex is selected (to complete the polygon or to move a vertex) if
- the mouse click is within *grab_range* pixels of the vertex.
- draw_bounding_box : bool, optional
- If `True`, a bounding box will be drawn around the polygon selector
- once it is complete. This box can be used to move and resize the
- selector.
- box_handle_props : dict, optional
- Properties to set for the box handles. See the documentation for the
- *handle_props* argument to `RectangleSelector` for more info.
- box_props : dict, optional
- Properties to set for the box. See the documentation for the *props*
- argument to `RectangleSelector` for more info.
- Examples
- --------
- :doc:`/gallery/widgets/polygon_selector_simple`
- :doc:`/gallery/widgets/polygon_selector_demo`
- Notes
- -----
- If only one point remains after removing points, the selector reverts to an
- incomplete state and you can start drawing a new polygon from the existing
- point.
- """
- def __init__(self, ax, onselect=None, *, useblit=False,
- props=None, handle_props=None, grab_range=10,
- draw_bounding_box=False, box_handle_props=None,
- box_props=None):
- # The state modifiers 'move', 'square', and 'center' are expected by
- # _SelectorWidget but are not supported by PolygonSelector
- # Note: could not use the existing 'move' state modifier in-place of
- # 'move_all' because _SelectorWidget automatically discards 'move'
- # from the state on button release.
- state_modifier_keys = dict(clear='escape', move_vertex='control',
- move_all='shift', move='not-applicable',
- square='not-applicable',
- center='not-applicable',
- rotate='not-applicable')
- super().__init__(ax, onselect, useblit=useblit,
- state_modifier_keys=state_modifier_keys)
- self._xys = [(0, 0)]
- if props is None:
- props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5)
- props = {**props, 'animated': self.useblit}
- self._selection_artist = line = Line2D([], [], **props)
- self.ax.add_line(line)
- if handle_props is None:
- handle_props = dict(markeredgecolor='k',
- markerfacecolor=props.get('color', 'k'))
- self._handle_props = handle_props
- self._polygon_handles = ToolHandles(self.ax, [], [],
- useblit=self.useblit,
- marker_props=self._handle_props)
- self._active_handle_idx = -1
- self.grab_range = grab_range
- self.set_visible(True)
- self._draw_box = draw_bounding_box
- self._box = None
- if box_handle_props is None:
- box_handle_props = {}
- self._box_handle_props = self._handle_props.update(box_handle_props)
- self._box_props = box_props
- def _get_bbox(self):
- return self._selection_artist.get_bbox()
- def _add_box(self):
- self._box = RectangleSelector(self.ax,
- useblit=self.useblit,
- grab_range=self.grab_range,
- handle_props=self._box_handle_props,
- props=self._box_props,
- interactive=True)
- self._box._state_modifier_keys.pop('rotate')
- self._box.connect_event('motion_notify_event', self._scale_polygon)
- self._update_box()
- # Set state that prevents the RectangleSelector from being created
- # by the user
- self._box._allow_creation = False
- self._box._selection_completed = True
- self._draw_polygon()
- def _remove_box(self):
- if self._box is not None:
- self._box.set_visible(False)
- self._box = None
- def _update_box(self):
- # Update selection box extents to the extents of the polygon
- if self._box is not None:
- bbox = self._get_bbox()
- self._box.extents = [bbox.x0, bbox.x1, bbox.y0, bbox.y1]
- # Save a copy
- self._old_box_extents = self._box.extents
- def _scale_polygon(self, event):
- """
- Scale the polygon selector points when the bounding box is moved or
- scaled.
- This is set as a callback on the bounding box RectangleSelector.
- """
- if not self._selection_completed:
- return
- if self._old_box_extents == self._box.extents:
- return
- # Create transform from old box to new box
- x1, y1, w1, h1 = self._box._rect_bbox
- old_bbox = self._get_bbox()
- t = (transforms.Affine2D()
- .translate(-old_bbox.x0, -old_bbox.y0)
- .scale(1 / old_bbox.width, 1 / old_bbox.height)
- .scale(w1, h1)
- .translate(x1, y1))
- # Update polygon verts. Must be a list of tuples for consistency.
- new_verts = [(x, y) for x, y in t.transform(np.array(self.verts))]
- self._xys = [*new_verts, new_verts[0]]
- self._draw_polygon()
- self._old_box_extents = self._box.extents
- @property
- def _handles_artists(self):
- return self._polygon_handles.artists
- def _remove_vertex(self, i):
- """Remove vertex with index i."""
- if (len(self._xys) > 2 and
- self._selection_completed and
- i in (0, len(self._xys) - 1)):
- # If selecting the first or final vertex, remove both first and
- # last vertex as they are the same for a closed polygon
- self._xys.pop(0)
- self._xys.pop(-1)
- # Close the polygon again by appending the new first vertex to the
- # end
- self._xys.append(self._xys[0])
- else:
- self._xys.pop(i)
- if len(self._xys) <= 2:
- # If only one point left, return to incomplete state to let user
- # start drawing again
- self._selection_completed = False
- self._remove_box()
- def _press(self, event):
- """Button press event handler."""
- # Check for selection of a tool handle.
- if ((self._selection_completed or 'move_vertex' in self._state)
- and len(self._xys) > 0):
- h_idx, h_dist = self._polygon_handles.closest(event.x, event.y)
- if h_dist < self.grab_range:
- self._active_handle_idx = h_idx
- # Save the vertex positions at the time of the press event (needed to
- # support the 'move_all' state modifier).
- self._xys_at_press = self._xys.copy()
- def _release(self, event):
- """Button release event handler."""
- # Release active tool handle.
- if self._active_handle_idx >= 0:
- if event.button == 3:
- self._remove_vertex(self._active_handle_idx)
- self._draw_polygon()
- self._active_handle_idx = -1
- # Complete the polygon.
- elif len(self._xys) > 3 and self._xys[-1] == self._xys[0]:
- self._selection_completed = True
- if self._draw_box and self._box is None:
- self._add_box()
- # Place new vertex.
- elif (not self._selection_completed
- and 'move_all' not in self._state
- and 'move_vertex' not in self._state):
- self._xys.insert(-1, self._get_data_coords(event))
- if self._selection_completed:
- self.onselect(self.verts)
- def onmove(self, event):
- """Cursor move event handler and validator."""
- # Method overrides _SelectorWidget.onmove because the polygon selector
- # needs to process the move callback even if there is no button press.
- # _SelectorWidget.onmove include logic to ignore move event if
- # _eventpress is None.
- if self.ignore(event):
- # Hide the cursor when interactive zoom/pan is active
- if not self.canvas.widgetlock.available(self) and self._xys:
- self._xys[-1] = (np.nan, np.nan)
- self._draw_polygon()
- return False
- else:
- event = self._clean_event(event)
- self._onmove(event)
- return True
- def _onmove(self, event):
- """Cursor move event handler."""
- # Move the active vertex (ToolHandle).
- if self._active_handle_idx >= 0:
- idx = self._active_handle_idx
- self._xys[idx] = self._get_data_coords(event)
- # Also update the end of the polygon line if the first vertex is
- # the active handle and the polygon is completed.
- if idx == 0 and self._selection_completed:
- self._xys[-1] = self._get_data_coords(event)
- # Move all vertices.
- elif 'move_all' in self._state and self._eventpress:
- xdata, ydata = self._get_data_coords(event)
- dx = xdata - self._eventpress.xdata
- dy = ydata - self._eventpress.ydata
- for k in range(len(self._xys)):
- x_at_press, y_at_press = self._xys_at_press[k]
- self._xys[k] = x_at_press + dx, y_at_press + dy
- # Do nothing if completed or waiting for a move.
- elif (self._selection_completed
- or 'move_vertex' in self._state or 'move_all' in self._state):
- return
- # Position pending vertex.
- else:
- # Calculate distance to the start vertex.
- x0, y0 = \
- self._selection_artist.get_transform().transform(self._xys[0])
- v0_dist = np.hypot(x0 - event.x, y0 - event.y)
- # Lock on to the start vertex if near it and ready to complete.
- if len(self._xys) > 3 and v0_dist < self.grab_range:
- self._xys[-1] = self._xys[0]
- else:
- self._xys[-1] = self._get_data_coords(event)
- self._draw_polygon()
- def _on_key_press(self, event):
- """Key press event handler."""
- # Remove the pending vertex if entering the 'move_vertex' or
- # 'move_all' mode
- if (not self._selection_completed
- and ('move_vertex' in self._state or
- 'move_all' in self._state)):
- self._xys.pop()
- self._draw_polygon()
- def _on_key_release(self, event):
- """Key release event handler."""
- # Add back the pending vertex if leaving the 'move_vertex' or
- # 'move_all' mode (by checking the released key)
- if (not self._selection_completed
- and
- (event.key == self._state_modifier_keys.get('move_vertex')
- or event.key == self._state_modifier_keys.get('move_all'))):
- self._xys.append(self._get_data_coords(event))
- self._draw_polygon()
- # Reset the polygon if the released key is the 'clear' key.
- elif event.key == self._state_modifier_keys.get('clear'):
- event = self._clean_event(event)
- self._xys = [self._get_data_coords(event)]
- self._selection_completed = False
- self._remove_box()
- self.set_visible(True)
- def _draw_polygon_without_update(self):
- """Redraw the polygon based on new vertex positions, no update()."""
- xs, ys = zip(*self._xys) if self._xys else ([], [])
- self._selection_artist.set_data(xs, ys)
- self._update_box()
- # Only show one tool handle at the start and end vertex of the polygon
- # if the polygon is completed or the user is locked on to the start
- # vertex.
- if (self._selection_completed
- or (len(self._xys) > 3
- and self._xys[-1] == self._xys[0])):
- self._polygon_handles.set_data(xs[:-1], ys[:-1])
- else:
- self._polygon_handles.set_data(xs, ys)
- def _draw_polygon(self):
- """Redraw the polygon based on the new vertex positions."""
- self._draw_polygon_without_update()
- self.update()
- @property
- def verts(self):
- """The polygon vertices, as a list of ``(x, y)`` pairs."""
- return self._xys[:-1]
- @verts.setter
- def verts(self, xys):
- """
- Set the polygon vertices.
- This will remove any preexisting vertices, creating a complete polygon
- with the new vertices.
- """
- self._xys = [*xys, xys[0]]
- self._selection_completed = True
- self.set_visible(True)
- if self._draw_box and self._box is None:
- self._add_box()
- self._draw_polygon()
- def _clear_without_update(self):
- self._selection_completed = False
- self._xys = [(0, 0)]
- self._draw_polygon_without_update()
- class Lasso(AxesWidget):
- """
- Selection curve of an arbitrary shape.
- The selected path can be used in conjunction with
- `~matplotlib.path.Path.contains_point` to select data points from an image.
- Unlike `LassoSelector`, this must be initialized with a starting
- point *xy*, and the `Lasso` events are destroyed upon release.
- Parameters
- ----------
- ax : `~matplotlib.axes.Axes`
- The parent Axes for the widget.
- xy : (float, float)
- Coordinates of the start of the lasso.
- callback : callable
- Whenever the lasso is released, the *callback* function is called and
- passed the vertices of the selected path.
- useblit : bool, default: True
- Whether to use blitting for faster drawing (if supported by the
- backend). See the tutorial :ref:`blitting`
- for details.
- props: dict, optional
- Lasso line properties. See `.Line2D` for valid properties.
- Default *props* are::
- {'linestyle' : '-', 'color' : 'black', 'lw' : 2}
- .. versionadded:: 3.9
- """
- def __init__(self, ax, xy, callback, *, useblit=True, props=None):
- super().__init__(ax)
- self.useblit = useblit and self.canvas.supports_blit
- if self.useblit:
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
- style = {'linestyle': '-', 'color': 'black', 'lw': 2}
- if props is not None:
- style.update(props)
- x, y = xy
- self.verts = [(x, y)]
- self.line = Line2D([x], [y], **style)
- self.ax.add_line(self.line)
- self.callback = callback
- self.connect_event('button_release_event', self.onrelease)
- self.connect_event('motion_notify_event', self.onmove)
- def onrelease(self, event):
- if self.ignore(event):
- return
- if self.verts is not None:
- self.verts.append(self._get_data_coords(event))
- if len(self.verts) > 2:
- self.callback(self.verts)
- self.line.remove()
- self.verts = None
- self.disconnect_events()
- def onmove(self, event):
- if (self.ignore(event)
- or self.verts is None
- or event.button != 1
- or not self.ax.contains(event)[0]):
- return
- self.verts.append(self._get_data_coords(event))
- self.line.set_data(list(zip(*self.verts)))
- if self.useblit:
- self.canvas.restore_region(self.background)
- self.ax.draw_artist(self.line)
- self.canvas.blit(self.ax.bbox)
- else:
- self.canvas.draw_idle()
|