diff --git a/.gitignore b/.gitignore index 1adb88f..4c21ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ config.dev.yaml blog/ .data/ data/ +main diff --git a/go.mod b/go.mod index f9dd9d4..0f1354b 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/go-echarts/go-echarts/v2 v2.4.1 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect diff --git a/go.sum b/go.sum index ea043f4..8b17075 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= +github.com/go-echarts/go-echarts/v2 v2.4.1 h1:imBFGngJ9zv/2zJVjK3k0uLL+LzyPDgzeV7MWzxH0rs= +github.com/go-echarts/go-echarts/v2 v2.4.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI= github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= diff --git a/internal/apps/inflation/chart_renderer.go b/internal/apps/inflation/chart_renderer.go new file mode 100644 index 0000000..306099b --- /dev/null +++ b/internal/apps/inflation/chart_renderer.go @@ -0,0 +1,54 @@ +package inflation + +import ( + "bytes" + "fmt" + "io" + "regexp" + + log "git.tijl.dev/tijl/tijl.dev-core/modules/logger" + "github.com/go-echarts/go-echarts/v2/render" + tpls "github.com/go-echarts/go-echarts/v2/templates" +) + +type InternalRenderer struct { + render.BaseRender + c interface{} + before []func() +} + +func NewRenderer(c interface{}, before ...func()) render.Renderer { + return &InternalRenderer{c: c, before: before} +} + +func CustomRender(r *InternalRenderer) (string, error) { + for _, fn := range r.before { + fn() + } + + contents := []string{tpls.BaseTpl, tpls.ChartTpl} + tpl := render.MustTemplate("chart", contents) + + var buf bytes.Buffer + if err := tpl.ExecuteTemplate(&buf, "chart", r.c); err != nil { + return "", err + } + + log.Debug().Interface("bufst", buf.String()).Msg("t") + return buf.String(), nil +} + +func (r *InternalRenderer) Render(w io.Writer) error { + return nil +} + +var scriptRegex = regexp.MustCompile(`]*>([\s\S]*?)<\/script>`) + +func handleScriptElement(scriptTag string) string { + matches := scriptRegex.FindStringSubmatch(scriptTag) + if len(matches) > 1 { + return matches[1] + } + + return fmt.Sprintf(`window.onload = function() {%v}`, scriptTag) +} diff --git a/internal/apps/inflation/locales/en.json b/internal/apps/inflation/locales/en.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/apps/inflation/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/internal/apps/inflation/locales/nl.json b/internal/apps/inflation/locales/nl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/apps/inflation/locales/nl.json @@ -0,0 +1 @@ +{} diff --git a/internal/apps/inflation/main.go b/internal/apps/inflation/main.go new file mode 100644 index 0000000..cba28d7 --- /dev/null +++ b/internal/apps/inflation/main.go @@ -0,0 +1,170 @@ +package inflation + +import ( + "embed" + "fmt" + "html/template" + "io" + "net/http" + "os" + "time" + + "git.tijl.dev/tijl/tijl.dev-core/internal/config" + "git.tijl.dev/tijl/tijl.dev-core/modules/i18n" + log "git.tijl.dev/tijl/tijl.dev-core/modules/logger" + "git.tijl.dev/tijl/tijl.dev-core/modules/web" + "github.com/go-echarts/go-echarts/v2/charts" + "github.com/go-echarts/go-echarts/v2/opts" + "github.com/gofiber/fiber/v2" +) + +type Datas struct { + Name string + ProcessFunction func(map[time.Time]float32) map[time.Time]float32 +} + +var rawData = make(map[string]map[time.Time]float32) + +var datas = map[string]Datas{ + /* + "FPCPITOTLZGUSA": { + Name: "CPI USA", + ProcessFunction: func(m map[time.Time]float32) map[time.Time]float32 { return m }, + }, + */ + "CPIAUCSL": { + Name: "CPI USA BASE", + ProcessFunction: toPercent, + }, + "M2SL": { + Name: "M2 USA", + ProcessFunction: toPercent, + }, + "CP0000EZ19M086NEST": { + Name: "HICP ECB", + ProcessFunction: toPercent, + }, +} + +//go:embed locales/* +var Embed embed.FS + +func Setup() { + err := LoadData() + if err != nil { + log.Error().Err(err).Msg("inflation.Setup: Error loading data") + } + + i18n.RegisterTranslations(Embed, "locales") + + web.RegisterAppSetupFunc(func(a *fiber.App) { + a.Get("/app/inflation", func(c *fiber.Ctx) error { + + processedData := make(map[string]map[time.Time]float32) + for id, data := range datas { + processedData[id] = data.ProcessFunction(rawData[id]) + } + + line := charts.NewLine() + + startYear, endYear := getYearRange(processedData) + yearList := createYearList(startYear, endYear) + for seriesID, timeData := range processedData { + + aggregatedData := aggregateDataByYear(timeData) + + yAxisT := []opts.LineData{} + for _, year := range yearList { + if value, exists := aggregatedData[year]; exists { + yAxisT = append(yAxisT, opts.LineData{Value: value}) + } else { + yAxisT = append(yAxisT, opts.LineData{Value: nil}) + } + } + + line.AddSeries(datas[seriesID].Name, yAxisT) + } + + line.SetXAxis(yearList) + + line.SetGlobalOptions( + charts.WithTitleOpts(opts.Title{ + Title: "Inflation", + Subtitle: "Copyright Tijl 2024", + }), + charts.WithDataZoomOpts(opts.DataZoom{ + Orient: "horizontal", + Type: "slider", + }), + ) + + rendered := line.RenderSnippet() + + data := *web.Common(c) + data["Title"] = "tmp" + data["RenderedScript"] = template.JS(handleScriptElement(rendered.Script)) + data["RenderedElement"] = template.HTML(rendered.Element) + return c.Render("apps/inflation/index", data, "layouts/base") + }) + }, 1000) +} + +func LoadData() error { + + var baseDataPath = config.Config.DataLocation + "/apps/inflation/data/" + if _, err := os.Stat(baseDataPath); os.IsNotExist(err) { + err := os.MkdirAll(baseDataPath, os.ModePerm) + if err != nil { + return err + } + } + + // Load fred data + const fredBaseUrl = "https://fred.stlouisfed.org/graph/fredgraph.csv?id=" + + for id := range datas { + info, err := os.Stat(baseDataPath + id + ".csv") + if !os.IsNotExist(err) { + modTime := info.ModTime() + currentTime := time.Now() + + if modTime.Year() == currentTime.Year() && modTime.Month() == currentTime.Month() { + csvdata, err := readCSV(baseDataPath + id + ".csv") + if err != nil { + return err + } + rawData[id] = csvdata + continue + } + } + + resp, err := http.Get(fredBaseUrl + id) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch data: %v", resp.Status) + } + + file, err := os.Create(baseDataPath + id + ".csv") + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + + csvdata, err := readCSV(baseDataPath + id + ".csv") + if err != nil { + return err + } + rawData[id] = csvdata + } + + return nil +} diff --git a/internal/apps/inflation/util.go b/internal/apps/inflation/util.go new file mode 100644 index 0000000..1e77e83 --- /dev/null +++ b/internal/apps/inflation/util.go @@ -0,0 +1,113 @@ +package inflation + +import ( + "encoding/csv" + "fmt" + "os" + "sort" + "strconv" + "time" +) + +func getYearRange(processedData map[string]map[time.Time]float32) (int, int) { + var startYear, endYear int + + for _, timeData := range processedData { + for date := range timeData { + year := date.Year() + if year < startYear || startYear == 0 { + startYear = year + } + if year > endYear || endYear == 0 { + endYear = year + } + } + } + + return startYear, endYear +} +func createYearList(startYear, endYear int) []int32 { + years := make([]int32, 0, endYear-startYear+1) + for year := startYear; year <= endYear; year++ { + years = append(years, int32(year)) + } + return years +} + +func aggregateDataByYear(data map[time.Time]float32) map[int32]float32 { + yearlyData := make(map[int]float32) + + for k, v := range data { + if _, exists := yearlyData[k.Year()]; !exists { + yearlyData[k.Year()] = 0 + } + yearlyData[k.Year()] += v + } + + aggregatedData := make(map[int32]float32) + for k, v := range yearlyData { + aggregatedData[int32(k)] = v + } + + return aggregatedData +} + +func toPercent(m map[time.Time]float32) map[time.Time]float32 { + var times []time.Time + for t := range m { + times = append(times, t) + } + sort.Slice(times, func(i, j int) bool { + return times[i].Before(times[j]) + }) + percentageChanges := make(map[time.Time]float32) + var previousValue float32 + for i, t := range times { + currentValue := m[t] + if i == 0 { + percentageChanges[t] = 0 + } else { + percentageChange := ((currentValue - previousValue) / previousValue) * 100 + percentageChanges[t] = percentageChange + } + previousValue = currentValue + } + + return percentageChanges +} + +func readCSV(filePath string) (map[time.Time]float32, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + csvReader := csv.NewReader(file) + records, err := csvReader.ReadAll() + if err != nil { + return nil, err + } + + dataMap := make(map[time.Time]float32) + + for i, record := range records { + if i == 0 { + continue + } + + date, err := time.Parse("2006-01-02", record[0]) + if err != nil { + return nil, fmt.Errorf("error parsing date on line %d: %v", i+1, err) + } + + value, err := strconv.ParseFloat(record[1], 32) + if err != nil { + return nil, fmt.Errorf("error parsing float on line %d: %v", i+1, err) + } + + dataMap[date] = float32(value) + } + + return dataMap, nil +} diff --git a/internal/service/main.go b/internal/service/main.go index 42ab71b..45789f3 100644 --- a/internal/service/main.go +++ b/internal/service/main.go @@ -9,6 +9,7 @@ import ( "time" "git.tijl.dev/tijl/tijl.dev-core/internal/apps/flags" + "git.tijl.dev/tijl/tijl.dev-core/internal/apps/inflation" "git.tijl.dev/tijl/tijl.dev-core/internal/apps/uploader" "git.tijl.dev/tijl/tijl.dev-core/internal/assets" "git.tijl.dev/tijl/tijl.dev-core/internal/config" @@ -50,6 +51,7 @@ func Listen() { // setup apps flags.Setup() uploader.Setup() + inflation.Setup() // setup web webinternal.Load() diff --git a/locales/en.json b/locales/en.json index 195522a..8f1b00f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -14,5 +14,7 @@ "logged_in": "You are now logged in!", "services": "Services", "error_long": "Something went wrong", - "copy": "Copy" + "open": "Open", + "copy": "Copy", + "info": "Info" } diff --git a/locales/nl.json b/locales/nl.json index a9c2550..2f7dc18 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -14,5 +14,7 @@ "logged_in": "Je bent nu ingelogd!", "services": "Services", "error_long": "Er ging iets fout", - "copy": "Copy" + "open": "Open", + "copy": "Kopieer", + "info": "Info" } diff --git a/main b/main deleted file mode 100755 index 67e4ac7..0000000 Binary files a/main and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 9597b32..3d76879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tailwindcss/typography": "^0.5.14", "@types/node": "^22.4.2", "daisyui": "^4.12.10", + "echarts": "^5.5.1", "tailwindcss": "^3.4.10", "typescript": "^5.0.0", "vite": "^4.0.0" @@ -771,6 +772,16 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/echarts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz", + "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "dev": true, + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1777,6 +1788,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "dev": true + }, "node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", @@ -1974,6 +1991,15 @@ "engines": { "node": ">= 14" } + }, + "node_modules/zrender": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz", + "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "dev": true, + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index 686a287..f0bc9d9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@tailwindcss/typography": "^0.5.14", "@types/node": "^22.4.2", "daisyui": "^4.12.10", + "echarts": "^5.5.1", "tailwindcss": "^3.4.10", "typescript": "^5.0.0", "vite": "^4.0.0" diff --git a/web/views/about.html b/web/views/about.html index 3f4e68b..0b21315 100644 --- a/web/views/about.html +++ b/web/views/about.html @@ -14,10 +14,10 @@ {{if (ne .Value "")}}
- +
{{end}} - Open + {{$.T.open}}