├── 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 | 
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 |
--------------------------------------------------------------------------------