diff --git a/README.md b/README.md index 6796699..a4d1b48 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ A command line tool that animates your exercise maps, inspired by an [article by Andriy Yaremenko](https://medium.com/geospatial-analytics/how-to-animate-strava-gpx-tracks-in-qgis-8a8ca6b58ebc). -![example output](lockdown_project.gif) +![example worms output](lockdown_worms.gif) ## Features * Supports FIT, TCX, GPX files. It can also traverse into ZIP files for easy ingestion of bulk activity exports. @@ -17,13 +17,12 @@ A command line tool that animates your exercise maps, inspired by an [article by --min_distance 3km \ --max_pace 10m/km \ --bounded_by -37.8,144.9,5km \ - --output lockdown_project \ + --output lockdown_worms \ path/to/my/activity/data ``` Some basic statistics are output to help validate the activities that were included and to aid in further refining filters. ```text -activity files: 9,327 - 100% |████████████████████████████████████████| [3s:0s] +files: 9,327 activities: 268 records: 154,326 sports: running (268) @@ -39,10 +38,14 @@ The easiest way to find the coordinates of a known location is to right-click on ## Options ```text -Usage of rainbow-roads: - --output string optional path of the generated file (default "out") - --format format output file format string, supports gif, png, zip -Filtering: +Usage: + rainbow-roads [flags] [input] + +General flags: + -o, --output string optional path of the generated file (default "out") + -f, --format string output file format string, supports gif, png, zip (default "gif") + +Filtering flags: --sport sports sports to include, can be specified multiple times, eg running, cycling --after date date from which activities should be included --before date date prior to which activities should be included @@ -56,11 +59,12 @@ Filtering: --starts_near circle region that activities must start from, eg 51.53,-0.21,1km --ends_near circle region that activities must end in, eg 30.06,31.22,1km --passes_through circle region that activities must pass through, eg 40.69,-74.12,10mi -Rendering: + +Rendering flags: --frames uint number of animation frames (default 200) --fps uint animation frame rate (default 20) - --width uint width of the generated image in pixels (default 500) - --colors colors CSS linear-colors inspired color scheme string, eg red,yellow,green,blue,black (default #fff,#ff8,#911,#414,#007@.5,#003) + -w, --width uint width of the generated image in pixels (default 500) + --colors colors CSS linear-colors inspired color scheme string, eg red,yellow,green,blue,black (default #fff,#ff8@0.125,#911@0.25,#414@0.375,#007@0.5,#003) --color_depth uint number of bits per color in the image palette (default 5) --speed float how quickly activities should progress (default 1.25) --loop start each activity sequentially and animate continuously @@ -86,16 +90,29 @@ Simply install Go and run: go install github.com/NathanBaulch/rainbow-roads@latest ``` +## Paint +A sub-command used to track street coverage, useful for #everystreet style challenges and to make animations more spectacular. + +![example paint output](lockdown_paint.png) + +## Features +* Streets are painted green by running within a 25 meters threshold of them. +* OpenStreetMap road data is automatically downloaded as needed, excluding alleyways, footpaths, trails and roads under construction. +* A progress percentage is calculated by the ratio of green to red pixels. +* Supports all the same activity filter options described above. + ## Built with * [lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) - color gradient interpolation -* [schollz/progressbar](https://github.com/schollz/progressbar) - CLI progress bar * [tormoder/fit](https://github.com/tormoder/fit) - FIT file support * [llehouerou/go-tcx](https://github.com/llehouerou/go-tcx) - TCX file support * [tkrajina/gpxgo](https://github.com/tkrajina/gpxgo) - GPX file support * [kettek/apng](https://github.com/kettek/apng) - animated PNG file support * [araddon/dateparse](https://github.com/araddon/dateparse) - permissive date parsing * [bcicen/go-units](https://github.com/bcicen/go-units) - distance unit conversion -* [StephaneBunel/bresenham](https://github.com/StephaneBunel/bresenham) - GPX distance calculation +* [serjvanilla/go-overpass](https://github.com/serjvanilla/go-overpass) - OpenStreetMap client +* [antonmedv/expr](https://github.com/antonmedv/expr) - expression language +* [fogleman/gg](https://github.com/fogleman/gg) - 2D floating point renderer +* [spf13/cobra](https://github.com/spf13/cobra) - CLI framework ## Future work * Improve rendering with smoother anti-aliasing diff --git a/conv/format.go b/conv/format.go new file mode 100644 index 0000000..6e9aaa0 --- /dev/null +++ b/conv/format.go @@ -0,0 +1,11 @@ +package conv + +import ( + "strconv" + "strings" +) + +func FormatFloat(val float64) string { + str := strconv.FormatFloat(val, 'f', 5, 64) + return strings.TrimRight(strings.TrimRight(str, "0"), ".") +} diff --git a/draw.go b/draw.go deleted file mode 100644 index a462854..0000000 --- a/draw.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "image" - "image/color" - - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" -) - -var grays = make([]color.Color, 0x100) - -func init() { - for i := range grays { - grays[i] = color.Gray{Y: uint8(i)} - } -} - -func drawFill(im *image.Paletted, ci uint8) { - if len(im.Pix) > 0 { - im.Pix[0] = ci - for i := 1; i < len(im.Pix); i *= 2 { - copy(im.Pix[i:], im.Pix[:i]) - } - } -} - -func drawString(im *image.Paletted, text string, ci uint8) { - d := &font.Drawer{ - Dst: im, - Src: image.NewUniform(im.Palette[ci]), - Face: basicfont.Face7x13, - } - b, _ := d.BoundString(text) - b = b.Sub(b.Min) - if b.In(fixed.R(0, 0, im.Bounds().Max.X-10, im.Rect.Max.Y-10)) { - d.Dot = fixed.P(im.Rect.Max.X, im.Rect.Max.Y). - Sub(b.Max.Sub(fixed.P(0, basicfont.Face7x13.Height))). - Sub(fixed.P(5, 5)) - d.DrawString(text) - } -} diff --git a/fit.go b/fit.go deleted file mode 100644 index 35566fc..0000000 --- a/fit.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "io" - - "github.com/tormoder/fit" -) - -func parseFIT(r io.Reader) ([]*activity, error) { - f, err := fit.Decode(r) - if err != nil { - if _, ok := err.(fit.FormatError); ok { - return nil, nil - } - return nil, err - } - - if a, err := f.Activity(); err != nil || len(a.Records) == 0 { - return nil, nil - } else { - act := &activity{ - sport: a.Sessions[0].Sport.String(), - distance: a.Sessions[0].GetTotalDistanceScaled(), - } - r0, r1 := a.Records[0], a.Records[len(a.Records)-1] - dur := r1.Timestamp.Sub(r0.Timestamp) - if !includeSport(act.sport) || - !includeTimestamp(r0.Timestamp, r1.Timestamp) || - !includeDuration(dur) || - !includeDistance(act.distance) || - !includePace(dur, act.distance) { - return nil, nil - } - act.records = make([]*record, 0, len(a.Records)) - for _, rec := range a.Records { - if !rec.PositionLat.Invalid() && !rec.PositionLong.Invalid() { - act.records = append(act.records, &record{ - ts: rec.Timestamp, - pt: newPointFromSemicircles(rec.PositionLat.Semicircles(), rec.PositionLong.Semicircles()), - }) - } - } - if len(act.records) == 0 { - return nil, nil - } - return []*activity{act}, nil - } -} diff --git a/flags.go b/flags.go index 495c7cf..7ae274f 100644 --- a/flags.go +++ b/flags.go @@ -3,157 +3,58 @@ package main import ( "errors" "fmt" - "image/color" - "math" "regexp" "sort" "strconv" "strings" "time" + "github.com/NathanBaulch/rainbow-roads/conv" + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/NathanBaulch/rainbow-roads/img" + "github.com/NathanBaulch/rainbow-roads/parse" "github.com/araddon/dateparse" "github.com/bcicen/go-units" - "github.com/lucasb-eyer/go-colorful" - "golang.org/x/image/colornames" + "github.com/spf13/pflag" ) -func NewFormatFlag(opts ...string) FormatFlag { - return FormatFlag{opts: opts} +func filterFlagSet(selector *parse.Selector) *pflag.FlagSet { + fs := &pflag.FlagSet{} + fs.Var((*SportsFlag)(&selector.Sports), "sport", "sports to include, can be specified multiple times, eg running, cycling") + fs.Var((*DateFlag)(&selector.After), "after", "date from which activities should be included") + fs.Var((*DateFlag)(&selector.Before), "before", "date prior to which activities should be included") + fs.Var((*DurationFlag)(&selector.MinDuration), "min_duration", "shortest duration of included activities, eg 15m") + fs.Var((*DurationFlag)(&selector.MaxDuration), "max_duration", "longest duration of included activities, eg 1h") + fs.Var((*DistanceFlag)(&selector.MinDistance), "min_distance", "shortest distance of included activities, eg 2km") + fs.Var((*DistanceFlag)(&selector.MaxDistance), "max_distance", "greatest distance of included activities, eg 10mi") + fs.Var((*PaceFlag)(&selector.MinPace), "min_pace", "slowest pace of included activities, eg 8km/h") + fs.Var((*PaceFlag)(&selector.MaxPace), "max_pace", "fastest pace of included activities, eg 10min/mi") + fs.Var((*CircleFlag)(&selector.BoundedBy), "bounded_by", "region that activities must be fully contained within, eg -37.8,144.9,10km") + fs.Var((*CircleFlag)(&selector.StartsNear), "starts_near", "region that activities must start from, eg 51.53,-0.21,1km") + fs.Var((*CircleFlag)(&selector.EndsNear), "ends_near", "region that activities must end in, eg 30.06,31.22,1km") + fs.Var((*CircleFlag)(&selector.PassesThrough), "passes_through", "region that activities must pass through, eg 40.69,-74.12,10mi") + return fs } -type FormatFlag struct { - opts []string - index int +func flagError(name string, value any, reason string) error { + return fmt.Errorf("invalid value %q for flag --%s: %s\n", value, name, reason) } -func (f *FormatFlag) Type() string { - return "format" -} - -func (f *FormatFlag) Set(str string) error { - if str == "" { - return errors.New("unexpected empty value") - } - for i, opt := range f.opts { - if strings.EqualFold(str, opt) { - f.index = i + 1 - return nil - } - } - return errors.New("invalid value") -} - -func (f *FormatFlag) String() string { - if f.index > 0 { - return f.opts[f.index-1] - } - return "" -} - -func (f *FormatFlag) IsZero() bool { - return f.index == 0 -} - -type ColorsFlag []struct { - colorful.Color - pos float64 -} +type ColorsFlag img.ColorGradient func (c *ColorsFlag) Type() string { return "colors" } func (c *ColorsFlag) Set(str string) error { - if str == "" { - return errors.New("unexpected empty value") - } - - parts := strings.Split(str, ",") - *c = make(ColorsFlag, len(parts)) - var err error - missingAt := 0 - - for i, part := range parts { - if part == "" { - return errors.New("unexpected empty entry") - } - - e := &(*c)[i] - if pos := strings.Index(part, "@"); pos >= 0 { - if strings.HasSuffix(part, "%") { - if p, err := strconv.ParseFloat(part[pos+1:len(part)-1], 64); err != nil { - return fmt.Errorf("position %q not recognized", part[pos+1:]) - } else { - e.pos = p / 100 - } - } else { - if e.pos, err = strconv.ParseFloat(part[pos+1:], 64); err != nil { - return fmt.Errorf("position %q not recognized", part[pos+1:]) - } - } - if e.pos < 0 || e.pos > 1 { - return fmt.Errorf("position %q not within range", part[pos+1:]) - } - part = part[:pos] - } else if i == 0 { - e.pos = 0 - } else if i == len(parts)-1 { - e.pos = 1 - } else { - e.pos = math.NaN() - if missingAt == 0 { - missingAt = i - } - } - if !math.IsNaN(e.pos) && missingAt > 0 { - p := (*c)[missingAt-1].pos - step := (e.pos - p) / float64(i+1-missingAt) - for j := missingAt; j < i; j++ { - p += step - (*c)[j].pos = p - } - missingAt = 0 - } - - if e.Color, err = colorful.Hex(part); err != nil { - if col, ok := colornames.Map[strings.ToLower(part)]; !ok { - return fmt.Errorf("color %q not recognized", part) - } else { - e.Color, _ = colorful.MakeColor(col) - } - } - i++ - } - - return nil + return (*img.ColorGradient)(c).Parse(str) } func (c *ColorsFlag) String() string { - parts := make([]string, len(*c)) - for i, e := range *c { - var hex string - if r, g, b := e.Color.RGB255(); r>>4 == r&0xf && g>>4 == g&0xf && b>>4 == b&0xf { - hex = fmt.Sprintf("#%1x%1x%1x", r&0xf, g&0xf, b&0xf) - } else { - hex = fmt.Sprintf("#%02x%02x%02x", r, g, b) - } - if (i == 0 && e.pos == 0) || (i == len(*c)-1 && e.pos == 1) { - parts[i] = hex - } else { - parts[i] = fmt.Sprintf("%s@%s", hex, formatFloat(e.pos)) - } - } - return strings.Join(parts, ",") -} - -func (c *ColorsFlag) GetColorAt(p float64) color.Color { - last := len(*c) - 1 - for i := 0; i < last; i++ { - if e0, e1 := (*c)[i], (*c)[i+1]; e0.pos <= p && p <= e1.pos { - return e0.Color.BlendHcl(e1.Color, (p-e0.pos)/(e1.pos-e0.pos)).Clamped() - } + if c == nil { + return "" } - return (*c)[last].Color + return (*img.ColorGradient)(c).String() } type SportsFlag []string @@ -255,7 +156,7 @@ func (d *DistanceFlag) Set(str string) error { } func (d *DistanceFlag) String() string { - return formatFloat(float64(*d)) + return conv.FormatFloat(float64(*d)) } type PaceFlag time.Duration @@ -295,7 +196,7 @@ func (p *PaceFlag) String() string { return time.Duration(*p).String() } -type CircleFlag Circle +type CircleFlag geo.Circle func (c *CircleFlag) Type() string { return "circle" @@ -312,16 +213,16 @@ func (c *CircleFlag) Set(str string) error { } else if lon, err := strconv.ParseFloat(parts[1], 64); err != nil { return fmt.Errorf("longitude %q not recognized", parts[1]) } else if lat < -85 || lat > 85 { - return fmt.Errorf("latitude %q not within range", formatFloat(lat)) + return fmt.Errorf("latitude %q not within range", conv.FormatFloat(lat)) } else if lon < -180 || lon > 180 { - return fmt.Errorf("longitude %q not within range", formatFloat(lon)) + return fmt.Errorf("longitude %q not within range", conv.FormatFloat(lon)) } else { *c = CircleFlag{ - origin: newPointFromDegrees(lat, lon), - radius: 100, + Origin: geo.NewPointFromDegrees(lat, lon), + Radius: 100, } if len(parts) == 3 { - if c.radius, err = parseDistance(parts[2]); err != nil { + if c.Radius, err = parseDistance(parts[2]); err != nil { return errors.New("radius " + err.Error()) } } @@ -330,10 +231,10 @@ func (c *CircleFlag) Set(str string) error { } func (c *CircleFlag) String() string { - if c == nil || Circle(*c).IsZero() { + if c == nil || geo.Circle(*c).IsZero() { return "" } - return Circle(*c).String() + return geo.Circle(*c).String() } var distanceRE = regexp.MustCompile(`^(.*\d)\s?(\w+)?$`) diff --git a/flags_test.go b/flags_test.go index 2f2746d..9facb68 100644 --- a/flags_test.go +++ b/flags_test.go @@ -6,111 +6,9 @@ import ( "strings" "testing" - "github.com/lucasb-eyer/go-colorful" + "github.com/NathanBaulch/rainbow-roads/geo" ) -func TestFormatSet(t *testing.T) { - testCases := []struct { - set string - expect interface{} - }{ - {"gif", "gif"}, - {"GIF", "gif"}, - {"", errors.New("unexpected empty value")}, - {"foo", errors.New("invalid value")}, - } - - for i, testCase := range testCases { - t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { - f := NewFormatFlag("gif") - if err := f.Set(testCase.set); err != nil { - if expectErr, ok := testCase.expect.(error); !ok { - t.Fatal(err) - } else if !strings.Contains(err.Error(), expectErr.Error()) { - t.Fatal(err, "!=", testCase.expect) - } else { - return - } - } - actual := f.String() - if actual != testCase.expect { - t.Fatal(actual, "!=", testCase.expect) - } - }) - } -} - -func TestColorsSet(t *testing.T) { - testCases := []struct { - set string - expect interface{} - }{ - {"#fff", "#fff"}, - {"#fff,#000", "#fff,#000"}, - {"#123456,#789abc", "#123456,#789abc"}, - {"#fff,#888,#000", "#fff,#888@0.5,#000"}, - {"#fff,#ccc,#666,#333,#000", "#fff,#ccc@0.25,#666@0.5,#333@0.75,#000"}, - {"#fff,#999@.7,#888,#777,#000", "#fff,#999@0.7,#888@0.8,#777@0.9,#000"}, - {"#fff,#aaa,#999@.7,#888,#777,#000", "#fff,#aaa@0.35,#999@0.7,#888@0.8,#777@0.9,#000"}, - {"#fff@.1,#000@.9", "#fff@0.1,#000@0.9"}, - {"#ABCDEF", "#abcdef"}, - {"red,green,blue", "#f00,#008000@0.5,#00f"}, - {"red@11.1%", "#f00@0.111"}, - {"", errors.New("unexpected empty value")}, - {",#fff", errors.New("unexpected empty entry")}, - {"#fff,", errors.New("unexpected empty entry")}, - {"foo", errors.New(`color "foo" not recognized`)}, - {"#fff@foo", errors.New(`position "foo" not recognized`)}, - {"#fff@foo%", errors.New(`position "foo%" not recognized`)}, - {"#fff@9e9", errors.New(`position "9e9" not within range`)}, - {"#fff@101%", errors.New(`position "101%" not within range`)}, - } - - for i, testCase := range testCases { - t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { - g := &ColorsFlag{} - if err := g.Set(testCase.set); err != nil { - if expectErr, ok := testCase.expect.(error); !ok { - t.Fatal(err) - } else if !strings.Contains(err.Error(), expectErr.Error()) { - t.Fatal(err, "!=", testCase.expect) - } else { - return - } - } - actual := g.String() - if actual != testCase.expect { - t.Fatal(actual, "!=", testCase.expect) - } - }) - } -} - -func TestColorsAt(t *testing.T) { - g := &ColorsFlag{} - if err := g.Set("#fff,#ccc,#888,#444,#222,#000"); err != nil { - t.Fatal(err) - } - for p, expect := range map[float64]string{ - 0.0: "#ffffff", - 0.1: "#e5e5e5", - 0.2: "#cccccc", - 0.3: "#a9a9a9", - 0.4: "#888888", - 0.5: "#656565", - 0.6: "#444444", - 0.7: "#333333", - 0.8: "#222222", - 0.9: "#151515", - 1.0: "#000000", - } { - actual := g.GetColorAt(p).(colorful.Color).Hex() - if actual != expect { - t.Fatal("palette color at ", p, ":", actual, "!=", expect) - } - } -} - func TestSportsSet(t *testing.T) { testCases := []struct { sets []string @@ -320,13 +218,13 @@ func TestRegionSet(t *testing.T) { for i, testCase := range testCases { t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { - c := &Circle{} + c := &geo.Circle{} if err := (*CircleFlag)(c).Set(testCase.set); err != nil { if expectErr, ok := testCase.expect.(error); !ok { t.Fatal(err) } else if !strings.Contains(err.Error(), expectErr.Error()) { t.Fatal(err, "!=", testCase.expect) - } else if !c.IsZero() { + } else if c != nil && !c.IsZero() { t.Fatal("expected zero") } else { return @@ -343,14 +241,14 @@ func TestRegionSet(t *testing.T) { } func TestRegionContains(t *testing.T) { - c := &Circle{} + c := &geo.Circle{} if err := (*CircleFlag)(c).Set("1,2,3"); err != nil { t.Fatal(err) } - if !c.Contains(Point{lat: 0.0174536, lon: 0.0349068}) { + if !c.Contains(geo.Point{Lat: 0.0174536, Lon: 0.0349068}) { t.Fatal("expected contains") } - if c.Contains(Point{lat: 0.0174537, lon: 0.0349069}) { + if c.Contains(geo.Point{Lat: 0.0174537, Lon: 0.0349069}) { t.Fatal("expected not contains") } } diff --git a/geo.go b/geo.go deleted file mode 100644 index 774b1b1..0000000 --- a/geo.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "fmt" - "math" - "strconv" - "strings" -) - -func degreesToRadians(d float64) float64 { - return d * math.Pi / 180 -} - -func radiansToDegrees(r float64) float64 { - return r * 180 / math.Pi -} - -func semicirclesToRadians(s int32) float64 { - return float64(s) * math.Pi / math.MaxInt32 -} - -const ( - mercatorRadius = 6_378_137 - haversineRadius = 6_371_000 -) - -func mercatorMeters(pt Point) (float64, float64) { - x := mercatorRadius * pt.lon - y := mercatorRadius * math.Log(math.Tan((2*pt.lat+math.Pi)/4)) - return x, y -} - -func haversineDistance(pt1, pt2 Point) float64 { - sinLat := math.Sin((pt2.lat - pt1.lat) / 2) - sinLon := math.Sin((pt2.lon - pt1.lon) / 2) - a := sinLat*sinLat + math.Cos(pt1.lat)*math.Cos(pt2.lat)*sinLon*sinLon - return 2 * haversineRadius * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) -} - -func newPointFromDegrees(lat, lon float64) Point { - return Point{lat: degreesToRadians(lat), lon: degreesToRadians(lon)} -} - -func newPointFromSemicircles(lat, lon int32) Point { - return Point{lat: semicirclesToRadians(lat), lon: semicirclesToRadians(lon)} -} - -type Point struct { - lat, lon float64 -} - -func (p Point) String() string { - return fmt.Sprintf("%s,%s", formatFloat(radiansToDegrees(p.lat)), formatFloat(radiansToDegrees(p.lon))) -} - -func (p Point) IsZero() bool { - return p.lat == 0 && p.lon == 0 -} - -type Circle struct { - origin Point - radius float64 -} - -func (c Circle) String() string { - return fmt.Sprintf("%s,%s", c.origin, formatFloat(c.radius)) -} - -func (c Circle) IsZero() bool { - return c.radius == 0 -} - -func (c Circle) Contains(pt Point) bool { - return haversineDistance(pt, c.origin) < c.radius -} - -func (c Circle) Enclose(pt Point) Circle { - c.radius = math.Max(c.radius, haversineDistance(c.origin, pt)) - return c -} - -type Box struct { - min, max Point -} - -func (b Box) IsZero() bool { - return b.min.IsZero() && b.max.IsZero() -} - -func (b Box) Center() Point { - return Point{lat: (b.max.lat + b.min.lat) / 2, lon: (b.max.lon + b.min.lon) / 2} -} - -func (b Box) Enclose(pt Point) Box { - if b.IsZero() { - b.min = pt - b.max = pt - } else { - b.min.lat = math.Min(b.min.lat, pt.lat) - b.max.lat = math.Max(b.max.lat, pt.lat) - b.min.lon = math.Min(b.min.lon, pt.lon) - b.max.lon = math.Max(b.max.lon, pt.lon) - } - return b -} - -func formatFloat(val float64) string { - str := strconv.FormatFloat(val, 'f', 5, 64) - return strings.TrimRight(strings.TrimRight(str, "0"), ".") -} diff --git a/geo/geo.go b/geo/geo.go new file mode 100644 index 0000000..77841ef --- /dev/null +++ b/geo/geo.go @@ -0,0 +1,110 @@ +package geo + +import ( + "fmt" + "math" + + "github.com/NathanBaulch/rainbow-roads/conv" +) + +const ( + mercatorRadius = 6_378_137 + haversineRadius = 6_371_000 +) + +func DegreesToRadians(d float64) float64 { + return d * math.Pi / 180 +} + +func RadiansToDegrees(r float64) float64 { + return r * 180 / math.Pi +} + +func SemicirclesToRadians(s int32) float64 { + return float64(s) * math.Pi / math.MaxInt32 +} + +func NewPointFromDegrees(lat, lon float64) Point { + return Point{Lat: DegreesToRadians(lat), Lon: DegreesToRadians(lon)} +} + +func NewPointFromSemicircles(lat, lon int32) Point { + return Point{Lat: SemicirclesToRadians(lat), Lon: SemicirclesToRadians(lon)} +} + +type Point struct { + Lat, Lon float64 +} + +func (p Point) String() string { + return fmt.Sprintf("%s,%s", conv.FormatFloat(RadiansToDegrees(p.Lat)), conv.FormatFloat(RadiansToDegrees(p.Lon))) +} + +func (p Point) IsZero() bool { + return p.Lat == 0 && p.Lon == 0 +} + +func (p Point) DistanceTo(pt Point) float64 { + sinLat := math.Sin((pt.Lat - p.Lat) / 2) + sinLon := math.Sin((pt.Lon - p.Lon) / 2) + a := sinLat*sinLat + math.Cos(p.Lat)*math.Cos(pt.Lat)*sinLon*sinLon + return 2 * haversineRadius * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +func (p Point) MercatorProjection() (float64, float64) { + x := mercatorRadius * p.Lon + y := mercatorRadius * math.Log(math.Tan((2*p.Lat+math.Pi)/4)) + return x, y +} + +type Circle struct { + Origin Point + Radius float64 +} + +func (c Circle) String() string { + return fmt.Sprintf("%s,%s", c.Origin, conv.FormatFloat(c.Radius)) +} + +func (c Circle) IsZero() bool { + return c.Radius == 0 +} + +func (c Circle) Contains(pt Point) bool { + return c.Origin.DistanceTo(pt) < c.Radius +} + +func (c Circle) Enclose(pt Point) Circle { + c.Radius = math.Max(c.Radius, c.Origin.DistanceTo(pt)) + return c +} + +func (c Circle) Grow(factor float64) Circle { + c.Radius *= factor + return c +} + +type Box struct { + Min, Max Point +} + +func (b Box) IsZero() bool { + return b.Min.IsZero() && b.Max.IsZero() +} + +func (b Box) Center() Point { + return Point{Lat: (b.Max.Lat + b.Min.Lat) / 2, Lon: (b.Max.Lon + b.Min.Lon) / 2} +} + +func (b Box) Enclose(pt Point) Box { + if b.IsZero() { + b.Min = pt + b.Max = pt + } else { + b.Min.Lat = math.Min(b.Min.Lat, pt.Lat) + b.Max.Lat = math.Max(b.Max.Lat, pt.Lat) + b.Min.Lon = math.Min(b.Min.Lon, pt.Lon) + b.Max.Lon = math.Max(b.Max.Lon, pt.Lon) + } + return b +} diff --git a/geo_test.go b/geo/geo_test.go similarity index 74% rename from geo_test.go rename to geo/geo_test.go index beb3615..94ee108 100644 --- a/geo_test.go +++ b/geo/geo_test.go @@ -1,4 +1,4 @@ -package main +package geo import ( "fmt" @@ -6,7 +6,7 @@ import ( "testing" ) -func TestMercatorMeters(t *testing.T) { +func TestMercatorProjection(t *testing.T) { testCases := []struct { lat, lon float64 }{ @@ -21,7 +21,7 @@ func TestMercatorMeters(t *testing.T) { for i, testCase := range testCases { t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { - x, y := mercatorMeters(newPointFromDegrees(testCase.lat, testCase.lon)) + x, y := NewPointFromDegrees(testCase.lat, testCase.lon).MercatorProjection() if math.IsNaN(x) { t.Fatal("expected x number") } diff --git a/go.mod b/go.mod index 8e7a4f3..4c1d41a 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,23 @@ module github.com/NathanBaulch/rainbow-roads -go 1.17 +go 1.18 require ( github.com/StephaneBunel/bresenham v0.0.0-20211027152503-ec76d7b8e923 + github.com/antonmedv/expr v1.9.0 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/bcicen/go-units v1.0.3 + github.com/fogleman/gg v1.3.0 github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 github.com/llehouerou/go-tcx v0.0.0-20161119054955-2b6af946ac47 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/schollz/progressbar v1.0.0 + github.com/serjvanilla/go-overpass v0.0.0-20220918094045-58606372f808 + github.com/spf13/cobra v1.6.0 github.com/spf13/pflag v1.0.5 github.com/tkrajina/gpxgo v1.2.1 github.com/tormoder/fit v0.14.0 + github.com/vmihailenco/msgpack/v5 v5.3.5 + golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6 golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 golang.org/x/text v0.3.8 ) @@ -22,10 +27,13 @@ require ( github.com/Knetic/govaluate v3.0.0+incompatible // indirect github.com/bcicen/bfstree v1.0.0 // indirect github.com/client9/misspell v0.3.4 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/gordonklaus/ineffassign v0.0.0-20220928193011-d2c82e48359b // indirect + github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/kisielk/errcheck v1.6.2 // indirect github.com/mdempsky/unconvert v0.0.0-20221005094106-27bfb232899d // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/exp/typeparams v0.0.0-20221011201855-a3968a42eed6 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect diff --git a/go.sum b/go.sum index 810fe15..4667cf9 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,14 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/StephaneBunel/bresenham v0.0.0-20211027152503-ec76d7b8e923 h1:tiBl/Mvf7sSO5y3OVK7wznaQkLOCfHhX9KReDeN9WjE= github.com/StephaneBunel/bresenham v0.0.0-20211027152503-ec76d7b8e923/go.mod h1:IEPPVq1llBFm+OeVCgoAyFiTqK5aE2c2tH4QEsjnQCM= +github.com/antonmedv/expr v1.9.0 h1:j4HI3NHEdgDnN9p6oI6Ndr0G5QryMY0FNxT4ONrFDGU= +github.com/antonmedv/expr v1.9.0/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/bcicen/bfstree v1.0.0 h1:Fx9vcyXYspj2GIJqAvd1lwCNI+cQF/r2JJqxHHmsAO0= @@ -17,20 +20,27 @@ github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A= github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= github.com/gordonklaus/ineffassign v0.0.0-20220928193011-d2c82e48359b h1:TYNAU9lu7ggdAereRq0dzCIDzHu9mNyGLj/hd5PXq8I= github.com/gordonklaus/ineffassign v0.0.0-20220928193011-d2c82e48359b/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joeshaw/gengen v0.0.0-20190604015154-c77d87825f5a/go.mod h1:v2qvRL8Xwk4OlARK6gPlf2JreZXzv0dYp/8+kUJ0y7Q= github.com/jonas-p/go-shp v0.1.1/go.mod h1:MRIhyxDQ6VVp0oYeD7yPGr5RSTNScUFKCDsI5DR7PtI= github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 h1:8tP9cdXzcGX2AvweVVG/lxbI7BSjWbNNUustwJ9dQVA= @@ -49,43 +59,58 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/llehouerou/go-tcx v0.0.0-20161119054955-2b6af946ac47 h1:XlBtO1BaPQOF7mm2dlPkNlP4T2IYviyGZCeAHVZoWYI= github.com/llehouerou/go-tcx v0.0.0-20161119054955-2b6af946ac47/go.mod h1:ARTRp9hq/2uKcv/HBDsrQGjVwkAnYgOmrHsp0/kvFhk= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mdempsky/unconvert v0.0.0-20200228143138-95ecdbfc0b5f/go.mod h1:AmCV4WB3cDMZqgPk+OUQKumliiQS4ZYsBt3AXekyuAU= github.com/mdempsky/unconvert v0.0.0-20221005094106-27bfb232899d h1:jBMmw08uv17XGVZTkBcxoQvQETFCm79jRbW7N6jQtB4= github.com/mdempsky/unconvert v0.0.0-20221005094106-27bfb232899d/go.mod h1:/EbenRHJqJiytYcUOXcCDNjyTIrenY3rQs57e/k3FrI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/schollz/progressbar v1.0.0 h1:gbyFReLHDkZo8mxy/dLWMr+Mpb1MokGJ1FqCiqacjZM= -github.com/schollz/progressbar v1.0.0/go.mod h1:/l9I7PC3L3erOuz54ghIRKUEFcosiWfLvJv+Eq26UMs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/serjvanilla/go-overpass v0.0.0-20220918094045-58606372f808 h1:0AObvHxEbYubS79jKxIvnHmjdgNpGXRWibS6omxz37A= +github.com/serjvanilla/go-overpass v0.0.0-20220918094045-58606372f808/go.mod h1:W2WcJBoB8P+XjAtc6TrLPK9+HG67xkz84vw0ghbV0qU= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= +github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tealeg/xlsx v1.0.3/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA= github.com/tkrajina/gpxgo v1.2.1 h1:MJJtT4Re5btDGg89brFDrUP3EWz+cBmyo8pQwV0ZOak= github.com/tkrajina/gpxgo v1.2.1/go.mod h1:795sjVRFo5wWyN6oOZp0RYienGGBJjpAlgOz2nCngA0= github.com/tormoder/fit v0.14.0 h1:VUFoJP9NQqIlvkUpj2mDsfgmtk3BvJSmJlIomM3hgFs= github.com/tormoder/fit v0.14.0/go.mod h1:SSqpKG5yF0THwzTqS0xW3S6eyheALtOfj8LytAkSRZQ= -github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6 h1:+hSdOdB7nHAFs+EDQXTvkJj7kUMugNAcE2x+BwxlVt4= +golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20221011201855-a3968a42eed6 h1:NC6xExxkTyBs1ibV9EXYSaNIwVMIdBcj3qoGA8WYOrA= golang.org/x/exp/typeparams v0.0.0-20221011201855-a3968a42eed6/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= @@ -104,17 +129,16 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4= golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -122,15 +146,9 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14 h1:k5II8e6QD8mITdi+okbbmR/cIyEbeXLBhy5Ha4nevyc= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -155,8 +173,10 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= honnef.co/go/tools v0.3.3 h1:oDx7VAwstgpYpb3wv0oxiZlxY+foCpRAwY7Vk6XpAgA= honnef.co/go/tools v0.3.3/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= diff --git a/img/color.go b/img/color.go new file mode 100644 index 0000000..325c79b --- /dev/null +++ b/img/color.go @@ -0,0 +1,112 @@ +package img + +import ( + "errors" + "fmt" + "image/color" + "math" + "strconv" + "strings" + + "github.com/NathanBaulch/rainbow-roads/conv" + "github.com/lucasb-eyer/go-colorful" + "golang.org/x/image/colornames" +) + +type ColorGradient []struct { + colorful.Color + pos float64 +} + +func (c *ColorGradient) Parse(str string) error { + if str == "" { + return errors.New("unexpected empty value") + } + + parts := strings.Split(str, ",") + *c = make(ColorGradient, len(parts)) + var err error + missingAt := 0 + + for i, part := range parts { + if part == "" { + return errors.New("unexpected empty entry") + } + + e := &(*c)[i] + if pos := strings.Index(part, "@"); pos >= 0 { + if strings.HasSuffix(part, "%") { + if p, err := strconv.ParseFloat(part[pos+1:len(part)-1], 64); err != nil { + return fmt.Errorf("position %q not recognized", part[pos+1:]) + } else { + e.pos = p / 100 + } + } else { + if e.pos, err = strconv.ParseFloat(part[pos+1:], 64); err != nil { + return fmt.Errorf("position %q not recognized", part[pos+1:]) + } + } + if e.pos < 0 || e.pos > 1 { + return fmt.Errorf("position %q not within range", part[pos+1:]) + } + part = part[:pos] + } else if i == 0 { + e.pos = 0 + } else if i == len(parts)-1 { + e.pos = 1 + } else { + e.pos = math.NaN() + if missingAt == 0 { + missingAt = i + } + } + if !math.IsNaN(e.pos) && missingAt > 0 { + p := (*c)[missingAt-1].pos + step := (e.pos - p) / float64(i+1-missingAt) + for j := missingAt; j < i; j++ { + p += step + (*c)[j].pos = p + } + missingAt = 0 + } + + if e.Color, err = colorful.Hex(part); err != nil { + if col, ok := colornames.Map[strings.ToLower(part)]; !ok { + return fmt.Errorf("color %q not recognized", part) + } else { + e.Color, _ = colorful.MakeColor(col) + } + } + i++ + } + + return nil +} + +func (c *ColorGradient) String() string { + parts := make([]string, len(*c)) + for i, e := range *c { + var hex string + if r, g, b := e.Color.RGB255(); r>>4 == r&0xf && g>>4 == g&0xf && b>>4 == b&0xf { + hex = fmt.Sprintf("#%1x%1x%1x", r&0xf, g&0xf, b&0xf) + } else { + hex = fmt.Sprintf("#%02x%02x%02x", r, g, b) + } + if (i == 0 && e.pos == 0) || (i == len(*c)-1 && e.pos == 1) { + parts[i] = hex + } else { + parts[i] = fmt.Sprintf("%s@%s", hex, conv.FormatFloat(e.pos)) + } + } + return strings.Join(parts, ",") +} + +func (c *ColorGradient) GetColorAt(p float64) color.Color { + last := len(*c) - 1 + for i := 0; i < last; i++ { + if e0, e1 := (*c)[i], (*c)[i+1]; e0.pos <= p && p <= e1.pos { + return e0.Color.BlendHcl(e1.Color, (p-e0.pos)/(e1.pos-e0.pos)).Clamped() + } + } + return (*c)[last].Color +} diff --git a/img/color_test.go b/img/color_test.go new file mode 100644 index 0000000..804c87f --- /dev/null +++ b/img/color_test.go @@ -0,0 +1,81 @@ +package img + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/lucasb-eyer/go-colorful" +) + +func TestColorGradientParse(t *testing.T) { + testCases := []struct { + set string + expect interface{} + }{ + {"#fff", "#fff"}, + {"#fff,#000", "#fff,#000"}, + {"#123456,#789abc", "#123456,#789abc"}, + {"#fff,#888,#000", "#fff,#888@0.5,#000"}, + {"#fff,#ccc,#666,#333,#000", "#fff,#ccc@0.25,#666@0.5,#333@0.75,#000"}, + {"#fff,#999@.7,#888,#777,#000", "#fff,#999@0.7,#888@0.8,#777@0.9,#000"}, + {"#fff,#aaa,#999@.7,#888,#777,#000", "#fff,#aaa@0.35,#999@0.7,#888@0.8,#777@0.9,#000"}, + {"#fff@.1,#000@.9", "#fff@0.1,#000@0.9"}, + {"#ABCDEF", "#abcdef"}, + {"red,green,blue", "#f00,#008000@0.5,#00f"}, + {"red@11.1%", "#f00@0.111"}, + {"", errors.New("unexpected empty value")}, + {",#fff", errors.New("unexpected empty entry")}, + {"#fff,", errors.New("unexpected empty entry")}, + {"foo", errors.New(`color "foo" not recognized`)}, + {"#fff@foo", errors.New(`position "foo" not recognized`)}, + {"#fff@foo%", errors.New(`position "foo%" not recognized`)}, + {"#fff@9e9", errors.New(`position "9e9" not within range`)}, + {"#fff@101%", errors.New(`position "101%" not within range`)}, + } + + for i, testCase := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + g := &ColorGradient{} + if err := g.Parse(testCase.set); err != nil { + if expectErr, ok := testCase.expect.(error); !ok { + t.Fatal(err) + } else if !strings.Contains(err.Error(), expectErr.Error()) { + t.Fatal(err, "!=", testCase.expect) + } else { + return + } + } + actual := g.String() + if actual != testCase.expect { + t.Fatal(actual, "!=", testCase.expect) + } + }) + } +} + +func TestColorGradientColorAt(t *testing.T) { + g := &ColorGradient{} + if err := g.Parse("#fff,#ccc,#888,#444,#222,#000"); err != nil { + t.Fatal(err) + } + for p, expect := range map[float64]string{ + 0.0: "#ffffff", + 0.1: "#e5e5e5", + 0.2: "#cccccc", + 0.3: "#a9a9a9", + 0.4: "#888888", + 0.5: "#656565", + 0.6: "#444444", + 0.7: "#333333", + 0.8: "#222222", + 0.9: "#151515", + 1.0: "#000000", + } { + actual := g.GetColorAt(p).(colorful.Color).Hex() + if actual != expect { + t.Fatal("palette color at ", p, ":", actual, "!=", expect) + } + } +} diff --git a/img/draw.go b/img/draw.go new file mode 100644 index 0000000..ca68e4c --- /dev/null +++ b/img/draw.go @@ -0,0 +1,27 @@ +package img + +import ( + "image" + "image/color" + "image/draw" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +func DrawWatermark(im image.Image, text string, c color.Color) { + d := &font.Drawer{ + Dst: im.(draw.Image), + Src: image.NewUniform(c), + Face: basicfont.Face7x13, + } + b, _ := d.BoundString(text) + b = b.Sub(b.Min) + if b.In(fixed.R(0, 0, im.Bounds().Max.X-10, im.Bounds().Max.Y-10)) { + d.Dot = fixed.P(im.Bounds().Max.X, im.Bounds().Max.Y). + Sub(b.Max.Sub(fixed.P(0, basicfont.Face7x13.Height))). + Sub(fixed.P(5, 5)) + d.DrawString(text) + } +} diff --git a/lockdown_paint.png b/lockdown_paint.png new file mode 100644 index 0000000..4a3b037 Binary files /dev/null and b/lockdown_paint.png differ diff --git a/lockdown_project.gif b/lockdown_worms.gif similarity index 100% rename from lockdown_project.gif rename to lockdown_worms.gif diff --git a/main.go b/main.go index 8c88c6c..4d41151 100644 --- a/main.go +++ b/main.go @@ -1,764 +1,47 @@ package main import ( - "archive/zip" - "bufio" - "bytes" - "compress/gzip" - "errors" - "fmt" - "image" - "image/color" - "image/gif" - "io" - "io/fs" - "log" - "math" "os" - "path/filepath" - "sort" "strings" - "sync" - "time" - "github.com/StephaneBunel/bresenham" - "github.com/kettek/apng" - "github.com/schollz/progressbar" - "github.com/spf13/pflag" - "golang.org/x/text/language" - "golang.org/x/text/message" + "github.com/spf13/cobra" ) -var ( - Version string - fullTitle string - shortTitle string +const Title = "rainbow-roads" - input []string - output string - width uint - frames uint - fps uint - format = NewFormatFlag("gif", "png", "zip") - colors ColorsFlag - colorDepth uint - speed float64 - loop bool - noWatermark bool +var Version string - sports SportsFlag - after time.Time - before time.Time - minDuration time.Duration - maxDuration time.Duration - minDistance float64 - maxDistance float64 - minPace time.Duration - maxPace time.Duration - boundedBy Circle - startsNear Circle - endsNear Circle - passesThrough Circle - - en = message.NewPrinter(language.English) - files []*file - activities []*activity - maxDur time.Duration - box Box - images []*image.Paletted -) - -func init() { - shortTitle = "rainbow-roads" - if Version != "" { - shortTitle += " " + Version - } - fullTitle = "NathanBaulch/" + shortTitle - - _ = colors.Set("#fff,#ff8,#911,#414,#007@.5,#003") +var rootCmd = &cobra.Command{ + Use: Title, + Version: Version, + Short: Title + ": Animate your exercise maps!", + CompletionOptions: cobra.CompletionOptions{HiddenDefaultCmd: true}, } func main() { - general := &pflag.FlagSet{} - general.StringVar(&output, "output", "out", "optional path of the generated file") - general.Var(&format, "format", "output file format string, supports gif, png, zip") - general.VisitAll(func(f *pflag.Flag) { pflag.Var(f.Value, f.Name, f.Usage) }) - - rendering := &pflag.FlagSet{} - rendering.UintVar(&frames, "frames", 200, "number of animation frames") - rendering.UintVar(&fps, "fps", 20, "animation frame rate") - rendering.UintVar(&width, "width", 500, "width of the generated image in pixels") - rendering.Var(&colors, "colors", "CSS linear-colors inspired color scheme string, eg red,yellow,green,blue,black") - rendering.UintVar(&colorDepth, "color_depth", 5, "number of bits per color in the image palette") - rendering.Float64Var(&speed, "speed", 1.25, "how quickly activities should progress") - rendering.BoolVar(&loop, "loop", false, "start each activity sequentially and animate continuously") - rendering.BoolVar(&noWatermark, "no_watermark", false, "suppress the embedded project name and version string") - rendering.VisitAll(func(f *pflag.Flag) { pflag.Var(f.Value, f.Name, f.Usage) }) - - filters := &pflag.FlagSet{} - filters.Var(&sports, "sport", "sports to include, can be specified multiple times, eg running, cycling") - filters.Var((*DateFlag)(&after), "after", "date from which activities should be included") - filters.Var((*DateFlag)(&before), "before", "date prior to which activities should be included") - filters.Var((*DurationFlag)(&minDuration), "min_duration", "shortest duration of included activities, eg 15m") - filters.Var((*DurationFlag)(&maxDuration), "max_duration", "longest duration of included activities, eg 1h") - filters.Var((*DistanceFlag)(&minDistance), "min_distance", "shortest distance of included activities, eg 2km") - filters.Var((*DistanceFlag)(&maxDistance), "max_distance", "greatest distance of included activities, eg 10mi") - filters.Var((*PaceFlag)(&minPace), "min_pace", "slowest pace of included activities, eg 8km/h") - filters.Var((*PaceFlag)(&maxPace), "max_pace", "fastest pace of included activities, eg 10min/mi") - filters.Var((*CircleFlag)(&boundedBy), "bounded_by", "region that activities must be fully contained within, eg -37.8,144.9,10km") - filters.Var((*CircleFlag)(&startsNear), "starts_near", "region that activities must start from, eg 51.53,-0.21,1km") - filters.Var((*CircleFlag)(&endsNear), "ends_near", "region that activities must end in, eg 30.06,31.22,1km") - filters.Var((*CircleFlag)(&passesThrough), "passes_through", "region that activities must pass through, eg 40.69,-74.12,10mi") - filters.VisitAll(func(f *pflag.Flag) { pflag.Var(f.Value, f.Name, f.Usage) }) - - pflag.CommandLine.Init("", pflag.ContinueOnError) - pflag.Usage = func() { - fmt.Fprintln(os.Stderr, "Usage of", shortTitle+":") - general.PrintDefaults() - fmt.Fprintln(os.Stderr, "Filtering:") - filters.PrintDefaults() - fmt.Fprintln(os.Stderr, "Rendering:") - rendering.PrintDefaults() - } - if err := pflag.CommandLine.Parse(os.Args[1:]); err != nil { - if err == pflag.ErrHelp { - return - } - fmt.Println(err) - os.Exit(2) - } - - invalidFlag := func(name string, reason string) { - fmt.Fprintf(os.Stderr, "invalid value %q for flag --%s: %s\n", pflag.Lookup(name).Value, name, reason) - pflag.Usage() - os.Exit(2) - } - if frames == 0 { - invalidFlag("frames", "must be positive") - } - if fps == 0 { - invalidFlag("fps", "must be positive") - } - if width == 0 { - invalidFlag("width", "must be positive") - } - if colorDepth == 0 { - invalidFlag("color_depth", "must be positive") - } - if speed < 1 { - invalidFlag("speed", "must be greater than or equal to 1") - } - - en.Println(shortTitle) - - input = pflag.Args() - if len(input) == 0 { - input = []string{"."} - } - - if fi, err := os.Stat(output); err != nil { - if _, ok := err.(*fs.PathError); !ok { - log.Fatalln(err) - } - } else if fi.IsDir() { - output = filepath.Join(output, "out") - } - - ext := filepath.Ext(output) - if ext != "" { - ext = ext[1:] - if format.IsZero() { - _ = format.Set(ext) - } - } - if format.IsZero() { - _ = format.Set("gif") - } - if !strings.EqualFold(ext, format.String()) { - output += "." + format.String() - } - - for _, step := range []func() error{scan, parse, render, save} { - if err := step(); err != nil { - log.Fatalln(err) - } - } -} - -func scan() error { - for _, in := range input { - paths := []string{in} - if strings.ContainsAny(in, "*?[") { - var err error - if paths, err = filepath.Glob(in); err != nil { - if err == filepath.ErrBadPattern { - return fmt.Errorf("input path pattern %q malformed", in) - } - return err - } - } - - for _, path := range paths { - dir, name := filepath.Split(path) - if dir == "" { - dir = "." - } - fsys := os.DirFS(dir) - if fi, err := os.Stat(path); err != nil { - if _, ok := err.(*fs.PathError); ok { - return fmt.Errorf("input path %q not found", path) - } - return err - } else if fi.IsDir() { - err := fs.WalkDir(fsys, name, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } else { - return scanFile(fsys, path) - } - }) - if err != nil { - return err - } - } else if err := scanFile(fsys, name); err != nil { - return err - } - } - } - - en.Println("activity files:", len(files)) - return nil -} - -func scanFile(fsys fs.FS, path string) error { - ext := filepath.Ext(path) - if strings.EqualFold(filepath.Ext(path), ".zip") { - if f, err := fsys.Open(path); err != nil { - return err - } else if s, err := f.Stat(); err != nil { - return err - } else { - r, ok := f.(io.ReaderAt) - if !ok { - if b, err := io.ReadAll(f); err != nil { - return err - } else { - r = bytes.NewReader(b) - } - } - if fsys, err := zip.NewReader(r, s.Size()); err != nil { - return err - } else { - return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } else { - return scanFile(fsys, path) - } - }) - } - } - } else { - gz := strings.EqualFold(ext, ".gz") - if gz { - ext = filepath.Ext(path[:len(path)-3]) - } - var parser func(io.Reader) ([]*activity, error) - if strings.EqualFold(ext, ".fit") { - parser = parseFIT - } else if strings.EqualFold(ext, ".gpx") { - parser = parseGPX - } else if strings.EqualFold(ext, ".tcx") { - parser = parseTCX - } else { - return nil - } - parse := func() ([]*activity, error) { - var r io.Reader - var err error - if r, err = fsys.Open(path); err != nil { - return nil, err - } else if gz { - if r, err = gzip.NewReader(r); err != nil { - return nil, err - } - } - return parser(r) - } - files = append(files, &file{path, parse}) - return nil - } -} - -type file struct { - path string - parse func() ([]*activity, error) -} - -func parse() error { - pb := progressbar.New(len(files)) - pb.SetWriter(os.Stderr) - wg := sync.WaitGroup{} - wg.Add(len(files)) - res := make([]struct { - acts []*activity - err error - }, len(files)) - for i := range files { - i := i - go func() { - res[i].acts, res[i].err = files[i].parse() - _ = pb.Add(1) - wg.Done() - }() - } - wg.Wait() - - fmt.Fprintln(os.Stderr) - activities = make([]*activity, 0, len(files)) - for _, r := range res { - if r.err != nil { - fmt.Fprintln(os.Stderr, "WARN:", r.err) - } else { - activities = append(activities, r.acts...) - } - } - if len(activities) == 0 { - return errors.New("no matching activities found") - } - - sportStats := make(map[string]int) - minDur := time.Duration(math.MaxInt64) - var minDate, maxDate time.Time - minDist, maxDist := math.MaxFloat64, 0.0 - minP, maxP := time.Duration(math.MaxInt64), time.Duration(0) - sumRec := 0 - var sumDur time.Duration - sumDist := 0.0 - var startBox, endBox Box - for i := len(activities) - 1; i >= 0; i-- { - act := activities[i] - include := passesThrough.IsZero() - exclude := false - for j, r := range act.records { - if j == 0 && !startsNear.IsZero() && !startsNear.Contains(r.pt) { - exclude = true - break - } - if j == len(act.records)-1 && !endsNear.IsZero() && !endsNear.Contains(r.pt) { - exclude = true - break - } - if !boundedBy.IsZero() && !boundedBy.Contains(r.pt) { - exclude = true - break - } - if !include && passesThrough.Contains(r.pt) { - include = true - } - } - if exclude || !include { - j := len(activities) - 1 - activities[i] = activities[j] - activities = activities[:j] - continue - } - - if act.sport == "" { - sportStats["unknown"]++ - } else { - sportStats[strings.ToLower(act.sport)]++ - } - ts0, ts1 := act.records[0].ts, act.records[len(act.records)-1].ts - if minDate.IsZero() || ts0.Before(minDate) { - minDate = ts0 - } - if maxDate.IsZero() || ts1.After(maxDate) { - maxDate = ts1 - } - dur := ts1.Sub(ts0) - if dur < minDur { - minDur = dur - } - if dur > maxDur { - maxDur = dur - } - if act.distance < minDist { - minDist = act.distance - } - if act.distance > maxDist { - maxDist = act.distance - } - pace := time.Duration(float64(dur) / act.distance) - if pace < minP { - minP = pace - } - if pace > maxP { - maxP = pace - } - - sumRec += len(act.records) - sumDur += dur - sumDist += act.distance - - for _, r := range act.records { - box = box.Enclose(r.pt) - } - startBox = startBox.Enclose(act.records[0].pt) - endBox = endBox.Enclose(act.records[len(act.records)-1].pt) - } - - if len(activities) == 0 { - return errors.New("no matching activities found") - } - - bounds := Circle{origin: box.Center()} - starts := Circle{origin: startBox.Center()} - ends := Circle{origin: endBox.Center()} - for _, act := range activities { - for _, r := range act.records { - bounds = bounds.Enclose(r.pt) - } - starts = starts.Enclose(act.records[0].pt) - ends = ends.Enclose(act.records[len(act.records)-1].pt) - } - - en.Printf("activities: %d\n", len(activities)) - en.Printf("records: %d\n", sumRec) - en.Printf("sports: %s\n", sprintSportStats(en, sportStats)) - en.Printf("period: %s\n", sprintPeriod(en, minDate, maxDate)) - en.Printf("duration: %s to %s, average %s, total %s\n", minDur, maxDur, (sumDur / time.Duration(len(activities))).Truncate(time.Second), sumDur) - en.Printf("distance: %.1fkm to %.1fkm, average %.1fkm, total %.1fkm\n", minDist/1000, maxDist/1000, sumDist/float64(len(activities))/1000, sumDist/1000) - en.Printf("pace: %s/km to %s/km, average %s/km\n", (minP * 1000).Truncate(time.Second), (maxP * 1000).Truncate(time.Second), (sumDur * 1000 / time.Duration(sumDist)).Truncate(time.Second)) - en.Printf("bounds: %s\n", bounds) - en.Printf("starts within: %s\n", starts) - en.Printf("ends within: %s\n", ends) - return nil -} - -func sprintSportStats(p *message.Printer, stats map[string]int) string { - pairs := make([]struct { - k string - v int - }, len(stats)) - i := 0 - for k, v := range stats { - pairs[i].k = k - pairs[i].v = v - i++ - } - sort.Slice(pairs, func(i, j int) bool { - p0, p1 := pairs[i], pairs[j] - return p0.v > p1.v || (p0.v == p1.v && p0.k < p1.k) - }) - a := make([]interface{}, len(stats)*2) - i = 0 - for _, kv := range pairs { - a[i] = kv.k - i++ - a[i] = kv.v - i++ - } - return p.Sprintf(strings.Repeat(", %s (%d)", len(stats))[2:], a...) -} - -func sprintPeriod(p *message.Printer, minDate, maxDate time.Time) string { - d := maxDate.Sub(minDate) - var num float64 - var unit string - switch { - case d.Hours() >= 365.25*24: - num, unit = d.Hours()/(365.25*24), "years" - case d.Hours() >= 365.25*2: - num, unit = d.Hours()/(365.25*2), "months" - case d.Hours() >= 7*24: - num, unit = d.Hours()/(7*24), "weeks" - case d.Hours() >= 24: - num, unit = d.Hours()/24, "days" - case d.Hours() >= 1: - num, unit = d.Hours(), "hours" - case d.Minutes() >= 1: - num, unit = d.Minutes(), "minutes" - default: - num, unit = d.Seconds(), "seconds" - } - return p.Sprintf("%.1f %s (%s to %s)", num, unit, minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) -} - -func includeSport(sport string) bool { - if len(sports) == 0 { - return true - } - for _, s := range sports { - if strings.EqualFold(s, sport) { - return true - } - } - return false -} - -func includeTimestamp(from, to time.Time) bool { - if !after.IsZero() && after.After(from) { - return false - } - if !before.IsZero() && before.Before(to) { - return false - } - return true -} - -func includeDuration(duration time.Duration) bool { - if duration == 0 { - return false - } - if minDuration != 0 && duration < minDuration { - return false - } - if maxDuration != 0 && duration > maxDuration { - return false - } - return true -} - -func includeDistance(distance float64) bool { - if distance == 0 { - return false - } - if minDistance != 0 && distance < float64(minDistance) { - return false - } - if maxDistance != 0 && distance > float64(maxDistance) { - return false - } - return true -} - -func includePace(duration time.Duration, distance float64) bool { - pace := time.Duration(float64(duration) / distance) - if pace == 0 { - return false - } - if minPace != 0 && pace < minPace { - return false - } - if maxPace != 0 && pace > maxPace { - return false - } - return true -} - -type activity struct { - sport string - distance float64 - records []*record -} - -type record struct { - ts time.Time - pt Point - x, y int - pc float64 -} - -func render() error { - if loop { - sort.Slice(activities, func(i, j int) bool { return activities[i].records[0].ts.Before(activities[j].records[0].ts) }) - } - - minX, minY := mercatorMeters(box.min) - maxX, maxY := mercatorMeters(box.max) - dX, dY := maxX-minX, maxY-minY - scale := float64(width) / dX - height := uint(dY * scale) - scale *= 0.9 - minX -= 0.05 * dX - maxY += 0.05 * dY - tScale := 1 / (speed * float64(maxDur)) - for i, act := range activities { - ts0 := act.records[0].ts - tOffset := 0.0 - if loop { - tOffset = float64(i) / float64(len(activities)) - } - for _, r := range act.records { - x, y := mercatorMeters(r.pt) - r.x = int((x - minX) * scale) - r.y = int((maxY - y) * scale) - r.pc = tOffset + float64(r.ts.Sub(ts0))*tScale - } - } - - pal := color.Palette(make([]color.Color, 1<= 0 && pc < 1 { - ci = uint8(math.Sqrt(pc) * float64(len(pal)-2)) - } - bresenham.DrawLine(gp, rPrev.x, rPrev.y, r.x, r.y, grays[ci]) - } - rPrev = r - } - } - wg.Done() - }() - } - wg.Wait() - - return nil -} - -type glowPlotter struct { - *image.Paletted -} - -func (p *glowPlotter) Set(x, y int, c color.Color) { - p.SetColorIndex(x, y, c.(color.Gray).Y) -} - -func (p *glowPlotter) SetColorIndex(x, y int, ci uint8) { - if p.setPixIfLower(x, y, ci) { - const sqrt2 = 1.414213562 - if i := float64(ci) * sqrt2; i < float64(len(p.Palette)-2) { - ci = uint8(i) - p.setPixIfLower(x-1, y, ci) - p.setPixIfLower(x, y-1, ci) - p.setPixIfLower(x+1, y, ci) - p.setPixIfLower(x, y+1, ci) - } - if i := float64(ci) * sqrt2; i < float64(len(p.Palette)-2) { - ci = uint8(i) - p.setPixIfLower(x-1, y-1, ci) - p.setPixIfLower(x-1, y+1, ci) - p.setPixIfLower(x+1, y-1, ci) - p.setPixIfLower(x+1, y+1, ci) - } - } -} - -func (p *glowPlotter) setPixIfLower(x, y int, ci uint8) bool { - if (image.Point{X: x, Y: y}.In(p.Rect)) { - i := p.PixOffset(x, y) - if p.Pix[i] > ci { - p.Pix[i] = ci - return true - } - } - return false -} - -func save() error { - if dir := filepath.Dir(output); dir != "." { - if err := os.MkdirAll(dir, os.ModePerm); err != nil { - return err - } - } - - out, err := os.Create(output) - if err != nil { - return err - } - defer func() { - if err := out.Close(); err != nil { - log.Fatal(err) - } - }() - - switch format.String() { - case "gif": - return saveGIF(out) - case "png": - return savePNG(out) - case "zip": - return saveZIP(out) - default: - return nil - } -} - -func saveGIF(w io.Writer) error { - optimizeFrames(images) - g := &gif.GIF{ - Image: images, - Delay: make([]int, len(images)), - Disposal: make([]byte, len(images)), - Config: image.Config{ - ColorModel: images[0].Palette, - Width: images[0].Rect.Max.X, - Height: images[0].Rect.Max.Y, - }, - } - d := int(math.Round(100 / float64(fps))) - for i := range images { - g.Disposal[i] = gif.DisposalNone - g.Delay[i] = d - } - return gif.EncodeAll(&gifWriter{Writer: bufio.NewWriter(w)}, g) -} - -func savePNG(w io.Writer) error { - optimizeFrames(images) - a := apng.APNG{Frames: make([]apng.Frame, len(images))} - for i, im := range images { - a.Frames[i].Image = im - a.Frames[i].XOffset = im.Rect.Min.X - a.Frames[i].YOffset = im.Rect.Min.Y - a.Frames[i].BlendOp = apng.BLEND_OP_OVER - a.Frames[i].DelayNumerator = 1 - a.Frames[i].DelayDenominator = uint16(fps) - } - return apng.Encode(&pngWriter{Writer: w}, a) -} - -func saveZIP(w io.Writer) error { - z := zip.NewWriter(w) - defer func() { - if err := z.Close(); err != nil { - log.Fatal(err) - } - }() - for i, im := range images { - if w, err := z.Create(fmt.Sprintf("%d.gif", i)); err != nil { - return err - } else if err = gif.Encode(w, im, nil); err != nil { - return err - } - } - return nil -} + rootCmd.InitDefaultHelpCmd() + if _, _, err := rootCmd.Find(os.Args[1:]); err != nil && strings.HasPrefix(err.Error(), "unknown command ") { + rootCmd.SetArgs(append([]string{wormsCmd.Name()}, os.Args[1:]...)) + } + if rootCmd.Execute() != nil { + os.Exit(1) + } +} + +/* TODO +- quiet mode flag +- update README +- decide progress bars +- cmd + - export + - heatmap + - blocks +- alternative region shapes + - geo + - circle + - rect + - polygon + - named +- "projects" with saved settings +- env vars? +*/ diff --git a/paint.go b/paint.go new file mode 100644 index 0000000..46d93a5 --- /dev/null +++ b/paint.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + + "github.com/NathanBaulch/rainbow-roads/paint" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var ( + paintOpts = &paint.Options{ + Title: Title, + Version: Version, + } + paintCmd = &cobra.Command{ + Use: "paint", + Short: "Track coverage in a region of interest", + PreRunE: func(cmd *cobra.Command, args []string) error { + if paintOpts.Width == 0 { + return flagError("width", paintOpts.Width, "must be positive") + } + return nil + }, + RunE: func(_ *cobra.Command, args []string) error { + paintOpts.Input = args + return paint.Run(paintOpts) + }, + } +) + +func init() { + rootCmd.AddCommand(paintCmd) + + general := &pflag.FlagSet{} + general.VarP((*CircleFlag)(&paintOpts.Region), "region", "r", "target region of interest, eg -37.8,144.9,10km") + general.StringVarP(&paintOpts.Output, "output", "o", "out", "optional path of the generated file") + general.VisitAll(func(f *pflag.Flag) { paintCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + _ = paintCmd.MarkFlagRequired("region") + + rendering := &pflag.FlagSet{} + rendering.UintVarP(&paintOpts.Width, "width", "w", 1000, "width of the generated image in pixels") + rendering.BoolVar(&paintOpts.NoWatermark, "no_watermark", false, "suppress the embedded project name and version string") + rendering.VisitAll(func(f *pflag.Flag) { paintCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + + filters := filterFlagSet(&paintOpts.Selector) + filters.VisitAll(func(f *pflag.Flag) { paintCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + + paintCmd.SetUsageFunc(func(*cobra.Command) error { + fmt.Fprintln(paintCmd.OutOrStderr()) + fmt.Fprintln(paintCmd.OutOrStderr(), "Usage:") + fmt.Fprintln(paintCmd.OutOrStderr(), " ", paintCmd.UseLine(), "[input]") + fmt.Fprintln(paintCmd.OutOrStderr()) + fmt.Fprintln(paintCmd.OutOrStderr(), "General flags:") + fmt.Fprintln(paintCmd.OutOrStderr(), general.FlagUsages()) + fmt.Fprintln(paintCmd.OutOrStderr(), "Filtering flags:") + fmt.Fprintln(paintCmd.OutOrStderr(), filters.FlagUsages()) + fmt.Fprintln(paintCmd.OutOrStderr(), "Rendering flags:") + fmt.Fprint(paintCmd.OutOrStderr(), rendering.FlagUsages()) + return nil + }) +} diff --git a/paint/expr.go b/paint/expr.go new file mode 100644 index 0000000..44aacfa --- /dev/null +++ b/paint/expr.go @@ -0,0 +1,264 @@ +package paint + +import ( + "github.com/antonmedv/expr" + "github.com/antonmedv/expr/ast" + "github.com/antonmedv/expr/vm" +) + +var operatorPairs = map[string]string{ + "and": "or", + "&&": "||", + "==": "!=", + ">=": "<", + ">": "<=", + "in": "not in", +} + +func init() { + for k, v := range operatorPairs { + operatorPairs[v] = k + } +} + +func mustCompile(input string, ops ...expr.Option) *vm.Program { + if program, err := expr.Compile(input, ops...); err != nil { + panic(err) + } else { + return program + } +} + +func mustRun(program *vm.Program, env interface{}) interface{} { + if res, err := expr.Run(program, env); err != nil { + panic(err) + } else { + return res + } +} + +type expandInArray struct{} + +func (*expandInArray) Enter(*ast.Node) {} + +func (*expandInArray) Exit(node *ast.Node) { + if bi := asBinaryIn(*node); bi != nil { + if an, ok := bi.Right.(*ast.ArrayNode); ok { + if len(an.Nodes) == 0 { + ast.Patch(node, &ast.BoolNode{}) + } else { + for i, n := range an.Nodes { + if i == 0 { + ast.Patch(node, &ast.BinaryNode{ + Operator: "==", + Left: bi.Left, + Right: n, + }) + } else { + ast.Patch(node, &ast.BinaryNode{ + Operator: "or", + Left: *node, + Right: &ast.BinaryNode{ + Operator: "==", + Left: bi.Left, + Right: n, + }, + }) + } + } + } + if bi.Operator == "not in" { + ast.Patch(node, &ast.UnaryNode{ + Operator: "not", + Node: *node, + }) + } + } + } +} + +type expandInRange struct{} + +func (*expandInRange) Enter(*ast.Node) {} + +func (*expandInRange) Exit(node *ast.Node) { + if bi := asBinaryIn(*node); bi != nil { + if br, ok := bi.Right.(*ast.BinaryNode); ok && br.Operator == ".." { + if getValue(br.Left) == getValue(br.Right) { + ast.Patch(node, &ast.BinaryNode{ + Operator: "==", + Left: bi.Left, + Right: br.Left, + }) + } else { + ast.Patch(node, &ast.BinaryNode{ + Operator: "and", + Left: &ast.BinaryNode{ + Operator: ">=", + Left: bi.Left, + Right: br.Left, + }, + Right: &ast.BinaryNode{ + Operator: "<=", + Left: bi.Left, + Right: br.Right, + }, + }) + } + + if bi.Operator == "not in" { + ast.Patch(node, &ast.UnaryNode{ + Operator: "not", + Node: *node, + }) + } + } + } +} + +func getValue(n ast.Node) any { + switch a := n.(type) { + case *ast.NilNode: + return nil + case *ast.IntegerNode: + return a.Value + case *ast.FloatNode: + return a.Value + case *ast.BoolNode: + return a.Value + case *ast.StringNode: + return a.Value + case *ast.ConstantNode: + return a.Value + default: + return n + } +} + +type distributeAndFoldNot struct{} + +func (d *distributeAndFoldNot) Enter(node *ast.Node) { + if un := asUnaryNot(*node); un != nil { + if bn, ok := un.Node.(*ast.BinaryNode); ok { + if op, ok := operatorPairs[bn.Operator]; ok { + switch bn.Operator { + case "and", "&&", "or", "||": + bn.Left = &ast.UnaryNode{ + Operator: un.Operator, + Node: bn.Left, + } + bn.Right = &ast.UnaryNode{ + Operator: un.Operator, + Node: bn.Right, + } + } + bn.Operator = op + ast.Patch(node, bn) + } + } else if n := asUnaryNot(un.Node); n != nil { + ast.Walk(&n.Node, d) + ast.Patch(node, n.Node) + } else if b, ok := un.Node.(*ast.BoolNode); ok { + b.Value = !b.Value + ast.Patch(node, b) + } + } +} + +func (*distributeAndFoldNot) Exit(*ast.Node) {} + +func toDNF(node *ast.Node) { + for limit := 1000; limit >= 0; limit-- { + f := &dnf{} + ast.Walk(node, f) + if !f.applied { + return + } + } +} + +type dnf struct { + depth int + applied bool +} + +func (f *dnf) Enter(node *ast.Node) { + if f.depth > 0 { + f.depth++ + } else if bn, ok := (*node).(*ast.BinaryNode); !ok || (bn.Operator != "and" && bn.Operator != "&&" && bn.Operator != "or" && bn.Operator != "||") { + f.depth++ + } +} + +func (f *dnf) Exit(node *ast.Node) { + if f.depth > 0 { + f.depth-- + return + } + + if ba := asBinaryAnd(*node); ba != nil { + if bo := asBinaryOr(ba.Left); bo != nil { + ast.Patch(node, &ast.BinaryNode{ + Operator: bo.Operator, + Left: &ast.BinaryNode{ + Operator: ba.Operator, + Left: bo.Left, + Right: ba.Right, + }, + Right: &ast.BinaryNode{ + Operator: ba.Operator, + Left: bo.Right, + Right: ba.Right, + }, + }) + f.applied = true + return + } + + if bo := asBinaryOr(ba.Right); bo != nil { + ast.Patch(node, &ast.BinaryNode{ + Operator: bo.Operator, + Left: &ast.BinaryNode{ + Operator: ba.Operator, + Left: ba.Left, + Right: bo.Left, + }, + Right: &ast.BinaryNode{ + Operator: ba.Operator, + Left: ba.Left, + Right: bo.Right, + }, + }) + f.applied = true + return + } + } +} + +func asBinaryIn(node ast.Node) *ast.BinaryNode { + if bn, ok := node.(*ast.BinaryNode); ok && (bn.Operator == "in" || bn.Operator == "not in") { + return bn + } + return nil +} + +func asBinaryAnd(node ast.Node) *ast.BinaryNode { + if bn, ok := node.(*ast.BinaryNode); ok && (bn.Operator == "and" || bn.Operator == "&&") { + return bn + } + return nil +} + +func asBinaryOr(node ast.Node) *ast.BinaryNode { + if bn, ok := node.(*ast.BinaryNode); ok && (bn.Operator == "or" || bn.Operator == "||") { + return bn + } + return nil +} + +func asUnaryNot(node ast.Node) *ast.UnaryNode { + if un, ok := node.(*ast.UnaryNode); ok && (un.Operator == "not" || un.Operator == "!") { + return un + } + return nil +} diff --git a/paint/expr_test.go b/paint/expr_test.go new file mode 100644 index 0000000..12ae631 --- /dev/null +++ b/paint/expr_test.go @@ -0,0 +1,142 @@ +package paint + +import ( + "fmt" + "testing" + + "github.com/antonmedv/expr/ast" + "github.com/antonmedv/expr/parser" +) + +func TestExpandInArray(t *testing.T) { + testCases := []struct{ input, want string }{ + {"A in []", "false"}, + {"A in [B]", "A == B"}, + {"A in [B,C]", "A == B or A == C"}, + {"A not in []", "not(false)"}, + {"A not in [B]", "not(A == B)"}, + {"A not in [B,C]", "not(A == B or A == C)"}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + gotT, err := parser.Parse(tc.input) + if err != nil { + t.Fatal(err) + } + ast.Walk(&gotT.Node, &expandInArray{}) + wantT, err := parser.Parse(tc.want) + if err != nil { + t.Fatal(err) + } + + got := ast.Dump(gotT.Node) + want := ast.Dump(wantT.Node) + if got != want { + t.Fatalf("%s != %s", got, want) + } + }) + } +} + +func TestExpandInRange(t *testing.T) { + testCases := []struct{ input, want string }{ + {"A in 2..2", "A == 2"}, + {"A in 2..4", "A >= 2 and A <= 4"}, + {"A not in 2..4", "not(A >= 2 and A <= 4)"}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + gotT, err := parser.Parse(tc.input) + if err != nil { + t.Fatal(err) + } + ast.Walk(&gotT.Node, &expandInRange{}) + wantT, err := parser.Parse(tc.want) + if err != nil { + t.Fatal(err) + } + + got := ast.Dump(gotT.Node) + want := ast.Dump(wantT.Node) + if got != want { + t.Fatalf("%s != %s", got, want) + } + }) + } +} + +func TestDistributeAndFoldNot(t *testing.T) { + testCases := []struct{ input, want string }{ + {"!!A", "A"}, + {"!!!A", "!A"}, + {"!!!!A", "A"}, + {"!true", "false"}, + {"!not(!A)", "!A"}, + {"!(A or B)", "!A and !B"}, + {"!(A and B)", "!A or !B"}, + {"!(!(A && B) || !C)", "A && B && C"}, + {"!(A == B)", "A != B"}, + {"!(A > B)", "A <= B"}, + {"!(A in B)", "A not in B"}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + gotT, err := parser.Parse(tc.input) + if err != nil { + t.Fatal(err) + } + ast.Walk(&gotT.Node, &distributeAndFoldNot{}) + wantT, err := parser.Parse(tc.want) + if err != nil { + t.Fatal(err) + } + + got := ast.Dump(gotT.Node) + want := ast.Dump(wantT.Node) + if got != want { + t.Fatalf("%s != %s", got, want) + } + }) + } +} + +func TestDNF(t *testing.T) { + testCases := []struct{ input, want string }{ + {"A and (B or C)", "(A and B) or (A and C)"}, + {"(A or B) and C", "(A and C) or (B and C)"}, + {"(A or B or C) and D", "(A and D) or (B and D) or (C and D)"}, + {"(A or B) and (C or D)", "(A and C) or (A and D) or ((B and C) or (B and D))"}, + {"(A and (B or C)) or D", "(A and B) or (A and C) or D"}, + {"((A or B) and C) or D", "(A and C) or (B and C) or D"}, + {"((A or B or C) and D) or E", "(A and D) or (B and D) or (C and D) or E"}, + {"((A or B) and (C or D)) or E", "(A and C) or (A and D) or ((B and C) or (B and D)) or E"}, + {"(A and (B or C)) and D", "(A and B and D) or (A and C and D)"}, + {"(A or B) and C and D", "(A and C and D) or (B and C and D)"}, + {"(A or B or C) and D and E", "(A and D and E) or (B and D and E) or (C and D and E)"}, + {"(A or B) and (C or D) and E", "(A and C and E) or (A and D and E) or ((B and C and E) or (B and D and E))"}, + {"A and (B or foo(C and (D or E)))", "(A and B) or (A and foo(C and (D or E)))"}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + gotT, err := parser.Parse(tc.input) + if err != nil { + t.Fatal(err) + } + toDNF(&gotT.Node) + wantT, err := parser.Parse(tc.want) + if err != nil { + t.Fatal(err) + } + + got := ast.Dump(gotT.Node) + want := ast.Dump(wantT.Node) + if got != want { + t.Fatalf("%s != %s", got, want) + } + }) + } +} diff --git a/paint/osm.go b/paint/osm.go new file mode 100644 index 0000000..050d8dd --- /dev/null +++ b/paint/osm.go @@ -0,0 +1,142 @@ +package paint + +import ( + "errors" + "hash/fnv" + "log" + "math" + "math/big" + "os" + "path" + "time" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/serjvanilla/go-overpass" + "github.com/vmihailenco/msgpack/v5" + "golang.org/x/exp/slices" +) + +type way struct { + Geometry []geo.Point + Highway string + Access string + Surface string +} + +const ttl = 168 * time.Hour + +func osmLookup(query string) ([]*way, error) { + h := fnv.New64() + _, _ = h.Write([]byte(query)) + name := path.Join(os.TempDir(), "rainbow-roads") + if err := os.MkdirAll(name, 777); err != nil { + return nil, err + } + name = path.Join(name, big.NewInt(0).SetBytes(h.Sum(nil)).Text(62)) + + if f, err := os.Stat(name); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } else if err == nil && time.Now().Sub(f.ModTime()) < ttl { + if data, err := os.ReadFile(name); err != nil { + log.Println("WARN:", err) + } else if ways, err := unpackWays(data); err != nil { + log.Println("WARN:", err) + } else { + return ways, nil + } + } + + if res, err := overpass.Query(query); err != nil { + return nil, err + } else if data, err := packWays(res.Ways); err != nil { + return nil, err + } else if err := os.WriteFile(name, data, 777); err != nil { + return nil, err + } else { + return unpackWays(data) + } +} + +func packWays(ways map[int64]*overpass.Way) ([]byte, error) { + d := doc{Ways: make([]elem, len(ways))} + + i := 0 + for _, w := range ways { + d.Ways[i].Geometry = make([][2]float32, len(w.Geometry)) + for j, g := range w.Geometry { + pt := geo.NewPointFromDegrees(g.Lat, g.Lon) + d.Ways[i].Geometry[j][0] = float32(pt.Lat) + d.Ways[i].Geometry[j][1] = float32(pt.Lon) + } + + packTag := func(tag string, known *[]string) uint8 { + if val, ok := w.Tags[tag]; ok { + j := slices.Index(*known, val) + if j < 0 { + j = len(*known) + *known = append(*known, val) + } + return uint8(j) + } + return math.MaxUint8 + } + d.Ways[i].Highway = packTag("highway", &d.Highways) + d.Ways[i].Access = packTag("access", &d.Accesses) + d.Ways[i].Surface = packTag("surface", &d.Surfaces) + + i++ + } + + return msgpack.Marshal(d) +} + +func unpackWays(data []byte) ([]*way, error) { + d := &doc{} + if err := msgpack.Unmarshal(data, d); err != nil { + return nil, err + } + + ways := make([]*way, len(d.Ways)) + for i, w := range d.Ways { + ways[i] = &way{Geometry: make([]geo.Point, len(w.Geometry))} + for j, p := range w.Geometry { + ways[i].Geometry[j].Lat = float64(p[0]) + ways[i].Geometry[j].Lon = float64(p[1]) + } + + if w.Highway < math.MaxUint8 { + if w.Highway >= uint8(len(d.Highways)) { + return nil, errors.New("invalid cache data") + } + ways[i].Highway = d.Highways[w.Highway] + } + if w.Access < math.MaxUint8 { + if w.Access >= uint8(len(d.Accesses)) { + return nil, errors.New("invalid cache data") + } + ways[i].Access = d.Accesses[w.Access] + } + if w.Surface < math.MaxUint8 { + if w.Surface >= uint8(len(d.Surfaces)) { + return nil, errors.New("invalid cache data") + } + ways[i].Surface = d.Surfaces[w.Surface] + } + } + + return ways, nil +} + +type doc struct { + Ways []elem `msgpack:"w"` + Highways []string `msgpack:"h"` + Accesses []string `msgpack:"a"` + Surfaces []string `msgpack:"s"` +} + +type elem struct { + Geometry [][2]float32 `msgpack:"g"` + Highway uint8 `msgpack:"h"` + Access uint8 `msgpack:"a"` + Surface uint8 `msgpack:"s"` +} diff --git a/paint/osm_test.go b/paint/osm_test.go new file mode 100644 index 0000000..650e0f9 --- /dev/null +++ b/paint/osm_test.go @@ -0,0 +1,41 @@ +package paint + +import ( + "fmt" + "testing" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/serjvanilla/go-overpass" +) + +func TestPackUnpackWays(t *testing.T) { + in := map[int64]*overpass.Way{ + 0: { + Meta: overpass.Meta{ + Tags: map[string]string{ + "highway": "primary", + "access": "public", + "surface": "paved", + }, + }, + Geometry: []overpass.Point{{Lat: 1, Lon: 2}}, + }, + } + if b, err := packWays(in); err != nil { + t.Fatal(err) + } else if out, err := unpackWays(b); err != nil { + t.Fatal(err) + } else if len(out) != 1 { + t.Fatal(fmt.Sprintf("ways len %d != %d", len(out), 1)) + } else if len(out[0].Geometry) != 1 { + t.Fatal(fmt.Sprintf("geometry len %d != %d", len(out[0].Geometry), 1)) + } else if !(geo.Circle{Origin: out[0].Geometry[0], Radius: 0.002}).Contains(geo.NewPointFromDegrees(1, 2)) { + t.Fatal(fmt.Sprintf("geometry %+v != %+v", out[0].Geometry[0], geo.NewPointFromDegrees(1, 2))) + } else if out[0].Highway != "primary" { + t.Fatal(fmt.Sprintf("highway %s != %s", out[0].Highway, "primary")) + } else if out[0].Access != "public" { + t.Fatal(fmt.Sprintf("access %s != %s", out[0].Access, "public")) + } else if out[0].Surface != "paved" { + t.Fatal(fmt.Sprintf("surface %s != %s", out[0].Surface, "paved")) + } +} diff --git a/paint/query.go b/paint/query.go new file mode 100644 index 0000000..6356786 --- /dev/null +++ b/paint/query.go @@ -0,0 +1,258 @@ +package paint + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "github.com/NathanBaulch/rainbow-roads/conv" + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/antonmedv/expr/ast" + "github.com/antonmedv/expr/parser" + "golang.org/x/exp/slices" +) + +func buildQuery(region geo.Circle, filter string) (string, error) { + if crits, err := buildCriteria(filter); err != nil { + return "", fmt.Errorf("overpass query error: %w", err) + } else { + prefix := fmt.Sprintf("way(around:%s,%s,%s)", + conv.FormatFloat(region.Radius), + conv.FormatFloat(geo.RadiansToDegrees(region.Origin.Lat)), + conv.FormatFloat(geo.RadiansToDegrees(region.Origin.Lon)), + ) + parts := make([]string, 0, len(crits)*3+2) + parts = append(parts, "[out:json];(") + for _, crit := range crits { + parts = append(parts, prefix, crit, ";") + } + parts = append(parts, ");out tags geom qt;") + return strings.Join(parts, ""), nil + } +} + +func buildCriteria(filter string) ([]string, error) { + tree, err := parser.Parse(filter) + if err != nil { + return nil, err + } + + ast.Walk(&tree.Node, &expandInArray{}) + ast.Walk(&tree.Node, &expandInRange{}) + ast.Walk(&tree.Node, &distributeAndFoldNot{}) + toDNF(&tree.Node) + + qb := queryBuilder{} + ast.Walk(&tree.Node, &qb) + if qb.err != nil { + return nil, qb.err + } + + for i, crit := range qb.stack { + if !isWrapped(crit) { + qb.stack[i] = fmt.Sprintf("(if:%s)", crit) + } + } + + return qb.stack, nil +} + +type queryBuilder struct { + stack []string + not []bool + depth int + err error +} + +func (q *queryBuilder) Enter(node *ast.Node) { + if q.depth > 0 { + q.depth++ + } else if not := asUnaryNot(*node) != nil; !not && asBinaryAnd(*node) == nil && asBinaryOr(*node) == nil { + q.depth++ + } else { + q.not = append(q.not, not) + } +} + +func (q *queryBuilder) Exit(node *ast.Node) { + if q.err != nil { + return + } + + if q.depth > 0 { + q.depth-- + } + root := q.depth == 0 + not := false + if root && len(q.not) > 0 { + i := len(q.not) - 1 + not = q.not[i] + q.not = q.not[:i] + } + + if not { + switch (*node).(type) { + case *ast.IntegerNode, *ast.FloatNode, *ast.StringNode: + q.err = fmt.Errorf("inverted %s not supported", nodeName(*node)) + return + } + } + + switch n := (*node).(type) { + case *ast.IdentifierNode: + name := n.Value + if slices.IndexFunc([]rune(n.Value), func(c rune) bool { return !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') }) >= 0 { + name = strconv.Quote(n.Value) + } + if !root { + q.push(bangIf(name, not)) + } else if not { + q.push("[", name, `="no"]`) + } else { + q.push("[", name, `="yes"]`) + } + case *ast.IntegerNode: + q.push(n.Value) + case *ast.FloatNode: + q.push(n.Value) + case *ast.BoolNode: + if n.Value != not { + q.push(`"yes"`) + } else { + q.push(`"no"`) + } + case *ast.StringNode: + q.push(strconv.Quote(n.Value)) + case *ast.UnaryNode: + if !root || (n.Operator != "not" && n.Operator != "!") { + q.push(bangIf(n.Operator, not), "(", q.pop(), ")") + } + case *ast.BinaryNode: + rhs, lhs := q.pop(), q.pop() + switch n.Operator { + case "and", "&&": + if !root || (!isWrapped(lhs) && !isWrapped(rhs)) { + q.push(lhs, "&&", rhs) + } else { + if !isWrapped(lhs) { + lhs = fmt.Sprintf("(if:%s)", lhs) + } + if !isWrapped(rhs) { + rhs = fmt.Sprintf("(if:%s)", rhs) + } + q.push(lhs, rhs) + } + case "or", "||": + if !root || (!isWrapped(lhs) && !isWrapped(rhs)) { + q.push(lhs, "||", rhs) + } else { + q.push(lhs) + q.push(rhs) + } + case ">", ">=", "<", "<=": + if _, ok := n.Left.(*ast.IdentifierNode); ok { + if lhs[0] != '"' { + lhs = strconv.Quote(lhs) + } + lhs = fmt.Sprintf("t[%s]", lhs) + } + q.push(lhs, n.Operator, rhs) + default: + op := n.Operator + switch op { + case "contains": + op = "~" + if _, ok := n.Right.(*ast.StringNode); ok { + rhs = regexp.QuoteMeta(rhs) + } + case "startsWith": + op = "~" + if _, ok := n.Right.(*ast.StringNode); ok { + rhs = rhs[:1] + "^" + regexp.QuoteMeta(rhs[1:]) + } + case "endsWith": + op = "~" + if _, ok := n.Right.(*ast.StringNode); ok { + rhs = regexp.QuoteMeta(rhs[:len(rhs)-1]) + "$" + rhs[len(rhs)-1:] + } + } + _, okl := n.Left.(*ast.FunctionNode) + _, okr := n.Right.(*ast.FunctionNode) + if okl || okr { + root = false + } + if root { + if op == "==" || op == "!=" { + if _, ok := n.Right.(*ast.IdentifierNode); ok { + lhs, rhs = rhs, lhs + } + if op == "!=" { + not = !not + } + if rhs == `""` { + op = "~" + rhs = `"^$"` + } else { + op = "=" + } + } + q.push("[", lhs, bangIf(op, not), rhs, "]") + } else { + q.push(lhs, bangIf(op, not), rhs) + } + } + case *ast.MatchesNode: + rhs, lhs := q.pop(), q.pop() + if root { + q.push("[", lhs, bangIf("~", not), rhs, "]") + } else { + q.push(lhs, bangIf("~", not), rhs) + } + case *ast.FunctionNode: + if root && n.Name == "is_tag" && len(n.Arguments) == 1 { + q.push("[", bangIf(q.pop(), not), "]") + } else { + parts := make([]any, 0, len(n.Arguments)+3) + parts = append(parts, bangIf(n.Name, not), "(") + for range n.Arguments { + parts = append(parts, q.pop()) + } + parts = append(parts, ")") + q.push(parts...) + } + case *ast.ConditionalNode: + e2, e1 := q.pop(), q.pop() + q.push(q.pop(), "?", e1, ":", e2) + default: + q.err = fmt.Errorf("%s not supported", nodeName(n)) + } +} + +func nodeName(n ast.Node) string { + name := reflect.TypeOf(n).Elem().Name() + return strings.ToLower(name[:len(name)-4]) +} + +func isWrapped(str string) bool { + return str[0] == '[' || strings.HasPrefix(str, "(if:") +} + +func bangIf(str string, not bool) string { + if not { + return "!" + str + } + return str +} + +func (q *queryBuilder) push(a ...any) { + q.stack = append(q.stack, fmt.Sprint(a...)) +} + +func (q *queryBuilder) pop() string { + i := len(q.stack) - 1 + str := q.stack[i] + q.stack = q.stack[:i] + return str +} diff --git a/paint/query_test.go b/paint/query_test.go new file mode 100644 index 0000000..da2ea4b --- /dev/null +++ b/paint/query_test.go @@ -0,0 +1,115 @@ +package paint + +import ( + "fmt" + "strings" + "testing" + + "github.com/NathanBaulch/rainbow-roads/geo" +) + +func TestBuildQuery(t *testing.T) { + testCases := []struct { + origin geo.Point + radius float64 + filter, want string + }{ + {geo.NewPointFromDegrees(1, 2), 3, "is_tag(highway)", `[out:json];(way(around:3,1,2)[highway];);out tags geom qt;`}, + {geo.NewPointFromDegrees(4, 5), 6, "is_tag(highway) or is_tag(service)", `[out:json];(way(around:6,4,5)[highway];way(around:6,4,5)[service];);out tags geom qt;`}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + got, err := buildQuery(geo.Circle{Origin: tc.origin, Radius: tc.radius}, tc.filter) + if err != nil { + t.Fatal(err) + } + + if got != tc.want { + t.Fatalf("%s != %s", got, tc.want) + } + }) + } +} + +func TestBuildCriteria(t *testing.T) { + testCases := []struct{ input, want string }{ + {"lit", `[lit="yes"]`}, + {"!lit", `[lit="no"]`}, + {"lit == true", `[lit="yes"]`}, + {"not(lit == false)", `[lit!="no"]`}, + {"highway == 'primary'", `[highway="primary"]`}, + {"maxspeed == 50", `[maxspeed=50]`}, + {"max(maxspeed == 50)", `(if:max(maxspeed==50))`}, + {"ref == 2.5", `[ref=2.5]`}, + {"public_transport == 'platform'", `["public_transport"="platform"]`}, + {"power == ''", `[power~"^$"]`}, + {"power != ''", `[power!~"^$"]`}, + {"is_tag(name)", `[name]`}, + {"is_tag('name')", `["name"]`}, + {"!is_tag(name)", `[!name]`}, + {"id() == 4", `(if:id()==4)`}, + {"lit ? 'light' : 'dark'", `(if:lit?"light":"dark")`}, + {"name contains 'Lane'", `[name~"Lane"]`}, + {"name startsWith 'Lane'", `[name~"^Lane"]`}, + {"name endsWith 'Lane'", `[name~"Lane$"]`}, + {"name matches '^L.n.$'", `[name~"^L.n.$"]`}, + {"not(name contains 'Lane')", `[name!~"Lane"]`}, + {"not(name startsWith 'Lane')", `[name!~"^Lane"]`}, + {"not(name endsWith 'Lane')", `[name!~"Lane$"]`}, + {"not(name matches '^L.n.$')", `[name!~"^L.n.$"]`}, + {"max(name contains 'Lane')", `(if:max(name~"Lane"))`}, + {"max(name startsWith 'Lane')", `(if:max(name~"^Lane"))`}, + {"max(name endsWith 'Lane')", `(if:max(name~"Lane$"))`}, + {"max(name matches '^L.n.$')", `(if:max(name~"^L.n.$"))`}, + {"maxspeed > 50", `(if:t["maxspeed"]>50)`}, + {"maxspeed in 50..60", `(if:t["maxspeed"]>=50&&t["maxspeed"]<=60)`}, + {"maxspeed not in 50..60", `(if:t["maxspeed"]<50||t["maxspeed"]>60)`}, + {"'proposed' == highway", `[highway="proposed"]`}, + {"max(!'primary')", `(if:max(!("primary")))`}, + {"!-id()", `(if:!-(id()))`}, + {"is_tag(highway) and maxspeed > 50", `[highway](if:t["maxspeed"]>50)`}, + {"maxspeed > 50 and is_tag(highway)", `(if:t["maxspeed"]>50)[highway]`}, + {"is_tag(highway) and maxspeed > 50 and service != 'driveway'", `[highway](if:t["maxspeed"]>50)[service!="driveway"]`}, + {"maxspeed > 50 and is_tag(highway) and maxspeed < 60", `(if:t["maxspeed"]>50)[highway](if:t["maxspeed"]<60)`}, + {"maxspeed > 50 and maxspeed < 60 and is_tag(highway)", `(if:t["maxspeed"]>50&&t["maxspeed"]<60)[highway]`}, + {"highway not in ['proposed','corridor'] and service != 'driveway'", `[highway!="proposed"][highway!="corridor"][service!="driveway"]`}, + {"highway in ['primary','secondary','tertiary']", `[highway="primary"];[highway="secondary"];[highway="tertiary"]`}, + {"highway in ['primary','secondary','tertiary'] and service == 'driveway'", `[highway="primary"][service="driveway"];[highway="secondary"][service="driveway"];[highway="tertiary"][service="driveway"]`}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + crits, err := buildCriteria(tc.input) + if err != nil { + t.Fatal(err) + } + + got := strings.Join(crits, ";") + if got != tc.want { + t.Fatalf("%s != %s", got, tc.want) + } + }) + } +} + +func TestBuildCriteriaUnsupported(t *testing.T) { + testCases := []struct{ input, err string }{ + {"!5", `inverted integer not supported`}, + {"!3.14", `inverted float not supported`}, + {"!'foo'", `inverted string not supported`}, + {"nil", `nil not supported`}, + {"[]", `array not supported`}, + {"{}", `map not supported`}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + if res, err := buildCriteria(tc.input); err == nil { + t.Fatalf("unexpected success: %s", res) + } else if err.Error() != tc.err { + t.Fatalf("%s != %s", err.Error(), tc.err) + } + }) + } +} diff --git a/paint/run.go b/paint/run.go new file mode 100644 index 0000000..c579ce6 --- /dev/null +++ b/paint/run.go @@ -0,0 +1,256 @@ +package paint + +import ( + "image" + "image/color" + "image/png" + "io/fs" + "log" + "math" + "os" + "path/filepath" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/NathanBaulch/rainbow-roads/img" + "github.com/NathanBaulch/rainbow-roads/parse" + "github.com/NathanBaulch/rainbow-roads/scan" + "github.com/antonmedv/expr" + "github.com/fogleman/gg" + "golang.org/x/image/colornames" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + o *Options + fullTitle string + en = message.NewPrinter(language.English) + files []*scan.File + activities []*parse.Activity + roads []*way + im image.Image + + backCol = colornames.Black + donePriCol = colornames.Lime + doneSecCol = colornames.Green + pendPriCol = colornames.Red + pendSecCol = colornames.Darkred + actCol = colornames.Blue + queryExpr = "is_tag(highway)" + + " and highway not in ['proposed','corridor','construction','footway','steps','busway','elevator','services']" + + " and service not in ['driveway','parking_aisle']" + + " and area != 'yes'" + primaryExpr = mustCompile( + "highway in ['cycleway','primary','residential','secondary','tertiary','trunk','living_street','unclassified']"+ + " and access not in ['private','customers','no']"+ + " and surface not in ['cobblestone','sett']", expr.AsBool()) +) + +type Options struct { + Title string + Version string + Input []string + Output string + Width uint + Region geo.Circle + NoWatermark bool + Selector parse.Selector +} + +func Run(opts *Options) error { + o = opts + fullTitle = "NathanBaulch/" + o.Title + if o.Version != "" { + fullTitle += " " + o.Version + } + + if len(o.Input) == 0 { + o.Input = []string{"."} + } + + if fi, err := os.Stat(o.Output); err != nil { + if _, ok := err.(*fs.PathError); !ok { + return err + } + } else if fi.IsDir() { + o.Output = filepath.Join(o.Output, "out") + } + if filepath.Ext(o.Output) == "" { + o.Output += ".png" + } + + for _, step := range []func() error{scanStep, parseStep, fetchStep, renderStep, saveStep} { + if err := step(); err != nil { + return err + } + } + + return nil +} + +func scanStep() error { + if f, err := scan.Scan(o.Input); err != nil { + return err + } else { + files = f + en.Println("files: ", len(files)) + return nil + } +} + +func parseStep() error { + if a, stats, err := parse.Parse(files, &o.Selector); err != nil { + return err + } else { + activities = a + stats.Print(en) + return nil + } +} + +func fetchStep() error { + query, err := buildQuery(o.Region.Grow(1/0.9), queryExpr) + if err != nil { + return err + } + + roads, err = osmLookup(query) + return err +} + +func renderStep() error { + oX, oY := o.Region.Origin.MercatorProjection() + scale := math.Cos(o.Region.Origin.Lat) * 0.9 * float64(o.Width) / (2 * o.Region.Radius) + + drawLine := func(gc *gg.Context, pt geo.Point) { + x, y := pt.MercatorProjection() + x = float64(o.Width)/2 + (x-oX)*scale + y = float64(o.Width)/2 - (y-oY)*scale + gc.LineTo(x, y) + } + drawActs := func(gc *gg.Context, lineWidth float64) { + gc.SetLineWidth(1.3 * lineWidth * scale) + for _, a := range activities { + for _, r := range a.Records { + drawLine(gc, r.Position) + } + gc.Stroke() + } + } + + gc := gg.NewContext(int(o.Width), int(o.Width)) + gc.SetFillStyle(gg.NewSolidPattern(backCol)) + gc.DrawRectangle(0, 0, float64(o.Width), float64(o.Width)) + gc.Fill() + + gc.SetStrokeStyle(gg.NewSolidPattern(actCol)) + drawActs(gc, 10) + + drawWays := func(primary bool, strokeColor color.Color) { + gc.SetStrokeStyle(gg.NewSolidPattern(strokeColor)) + + for _, w := range roads { + if !primary || mustRun(primaryExpr, (*wayEnv)(w)).(bool) { + lineWidth := 10.0 + switch w.Highway { + case "motorway", "trunk", "primary", "secondary", "tertiary": + lineWidth *= 3.6 + case "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link", "residential", "living_street": + lineWidth *= 2.4 + case "pedestrian", "footway", "cycleway", "track": + lineWidth *= 1.4 + } + gc.SetLineWidth(lineWidth * scale) + for _, pt := range w.Geometry { + drawLine(gc, pt) + } + gc.Stroke() + } + } + } + + maskGC := gg.NewContext(int(o.Width), int(o.Width)) + drawActs(maskGC, 50) + actMask := maskGC.AsMask() + + _ = gc.SetMask(actMask) + drawWays(false, doneSecCol) + gc.InvertMask() + drawWays(false, pendSecCol) + + _ = maskGC.SetMask(actMask) + maskGC.SetColor(color.Transparent) + maskGC.Clear() + maskGC.SetColor(color.Black) + maskGC.DrawCircle(float64(o.Width)/2, float64(o.Width)/2, 0.9*float64(o.Width)/2) + maskGC.Fill() + _ = gc.SetMask(maskGC.AsMask()) + drawWays(true, pendPriCol) + + maskGC.InvertMask() + maskGC.SetColor(color.Transparent) + maskGC.Clear() + maskGC.SetColor(color.Black) + maskGC.DrawCircle(float64(o.Width)/2, float64(o.Width)/2, 0.9*float64(o.Width)/2) + maskGC.Fill() + _ = gc.SetMask(maskGC.AsMask()) + drawWays(true, donePriCol) + + if !o.NoWatermark { + img.DrawWatermark(gc.Image(), fullTitle, pendSecCol) + } + + done, pend := 0, 0 + rect := gc.Image().Bounds() + for y := rect.Min.Y; y <= rect.Max.Y; y++ { + for x := rect.Min.X; x <= rect.Max.X; x++ { + switch gc.Image().At(x, y) { + case donePriCol: + done++ + case pendPriCol: + pend++ + } + } + } + if done == 0 && pend == 0 { + pend = 1 + } + en.Printf("progress: %.2f%%\n", 100*float64(done)/float64(done+pend)) + + im = gc.Image() + return nil +} + +type wayEnv way + +func (e *wayEnv) Fetch(k interface{}) interface{} { + switch k.(string) { + case "highway": + return e.Highway + case "access": + return e.Access + case "surface": + return e.Surface + } + return nil +} + +func saveStep() error { + if dir := filepath.Dir(o.Output); dir != "." { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + } + + out, err := os.Create(o.Output) + if err != nil { + return err + } + defer func() { + if err := out.Close(); err != nil { + log.Fatal(err) + } + }() + + return png.Encode(out, im) +} diff --git a/parse/fit.go b/parse/fit.go new file mode 100644 index 0000000..7762bb7 --- /dev/null +++ b/parse/fit.go @@ -0,0 +1,50 @@ +package parse + +import ( + "io" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/tormoder/fit" +) + +func parseFIT(r io.Reader, selector *Selector) ([]*Activity, error) { + f, err := fit.Decode(r) + if err != nil { + if _, ok := err.(fit.FormatError); ok { + return nil, nil + } + return nil, err + } + + if a, err := f.Activity(); err != nil || len(a.Records) == 0 { + return nil, nil + } else { + act := &Activity{ + Sport: a.Sessions[0].Sport.String(), + Distance: a.Sessions[0].GetTotalDistanceScaled(), + } + r0, r1 := a.Records[0], a.Records[len(a.Records)-1] + dur := r1.Timestamp.Sub(r0.Timestamp) + if !selector.Sport(act.Sport) || + !selector.Timestamp(r0.Timestamp, r1.Timestamp) || + !selector.Duration(dur) || + !selector.Distance(act.Distance) || + !selector.Pace(dur, act.Distance) { + return nil, nil + } + + act.Records = make([]*Record, 0, len(a.Records)) + for _, rec := range a.Records { + if !rec.PositionLat.Invalid() && !rec.PositionLong.Invalid() { + act.Records = append(act.Records, &Record{ + Timestamp: rec.Timestamp, + Position: geo.NewPointFromSemicircles(rec.PositionLat.Semicircles(), rec.PositionLong.Semicircles()), + }) + } + } + if len(act.Records) == 0 { + return nil, nil + } + return []*Activity{act}, nil + } +} diff --git a/fit_test.go b/parse/fit_test.go similarity index 90% rename from fit_test.go rename to parse/fit_test.go index 8f6d579..14237d3 100644 --- a/fit_test.go +++ b/parse/fit_test.go @@ -1,4 +1,4 @@ -package main +package parse import ( "bytes" @@ -21,7 +21,7 @@ func TestFITShortDistance(t *testing.T) { t.Fatal(err) } } - if acts, err := parseFIT(w); err != nil { + if acts, err := parseFIT(w, &Selector{}); err != nil { t.Fatal(err) } else if len(acts) != 1 { t.Fatal("expected 1 activity") diff --git a/gpx.go b/parse/gpx.go similarity index 65% rename from gpx.go rename to parse/gpx.go index d42a79d..1a5a3ad 100644 --- a/gpx.go +++ b/parse/gpx.go @@ -1,9 +1,10 @@ -package main +package parse import ( "io" "strings" + "github.com/NathanBaulch/rainbow-roads/geo" "github.com/tkrajina/gpxgo/gpx" ) @@ -43,7 +44,7 @@ var stravaTypeCodes = map[string]string{ "53": "VirtualRunning", } -func parseGPX(r io.Reader) ([]*activity, error) { +func parseGPX(r io.Reader, selector *Selector) ([]*Activity, error) { buf, err := io.ReadAll(r) if err != nil { return nil, err @@ -54,7 +55,7 @@ func parseGPX(r io.Reader) ([]*activity, error) { return nil, err } - acts := make([]*activity, 0, len(g.Tracks)) + acts := make([]*Activity, 0, len(g.Tracks)) for _, t := range g.Tracks { sport := t.Type @@ -63,13 +64,13 @@ func parseGPX(r io.Reader) ([]*activity, error) { sport = s } } - if len(t.Segments) == 0 || !includeSport(sport) { + if len(t.Segments) == 0 || !selector.Sport(sport) { continue } - act := &activity{ - sport: sport, - records: make([]*record, 0, len(t.Segments[0].Points)), + act := &Activity{ + Sport: sport, + Records: make([]*Record, 0, len(t.Segments[0].Points)), } var p0, p1 gpx.GPXPoint @@ -79,26 +80,26 @@ func parseGPX(r io.Reader) ([]*activity, error) { } for i, p := range s.Points { - if len(act.records) == 0 { + if len(act.Records) == 0 { p0 = p } p1 = p - act.records = append(act.records, &record{ - ts: p.Timestamp, - pt: newPointFromDegrees(p.Latitude, p.Longitude), + act.Records = append(act.Records, &Record{ + Timestamp: p.Timestamp, + Position: geo.NewPointFromDegrees(p.Latitude, p.Longitude), }) if i > 0 { - act.distance += haversineDistance(act.records[i-1].pt, act.records[i].pt) + act.Distance += act.Records[i-1].Position.DistanceTo(act.Records[i].Position) } } } dur := p1.Timestamp.Sub(p0.Timestamp) - if len(act.records) == 0 || - !includeTimestamp(p0.Timestamp, p1.Timestamp) || - !includeDuration(dur) || - !includeDistance(act.distance) || - !includePace(dur, act.distance) { + if len(act.Records) == 0 || + !selector.Timestamp(p0.Timestamp, p1.Timestamp) || + !selector.Duration(dur) || + !selector.Distance(act.Distance) || + !selector.Pace(dur, act.Distance) { continue } diff --git a/gpx_test.go b/parse/gpx_test.go similarity index 91% rename from gpx_test.go rename to parse/gpx_test.go index 12779f8..98d6fea 100644 --- a/gpx_test.go +++ b/parse/gpx_test.go @@ -1,4 +1,4 @@ -package main +package parse import ( "bytes" @@ -6,7 +6,6 @@ import ( ) func TestGPXStravaTypeCodes(t *testing.T) { - _ = sports.Set("running") if acts, err := parseGPX(bytes.NewBufferString(` @@ -31,7 +30,7 @@ func TestGPXStravaTypeCodes(t *testing.T) { - `)); err != nil { + `), &Selector{Sports: []string{"running"}}); err != nil { t.Fatal(err) } else if len(acts) != 1 { t.Fatal("expected 1 activity") diff --git a/parse/parse.go b/parse/parse.go new file mode 100644 index 0000000..2990b06 --- /dev/null +++ b/parse/parse.go @@ -0,0 +1,315 @@ +package parse + +import ( + "errors" + "fmt" + "io" + "math" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/NathanBaulch/rainbow-roads/scan" + "golang.org/x/exp/slices" + "golang.org/x/text/message" +) + +func Parse(files []*scan.File, selector *Selector) ([]*Activity, *Stats, error) { + wg := sync.WaitGroup{} + wg.Add(len(files)) + res := make([]struct { + acts []*Activity + err error + }, len(files)) + for i := range files { + i := i + go func() { + defer wg.Done() + var parser func(io.Reader, *Selector) ([]*Activity, error) + switch files[i].Ext { + case ".fit": + parser = parseFIT + case ".gpx": + parser = parseGPX + case ".tcx": + parser = parseTCX + default: + return + } + if r, err := files[i].Opener(); err != nil { + res[i].err = err + } else { + res[i].acts, res[i].err = parser(r, selector) + } + }() + } + wg.Wait() + + activities := make([]*Activity, 0, len(files)) + for _, r := range res { + if r.err != nil { + fmt.Fprintln(os.Stderr, "WARN:", r.err) + } else { + activities = append(activities, r.acts...) + } + } + if len(activities) == 0 { + return nil, nil, errors.New("no matching activities found") + } + + stats := &Stats{ + SportCounts: make(map[string]int), + After: time.UnixMilli(math.MaxInt64), + MinDuration: time.Duration(math.MaxInt64), + MinDistance: math.MaxFloat64, + MinPace: time.Duration(math.MaxInt64), + } + var startExtent, endExtent geo.Box + + for i := len(activities) - 1; i >= 0; i-- { + act := activities[i] + include := selector.PassesThrough.IsZero() + exclude := false + for j, r := range act.Records { + if !selector.Bounded(r.Position) { + exclude = true + break + } + if j == 0 && !selector.Starts(r.Position) { + exclude = true + break + } + if j == len(act.Records)-1 && !selector.Ends(r.Position) { + exclude = true + break + } + if !include && selector.Passes(r.Position) { + include = true + } + } + if exclude || !include { + j := len(activities) - 1 + activities[i] = activities[j] + activities = activities[:j] + continue + } + + if act.Sport == "" { + stats.SportCounts["unknown"]++ + } else { + stats.SportCounts[strings.ToLower(act.Sport)]++ + } + ts0, ts1 := act.Records[0].Timestamp, act.Records[len(act.Records)-1].Timestamp + if ts0.Before(stats.After) { + stats.After = ts0 + } + if ts1.After(stats.Before) { + stats.Before = ts1 + } + dur := ts1.Sub(ts0) + if dur < stats.MinDuration { + stats.MinDuration = dur + } + if dur > stats.MaxDuration { + stats.MaxDuration = dur + } + if act.Distance < stats.MinDistance { + stats.MinDistance = act.Distance + } + if act.Distance > stats.MaxDistance { + stats.MaxDistance = act.Distance + } + pace := time.Duration(float64(dur) / act.Distance) + if pace < stats.MinPace { + stats.MinPace = pace + } + if pace > stats.MaxPace { + stats.MaxPace = pace + } + + stats.CountRecords += len(act.Records) + stats.SumDuration += dur + stats.SumDistance += act.Distance + + for _, r := range act.Records { + stats.Extent = stats.Extent.Enclose(r.Position) + } + startExtent = startExtent.Enclose(act.Records[0].Position) + endExtent = endExtent.Enclose(act.Records[len(act.Records)-1].Position) + } + + if len(activities) == 0 { + return nil, nil, errors.New("no matching activities found") + } + + stats.CountActivities = len(activities) + stats.BoundedBy = geo.Circle{Origin: stats.Extent.Center()} + stats.StartsNear = geo.Circle{Origin: startExtent.Center()} + stats.EndsNear = geo.Circle{Origin: endExtent.Center()} + for _, act := range activities { + for _, r := range act.Records { + stats.BoundedBy = stats.BoundedBy.Enclose(r.Position) + } + stats.StartsNear = stats.StartsNear.Enclose(act.Records[0].Position) + stats.EndsNear = stats.EndsNear.Enclose(act.Records[len(act.Records)-1].Position) + } + + return activities, stats, nil +} + +type Selector struct { + Sports []string + After, Before time.Time + MinDuration, MaxDuration time.Duration + MinDistance, MaxDistance float64 + MinPace, MaxPace time.Duration + BoundedBy, StartsNear, EndsNear, PassesThrough geo.Circle +} + +func (s *Selector) Sport(sport string) bool { + return len(s.Sports) == 0 || slices.IndexFunc(s.Sports, func(s string) bool { return strings.EqualFold(s, sport) }) >= 0 +} + +func (s *Selector) Timestamp(from, to time.Time) bool { + return (s.After.IsZero() || s.After.Before(from)) && (s.Before.IsZero() || s.Before.After(to)) +} + +func (s *Selector) Duration(duration time.Duration) bool { + return duration > 0 && + (s.MinDuration == 0 || duration > s.MinDuration) && + (s.MaxDuration == 0 || duration < s.MaxDuration) +} + +func (s *Selector) Distance(distance float64) bool { + return distance > 0 && + (s.MinDistance == 0 || distance > s.MinDistance) && + (s.MaxDistance == 0 || distance < s.MaxDistance) +} + +func (s *Selector) Pace(duration time.Duration, distance float64) bool { + pace := time.Duration(float64(duration) / distance) + return pace > 0 && + (s.MinPace == 0 || pace > s.MinPace) && + (s.MaxPace == 0 || pace < s.MaxPace) +} + +func (s *Selector) Bounded(pt geo.Point) bool { + return s.BoundedBy.IsZero() || s.BoundedBy.Contains(pt) +} + +func (s *Selector) Starts(pt geo.Point) bool { + return s.StartsNear.IsZero() || s.StartsNear.Contains(pt) +} + +func (s *Selector) Ends(pt geo.Point) bool { + return s.EndsNear.IsZero() || s.EndsNear.Contains(pt) +} + +func (s *Selector) Passes(pt geo.Point) bool { + return s.PassesThrough.IsZero() || s.PassesThrough.Contains(pt) +} + +type Activity struct { + Sport string + Distance float64 + Records []*Record +} + +type Record struct { + Timestamp time.Time + Position geo.Point + X, Y int + Percent float64 +} + +type Stats struct { + CountActivities, CountRecords int + SportCounts map[string]int + After, Before time.Time + MinDuration, MaxDuration, SumDuration time.Duration + MinDistance, MaxDistance, SumDistance float64 + MinPace, MaxPace time.Duration + BoundedBy, StartsNear, EndsNear geo.Circle + Extent geo.Box +} + +func (s *Stats) Print(p *message.Printer) { + avgDur := s.SumDuration / time.Duration(s.CountActivities) + avgDist := s.SumDistance / float64(s.CountActivities) + avgPace := s.SumDuration / time.Duration(s.SumDistance) + + p.Printf("activities: %d\n", s.CountActivities) + p.Printf("records: %d\n", s.CountRecords) + p.Printf("sports: %s\n", sprintSportStats(p, s.SportCounts)) + p.Printf("period: %s\n", sprintPeriod(p, s.After, s.Before)) + p.Printf("duration: %s to %s, average %s, total %s\n", sprintDuration(p, s.MinDuration), sprintDuration(p, s.MaxDuration), sprintDuration(p, avgDur), sprintDuration(p, s.SumDuration)) + p.Printf("distance: %s to %s, average %s, total %s\n", sprintDistance(p, s.MinDistance), sprintDistance(p, s.MaxDistance), sprintDistance(p, avgDist), sprintDistance(p, s.SumDistance)) + p.Printf("pace: %s to %s, average %s\n", sprintPace(p, s.MinPace), sprintPace(p, s.MaxPace), sprintPace(p, avgPace)) + p.Printf("bounds: %s\n", s.BoundedBy) + p.Printf("starts within: %s\n", s.StartsNear) + p.Printf("ends within: %s\n", s.EndsNear) +} + +func sprintSportStats(p *message.Printer, stats map[string]int) string { + pairs := make([]struct { + k string + v int + }, len(stats)) + i := 0 + for k, v := range stats { + pairs[i].k = k + pairs[i].v = v + i++ + } + sort.Slice(pairs, func(i, j int) bool { + p0, p1 := pairs[i], pairs[j] + return p0.v > p1.v || (p0.v == p1.v && p0.k < p1.k) + }) + a := make([]interface{}, len(stats)*2) + i = 0 + for _, kv := range pairs { + a[i] = kv.k + i++ + a[i] = kv.v + i++ + } + return p.Sprintf(strings.Repeat(", %s (%d)", len(stats))[2:], a...) +} + +func sprintPeriod(p *message.Printer, minDate, maxDate time.Time) string { + d := maxDate.Sub(minDate) + var num float64 + var unit string + switch { + case d.Hours() >= 365.25*24: + num, unit = d.Hours()/(365.25*24), "years" + case d.Hours() >= 365.25*2: + num, unit = d.Hours()/(365.25*2), "months" + case d.Hours() >= 7*24: + num, unit = d.Hours()/(7*24), "weeks" + case d.Hours() >= 24: + num, unit = d.Hours()/24, "days" + case d.Hours() >= 1: + num, unit = d.Hours(), "hours" + case d.Minutes() >= 1: + num, unit = d.Minutes(), "minutes" + default: + num, unit = d.Seconds(), "seconds" + } + return p.Sprintf("%.1f %s (%s to %s)", num, unit, minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) +} + +func sprintDuration(p *message.Printer, dur time.Duration) string { + return p.Sprintf("%s", dur.Truncate(time.Second)) +} + +func sprintDistance(p *message.Printer, dist float64) string { + return p.Sprintf("%.1fkm", dist/1000) +} + +func sprintPace(p *message.Printer, pace time.Duration) string { + return p.Sprintf("%s/km", (pace * 1000).Truncate(time.Second)) +} diff --git a/parse/tcx.go b/parse/tcx.go new file mode 100644 index 0000000..820e6db --- /dev/null +++ b/parse/tcx.go @@ -0,0 +1,64 @@ +package parse + +import ( + "io" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/llehouerou/go-tcx" +) + +func parseTCX(r io.Reader, selector *Selector) ([]*Activity, error) { + f, err := tcx.Parse(r) + if err != nil { + return nil, err + } + + acts := make([]*Activity, 0, len(f.Activities)) + + for _, a := range f.Activities { + if len(a.Laps) == 0 || !selector.Sport(a.Sport) { + continue + } + + act := &Activity{ + Sport: a.Sport, + Records: make([]*Record, 0, len(a.Laps[0].Track)), + } + + var t0, t1 tcx.Trackpoint + for _, l := range a.Laps { + if len(l.Track) == 0 { + continue + } + + act.Distance += l.DistanceInMeters + + for _, t := range l.Track { + if t.LatitudeInDegrees == 0 || t.LongitudeInDegrees == 0 { + continue + } + if len(act.Records) == 0 { + t0 = t + } + t1 = t + act.Records = append(act.Records, &Record{ + Timestamp: t.Time, + Position: geo.NewPointFromDegrees(t.LatitudeInDegrees, t.LongitudeInDegrees), + }) + } + } + + dur := t1.Time.Sub(t0.Time) + if len(act.Records) == 0 || + !selector.Timestamp(t0.Time, t1.Time) || + !selector.Duration(dur) || + !selector.Distance(act.Distance) || + !selector.Pace(dur, act.Distance) { + continue + } + + acts = append(acts, act) + } + + return acts, nil +} diff --git a/tcx_test.go b/parse/tcx_test.go similarity index 84% rename from tcx_test.go rename to parse/tcx_test.go index b2d2aad..cfeb5e7 100644 --- a/tcx_test.go +++ b/parse/tcx_test.go @@ -1,4 +1,4 @@ -package main +package parse import ( "bytes" @@ -6,7 +6,6 @@ import ( ) func TestTCXNoPosition(t *testing.T) { - sports = sports[:0] if acts, err := parseTCX(bytes.NewBufferString(` @@ -20,7 +19,7 @@ func TestTCXNoPosition(t *testing.T) { - `)); err != nil { + `), &Selector{}); err != nil { t.Fatal(err) } else if len(acts) > 0 { t.Fatal("expected no activities") diff --git a/scan/scan.go b/scan/scan.go new file mode 100644 index 0000000..5e58c3e --- /dev/null +++ b/scan/scan.go @@ -0,0 +1,112 @@ +package scan + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type File struct { + Ext string + Opener func() (io.Reader, error) +} + +func Scan(paths []string) ([]*File, error) { + var files []*File + err := walkPaths(paths, func(fsys fs.FS, path string) error { + ext := strings.ToLower(filepath.Ext(path)) + opener := func() (io.Reader, error) { return fsys.Open(path) } + if ext == ".gz" { + ext = filepath.Ext(path[:len(path)-3]) + opener = func() (io.Reader, error) { + if r, err := opener(); err != nil { + return nil, err + } else { + return gzip.NewReader(r) + } + } + } + files = append(files, &File{ext, opener}) + return nil + }) + return files, err +} + +func walkPaths(paths []string, fn func(fsys fs.FS, path string) error) error { + for _, path := range paths { + paths := []string{path} + if strings.ContainsAny(path, "*?[") { + var err error + if paths, err = filepath.Glob(path); err != nil { + if err == filepath.ErrBadPattern { + return fmt.Errorf("input path pattern %q malformed", path) + } + return err + } + } + + for _, path := range paths { + dir, name := filepath.Split(path) + if dir == "" { + dir = "." + } + fsys := os.DirFS(dir) + if fi, err := os.Stat(path); err != nil { + if _, ok := err.(*fs.PathError); ok { + return fmt.Errorf("input path %q not found", path) + } + return err + } else if fi.IsDir() { + if err := walkDir(fsys, name, fn); err != nil { + return err + } + } else if err := walkFile(fsys, path, fn); err != nil { + return err + } + } + } + + return nil +} + +func walkDir(fsys fs.FS, path string, fn func(fsys fs.FS, path string) error) error { + return fs.WalkDir(fsys, path, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } else { + return walkFile(fsys, path, fn) + } + }) +} + +func walkFile(fsys fs.FS, path string, fn func(fsys fs.FS, path string) error) error { + if strings.EqualFold(filepath.Ext(path), ".zip") { + if f, err := fsys.Open(path); err != nil { + return err + } else if s, err := f.Stat(); err != nil { + return err + } else { + r, ok := f.(io.ReaderAt) + if !ok { + if b, err := io.ReadAll(f); err != nil { + return err + } else { + r = bytes.NewReader(b) + } + } + if fsys, err := zip.NewReader(r, s.Size()); err != nil { + return err + } else { + return walkDir(fsys, ".", fn) + } + } + } else { + return fn(fsys, path) + } +} diff --git a/tcx.go b/tcx.go deleted file mode 100644 index 7768d33..0000000 --- a/tcx.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "io" - - "github.com/llehouerou/go-tcx" -) - -func parseTCX(r io.Reader) ([]*activity, error) { - f, err := tcx.Parse(r) - if err != nil { - return nil, err - } - - acts := make([]*activity, 0, len(f.Activities)) - - for _, a := range f.Activities { - if len(a.Laps) == 0 || !includeSport(a.Sport) { - continue - } - - act := &activity{ - sport: a.Sport, - records: make([]*record, 0, len(a.Laps[0].Track)), - } - - var t0, t1 tcx.Trackpoint - for _, l := range a.Laps { - if len(l.Track) == 0 { - continue - } - - act.distance += l.DistanceInMeters - - for _, t := range l.Track { - if t.LatitudeInDegrees == 0 || t.LongitudeInDegrees == 0 { - continue - } - if len(act.records) == 0 { - t0 = t - } - t1 = t - act.records = append(act.records, &record{ - ts: t.Time, - pt: newPointFromDegrees(t.LatitudeInDegrees, t.LongitudeInDegrees), - }) - } - } - - dur := t1.Time.Sub(t0.Time) - if len(act.records) == 0 || - !includeTimestamp(t0.Time, t1.Time) || - !includeDuration(dur) || - !includeDistance(act.distance) || - !includePace(dur, act.distance) { - continue - } - - acts = append(acts, act) - } - - return acts, nil -} diff --git a/worms.go b/worms.go new file mode 100644 index 0000000..10ffbd8 --- /dev/null +++ b/worms.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + + "github.com/NathanBaulch/rainbow-roads/worms" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var ( + wormsOpts = &worms.Options{ + Title: Title, + Version: Version, + } + wormsCmd = &cobra.Command{ + Use: "worms", + Short: "Animate exercise activities", + PreRunE: func(cmd *cobra.Command, args []string) error { + if wormsOpts.Frames == 0 { + return flagError("frames", wormsOpts.Frames, "must be positive") + } + if wormsOpts.FPS == 0 { + return flagError("fps", wormsOpts.FPS, "must be positive") + } + if wormsOpts.Width == 0 { + return flagError("width", wormsOpts.Width, "must be positive") + } + if wormsOpts.ColorDepth == 0 { + return flagError("color_depth", wormsOpts.ColorDepth, "must be positive") + } + if wormsOpts.Speed < 1 { + return flagError("speed", wormsOpts.Speed, "must be greater than or equal to 1") + } + return nil + }, + RunE: func(_ *cobra.Command, args []string) error { + wormsOpts.Input = args + return worms.Run(wormsOpts) + }, + } +) + +func init() { + rootCmd.AddCommand(wormsCmd) + + general := &pflag.FlagSet{} + general.StringVarP(&wormsOpts.Output, "output", "o", "out", "optional path of the generated file") + general.StringVarP(&wormsOpts.Format, "format", "f", "gif", "output file format string, supports gif, png, zip") + general.VisitAll(func(f *pflag.Flag) { wormsCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + + rendering := &pflag.FlagSet{} + rendering.UintVar(&wormsOpts.Frames, "frames", 200, "number of animation frames") + rendering.UintVar(&wormsOpts.FPS, "fps", 20, "animation frame rate") + rendering.UintVarP(&wormsOpts.Width, "width", "w", 500, "width of the generated image in pixels") + _ = wormsOpts.Colors.Parse("#fff,#ff8,#911,#414,#007@.5,#003") + rendering.Var((*ColorsFlag)(&wormsOpts.Colors), "colors", "CSS linear-colors inspired color scheme string, eg red,yellow,green,blue,black") + rendering.UintVar(&wormsOpts.ColorDepth, "color_depth", 5, "number of bits per color in the image palette") + rendering.Float64Var(&wormsOpts.Speed, "speed", 1.25, "how quickly activities should progress") + rendering.BoolVar(&wormsOpts.Loop, "loop", false, "start each activity sequentially and animate continuously") + rendering.BoolVar(&wormsOpts.NoWatermark, "no_watermark", false, "suppress the embedded project name and version string") + rendering.VisitAll(func(f *pflag.Flag) { wormsCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + + filters := filterFlagSet(&wormsOpts.Selector) + filters.VisitAll(func(f *pflag.Flag) { wormsCmd.Flags().Var(f.Value, f.Name, f.Usage) }) + + wormsCmd.SetUsageFunc(func(*cobra.Command) error { + fmt.Fprintln(wormsCmd.OutOrStderr()) + fmt.Fprintln(wormsCmd.OutOrStderr(), "Usage:") + fmt.Fprintln(wormsCmd.OutOrStderr(), " ", wormsCmd.UseLine(), "[input]") + fmt.Fprintln(wormsCmd.OutOrStderr()) + fmt.Fprintln(wormsCmd.OutOrStderr(), "General flags:") + fmt.Fprintln(wormsCmd.OutOrStderr(), general.FlagUsages()) + fmt.Fprintln(wormsCmd.OutOrStderr(), "Filtering flags:") + fmt.Fprintln(wormsCmd.OutOrStderr(), filters.FlagUsages()) + fmt.Fprintln(wormsCmd.OutOrStderr(), "Rendering flags:") + fmt.Fprint(wormsCmd.OutOrStderr(), rendering.FlagUsages()) + return nil + }) +} diff --git a/image.go b/worms/image.go similarity index 67% rename from image.go rename to worms/image.go index 970e5db..f45d54a 100644 --- a/image.go +++ b/worms/image.go @@ -1,13 +1,68 @@ -package main +package worms import ( "bufio" "encoding/binary" "hash/crc32" "image" + "image/color" "io" ) +var grays = make([]color.Color, 0x100) + +func init() { + for i := range grays { + grays[i] = color.Gray{Y: uint8(i)} + } +} + +func drawFill(im *image.Paletted, ci uint8) { + if len(im.Pix) > 0 { + im.Pix[0] = ci + for i := 1; i < len(im.Pix); i *= 2 { + copy(im.Pix[i:], im.Pix[:i]) + } + } +} + +type glowPlotter struct{ *image.Paletted } + +func (p *glowPlotter) Set(x, y int, c color.Color) { + p.SetColorIndex(x, y, c.(color.Gray).Y) +} + +func (p *glowPlotter) SetColorIndex(x, y int, ci uint8) { + if p.setPixIfLower(x, y, ci) { + const sqrt2 = 1.414213562 + if i := float64(ci) * sqrt2; i < float64(len(p.Palette)-2) { + ci = uint8(i) + p.setPixIfLower(x-1, y, ci) + p.setPixIfLower(x, y-1, ci) + p.setPixIfLower(x+1, y, ci) + p.setPixIfLower(x, y+1, ci) + } + if i := float64(ci) * sqrt2; i < float64(len(p.Palette)-2) { + ci = uint8(i) + p.setPixIfLower(x-1, y-1, ci) + p.setPixIfLower(x-1, y+1, ci) + p.setPixIfLower(x+1, y-1, ci) + p.setPixIfLower(x+1, y+1, ci) + } + } +} + +func (p *glowPlotter) setPixIfLower(x, y int, ci uint8) bool { + if (image.Point{X: x, Y: y}.In(p.Rect)) { + i := p.PixOffset(x, y) + if p.Pix[i] > ci { + p.Pix[i] = ci + return true + } + } + return false +} + func optimizeFrames(ims []*image.Paletted) { if len(ims) == 0 { return @@ -61,7 +116,8 @@ func optimizeFrames(ims []*image.Paletted) { type gifWriter struct { *bufio.Writer - done bool + Comment string + done bool } func (w *gifWriter) Write(p []byte) (nn int, err error) { @@ -69,7 +125,7 @@ func (w *gifWriter) Write(p []byte) (nn int, err error) { if !w.done { // intercept application extension if len(p) == 3 && p[0] == 0x21 && p[1] == 0xff && p[2] == 0x0b { - if n, err = w.writeExtension([]byte(fullTitle), 0xfe); err != nil { + if n, err = w.writeExtension([]byte(w.Comment), 0xfe); err != nil { return } else { nn += n @@ -107,6 +163,7 @@ func (w *gifWriter) writeExtension(b []byte, e byte) (nn int, err error) { type pngWriter struct { io.Writer + Text string done bool } @@ -115,7 +172,7 @@ func (w *pngWriter) Write(p []byte) (nn int, err error) { if !w.done { // intercept first data chunk if len(p) >= 8 && string(p[4:8]) == "IDAT" { - if n, err = w.writeChunk([]byte(fullTitle), "tEXt"); err != nil { + if n, err = w.writeChunk([]byte(w.Text), "tEXt"); err != nil { return } else { nn += n diff --git a/image_test.go b/worms/image_test.go similarity index 84% rename from image_test.go rename to worms/image_test.go index c363652..aa76055 100644 --- a/image_test.go +++ b/worms/image_test.go @@ -1,4 +1,4 @@ -package main +package worms import ( "bufio" @@ -50,29 +50,29 @@ func TestImageOptimizeFrames(t *testing.T) { func TestImageGifWriter(t *testing.T) { b := &bytes.Buffer{} - w := &gifWriter{Writer: bufio.NewWriter(b)} + w := &gifWriter{Writer: bufio.NewWriter(b), Comment: "foo"} if n, err := w.Write([]byte{0x21, 0xff, 0x0b}); err != nil { t.Fatal(err) - } else if n != 33 { - t.Fatal("number of bytes written:", n, "!= 33") + } else if n != 10 { + t.Fatal("number of bytes written:", n, "!=", 10) } if err := w.Flush(); err != nil { t.Fatal(err) } - if !bytes.Contains(b.Bytes(), []byte(fullTitle)) { + if !bytes.Contains(b.Bytes(), []byte("foo")) { t.Fatal("metadata text not found") } } func TestImagePngWriter(t *testing.T) { b := &bytes.Buffer{} - w := &pngWriter{Writer: b} + w := &pngWriter{Writer: b, Text: "foo"} if n, err := w.Write([]byte(" IDAT")); err != nil { t.Fatal(err) - } else if n != 46 { - t.Fatal("number of bytes written:", n, "!= 46") + } else if n != 23 { + t.Fatal("number of bytes written:", n, "!=", 23) } - if !bytes.Contains(b.Bytes(), []byte(fullTitle)) { + if !bytes.Contains(b.Bytes(), []byte("foo")) { t.Fatal("metadata text not found") } } diff --git a/worms/run.go b/worms/run.go new file mode 100644 index 0000000..719ca87 --- /dev/null +++ b/worms/run.go @@ -0,0 +1,287 @@ +package worms + +import ( + "archive/zip" + "bufio" + "fmt" + "image" + "image/color" + "image/gif" + "io" + "io/fs" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/NathanBaulch/rainbow-roads/geo" + "github.com/NathanBaulch/rainbow-roads/img" + "github.com/NathanBaulch/rainbow-roads/parse" + "github.com/NathanBaulch/rainbow-roads/scan" + "github.com/StephaneBunel/bresenham" + "github.com/kettek/apng" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + o *Options + fullTitle string + en = message.NewPrinter(language.English) + files []*scan.File + activities []*parse.Activity + maxDur time.Duration + extent geo.Box + images []*image.Paletted +) + +type Options struct { + Title string + Version string + Input []string + Output string + Width uint + Frames uint + FPS uint + Format string + Colors img.ColorGradient + ColorDepth uint + Speed float64 + Loop bool + NoWatermark bool + Selector parse.Selector +} + +func Run(opts *Options) error { + o = opts + + fullTitle = "NathanBaulch/" + o.Title + if o.Version != "" { + fullTitle += " " + o.Version + } + + if len(o.Input) == 0 { + o.Input = []string{"."} + } + + if fi, err := os.Stat(o.Output); err != nil { + if _, ok := err.(*fs.PathError); !ok { + return err + } + } else if fi.IsDir() { + o.Output = filepath.Join(o.Output, "out") + } + ext := filepath.Ext(o.Output) + if ext != "" { + ext = ext[1:] + if o.Format == "" { + o.Format = ext[1:] + } + } + if o.Format == "" { + o.Format = "gif" + } + if !strings.EqualFold(ext, o.Format) { + o.Output += "." + o.Format + } + + for _, step := range []func() error{scanStep, parseStep, renderStep, saveStep} { + if err := step(); err != nil { + return err + } + } + + return nil +} + +func scanStep() error { + if f, err := scan.Scan(o.Input); err != nil { + return err + } else { + files = f + en.Println("files: ", len(files)) + return nil + } +} + +func parseStep() error { + if a, stats, err := parse.Parse(files, &o.Selector); err != nil { + return err + } else { + activities = a + extent = stats.Extent + maxDur = stats.MaxDuration + stats.Print(en) + return nil + } +} + +func renderStep() error { + if o.Loop { + sort.Slice(activities, func(i, j int) bool { + return activities[i].Records[0].Timestamp.Before(activities[j].Records[0].Timestamp) + }) + } + + minX, minY := extent.Min.MercatorProjection() + maxX, maxY := extent.Max.MercatorProjection() + dX, dY := maxX-minX, maxY-minY + scale := float64(o.Width) / dX + height := uint(dY * scale) + scale *= 0.9 + minX -= 0.05 * dX + maxY += 0.05 * dY + tScale := 1 / (o.Speed * float64(maxDur)) + for i, act := range activities { + ts0 := act.Records[0].Timestamp + tOffset := 0.0 + if o.Loop { + tOffset = float64(i) / float64(len(activities)) + } + for _, r := range act.Records { + x, y := r.Position.MercatorProjection() + r.X = int((x - minX) * scale) + r.Y = int((maxY - y) * scale) + r.Percent = tOffset + float64(r.Timestamp.Sub(ts0))*tScale + } + } + + pal := color.Palette(make([]color.Color, 1<= 0 && pc < 1 { + ci = uint8(math.Sqrt(pc) * float64(len(pal)-2)) + } + bresenham.DrawLine(gp, rPrev.X, rPrev.Y, r.X, r.Y, grays[ci]) + } + rPrev = r + } + } + wg.Done() + }() + } + wg.Wait() + + return nil +} + +func saveStep() error { + if dir := filepath.Dir(o.Output); dir != "." { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + } + + out, err := os.Create(o.Output) + if err != nil { + return err + } + defer func() { + if err := out.Close(); err != nil { + log.Fatal(err) + } + }() + + switch o.Format { + case "gif": + return saveGIF(out) + case "png": + return savePNG(out) + case "zip": + return saveZIP(out) + default: + return nil + } +} + +func saveGIF(w io.Writer) error { + optimizeFrames(images) + g := &gif.GIF{ + Image: images, + Delay: make([]int, len(images)), + Disposal: make([]byte, len(images)), + Config: image.Config{ + ColorModel: images[0].Palette, + Width: images[0].Rect.Max.X, + Height: images[0].Rect.Max.Y, + }, + } + d := int(math.Round(100 / float64(o.FPS))) + for i := range images { + g.Disposal[i] = gif.DisposalNone + g.Delay[i] = d + } + return gif.EncodeAll(&gifWriter{Writer: bufio.NewWriter(w), Comment: fullTitle}, g) +} + +func savePNG(w io.Writer) error { + optimizeFrames(images) + a := apng.APNG{Frames: make([]apng.Frame, len(images))} + for i, im := range images { + a.Frames[i].Image = im + a.Frames[i].XOffset = im.Rect.Min.X + a.Frames[i].YOffset = im.Rect.Min.Y + a.Frames[i].BlendOp = apng.BLEND_OP_OVER + a.Frames[i].DelayNumerator = 1 + a.Frames[i].DelayDenominator = uint16(o.FPS) + } + return apng.Encode(&pngWriter{Writer: w, Text: fullTitle}, a) +} + +func saveZIP(w io.Writer) error { + z := zip.NewWriter(w) + defer func() { + if err := z.Close(); err != nil { + log.Fatal(err) + } + }() + for i, im := range images { + if w, err := z.Create(fmt.Sprintf("%d.gif", i)); err != nil { + return err + } else if err = gif.Encode(w, im, nil); err != nil { + return err + } + } + return nil +}