1# -*- encoding: utf-8 -*- 2# 3# 4# Copyright (C) 2002-2012 Jörg Lehmann <joerg@pyx-project.org> 5# Copyright (C) 2003-2011 Michael Schindler <m-schindler@users.sourceforge.net> 6# Copyright (C) 2002-2012 André Wobst <wobsta@pyx-project.org> 7# 8# This file is part of PyX (https://pyx-project.org/). 9# 10# PyX is free software; you can redistribute it and/or modify 11# it under the terms of the GNU General Public License as published by 12# the Free Software Foundation; either version 2 of the License, or 13# (at your option) any later version. 14# 15# PyX is distributed in the hope that it will be useful, 16# but WITHOUT ANY WARRANTY; without even the implied warranty of 17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18# GNU General Public License for more details. 19# 20# You should have received a copy of the GNU General Public License 21# along with PyX; if not, write to the Free Software 22# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 23 24import functools, logging, math 25from pyx import attr, unit, text 26from pyx.graph.axis import painter, parter, positioner, rater, texter, tick 27 28logger = logging.getLogger("pyx") 29class _marker: pass 30 31 32class axisdata: 33 """axis data storage class 34 35 Instances of this class are used to store axis data local to the 36 graph. It will always contain an axispos instance provided by the 37 graph during initialization.""" 38 39 def __init__(self, **kwargs): 40 for key, value in list(kwargs.items()): 41 setattr(self, key, value) 42 43 44class _axis: 45 """axis""" 46 47 def createlinked(self, data, positioner, graphtextengine, errorname, linkpainter): 48 canvas = painter.axiscanvas(self.painter, graphtextengine) 49 if linkpainter is not None: 50 linkpainter.paint(canvas, data, self, positioner) 51 return canvas 52 53 54class NoValidPartitionError(RuntimeError): 55 56 pass 57 58 59class _regularaxis(_axis): 60 """base implementation a regular axis 61 62 Regular axis have a continuous variable like linear axes, 63 logarithmic axes, time axes etc.""" 64 65 def __init__(self, min=None, max=None, reverse=0, divisor=None, title=None, 66 painter=painter.regular(), texter=texter.default(), linkpainter=painter.linked(), 67 density=1, maxworse=2, manualticks=[], fallbackrange=None): 68 if min is not None and max is not None and min > max: 69 min, max, reverse = max, min, not reverse 70 self.min = min 71 self.max = max 72 self.reverse = reverse 73 self.divisor = divisor 74 self.title = title 75 self.painter = painter 76 self.texter = texter 77 self.linkpainter = linkpainter 78 self.density = density 79 self.maxworse = maxworse 80 self.manualticks = self.checkfraclist(manualticks) 81 self.fallbackrange = fallbackrange 82 83 def createdata(self, errorname): 84 return axisdata(min=self.min, max=self.max) 85 86 zero = 0.0 87 88 def adjustaxis(self, data, columndata, graphtextengine, errorname): 89 if self.min is None or self.max is None: 90 for value in columndata: 91 try: 92 value = value + self.zero 93 except: 94 pass 95 else: 96 if self.min is None and (data.min is None or value < data.min): 97 data.min = value 98 if self.max is None and (data.max is None or value > data.max): 99 data.max = value 100 101 def checkfraclist(self, fracs): 102 "orders a list of fracs, equal entries are not allowed" 103 if not len(fracs): return [] 104 sorted = list(fracs) 105 sorted.sort() 106 last = sorted[0] 107 for item in sorted[1:]: 108 if last == item: 109 raise ValueError("duplicate entry found") 110 last = item 111 return sorted 112 113 def _create(self, data, positioner, graphtextengine, parter, rater, errorname): 114 errorname = " for axis %s" % errorname 115 if data.min is None or data.max is None: 116 raise RuntimeError("incomplete axis range%s" % errorname) 117 if data.max == data.min: 118 if self.fallbackrange is not None: 119 try: 120 data.min, data.max = data.min - 0.5*self.fallbackrange, data.min + 0.5*self.fallbackrange 121 except TypeError: 122 data.min, data.max = self.fallbackrange[0], self.fallbackrange[1] 123 else: 124 raise RuntimeError("zero axis range%s" % errorname) 125 126 if self.divisor is not None: 127 rational_divisor = tick.rational(self.divisor) 128 convert_tick = lambda x: float(x)*self.divisor 129 else: 130 convert_tick = lambda x: x 131 132 def layout(data): 133 if data.ticks: 134 self.adjustaxis(data, [convert_tick(data.ticks[0]), convert_tick(data.ticks[-1])], graphtextengine, errorname) 135 self.texter.labels(data.ticks) 136 if self.divisor: 137 for t in data.ticks: 138 t *= rational_divisor 139 canvas = painter.axiscanvas(self.painter, graphtextengine) 140 if self.painter is not None: 141 self.painter.paint(canvas, data, self, positioner) 142 return canvas 143 144 if parter is None: 145 data.ticks = self.manualticks 146 return layout(data) 147 148 # a variant is a data copy with local modifications to test several partitions 149 @functools.total_ordering 150 class variant: 151 def __init__(self, data, **kwargs): 152 self.data = data 153 for key, value in list(kwargs.items()): 154 setattr(self, key, value) 155 156 def __getattr__(self, key): 157 return getattr(data, key) 158 159 def __lt__(self, other): 160 # we can also sort variants by their rate 161 return self.rate < other.rate 162 163 def __eq__(self, other): 164 # we can also sort variants by their rate 165 return self.rate == other.rate 166 167 # build a list of variants 168 bestrate = None 169 if self.divisor is not None: 170 if data.min is not None: 171 data_min_divided = data.min/self.divisor 172 else: 173 data_min_divided = None 174 if data.max is not None: 175 data_max_divided = data.max/self.divisor 176 else: 177 data_max_divided = None 178 partfunctions = parter.partfunctions(data_min_divided, data_max_divided, 179 self.min is None, self.max is None) 180 else: 181 partfunctions = parter.partfunctions(data.min, data.max, 182 self.min is None, self.max is None) 183 variants = [] 184 for partfunction in partfunctions: 185 worse = 0 186 while worse < self.maxworse: 187 worse += 1 188 ticks = partfunction() 189 if ticks is None: 190 break 191 ticks = tick.mergeticklists(self.manualticks, ticks, mergeequal=0) 192 if ticks: 193 rate = rater.rateticks(self, ticks, self.density) 194 if rate is not None: 195 if self.reverse: 196 rate += rater.raterange(self.convert(data, convert_tick(ticks[0])) - 197 self.convert(data, convert_tick(ticks[-1])), 1) 198 else: 199 rate += rater.raterange(self.convert(data, convert_tick(ticks[-1])) - 200 self.convert(data, convert_tick(ticks[0])), 1) 201 if bestrate is None or rate < bestrate: 202 bestrate = rate 203 worse = 0 204 variants.append(variant(data, rate=rate, ticks=ticks)) 205 206 if not variants: 207 raise RuntimeError("no axis partitioning found%s" % errorname) 208 209 if len(variants) == 1 or self.painter is None: 210 # When the painter is None, we could sort the variants here by their rating. 211 # However, we didn't did this so far and there is no real reason to change that. 212 data.ticks = variants[0].ticks 213 return layout(data) 214 215 # build the layout for best variants 216 for variant in variants: 217 variant.storedcanvas = None 218 variants.sort() 219 while not variants[0].storedcanvas: 220 variants[0].storedcanvas = layout(variants[0]) 221 ratelayout = rater.ratelayout(variants[0].storedcanvas, self.density) 222 if ratelayout is None: 223 del variants[0] 224 if not variants: 225 raise NoValidPartitionError("no valid axis partitioning found%s" % errorname) 226 else: 227 variants[0].rate += ratelayout 228 variants.sort() 229 self.adjustaxis(data, variants[0].ticks, graphtextengine, errorname) 230 data.ticks = variants[0].ticks 231 return variants[0].storedcanvas 232 233 234class linear(_regularaxis): 235 """linear axis""" 236 237 def __init__(self, parter=parter.autolinear(), rater=rater.linear(), **args): 238 _regularaxis.__init__(self, **args) 239 self.parter = parter 240 self.rater = rater 241 242 def convert(self, data, value): 243 """axis coordinates -> graph coordinates""" 244 if self.reverse: 245 return (data.max - float(value)) / (data.max - data.min) 246 else: 247 return (float(value) - data.min) / (data.max - data.min) 248 249 def create(self, data, positioner, graphtextengine, errorname): 250 return _regularaxis._create(self, data, positioner, graphtextengine, self.parter, self.rater, errorname) 251 252lin = linear 253 254 255class logarithmic(_regularaxis): 256 """logarithmic axis""" 257 258 def __init__(self, parter=parter.autologarithmic(), rater=rater.logarithmic(), 259 linearparter=parter.autolinear(extendtick=None), **args): 260 _regularaxis.__init__(self, **args) 261 self.parter = parter 262 self.rater = rater 263 self.linearparter = linearparter 264 265 def convert(self, data, value): 266 """axis coordinates -> graph coordinates""" 267 # TODO: store log(data.min) and log(data.max) 268 if self.reverse: 269 return (math.log(data.max) - math.log(float(value))) / (math.log(data.max) - math.log(data.min)) 270 else: 271 return (math.log(float(value)) - math.log(data.min)) / (math.log(data.max) - math.log(data.min)) 272 273 def create(self, data, positioner, graphtextengine, errorname): 274 try: 275 return _regularaxis._create(self, data, positioner, graphtextengine, self.parter, self.rater, errorname) 276 except NoValidPartitionError: 277 if self.linearparter: 278 logger.warning("no valid logarithmic partitioning found for axis %s, switch to linear partitioning" % errorname) 279 return _regularaxis._create(self, data, positioner, graphtextengine, self.linearparter, self.rater, errorname) 280 raise 281 282log = logarithmic 283 284 285class subaxispositioner(positioner._positioner): 286 """a subaxis positioner""" 287 288 def __init__(self, basepositioner, subaxis): 289 self.basepositioner = basepositioner 290 self.vmin = subaxis.vmin 291 self.vmax = subaxis.vmax 292 self.vminover = subaxis.vminover 293 self.vmaxover = subaxis.vmaxover 294 295 def vbasepath(self, v1=None, v2=None): 296 if v1 is not None: 297 v1 = self.vmin+v1*(self.vmax-self.vmin) 298 else: 299 v1 = self.vminover 300 if v2 is not None: 301 v2 = self.vmin+v2*(self.vmax-self.vmin) 302 else: 303 v2 = self.vmaxover 304 return self.basepositioner.vbasepath(v1, v2) 305 306 def vgridpath(self, v): 307 return self.basepositioner.vgridpath(self.vmin+v*(self.vmax-self.vmin)) 308 309 def vtickpoint_pt(self, v, axis=None): 310 return self.basepositioner.vtickpoint_pt(self.vmin+v*(self.vmax-self.vmin)) 311 312 def vtickdirection(self, v, axis=None): 313 return self.basepositioner.vtickdirection(self.vmin+v*(self.vmax-self.vmin)) 314 315 316class bar(_axis): 317 318 def __init__(self, subaxes=None, defaultsubaxis=linear(painter=None, linkpainter=None, parter=None), 319 dist=0.5, firstdist=None, lastdist=None, title=None, reverse=0, 320 painter=painter.bar(), linkpainter=painter.linkedbar()): 321 self.subaxes = subaxes 322 self.defaultsubaxis = defaultsubaxis 323 self.dist = dist 324 if firstdist is not None: 325 self.firstdist = firstdist 326 else: 327 self.firstdist = 0.5 * dist 328 if lastdist is not None: 329 self.lastdist = lastdist 330 else: 331 self.lastdist = 0.5 * dist 332 self.title = title 333 self.reverse = reverse 334 self.painter = painter 335 self.linkpainter = linkpainter 336 337 def createdata(self, errorname): 338 data = axisdata(size=self.firstdist+self.lastdist-self.dist, subaxes={}, names=[]) 339 return data 340 341 def addsubaxis(self, data, name, subaxis, graphtextengine, errorname): 342 subaxis = anchoredaxis(subaxis, graphtextengine, "%s, subaxis %s" % (errorname, name)) 343 subaxis.setcreatecall(lambda: None) 344 subaxis.sized = hasattr(subaxis.data, "size") 345 if subaxis.sized: 346 data.size += subaxis.data.size 347 else: 348 data.size += 1 349 data.size += self.dist 350 data.subaxes[name] = subaxis 351 if self.reverse: 352 data.names.insert(0, name) 353 else: 354 data.names.append(name) 355 356 def adjustaxis(self, data, columndata, graphtextengine, errorname): 357 for value in columndata: 358 359 # some checks and error messages 360 try: 361 len(value) 362 except: 363 raise ValueError("tuple expected by bar axis '%s'" % errorname) 364 try: 365 value + "" 366 except: 367 pass 368 else: 369 raise ValueError("tuple expected by bar axis '%s'" % errorname) 370 assert len(value) == 2, "tuple of size two expected by bar axis '%s'" % errorname 371 372 name = value[0] 373 if name is not None and name not in data.names: 374 if self.subaxes: 375 if self.subaxes[name] is not None: 376 self.addsubaxis(data, name, self.subaxes[name], graphtextengine, errorname) 377 else: 378 self.addsubaxis(data, name, self.defaultsubaxis, graphtextengine, errorname) 379 for name in data.names: 380 subaxis = data.subaxes[name] 381 if subaxis.sized: 382 data.size -= subaxis.data.size 383 subaxis.axis.adjustaxis(subaxis.data, 384 [value[1] for value in columndata if value[0] == name], 385 graphtextengine, 386 "%s, subaxis %s" % (errorname, name)) 387 if subaxis.sized: 388 data.size += subaxis.data.size 389 390 def convert(self, data, value): 391 if value[0] is None: 392 raise ValueError 393 axis = data.subaxes[value[0]] 394 vmin = axis.vmin 395 vmax = axis.vmax 396 return axis.vmin + axis.convert(value[1]) * (axis.vmax - axis.vmin) 397 398 def create(self, data, positioner, graphtextengine, errorname): 399 canvas = painter.axiscanvas(self.painter, graphtextengine) 400 v = 0 401 position = self.firstdist 402 for name in data.names: 403 subaxis = data.subaxes[name] 404 subaxis.vmin = position / float(data.size) 405 if subaxis.sized: 406 position += subaxis.data.size 407 else: 408 position += 1 409 subaxis.vmax = position / float(data.size) 410 position += 0.5*self.dist 411 subaxis.vminover = v 412 if name == data.names[-1]: 413 subaxis.vmaxover = 1 414 else: 415 subaxis.vmaxover = position / float(data.size) 416 subaxis.setpositioner(subaxispositioner(positioner, subaxis)) 417 subaxis.create() 418 for layer, subcanvas in list(subaxis.canvas.layers.items()): 419 canvas.layer(layer).insert(subcanvas) 420 assert len(subaxis.canvas.layers) == len(subaxis.canvas.items) 421 if canvas.extent_pt < subaxis.canvas.extent_pt: 422 canvas.extent_pt = subaxis.canvas.extent_pt 423 position += 0.5*self.dist 424 v = subaxis.vmaxover 425 if self.painter is not None: 426 self.painter.paint(canvas, data, self, positioner) 427 return canvas 428 429 def createlinked(self, data, positioner, graphtextengine, errorname, linkpainter): 430 canvas = painter.axiscanvas(self.painter, graphtextengine) 431 for name in data.names: 432 subaxis = data.subaxes[name] 433 subaxis = linkedaxis(subaxis, name) 434 subaxis.setpositioner(subaxispositioner(positioner, data.subaxes[name])) 435 subaxis.create() 436 for layer, subcanvas in list(subaxis.canvas.layers.items()): 437 canvas.layer(layer).insert(subcanvas) 438 assert len(subaxis.canvas.layers) == len(subaxis.canvas.items) 439 if canvas.extent_pt < subaxis.canvas.extent_pt: 440 canvas.extent_pt = subaxis.canvas.extent_pt 441 if linkpainter is not None: 442 linkpainter.paint(canvas, data, self, positioner) 443 return canvas 444 445 446class nestedbar(bar): 447 448 def __init__(self, defaultsubaxis=bar(dist=0, painter=None, linkpainter=None), **kwargs): 449 bar.__init__(self, defaultsubaxis=defaultsubaxis, **kwargs) 450 451 452class split(bar): 453 454 def __init__(self, defaultsubaxis=linear(), 455 firstdist=0, lastdist=0, 456 painter=painter.split(), linkpainter=painter.linkedsplit(), **kwargs): 457 bar.__init__(self, defaultsubaxis=defaultsubaxis, 458 firstdist=firstdist, lastdist=lastdist, 459 painter=painter, linkpainter=linkpainter, **kwargs) 460 461 462class sizedlinear(linear): 463 464 def __init__(self, size=1, **kwargs): 465 linear.__init__(self, **kwargs) 466 self.size = size 467 468 def createdata(self, errorname): 469 data = linear.createdata(self, errorname) 470 data.size = self.size 471 return data 472 473sizedlin = sizedlinear 474 475 476class autosizedlinear(linear): 477 478 def __init__(self, parter=parter.autolinear(extendtick=None), **kwargs): 479 linear.__init__(self, parter=parter, **kwargs) 480 481 def createdata(self, errorname): 482 data = linear.createdata(self, errorname) 483 try: 484 data.size = data.max - data.min 485 except: 486 data.size = 0 487 return data 488 489 def adjustaxis(self, data, columndata, graphtextengine, errorname): 490 linear.adjustaxis(self, data, columndata, graphtextengine, errorname) 491 try: 492 data.size = data.max - data.min 493 except: 494 data.size = 0 495 496 def create(self, data, positioner, graphtextengine, errorname): 497 min = data.min 498 max = data.max 499 canvas = linear.create(self, data, positioner, graphtextengine, errorname) 500 if min != data.min or max != data.max: 501 raise RuntimeError("range change during axis creation of autosized linear axis") 502 return canvas 503 504autosizedlin = autosizedlinear 505 506 507class anchoredaxis: 508 509 def __init__(self, axis, graphtextengine, errorname): 510 assert not isinstance(axis, anchoredaxis), errorname 511 self.axis = axis 512 self.errorname = errorname 513 self.graphtextengine = graphtextengine 514 self.data = axis.createdata(self.errorname) 515 self.canvas = None 516 self.positioner = None 517 518 def setcreatecall(self, function, *args, **kwargs): 519 self._createfunction = function 520 self._createargs = args 521 self._createkwargs = kwargs 522 523 def docreate(self): 524 if not self.canvas: 525 self._createfunction(*self._createargs, **self._createkwargs) 526 527 def setpositioner(self, positioner): 528 assert positioner is not None, self.errorname 529 assert self.positioner is None, self.errorname 530 self.positioner = positioner 531 532 def convert(self, x): 533 self.docreate() 534 return self.axis.convert(self.data, x) 535 536 def adjustaxis(self, columndata): 537 if self.canvas is None: 538 self.axis.adjustaxis(self.data, columndata, self.graphtextengine, self.errorname) 539 else: 540 logger.warning("ignore axis range adjustment of already created axis '%s'" % self.errorname) 541 542 def vbasepath(self, v1=None, v2=None): 543 return self.positioner.vbasepath(v1=v1, v2=v2) 544 545 def basepath(self, x1=None, x2=None): 546 self.docreate() 547 if x1 is None: 548 if x2 is None: 549 return self.positioner.vbasepath() 550 else: 551 return self.positioner.vbasepath(v2=self.axis.convert(self.data, x2)) 552 else: 553 if x2 is None: 554 return self.positioner.vbasepath(v1=self.axis.convert(self.data, x1)) 555 else: 556 return self.positioner.vbasepath(v1=self.axis.convert(self.data, x1), 557 v2=self.axis.convert(self.data, x2)) 558 559 def vgridpath(self, v): 560 return self.positioner.vgridpath(v) 561 562 def gridpath(self, x): 563 self.docreate() 564 return self.positioner.vgridpath(self.axis.convert(self.data, x)) 565 566 def vtickpoint_pt(self, v): 567 return self.positioner.vtickpoint_pt(v) 568 569 def vtickpoint(self, v): 570 return self.positioner.vtickpoint_pt(v) * unit.t_pt 571 572 def tickpoint_pt(self, x): 573 self.docreate() 574 return self.positioner.vtickpoint_pt(self.axis.convert(self.data, x)) 575 576 def tickpoint(self, x): 577 self.docreate() 578 x_pt, y_pt = self.positioner.vtickpoint_pt(self.axis.convert(self.data, x)) 579 return x_pt * unit.t_pt, y_pt * unit.t_pt 580 581 def vtickdirection(self, v): 582 return self.positioner.vtickdirection(v) 583 584 def tickdirection(self, x): 585 self.docreate() 586 return self.positioner.vtickdirection(self.axis.convert(self.data, x)) 587 588 def create(self): 589 if self.canvas is None: 590 assert self.positioner is not None, self.errorname 591 self.canvas = self.axis.create(self.data, self.positioner, self.graphtextengine, self.errorname) 592 return self.canvas 593 594 595class linkedaxis(anchoredaxis): 596 597 def __init__(self, linkedaxis=None, errorname="manual-linked", painter=_marker): 598 self.painter = painter 599 self.linkedto = None 600 self.errorname = errorname 601 self.canvas = None 602 self.positioner = None 603 if linkedaxis: 604 self.setlinkedaxis(linkedaxis) 605 606 def setlinkedaxis(self, linkedaxis): 607 assert isinstance(linkedaxis, anchoredaxis), self.errorname 608 self.linkedto = linkedaxis 609 self.axis = linkedaxis.axis 610 self.graphtextengine = self.linkedto.graphtextengine 611 self.errorname = "%s (linked to %s)" % (self.errorname, linkedaxis.errorname) 612 self.data = linkedaxis.data 613 if self.painter is _marker: 614 self.painter = linkedaxis.axis.linkpainter 615 616 def create(self): 617 assert self.linkedto is not None, self.errorname 618 assert self.positioner is not None, self.errorname 619 if self.canvas is None: 620 self.linkedto.docreate() 621 self.canvas = self.axis.createlinked(self.data, self.positioner, self.graphtextengine, self.errorname, self.painter) 622 return self.canvas 623 624 625class anchoredpathaxis(anchoredaxis): 626 """an anchored axis along a path""" 627 628 def __init__(self, path, axis, **kwargs): 629 anchoredaxis.__init__(self, axis, text.defaulttextengine, "pathaxis") 630 self.setpositioner(positioner.pathpositioner(path, **kwargs)) 631 self.create() 632 633def pathaxis(*args, **kwargs): 634 """creates an axiscanvas for an axis along a path""" 635 return anchoredpathaxis(*args, **kwargs).canvas 636 637