diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05abf78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +backup +*_test.go diff --git a/go.mod b/go.mod index e3064c9..0fc02a1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,25 @@ module git.tdpain.net/codemicro/kindle-dashboard go 1.21.4 + +require ( + github.com/carlmjohnson/requests v0.23.5 + github.com/disintegration/imaging v1.6.2 + github.com/tdewolff/canvas v0.0.0-20231102134958-6de43c767dbf +) + +require ( + github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect + github.com/benoitkugler/textlayout v0.3.0 // indirect + github.com/benoitkugler/textprocessing v0.0.3 // indirect + github.com/dsnet/compress v0.0.1 // indirect + github.com/go-fonts/latin-modern v0.3.1 // indirect + github.com/go-text/typesetting v0.0.0-20231013144250-6cc35dbfae7d // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/tdewolff/minify/v2 v2.20.5 // indirect + github.com/tdewolff/parse/v2 v2.7.3 // indirect + golang.org/x/image v0.13.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/text v0.13.0 // indirect + star-tex.org/x/tex v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..12ff583 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8= +git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo= +github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw= +github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE= +github.com/benoitkugler/textlayout v0.3.0 h1:2ehWXEkgb6RUokTjXh1LzdGwG4dRP6X3dqhYYDYhUVk= +github.com/benoitkugler/textlayout v0.3.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w= +github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo= +github.com/benoitkugler/textprocessing v0.0.3 h1:Q2X+Z6vxuW5Bxn1R9RaNt0qcprBfpc2hEUDeTlz90Ng= +github.com/benoitkugler/textprocessing v0.0.3/go.mod h1:/4bLyCf1QYywunMK3Gf89Nhb50YI/9POewqrLxWhxd4= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/carlmjohnson/requests v0.23.5 h1:NPANcAofwwSuC6SIMwlgmHry2V3pLrSqRiSBKYbNHHA= +github.com/carlmjohnson/requests v0.23.5/go.mod h1:zG9P28thdRnN61aD7iECFhH5iGGKX2jIjKQD9kqYH+o= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= +github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/go-fonts/latin-modern v0.3.1 h1:/cT8A7uavYKvglYXvrdDw4oS5ZLkcOU22fa2HJ1/JVM= +github.com/go-fonts/latin-modern v0.3.1/go.mod h1:ysEQXnuT/sCDOAONxC7ImeEDVINbltClhasMAqEtRK0= +github.com/go-fonts/liberation v0.3.1 h1:9RPT2NhUpxQ7ukUvz3jeUckmN42T9D9TpjtQcqK/ceM= +github.com/go-fonts/liberation v0.3.1/go.mod h1:jdJ+cqF+F4SUL2V+qxBth8fvBpBDS7yloUL5Fi8GTGY= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/go-text/typesetting v0.0.0-20231013144250-6cc35dbfae7d h1:HrdwTlHVMdi9nOW7ZnYiLmIT1hJHvipIwM0aX3rKn8I= +github.com/go-text/typesetting v0.0.0-20231013144250-6cc35dbfae7d/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= +github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= +github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +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/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +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/tdewolff/canvas v0.0.0-20231102134958-6de43c767dbf h1:SiFIPDecTcEpw8GTDi2NsX0Cedp+GizeammteylMvmE= +github.com/tdewolff/canvas v0.0.0-20231102134958-6de43c767dbf/go.mod h1:QT5dPLAqJLD02UXi9Dd2BkCBV4i3zh+dVxITYpd7q2Y= +github.com/tdewolff/minify/v2 v2.20.5 h1:IbJpmpAFESnuJPdsvFBJWsDcXE5qHsmaVQrRqhOI9sI= +github.com/tdewolff/minify/v2 v2.20.5/go.mod h1:N78HtaitkDYAWXFbqhWX/LzgwylwudK0JvybGDVQ+Mw= +github.com/tdewolff/parse/v2 v2.7.3 h1:SHj/ry85FdqniccvzJTG+Gt/mi/HNa1cJcTzYZnvc5U= +github.com/tdewolff/parse/v2 v2.7.3/go.mod h1:9p2qMIHpjRSTr1qnFxQr+igogyTUTlwvf9awHSm84h8= +github.com/tdewolff/test v1.0.10/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA= +github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg= +golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE= +gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU= +star-tex.org/x/tex v0.4.0 h1:AXUwgpnHLCxZUWW3qrmjv6ezNhH3PjUVBuLLejz2cgU= +star-tex.org/x/tex v0.4.0/go.mod h1:w91ycsU/DkkCr7GWr60GPWqp3gn2U+6VX71T0o8k8qE= diff --git a/imagegen/drawing.go b/imagegen/drawing.go new file mode 100644 index 0000000..b22acb4 --- /dev/null +++ b/imagegen/drawing.go @@ -0,0 +1,274 @@ +package imagegen + +import ( + _ "embed" + "errors" + "fmt" + "github.com/tdewolff/canvas" + "time" +) + +//go:embed fonts/JetBrainsMono-Regular.ttf +var srcfontJBMonoRegular []byte + +//go:embed fonts/JetBrainsMono-Bold.ttf +var srcfontJBMonoBold []byte + +//go:embed fonts/JetBrainsMono-Italic.ttf +var srcfontJBMonoItalic []byte + +//go:embed fonts/weathericons-regular-webfont.ttf +var srcFontWeatherIcons []byte + +var ( + fontJetBrainsMono *canvas.FontFamily + fontWeatherIcons *canvas.Font + + fontMonoTitle *canvas.FontFace + fontMonoSubtitleInverted *canvas.FontFace + fontMonoLittleSubtitle *canvas.FontFace + fontMonoMedText *canvas.FontFace + fontMonoSmallText *canvas.FontFace + fontWeatherIconsSmall *canvas.FontFace + fontWeatherIconsLarge *canvas.FontFace +) + +func init() { + fontJetBrainsMono = canvas.NewFontFamily("jetbrainsmono") + + if err := fontJetBrainsMono.LoadFont(srcfontJBMonoRegular, 0, canvas.FontRegular); err != nil { + panic(err) + } + + if err := fontJetBrainsMono.LoadFont(srcfontJBMonoBold, 0, canvas.FontBold); err != nil { + panic(err) + } + + if err := fontJetBrainsMono.LoadFont(srcfontJBMonoItalic, 0, canvas.FontItalic); err != nil { + panic(err) + } + + fontMonoTitle = fontJetBrainsMono.Face(220) + fontMonoSubtitleInverted = fontJetBrainsMono.Face(95, canvas.White, canvas.FontBold) + fontMonoLittleSubtitle = fontJetBrainsMono.Face(80, canvas.FontBold) + fontMonoMedText = fontJetBrainsMono.Face(150, canvas.FontRegular) + fontMonoSmallText = fontJetBrainsMono.Face(75, canvas.FontRegular) + + if i, err := canvas.LoadFont(srcFontWeatherIcons, 0, canvas.FontRegular); err != nil { + panic(err) + } else { + fontWeatherIcons = i + } + + fontWeatherIconsSmall = fontWeatherIcons.Face(150, canvas.Black) + fontWeatherIconsLarge = fontWeatherIcons.Face(300, canvas.Black) +} + +func drawTitle(ctx *canvas.Context, startFrom float64) float64 { + now := time.Now().UTC() + + var tod string + if hr := now.Hour(); 0 <= hr && hr < 12 { + tod = "morning" + } else if 12 <= hr && hr < 17 { + tod = "afternoon" + } else if 17 <= hr && hr < 21 { + tod = "evening" + } else { + tod = "night" + } + + titleText := "good " + tod + + var ( + titleBounds canvas.Rect + ) + + { + // draw title + tl := canvas.NewTextLine(fontMonoTitle, titleText, canvas.Left) + titleBounds = tl.OutlineBounds() + ctx.DrawText(padding, startFrom+titleBounds.H, tl) + } + + { + // draw date + rt := canvas.NewTextLine(fontMonoLittleSubtitle, now.Format("02 Jan"), canvas.Right) + ctx.DrawText(imageWidth-padding, startFrom+titleBounds.H, rt) + } + + return startFrom + titleBounds.H +} + +func drawWeather(ctx *canvas.Context, startFrom float64, wxLoc string, wx []*weatherEntry) (float64, error) { + if len(wx) < 1 { + return 0, errors.New("at least 1 weather entry required") + } + + cursorX := float64(0) + cursorY := startFrom + 15 + startFrom += 15 + + tl := canvas.NewTextLine(fontMonoSubtitleInverted, "Weather", canvas.Left) + bounds := tl.OutlineBounds() + + { + cursorX += padding + cursorY += padding + //posX := padding + //posY := startFrom + padding + titlePad := float64(5) + + ctx.SetFill(canvas.Black) + ctx.DrawPath(cursorX, cursorY, canvas.Rectangle(bounds.W+titlePad*2, bounds.H+titlePad*2)) + + cursorX += titlePad + cursorY += titlePad + bounds.H + + ctx.DrawText(cursorX, cursorY, tl) + + cursorX += bounds.W + ctx.DrawText(cursorX, cursorY, canvas.NewTextLine(fontMonoLittleSubtitle, fmt.Sprintf(" in %s at %02d00z", wxLoc, wx[0].Time), canvas.Left)) + } + + cursorY += bounds.H + 5 + cursorX = padding + titleBaseline := startFrom + (bounds.H * 2) + 5 + + //ctx.SetFill(canvas.Red) + //ctx.DrawPath(1, titleBaseline, canvas.Rectangle(900, 1)) + + currTempStr := fmt.Sprintf("%02d", wx[0].Temperature) + currWindSpeedStr := fmt.Sprintf("w%dmph", wx[0].WindSpeed) + currPrecipProbStr := fmt.Sprintf("r%d%%", wx[0].PrecipitationChance) + + tl = canvas.NewTextLine(fontWeatherIconsLarge, weatherIcons[wx[0].Type], canvas.Left) + bounds = tl.OutlineBounds() + + wxIconsBaseline := titleBaseline + fontWeatherIconsLarge.Size + + ctx.DrawText(padding*2, wxIconsBaseline, tl) + + minitextblockxpos := (padding * 2) + 15 + bounds.W + + rt := canvas.NewRichText(fontMonoMedText) + rt.WriteString(currTempStr) + rt.SetFace(fontWeatherIconsSmall) + rt.WriteString("\uf03c") + rtt := rt.ToText(fontMonoMedText.TextWidth(currTempStr)+fontWeatherIconsSmall.TextWidth("\uf03c"), fontMonoMedText.LineHeight()+10, canvas.Left, canvas.Left, 0.0, 0.0) + ctx.DrawText(minitextblockxpos, titleBaseline, rtt) + + tl = canvas.NewTextLine(fontMonoSmallText, currWindSpeedStr, canvas.Left) + ctx.DrawText(minitextblockxpos, titleBaseline+90, tl) + + tl = canvas.NewTextLine(fontMonoSmallText, currPrecipProbStr, canvas.Left) + ctx.DrawText(minitextblockxpos, titleBaseline+120, tl) + + // ----- + + minitextblockxpos += rtt.Width + 15 + dividerLineHeight := wxIconsBaseline - titleBaseline + + for _, weather := range wx[1:] { + ctx.SetFill(canvas.Black) + ctx.DrawPath(minitextblockxpos, titleBaseline+20, canvas.Rectangle(3, dividerLineHeight)) + + minitextblockxpos += 15 + + wtl := canvas.NewTextLine(fontWeatherIconsSmall, weatherIcons[weather.Type], canvas.Left) + bounds := wtl.OutlineBounds() + + tl = canvas.NewTextLine(fontMonoSmallText, fmt.Sprintf("%02dz %02dc", weather.Time, weather.Temperature), canvas.Left) + nbounds := tl.OutlineBounds() + ctx.DrawText(minitextblockxpos, titleBaseline+bounds.H+nbounds.H+50, tl) + + ctx.DrawText(minitextblockxpos+((8+nbounds.W-bounds.W)/2), titleBaseline+bounds.H+30, wtl) + + minitextblockxpos += 15 + nbounds.W + } + + return titleBaseline + dividerLineHeight, nil +} + +// https://erikflowers.github.io/weather-icons/ +var weatherIcons = map[int]string{ + 0: "\uf02e", // clear night + 1: "\uf00d", // sunny day + 2: "\uf086", // partly cloudy (night) + 3: "\uf002", // partly cloudy (day) + // 4 not used + 5: "\uf014", // mist + 6: "\uf014", // fog + 7: "\uf013", // cloudy + 8: "\uf041", // overcast + 9: "\uf029", // light rain shower (night) + 10: "\uf009", // light rain shower (day) + 11: "\uf01c", // drizzle + 12: "\uf01a", // light rain + 13: "\uf008", // Heavy rain shower (night) + 14: "\uf028", // Heavy rain shower (day) + 15: "\uf019", // Heavy rain + 16: "\uf0b4", // Sleet shower (night) + 17: "\uf0b2", // Sleet shower (day) + 18: "\uf0b5", // Sleet + 19: "\uf024", // Hail shower (night) + 20: "\uf004", // Hail shower (day) + 21: "\uf015", // Hail + 22: "\uf067", // Light snow shower (night) + 23: "\uf065", // Light snow shower (day) + 24: "\uf064", // Light snow + 25: "\uf076", // Heavy snow shower (night) + 26: "\uf076", // Heavy snow shower (day) + 27: "\uf076", // Heavy snow + 28: "\uf02d", // Thunder shower (night) + 29: "\uf010", // Thunder shower (day) + 30: "\uf01e", // Thunder +} + +func drawTrains(ctx *canvas.Context, startFrom float64, services []string) (float64, error) { + cursorX := float64(0) + cursorY := startFrom + 15 + startFrom += 15 + + tl := canvas.NewTextLine(fontMonoSubtitleInverted, "Trains", canvas.Left) + bounds := tl.OutlineBounds() + + { + cursorX += padding + cursorY += padding + //posX := padding + //posY := startFrom + padding + titlePad := float64(5) + + ctx.SetFill(canvas.Black) + ctx.DrawPath(cursorX, cursorY, canvas.Rectangle(bounds.W+titlePad*2, bounds.H+titlePad*2)) + + cursorX += titlePad + cursorY += titlePad + bounds.H + + ctx.DrawText(cursorX, cursorY, tl) + + cursorX += bounds.W + ctx.DrawText(cursorX, cursorY, canvas.NewTextLine(fontMonoLittleSubtitle, " at SLY", canvas.Left)) + } + + cursorY += bounds.H + 10 + cursorX = padding + titleBaseline := startFrom + (bounds.H * 2) + 5 + + if len(services) == 0 { + services = []string{"*** No services in the next 30 mins ***"} + } + + var lh float64 + for _, str := range services { + tl := canvas.NewTextLine(fontMonoSmallText, str, canvas.Left) + if lh == 0 { + lh = tl.OutlineBounds().H + } + ctx.DrawText(cursorX, cursorY, tl) + cursorY += lh + 5 + } + + return titleBaseline, nil +} diff --git a/imagegen/fonts/JetBrainsMono-Bold.ttf b/imagegen/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..0a92809 Binary files /dev/null and b/imagegen/fonts/JetBrainsMono-Bold.ttf differ diff --git a/imagegen/fonts/JetBrainsMono-Italic.ttf b/imagegen/fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 0000000..e54a46e Binary files /dev/null and b/imagegen/fonts/JetBrainsMono-Italic.ttf differ diff --git a/imagegen/fonts/JetBrainsMono-Regular.ttf b/imagegen/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..8da8aa4 Binary files /dev/null and b/imagegen/fonts/JetBrainsMono-Regular.ttf differ diff --git a/imagegen/fonts/weathericons-regular-webfont.ttf b/imagegen/fonts/weathericons-regular-webfont.ttf new file mode 100644 index 0000000..948f0a5 Binary files /dev/null and b/imagegen/fonts/weathericons-regular-webfont.ttf differ diff --git a/imagegen/imagegen.go b/imagegen/imagegen.go new file mode 100644 index 0000000..d0c0a8d --- /dev/null +++ b/imagegen/imagegen.go @@ -0,0 +1,78 @@ +package imagegen + +import ( + "bytes" + "fmt" + "github.com/disintegration/imaging" + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers/rasterizer" + "image" + "image/png" + "strings" +) + +type Config struct { + WxLocation string + MetOfficeDatapointAPIKey string + TrainsLocation string + RTTUsername string + RTTPassword string +} + +const ( + padding float64 = 20 + + imageWidth float64 = 800 + imageHeight float64 = 600 +) + +func Generate(conf *Config) ([]byte, error) { + // TODO: hardcoded secrets ick + locStr, wx, err := getWeatherInLocation(conf.MetOfficeDatapointAPIKey, conf.WxLocation) + if err != nil { + return nil, err + } + trains, err := getNextTrains(conf.RTTUsername, conf.RTTPassword, conf.TrainsLocation) + if err != nil { + return nil, err + } + + fmt.Println(strings.Join(trains, "\n")) + + for _, w := range wx { + fmt.Printf("%#v\n", w) + } + + c := canvas.New(imageWidth, imageHeight) + ctx := canvas.NewContext(c) + ctx.SetCoordSystem(canvas.CartesianIV) + + ctx.SetFill(canvas.White) + ctx.DrawPath(0, 0, canvas.Rectangle(imageWidth, imageHeight)) + + bottom := drawTitle(ctx, padding) + bottom, err = drawWeather(ctx, bottom, locStr, wx) + if err != nil { + return nil, err + } + bottom, err = drawTrains(ctx, bottom, trains) + if err != nil { + return nil, err + } + + //bottom += padding + // + //ctx.SetFill(canvas.Red) + //ctx.DrawPath(1, bottom, canvas.Rectangle(900, 3)) + + rasterizer.New(imageWidth, imageHeight, canvas.DPMM(1), canvas.DefaultColorSpace) + var img image.Image + img = rasterizer.Draw(c, canvas.DPMM(1), canvas.DefaultColorSpace) + img = imaging.Rotate270(img) + + buf := new(bytes.Buffer) + if err := png.Encode(buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/imagegen/kindle-dashboard.test.png b/imagegen/kindle-dashboard.test.png new file mode 100644 index 0000000..d765010 Binary files /dev/null and b/imagegen/kindle-dashboard.test.png differ diff --git a/imagegen/rttclient.go b/imagegen/rttclient.go new file mode 100644 index 0000000..f299677 --- /dev/null +++ b/imagegen/rttclient.go @@ -0,0 +1,110 @@ +package imagegen + +import ( + "context" + "fmt" + "github.com/carlmjohnson/requests" + "time" +) + +type rawTrainsResp struct { + Location struct { + Name string `json:"name"` + Crs string `json:"crs"` + Tiploc string `json:"tiploc"` + Country string `json:"country"` + System string `json:"system"` + } `json:"location"` + Filter interface{} `json:"filter"` + Services []struct { + LocationDetail struct { + RealtimeActivated bool `json:"realtimeActivated"` + Tiploc string `json:"tiploc"` + Crs string `json:"crs"` + Description string `json:"description"` + GbttBookedArrival string `json:"gbttBookedArrival"` + GbttBookedDeparture string `json:"gbttBookedDeparture"` + Origin []struct { + Tiploc string `json:"tiploc"` + Description string `json:"description"` + WorkingTime string `json:"workingTime"` + PublicTime string `json:"publicTime"` + } `json:"origin"` + Destination []struct { + Tiploc string `json:"tiploc"` + Description string `json:"description"` + WorkingTime string `json:"workingTime"` + PublicTime string `json:"publicTime"` + } `json:"destination"` + IsCall bool `json:"isCall"` + IsPublicCall bool `json:"isPublicCall"` + RealtimeArrival string `json:"realtimeArrival"` + RealtimeArrivalActual bool `json:"realtimeArrivalActual"` + RealtimeDeparture string `json:"realtimeDeparture"` + RealtimeDepartureActual bool `json:"realtimeDepartureActual"` + DisplayAs string `json:"displayAs"` + } `json:"locationDetail"` + ServiceUid string `json:"serviceUid"` + RunDate string `json:"runDate"` + TrainIdentity string `json:"trainIdentity"` + RunningIdentity string `json:"runningIdentity"` + AtocCode string `json:"atocCode"` + AtocName string `json:"atocName"` + ServiceType string `json:"serviceType"` + IsPassenger bool `json:"isPassenger"` + } `json:"services"` +} + +func getNextTrains(username, password, location string) ([]string, error) { + resp := new(rawTrainsResp) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + err := requests. + URL("https://api.rtt.io"). + Pathf("/api/v1/json/search/%s", location). + BasicAuth(username, password). + ToJSON(&resp). + Fetch(ctx) + cancel() + if err != nil { + return nil, fmt.Errorf("get trains: %w", err) + } + + var res []string + for _, service := range resp.Services { + depTime, err := time.ParseInLocation("1504 2006-01-02", service.LocationDetail.GbttBookedDeparture+" "+service.RunDate, time.UTC) + if err != nil { + return nil, fmt.Errorf("parse trains: %w", err) + } + now := time.Now().UTC() + if depTime.Sub(now) < time.Minute*60 { + str := fmt.Sprintf("%s % -25s", service.LocationDetail.GbttBookedDeparture, service.LocationDetail.Destination[0].Description) + var time string + if service.LocationDetail.DisplayAs == "CANCELLED_CALL" { + time = "Cancelled" + } else if service.LocationDetail.RealtimeDeparture != service.LocationDetail.GbttBookedDeparture { + time = "Expt " + service.LocationDetail.RealtimeDeparture + } else if service.LocationDetail.RealtimeArrivalActual { + time = "Arrived" + } else { + time = "On time" + } + + str += " " + time + res = append(res, str) + } + } + + for i, str := range res { + suffix := "th" + if i+1 == 1 { + suffix = "st" + } else if i+1 == 2 { + suffix = "nd" + } else if i+1 == 3 { + suffix = "rd" + } + res[i] = fmt.Sprintf("%d%s %s", i+1, suffix, str) + } + + return res, nil +} diff --git a/imagegen/wxclient.go b/imagegen/wxclient.go new file mode 100644 index 0000000..f7f3320 --- /dev/null +++ b/imagegen/wxclient.go @@ -0,0 +1,137 @@ +package imagegen + +import ( + "context" + "fmt" + "github.com/carlmjohnson/requests" + "strconv" + "time" + "unicode" +) + +type rawWxResp struct { + SiteRep struct { + DV struct { + DataDate time.Time `json:"dataDate"` + Type string `json:"type"` + Location struct { + ID string `json:"i"` + Lat string `json:"lat"` + Lon string `json:"lon"` + Name string `json:"name"` + Country string `json:"country"` + Continent string `json:"continent"` + Elevation string `json:"elevation"` + Period []struct { + Type string `json:"type"` + Value string `json:"value"` + Rep []struct { + WindDirection string `json:"D"` + FeelsLikeTemperature string `json:"F"` + WindGust string `json:"G"` + RelativeHumidityPercentage string `json:"H"` + PrecipitationProbability string `json:"Pp"` + WindSpeed string `json:"S"` + Temperature string `json:"T"` + Visibility string `json:"V"` + WeatherType string `json:"W"` + MaxUVIndex string `json:"U"` + MinutesPastMidnight string `json:"$"` + } `json:"Rep"` + } `json:"Period"` + } `json:"Location"` + } `json:"DV"` + } `json:"SiteRep"` +} + +type weatherEntry struct { + Time int + Type int + Temperature int + PrecipitationChance int + WindSpeed int +} + +func getWeatherInLocation(apiKey string, location string) (string, []*weatherEntry, error) { + resp := new(rawWxResp) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + err := requests. + URL("http://datapoint.metoffice.gov.uk"). + Pathf("/public/data/val/wxfcs/all/json/%s", location). + Params(map[string][]string{ + "key": {apiKey}, + "res": {"3hourly"}, + }). + ToJSON(&resp). + Fetch(ctx) + cancel() + if err != nil { + return "", nil, fmt.Errorf("get weather: %w", err) + } + + // get 4 + + var res []*weatherEntry + +dayLoop: + for _, dayWx := range resp.SiteRep.DV.Location.Period { + day, err := time.Parse("2006-01-02Z", dayWx.Value) + if err != nil { + return "", nil, fmt.Errorf("parse date: %w", err) + } + + for _, wx := range dayWx.Rep { + if len(res) >= 4 { + break dayLoop + } + + minsPastMidnight, err := strconv.Atoi(wx.MinutesPastMidnight) + if err != nil { + return "", nil, fmt.Errorf("parse weather: %w", err) + } + thisTime := day.Add(time.Minute * time.Duration(minsPastMidnight)) + if resp.SiteRep.DV.DataDate.Equal(thisTime) || resp.SiteRep.DV.DataDate.Before(thisTime.Add(time.Hour*3)) { + // if this is applicable + wxE := new(weatherEntry) + wxE.Time = minsPastMidnight / 60 + wxE.Type, err = strconv.Atoi(wx.WeatherType) + if err != nil { + return "", nil, fmt.Errorf("parse weather: %w", err) + } + { + temp, err := strconv.ParseFloat(wx.Temperature, 32) + if err != nil { + return "", nil, fmt.Errorf("parse weather: %w", err) + } + wxE.Temperature = int(temp) + } + { + precProb, err := strconv.ParseFloat(wx.PrecipitationProbability, 32) + if err != nil { + return "", nil, fmt.Errorf("parse weather: %w", err) + } + wxE.PrecipitationChance = int(precProb) + } + { + windSpeed, err := strconv.ParseFloat(wx.WindSpeed, 32) + if err != nil { + return "", nil, fmt.Errorf("parse weather: %w", err) + } + wxE.WindSpeed = int(windSpeed) + } + res = append(res, wxE) + } + } + } + + loc := []rune(resp.SiteRep.DV.Location.Name) + for i, x := range loc { + if i == 0 || loc[i-1] == ' ' { + loc[i] = unicode.ToUpper(x) + } else { + loc[i] = unicode.ToLower(x) + } + } + + return string(loc), res, nil +} diff --git a/rootpasswordtool.txt b/rootpasswordtool.txt new file mode 100644 index 0000000..1725499 --- /dev/null +++ b/rootpasswordtool.txt @@ -0,0 +1 @@ +https://www.sven.de/kindle/#