├── .gitignore ├── LICENSE.md ├── README.md ├── convert.py ├── punchcard.py ├── sample.csv └── sizers.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.csv 3 | 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Michael Fogleman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Punchcard 2 | 3 | Generate GitHub-style punchcard charts with ease. 4 | 5 | python punchcard.py sample.csv output.png "Sample Chart" 6 | 7 | ![Sample](http://i.imgur.com/I50Rejy.png) 8 | 9 | ### Dependencies 10 | 11 | brew install py2cairo pango pygtk 12 | 13 | ### Command Line 14 | 15 | python punchcard.py input.csv output.png [title] 16 | 17 | ### Programmatically 18 | 19 | punchcard(png_path, data, row_labels, col_labels) 20 | 21 | `data` must be a two-dimensional array of data for the punchcard chart (a list of lists where each list is a row). `len(data) == len(row_labels)` and `len(data[0]) == len(col_labels)` 22 | 23 | The following keyword arguments are also allowed. 24 | 25 | | keyword | default | description | 26 | |------------------------|------------:|--------------------------------------------| 27 | |padding | 12| padding between chart, labels and boundary | 28 | |cell_padding | 4| padding between circles and cell edges | 29 | |min_size | 4| minimum circle size, for smallest value | 30 | |max_size | 32| maximum circle size, for largest value | 31 | |min_color | 0.8| grayscale value for smallest value | 32 | |max_color | 0.0| grayscale value for largest value | 33 | |font | 'Helvetica'| facename used for labels | 34 | |font_size | 14| font size for labels | 35 | |font_bold | False| bold labels | 36 | |title | None| title text, optional | 37 | |title_font | 'Helvetica'| facename used for title | 38 | |title_font_size | 20| font size for title | 39 | |title_font_bold | True| bold title | 40 | |diagonal_column_labels | False| diagonal column labels | 41 | -------------------------------------------------------------------------------- /convert.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This utility script converts (row, col, value) records like this: 3 | 4 | 2,6,9 5 | 2,7,23 6 | 2,8,74 7 | ... 8 | 6,20,76 9 | 6,21,27 10 | 6,22,0 11 | 12 | Into a tabular format like this: 13 | 14 | ,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22 15 | 2,9,23,74,225,351,434,513,666,710,890,776,610,435,166,100,46,1 16 | 3,12,29,53,166,250,369,370,428,549,625,618,516,386,179,101,51,5 17 | 4,9,30,79,214,350,460,478,568,677,743,700,448,473,207,138,42,2 18 | 5,9,16,84,171,294,342,435,470,552,594,642,518,350,182,95,54,2 19 | 6,13,27,93,224,402,568,693,560,527,374,364,223,139,89,76,27,0 20 | 21 | The tabular format CSV can then be used with punchcard.py 22 | ''' 23 | 24 | import csv 25 | 26 | def process(path): 27 | with open(path, 'rb') as fp: 28 | reader = csv.reader(fp) 29 | csv_rows = list(reader) 30 | rows = set() 31 | cols = set() 32 | lookup = {} 33 | int_rows = all(x[0].isdigit() for x in csv_rows[1:]) 34 | int_cols = all(x[1].isdigit() for x in csv_rows[1:]) 35 | for row, col, value in csv_rows[1:]: 36 | if int_rows: 37 | row = int(row) 38 | if int_cols: 39 | col = int(col) 40 | rows.add(row) 41 | cols.add(col) 42 | lookup[(row, col)] = value 43 | rows = sorted(rows) 44 | cols = sorted(cols) 45 | result = [[''] + cols] 46 | for row in rows: 47 | data = [lookup.get((row, col), 0) for col in cols] 48 | result.append([row] + data) 49 | with open(path, 'wb') as fp: 50 | writer = csv.writer(fp) 51 | writer.writerows(result) 52 | 53 | if __name__ == '__main__': 54 | import sys 55 | process(sys.argv[1]) 56 | -------------------------------------------------------------------------------- /punchcard.py: -------------------------------------------------------------------------------- 1 | from math import pi, sin 2 | import csv 3 | import cairo 4 | import pango 5 | import pangocairo 6 | import sizers 7 | 8 | DEFAULTS = { 9 | 'padding': 12, 10 | 'cell_padding': 4, 11 | 'min_size': 4, 12 | 'max_size': 32, 13 | 'min_color': 0.8, 14 | 'max_color': 0.0, 15 | 'font': 'Helvetica', 16 | 'font_size': 14, 17 | 'font_bold': False, 18 | 'title': None, 19 | 'title_font': 'Helvetica', 20 | 'title_font_size': 20, 21 | 'title_font_bold': True, 22 | 'diagonal_column_labels': False, 23 | } 24 | 25 | class Text(object): 26 | def __init__(self, dc=None): 27 | self.dc = dc or cairo.Context( 28 | cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1)) 29 | self.pc = pangocairo.CairoContext(self.dc) 30 | self.layout = self.pc.create_layout() 31 | def set_font(self, name, size, bold): 32 | weight = ' bold ' if bold else ' ' 33 | fd = pango.FontDescription('%s%s%d' % (name, weight, size)) 34 | self.layout.set_font_description(fd) 35 | def measure(self, text): 36 | self.layout.set_text(str(text)) 37 | return self.layout.get_pixel_size() 38 | def render(self, text): 39 | self.layout.set_text(str(text)) 40 | self.pc.update_layout(self.layout) 41 | self.pc.show_layout(self.layout) 42 | 43 | class ColLabels(sizers.Box): 44 | def __init__(self, model): 45 | super(ColLabels, self).__init__() 46 | self.model = model 47 | def get_min_size(self): 48 | if self.model.col_labels is None: 49 | return (0, 0) 50 | text = Text() 51 | text.set_font( 52 | self.model.font, self.model.font_size, self.model.font_bold) 53 | width = self.model.width 54 | height = 0 55 | for i, col in enumerate(self.model.col_labels): 56 | tw, th = text.measure(col) 57 | if self.model.diagonal_column_labels: 58 | x = i * self.model.cell_size + th / 2 59 | w = (tw + th / 2) * sin(pi / 4) 60 | width = max(width, x + w) 61 | height = max(height, w) 62 | else: 63 | height = max(height, tw) 64 | return (width, height) 65 | def render(self, dc): 66 | if self.model.col_labels is None: 67 | return 68 | dc.set_source_rgb(0, 0, 0) 69 | text = Text(dc) 70 | text.set_font( 71 | self.model.font, self.model.font_size, self.model.font_bold) 72 | for i, col in enumerate(self.model.col_labels): 73 | tw, th = text.measure(col) 74 | x = self.x + i * self.model.cell_size + th / 2 75 | y = self.bottom 76 | dc.save() 77 | if self.model.diagonal_column_labels: 78 | dc.translate(x, y - th * sin(pi / 4) / 2) 79 | dc.rotate(-pi / 4) 80 | else: 81 | dc.translate(x, y) 82 | dc.rotate(-pi / 2) 83 | dc.move_to(0, 0) 84 | text.render(col) 85 | dc.restore() 86 | 87 | class RowLabels(sizers.Box): 88 | def __init__(self, model): 89 | super(RowLabels, self).__init__() 90 | self.model = model 91 | def get_min_size(self): 92 | if self.model.row_labels is None: 93 | return (0, 0) 94 | text = Text() 95 | text.set_font( 96 | self.model.font, self.model.font_size, self.model.font_bold) 97 | width = max(text.measure(x)[0] for x in self.model.row_labels) 98 | height = self.model.height 99 | return (width, height) 100 | def render(self, dc): 101 | if self.model.row_labels is None: 102 | return 103 | dc.set_source_rgb(0, 0, 0) 104 | text = Text(dc) 105 | text.set_font( 106 | self.model.font, self.model.font_size, self.model.font_bold) 107 | for i, row in enumerate(self.model.row_labels): 108 | tw, th = text.measure(row) 109 | x = self.right - tw 110 | y = self.y + i * self.model.cell_size + th / 2 111 | dc.move_to(x, y) 112 | text.render(row) 113 | 114 | class Chart(sizers.Box): 115 | def __init__(self, model): 116 | super(Chart, self).__init__() 117 | self.model = model 118 | def get_min_size(self): 119 | return (self.model.width, self.model.height) 120 | def render(self, dc): 121 | self.render_grid(dc) 122 | self.render_punches(dc) 123 | def render_grid(self, dc): 124 | size = self.model.cell_size 125 | dc.set_source_rgb(0.5, 0.5, 0.5) 126 | dc.set_line_width(1) 127 | for i in range(self.model.cols): 128 | for j in range(self.model.rows): 129 | x = self.x + i * size - 0.5 130 | y = self.y + j * size - 0.5 131 | dc.rectangle(x, y, size, size) 132 | dc.stroke() 133 | dc.set_source_rgb(0, 0, 0) 134 | dc.set_line_width(3) 135 | width, height = self.get_min_size() 136 | dc.rectangle(self.x - 0.5, self.y - 0.5, width, height) 137 | dc.stroke() 138 | def render_punches(self, dc): 139 | data = self.model.data 140 | size = self.model.cell_size 141 | lo = min(x for row in data for x in row if x) 142 | hi = max(x for row in data for x in row if x) 143 | min_area = pi * (self.model.min_size / 2.0) ** 2 144 | max_area = pi * (self.model.max_size / 2.0) ** 2 145 | min_color = self.model.min_color 146 | max_color = self.model.max_color 147 | for i in range(self.model.cols): 148 | for j in range(self.model.rows): 149 | value = data[j][i] 150 | if not value: 151 | continue 152 | pct = 1.0 * (value - lo) / (hi - lo) 153 | # pct = pct ** 0.5 154 | area = pct * (max_area - min_area) + min_area 155 | radius = (area / pi) ** 0.5 156 | color = pct * (max_color - min_color) + min_color 157 | dc.set_source_rgb(color, color, color) 158 | x = self.x + i * size + size / 2 - 0.5 159 | y = self.y + j * size + size / 2 - 0.5 160 | dc.arc(x, y, radius, 0, 2 * pi) 161 | dc.fill() 162 | 163 | class Title(sizers.Box): 164 | def __init__(self, model): 165 | super(Title, self).__init__() 166 | self.model = model 167 | def get_min_size(self): 168 | if self.model.title is None: 169 | return (0, 0) 170 | text = Text() 171 | text.set_font( 172 | self.model.title_font, self.model.title_font_size, 173 | self.model.title_font_bold) 174 | return text.measure(self.model.title) 175 | def render(self, dc): 176 | if self.model.title is None: 177 | return 178 | dc.set_source_rgb(0, 0, 0) 179 | text = Text(dc) 180 | text.set_font( 181 | self.model.title_font, self.model.title_font_size, 182 | self.model.title_font_bold) 183 | tw, th = text.measure(self.model.title) 184 | x = max(self.x, self.x + self.model.width / 2 - tw / 2) 185 | y = self.cy - th / 2 186 | dc.move_to(x, y) 187 | text.render(self.model.title) 188 | 189 | class Model(object): 190 | def __init__(self, data, row_labels=None, col_labels=None, **kwargs): 191 | self.data = data 192 | self.row_labels = row_labels 193 | self.col_labels = col_labels 194 | for key, value in DEFAULTS.items(): 195 | value = kwargs.get(key, value) 196 | setattr(self, key, value) 197 | self.cell_size = self.max_size + self.cell_padding * 2 198 | self.rows = len(self.data) 199 | self.cols = len(self.data[0]) 200 | self.width = self.cols * self.cell_size 201 | self.height = self.rows * self.cell_size 202 | def render(self): 203 | col_labels = ColLabels(self) 204 | row_labels = RowLabels(self) 205 | chart = Chart(self) 206 | title = Title(self) 207 | grid = sizers.GridSizer(3, 2, self.padding, self.padding) 208 | grid.add_spacer() 209 | grid.add(col_labels) 210 | grid.add(row_labels) 211 | grid.add(chart) 212 | grid.add_spacer() 213 | grid.add(title) 214 | sizer = sizers.VerticalSizer() 215 | sizer.add(grid, border=self.padding) 216 | sizer.fit() 217 | surface = cairo.ImageSurface( 218 | cairo.FORMAT_RGB24, int(sizer.width), int(sizer.height)) 219 | dc = cairo.Context(surface) 220 | dc.set_source_rgb(1, 1, 1) 221 | dc.paint() 222 | col_labels.render(dc) 223 | row_labels.render(dc) 224 | chart.render(dc) 225 | title.render(dc) 226 | return surface 227 | 228 | def punchcard(path, data, row_labels, col_labels, **kwargs): 229 | model = Model(data, row_labels, col_labels, **kwargs) 230 | surface = model.render() 231 | surface.write_to_png(path) 232 | 233 | def punchcard_from_csv(csv_path, path, **kwargs): 234 | with open(csv_path, 'rb') as fp: 235 | reader = csv.reader(fp) 236 | csv_rows = list(reader) 237 | row_labels = [x[0] for x in csv_rows[1:]] 238 | col_labels = csv_rows[0][1:] 239 | data = [] 240 | for csv_row in csv_rows[1:]: 241 | row = [] 242 | for value in csv_row[1:]: 243 | try: 244 | value = float(value) 245 | except ValueError: 246 | value = None 247 | row.append(value) 248 | data.append(row) 249 | punchcard(path, data, row_labels, col_labels, **kwargs) 250 | 251 | if __name__ == '__main__': 252 | import sys 253 | args = sys.argv[1:] 254 | if len(args) == 2: 255 | punchcard_from_csv(args[0], args[1]) 256 | elif len(args) == 3: 257 | punchcard_from_csv(args[0], args[1], title=args[2]) 258 | else: 259 | print 'Usage: python punchcard.py input.csv output.png [title]' 260 | -------------------------------------------------------------------------------- /sample.csv: -------------------------------------------------------------------------------- 1 | ,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 2 | Sunday,0,0,0,0,0,0,3,445,544,818,756,477,538,493,589,611,351,650,211,5,1,0,0,0 3 | Monday,0,0,0,1,0,0,144,2193,2667,5443,5444,5029,6198,4324,4849,4051,2894,2667,1471,832,510,417,64,0 4 | Tuesday,3,5,3,1,0,0,230,1716,2936,3954,4516,3955,4081,3628,3928,3481,3094,2688,2068,1260,1119,622,209,14 5 | Wednesday,0,0,0,9,0,0,242,2308,4310,4680,4065,4727,4615,4628,4964,4282,4748,4564,3215,1642,987,714,306,0 6 | Thursday,0,0,0,3,0,0,247,1992,3912,4536,3436,4633,4083,3728,3516,2339,2915,2345,1403,826,741,375,219,1 7 | Friday,0,0,0,0,0,0,132,1367,2226,2618,1883,2428,2005,1991,2190,1495,1824,1448,800,556,366,319,13,0 8 | Saturday,0,0,0,6,0,0,46,411,624,684,800,332,154,72,98,448,353,532,270,4,0,0,0,0 9 | -------------------------------------------------------------------------------- /sizers.py: -------------------------------------------------------------------------------- 1 | from itertools import product 2 | 3 | # Orientation 4 | HORIZONTAL = 1 5 | VERTICAL = 2 6 | 7 | # Alignment 8 | NONE = 0 9 | LEFT = 1 10 | RIGHT = 2 11 | TOP = 3 12 | BOTTOM = 4 13 | CENTER = 5 14 | 15 | def unpack_border(border): 16 | try: 17 | l, t, r, b = border 18 | return (l, t, r, b) 19 | except Exception: 20 | pass 21 | try: 22 | x, y = border 23 | return (x, y, x, y) 24 | except Exception: 25 | pass 26 | n = border 27 | return (n, n, n, n) 28 | 29 | class Target(object): 30 | def get_min_size(self): 31 | raise NotImplementedError 32 | def get_dimensions(self): 33 | raise NotImplementedError 34 | def set_dimensions(self, x, y, width, height): 35 | raise NotImplementedError 36 | x = l = left = property(lambda self: self.get_dimensions()[0]) 37 | y = t = top = property(lambda self: self.get_dimensions()[1]) 38 | w = width = property(lambda self: self.get_dimensions()[2]) 39 | h = height = property(lambda self: self.get_dimensions()[3]) 40 | r = right = property(lambda self: self.x + self.w) 41 | b = bottom = property(lambda self: self.y + self.h) 42 | cx = property(lambda self: self.x + self.w / 2) 43 | cy = property(lambda self: self.y + self.h / 2) 44 | 45 | class Box(Target): 46 | def __init__(self, width=0, height=0): 47 | self.min_size = (width, height) 48 | self.dimensions = (0, 0, width, height) 49 | def get_min_size(self): 50 | return self.min_size 51 | def get_dimensions(self): 52 | return self.dimensions 53 | def set_dimensions(self, x, y, width, height): 54 | self.dimensions = (x, y, width, height) 55 | 56 | class SizerItem(object): 57 | def __init__(self, target, proportion, expand, border, align): 58 | self.target = target 59 | self.proportion = proportion 60 | self.expand = expand 61 | self.border = unpack_border(border) 62 | self.align = align 63 | def get_min_size(self): 64 | l, t, r, b = self.border 65 | width, height = self.target.get_min_size() 66 | width = width + l + r 67 | height = height + t + b 68 | return (width, height) 69 | def get_dimensions(self): 70 | return self.target.get_dimensions() 71 | def set_dimensions(self, x, y, width, height): 72 | l, t, r, b = self.border 73 | lr, tb = l + r, t + b 74 | self.target.set_dimensions(x + l, y + t, width - lr, height - tb) 75 | 76 | class Sizer(Target): 77 | def __init__(self): 78 | self.items = [] 79 | self.dimensions = (0, 0, 0, 0) 80 | def add(self, target, proportion=0, expand=False, border=0, align=NONE): 81 | item = SizerItem(target, proportion, expand, border, align) 82 | self.items.append(item) 83 | def add_spacer(self, size=0): 84 | spacer = Box(size, size) 85 | self.add(spacer) 86 | def add_stretch_spacer(self, proportion=1): 87 | spacer = Box() 88 | self.add(spacer, proportion) 89 | def get_dimensions(self): 90 | return self.dimensions 91 | def set_dimensions(self, x, y, width, height): 92 | min_width, min_height = self.get_min_size() 93 | width = max(min_width, width) 94 | height = max(min_height, height) 95 | self.dimensions = (x, y, width, height) 96 | self.layout() 97 | def fit(self): 98 | width, height = self.get_min_size() 99 | self.set_dimensions(0, 0, width, height) 100 | def get_min_size(self): 101 | raise NotImplementedError 102 | def layout(self): 103 | raise NotImplementedError 104 | 105 | class BoxSizer(Sizer): 106 | def __init__(self, orientation): 107 | super(BoxSizer, self).__init__() 108 | self.orientation = orientation 109 | def get_min_size(self): 110 | width = 0 111 | height = 0 112 | for item in self.items: 113 | w, h = item.get_min_size() 114 | if self.orientation == HORIZONTAL: 115 | width += w 116 | height = max(height, h) 117 | else: 118 | width = max(width, w) 119 | height += h 120 | return (width, height) 121 | def layout(self): 122 | x, y = self.x, self.y 123 | width, height = self.width, self.height 124 | min_width, min_height = self.get_min_size() 125 | extra_width = max(0, width - min_width) 126 | extra_height = max(0, height - min_height) 127 | total_proportions = float(sum(item.proportion for item in self.items)) 128 | if self.orientation == HORIZONTAL: 129 | for item in self.items: 130 | w, h = item.get_min_size() 131 | if item.expand: 132 | h = height 133 | if item.proportion: 134 | p = item.proportion / total_proportions 135 | w += int(extra_width * p) 136 | if item.align == CENTER: 137 | offset = height / 2 - h / 2 138 | item.set_dimensions(x, y + offset, w, h) 139 | elif item.align == BOTTOM: 140 | item.set_dimensions(x, y + height - h, w, h) 141 | else: # TOP 142 | item.set_dimensions(x, y, w, h) 143 | x += w 144 | else: 145 | for item in self.items: 146 | w, h = item.get_min_size() 147 | if item.expand: 148 | w = width 149 | if item.proportion: 150 | p = item.proportion / total_proportions 151 | h += int(extra_height * p) 152 | if item.align == CENTER: 153 | offset = width / 2 - w / 2 154 | item.set_dimensions(x + offset, y, w, h) 155 | elif item.align == RIGHT: 156 | item.set_dimensions(x + width - w, y, w, h) 157 | else: # LEFT 158 | item.set_dimensions(x, y, w, h) 159 | y += h 160 | 161 | class HorizontalSizer(BoxSizer): 162 | def __init__(self): 163 | super(HorizontalSizer, self).__init__(HORIZONTAL) 164 | 165 | class VerticalSizer(BoxSizer): 166 | def __init__(self): 167 | super(VerticalSizer, self).__init__(VERTICAL) 168 | 169 | class GridSizer(Sizer): 170 | def __init__(self, rows, cols, row_spacing=0, col_spacing=0): 171 | super(GridSizer, self).__init__() 172 | self.rows = rows 173 | self.cols = cols 174 | self.row_spacing = row_spacing 175 | self.col_spacing = col_spacing 176 | self.row_proportions = {} 177 | self.col_proportions = {} 178 | def set_row_proportion(self, row, proportion): 179 | self.row_proportions[row] = proportion 180 | def set_col_proportion(self, col, proportion): 181 | self.col_proportions[col] = proportion 182 | def get_rows_cols(self): 183 | rows, cols = self.rows, self.cols 184 | count = len(self.items) 185 | if rows <= 0: 186 | rows = count / cols + int(bool(count % cols)) 187 | if cols <= 0: 188 | cols = count / rows + int(bool(count % rows)) 189 | return (rows, cols) 190 | def get_row_col_sizes(self): 191 | rows, cols = self.get_rows_cols() 192 | row_heights = [0] * rows 193 | col_widths = [0] * cols 194 | positions = product(range(rows), range(cols)) 195 | for item, (row, col) in zip(self.items, positions): 196 | w, h = item.get_min_size() 197 | row_heights[row] = max(h, row_heights[row]) 198 | col_widths[col] = max(w, col_widths[col]) 199 | return row_heights, col_widths 200 | def get_min_size(self): 201 | row_heights, col_widths = self.get_row_col_sizes() 202 | width = sum(col_widths) + self.col_spacing * (len(col_widths) - 1) 203 | height = sum(row_heights) + self.row_spacing * (len(row_heights) - 1) 204 | return (width, height) 205 | def layout(self): 206 | row_spacing, col_spacing = self.row_spacing, self.col_spacing 207 | min_width, min_height = self.get_min_size() 208 | extra_width = max(0, self.width - min_width) 209 | extra_height = max(0, self.height - min_height) 210 | rows, cols = self.get_rows_cols() 211 | row_proportions = [ 212 | self.row_proportions.get(row, 0) for row in range(rows)] 213 | col_proportions = [ 214 | self.col_proportions.get(col, 0) for col in range(cols)] 215 | total_row_proportions = float(sum(row_proportions)) 216 | total_col_proportions = float(sum(col_proportions)) 217 | row_heights, col_widths = self.get_row_col_sizes() 218 | for row, proportion in enumerate(row_proportions): 219 | if proportion: 220 | p = proportion / total_row_proportions 221 | row_heights[row] += int(extra_height * p) 222 | for col, proportion in enumerate(col_proportions): 223 | if proportion: 224 | p = proportion / total_col_proportions 225 | col_widths[col] += int(extra_width * p) 226 | row_y = [sum(row_heights[:i]) + row_spacing * i for i in range(rows)] 227 | col_x = [sum(col_widths[:i]) + col_spacing * i for i in range(cols)] 228 | positions = product(range(rows), range(cols)) 229 | for item, (row, col) in zip(self.items, positions): 230 | x, y = self.x + col_x[col], self.y + row_y[row] 231 | w, h = col_widths[col], row_heights[row] 232 | item.set_dimensions(x, y, w, h) 233 | 234 | def main(): 235 | a = Box(10, 10) 236 | b = Box(25, 25) 237 | c = Box(50, 10) 238 | # sizer = VerticalSizer() 239 | sizer = GridSizer(2, 2) 240 | sizer.add(a) 241 | sizer.add(b) 242 | sizer.add(c) 243 | sizer.fit() 244 | print a.dimensions 245 | print b.dimensions 246 | print c.dimensions 247 | 248 | if __name__ == '__main__': 249 | main() 250 | --------------------------------------------------------------------------------