├── nicolas_seriot.png ├── Swift ├── PulsarRuns.xcodeproj │ ├── xcuserdata │ │ └── nst.xcuserdatad │ │ │ ├── xcdebugger │ │ │ └── Breakpoints_v2.xcbkptlist │ │ │ └── xcschemes │ │ │ ├── xcschememanagement.plist │ │ │ └── PulsarRuns.xcscheme │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcuserdata │ │ │ └── nst.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ └── project.pbxproj ├── CanvasView.swift └── PulsarRuns │ ├── main.swift │ └── RunningView.swift ├── README.md ├── LICENSE └── Python ├── pulsar_runs_draw.py └── pulsar_runs_data.py /nicolas_seriot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/PulsarRuns/master/nicolas_seriot.png -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/xcuserdata/nst.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/project.xcworkspace/xcuserdata/nst.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/PulsarRuns/master/Swift/PulsarRuns.xcodeproj/project.xcworkspace/xcuserdata/nst.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/xcuserdata/nst.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | PulsarRuns.xcscheme 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 03A037B01D2299610038A0BB 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PulsarRuns 2 | Pulsar-plot of Strava runs 3 | 4 | Inspired by [http://www.xavigimenez.net/blog/2015/05/plotting-my-strava-running-activity-as-a-pulsar-plot/](http://www.xavigimenez.net/blog/2015/05/plotting-my-strava-running-activity-as-a-pulsar-plot/) 5 | 6 | __Python Version__ 7 | 8 | Requires the requests and cairo modules. 9 | 10 | 1. get a Strava access token from [https://www.strava.com/settings/api](https://www.strava.com/settings/api) and put on top of `pulsar_runs_data.py`. 11 | 2. run `pulsar_runs_data.py` to download your activites 12 | 3. run `pulsar_runs_draw.py` to draw your activities 13 | 14 | __Swift Version (Unmaintained)__ 15 | 16 | 1. get a Strava access token from [https://www.strava.com/settings/api](https://www.strava.com/settings/api) and put it in `downloadAndDumpAthleteAndActivities()` 17 | 2. run the code once to download the data and save it in `/tmp` 18 | 3. set `download` to `false` and run the code again to draw the picture 19 | 20 | ![PulsarRuns](https://raw.githubusercontent.com/nst/PulsarRuns/master/nicolas_seriot.png) 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Nicolas Seriot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/xcuserdata/nst.xcuserdatad/xcschemes/PulsarRuns.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Python/pulsar_runs_draw.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import json 5 | import cairo 6 | 7 | ROOT_DIR = "/Users/nst/Library/Application Support/PulsarRuns" 8 | PULSAR_DIR = "pulsar" 9 | PULSAR_PATH = os.path.sep.join([ROOT_DIR, PULSAR_DIR]) 10 | 11 | CANVAS_W = 1000 12 | CANVAS_H = 4200 13 | 14 | MAX_ACTIVITY_H = 100.0 15 | 16 | TOP_MARGIN_H = 50.0 17 | 18 | LEFT_MARGIN_W = 50.0 19 | LEFT_FLAT_ZONE_W = 50.0 20 | 21 | RIGHT_MARGIN_W = 50.0 22 | RIGHT_FLAT_ZONE_W = 50.0 23 | RIGHT_TEXT_ZONE_W = 460.0 24 | 25 | ACTIVITIES_OFFSET_H = 20.0 26 | 27 | GRAY_COLOR = (0.5, 0.5, 0.5) 28 | WHITE_COLOR = (1,1,1) 29 | BLACK_COLOR = (0,0,0) 30 | CLEAR_COLOR = (0,0,0,0) # transparent black 31 | 32 | def sort_activities(a): 33 | low = a["lowest_relative_altitude"] 34 | high = a["highest_relative_altitude"] 35 | 36 | return low if abs(low) > high else high 37 | 38 | if __name__ == "__main__": 39 | 40 | # check that relevant directories exist 41 | 42 | for p in [ROOT_DIR, PULSAR_PATH]: 43 | if not os.path.exists(p): 44 | print("--" + p + "is missing") 45 | sys.exit(1) 46 | 47 | # read athlete 48 | 49 | with open(os.path.sep.join([ROOT_DIR, "athlete.json"])) as f: 50 | athlete = json.load(f) 51 | 52 | # read activities in /pulsar/ directory 53 | 54 | activities_paths = [os.path.sep.join([PULSAR_PATH, f]) for f in os.listdir(PULSAR_PATH) if os.path.splitext(f)[1] == ".json"] 55 | 56 | activities = [] 57 | 58 | for p in activities_paths: 59 | with open(p) as f: 60 | a = json.load(f) 61 | activities.append(a) 62 | 63 | max_distance = max([a["distances"][-1] for a in activities]) 64 | 65 | max_altitude_delta = max([max(a["relative_altitudes"]) for a in activities]) 66 | 67 | activities.sort(key=sort_activities, reverse=True) 68 | 69 | img = cairo.ImageSurface(cairo.FORMAT_ARGB32, CANVAS_W, CANVAS_H) 70 | c = cairo.Context(img) 71 | 72 | c.set_source_rgb(*BLACK_COLOR) 73 | c.rectangle(0, 0, CANVAS_W, CANVAS_H) 74 | c.fill() 75 | 76 | for i,a in enumerate(activities): 77 | 78 | # 1. compute base Y 79 | 80 | base_y = TOP_MARGIN_H + MAX_ACTIVITY_H + ACTIVITIES_OFFSET_H * i 81 | 82 | # 2. draw left gray line 83 | 84 | c.set_source_rgb(*GRAY_COLOR) 85 | 86 | c.move_to(LEFT_MARGIN_W, base_y) 87 | c.line_to(LEFT_MARGIN_W+LEFT_FLAT_ZONE_W, base_y) 88 | c.stroke() 89 | 90 | # 3. draw run profile in white 91 | 92 | low = a["lowest_relative_altitude"] 93 | high = a["highest_relative_altitude"] 94 | 95 | c.move_to(LEFT_MARGIN_W + LEFT_FLAT_ZONE_W, base_y) 96 | 97 | first_altitude = a["relative_altitudes"][0] 98 | 99 | last_x = 0.0 100 | 101 | for j,rel_alt in enumerate(a["relative_altitudes"]): 102 | 103 | y = base_y - MAX_ACTIVITY_H * (rel_alt / max_altitude_delta) 104 | 105 | x_ratio = a["distances"][j] / max_distance 106 | 107 | x = LEFT_MARGIN_W + LEFT_FLAT_ZONE_W + (CANVAS_W - LEFT_MARGIN_W - RIGHT_MARGIN_W - LEFT_FLAT_ZONE_W - RIGHT_FLAT_ZONE_W - RIGHT_TEXT_ZONE_W) * x_ratio 108 | last_x = x 109 | 110 | c.line_to(x,y) 111 | 112 | profile_is_more_down = abs(low) > high 113 | fill_color = CLEAR_COLOR if profile_is_more_down else BLACK_COLOR 114 | c.set_source_rgba(*fill_color) 115 | c.fill_preserve() 116 | 117 | c.set_source_rgb(*WHITE_COLOR) 118 | c.stroke() 119 | 120 | # 4. draw right gray line 121 | 122 | c.set_source_rgb(*GRAY_COLOR) 123 | 124 | c.move_to(last_x, base_y) 125 | c.line_to(CANVAS_W-RIGHT_MARGIN_W-RIGHT_TEXT_ZONE_W, base_y) 126 | c.stroke() 127 | 128 | # 5. draw run name 129 | 130 | font_size = 16 131 | 132 | c.set_source_rgb(*GRAY_COLOR) 133 | c.set_font_size(font_size) 134 | 135 | c.select_font_face("Helvetica", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 136 | c.move_to(1000 - RIGHT_MARGIN_W-RIGHT_TEXT_ZONE_W + 16, base_y) 137 | c.show_text(a["name"]) 138 | 139 | # 6. draw athlete name 140 | 141 | c.move_to(LEFT_MARGIN_W, TOP_MARGIN_H + len(activities) * ACTIVITIES_OFFSET_H + MAX_ACTIVITY_H + 80) 142 | 143 | font_size = 72 144 | 145 | c.select_font_face("Helvetica", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 146 | c.set_source_rgb(*WHITE_COLOR) 147 | c.set_font_size(font_size) 148 | c.show_text(athlete["firstname"] + " " + athlete["lastname"]) 149 | 150 | FILE_NAME = "pulsar_runs.png" 151 | img.write_to_png(FILE_NAME) 152 | 153 | os.system("open -a Safari %s" % FILE_NAME) 154 | -------------------------------------------------------------------------------- /Swift/CanvasView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasView.swift 3 | // CanvasView 4 | // 5 | // Created by Nicolas Seriot on 16/06/16. 6 | // Copyright © 2016 Nicolas Seriot. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | func P(_ x:CGFloat, _ y:CGFloat) -> NSPoint { 12 | return NSMakePoint(x, y) 13 | } 14 | 15 | func P(x:Int, _ y:Int) -> NSPoint { 16 | return NSMakePoint(CGFloat(x), CGFloat(y)) 17 | } 18 | 19 | func RandomPoint(maxX:Int, maxY:Int) -> NSPoint { 20 | return P(CGFloat(arc4random_uniform((UInt32(maxX+1)))), CGFloat(arc4random_uniform((UInt32(maxY+1))))) 21 | } 22 | 23 | func R(_ x:CGFloat, _ y:CGFloat, _ w:CGFloat, _ h:CGFloat) -> NSRect { 24 | return NSMakeRect(x, y, w, h) 25 | } 26 | 27 | func R(_ x:Int, _ y:Int, _ w:Int, _ h:Int) -> NSRect { 28 | return NSMakeRect(CGFloat(x), CGFloat(y), CGFloat(w), CGFloat(h)) 29 | } 30 | 31 | func degreesToRadians(_ x:CGFloat) -> CGFloat { 32 | return ((CGFloat(Double.pi) * x) / 180.0) 33 | } 34 | 35 | class CanvasView : NSView { 36 | 37 | var context : CGContext! 38 | 39 | override func draw(_ dirtyRect: NSRect) { 40 | super.draw(dirtyRect) 41 | 42 | self.context = unsafeBitCast(NSGraphicsContext.current()!.graphicsPort, to:CGContext.self) 43 | } 44 | 45 | func text(_ text:String, _ p:NSPoint, rotationRadians:CGFloat?, font : NSFont = NSFont(name: "Monaco", size: 10)!, color : NSColor = NSColor.black) { 46 | 47 | let attr = [ 48 | NSFontAttributeName:font, 49 | NSForegroundColorAttributeName:color 50 | ] 51 | 52 | context.saveGState() 53 | 54 | if let radians = rotationRadians { 55 | context.translateBy(x: p.x, y: p.y) 56 | context.rotate(by: radians) 57 | context.translateBy(x: -p.x, y: -p.y) 58 | } 59 | 60 | context.scaleBy(x: 1.0, y: -1.0) 61 | context.translateBy(x: 0.0, y: (-2.0 * p.y) - font.pointSize) 62 | 63 | text.draw(at: p, withAttributes: attr) 64 | 65 | context.restoreGState() 66 | } 67 | 68 | func text(_ text:String, _ p:NSPoint, rotationDegrees degrees:CGFloat = 0.0, font : NSFont = NSFont(name: "Monaco", size: 10)!, color : NSColor = NSColor.black) { 69 | self.text(text, p, rotationRadians: degreesToRadians(degrees), font: font, color: color) 70 | } 71 | 72 | func rectangle(rect:NSRect, stroke stroke_:NSColor? = NSColor.black, fill fill_:NSColor? = nil) { 73 | 74 | let stroke = stroke_ 75 | let fill = fill_ 76 | 77 | context.saveGState() 78 | 79 | if let existingFillColor = fill { 80 | existingFillColor.setFill() 81 | NSBezierPath.fill(rect) 82 | } 83 | 84 | if let existingStrokeColor = stroke { 85 | existingStrokeColor.setStroke() 86 | NSBezierPath.stroke(rect) 87 | } 88 | 89 | context.restoreGState() 90 | } 91 | 92 | func polygon(points:[NSPoint], stroke stroke_:NSColor? = NSColor.black, lineWidth:CGFloat=1.0, fill fill_:NSColor? = nil) { 93 | 94 | guard points.count >= 3 else { 95 | assertionFailure("at least 3 points are needed") 96 | return 97 | } 98 | 99 | context.saveGState() 100 | 101 | let path = NSBezierPath() 102 | 103 | path.move(to:points[0]) 104 | 105 | for i in 1..Void)) { 14 | 15 | let urlString = "https://www.strava.com/api/v3/athlete?access_token=\(ACCESS_TOKEN)&per_page=200" 16 | print(urlString) 17 | 18 | URLSession.shared.dataTask(with: URL(string: urlString)!) { (data, response, error) in 19 | guard let existingData = data else { completion([:]); return } 20 | guard let optionalAthlete = try? JSONSerialization.jsonObject(with: existingData) as? [String:AnyObject] else { completion([:]); return } 21 | guard let athlete = optionalAthlete else { completion([:]); return } 22 | 23 | DispatchQueue.main.async { 24 | completion(athlete) 25 | } 26 | }.resume() 27 | } 28 | 29 | func fetchActivities(completion: @escaping (([[String:AnyObject]])->Void)) { 30 | 31 | let urlString = "https://www.strava.com/api/v3/activities?access_token=\(ACCESS_TOKEN)&per_page=200" 32 | print(urlString) 33 | 34 | URLSession.shared.dataTask(with: URL(string: urlString)!) { (data, response, error) in 35 | guard let existingData = data else { completion([]); return } 36 | guard let optionalActivities = try? JSONSerialization.jsonObject(with: existingData) as? [[String:AnyObject]] else { completion([]); return } 37 | guard let activities = optionalActivities else { completion([]); return } 38 | 39 | DispatchQueue.main.async { 40 | completion(activities) 41 | } 42 | }.resume() 43 | } 44 | 45 | func fetchAltitudes(_ id:Int, resolution:String = "medium", completion: @escaping ((_ distancePoints:[Double], _ altitudePoints:[Double])->Void)) { 46 | 47 | let urlString = "https://www.strava.com/api/v3/activities/\(id)/streams/altitude?resolution=\(resolution)&access_token=\(ACCESS_TOKEN)" 48 | print(urlString) 49 | 50 | URLSession.shared.dataTask(with: URL(string: urlString)!) { (data, response, error) in 51 | guard let existingData = data else { completion([], []); return } 52 | guard let optionalStreams = try? JSONSerialization.jsonObject(with: existingData) as? [[String:AnyObject]] else { completion([], []); return } 53 | guard let streams = optionalStreams else { completion([], []); return } 54 | 55 | guard let distanceStream = streams.filter({ $0["type"] as? String == "distance" }).first else { assertionFailure(); return } 56 | guard let altitudeStream = streams.filter({ $0["type"] as? String == "altitude" }).first else { assertionFailure(); return } 57 | 58 | guard let distancePoints = distanceStream["data"] as? [Double] else { assertionFailure(); return } 59 | guard let altitudePoints = altitudeStream["data"] as? [Double] else { assertionFailure(); return } 60 | 61 | DispatchQueue.main.async { 62 | completion(distancePoints, altitudePoints) 63 | } 64 | }.resume() 65 | } 66 | 67 | func downloadAndDumpAthleteAndActivities(dirPath:String, completion: @escaping (()->Void)) { 68 | 69 | assert(ACCESS_TOKEN.count > 0, "Get an access token from Strava on https://www.strava.com/settings/api") 70 | 71 | fetchActivities { (activities) in 72 | 73 | let group = DispatchGroup() 74 | 75 | group.enter() 76 | 77 | fetchAthlete(completion: { (athlete) in 78 | 79 | do { 80 | let data = try JSONSerialization.data(withJSONObject: athlete, options: []) 81 | let path = (dirPath as NSString).appendingPathComponent("athlete.json") 82 | let url = URL(fileURLWithPath: path) 83 | try data.write(to: url) 84 | print("-- athlete \(athlete) saved") 85 | } catch let error { 86 | print(error) 87 | print("-- athlete \(athlete) error: \(error)") 88 | } 89 | 90 | group.leave() 91 | }) 92 | 93 | for a in activities { 94 | 95 | guard let id = a["id"] as? Int else { continue } 96 | guard let type = a["type"] as? String, type == "Run" else { continue } 97 | 98 | var a_augmented = a 99 | 100 | group.enter() 101 | 102 | fetchAltitudes(id, completion: { (distancePoints, altitudePoints) in 103 | assert(distancePoints.count == altitudePoints.count) 104 | 105 | a_augmented["distance_points"] = distancePoints as AnyObject 106 | a_augmented["altitude_points"] = altitudePoints as AnyObject 107 | 108 | guard let startAltitude = altitudePoints.first else { assertionFailure(); return } 109 | let altitudesDelta = altitudePoints.map{ $0 - startAltitude } 110 | 111 | guard let min = altitudesDelta.min() else { assertionFailure(); return } 112 | guard let max = altitudesDelta.max() else { assertionFailure(); return } 113 | 114 | a_augmented["lowest_relative_altitude"] = min as AnyObject 115 | a_augmented["highest_relative_altitude"] = max as AnyObject 116 | 117 | do { 118 | let data = try JSONSerialization.data(withJSONObject: a_augmented, options: []) 119 | let path = (dirPath as NSString).appendingPathComponent("\(id).json") 120 | let url = URL(fileURLWithPath: path) 121 | try data.write(to: url) 122 | print("-- \(id) saved") 123 | } catch let error { 124 | print(error) 125 | print("-- \(id) error: \(error)") 126 | } 127 | 128 | group.leave() 129 | }) 130 | } 131 | 132 | group.notify(queue: DispatchQueue.main) { 133 | print("-- dumping activities finished") 134 | completion() 135 | } 136 | } 137 | } 138 | 139 | func loadAthleteAndActivities(dirPath:String) -> ([String:AnyObject], [[String:AnyObject]]) { 140 | 141 | do { 142 | let athleteData = try Data(contentsOf: URL(fileURLWithPath: dirPath + "/athlete.json")) 143 | let optionalAthlete = try JSONSerialization.jsonObject(with: athleteData) as? [String:AnyObject] 144 | let athlete = optionalAthlete ?? [:] 145 | 146 | let url = URL(fileURLWithPath: dirPath) 147 | let urls = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) 148 | 149 | let activities = urls 150 | .filter{ $0.pathExtension == "json" } 151 | .flatMap{ try? Data(contentsOf:$0) } 152 | .flatMap{ try? JSONSerialization.jsonObject(with: $0) as? [String:AnyObject] } 153 | .flatMap{ $0 } 154 | .filter{ return $0["type"] as? String == "Run" || $0["type"] as? String == "Race" } 155 | 156 | return (athlete, activities) 157 | } catch let error as NSError { 158 | print("-- error", error) 159 | } 160 | 161 | return ([:], []) 162 | } 163 | 164 | let download = false // true to download data, then false to draw picture 165 | 166 | if download { 167 | 168 | downloadAndDumpAthleteAndActivities(dirPath: "/private/tmp") { 169 | print("-- downloadAndDumpActivities completed") 170 | exit(0) 171 | } 172 | 173 | RunLoop.current.run() 174 | 175 | } else { 176 | let (athlete, activities) = loadAthleteAndActivities(dirPath: "/private/tmp/") 177 | //print(athlete) 178 | //print(activities) 179 | 180 | // eg [id_1, id_2, id_3] to get only one path for several consecutive ids 181 | let kvChando = [722892381, 723144918, 723144855] 182 | let montreuxRochersDeNaye = [628334311, 628519686] 183 | let martinaux = [691604950, 691722446] 184 | let thyonDixence = [667441401, 672243034] 185 | let idsToBeMerged : [[Int]] = [kvChando, montreuxRochersDeNaye, martinaux, thyonDixence] 186 | 187 | let view = RunningView(frame: NSMakeRect(0, 0, 1000, 4300), activities:activities, athlete:athlete, activityIDsToBeMerged:idsToBeMerged) 188 | 189 | let shortName = athlete["username"] as? String ?? "strava_runs" 190 | 191 | //view.savePDF("/tmp/\(shortName).pdf", open:true) 192 | view.savePNG("/tmp/\(shortName).png", open:true) 193 | } 194 | -------------------------------------------------------------------------------- /Swift/PulsarRuns.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 03A037B51D2299610038A0BB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A037B41D2299610038A0BB /* main.swift */; }; 11 | 03A037BC1D2299700038A0BB /* CanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A037BB1D2299700038A0BB /* CanvasView.swift */; }; 12 | 03A037BE1D2299740038A0BB /* RunningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A037BD1D2299740038A0BB /* RunningView.swift */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXCopyFilesBuildPhase section */ 16 | 03A037AF1D2299610038A0BB /* CopyFiles */ = { 17 | isa = PBXCopyFilesBuildPhase; 18 | buildActionMask = 2147483647; 19 | dstPath = /usr/share/man/man1/; 20 | dstSubfolderSpec = 0; 21 | files = ( 22 | ); 23 | runOnlyForDeploymentPostprocessing = 1; 24 | }; 25 | /* End PBXCopyFilesBuildPhase section */ 26 | 27 | /* Begin PBXFileReference section */ 28 | 03A037B11D2299610038A0BB /* PulsarRuns */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = PulsarRuns; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 03A037B41D2299610038A0BB /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 30 | 03A037BB1D2299700038A0BB /* CanvasView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CanvasView.swift; sourceTree = ""; }; 31 | 03A037BD1D2299740038A0BB /* RunningView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RunningView.swift; sourceTree = ""; }; 32 | 03A037BF1D229A890038A0BB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXFrameworksBuildPhase section */ 36 | 03A037AE1D2299610038A0BB /* Frameworks */ = { 37 | isa = PBXFrameworksBuildPhase; 38 | buildActionMask = 2147483647; 39 | files = ( 40 | ); 41 | runOnlyForDeploymentPostprocessing = 0; 42 | }; 43 | /* End PBXFrameworksBuildPhase section */ 44 | 45 | /* Begin PBXGroup section */ 46 | 03A037A81D2299610038A0BB = { 47 | isa = PBXGroup; 48 | children = ( 49 | 03A037BF1D229A890038A0BB /* README.md */, 50 | 03A037BB1D2299700038A0BB /* CanvasView.swift */, 51 | 03A037B31D2299610038A0BB /* PulsarRuns */, 52 | 03A037B21D2299610038A0BB /* Products */, 53 | ); 54 | sourceTree = ""; 55 | }; 56 | 03A037B21D2299610038A0BB /* Products */ = { 57 | isa = PBXGroup; 58 | children = ( 59 | 03A037B11D2299610038A0BB /* PulsarRuns */, 60 | ); 61 | name = Products; 62 | sourceTree = ""; 63 | }; 64 | 03A037B31D2299610038A0BB /* PulsarRuns */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | 03A037BD1D2299740038A0BB /* RunningView.swift */, 68 | 03A037B41D2299610038A0BB /* main.swift */, 69 | ); 70 | path = PulsarRuns; 71 | sourceTree = ""; 72 | }; 73 | /* End PBXGroup section */ 74 | 75 | /* Begin PBXNativeTarget section */ 76 | 03A037B01D2299610038A0BB /* PulsarRuns */ = { 77 | isa = PBXNativeTarget; 78 | buildConfigurationList = 03A037B81D2299610038A0BB /* Build configuration list for PBXNativeTarget "PulsarRuns" */; 79 | buildPhases = ( 80 | 03A037AD1D2299610038A0BB /* Sources */, 81 | 03A037AE1D2299610038A0BB /* Frameworks */, 82 | 03A037AF1D2299610038A0BB /* CopyFiles */, 83 | ); 84 | buildRules = ( 85 | ); 86 | dependencies = ( 87 | ); 88 | name = PulsarRuns; 89 | productName = PulsarRuns; 90 | productReference = 03A037B11D2299610038A0BB /* PulsarRuns */; 91 | productType = "com.apple.product-type.tool"; 92 | }; 93 | /* End PBXNativeTarget section */ 94 | 95 | /* Begin PBXProject section */ 96 | 03A037A91D2299610038A0BB /* Project object */ = { 97 | isa = PBXProject; 98 | attributes = { 99 | LastSwiftUpdateCheck = 0800; 100 | LastUpgradeCheck = 0800; 101 | ORGANIZATIONNAME = "Nicolas Seriot"; 102 | TargetAttributes = { 103 | 03A037B01D2299610038A0BB = { 104 | CreatedOnToolsVersion = 8.0; 105 | DevelopmentTeam = VBYRKYS73S; 106 | DevelopmentTeamName = "Nicolas Seriot"; 107 | ProvisioningStyle = Automatic; 108 | }; 109 | }; 110 | }; 111 | buildConfigurationList = 03A037AC1D2299610038A0BB /* Build configuration list for PBXProject "PulsarRuns" */; 112 | compatibilityVersion = "Xcode 3.2"; 113 | developmentRegion = English; 114 | hasScannedForEncodings = 0; 115 | knownRegions = ( 116 | en, 117 | ); 118 | mainGroup = 03A037A81D2299610038A0BB; 119 | productRefGroup = 03A037B21D2299610038A0BB /* Products */; 120 | projectDirPath = ""; 121 | projectRoot = ""; 122 | targets = ( 123 | 03A037B01D2299610038A0BB /* PulsarRuns */, 124 | ); 125 | }; 126 | /* End PBXProject section */ 127 | 128 | /* Begin PBXSourcesBuildPhase section */ 129 | 03A037AD1D2299610038A0BB /* Sources */ = { 130 | isa = PBXSourcesBuildPhase; 131 | buildActionMask = 2147483647; 132 | files = ( 133 | 03A037BC1D2299700038A0BB /* CanvasView.swift in Sources */, 134 | 03A037BE1D2299740038A0BB /* RunningView.swift in Sources */, 135 | 03A037B51D2299610038A0BB /* main.swift in Sources */, 136 | ); 137 | runOnlyForDeploymentPostprocessing = 0; 138 | }; 139 | /* End PBXSourcesBuildPhase section */ 140 | 141 | /* Begin XCBuildConfiguration section */ 142 | 03A037B61D2299610038A0BB /* Debug */ = { 143 | isa = XCBuildConfiguration; 144 | buildSettings = { 145 | ALWAYS_SEARCH_USER_PATHS = NO; 146 | CLANG_ANALYZER_NONNULL = YES; 147 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 148 | CLANG_CXX_LIBRARY = "libc++"; 149 | CLANG_ENABLE_MODULES = YES; 150 | CLANG_ENABLE_OBJC_ARC = YES; 151 | CLANG_WARN_BOOL_CONVERSION = YES; 152 | CLANG_WARN_CONSTANT_CONVERSION = YES; 153 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 154 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 155 | CLANG_WARN_EMPTY_BODY = YES; 156 | CLANG_WARN_ENUM_CONVERSION = YES; 157 | CLANG_WARN_INT_CONVERSION = YES; 158 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | CODE_SIGN_IDENTITY = "-"; 162 | COPY_PHASE_STRIP = NO; 163 | DEBUG_INFORMATION_FORMAT = dwarf; 164 | ENABLE_STRICT_OBJC_MSGSEND = YES; 165 | ENABLE_TESTABILITY = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu99; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | MACOSX_DEPLOYMENT_TARGET = 10.11; 181 | MTL_ENABLE_DEBUG_INFO = YES; 182 | ONLY_ACTIVE_ARCH = YES; 183 | SDKROOT = macosx; 184 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 185 | }; 186 | name = Debug; 187 | }; 188 | 03A037B71D2299610038A0BB /* Release */ = { 189 | isa = XCBuildConfiguration; 190 | buildSettings = { 191 | ALWAYS_SEARCH_USER_PATHS = NO; 192 | CLANG_ANALYZER_NONNULL = YES; 193 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 194 | CLANG_CXX_LIBRARY = "libc++"; 195 | CLANG_ENABLE_MODULES = YES; 196 | CLANG_ENABLE_OBJC_ARC = YES; 197 | CLANG_WARN_BOOL_CONVERSION = YES; 198 | CLANG_WARN_CONSTANT_CONVERSION = YES; 199 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 200 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 201 | CLANG_WARN_EMPTY_BODY = YES; 202 | CLANG_WARN_ENUM_CONVERSION = YES; 203 | CLANG_WARN_INT_CONVERSION = YES; 204 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 205 | CLANG_WARN_UNREACHABLE_CODE = YES; 206 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 207 | CODE_SIGN_IDENTITY = "-"; 208 | COPY_PHASE_STRIP = NO; 209 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 210 | ENABLE_NS_ASSERTIONS = NO; 211 | ENABLE_STRICT_OBJC_MSGSEND = YES; 212 | GCC_C_LANGUAGE_STANDARD = gnu99; 213 | GCC_NO_COMMON_BLOCKS = YES; 214 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 215 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 216 | GCC_WARN_UNDECLARED_SELECTOR = YES; 217 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 218 | GCC_WARN_UNUSED_FUNCTION = YES; 219 | GCC_WARN_UNUSED_VARIABLE = YES; 220 | MACOSX_DEPLOYMENT_TARGET = 10.11; 221 | MTL_ENABLE_DEBUG_INFO = NO; 222 | SDKROOT = macosx; 223 | }; 224 | name = Release; 225 | }; 226 | 03A037B91D2299610038A0BB /* Debug */ = { 227 | isa = XCBuildConfiguration; 228 | buildSettings = { 229 | PRODUCT_NAME = "$(TARGET_NAME)"; 230 | SWIFT_VERSION = 3.0; 231 | }; 232 | name = Debug; 233 | }; 234 | 03A037BA1D2299610038A0BB /* Release */ = { 235 | isa = XCBuildConfiguration; 236 | buildSettings = { 237 | PRODUCT_NAME = "$(TARGET_NAME)"; 238 | SWIFT_VERSION = 3.0; 239 | }; 240 | name = Release; 241 | }; 242 | /* End XCBuildConfiguration section */ 243 | 244 | /* Begin XCConfigurationList section */ 245 | 03A037AC1D2299610038A0BB /* Build configuration list for PBXProject "PulsarRuns" */ = { 246 | isa = XCConfigurationList; 247 | buildConfigurations = ( 248 | 03A037B61D2299610038A0BB /* Debug */, 249 | 03A037B71D2299610038A0BB /* Release */, 250 | ); 251 | defaultConfigurationIsVisible = 0; 252 | defaultConfigurationName = Release; 253 | }; 254 | 03A037B81D2299610038A0BB /* Build configuration list for PBXNativeTarget "PulsarRuns" */ = { 255 | isa = XCConfigurationList; 256 | buildConfigurations = ( 257 | 03A037B91D2299610038A0BB /* Debug */, 258 | 03A037BA1D2299610038A0BB /* Release */, 259 | ); 260 | defaultConfigurationIsVisible = 0; 261 | defaultConfigurationName = Release; 262 | }; 263 | /* End XCConfigurationList section */ 264 | }; 265 | rootObject = 03A037A91D2299610038A0BB /* Project object */; 266 | } 267 | -------------------------------------------------------------------------------- /Swift/PulsarRuns/RunningView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShapefileView.swift 3 | // Shapefile 4 | // 5 | // Created by nst on 26/03/16. 6 | // Copyright © 2016 Nicolas Seriot. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | // idea from http://www.xavigimenez.net/blog/2015/05/plotting-my-strava-running-activity-as-a-pulsar-plot/ 12 | 13 | class RunningView : CanvasView { 14 | 15 | var activities : [[String:AnyObject]] = [] 16 | var athlete : [String:AnyObject] = [:] 17 | var maxDistance = 0.0 18 | var maxAltitudeDelta = 0.0 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | } 23 | 24 | init(frame frameRect: NSRect, activities:[[String:AnyObject]], athlete:[String:AnyObject], activityIDsToBeMerged:[[Int]]) { 25 | super.init(frame:frameRect) 26 | self.activities = activities 27 | self.athlete = athlete 28 | 29 | self.updateActivitiesByMergingIDs(activityIDsToBeMerged:activityIDsToBeMerged) 30 | 31 | let optionalMaxDistance = activities.map{ $0["distance"] as? Double }.flatMap{ $0 }.max() 32 | 33 | guard let existingMaxDistance = optionalMaxDistance else { 34 | fatalError("ensure you've downloaded athlete first") 35 | } 36 | 37 | maxDistance = existingMaxDistance 38 | 39 | // 40 | 41 | let altitudesDelta : [Double] = activities.map { 42 | guard let altitudes = $0["altitude_points"] as? [Double] else { assertionFailure(); return 0.0 } 43 | guard let altitudeMin = altitudes.min() else { assertionFailure(); return 0.0 } 44 | guard let altitudeMax = altitudes.max() else { assertionFailure(); return 0.0 } 45 | return altitudeMax - altitudeMin 46 | } 47 | 48 | guard let existingMaxAltitudeDelta = altitudesDelta.max() else { 49 | fatalError("ensure you've downloaded athlete first") 50 | } 51 | 52 | maxAltitudeDelta = existingMaxAltitudeDelta 53 | } 54 | 55 | func updateActivitiesByMergingIDs(activityIDsToBeMerged:[[Int]]) { 56 | // for IDs to be merged, update 'altitude_points' and 'distance_points' 57 | for ids in activityIDsToBeMerged { 58 | let parentID = ids[0] 59 | let childrenID = ids[1.. [String:AnyObject]? { 80 | return activities.filter { $0["id"] as? Int == id }.first 81 | } 82 | 83 | func removeActivity(id:Int) { 84 | guard let a = activity(id: id) else { fatalError() } 85 | 86 | for (i,o) in activities.enumerated() { 87 | guard let oID = o["id"] as? Int else { fatalError() } 88 | guard let aID = a["id"] as? Int else { fatalError() } 89 | if oID == aID { 90 | self.activities.remove(at: i) 91 | return 92 | } 93 | } 94 | } 95 | 96 | override func draw(_ dirtyRect: NSRect) { 97 | 98 | super.draw(dirtyRect) 99 | 100 | context.setShouldAntialias(true) 101 | 102 | context.saveGState() 103 | 104 | // ensure pixel-perfect bitmap 105 | // context.translate(x: 0.5, y: 0.5) 106 | 107 | // makes coordinates start upper left 108 | context.translateBy(x: 0.0, y: self.bounds.height) 109 | context.scaleBy(x: 1.0, y: -1.0) 110 | 111 | NSColor.black.setFill() 112 | context.fill(dirtyRect) 113 | 114 | let MAX_ACTIVITY_H = 100.0 115 | 116 | let TOP_MARGIN_H = 50.0 117 | 118 | let LEFT_MARGIN_W : CGFloat = 50.0 119 | let LEFT_FLAT_ZONE_W : CGFloat = 50.0 120 | 121 | let RIGHT_MARGIN_W : CGFloat = 50.0 122 | let RIGHT_FLAT_ZONE_W : CGFloat = 50.0 123 | let RIGHT_TEXT_ZONE_W : CGFloat = 460.0 124 | 125 | let ACTIVITIES_OFFSET_H = 20.0 126 | 127 | activities.sort { 128 | 129 | guard let low1 = $0["lowest_relative_altitude"] as? Double else { assertionFailure(); return false } 130 | guard let low2 = $1["lowest_relative_altitude"] as? Double else { assertionFailure(); return false } 131 | 132 | guard let high1 = $0["highest_relative_altitude"] as? Double else { assertionFailure(); return false } 133 | guard let high2 = $1["highest_relative_altitude"] as? Double else { assertionFailure(); return false } 134 | 135 | let delta1 = abs(low1) > high1 ? low1 : high1 136 | let delta2 = abs(low2) > high2 ? low2 : high2 137 | 138 | return delta1 > delta2 139 | } 140 | 141 | for (i, activity) in activities.enumerated() { 142 | 143 | guard let altitudes = activity["altitude_points"] as? [Double] else { assertionFailure(); continue } 144 | guard let distances = activity["distance_points"] as? [Double] else { assertionFailure(); continue } 145 | 146 | assert(altitudes.count == distances.count) 147 | 148 | // 1. compute base Y 149 | 150 | let baseY = TOP_MARGIN_H + MAX_ACTIVITY_H + ACTIVITIES_OFFSET_H * Double(i) 151 | 152 | // 2. draw left gray line 153 | 154 | NSColor.gray.setStroke() 155 | 156 | let pathLeft = NSBezierPath() 157 | pathLeft.lineWidth = 2.0 158 | pathLeft.lineCapStyle = .roundLineCapStyle 159 | pathLeft.move(to: P(LEFT_MARGIN_W, CGFloat(baseY))) 160 | pathLeft.line(to: P(CGFloat(LEFT_MARGIN_W+LEFT_FLAT_ZONE_W), CGFloat(baseY))) 161 | pathLeft.stroke() 162 | 163 | // 3. draw run profile in white 164 | 165 | NSColor.white.setStroke() 166 | 167 | guard let low = activity["lowest_relative_altitude"] as? Double else { assertionFailure(); continue } 168 | guard let high = activity["highest_relative_altitude"] as? Double else { assertionFailure(); continue } 169 | let profileIsMoreDown = abs(low) > high 170 | 171 | let fillColor = profileIsMoreDown ? NSColor.clear : NSColor.black 172 | 173 | fillColor.setFill() 174 | 175 | let path = NSBezierPath() 176 | path.lineWidth = 2.0 177 | path.lineCapStyle = .roundLineCapStyle 178 | path.move(to: P(LEFT_MARGIN_W + LEFT_FLAT_ZONE_W, CGFloat(baseY))) 179 | // path.line(to: P(CGFloat(LEFT_MARGIN_W+LEFT_FLAT_ZONE_W), CGFloat(baseY))) 180 | 181 | guard let firstAltitude = altitudes.first else { assertionFailure(); continue } 182 | 183 | var lastX : CGFloat = 0.0 184 | for (j, altitude) in altitudes.enumerated() { 185 | 186 | let altitudeDelta = altitude - firstAltitude 187 | 188 | let y = baseY - MAX_ACTIVITY_H * (Double(altitudeDelta) / Double(maxAltitudeDelta)) 189 | 190 | let xRatio = distances[j] / maxDistance 191 | let x = LEFT_MARGIN_W + LEFT_FLAT_ZONE_W + (self.frame.width - LEFT_MARGIN_W - RIGHT_MARGIN_W - LEFT_FLAT_ZONE_W - RIGHT_FLAT_ZONE_W - RIGHT_TEXT_ZONE_W) * CGFloat(xRatio) 192 | lastX = x 193 | 194 | path.line(to: P(x,CGFloat(y))) 195 | } 196 | 197 | path.fill() 198 | path.stroke() 199 | 200 | // 4. draw right gray line 201 | 202 | NSColor.gray.setStroke() 203 | 204 | let pathRight = NSBezierPath() 205 | pathRight.lineWidth = 2.0 206 | pathRight.lineCapStyle = .roundLineCapStyle 207 | pathRight.move(to: P(lastX, CGFloat(baseY))) 208 | pathRight.line(to: P(self.frame.width-RIGHT_MARGIN_W-RIGHT_TEXT_ZONE_W,CGFloat(baseY))) 209 | pathRight.stroke() 210 | 211 | // 5. draw run name 212 | 213 | if let activityName = activity["name"] as? String { 214 | let fontSize = CGFloat(18) 215 | let point = P(self.frame.width-RIGHT_MARGIN_W-RIGHT_TEXT_ZONE_W + 16, CGFloat(baseY) - fontSize + 6) 216 | let font = NSFont(name:"Helvetica", size:fontSize)! 217 | self.text(activityName, point, font:font, color:NSColor.gray) 218 | } 219 | } 220 | 221 | // 6. draw athlete name 222 | 223 | let drawName = true 224 | 225 | if drawName { 226 | guard let firstName = athlete["firstname"] else { assertionFailure(); return } 227 | guard let lastName = athlete["lastname"] else { assertionFailure(); return } 228 | 229 | let textPoint = NSMakePoint( 230 | LEFT_MARGIN_W, 231 | CGFloat(TOP_MARGIN_H) + CGFloat(activities.count) * CGFloat(ACTIVITIES_OFFSET_H) + CGFloat(MAX_ACTIVITY_H) + CGFloat(40)) 232 | 233 | let font = NSFont(name:"Helvetica", size:72)! 234 | self.text("\(firstName) \(lastName)", textPoint, font:font, color:NSColor.white) 235 | } 236 | 237 | context.restoreGState() 238 | } 239 | } 240 | --------------------------------------------------------------------------------