def _draw_circle_segment( self, dr: svgwrite.Drawing, tracks: List[Track], a1: float, a2: float, rr: ValueRange, center: XY, ): length = sum([t.length for t in tracks]) has_special = len([t for t in tracks if t.special]) > 0 color = self.color(self.poster.length_range_by_date, length, has_special) r1 = rr.lower() r2 = ( rr.lower() + rr.diameter() * length / self.poster.length_range_by_date.upper() ) sin_a1, cos_a1 = math.sin(a1), math.cos(a1) sin_a2, cos_a2 = math.sin(a2), math.cos(a2) path = dr.path( d=("M", center.x + r1 * sin_a1, center.y - r1 * cos_a1), fill=color, stroke="none", ) path.push("l", (r2 - r1) * sin_a1, (r1 - r2) * cos_a1) path.push(f"a{r2},{r2} 0 0,0 {r2 * (sin_a2 - sin_a1)},{r2 * (cos_a1 - cos_a2)}") path.push("l", (r1 - r2) * sin_a2, (r2 - r1) * cos_a2) dr.add(path)
def _draw_circle_segment( self, dr: svgwrite.Drawing, g: svgwrite.container.Group, tracks: typing.List[Track], a1: float, a2: float, rr: ValueRange, center: XY, ) -> None: length = sum([t.length() for t in tracks]) has_special = len([t for t in tracks if t.special]) > 0 color = self.color(self.poster.length_range_by_date, length, has_special) max_length = self.poster.length_range_by_date.upper() assert max_length is not None r1 = rr.lower() assert r1 is not None r2 = rr.interpolate((length / max_length).magnitude) sin_a1, cos_a1 = math.sin(a1), math.cos(a1) sin_a2, cos_a2 = math.sin(a2), math.cos(a2) path = dr.path( d=("M", center.x + r1 * sin_a1, center.y - r1 * cos_a1), fill=color, stroke="none", ) path.push("l", (r2 - r1) * sin_a1, (r1 - r2) * cos_a1) path.push( f"a{r2},{r2} 0 0,0 {r2 * (sin_a2 - sin_a1)},{r2 * (cos_a1 - cos_a2)}" ) path.push("l", (r1 - r2) * sin_a2, (r2 - r1) * cos_a2) date_title = str(tracks[0].start_time().date()) str_length = utils.format_float(self.poster.m2u(length)) path.set_desc(title=f"{date_title} {str_length} {self.poster.u()}") g.add(path)
def compute_bounds_xy(lines: typing.List[typing.List[XY]]) -> typing.Tuple[ValueRange, ValueRange]: range_x = ValueRange() range_y = ValueRange() for line in lines: for xy in line: range_x.extend(xy.x) range_y.extend(xy.y) return range_x, range_y
def _draw_rings(self, dr: svgwrite.Drawing, g: svgwrite.container.Group, center: XY, radius_range: ValueRange) -> None: length_range = self.poster.length_range_by_date if not length_range.is_valid(): return min_length = length_range.lower() max_length = length_range.upper() assert min_length is not None assert max_length is not None ring_distance = self._determine_ring_distance(max_length) if ring_distance is None: return distance = ring_distance while distance < max_length: radius = radius_range.interpolate( (distance / max_length).magnitude) g.add( dr.circle( center=center.tuple(), r=radius, stroke=self._ring_color, stroke_opacity="0.2", fill="none", stroke_width=0.3, )) distance += ring_distance
def color(self, length_range: ValueRange, length: float, is_special: bool = False) -> str: assert length_range.is_valid() assert length_range.contains(length) color1 = (self.poster.colors["special"] if is_special else self.poster.colors["track"]) color2 = (self.poster.colors["special2"] if is_special else self.poster.colors["track2"]) diff = length_range.diameter() if diff == 0: return color1 return utils.interpolate_color(color1, color2, (length - length_range.lower()) / diff)
def _draw_rings(self, dr: svgwrite.Drawing, center: XY, radius_range: ValueRange): length_range = self.poster.length_range_by_date ring_distance = self._determine_ring_distance() if ring_distance is None: return distance = ring_distance while distance < length_range.upper(): radius = ( radius_range.lower() + radius_range.diameter() * distance / length_range.upper() ) dr.add( dr.circle( center=center.tuple(), r=radius, stroke=self._ring_color, stroke_opacity="0.2", fill="none", stroke_width=0.3, ) ) distance += ring_distance
def set_tracks(self, tracks): """Associate the set of tracks with this poster. In addition to setting self.tracks, also compute the necessary attributes for the Poster based on this set of tracks. """ self.tracks = tracks self.tracks_by_date = {} self.length_range = ValueRange() self.length_range_by_date = ValueRange() self.__compute_years(tracks) for track in tracks: if not self.years.contains(track.start_time): continue text_date = track.start_time.strftime("%Y-%m-%d") if text_date in self.tracks_by_date: self.tracks_by_date[text_date].append(track) else: self.tracks_by_date[text_date] = [track] self.length_range.extend(track.length) for tracks in self.tracks_by_date.values(): length = sum([t.length for t in tracks]) self.length_range_by_date.extend(length)
def __compute_track_statistics(self): length_range = ValueRange() total_length = 0 weeks = {} for t in self.tracks: total_length += t.length length_range.extend(t.length) # time.isocalendar()[1] -> week number weeks[(t.start_time.year, t.start_time.isocalendar()[1])] = 1 return ( total_length, total_length / len(self.tracks), length_range.lower(), length_range.upper(), len(weeks), )
def _draw_year(self, dr: svgwrite.Drawing, g: svgwrite.container.Group, size: XY, offset: XY, year: int) -> None: min_size = min(size.x, size.y) outer_radius = 0.5 * min_size - 6 radius_range = ValueRange.from_pair(outer_radius / 4, outer_radius) center = offset + 0.5 * size if self._rings: self._draw_rings(dr, g, center, radius_range) year_style = f"dominant-baseline: central; font-size:{min_size * 4.0 / 80.0}px; font-family:Arial;" month_style = f"font-size:{min_size * 3.0 / 80.0}px; font-family:Arial;" g.add( dr.text( f"{year}", insert=center.tuple(), fill=self.poster.colors["text"], text_anchor="middle", alignment_baseline="middle", style=year_style, )) df = 360.0 / (366 if calendar.isleap(year) else 365) day = 0 date = datetime.date(year, 1, 1) while date.year == year: text_date = date.strftime("%Y-%m-%d") a1 = math.radians(day * df) a2 = math.radians((day + 1) * df) if date.day == 1: (_, last_day) = calendar.monthrange(date.year, date.month) a3 = math.radians((day + last_day - 1) * df) sin_a1, cos_a1 = math.sin(a1), math.cos(a1) sin_a3, cos_a3 = math.sin(a3), math.cos(a3) r1 = outer_radius + 1 r2 = outer_radius + 6 r3 = outer_radius + 2 g.add( dr.line( start=(center + r1 * XY(sin_a1, -cos_a1)).tuple(), end=(center + r2 * XY(sin_a1, -cos_a1)).tuple(), stroke=self.poster.colors["text"], stroke_width=0.3, )) path = dr.path( d=("M", center.x + r3 * sin_a1, center.y - r3 * cos_a1), fill="none", stroke="none", ) path.push( f"a{r3},{r3} 0 0,1 {r3 * (sin_a3 - sin_a1)},{r3 * (cos_a1 - cos_a3)}" ) g.add(path) tpath = svgwrite.text.TextPath( path, self.poster.month_name(date.month), startOffset=(0.5 * r3 * (a3 - a1))) text = dr.text( "", fill=self.poster.colors["text"], text_anchor="middle", style=month_style, ) text.add(tpath) g.add(text) if text_date in self.poster.tracks_by_date: self._draw_circle_segment( dr, g, self.poster.tracks_by_date[text_date], a1, a2, radius_range, center, ) day += 1 date += datetime.timedelta(1)
class Poster: """Create a poster from track data. Attributes: athlete: Name of athlete to be displayed on poster. title: Title of poster. tracks_by_date: Tracks organized temporally if needed. tracks: List of tracks to be used in the poster. length_range: Range of lengths of tracks in poster. length_range_by_date: Range of lengths organized temporally. units: Length units to be used in poster. colors: Colors for various components of the poster. width: Poster width. height: Poster height. years: Years included in the poster. tracks_drawer: drawer used to draw the poster. Methods: set_tracks: Associate the Poster with a set of tracks draw: Draw the tracks on the poster. m2u: Convert meters to kilometers or miles based on units u: Return distance unit (km or mi) """ def __init__(self): self.athlete = None self.title = None self.tracks_by_date = {} self.tracks = [] self.length_range = None self.length_range_by_date = None self.units = "metric" self.colors = { "background": "#222222", "text": "#FFFFFF", "special": "#FFFF00", "track": "#4DD2FF", } self.special_distance = { "special_distance1": "10", "special_distance2": "20" } self.width = 200 self.height = 300 self.years = None self.tracks_drawer = None self.trans = None self.set_language(None) def set_language(self, language): if language: try: locale.setlocale(locale.LC_ALL, f"{language}.utf8") except locale.Error as e: print(f'Cannot set locale to "{language}": {e}') language = None pass # Fall-back to NullTranslations, if the specified language translation cannot be found. if language: lang = gettext.translation("gpxposter", localedir="locale", languages=[language], fallback=True) else: lang = gettext.NullTranslations() self.trans = lang.gettext def set_tracks(self, tracks): """Associate the set of tracks with this poster. In addition to setting self.tracks, also compute the necessary attributes for the Poster based on this set of tracks. """ self.tracks = tracks self.tracks_by_date = {} self.length_range = ValueRange() self.length_range_by_date = ValueRange() self.__compute_years(tracks) for track in tracks: if not self.years.contains(track.start_time): continue text_date = track.start_time.strftime("%Y-%m-%d") if text_date in self.tracks_by_date: self.tracks_by_date[text_date].append(track) else: self.tracks_by_date[text_date] = [track] self.length_range.extend(track.length) for tracks in self.tracks_by_date.values(): length = sum([t.length for t in tracks]) self.length_range_by_date.extend(length) def draw(self, drawer, output): """Set the Poster's drawer and draw the tracks.""" self.tracks_drawer = drawer d = svgwrite.Drawing(output, (f"{self.width}mm", f"{self.height}mm")) d.viewbox(0, 0, self.width, self.height) d.add( d.rect((0, 0), (self.width, self.height), fill=self.colors["background"])) self.__draw_header(d) self.__draw_footer(d) self.__draw_tracks(d, XY(self.width - 20, self.height - 30 - 30), XY(10, 30)) d.save() def m2u(self, m): """Convert meters to kilometers or miles, according to units.""" if self.units == "metric": return 0.001 * m return 0.001 * m / 1.609344 def u(self): """Return the unit of distance being used on the Poster.""" if self.units == "metric": return "km" return "mi" def format_distance(self, d: float) -> str: """Formats a distance using the locale specific float format and the selected unit.""" return format_float(self.m2u(d)) + " " + self.u() def __draw_tracks(self, d, size: XY, offset: XY): self.tracks_drawer.draw(d, size, offset) def __draw_header(self, d): text_color = self.colors["text"] title_style = "font-size:12px; font-family:Arial; font-weight:bold;" d.add( d.text(self.title, insert=(10, 20), fill=text_color, style=title_style)) def __draw_footer(self, d): text_color = self.colors["text"] header_style = "font-size:4px; font-family:Arial" value_style = "font-size:9px; font-family:Arial" small_value_style = "font-size:3px; font-family:Arial" ( total_length, average_length, min_length, max_length, weeks, ) = self.__compute_track_statistics() d.add( d.text( self.trans("ATHLETE"), insert=(10, self.height - 20), fill=text_color, style=header_style, )) d.add( d.text( self.athlete, insert=(10, self.height - 10), fill=text_color, style=value_style, )) d.add( d.text( self.trans("STATISTICS"), insert=(120, self.height - 20), fill=text_color, style=header_style, )) d.add( d.text( self.trans("Number") + f": {len(self.tracks)}", insert=(120, self.height - 15), fill=text_color, style=small_value_style, )) d.add( d.text( self.trans("Weekly") + ": " + format_float(len(self.tracks) / weeks), insert=(120, self.height - 10), fill=text_color, style=small_value_style, )) d.add( d.text( self.trans("Total") + ": " + self.format_distance(total_length), insert=(139, self.height - 15), fill=text_color, style=small_value_style, )) d.add( d.text( self.trans("Avg") + ": " + self.format_distance(average_length), insert=(139, self.height - 10), fill=text_color, style=small_value_style, )) d.add( d.text( self.trans("Min") + ": " + self.format_distance(min_length), insert=(167, self.height - 15), fill=text_color, style=small_value_style, )) d.add( d.text( self.trans("Max") + ": " + self.format_distance(max_length), insert=(167, self.height - 10), fill=text_color, style=small_value_style, )) def __compute_track_statistics(self): length_range = ValueRange() total_length = 0 total_length_year_dict = defaultdict(int) weeks = {} for t in self.tracks: total_length += t.length total_length_year_dict[t.start_time.year] += t.length length_range.extend(t.length) # time.isocalendar()[1] -> week number weeks[(t.start_time.year, t.start_time.isocalendar()[1])] = 1 self.total_length_year_dict = total_length_year_dict return ( total_length, total_length / len(self.tracks), length_range.lower(), length_range.upper(), len(weeks), ) def __compute_years(self, tracks): if self.years is not None: return self.years = YearRange() for t in tracks: self.years.add(t.start_time)