Compare commits

..

179 Commits

Author SHA1 Message Date
bel
045ab30dfa install script
All checks were successful
cicd / ci (push) Successful in 2m6s
2026-01-03 11:21:47 -07:00
Bel LaPointe
b1cf639f39 un-break shared
All checks were successful
cicd / ci (push) Successful in 3m5s
2025-10-31 07:28:26 -06:00
Bel LaPointe
cc546d9b2d fix
All checks were successful
cicd / ci (push) Successful in 1m14s
2025-10-26 13:11:09 -06:00
Bel LaPointe
0066a267a6 bal prints percents
All checks were successful
cicd / ci (push) Successful in 2m40s
2025-10-26 09:27:07 -06:00
Bel LaPointe
f58053ebe9 5% raise neat
All checks were successful
cicd / ci (push) Successful in 1m55s
2025-10-15 14:55:00 -06:00
Bel LaPointe
b30b1811ea shared flags
All checks were successful
cicd / ci (push) Successful in 1m53s
2025-09-04 22:18:04 -06:00
Bel LaPointe
3fb1ee4ea3 qt got a turtle rock promo~
All checks were successful
cicd / ci (push) Successful in 2m13s
2025-08-28 20:56:35 -06:00
Bel LaPointe
7a790fa31e group date
All checks were successful
cicd / ci (push) Successful in 1m51s
2025-08-23 08:25:08 -06:00
Bel LaPointe
774de58bf7 not u
All checks were successful
cicd / ci (push) Successful in 1m3s
2025-06-16 18:18:04 -06:00
Bel LaPointe
6aa549cb02 bpis predictFixedGrowth fixed
All checks were successful
cicd / ci (push) Successful in 1m15s
2025-06-16 18:12:11 -06:00
Bel LaPointe
fe7c3a9682 oop 3500 saved per mo not 3600
All checks were successful
cicd / ci (push) Successful in 2m45s
2025-06-16 17:58:38 -06:00
Bel LaPointe
36dcf70dbe from Asset to Bel:Asset
All checks were successful
cicd / ci (push) Successful in 1m1s
2025-06-16 17:57:06 -06:00
Bel LaPointe
989bc3c2ff update prediction default
All checks were successful
cicd / ci (push) Successful in 2m19s
2025-06-16 17:55:16 -06:00
Bel LaPointe
f22d7d958b WILDCARD
All checks were successful
cicd / ci (push) Successful in 1m28s
2025-05-25 13:04:27 -06:00
Bel LaPointe
6a4e10ee2b trigger on self 2025-05-25 13:04:06 -06:00
Bel LaPointe
09da985455 build on build/ 2025-05-25 13:03:36 -06:00
Bel LaPointe
f52832ee82 docker build 2025-05-25 13:02:59 -06:00
bel
0cafba0571 bel pay raise
Some checks failed
cicd / ci (push) Failing after 1m30s
2025-05-24 08:46:51 -06:00
Bel LaPointe
1a397dbf45 mmmm giving out security info wasnt great so rotate bank passwords
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 23:36:02 -06:00
Bel LaPointe
b10283d752 multi-acc rate limited lookin pretty good~
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:17:54 -06:00
Bel LaPointe
284613b5bc fix https
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:01:15 -06:00
Bel LaPointe
929d15c5b7 teller accepts multi token in tokens.txt
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 23:00:09 -06:00
Bel LaPointe
5e61378d63 resolved chase since dawn of time yay 2025-05-23 22:38:52 -06:00
Bel LaPointe
c948a32458 fix onedayoff checks day after
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:24:05 -06:00
Bel LaPointe
35e2e40ce6 oooo onedayoff ok
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:15:10 -06:00
Bel LaPointe
847cd736b6 at least round both wrong
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:02:27 -06:00
Bel LaPointe
9cf8cb0736 rec ok with negatives/positives
Some checks failed
cicd / ci (push) Failing after 21s
2025-05-23 21:59:19 -06:00
Bel LaPointe
4997264f4c fix cache test 2025-05-23 21:56:36 -06:00
Bel LaPointe
f69a850bd8 got a silly ui that can yield a test token 2025-05-23 21:38:07 -06:00
Bel LaPointe
5a3d5e5610 stub teller.Init 2025-05-23 21:12:27 -06:00
Bel LaPointe
c38e8529af ready to get a real token 2025-05-23 21:09:26 -06:00
Bel LaPointe
7a946b7604 go run ./ cli rec to pull all from teller
Some checks failed
cicd / ci (push) Failing after 18s
2025-05-23 21:01:09 -06:00
Bel LaPointe
e0fa44eef7 test bank.cache
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 20:32:49 -06:00
Bel LaPointe
6440b07d14 impl teller.Client returns bank.*
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 20:15:59 -06:00
Bel LaPointe
bae33f8c60 try /transactions too
Some checks failed
cicd / ci (push) Failing after 19s
2025-05-23 19:44:24 -06:00
Bel LaPointe
daa446cc02 teller seems k so long as they accept 2025-05-23 19:41:39 -06:00
Bel LaPointe
daac3907f4 teller certs
Some checks failed
cicd / ci (push) Failing after 1m7s
2025-05-23 19:23:52 -06:00
Bel LaPointe
83305227db no more ofx 2025-05-23 19:20:52 -06:00
Bel LaPointe
f5d82fc6aa ofx died 3y ago 2025-05-23 18:59:39 -06:00
Bel LaPointe
e581b7835c DRAW
Some checks failed
cicd / ci (push) Failing after 23s
2025-05-07 15:54:58 -06:00
Bel LaPointe
19f4b614d3 insta..sh
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-07 15:35:05 -06:00
Bel LaPointe
58462fb5a4 cli graph graphs
Some checks failed
cicd / ci (push) Failing after 51s
2025-05-07 15:34:31 -06:00
Bel LaPointe
2f8dba4e23 accept --cpi=FILE --cpiy=YEAR to translate money to target year
All checks were successful
cicd / ci (push) Successful in 1m30s
2025-05-07 09:55:16 -06:00
Bel LaPointe
0d91cd63db helpers 2025-05-07 09:40:53 -06:00
Bel LaPointe
9681050a46 non usd 3 decimal places
All checks were successful
cicd / ci (push) Successful in 48s
2025-04-11 09:29:13 -06:00
Bel LaPointe
0b4d78796e minor refactor but i think no problem
All checks were successful
cicd / ci (push) Successful in 1m28s
2025-04-11 09:22:41 -06:00
bel
1805a087e0 cli depth doesnt require mindepth
All checks were successful
cicd / ci (push) Successful in 45s
2025-04-06 11:27:54 -06:00
bel
fabd26e8e9 reg prettier print same col w
All checks were successful
cicd / ci (push) Successful in 54s
2025-04-06 11:08:38 -06:00
bel
9646356d7f reg -c prints DATE FULL:ACC DELTA (BAL)
All checks were successful
cicd / ci (push) Successful in 46s
2025-04-06 11:03:53 -06:00
bel
84bbda1031 fix and
All checks were successful
cicd / ci (push) Successful in 51s
2025-04-06 10:40:44 -06:00
bel
9b4accafe3 neater bal print 2025-04-06 10:38:54 -06:00
bel
9352ca82de WAY faster doing cumulative balances over summing [0,i] every reg loop
All checks were successful
cicd / ci (push) Successful in 1m39s
2025-04-06 10:05:42 -06:00
bel
058874b566 buffering writes did NOT save any time
All checks were successful
cicd / ci (push) Successful in 1m37s
2025-04-05 23:07:56 -06:00
bel
8ade122378 ana-ledger-cli shared FILES
All checks were successful
cicd / ci (push) Successful in 55s
2025-04-03 23:36:31 -06:00
bel
fe98fa67f3 printing normalize appends factor and real delta
All checks were successful
cicd / ci (push) Successful in 58s
2025-04-03 23:20:50 -06:00
bel
5cc9b141b9 normalize
All checks were successful
cicd / ci (push) Successful in 1m0s
2025-04-03 23:04:08 -06:00
bel
266af7353a cli accepts -with PATTERN to filter transactions by all attendees
All checks were successful
cicd / ci (push) Successful in 1m31s
2025-04-03 22:32:56 -06:00
Bel LaPointe
dd20f066a3 WIP cmd/cli$ go run ../ cli $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*.txt) -n -w ^Housey --depth 1 -usd bal
All checks were successful
cicd / ci (push) Successful in 1m32s
2025-04-03 21:39:36 -06:00
Bel LaPointe
697fc16b52 each delta has .with and LikeWith to find X related to Y 2025-04-03 17:28:57 -06:00
Bel LaPointe
8621619433 cli bal nonzero
All checks were successful
cicd / ci (push) Successful in 54s
2025-04-03 12:28:00 -06:00
Bel LaPointe
768ce8e92e cumulative bal in reg includes bpis
All checks were successful
cicd / ci (push) Successful in 59s
2025-04-03 12:25:39 -06:00
Bel LaPointe
757afa603e hide zeros only in bal 2025-04-03 12:11:32 -06:00
Bel LaPointe
d093db1a2b cli supports bal again!
All checks were successful
cicd / ci (push) Successful in 1m35s
2025-04-03 11:55:19 -06:00
Bel LaPointe
30a0414dcd evalulate acc = VALUE in date, filename, lineno order 2025-04-03 11:55:06 -06:00
Bel LaPointe
1f9919a172 deltas try to remember filename, lineno 2025-04-03 11:54:29 -06:00
Bel LaPointe
fb1ddc72c3 ledger.Balances accept Like, Group kinda 2025-04-03 11:53:33 -06:00
Bel LaPointe
0eaeeec359 cli accepts -n normalie
All checks were successful
cicd / ci (push) Successful in 1m50s
2025-04-03 10:24:49 -06:00
Bel LaPointe
188b31aa0c move me and qt salaries to ana.NewDefaultNormalizer 2025-04-03 10:15:05 -06:00
Bel LaPointe
af1b23731a gui CREATE button
All checks were successful
cicd / ci (push) Successful in 1m3s
2025-02-21 16:24:41 -07:00
Bel LaPointe
fcd7dd208c impl /api/create 2025-02-21 15:55:12 -07:00
Bel LaPointe
9bbdeeae9c oop no extra loop
All checks were successful
cicd / ci (push) Successful in 52s
2025-02-21 15:38:03 -07:00
Bel LaPointe
4d01a5e481 impl normalize in HTTP
All checks were successful
cicd / ci (push) Successful in 2m18s
2025-02-21 15:31:09 -07:00
Bel LaPointe
5cc7b7ec55 impl ana.Normalizer 2025-02-21 15:06:44 -07:00
Bel LaPointe
ed0ce83556 impl cli reg depth
All checks were successful
cicd / ci (push) Successful in 1m24s
2025-02-21 14:47:30 -07:00
Bel LaPointe
a1631019f0 tood 2025-02-21 14:37:00 -07:00
Bel LaPointe
d7edc1ff1d nicer but my fsm is wrong if i want to bundle ands first
All checks were successful
cicd / ci (push) Successful in 3m29s
2024-12-13 10:49:19 -07:00
Bel LaPointe
012024ac66 i need fsm 2024-12-13 09:48:24 -07:00
Bel LaPointe
a5b1e653ae go run ./.. cli --period 2024 $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*) reg [^K]:[^:]*:Retirement$ 2024-12-12 23:03:17 -07:00
Bel LaPointe
37f6f47375 pretty print vs cumulative
All checks were successful
cicd / ci (push) Successful in 1m52s
2024-12-12 22:56:29 -07:00
Bel LaPointe
1a517eb8f2 temp 2024-12-12 22:53:40 -07:00
Bel LaPointe
c3f7800dec can get retirement contributions with grepping 2024-12-12 22:44:38 -07:00
Bel LaPointe
e25d52b141 prettier printing 2024-12-12 22:33:21 -07:00
Bel LaPointe
7a828f8463 reg only prints accounts matching LIKES and not NOTLIKEs 2024-12-12 22:29:30 -07:00
Bel LaPointe
117c300533 reg prints balance per xaction now 2024-12-12 22:28:40 -07:00
Bel LaPointe
4d484b8aa4 reg prints balance per xaction now 2024-12-12 22:27:44 -07:00
Bel LaPointe
4831914251 o my register is a balance series but not a transaction series try again
All checks were successful
cicd / ci (push) Successful in 1m52s
2024-12-12 22:18:38 -07:00
Bel LaPointe
eb3af4b54f ledger balances helpers like Sub, Sum, Invert 2024-12-12 22:17:08 -07:00
Bel LaPointe
849a8696f5 a reg 2024-12-12 22:10:16 -07:00
Bel LaPointe
1a2c88687f progress i guess 2024-12-12 21:59:51 -07:00
Bel LaPointe
889dc48d6c oo more tood 2024-12-12 21:53:18 -07:00
Bel LaPointe
881162357b no debug log 2024-12-12 21:50:18 -07:00
Bel LaPointe
62e65c47df support --period 2024-12-12 21:50:03 -07:00
Bel LaPointe
8ab5a0edf5 prints bal
All checks were successful
cicd / ci (push) Successful in 2m1s
2024-12-12 21:37:02 -07:00
Bel LaPointe
d1b3da3fa3 wip
Some checks failed
cicd / ci (push) Failing after 1m54s
2024-12-12 21:16:02 -07:00
Bel LaPointe
e4d60a9e73 stub cli
All checks were successful
cicd / ci (push) Successful in 1m1s
2024-12-12 11:37:16 -07:00
Bel LaPointe
23bd6e29c9 handle single-line ; comments
All checks were successful
cicd / ci (push) Successful in 1m51s
2024-12-12 11:34:00 -07:00
Bel LaPointe
5587429922 resize
All checks were successful
cicd / ci (push) Successful in 2m6s
2024-07-22 11:40:00 -06:00
Bel LaPointe
e3c1af4f5d update title
All checks were successful
cicd / ci (push) Successful in 50s
2024-07-22 07:16:25 -06:00
Bel LaPointe
ce19c74cb0 oop
All checks were successful
cicd / ci (push) Successful in 1m36s
2024-07-22 07:13:12 -06:00
Bel LaPointe
16bbeb8749 pie trims common prefix and sorts slice names 2024-07-22 07:12:36 -06:00
bel
3211dbd73f home links to pies
All checks were successful
cicd / ci (push) Successful in 1m18s
2024-07-21 17:20:45 -06:00
bel
6df7171057 /api/trends has PIES
All checks were successful
cicd / ci (push) Successful in 1m36s
2024-07-21 17:18:56 -06:00
bel
6be00d6423 /api/trends has 1 pie of median 2024-07-21 17:12:52 -06:00
bel
b8c78fe55e /api/trends prints pie of each of last N months 2024-07-21 17:03:44 -06:00
bel
18a19e52f5 build defaults to ts 2024-07-21 15:44:03 -06:00
bel
bb11214180 always highlight all inbox transactions
All checks were successful
cicd / ci (push) Successful in 50s
2024-07-21 08:43:09 -06:00
bel
6c66b6debb highlight original on amend... 2024-07-21 08:36:16 -06:00
bel
518909b7f7 transactions.html can edit though needs some kinda user feedback of the changed row or something...
All checks were successful
cicd / ci (push) Successful in 1m28s
2024-07-21 08:31:37 -06:00
bel
d69de4da11 ui ready enough for amending 2024-07-21 08:23:34 -06:00
bel
b8323f731f TODO clicking on transactions switches them to a stub state that will be a form that submits amendments BUT ASSERTS ONLY 1 DELTA MODIFIED BEFORE SENDING and sends the first
All checks were successful
cicd / ci (push) Successful in 56s
2024-07-20 21:32:59 -06:00
bel
912c3a2659 anywidth
All checks were successful
cicd / ci (push) Successful in 1m12s
2024-07-20 21:19:42 -06:00
bel
6026d54062 fill a dummy form 2024-07-20 21:16:54 -06:00
bel
b6cf4656c8 dummy mvp is ew 2024-07-20 21:12:49 -06:00
bel
45c4d7b684 transactions.html omits non-payee currency and value table cells
All checks were successful
cicd / ci (push) Successful in 1m3s
2024-07-20 20:45:31 -06:00
bel
59205fba4c ledger.Delta.Payee = true 2024-07-20 20:25:37 -06:00
bel
1d18cb50c5 ledger.Transaction.Payee()
All checks were successful
cicd / ci (push) Successful in 1m21s
2024-07-20 20:10:35 -06:00
bel
bd85dcc86a register cleaner 2024-07-20 19:56:29 -06:00
Bel LaPointe
2ce78d2b42 got transactions into rows in transactions.html, now gotta fix column with for description then go for editable
All checks were successful
cicd / ci (push) Successful in 55s
2024-07-20 11:40:06 -06:00
Bel LaPointe
8886442e89 /api/transactions returns .transactions [][]Delta
All checks were successful
cicd / ci (push) Successful in 1m0s
2024-07-20 11:30:42 -06:00
Bel LaPointe
1e50274681 todo 2024-07-20 11:29:20 -06:00
Bel LaPointe
80ca2ea61e make ledger.Deltas.Transactions()
All checks were successful
cicd / ci (push) Successful in 1m8s
2024-07-20 11:29:10 -06:00
Bel LaPointe
e8f42c7a5d Fix /api/bal t route to router.APIReg
All checks were successful
cicd / ci (push) Successful in 54s
2024-07-20 10:45:59 -06:00
Bel LaPointe
ae44534fc3 api/transactions pretty 2024-07-20 10:43:57 -06:00
bel
cd36e12b68 DEBUG=true go run . http -http :38080 -group-date=^....-.. -group-name=^[^:]*:[^:]* -bpi=./http/bpi.dat ./http/macro.d/ 2024-07-20 10:19:24 -06:00
bel
9a0eb89f54 WIP api transactions doesnt return net house debt hrmmmm
All checks were successful
cicd / ci (push) Successful in 1m39s
2024-07-20 10:18:58 -06:00
bel
fe8d80ffc8 balances.NotLike 2024-07-20 10:05:17 -06:00
bel
f41386b3b4 del code related to last n lines 2024-07-20 09:55:03 -06:00
bel
2141e030ef no more last n lines 2024-07-20 09:53:41 -06:00
bel
ee6ce95c0a ui balance filters and groups to houseyMcHouseface 2024-07-20 09:48:46 -06:00
bel
6d174b031b balances.Group 2024-07-20 09:46:19 -06:00
bel
b6c6e83443 Refactoring http/main into http router
All checks were successful
cicd / ci (push) Successful in 1m39s
2024-07-20 09:27:26 -06:00
bel
ddc95c9054 delta.Transaction export field 2024-07-20 09:27:08 -06:00
bel
666965042a http chart to src/view 2024-07-20 09:07:06 -06:00
Bel LaPointe
f6b8b92bff impl and test src.ledger.Files.Amend(old, new Delta)
All checks were successful
cicd / ci (push) Successful in 1m15s
2024-07-14 14:24:29 -06:00
bel
3e001b8ddd test start
All checks were successful
cicd / ci (push) Successful in 1m28s
2024-07-14 08:42:31 -06:00
bel
682dc9880e todo
All checks were successful
cicd / ci (push) Successful in 1m21s
2024-07-13 23:35:52 -06:00
bel
4e72d25dce todo 2024-07-13 23:29:42 -06:00
bel
0d456f4d73 move files.Add to inbox.txt 2024-07-13 23:23:32 -06:00
bel
9b1ad420fe allow updates as invert + inbox, inserts as inbox 2024-07-13 22:46:59 -06:00
bel
e633ee07ab .src.ledger.Delta.transaction = filename/idx
All checks were successful
cicd / ci (push) Successful in 1m27s
2024-07-13 12:35:10 -06:00
Bel LaPointe
bdcd9fe26a hide because i dont want to finish it
All checks were successful
cicd / ci (push) Successful in 1m15s
2023-11-11 18:43:38 -07:00
Bel LaPointe
2051fa1cdc rename because ledger-ui doesnt like symlinks
All checks were successful
cicd / ci (push) Successful in 50s
2023-11-07 07:00:43 -07:00
Bel LaPointe
f7098def0e use bpi to create monopoly money and weigh contributions to house and update staging for it
All checks were successful
cicd / ci (push) Successful in 1m40s
2023-11-07 06:36:15 -07:00
bel
8fd1a4fedb add helper buttons to Stage Zach/Bel Charge/Payment
All checks were successful
cicd / ci (push) Successful in 46s
2023-10-29 10:37:03 -06:00
bel
566b3ad571 ledger.Files.?etLastNLines trims empty lines 2023-10-29 10:36:45 -06:00
bel
f078a1e6a1 todo, rename cicd to ci, rm more stale images 2023-10-29 10:19:40 -06:00
bel
8ae90619d2 display nice
All checks were successful
cicd / cicd (push) Successful in 58s
2023-10-29 10:15:56 -06:00
bel
89a62ec30c todo 2023-10-29 10:11:52 -06:00
bel
83c8f63fe3 revert
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-29 10:10:35 -06:00
bel
aaeb3f1930 manual test to confirm symlinks are edited 2023-10-29 10:10:05 -06:00
bel
6604c26204 symlink 2023-10-29 10:09:31 -06:00
bel
ba6d6483e0 i think files.TempLastNLines are symlink friendly... 2023-10-29 10:09:14 -06:00
bel
58cafcfaa3 trim /public from http 2023-10-29 09:52:57 -06:00
bel
b006333035 one more open
All checks were successful
cicd / cicd (push) Successful in 47s
2023-10-29 09:39:26 -06:00
bel
b2d233bb82 editor works and is gross but works
All checks were successful
cicd / cicd (push) Has been cancelled
2023-10-29 09:38:30 -06:00
bel
253bff9f1d cp 2023-10-29 09:37:02 -06:00
bel
09f3baee4d here we go 2023-10-29 09:36:16 -06:00
bel
4de3b4a822 at least it 501s nicely 2023-10-29 09:33:53 -06:00
bel
2f21a23a33 stub 2023-10-29 09:29:27 -06:00
bel
227de17951 wip 2023-10-29 09:21:06 -06:00
bel
a623dcc195 impl ledger.Files.TempGet/SetLastNLines 2023-10-29 09:14:10 -06:00
bel
569e50b162 iwp 2023-10-29 09:13:19 -06:00
bel
622c173264 wip 2023-10-29 09:08:44 -06:00
bel
ce5bf71f6b wip 2023-10-29 09:05:39 -06:00
bel
c75bd74823 todo
Some checks failed
cicd / cicd (push) Failing after 39s
2023-10-28 10:54:23 -06:00
bel
93e5a77e04 auto-compute TODAY-6 months
All checks were successful
cicd / cicd (push) Successful in 43s
2023-10-28 10:49:43 -06:00
bel
cdad3092e1 todo 2023-10-28 10:41:04 -06:00
bel
432b2df131 embed static files
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-28 10:38:28 -06:00
bel
cb6e23a498 wider transactions.html
All checks were successful
cicd / cicd (push) Successful in 42s
2023-10-28 10:31:10 -06:00
bel
f7a9bd7bb7 only try rmi and push before 2023-10-28 10:26:29 -06:00
bel
476c44f5cd accept dir for http args
All checks were successful
cicd / cicd (push) Successful in 12s
2023-10-28 10:24:29 -06:00
bel
4b2af6b85e todo 2023-10-28 10:10:09 -06:00
bel
0baf3ccc8f move cmd into cmd/http as subcommand
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-28 09:52:39 -06:00
bel
8b7d0e84c0 trim 2023-10-28 09:48:21 -06:00
bel
4ec42ad877 only gitea when cmd or src changes 2023-10-28 09:48:07 -06:00
bel
fbbcaa7f78 double
Some checks failed
cicd / cicd (push) Failing after 9s
2023-10-28 09:45:37 -06:00
bel
f0411fe6db lets go lazy
Some checks failed
cicd / cicd (push) Failing after 8s
2023-10-28 09:45:13 -06:00
bel
910aa78faf todo 2023-10-28 09:44:58 -06:00
bel
8f91316d27 go mod vendor
All checks were successful
cicd / cicd (push) Successful in 42s
2023-10-28 09:43:26 -06:00
511 changed files with 243516 additions and 664 deletions

View File

@@ -3,14 +3,19 @@ on:
push: push:
branches: branches:
- main - main
paths:
- 'cmd/**'
- 'src/**'
- 'build/**'
- '.gitea/**'
jobs: jobs:
cicd: ci:
name: cicd name: ci
runs-on: dind runs-on: dind
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: cicd - name: ci
run: | run: |
bash ./build/build.sh bash ./build/build.sh

View File

@@ -1,4 +1,4 @@
FROM golang:1.21.3-alpine3.18 as builder FROM golang:1.23.9-alpine3.21 as builder
COPY ./ /go/src/ana-ledger COPY ./ /go/src/ana-ledger
WORKDIR /go/src/ana-ledger WORKDIR /go/src/ana-ledger

View File

@@ -2,12 +2,13 @@
set -e set -e
img=registry-app.inhome.blapointe.com:5001/bel/ana-ledger:latest img=registry-app.inhome.blapointe.com:5001/bel/ana-ledger:${1:-$(date +%Y%m%d%H%M)}
cd "$(dirname "$(dirname "$(realpath "$BASH_SOURCE")")")" cd "$(dirname "$(dirname "$(realpath "$BASH_SOURCE")")")"
was=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://') was=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://')
docker build -f ./build/Dockerfile -t $img . docker build -f ./build/Dockerfile -t $img .
now=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://') now=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://')
if [ -n "$was" ] && [ "$was" != "$now" ] && docker inspect "$was" &> /dev/null; then
docker rmi "$was"
fi
docker push $img docker push $img
if [ -n "$was" ] && [ "$was" != "$now" ] && docker inspect "$was" &> /dev/null; then
docker rmi "$was" || true
docker rmi $(docker ps | grep ${img%:*} | grep '<none>' | awk '{print $3}') || true
fi

View File

@@ -1 +0,0 @@
../../../../../Sync/Core/ledger/bpi.dat

1
cmd/cli/bpi.dat Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../Sync/Core/ledger/bpi.dat

21
cmd/cli/config.go Normal file
View File

@@ -0,0 +1,21 @@
package cli
type Config struct {
Files FileList
BPI string
CPI string
CPIYear int
Query struct {
Period Period
Sort string
NoRounding bool
Depth int
With string
NoExchanging bool
Normalize bool
USDOnly bool
}
Compact bool
GroupDate string
NoPercent bool
}

1
cmd/cli/cpi.dat Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../Sync/Core/ledger/cpi.dat

90
cmd/cli/flag.go Normal file
View File

@@ -0,0 +1,90 @@
package cli
import (
"fmt"
"os"
"strings"
"time"
)
type FlagStringArray []string
func (array *FlagStringArray) String() string {
return strings.Join(*array, ", ")
}
func (array *FlagStringArray) Set(s string) error {
*array = append(*array, s)
return nil
}
type FileList FlagStringArray
func (fileList FileList) Strings() []string {
return ([]string)(fileList)
}
func (fileList *FileList) String() string {
return (*FlagStringArray)(fileList).String()
}
func (fileList *FileList) Set(s string) error {
if _, err := os.Stat(s); os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", s)
}
return (*FlagStringArray)(fileList).Set(s)
}
type Period struct {
Start time.Time
Stop time.Time
}
func (period Period) Empty() bool {
return period.Stop.Sub(period.Start) == 0
}
func (period *Period) String() string {
return fmt.Sprintf("%s..%s", period.Start, period.Stop)
}
func (period *Period) Set(s string) error {
ss := strings.Split(s, "..")
if err := period.setStartStop(ss[0]); err != nil {
return err
}
if len(ss) != 2 {
} else if err := period.setStop(ss[1]); err != nil {
return err
}
return nil
}
func (period *Period) setStartStop(s string) error {
stop, err := period.setT(s, &period.Start)
period.Stop = stop
return err
}
func (period *Period) setStop(s string) error {
_, err := period.setT(s, &period.Stop)
return err
}
func (*Period) setT(s string, t *time.Time) (time.Time, error) {
if result, err := time.Parse("2006", s); err == nil {
*t = result
return result.AddDate(1, 0, 0).Add(-1 * time.Minute), nil
}
if result, err := time.Parse("2006-01", s); err == nil {
*t = result
return result.AddDate(0, 1, 0).Add(-1 * time.Minute), nil
}
if result, err := time.Parse("2006-01-02", s); err == nil {
*t = result
return result.AddDate(0, 0, 1).Add(-1 * time.Minute), nil
}
return time.Time{}, fmt.Errorf("unimplemented format: %s", s)
}

443
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,443 @@
package cli
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"math"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
"github.com/guptarohit/asciigraph"
"golang.org/x/crypto/ssh/terminal"
)
func Main() {
var config Config
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
fs.Var(&config.Files, "f", "paths to files")
fs.Var(&config.Query.Period, "period", "period can be YYYY, YYYY-mm, YYYY-mm-dd, x..y")
fs.StringVar(&config.Query.Sort, "S", "", "sort ie date")
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
fs.StringVar(&config.Query.With, "w", "", "regexp for transactions")
fs.IntVar(&config.Query.Depth, "depth", 0, "depth grouping")
fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer")
fs.BoolVar(&config.Query.USDOnly, "usd", false, "filter to usd")
fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges")
fs.StringVar(&config.BPI, "bpi", "", "path to bpi")
fs.StringVar(&config.CPI, "cpi", "", "path to cpi")
fs.StringVar(&config.GroupDate, "group-date", "^....-..-..", "date grouping")
fs.IntVar(&config.CPIYear, "cpiy", 0, "use cpi to convert usd to this year's value")
fs.BoolVar(&config.NoPercent, "no-percent", false, "compute percent")
if err := fs.Parse(os.Args[1:]); err != nil {
panic(err)
}
files := config.Files.Strings()
if len(files) == 0 {
panic("must specify at least one file")
}
ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...)
if err != nil {
panic(err)
}
positional := fs.Args()
if len(positional) == 0 || len(positional[0]) < 3 {
panic("positional arguments required, ie bal|reg PATTERN MATCHING")
}
cmd := positional[0]
q, err := BuildQuery(config, positional[1:])
if err != nil {
panic(err)
}
deltas, err := ledgerFiles.Deltas()
if err != nil {
panic(err)
}
if period := config.Query.Period; !period.Empty() {
after := period.Start.Format("2006-01-02")
before := period.Stop.Format("2006-01-02")
deltas = deltas.Like(
ledger.LikeAfter(after),
ledger.LikeBefore(before),
)
}
pattern := ".*"
if depth := config.Query.Depth; depth > 0 {
patterns := []string{}
pattern = ""
for i := 0; i < depth; i++ {
pattern += "[^:]*:"
patterns = append([]string{strings.Trim(pattern, ":")}, patterns...)
}
pattern = strings.Join(patterns, "|")
}
group := ledger.GroupName(pattern)
bpis := make(ledger.BPIs)
if config.BPI != "" {
b, err := ledger.NewBPIs(config.BPI)
if err != nil {
panic(err)
}
bpis = b
}
cpiNormalizer := ana.NewNormalizer()
if config.CPI != "" && config.CPIYear > 0 {
c, err := ledger.NewBPIs(config.CPI)
if err != nil {
panic(err)
}
cpi := c["CPI"]
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
if cpiy == nil {
panic(fmt.Errorf("no cpi for year %d", config.CPIYear))
}
for date, value := range cpi {
cpiNormalizer = cpiNormalizer.With(".*", date, value/(*cpiy))
}
}
if config.Query.Normalize {
deltas = ana.NewDefaultNormalizer().Normalize(deltas)
}
if config.Query.With != "" {
deltas = deltas.Like(ledger.LikeWith(config.Query.With))
}
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
maxAccW := 0
for _, delta := range deltas.Like(q).Group(group) {
if accW := len(delta.Name); accW > maxAccW {
maxAccW = accW
}
}
switch cmd[:3] {
case "bal": // balances
balances := deltas.Group(ledger.GroupDate(config.GroupDate)).Balances().
WithBPIs(bpis).
KindaLike(q).
KindaGroup(group).
Nonzero().
Normalize(cpiNormalizer, "9")
cumulatives := make(ledger.Balances)
cumulativesFormat := "%s%.2f"
if !config.NoPercent {
var sum float64
for key := range balances {
if _, ok := cumulatives[key]; !ok {
cumulatives[key] = make(ledger.Balance)
}
for currency, val := range balances[key] {
if currency == ledger.USD {
cumulatives[key][currency] = val
sum += val
} else {
cumulatives[key][currency] = 0
}
}
}
for key := range cumulatives {
cumulatives[key][ledger.USD] = 100 * cumulatives[key][ledger.USD] / sum
}
cumulativesFormat = "%.0f%%"
}
FPrintBalances(w, "", balances, cumulatives, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW, cumulativesFormat)
case "gra": // graph
dateGrouping := "^[0-9]{4}-[0-9]{2}"
if period := config.Query.Period; !period.Empty() {
day := time.Hour * 24
year := day * 365
r := period.Stop.Sub(period.Start)
if r > 10*year {
dateGrouping = "^[0-9]{4}"
} else if r > 5*year {
} else if r > year {
dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]"
} else {
dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]{2}"
}
}
deltas = deltas.Group(ledger.GroupDate(dateGrouping))
transactions := deltas.Transactions()
cumulative := make(ledger.Balances)
data := map[string][]float64{}
pushData := func() {
soFar := 0
for _, v := range data {
soFar = len(v)
}
for k, balance := range cumulative {
for curr, v := range balance {
if curr != ledger.USD {
continue
}
if _, ok := data[k]; !ok {
data[k] = make([]float64, soFar)
}
data[k] = append(data[k], v)
}
}
}
for i, transaction := range transactions {
if i > 0 && transactions[i-1][0].Date != transaction[0].Date {
pushData()
}
balances := ledger.Deltas(transaction).
Like(q).
Group(group).
Balances().
WithBPIsAt(bpis, transaction[0].Date).
Nonzero().
Normalize(cpiNormalizer, transaction[0].Date)
cumulative.PushAll(balances)
}
pushData()
labels := []string{}
for k := range data {
labels = append(labels, k)
}
slices.Sort(labels)
points := [][]float64{}
for _, k := range labels {
points = append(points, data[k])
}
for i := range points {
for j := range points[i] {
points[i][j] /= 1000.0
}
}
options := []asciigraph.Option{asciigraph.Precision(0)}
if _, h, err := terminal.GetSize(0); err == nil && h > 4 {
options = append(options, asciigraph.Height(h-4))
}
if len(labels) < 256 {
seriesColors := make([]asciigraph.AnsiColor, len(labels))
for i := range seriesColors {
seriesColors[i] = asciigraph.AnsiColor(i)
}
options = append(options, asciigraph.SeriesLegends(labels...))
options = append(options, asciigraph.SeriesColors(seriesColors...))
}
fmt.Println(asciigraph.PlotMany(points, options...))
case "rec": // reconcile via teller // DEAD
panic("dead and bad")
byDate := map[string]ledger.Deltas{}
for _, delta := range deltas {
delta := delta
byDate[delta.Date] = append(byDate[delta.Date], delta)
}
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer can()
teller, err := teller.New()
if err != nil {
panic(err)
}
client := cache.New(teller)
accounts, err := client.Accounts(ctx)
if err != nil {
panic(err)
}
inDay := func(date string, transaction bank.Transaction) bool {
return slices.ContainsFunc(byDate[date], func(d ledger.Delta) bool {
v := fmt.Sprintf("%.2f", d.Value)
nv := fmt.Sprintf("%.2f", -1.0*d.Value)
a := fmt.Sprintf("%.2f", transaction.Amount)
return v == a || nv == a
})
}
for _, acc := range accounts {
transactions, err := client.Transactions(ctx, acc)
if err != nil {
panic(err)
}
for _, transaction := range transactions {
if inDay(transaction.Date, transaction) {
continue
}
msg := "missing"
ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local)
if err != nil {
panic(err)
}
dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02")
dayAfter := ts.Add(24 * time.Hour).Format("2006-01-02")
if inDay(dayBefore, transaction) || inDay(dayAfter, transaction) {
msg = "1dayoff"
}
prefix := " "
if transaction.Status != "posted" {
prefix = "! "
}
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", msg, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
}
}
case "reg": // reg
deltas = deltas.Group(ledger.GroupDate(config.GroupDate))
transactions := deltas.Transactions()
cumulative := make(ledger.Balances)
for _, transaction := range transactions {
balances := ledger.Deltas(transaction).Like(q).Group(group).Balances().WithBPIsAt(bpis, transaction[0].Date).Nonzero().Normalize(cpiNormalizer, transaction[0].Date)
shouldPrint := false
shouldPrint = shouldPrint || len(balances) > 2
if config.Query.NoExchanging {
shouldPrint = shouldPrint || len(balances) > 1
for _, v := range balances {
shouldPrint = shouldPrint || len(v) == 1
}
} else {
shouldPrint = shouldPrint || len(balances) > 0
}
if shouldPrint {
cumulative.PushAll(balances)
cumulative = cumulative.Nonzero()
FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW, "%s%.2f")
}
}
default:
panic("unknown command " + positional[0])
}
}
func FPrintBalancesFor(description string, w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, compact bool, keyW int, cumulativeFormat string) {
if compact {
FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
} else {
fmt.Fprintf(w, "%s\t%s\n", date, description)
FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
}
}
func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int, cumulativeFormat string) {
maxes := map[ledger.Currency]float64{}
keys := []string{}
for k, v := range balances {
keys = append(keys, k)
for k2, v2 := range v {
if math.Abs(v2) > math.Abs(maxes[k2]) {
maxes[k2] = math.Abs(v2)
}
}
}
slices.Sort(keys)
normalizer := ana.NewDefaultNormalizer()
cumulativeFormat = strings.ReplaceAll(cumulativeFormat, "%", "%%")
format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f ("+cumulativeFormat+")\n", linePrefix, max)
if normalized {
format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f ("+cumulativeFormat+")))\n", linePrefix, max)
}
for i, key := range keys {
printableKey := key
if fullKey {
} else if i == 0 {
} else {
commonPrefixLen := func() int {
j := 0
n := len(keys[i])
if n2 := len(keys[i-1]); n2 < n {
n = n2
}
for j = 0; j < n; j++ {
if keys[i-1][j] != keys[i][j] {
break
}
}
for keys[i][j] != ':' && j > 0 {
j -= 1
}
return j
}()
printableKey = strings.Repeat(" ", commonPrefixLen) + keys[i][commonPrefixLen:]
}
currencies := []ledger.Currency{}
for currency := range balances[key] {
currencies = append(currencies, currency)
}
slices.Sort(currencies)
for _, currency := range currencies {
printableCurrency := currency
format := format
if printableCurrency != "$" {
printableCurrency += " "
format = strings.ReplaceAll(format, "%.2f", "%.3f")
}
if usdOnly && printableCurrency != "$" {
continue
}
cumulative := balances[key][currency]
if balance, ok := cumulatives[key]; !ok {
} else if value, ok := balance[currency]; !ok {
} else {
cumulative = value
}
printingPercents := strings.Contains(cumulativeFormat, "%%%%")
if !printingPercents {
if !normalized {
fmt.Fprintf(w, format,
printableKey,
printableCurrency, balances[key][currency],
printableCurrency, cumulative,
)
} else {
factor := normalizer.NormalizeFactor(key, date)
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, printableCurrency, factor*trailingMax)
}
} else {
if !normalized {
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], cumulative)
} else {
factor := normalizer.NormalizeFactor(key, date)
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, factor*trailingMax)
}
}
}
}
}

100
cmd/cli/query.go Normal file
View File

@@ -0,0 +1,100 @@
package cli
import (
"fmt"
"log"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Query struct{}
func BuildQuery(config Config, args args) (ledger.Like, error) {
var result ledger.Like
var err error
func() {
defer func() {
if err := recover(); err != nil {
err = fmt.Errorf("panicked: %v", err)
}
}()
result, err = buildQuery(config, args)
}()
return result, err
}
func buildQuery(config Config, args args) (ledger.Like, error) {
likeName := func(s string) ledger.Like {
return ledger.LikeName(s)
}
andLike := func(a, b ledger.Like) ledger.Like {
return (ledger.Likes{a, b}).All
}
orLike := func(a, b ledger.Like) ledger.Like {
return (ledger.Likes{a, b}).Any
}
notLike := func(a ledger.Like) ledger.Like {
return ledger.LikeNot(a)
}
if !args.more() {
return likeName(""), nil
}
like := likeName("ajskfdlsjfkdasj")
for args.more() {
switch args.peek() {
case "and":
args.pop()
var and ledger.Like
switch args.peek() {
case "not":
args.pop()
log.Println("and not", args.peek())
and = notLike(likeName(args.pop()))
default:
log.Println("and ", args.peek())
and = likeName(args.pop())
}
like = andLike(like, and)
case "not":
args.pop()
log.Println("or not ", args.peek())
like = orLike(like, notLike(
likeName(args.pop()),
))
default:
log.Println("or ", args.peek())
like = orLike(like,
likeName(args.pop()),
)
}
}
return like, nil
}
type args []string
func (args *args) pop() string {
if !args.more() {
panic("expected more arguments")
}
first := ""
if len(*args) > 0 {
first = (*args)[0]
*args = (*args)[1:]
}
return first
}
func (args *args) peek() string {
if len(*args) == 0 {
return ""
}
return (*args)[0]
}
func (args *args) more() bool {
return len(*args) > 0
}

12
cmd/cli/run.sh Normal file
View File

@@ -0,0 +1,12 @@
#! /bin/bash
cmd="bal"
case "$1" in
bal|reg|gra|graph|rec )
cmd="$1"
shift
;;
esac
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go run ../ cli "$@" $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*.txt) "$cmd" :AssetAccount:

1
cmd/http/bpi.dat Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../Sync/Core/ledger/bpi.dat

View File

@@ -1,12 +1,13 @@
#! /bin/bash #! /bin/bash
cd "$(dirname "$(realpath "$BASH_SOURCE")")" cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go run . -http=:8081 \ cd ..
go run . http -http=:8081 \
-foo reg \ -foo reg \
-like-after 1023-08 \ -like-after 1023-08 \
-group-date ^....-.. \ -group-date ^....-.. \
-group-name '^[^:]*:[^:]*' \ -group-name '^[^:]*:[^:]*' \
-like-name '(AssetAccount|Retirement)' \ -like-name '(AssetAccount|Retirement)' \
-bpi ./bpi.dat \ -bpi ./http/bpi.dat \
"$@" \ "$@" \
macro.d/* ./http/macro.d/

1
cmd/http/ledger.dat Symbolic link
View File

@@ -0,0 +1 @@
moolah.dat.real

1
cmd/http/macro.d Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../Sync/Core/ledger/eras/2022-/

46
cmd/http/main.go Normal file
View File

@@ -0,0 +1,46 @@
package http
import (
"flag"
"fmt"
"log"
"net/http"
_ "embed"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func Main() {
likeName := flag.String("like-name", ".", "regexp to match")
likeBefore := flag.String("like-before", "9", "date str to compare")
likeAfter := flag.String("like-after", "0", "date str to compare")
groupName := flag.String("group-name", ".*", "grouping to apply to names")
groupDate := flag.String("group-date", ".*", "grouping to apply to dates")
bpiPath := flag.String("bpi", "/dev/null", "bpi file")
httpOutput := flag.String("http", ":8080", "http output listen address, like :8080")
flag.Parse()
if flag.NArg() < 1 {
panic(fmt.Errorf("positional arguments for files required"))
}
f, err := ledger.NewFiles(flag.Args()[0], flag.Args()[1:]...)
if err != nil {
panic(err)
}
r := NewRouter(
f,
*likeName,
*likeBefore,
*likeAfter,
*groupName,
*groupDate,
*bpiPath,
)
log.Println("listening on", *httpOutput)
if err := http.ListenAndServe(*httpOutput, r); err != nil {
panic(err)
}
}

3090
cmd/http/moolah.dat.real Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -29,9 +29,15 @@
console.log(path, query); console.log(path, query);
document.getElementById("graph").src = window.origin + path + query document.getElementById("graph").src = window.origin + path + query
} }
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
document.getElementsByName("zoomStart")[0].value = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
}
</script> </script>
</header> </header>
<body style="height: 100%;"> <body style="height: 100%;" onload="init();">
<div id="grapher" style="width: 100%; height: 100%; display: flex; flex-direction: column;"> <div id="grapher" style="width: 100%; height: 100%; display: flex; flex-direction: column;">
<form onsubmit="draw(this); return false;" style="display: flex; flex-direction: row; flex-wrap: wrap; gap: 1em;"> <form onsubmit="draw(this); return false;" style="display: flex; flex-direction: row; flex-wrap: wrap; gap: 1em;">
<span> <span>
@@ -41,7 +47,7 @@
<span> <span>
<label for="likeName">likeName</label> <label for="likeName">likeName</label>
<input name="likeName" type="text" value="AssetAccount"/> <input name="likeName" type="text" value="Bel:AssetAccount"/>
</span> </span>
<span> <span>
@@ -61,12 +67,12 @@
<span> <span>
<label for="zoomStart">zoomStart</label> <label for="zoomStart">zoomStart</label>
<input name="zoomStart" type="text" value="2023-06"/> <input name="zoomStart" type="text" value="YYYY-MM"/>
</span> </span>
<span> <span>
<label for="prediction">prediction</label> <label for="prediction">prediction</label>
<input name="prediction" type="text" value="interest=AssetAccount:Cash \$ 0.02&prediction=contributions=AssetAccount:Bonds $ 1875&prediction=interest=AssetAccount:Monthly \$ 0.03&prediction=contributions=AssetAccount:Monthly $ 2500"/> <input name="prediction" type="text" value="interest=Bel:AssetAccount:Cash \$ 0.02&prediction=contributions=Bel:AssetAccount:Bonds $ 1916&prediction=interest=Bel:AssetAccount:Monthly \$ 0.03&prediction=contributions=Bel:AssetAccount:Monthly $ 3500"/>
</span> </span>
<span> <span>
@@ -76,7 +82,7 @@
<span> <span>
<label for="whatIf">whatIf</label> <label for="whatIf">whatIf</label>
<input name="whatIf" type="text" value="AssetAccount:Cash $ -.10000"/> <input name="whatIf" type="text" value="Bel:AssetAccount:Cash $ -.10000"/>
</span> </span>
<span> <span>

View File

@@ -0,0 +1,28 @@
<html style="height: calc(100% - 4em);">
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script>
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
const replacement = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
var anchors = document.getElementsByTagName("a")
for (var i = 0; i < anchors.length; i++)
anchors[i].href = anchors[i].href.replace("YYYY-MM", replacement)
}
</script>
</header>
<body style="height: 100%;" onload="init();">
<h1>Moolah2 Hub</h1>
<ul style="line-height: 3em;">
<li><a href="/transactions.html">Transactions on Shared Chase</a></li>
<li><a href="/api/trends">Where does the house money go?</a></li>
<li><a href="/explore.html">Explore Bel's Money</a></li>
<li><a href="/api/bal?x=y&mode=bal&likeName=AssetAccount&chart=stack&predictionMonths=120&bpi=true&zoomStart=YYYY-MM&prediction=interest=AssetAccount:Cash%20\$%200.02&prediction=contributions=AssetAccount:Bonds%20$%201875&prediction=interest=AssetAccount:Monthly%20\$%200.03&prediction=contributions=AssetAccount:Monthly%20$%202500&predictFixedGrowth=VBTLX=0.02&predictFixedGrowth=GLD=0.02&predictFixedGrowth=FXAIX=0.03&predictFixedGrowth=FSPSX=0.03&whatIf=AssetAccount:Cash%20$%20-.10000&=">Project Bel's Net Worth</a></li>
<!--<li><a href="/api/reg?x=y&mode=reg&likeName=Bel:Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=YYYY-MM&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>-->
</ul>
</body>
<footer>
</footer>
</html>

View File

@@ -0,0 +1,264 @@
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<style>
input[type="text"] {
border: 1px solid black;
}
.amended {
border: 2px solid red;
}
.revised {
border: 2px solid green;
}
</style>
<script>
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest()
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
}
xmlhttp.open(method, remote, true)
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body)
}
function callback(responseBody, responseStatus) {
}
//var f = String(window.location).split("/transactions.html")[1]
//if (!f) {
// f = "/ledger.dat"
//}
//f = "." + f
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
const replacement = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
var iframe = document.getElementsByTagName("iframe")[0]
iframe.src = iframe.src.replace("YYYY-MM", replacement)
load()
}
function load(callback) {
http("GET", "/api/transactions", (body, status) => {
var d = JSON.parse(body)
console.log("loading", d)
loadNormalized(d.normalized)
loadBalances(d.balances)
loadTransactions(d.transactions)
if (callback != null) {
callback()
}
})
}
function loadNormalized(normalized) {
console.log("loading normalized", normalized)
var result = `<table>`
for (var k in normalized) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(normalized[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("norm").innerHTML = result
}
function loadBalances(balances) {
console.log("loading balances", balances)
var result = `<table>`
for (var k in balances) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(balances[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("bal").innerHTML = result
}
function loadTransactions(transactions) {
transactions.reverse()
console.log(transactions[0])
var result = `<table>`
for (var t of transactions) {
result += `<tr name="transaction-${t[0].Date}-${t[0].Description}" transaction='${btoa(JSON.stringify(t))}' style="cursor: crosshair;" onclick="stageEdit(this); return false;" class="${t[0].Transaction.includes("inbox") ? "revised" : ""}">`
result += ` <td style="width: 6em;">${t[0].Date}</td>`
result += ` <td style="width: 10em;">${t[0].Description}</td>`
result += ` <td><table style="margin: 0;">`
for (var delta of t) {
result += ` <tr>`
result += ` <td><span style="font-variant: petite-caps;">${delta.Name.split(":")[0]}</span><span style="opacity: 0.6;"> :${delta.Name.split(":").slice(1, 100).join(":")}</span></td>`
result += ` <td style="text-align: right; width: 2em;">${delta.Payee ? delta.Currency : ""}</td>`
result += ` <td style="text-align: right; width: 5em;">${delta.Payee ? delta.Value : ""}</td>`
result += ` </tr>`
}
result += ` </table></td>`
result += `</tr>`
}
result += `</table>`
document.getElementById("reg").innerHTML = result
}
function stageEdit(row) {
const xactionJSON = atob(row.attributes.transaction.value)
const xaction = JSON.parse(xactionJSON)
console.log(xaction)
row.attributes.onclick = ""
row.onclick = ""
row.style = ""
console.log(row)
var result = `<td colspan="3">`
result += `<form method="modal">`
result += ` <div>`
result += ` <label for="date">Date</label>`
result += ` <input type="text" name="date" value='${xaction[0].Date}' style="width: 100%;"/>`
result += ` </div>`
result += ` <div>`
result += ` <label for="description">Description</label>`
result += ` <input type="text" name="description" value='${xaction[0].Description}' style="width: 100%;"/>`
result += ` </div>`
for (var i in xaction) {
var delta = xaction[i]
result += `<hr>`
result += `<div style="display: flex; flex-direction: row; width: 100%; margin-top: 1em;">`
result += ` <div style="flex-grow: 1; margin-left: 1em; margin-right: 1em;">`
result += ` <label for="name${i}">Name${i}</label>`
result += ` <input type="text" name="name${i}" value='${delta.Name}' style="width: 100%;"/>`
result += ` </div>`
result += ` <div style="margin-left: 1em; margin-right: 1em;">`
result += ` <label for="value${i}">Value${i}</label>`
result += ` <input type="text" name="value${i}" value='${delta.Value}' style="width: 100%;"/>`
result += ` </div>`
result += ` <input type="button" name="submit${i}" value="Submit${i}" onclick="submitEdit(this); return false;" delta="${btoa(JSON.stringify(delta))}"/>`
result += `</div>`
}
result += `</form`
result += `</td>`
row.innerHTML = result
}
function submitEdit(btn) {
const old = JSON.parse(atob(btn.attributes.delta.value))
const now = JSON.parse(atob(btn.attributes.delta.value))
const idx = btn.name.split("submit")[1]
const form = btn.parentElement.parentElement
now.Value = parseFloat(form[`value${idx}`].value)
now.Name = form[`name${idx}`].value
now.Description = form[`description`].value
now.Date = form[`date`].value
var different = false
for(var k in old) {
different = different || old[k] != now[k]
}
if (!different) {
return
}
http("PUT", "/api/amend", (body, status) => {
if (status != 200)
throw(`Unexpected status ${status}: ${body}`)
load(() => {
/*
var elements = document.getElementsByName(`transaction-${old.Date}-${old.Description}`)
for (var ele of elements)
ele.className = "amended"
var elements = document.getElementsByName(`transaction-${now.Date}-${now.Description}`)
for (var ele of elements)
ele.className = "revised"
*/
})
}, JSON.stringify({
old: old,
now: now,
}))
}
function create() {
http("POST", "/api/create", (body, status) => {
if (status != 200)
throw(`Unexpected status ${status}: ${body}`)
loadTransactions([])
load()
}, JSON.stringify({}))
}
function stage(who, contributesToHouse) {
var d = new Date()
const zeroPad = (num, places) => String(num).padStart(places, '0')
const today = `${d.getFullYear()}-${zeroPad(1+d.getMonth(), 2)}-${zeroPad(d.getDate(), 2)}`
var who_currency = who.split(":").at(-1).toUpperCase()
var benefactor = who
var benefactor_value = `-0.00 ${who_currency}`
var beneficiary = "AssetAccount:Chase:5876"
var beneficiary_value = `$0.00`
if (!contributesToHouse) {
beneficiary_value = `$-0.00`
benefactor_value = `0.00 ${who_currency}`
}
console.log(`@${today} ${benefactor} gave to ${beneficiary}`)
}
</script>
</header>
<body onload="init();" style="">
<h2>Moolah2</h2>
<details open>
<summary>Normalized</summary>
<div id="norm">
</details>
<details>
<summary>Balance</summary>
<div id="bal">
</div>
<details>
<summary><i>Look at this graph</i></summary>
<iframe style="background: white; width: 100%; resize: both;" src="/api/reg?x=y&mode=reg&likeName=Withdrawal:[0123]&chart=stack&predictionMonths=6&prediction=autoContributions=&bpi=true&zoomStart=YYYY-MM"></iframe>
</details>
<details>
<summary><i>Where did the money go</i></summary>
<iframe style="background: white; width: 100%; resize: both;" src="/api/trends"></iframe>
</details>
</details>
<details open style="display: none;">
<summary>Edit</summary>
<div style="display:flex; flex-direction:row; width:100%; justify-content:space-between;">
<div>
<input type="button" onclick="stage('AssetAccount:Zach', true)" value="Stage Zach's Payment"/>
<input type="button" onclick="stage('AssetAccount:Zach', false)" value="Stage Zach's Charge"/>
</div>
<div>
<input type="button" onclick="stage('AssetAccount:Bel', true)" value="Stage Bel's Payment"/>
<input type="button" onclick="stage('AssetAccount:Bel', false)" value="Stage Bel's Charge"/>
</div>
</div>
</details>
<details open>
<summary>Register</summary>
<form action="#" onsubmit="create(); return false;">
<button style="width: 100%">
CREATE
</button>
</form>
<div id="reg">
</div>
</details>
</body>
<footer>
</footer>
</html>

485
cmd/http/router.go Normal file
View File

@@ -0,0 +1,485 @@
package http
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"math"
"net/http"
"os"
"slices"
"strconv"
"strings"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/view"
)
//go:embed public/*
var _staticFileDir embed.FS
type Router struct {
files ledger.Files
like struct {
name string
before string
after string
}
group struct {
name string
date string
}
bpiPath string
}
func NewRouter(files ledger.Files, likeName, likeBefore, likeAfter string, groupName, groupDate string, bpiPath string) Router {
r := Router{}
r.files = files
r.like.name = likeName
r.like.before = likeBefore
r.like.after = likeAfter
r.group.name = groupName
r.group.date = groupDate
r.bpiPath = bpiPath
return r
}
func (router Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch strings.Split(r.URL.Path, "/")[1] {
case "api":
router.API(w, r)
default:
router.FS(w, r)
}
}
func (router Router) FS(w http.ResponseWriter, r *http.Request) {
if os.Getenv("DEBUG") != "" {
http.FileServer(http.Dir("./http/public")).ServeHTTP(w, r)
} else {
sub, err := fs.Sub(_staticFileDir, "public")
if err != nil {
panic(err)
}
http.FileServer(http.FS(sub)).ServeHTTP(w, r)
}
}
func (router Router) API(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/transactions":
router.APITransactions(w, r)
case "/api/amend":
router.APIAmend(w, r)
case "/api/create":
router.APICreate(w, r)
case "/api/trends":
router.APITrends(w, r)
case "/api/reg", "/api/bal":
router.APIReg(w, r)
default:
http.NotFound(w, r)
}
}
func (router Router) APITransactions(w http.ResponseWriter, r *http.Request) {
bpis, err := router.bpis()
if err != nil {
panic(err)
}
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
houseRelatedDeltas := deltas.Like(ledger.LikeTransactions(
deltas.Like(ledger.LikeName(`^House`))...,
))
recent := time.Hour * 24 * 365 / 6
normalizer := ana.NewDefaultNormalizer()
{
deltas := houseRelatedDeltas.
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01")))
balances := houseRelatedDeltas.Balances().
Like(`^(Zach|Bel|House[^:]*:Debts:)`).
Group(`^[^:]*`).
WithBPIs(bpis)
transactions := houseRelatedDeltas.
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))).
Transactions()
normalized := normalizer.Normalize(houseRelatedDeltas).Balances().
Like(`^(Zach|Bel):`).
Group(`^[^:]*:`).
WithBPIs(bpis)
var biggest float64
for _, v := range normalized {
if v := math.Abs(v["$"]); v > biggest {
biggest = v
}
}
ks := []string{}
for k := range normalized {
ks = append(ks, k)
}
for _, k := range ks {
v := normalized[k]
if v := math.Abs(v["$"]); v < biggest {
normalizedDelta := biggest - v
normalizedFactor := normalizer.NormalizeFactor(k, time.Now().Format("2006-01-02"))
normalized[fmt.Sprintf(`(%s trailing $)`, k)] = ledger.Balance{"$": normalizedDelta * normalizedFactor}
}
}
json.NewEncoder(w).Encode(map[string]any{
"deltas": deltas,
"balances": balances,
"normalized": normalized,
"transactions": transactions,
})
}
}
func (router Router) APITrends(w http.ResponseWriter, r *http.Request) {
bpis, err := router.bpis()
if err != nil {
panic(err)
}
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
recent := time.Hour * 24 * 365 / 2
pie := func(title, groupName string, min int) {
recentHouseRelatedDeltas := deltas.
Like(ledger.LikeTransactions(
deltas.Like(ledger.LikeName(`^House`))...,
)).
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))).
Group(ledger.GroupName(groupName)).
Group(ledger.GroupDate(`^[0-9]*-[0-9]*`)).
Like(ledger.LikeNotName(`^$`))
monthsToDeltas := map[string]ledger.Deltas{}
for _, delta := range recentHouseRelatedDeltas {
monthsToDeltas[delta.Date] = append(monthsToDeltas[delta.Date], delta)
}
months := []string{}
for k := range monthsToDeltas {
months = append(months, k)
}
slices.Sort(months)
catToMonth := map[string][]int{}
for _, month := range months {
balances := monthsToDeltas[month].Balances().WithBPIs(bpis)
for category, balance := range balances {
catToMonth[category] = append(catToMonth[category], int(balance[ledger.USD]))
}
}
chart := view.NewChart("pie")
ttl := 0
for cat, month := range catToMonth {
for i := 0; i < len(months)-len(month); i++ {
month = append(month, 0)
}
slices.Sort(month)
median := month[len(month)/2]
ttl += median
if median > min {
chart.AddY(cat, []int{median})
}
}
fmt.Fprintln(w, "<!DOCTYPE html><h2>", title, "($", ttl, ")</h2>")
if err := chart.Render(w); err != nil {
panic(err)
}
}
pie(fmt.Sprintf("Median Monthly Spending Since %dmo ago", int(recent/time.Hour/24/30)), `Withdrawal:[0-9]*`, 50)
pie("Median Monthly Spending (detailed)", `Withdrawal:[0-9]*:[^:]*`, 25)
pie("Median Monthly Spending (MORE detailed)", `Withdrawal:[0-9]*:[^:]*:[^:]*`, 10)
}
func (router Router) APICreate(w http.ResponseWriter, r *http.Request) {
new := ledger.Delta{
Name: "TODO",
Date: time.Now().Format(`2006-01-02`),
Description: "TODO",
Currency: "$",
Value: 0.01,
}
if err := router.files.Add("HouseyMcHouseface:Withdrawal:0:TODO", new); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
func (router Router) APIAmend(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var req struct {
Old ledger.Delta
Now ledger.Delta
}
if err := json.Unmarshal(b, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Now.Name = strings.ReplaceAll(req.Now.Name, " ", "_")
if err := router.files.Amend(req.Old, req.Now); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(router.group.name), ledger.GroupDate(router.group.date))
like := ledger.Likes{
ledger.LikeName(router.like.name),
ledger.LikeBefore(router.like.before),
ledger.LikeAfter(router.like.after),
}
foolike := make(ledger.Likes, 0)
for _, v := range r.URL.Query()["likeName"] {
foolike = append(foolike, ledger.LikeName(v))
}
for _, v := range r.URL.Query()["likeAfter"] {
foolike = append(foolike, ledger.LikeAfter(v))
}
for _, v := range r.URL.Query()["likeBefore"] {
foolike = append(foolike, ledger.LikeBefore(v))
}
if len(foolike) == 0 {
foolike = like
}
deltas = deltas.Like(foolike...)
// MODIFIERS
for i, whatIf := range r.URL.Query()["whatIf"] {
fields := strings.Fields(whatIf)
date := "2001-01"
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
deltas = append(deltas, ledger.Delta{
Date: date,
Name: name,
Value: value,
Currency: currency,
Description: fmt.Sprintf("?whatIf[%d]", i),
})
}
register := deltas.Register()
predicted := make(ledger.Register)
bpis, err := router.bpis()
if err != nil {
panic(err)
}
if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 {
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
// TODO whatif
prediction := make(ana.Prediction, 0)
for _, spec := range r.URL.Query()["prediction"] {
idx := strings.Index(spec, "=")
k := spec[:idx]
fields := strings.Fields(spec[idx+1:])
switch k {
case "interest":
apy, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewInterestPredictor(fields[0], fields[1], apy))
case "autoContributions":
prediction = append(prediction, ana.NewAutoContributionPredictor(register))
case "contributions":
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewContributionPredictor(ledger.Balances{name: ledger.Balance{currency: value}}))
default:
panic(k)
}
}
predicted = prediction.Predict(register, window)
for _, currencyRate := range r.URL.Query()["predictFixedGrowth"] {
currency := strings.Split(currencyRate, "=")[0]
rate, err := strconv.ParseFloat(strings.Split(currencyRate, "=")[1], 64)
if err != nil {
panic(err)
}
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
if err != nil {
panic(err)
}
}
}
if r.URL.Query().Get("bpi") == "true" {
register = register.WithBPIs(bpis)
predicted = predicted.WithBPIs(bpis)
}
if zoomStart, err := time.ParseInLocation("2006-01", r.URL.Query().Get("zoomStart"), time.Local); err == nil {
register = register.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
predicted = predicted.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
}
// /MODIFIERS
dates := register.Dates()
names := register.Names()
for _, date := range predicted.Dates() {
found := false
for i := range dates {
found = found || dates[i] == date
}
if !found {
dates = append(dates, date)
}
}
for _, name := range predicted.Names() {
found := false
for i := range names {
found = found || names[i] == name
}
if !found {
names = append(names, name)
}
}
instant := map[string]string{}
toChart := func(cumulative bool, display string, reg ledger.Register) view.Chart {
nameCurrencyDateValue := map[string]map[ledger.Currency]map[string]float64{}
for date, balances := range reg {
for name, balance := range balances {
for currency, value := range balance {
if _, ok := nameCurrencyDateValue[name]; !ok {
nameCurrencyDateValue[name] = make(map[ledger.Currency]map[string]float64)
}
if _, ok := nameCurrencyDateValue[name][currency]; !ok {
nameCurrencyDateValue[name][currency] = make(map[string]float64)
}
nameCurrencyDateValue[name][currency][date] += value
}
}
}
chart := view.NewChart("line")
if v := display; v != "" {
chart = view.NewChart(v)
}
chart.AddX(dates)
if cumulative {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
lastValue = newLastValue
}
}
series[i] = int(lastValue)
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
} else {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var prevValue float64
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
prevValue = lastValue
lastValue = newLastValue
}
}
series[i] = int(lastValue - prevValue)
}
for i := range series { // TODO no prior so no delta
if series[i] != 0 {
series[i] = 0
break
}
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
}
return chart
}
primary := toChart(r.URL.Path == "/api/bal", r.URL.Query().Get("chart"), register)
if len(predicted) > 0 {
primary.Overlap(toChart(r.URL.Path == "/api/bal", "line", predicted))
}
if err := primary.Render(w); err != nil {
panic(err)
}
for k, v := range instant {
fmt.Fprintf(w, "<br>\n%s = %s", k, v)
}
}
func (r Router) bpis() (ledger.BPIs, error) {
if r.bpiPath == "" {
return make(ledger.BPIs), nil
}
return ledger.NewBPIs(r.bpiPath)
}

3
cmd/install.sh Normal file
View File

@@ -0,0 +1,3 @@
#! /bin/bash
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go build -o $GOPATH/bin/ana-ledger

1
cmd/install_scratch.sh Normal file
View File

@@ -0,0 +1 @@
CGO_ENABLED=1 CC=x86_64-linux-musl-gcc go build -ldflags="-linkmode external -extldflags -static" -o $HOME/Go/bin/ana-ledger

View File

@@ -1 +0,0 @@
../../../../../Sync/Core/ledger/eras/2022-

View File

@@ -1,473 +1,57 @@
package main package main
import ( import (
"encoding/json" "context"
"flag"
"fmt"
"io"
"log" "log"
"maps"
"net/http"
"os" "os"
"slices" "os/signal"
"sort"
"strconv"
"strings" "strings"
"time" "syscall"
"github.com/go-echarts/go-echarts/v2/charts" "gogs.inhome.blapointe.com/ana-ledger/cmd/cli"
"github.com/go-echarts/go-echarts/v2/opts" "gogs.inhome.blapointe.com/ana-ledger/cmd/http"
"gogs.inhome.blapointe.com/ana-ledger/src/ana" "gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
) )
func main() { func main() {
foo := flag.String("foo", "bal", "bal or reg") switch os.Args[1] {
likeName := flag.String("like-name", ".", "regexp to match") case "tel":
likeBefore := flag.String("like-before", "9", "date str to compare") ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
likeAfter := flag.String("like-after", "0", "date str to compare") defer can()
likeLedger := flag.Bool("like-ledger", false, "limit data to these -like-* rather than zoom to these -like-*")
groupName := flag.String("group-name", ".*", "grouping to apply to names")
groupDate := flag.String("group-date", ".*", "grouping to apply to dates")
bpiPath := flag.String("bpi", "/dev/null", "bpi file")
jsonOutput := flag.Bool("json", false, "json output")
httpOutput := flag.String("http", "", "http output listen address, like :8080")
flag.Parse()
if flag.NArg() < 1 { if c, err := teller.New(); err != nil {
panic(fmt.Errorf("positional arguments for files required")) } else if _, err := c.Accounts(ctx); err != nil {
}
f, err := ledger.NewFiles(flag.Args()[0], flag.Args()[1:]...)
if err != nil {
panic(err)
}
bpis := make(ledger.BPIs)
if *bpiPath != "" {
bpis, err = ledger.NewBPIs(*bpiPath)
if err != nil {
panic(err)
}
}
if *httpOutput != "" {
foo := func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api") {
http.FileServer(http.Dir("./public")).ServeHTTP(w, r)
return
}
switch r.URL.Path {
case "/api/transactions":
reqF := f
if queryF := r.URL.Query().Get("f"); queryF != "" {
reqF, err = ledger.NewFiles(queryF)
if err != nil {
panic(err)
}
}
deltas, err := reqF.Deltas()
if err != nil {
panic(err)
}
json.NewEncoder(w).Encode(map[string]any{
"deltas": deltas.Like(ledger.LikeAfter(time.Now().Add(-1 * time.Hour * 24 * 365 / 2).Format("2006-01"))),
"balances": deltas.Balances().Like("^AssetAccount:").WithBPIs(bpis),
})
return
}
deltas, err := f.Deltas()
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(*groupName), ledger.GroupDate(*groupDate))
like := ledger.Likes{
ledger.LikeName(*likeName),
ledger.LikeBefore(*likeBefore),
ledger.LikeAfter(*likeAfter),
}
foolike := make(ledger.Likes, 0)
for _, v := range r.URL.Query()["likeName"] {
foolike = append(foolike, ledger.LikeName(v))
}
for _, v := range r.URL.Query()["likeAfter"] {
foolike = append(foolike, ledger.LikeAfter(v))
}
for _, v := range r.URL.Query()["likeBefore"] {
foolike = append(foolike, ledger.LikeBefore(v))
}
if len(foolike) == 0 {
foolike = like
}
deltas = deltas.Like(foolike...)
// MODIFIERS
for i, whatIf := range r.URL.Query()["whatIf"] {
fields := strings.Fields(whatIf)
date := "2001-01"
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
deltas = append(deltas, ledger.Delta{
Date: date,
Name: name,
Value: value,
Currency: currency,
Description: fmt.Sprintf("?whatIf[%d]", i),
})
}
register := deltas.Register()
predicted := make(ledger.Register)
bpis := maps.Clone(bpis)
if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 {
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
// TODO whatif
prediction := make(ana.Prediction, 0)
for _, spec := range r.URL.Query()["prediction"] {
idx := strings.Index(spec, "=")
k := spec[:idx]
fields := strings.Fields(spec[idx+1:])
switch k {
case "interest":
apy, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewInterestPredictor(fields[0], fields[1], apy))
case "autoContributions":
prediction = append(prediction, ana.NewAutoContributionPredictor(register))
case "contributions":
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewContributionPredictor(ledger.Balances{name: ledger.Balance{currency: value}}))
default:
panic(k)
}
}
predicted = prediction.Predict(register, window)
for _, currencyRate := range r.URL.Query()["predictFixedGrowth"] {
currency := strings.Split(currencyRate, "=")[0]
rate, err := strconv.ParseFloat(strings.Split(currencyRate, "=")[1], 64)
if err != nil {
panic(err)
}
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
if err != nil {
panic(err)
}
}
}
if r.URL.Query().Get("bpi") == "true" {
register = register.WithBPIs(bpis)
predicted = predicted.WithBPIs(bpis)
}
if zoomStart, err := time.ParseInLocation("2006-01", r.URL.Query().Get("zoomStart"), time.Local); err == nil {
register = register.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
predicted = predicted.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
}
// /MODIFIERS
dates := register.Dates()
names := register.Names()
for _, date := range predicted.Dates() {
found := false
for i := range dates {
found = found || dates[i] == date
}
if !found {
dates = append(dates, date)
}
}
for _, name := range predicted.Names() {
found := false
for i := range names {
found = found || names[i] == name
}
if !found {
names = append(names, name)
}
}
instant := map[string]string{}
toChart := func(cumulative bool, display string, reg ledger.Register) Chart {
nameCurrencyDateValue := map[string]map[ledger.Currency]map[string]float64{}
for date, balances := range reg {
for name, balance := range balances {
for currency, value := range balance {
if _, ok := nameCurrencyDateValue[name]; !ok {
nameCurrencyDateValue[name] = make(map[ledger.Currency]map[string]float64)
}
if _, ok := nameCurrencyDateValue[name][currency]; !ok {
nameCurrencyDateValue[name][currency] = make(map[string]float64)
}
nameCurrencyDateValue[name][currency][date] += value
}
}
}
chart := NewChart("line")
if v := display; v != "" {
chart = NewChart(v)
}
chart.AddX(dates)
if cumulative {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
lastValue = newLastValue
}
}
series[i] = int(lastValue)
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else { } else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i]) log.Println("teller already init")
} }
}
if slices.Min(series) != 0 || slices.Max(series) != 0 { if err := teller.Init(ctx); err != nil {
chart.AddY(key, series)
}
}
}
} else {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var prevValue float64
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
prevValue = lastValue
lastValue = newLastValue
}
}
series[i] = int(lastValue - prevValue)
}
for i := range series { // TODO no prior so no delta
if series[i] != 0 {
series[i] = 0
break
}
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
}
return chart
}
primary := toChart(r.URL.Path == "/api/bal", r.URL.Query().Get("chart"), register)
if len(predicted) > 0 {
primary.Overlap(toChart(r.URL.Path == "/api/bal", "line", predicted))
}
if err := primary.Render(w); err != nil {
panic(err)
}
for k, v := range instant {
fmt.Fprintf(w, "<br>\n%s = %s", k, v)
}
}
log.Println("listening on", *httpOutput)
if err := http.ListenAndServe(*httpOutput, http.HandlerFunc(foo)); err != nil {
panic(err) panic(err)
} }
case "http":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
http.Main()
case "cli":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
cli.Main()
case "shared":
files := os.Args[2:]
os.Args = []string{os.Args[0], "cli"}
for _, f := range files {
if strings.HasPrefix(f, "-") {
os.Args = append(os.Args, f)
} else { } else {
deltas, err := f.Deltas() os.Args = append(os.Args, "-f", f)
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(*groupName), ledger.GroupDate(*groupDate))
like := ledger.Likes{ledger.LikeName(*likeName)}
if *likeLedger {
like = append(like, ledger.LikeBefore(*likeBefore))
like = append(like, ledger.LikeAfter(*likeAfter))
deltas = deltas.Like(like...)
} else {
deltas = deltas.Like(like...)
like = append(like, ledger.LikeBefore(*likeBefore))
like = append(like, ledger.LikeAfter(*likeAfter))
}
jsonResult := []any{}
switch *foo {
case "reg":
sort.Slice(deltas, func(i, j int) bool {
return deltas[i].Debug() < deltas[j].Debug()
})
register := deltas.Register()
for i := range deltas {
if like.All(deltas[i]) {
if !*jsonOutput {
fmt.Printf("%s (%+v)\n", deltas[i].Debug(), register[deltas[i].Date][deltas[i].Name].Debug())
} else {
jsonResult = append(jsonResult, map[string]any{
"name": deltas[i].Name,
"delta": deltas[i],
"balance": register[deltas[i].Date][deltas[i].Name],
})
} }
} }
} os.Args = append(os.Args,
case "bal": "-w=^Housey",
deltas = deltas.Like(like...) "--depth=1",
for k, v := range deltas.Balances() { "--usd",
results := []string{} "-n",
for subk, subv := range v { "--no-percent",
results = append(results, fmt.Sprintf("%s %.2f", subk, subv)) "bal", "^Bel", "^Zach",
} )
if len(results) > 0 { main()
if !*jsonOutput {
fmt.Printf("%s\t%s\n", k, strings.Join(results, " + "))
} else {
jsonResult = append(jsonResult, map[string]any{
"name": k,
"balance": v,
})
}
}
}
default:
panic(fmt.Errorf("not impl %q", *foo))
}
if *jsonOutput {
json.NewEncoder(os.Stdout).Encode(jsonResult)
}
} }
} }
type Chart interface {
AddX(interface{})
AddY(string, []int)
Render(io.Writer) error
Overlap(Chart)
}
func NewChart(name string) Chart {
switch name {
case "line":
return NewLine()
case "bar":
return NewBar()
case "stack":
return NewStack()
default:
panic("bad chart name " + name)
}
}
type Line struct {
*charts.Line
}
func NewLine() Line {
return Line{Line: charts.NewLine()}
}
func (line Line) AddX(v interface{}) {
line.SetXAxis(v)
}
func (line Line) AddY(name string, v []int) {
y := make([]opts.LineData, len(v))
for i := range y {
y[i].Value = v[i]
}
line.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackB",
}))
}
func (line Line) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
line.Line.Overlap(overlapper)
}
type Bar struct {
*charts.Bar
}
func NewBar() Bar {
return Bar{Bar: charts.NewBar()}
}
func (bar Bar) AddX(v interface{}) {
bar.SetXAxis(v)
}
func (bar Bar) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
bar.AddSeries(name, y)
}
func (bar Bar) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
bar.Bar.Overlap(overlapper)
}
type Stack struct {
Bar
}
func NewStack() Stack {
bar := NewBar()
bar.SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{Stack: "x"}))
return Stack{Bar: bar}
}
func (stack Stack) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
stack.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackA",
}))
}

View File

@@ -1 +0,0 @@
../../../../../Sync/Core/tmp/moolah.dat

View File

@@ -1,16 +0,0 @@
<html style="height: calc(100% - 4em);">
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
</header>
<body style="height: 100%;">
<h1>Moolah2 Hub</h1>
<ul style="line-height: 3em;">
<li><a href="/transactions.html">Transactions on Shared Chase</a></li>
<li><a href="/explore.html">Explore Bel's Money</a></li>
<li><a href="/api/bal?x=y&mode=bal&likeName=AssetAccount&chart=stack&predictionMonths=120&bpi=true&zoomStart=2023-06&prediction=interest=AssetAccount:Cash%20\$%200.02&prediction=contributions=AssetAccount:Bonds%20$%201875&prediction=interest=AssetAccount:Monthly%20\$%200.03&prediction=contributions=AssetAccount:Monthly%20$%202500&predictFixedGrowth=VBTLX=0.02&predictFixedGrowth=GLD=0.02&predictFixedGrowth=FXAIX=0.03&predictFixedGrowth=FSPSX=0.03&whatIf=AssetAccount:Cash%20$%20-.10000&=">Project Bel's Net Worth</a></li>
<li><a href="/api/reg?x=y&mode=reg&likeName=Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=2023-01&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>
</ul>
</body>
<footer>
</footer>
</html>

View File

@@ -1,85 +0,0 @@
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script>
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
function callback(responseBody, responseStatus) {
}
var f = String(window.location).split("/transactions.html")[1]
if (!f) {
f = "/moolah.dat"
}
f = "." + f
function init() {
load(f)
}
function load(f) {
http("GET", "/api/transactions?f="+f, (body, status) => {
var d = JSON.parse(body)
loadBalances(d.balances)
loadDeltas(d.deltas)
})
}
function loadBalances(balances) {
var result = `<table>`
for (var k in balances) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(balances[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("bal").innerHTML = result
}
function loadDeltas(deltas) {
console.log(deltas[0])
for (var i = 0; i < deltas.length/2; i++) {
tmp = deltas[i]
deltas[i] = deltas[deltas.length-1-i]
deltas[deltas.length-1-i] = tmp
}
console.log(deltas[0])
var result = `<table>`
for (var k of deltas) {
result += `<tr>`
result += ` <td>${k.Date}</td>`
result += ` <td>${k.Description}</td>`
result += ` <td>${k.Name}</td>`
result += ` <td style="text-align: right">${k.Currency}</td>`
result += ` <td style="text-align: right">${k.Value}</td>`
result += `</tr>`
}
result += `</table>`
document.getElementById("reg").innerHTML = result
}
</script>
</header>
<body onload="init();">
<h2>Moolah2</h2>
<details>
<summary>Balance</summary>
<div id="bal">
</div>
<details>
<summary><i>Look at this graph</i></summary>
<iframe style="background: white; width: 100%;" src="/api/reg?x=y&mode=reg&likeName=Withdrawal:[0123]&chart=stack&predictionMonths=6&prediction=autoContributions=&bpi=true&zoomStart=2023-01"></iframe>
</details>
</details>
<details open>
<summary>Register</summary>
<div id="reg">
</div>
</details>
</body>
<footer>
</footer>
</html>

16
go.mod
View File

@@ -1,5 +1,17 @@
module gogs.inhome.blapointe.com/ana-ledger module gogs.inhome.blapointe.com/ana-ledger
go 1.21.1 go 1.23.0
require github.com/go-echarts/go-echarts/v2 v2.3.1 toolchain go1.24.2
require (
github.com/go-echarts/go-echarts/v2 v2.3.1
github.com/guptarohit/asciigraph v0.7.3
golang.org/x/crypto v0.38.0
golang.org/x/time v0.11.0
)
require (
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
)

10
go.sum
View File

@@ -2,9 +2,19 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-echarts/go-echarts/v2 v2.3.1 h1:Yw0HVjVTxpYm48l974dMjRzx8ni2ql0kKi/kawSgxFE= github.com/go-echarts/go-echarts/v2 v2.3.1 h1:Yw0HVjVTxpYm48l974dMjRzx8ni2ql0kKi/kawSgxFE=
github.com/go-echarts/go-echarts/v2 v2.3.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI= github.com/go-echarts/go-echarts/v2 v2.3.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY=
github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

89
src/ana/normalize.go Normal file
View File

@@ -0,0 +1,89 @@
package ana
import (
"regexp"
"slices"
"strings"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Normalizer struct {
m map[string][]normalize
}
type normalize struct {
startDate string
factor float64
}
func NewDefaultNormalizer() Normalizer {
return NewNormalizer().
With("^Zach", "2025-09-01", 151). // turtle up
With("^Zach", "2023-10-05", 139). // to turtle
With("^Zach", "2021-12-30", 135). // at pluralsight
With("^Zach", "2020-07-30", 120). // to pluralsight
With("^Zach", "2019-07-16", 77). // at fedex
With("^Zach", "2017-02-16", 49). // to fedex
With("^Bel", "2025-10-01", 225). // render up
With("^Bel", "2025-04-01", 214). // lc4 at render
With("^Bel", "2023-12-05", 190). // to render
With("^Bel", "2022-12-31", 154). // at q
With("^Bel", "2022-06-30", 148). // at q
With("^Bel", "2021-12-31", 122). // at q
With("^Bel", "2020-12-31", 118). // at q
With("^Bel", "2019-12-31", 111). // at q
With("^Bel", "2018-12-31", 92). // at q
With("^Bel", "2018-02-16", 86) // to q
}
func NewNormalizer() Normalizer {
return Normalizer{
m: make(map[string][]normalize),
}
}
func (n Normalizer) With(pattern, startDate string, factor float64) Normalizer {
n.m[pattern] = append(n.m[pattern], normalize{startDate: startDate, factor: factor})
slices.SortFunc(n.m[pattern], func(a, b normalize) int {
return -1 * strings.Compare(a.startDate, b.startDate)
})
return n
}
func (n Normalizer) Normalize(deltas ledger.Deltas) ledger.Deltas {
deltas = slices.Clone(deltas)
patterns := []string{}
for pattern := range n.m {
patterns = append(patterns, pattern)
}
like := ledger.LikeName(strings.Join(patterns, "|"))
for i, delta := range deltas {
if !like(delta) {
continue
}
deltas[i] = n.NormalizeDelta(delta)
}
return deltas
}
func (n Normalizer) NormalizeDelta(delta ledger.Delta) ledger.Delta {
delta.Value /= n.NormalizeFactor(delta.Name, delta.Date)
return delta
}
func (n Normalizer) NormalizeFactor(name, date string) float64 {
for pattern := range n.m {
if regexp.MustCompile(pattern).MatchString(name) {
for _, normalize := range n.m[pattern] {
if normalize.startDate < date {
return normalize.factor
}
}
}
}
return 1.0
}

30
src/ana/normalize_test.go Normal file
View File

@@ -0,0 +1,30 @@
package ana
import (
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestNormalize(t *testing.T) {
deltas := ledger.Deltas{
ledger.Delta{Date: "2024-12-04", Value: 100, Name: "Bel:Withdrawal"},
ledger.Delta{Date: "2024-12-06", Value: 100, Name: "Bel:Withdrawal"},
}
normalizer := NewNormalizer().
With("^Bel:", "2024-12-05", 190_000).
With("^Bel:", "2018-02-16", 86_000)
normalized := normalizer.Normalize(deltas)
for i, delta := range normalized {
t.Logf("[%d] %+v", i, delta)
}
if normalized[0].Value != 100/float64(86_000) {
t.Errorf("earliest transaction didnt use earliest salary")
}
if normalized[1].Value != 100/float64(190_000) {
t.Errorf("latest transaction didnt use latest salary")
}
}

107
src/bank/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,107 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
)
type Client struct {
Client bank.Agg
}
var _ bank.Agg = Client{}
func New(client bank.Agg) Client {
return Client{Client: client}
}
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
k := "accounts"
result := []bank.Account{}
if err := fromCache(k, &result); err != nil {
log.Printf("%q not in cache: %v", k, err)
} else {
return result, nil
}
result, err := c.Client.Accounts(ctx)
if err != nil {
return nil, err
}
toCache(k, result)
return result, nil
}
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
k := path.Join("accounts.d", a.Account)
result := []bank.Transaction{}
if err := fromCache(k, &result); err != nil {
log.Printf("%q not in cache: %v", k, err)
} else {
return result, nil
}
result, err := c.Client.Transactions(ctx, a)
if err != nil {
return nil, err
}
toCache(k, result)
return result, nil
}
var (
d = path.Join("/tmp/ana_ledger_bank_cache.d")
)
func toCache(k string, v interface{}) {
if err := _toCache(k, v); err != nil {
log.Printf("failed to cache %s: %v", k, err)
}
}
func _toCache(k string, v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
p := path.Join(d, k)
os.MkdirAll(path.Dir(p), os.ModePerm)
if err := os.WriteFile(p, b, os.ModePerm); err != nil {
os.Remove(p)
return err
}
return nil
}
func fromCache(k string, ptr interface{}) error {
p := path.Join(d, k)
if stat, err := os.Stat(p); err != nil {
return err
} else if time.Since(stat.ModTime()) > 24*time.Hour {
return fmt.Errorf("stale")
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
if err := json.Unmarshal(b, ptr); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,50 @@
//go:build integration
package cache_test
import (
"context"
"strconv"
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func Test(t *testing.T) {
tellerC, err := teller.New()
if err != nil {
t.Fatal(err)
}
client := cache.New(tellerC)
ctx := context.Background()
for i := 0; i < 2; i++ {
i := i
client := client
t.Run(strconv.Itoa(i), func(t *testing.T) {
accounts, err := client.Accounts(ctx)
if err != nil {
t.Fatal(err)
}
for _, account := range accounts {
account := account
t.Run(account.Account, func(t *testing.T) {
transactions, err := client.Transactions(ctx, account)
if err != nil {
t.Fatal(err)
}
for i, tr := range transactions {
t.Logf("[%d] %+v", i, tr)
}
})
break
}
})
client.Client = nil
}
}

View File

@@ -0,0 +1 @@
app_pdvv33dtmta4fema66000

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIExjCCAq6gAwIBAgIIGEJSCPAVjIYwDQYJKoZIhvcNAQELBQAwYTELMAkGA1UE
BhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNVBAcMBkxvbmRvbjEPMA0GA1UE
CgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBwbGljYXRpb24gQ0EwHhcNMjUw
NTI0MDEyMzIzWhcNMjgwNTIzMDEyMzIzWjAkMSIwIAYDVQQDDBlhcHBfcGR2djMz
ZHRtdGE0ZmVtYTY2MDAwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
tWbYAc2wQBBvKJhTlo5YPLqkM5GgaYaWUqJJ5bFht+U2PL0rJHbS7oRG9fXtgb12
jEzD3CUUPpCxj7oRgYdji2SH5NKmo3M85/Cry1y5pmotmqGlqrt01zStj2A+FuzR
BqdmVb1CIE2iJmttGn3C3f9OCbV5kMKreu4DhVdPW7eafpo+yIMaoJxx2CAMqw5Y
GK2NNpehMHzpL4Z0032yEQJaBblYeUT4zgMCxoupXQ7hCsyjn/ws7ocpMHXY9r08
6PjaY1j6wYwX9OUJDXz0zgoeShr2vcfkjBc1QavRLVQQUWgYJevS/8rCfo32B7ub
Ym1CmWz2cY3+4UV3uihJgQIDAQABo4G+MIG7MA4GA1UdDwEB/wQEAwIF4DATBgNV
HSUEDDAKBggrBgEFBQcDAjCBkwYDVR0jBIGLMIGIgBSEq++simSLxXkuNSUKjel6
pmhxmqFlpGMwYTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNV
BAcMBkxvbmRvbjEPMA0GA1UECgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBw
bGljYXRpb24gQ0GCCQDiNWG/vm85CTANBgkqhkiG9w0BAQsFAAOCAgEARv/Kjwcu
ppXbTf9pvsesEgo6O+OM1qW73SkmQeB5ZfF6KEOn57SujTjVQRlBGhVs//+Ezlav
GKKm4Xw0eCKUfISIgz+nY7lSlVvW2REZAdpuiY6owsqtiL/Fe/RBvkUmNSWnu8vc
OIfnpqP6flKL7KjXwQXoI/Xt9Zw6D56dHQi5hYgYtP0HqtEZn6vdtroHM3mbIL6D
Dnfdhb3ywwVZTiQE5ceQk+StDYuzTz7PNQL6IWxcH4j73dQlvFzMLSDm9yA2NguK
SEiRi4gYmluQxSiTN3gYqfOMeVv/buklHknkRHCInOTOiDWX8ku0FWwApiwsmvLS
3z/9WK4QEMLQfxBbp7UJePWx0Tq6KE61gcTgMbjqz+Xi9n2KM6RenDdMg2rQXWX4
xqC0sOOuSS19WFCGYDBRjI2JOQqfeEymq8pQsrGm+XyzCPMi+eFyLZqkRiT85MCr
IZuUWpMTcqILGP3Ar5Z0ppDI1ppVDhWMoq5EYx0iuJjNQZEtHlbu1j+cw6uVAoqJ
T/E5/qKcLRkkDKV68B+CP2z+iOXH5M1dcYFu6yEkKxVbEuTJNyN5gHhBATyIoMQ0
RdohL8F9hdOHvTLo89xrFEHiLExIT0NISjlT0M/mTqq4rbr5kp5W/ee9h5uSVUJ8
3nCv0NH3CX0Ygjzd7Czd9hWRahQz+vv55u4=
-----END CERTIFICATE-----

64
src/bank/teller/init.go Normal file
View File

@@ -0,0 +1,64 @@
package teller
import (
"context"
_ "embed"
"fmt"
"io"
"net/http"
"os"
"slices"
"text/template"
)
var (
//go:embed application_id.txt
applicationId string
//go:embed init.html
initHTML string
)
func Init(ctx context.Context) error {
environment := "development"
if sandbox := !slices.Contains(os.Args, "forreal"); sandbox {
environment = "sandbox"
}
fmt.Printf("environment=%q\n", environment)
newTokens := make(chan string)
defer close(newTokens)
s := &http.Server{
Addr: ":20000",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
b, _ := io.ReadAll(r.Body)
newTokens <- string(b)
return
}
t, err := template.New("initHTML").Parse(initHTML)
if err != nil {
panic(err)
}
if err := t.Execute(w, map[string]string{
"applicationId": applicationId,
"environment": environment,
}); err != nil {
panic(err)
}
}),
}
defer s.Close()
go s.ListenAndServe()
fmt.Println("Open http://localhost:20000")
select {
case <-ctx.Done():
case newToken := <-newTokens:
return fmt.Errorf("not impl: %q >> token.txt", newToken)
}
return ctx.Err()
}

58
src/bank/teller/init.html Normal file
View File

@@ -0,0 +1,58 @@
<html>
<head></head>
<body>
<button id="teller-connect">Connect to your bank</button>
<h3 id="log">
</h3>
<script src="https://cdn.teller.io/connect/connect.js"></script>
<script>
function logme(msg) {
document.getElementById("log").innerHTML += `<br>* ${msg}`
}
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
function callback(responseBody, responseStatus) {
}
document.addEventListener("DOMContentLoaded", function() {
var tellerConnect = TellerConnect.setup({
applicationId: "{{.applicationId}}",
environment: "{{.environment}}",
products: ["verify", "balance", "transactions"],
onInit: function() {
logme("Teller Connect has initialized")
},
onSuccess: function(enrollment) {
logme(`User enrolled successfully: ${enrollment.accessToken}`)
http("post", "/", callback, enrollment.accessToken)
},
onExit: function() {
logme("User closed Teller Connect")
},
onFailure: function(failure) {
logme(`Failed: type=${failure.type} code=${failure.code} message=${failure.message}`)
},
});
var el = document.getElementById("teller-connect");
el.addEventListener("click", function() {
tellerConnect.open();
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1ZtgBzbBAEG8o
mFOWjlg8uqQzkaBphpZSoknlsWG35TY8vSskdtLuhEb19e2BvXaMTMPcJRQ+kLGP
uhGBh2OLZIfk0qajczzn8KvLXLmmai2aoaWqu3TXNK2PYD4W7NEGp2ZVvUIgTaIm
a20afcLd/04JtXmQwqt67gOFV09bt5p+mj7IgxqgnHHYIAyrDlgYrY02l6EwfOkv
hnTTfbIRAloFuVh5RPjOAwLGi6ldDuEKzKOf/Czuhykwddj2vTzo+NpjWPrBjBf0
5QkNfPTOCh5KGva9x+SMFzVBq9EtVBBRaBgl69L/ysJ+jfYHu5tibUKZbPZxjf7h
RXe6KEmBAgMBAAECggEAExDEEyxzIciYZkPcRS6gx4E2UNU1buHeWsED00hZZOKK
WMfpCOQUN01fx+oZFFG9a/GFhFXBUvISN3Du9hYsuDHQtpQNP5CVDiuVYsJUINF4
CZCDwPYCybuXokITRIWPUou1jb1efdaq/C6+QNKG8J4srYiNRlGvhDQP2qvag2ET
YtY+6OuQPF9QSeoKLBCJJi8bP/wy3OfOQkTqvlpyEGVOUc5utGuziITShL2mLtQw
1O22r4BIFtkvnhM4kGimbSDMYwtAgMrBrktR4Xp7JICV6eZn6fJgjpYOix/4ILxS
Ri69qPLso4qF+ML/vsTHlxQj7FwZDZozT4hm3DK3gQKBgQDdnEAdQYuKIvwrHflk
Pxb9q7MjrMJMGiA9GvSVTimeWu68kH2y/dUUQyBtr8SRCScNUmhRCfaZCBakSYR0
0v+6FLXleI28dD93Qzn70G0N6kVdKLdM3Rt0r+fojPhP+8pHn74UzvcoGUDOzoax
VPvHLe19wqrbRFIU4IAE6qjmNQKBgQDRjUFjRJIh4wgrB6rROeoTtYRRyS9RLliw
dpH78Vz7OYTx7qnvtVOl8led82Ott7hEXcK9Ihobz8uPCewW/x+sPYty13shT46K
8zEj9FoP9XhAS3MOqLDNx7h5jv7nvua8aRQEPkF30SmPqb+X4nN5+JjuJiTqWgJ+
5nfnyX4PnQKBgQCE87brVmV27GJJI+R5JfiPG7GPl5fBvHLW9hMCeDAz1u4fprgi
6HIrg9IyvB67vLf3IBeBdu7BBL9AtPKIfAX8B2zRTLAL/doNnQFud67ViFUw/Lpr
nMNaECabt+dJZRAIRGfvZ/OT1QKyj+jy/r9G0eEHcAC9J5HvAHkNehL2eQKBgAU+
a6x4Qs/mRoYNIxEpSdpEaJNDXZPCfSWtUenkGFeREOqc9lOxTe6RKfAh7xShzFKp
pf3lpJGdmZJyxR2uNLSytZKiIcqrmv2PKGOl8bsEgYXaXX64afQ8Uzl3gpl6BXwh
hQa2KB0/drLJpKnAWPNsbSdIfRQAPJ/AVK/QMv9hAoGAMtmCDJK1KVZcpFJbFSMk
pPJjcp1XRWKsiBOPfUKlwbBSFDBUnhyPcsfL9ooAwzBnBcpNf6S5I+I22AdDuk8E
S3uOBBlNhoecWN1tqVlcbTR1p5kXV0WVAcQ09hGPqja5ghrTTpWbC7SviSVNa0fx
LXKvqDx5qTleSmrPwPs5TW4=
-----END PRIVATE KEY-----

95
src/bank/teller/teller.go Normal file
View File

@@ -0,0 +1,95 @@
package teller
import (
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
"golang.org/x/time/rate"
)
type Client struct {
cert tls.Certificate
}
var _ bank.Agg = Client{}
var (
//go:embed certificate.pem
certificate []byte
//go:embed private_key.pem
privateKey []byte
//go:embed token.txt
Tokens string
)
func New() (Client, error) {
cert, err := tls.X509KeyPair(certificate, privateKey)
return Client{cert: cert}, err
}
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
var result []bank.Account
for _, token := range strings.Fields(Tokens) {
var more []bank.Account
if err := c.get(ctx, "https://api.teller.io/accounts", token, &more); err != nil {
return nil, err
}
for i := range more {
more[i].Token = token
}
result = append(result, more...)
}
return result, nil
}
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
var result []bank.Transaction
err := c.get(ctx, "https://api.teller.io/accounts/"+a.Account+"/transactions", a.Token, &result)
return result, err
}
var limiter = rate.NewLimiter(0.1, 1)
func (c Client) get(ctx context.Context, url, token string, ptr interface{}) error {
if err := limiter.Wait(ctx); err != nil {
return err
}
log.Printf("Teller.Get(%s, %s)", url, token)
httpc := &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{c.cert}},
},
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(token, "")
req = req.WithContext(ctx)
resp, err := httpc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(b, &ptr); err != nil {
return fmt.Errorf("cannot unmarshal: %w: %s", err, b)
}
return nil
}

View File

@@ -0,0 +1,41 @@
//go:build integration
package teller_test
import (
"context"
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func Test(t *testing.T) {
teller.Tokens = "test_token_bfu2cyvq3il6o"
c, err := teller.New()
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
accounts, err := c.Accounts(ctx)
if err != nil {
t.Fatal(err)
}
for _, account := range accounts {
account := account
t.Run(account.Account, func(t *testing.T) {
transactions, err := c.Transactions(ctx, account)
if err != nil {
t.Fatal(err)
}
for i, tr := range transactions {
t.Logf("[%d] %+v", i, tr)
}
})
break
}
}

View File

@@ -0,0 +1,59 @@
//go:build manual
package teller_test
import (
"crypto/tls"
"io"
"net/http"
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func TestIntegration(t *testing.T) {
teller.Tokens = "test_token_bfu2cyvq3il6o"
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
cert, err := tls.LoadX509KeyPair("./certificate.pem", "./private_key.pem")
if err != nil {
t.Fatal(err)
}
c := &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
},
}
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
for _, url := range []string{
"https://api.teller.io/accounts",
"https://api.teller.io/accounts/acc_pdvv4810fi9hmrcn6g000/transactions",
} {
url := url
t.Run(url, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth("test_token_bfu2cyvq3il6o", "")
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if code := resp.StatusCode; code >= 300 {
t.Fatalf("(%d) %s", code, body)
}
t.Logf("(%d) %s", resp.StatusCode, body)
})
}
}

View File

@@ -0,0 +1,2 @@
token_2utqstwpn3pxwgvyno56hqdehq
token_vr6dnzvfv7c24wuxtmnnzyiqbm

31
src/bank/types.go Normal file
View File

@@ -0,0 +1,31 @@
package bank
import "context"
type Agg interface {
Accounts(context.Context) ([]Account, error)
Transactions(context.Context, Account) ([]Transaction, error)
}
type Account struct {
Institution struct {
Name string `json:"name"`
} `json:"institution"`
Name string `json:"last_four"`
Account string `json:"id"`
Token string `json:"__token"`
}
type Transaction struct {
Amount float64 `json:"amount,string"`
Details struct {
ProcessingStatus string `json:"processing_status"`
CounterParty struct {
Name string `json:"name"`
} `json:"counterparty"`
} `json:"details"`
Description string `json:"description"`
Date string `json:"date"`
Type string `json:"type"`
Status string `json:"status"`
}

View File

@@ -4,13 +4,82 @@ import (
"fmt" "fmt"
"maps" "maps"
"regexp" "regexp"
"sort"
"strings" "strings"
"time"
) )
type Normalizer interface {
NormalizeFactor(string, string) float64
}
type Balances map[string]Balance type Balances map[string]Balance
type Balance map[Currency]float64 type Balance map[Currency]float64
func (balances Balances) Sub(other Balances) Balances {
result := make(Balances)
for k, v := range balances {
result[k] = v.Sub(other[k])
}
for k, v := range other {
if _, ok := balances[k]; ok {
continue
}
result[k] = v.Invert()
}
return result
}
func (balances Balances) Nonzero() Balances {
result := make(Balances)
for k, v := range balances {
if nonzero := v.Nonzero(); len(nonzero) > 0 {
result[k] = nonzero
}
}
return result
}
func (balances Balances) NotLike(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if !p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) KindaLike(like Like) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances)
for _, k := range ks {
v := balances[k]
if like(v.kinda(k)) {
result[k] = maps.Clone(v)
}
}
return result
}
func (b Balance) kinda(k string) Delta {
return Delta{
Date: time.Now().Format("2006-01-02"),
Name: k,
Value: b[USD],
Description: k,
Transaction: k,
Payee: false,
}
}
func (balances Balances) Like(pattern string) Balances { func (balances Balances) Like(pattern string) Balances {
result := make(Balances) result := make(Balances)
p := regexp.MustCompile(pattern) p := regexp.MustCompile(pattern)
@@ -22,22 +91,80 @@ func (balances Balances) Like(pattern string) Balances {
return result return result
} }
func (balances Balances) Group(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
k2 := k
if p.MatchString(k) {
k2 = p.FindString(k)
}
was := result[k2]
if was == nil {
was = make(Balance)
}
for k3, v3 := range v {
was[k3] += v3
}
result[k2] = was
}
return result
}
func (balances Balances) KindaGroup(group Group) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances)
for _, k := range ks {
v := balances[k]
k2 := k
if k3 := group(v.kinda(k)).Name; k2 != k3 {
k2 = k3
}
if _, ok := result[k2]; !ok {
result[k2] = make(Balance)
}
for k3, v3 := range v {
result[k2][k3] += v3
}
}
return result
}
func (balances Balances) WithBPIs(bpis BPIs) Balances { func (balances Balances) WithBPIs(bpis BPIs) Balances {
return balances.WithBPIsAt(bpis, "9") return balances.WithBPIsAt(bpis, "9")
} }
func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances { func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances) result := make(Balances)
for k, v := range balances { for _, k := range ks {
v := balances[k]
if _, ok := result[k]; !ok { if _, ok := result[k]; !ok {
result[k] = make(Balance) result[k] = make(Balance)
} }
for k2, v2 := range v { for k2, v2 := range v {
scalar := 1.0 if k2 == USD {
if k2 != USD { result[k][USD] = result[k][USD] + v2
scalar = bpis[k2].Lookup(date) } else if scalar := bpis[k2].Lookup(date); scalar != nil {
result[k][USD] = result[k][USD] + v2*(*scalar)
} else {
result[k][k2] = result[k][k2] + v2
} }
result[k][USD] += v2 * scalar
} }
} }
return result return result
@@ -64,6 +191,39 @@ func (balances Balances) Push(d Delta) {
balances[d.Name].Push(d) balances[d.Name].Push(d)
} }
func (balance Balance) Sub(other Balance) Balance {
return balance.Sum(other.Invert())
}
func (balance Balance) Sum(other Balance) Balance {
result := make(Balance)
for k, v := range balance {
result[k] += v
}
for k, v := range other {
result[k] += v
}
return result
}
func (balance Balance) Nonzero() Balance {
result := make(Balance)
for k, v := range balance {
if v != 0 {
result[k] = v
}
}
return result
}
func (balance Balance) Invert() Balance {
result := make(Balance)
for k, v := range balance {
result[k] = v * -1.0
}
return result
}
func (balance Balance) Push(d Delta) { func (balance Balance) Push(d Delta) {
if _, ok := balance[d.Currency]; !ok { if _, ok := balance[d.Currency]; !ok {
balance[d.Currency] = 0 balance[d.Currency] = 0
@@ -90,3 +250,14 @@ func (balance Balance) Debug() string {
} }
return strings.Join(result, " + ") return strings.Join(result, " + ")
} }
func (balances Balances) Normalize(n Normalizer, date string) Balances {
result := make(Balances)
for name, balance := range balances {
result[name] = make(Balance)
for currency, value := range balance {
result[name][currency] = value / n.NormalizeFactor(name, date)
}
}
return result
}

View File

@@ -51,4 +51,38 @@ func TestBalances(t *testing.T) {
t.Error("didnt sum other", b["ab"]) t.Error("didnt sum other", b["ab"])
} }
}) })
t.Run("like", func(t *testing.T) {
was := make(Balances)
was.Push(Delta{Name: "a", Currency: USD, Value: 0.1})
was.Push(Delta{Name: "ab", Currency: USD, Value: 1.2})
got := was.Like(`^ab$`)
if len(got) != 1 || got["ab"][USD] != 1.2 {
t.Error(got)
}
got = was.NotLike(`^ab$`)
if len(got) != 1 || got["a"][USD] != 0.1 {
t.Error(got)
}
})
t.Run("group", func(t *testing.T) {
was := make(Balances)
was.Push(Delta{Name: "a:1", Currency: USD, Value: 0.1})
was.Push(Delta{Name: "a:2", Currency: USD, Value: 1.2})
was.Push(Delta{Name: "b:1", Currency: USD, Value: 2.2})
got := was.Group(`^[^:]*`)
if len(got) != 2 {
t.Error(got)
}
if got["a"][USD] != 1.3 {
t.Error(got)
}
if got["b"][USD] != 2.2 {
t.Error(got)
}
})
} }

View File

@@ -50,7 +50,7 @@ func NewBPIs(p string) (BPIs, error) {
} }
} }
func (bpi BPI) Lookup(date string) float64 { func (bpi BPI) Lookup(date string) *float64 {
var closestWithoutGoingOver string var closestWithoutGoingOver string
for k := range bpi { for k := range bpi {
if k <= date && k > closestWithoutGoingOver { if k <= date && k > closestWithoutGoingOver {
@@ -58,7 +58,8 @@ func (bpi BPI) Lookup(date string) float64 {
} }
} }
if closestWithoutGoingOver == "" { if closestWithoutGoingOver == "" {
return 0 return nil
} }
return bpi[closestWithoutGoingOver] f := bpi[closestWithoutGoingOver]
return &f
} }

View File

@@ -157,16 +157,16 @@ P 2023-10-22 07:33:56 GME $17.18
t.Fatal(err) t.Fatal(err)
} }
if got := got["USDC"].Lookup("2099-01-01"); got != 0 { if got := got["USDC"].Lookup("2099-01-01"); got != nil {
t.Error("default got != 0:", got) t.Error("default got != 0:", got)
} }
if got := got["GME"].Lookup("2099-01-01"); got != 17.18 { if got := got["GME"].Lookup("2099-01-01"); got == nil || *got != 17.18 {
t.Errorf("shouldve returned latest but got %v", got) t.Errorf("shouldve returned latest but got %v", got)
} }
if got := got["GME"].Lookup("2023-09-19"); got != 18.22 { if got := got["GME"].Lookup("2023-09-19"); got == nil || *got != 18.22 {
t.Errorf("shouldve returned one day before but got %v", got) t.Errorf("shouldve returned one day before but got %v", got)
} }
if got := got["GME"].Lookup("2023-09-18"); got != 18.22 { if got := got["GME"].Lookup("2023-09-18"); got == nil || *got != 18.22 {
t.Errorf("shouldve returned day of but got %v", got) t.Errorf("shouldve returned day of but got %v", got)
} }
t.Log(got) t.Log(got)

View File

@@ -14,25 +14,52 @@ type Delta struct {
Value float64 Value float64
Currency Currency Currency Currency
Description string Description string
Transaction string
Payee bool
isSet bool isSet bool
fileName string
lineNo int
with []Delta
} }
func newDelta(d, desc, name string, v float64, c string, isSet bool) Delta { func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int) Delta {
return Delta{ return Delta{
Date: d, Date: d,
Name: name, Name: name,
Value: v, Value: v,
Currency: Currency(c), Currency: Currency(c),
Description: desc, Description: desc,
Transaction: transaction,
Payee: payee,
isSet: isSet, isSet: isSet,
fileName: fileName,
lineNo: lineNo,
} }
} }
func (delta Delta) withWith(other Delta) Delta {
other.with = nil
delta.with = append(delta.with, other)
return delta
}
func (delta Delta) equivalent(other Delta) bool {
delta.fileName = ""
delta.lineNo = 0
delta.with = nil
other.fileName = ""
other.lineNo = 0
other.with = nil
return fmt.Sprintf("%+v", delta) == fmt.Sprintf("%+v", other)
}
func (delta Delta) Debug() string { func (delta Delta) Debug() string {
return fmt.Sprintf("{@%s %s:\"%s\" %s%.2f %s}", delta.Date, delta.Name, delta.Description, func() string { return fmt.Sprintf("{@%s %s(payee=%v):\"%s\" %s%.2f %s @%s#%d}", delta.Date, delta.Name, delta.Payee, delta.Description, func() string {
if !delta.isSet { if !delta.isSet {
return "" return ""
} }
return "= " return "= "
}(), delta.Value, delta.Currency) }(), delta.Value, delta.Currency, delta.fileName, delta.lineNo)
} }

View File

@@ -6,8 +6,14 @@ import (
func TestDelta(t *testing.T) { func TestDelta(t *testing.T) {
d := "2099-08-07" d := "2099-08-07"
delta := newDelta(d, "", "name", 34.56, "$", false) delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0)
if delta.Transaction != "x" {
t.Error(delta.Transaction)
}
if !delta.Payee {
t.Error(delta.Payee)
}
if delta.Date != d { if delta.Date != d {
t.Error(delta.Date) t.Error(delta.Date)
} }

View File

@@ -57,12 +57,8 @@ func (deltas Deltas) Balances() Balances {
} }
result[delta.Name][delta.Currency] += delta.Value result[delta.Name][delta.Currency] += delta.Value
if result[delta.Name][delta.Currency] < 0.000000001 && result[delta.Name][delta.Currency] > -0.000000001 { if result[delta.Name][delta.Currency] < 0.000000001 && result[delta.Name][delta.Currency] > -0.000000001 {
delete(result[delta.Name], delta.Currency) result[delta.Name][delta.Currency] = 0
if len(result[delta.Name]) == 0 {
delete(result, delta.Name)
} }
} }
}
return result return result
} }

View File

@@ -38,9 +38,12 @@ func TestDeltas(t *testing.T) {
} }
balances := deltas.Balances() balances := deltas.Balances()
if len(balances) != 1 { if len(balances) != 2 {
t.Error(len(balances), balances) t.Error(len(balances), balances)
} }
if balances["a"][""] != 0 {
t.Error(balances["a"])
}
if balances["b"][""] != 1.3 { if balances["b"][""] != 1.3 {
t.Error(balances["b"]) t.Error(balances["b"])
} }

View File

@@ -3,8 +3,13 @@ package ledger
import ( import (
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path"
"path/filepath"
"slices"
"sort" "sort"
"strings"
"unicode" "unicode"
) )
@@ -18,7 +23,78 @@ func NewFiles(p string, q ...string) (Files, error) {
return f, err return f, err
} }
func (files Files) Add(payee string, delta Delta) error { func (files Files) Amend(old, now Delta) error {
if now.isSet {
return fmt.Errorf("cannot ammend: immutable isSet is set")
}
xactions, err := files.transactions()
if err != nil {
return err
}
var transaction transaction
for _, xaction := range xactions {
if xaction.name != old.Transaction {
continue
}
transaction = xaction
break
}
if transaction.payee == old.Name {
if len(transaction.recipients) != 1 {
return fmt.Errorf("cannot amend: modifying original payee, but many recipients cant share new value")
}
transaction.payee, transaction.recipients[0].name, transaction.recipients[0].value = transaction.recipients[0].name, transaction.payee, transaction.recipients[0].value*-1.0
}
idx := -1
for i, recipient := range transaction.recipients {
if recipient.name == old.Name && recipient.value == old.Value {
idx = i
}
}
if idx == -1 {
return fmt.Errorf("cannot amend: no recipient with name %q value %.2f found in %+v to set new value", old.Name, old.Value, transaction)
}
old.Value *= -1
return files.Add(transaction.payee, []Delta{old, now}...)
}
func (files Files) paths() []string {
result := make([]string, 0, len(files))
for i := range files {
if info, err := os.Stat(files[i]); err == nil && info.IsDir() {
if err := filepath.WalkDir(files[i], func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
result = append(result, p)
}
return nil
}); err != nil {
panic(err)
}
} else {
result = append(result, files[i])
}
}
return result
}
func (files Files) Add(payee string, deltas ...Delta) error {
for _, delta := range deltas {
if err := files.add(payee, delta); err != nil {
return err
}
}
return nil
}
func (files Files) add(payee string, delta Delta) error {
currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value) currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value)
if delta.Currency != USD { if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency) currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
@@ -31,11 +107,13 @@ func (files Files) Add(payee string, delta Delta) error {
} }
func (files Files) append(s string) error { func (files Files) append(s string) error {
if err := files.trimTrainlingWhitespace(); err != nil { p := path.Join(path.Dir(files.paths()[0]), "inbox.txt")
if err := files.trimTrailingWhitespace(p); err != nil {
return err return err
} }
f, err := os.OpenFile(string(files[0]), os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm) f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil { if err != nil {
return err return err
} }
@@ -45,8 +123,8 @@ func (files Files) append(s string) error {
return f.Close() return f.Close()
} }
func (files Files) trimTrainlingWhitespace() error { func (files Files) trimTrailingWhitespace(p string) error {
idx, err := files._lastNonWhitespacePos() idx, err := files._lastNonWhitespacePos(p)
if err != nil { if err != nil {
return err return err
} }
@@ -54,7 +132,7 @@ func (files Files) trimTrainlingWhitespace() error {
return nil return nil
} }
f, err := os.OpenFile(string(files[0]), os.O_CREATE|os.O_WRONLY, os.ModePerm) f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil { if err != nil {
return err return err
} }
@@ -63,8 +141,8 @@ func (files Files) trimTrainlingWhitespace() error {
return f.Truncate(int64(idx + 1)) return f.Truncate(int64(idx + 1))
} }
func (files Files) _lastNonWhitespacePos() (int, error) { func (files Files) _lastNonWhitespacePos(p string) (int, error) {
f, err := os.Open(string(files[0])) f, err := os.Open(p)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return -1, nil return -1, nil
} }
@@ -96,38 +174,14 @@ func (files Files) Deltas(like ...Like) (Deltas, error) {
result := make(Deltas, 0, len(transactions)*2) result := make(Deltas, 0, len(transactions)*2)
for _, transaction := range transactions { for _, transaction := range transactions {
sums := map[string]float64{} result = append(result, transaction.deltas()...)
for _, recipient := range transaction.recipients {
sums[recipient.currency] += recipient.value
delta := newDelta(
transaction.date,
transaction.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
)
result = append(result, delta)
}
for currency, value := range sums {
if value == 0 {
continue
}
if transaction.payee == "" {
//return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction)
} else {
delta := newDelta(
transaction.date,
transaction.description,
transaction.payee,
-1.0*value,
currency,
false,
)
result = append(result, delta)
}
} }
slices.SortFunc(result, func(a, b Delta) int {
if str := strings.Compare(a.Date+a.fileName, b.Date+b.fileName); str != 0 {
return str
} }
return a.lineNo - b.lineNo
})
balances := make(Balances) balances := make(Balances)
for i := range result { for i := range result {

View File

@@ -7,6 +7,160 @@ import (
"testing" "testing"
) )
func TestFileAmend(t *testing.T) {
cases := map[string]struct {
from string
old Delta
now Delta
want string
}{
"multi recipient": {
from: `
2006-01-02 description
recipientA $3.45
recipientB $6.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "recipientB",
Value: 6.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "recipientC",
Value: 16.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipientB $-6.45
payee
2106-11-12 1description
recipientC 16.45 T
payee`,
},
"recipient": {
from: `
2006-01-02 description
recipient $3.45
payee $-3.45
`,
old: Delta{
Date: "2006-01-02",
Name: "recipient",
Value: 3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1recipient",
Value: 13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipient $-3.45
payee
2106-11-12 1description
1recipient 13.45 T
payee`,
},
"payee": {
from: `
2006-01-02 description
recipient $3.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "payee",
Value: -3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1payee",
Value: -13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
payee $3.45
recipient
2106-11-12 1description
1payee -13.45 T
recipient`,
},
"was set": {
from: `
2006-01-02 description
recipient $3.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "recipient",
Value: 3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1recipient",
Value: 13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipient $-3.45
payee
2106-11-12 1description
1recipient 13.45 T
payee`,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "dat")
if err := os.WriteFile(p, []byte(c.from), os.ModePerm); err != nil {
t.Fatal(err)
}
files, err := NewFiles(p)
if err != nil {
t.Fatal(err)
} else if deltas, err := files.Deltas(); err != nil {
t.Fatal(err)
} else if filtered := deltas.Like(func(d Delta) bool {
c.old.Transaction = d.Transaction
c.old.Payee = d.Payee
return d.equivalent(c.old)
}); len(filtered) != 1 {
t.Fatalf("input \n\t%s \ndidnt include old \n\t%+v \nin \n\t%+v: \n\t%+v", c.from, c.old, deltas, filtered)
}
if err := files.Amend(c.old, c.now); err != nil {
t.Fatal(err)
} else if b, err := os.ReadFile(path.Join(path.Dir(p), "inbox.txt")); err != nil {
t.Fatal(err)
} else if string(b) != c.want {
t.Fatalf("expected \n\t%q\nbut got\n\t%q\n\t%s", c.want, b, b)
}
})
}
}
func TestFileAdd(t *testing.T) { func TestFileAdd(t *testing.T) {
filesAppendDelim = " " filesAppendDelim = " "
payee := "name:3" payee := "name:3"
@@ -67,7 +221,7 @@ func TestFileAdd(t *testing.T) {
for name, d := range cases { for name, d := range cases {
c := d c := d
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "input") p := path.Join(t.TempDir(), "inbox.txt")
if c.given != nil { if c.given != nil {
if err := os.WriteFile(p, []byte(c.given), os.ModePerm); err != nil { if err := os.WriteFile(p, []byte(c.given), os.ModePerm); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -218,6 +372,10 @@ func TestFileDeltas(t *testing.T) {
Value: -97.92, Value: -97.92,
Currency: USD, Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5", Description: "Electricity / Power Bill TG2PJ-2PLP5",
Payee: true,
fileName: "",
lineNo: 0,
}, },
{ {
Date: "2022-12-12", Date: "2022-12-12",
@@ -225,6 +383,9 @@ func TestFileDeltas(t *testing.T) {
Value: 97.92, Value: 97.92,
Currency: USD, Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5", Description: "Electricity / Power Bill TG2PJ-2PLP5",
fileName: "",
lineNo: 0,
}, },
{ {
Date: "2022-12-12", Date: "2022-12-12",
@@ -232,6 +393,10 @@ func TestFileDeltas(t *testing.T) {
Value: -1.00, Value: -1.00,
Currency: USD, Currency: USD,
Description: "Test pay chase TG32S-BT2FF", Description: "Test pay chase TG32S-BT2FF",
Payee: true,
fileName: "",
lineNo: 0,
}, },
{ {
Date: "2022-12-12", Date: "2022-12-12",
@@ -239,6 +404,9 @@ func TestFileDeltas(t *testing.T) {
Value: 1.00, Value: 1.00,
Currency: USD, Currency: USD,
Description: "Test pay chase TG32S-BT2FF", Description: "Test pay chase TG32S-BT2FF",
fileName: "",
lineNo: 0,
}, },
} }
@@ -251,7 +419,8 @@ func TestFileDeltas(t *testing.T) {
for name, d := range cases { for name, d := range cases {
want := d want := d
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
f, err := NewFiles("./testdata/" + name + ".dat") fileName := "./testdata/" + name + ".dat"
f, err := NewFiles(fileName)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -265,13 +434,34 @@ func TestFileDeltas(t *testing.T) {
t.Error(len(deltas)) t.Error(len(deltas))
} }
for i := range want { for i := range want {
want[i].fileName = fileName
deltas[i].lineNo = 0
if i >= len(deltas) { if i >= len(deltas) {
break break
} }
if want[i] != deltas[i] { if deltas[i].Transaction == "" {
t.Error(deltas[i].Transaction)
}
deltas[i].Transaction = ""
if !want[i].equivalent(deltas[i]) {
t.Errorf("[%d] \n\twant=%s, \n\t got=%s", i, want[i].Debug(), deltas[i].Debug()) t.Errorf("[%d] \n\twant=%s, \n\t got=%s", i, want[i].Debug(), deltas[i].Debug())
} }
} }
}) })
} }
} }
func TestFilesOfDir(t *testing.T) {
d := t.TempDir()
files := Files([]string{d, "/dev/null"})
if paths := files.paths(); len(paths) != 1 {
t.Error(paths)
}
os.WriteFile(path.Join(d, "1"), []byte{}, os.ModePerm)
os.Mkdir(path.Join(d, "d2"), os.ModePerm)
os.WriteFile(path.Join(d, "d2", "2"), []byte{}, os.ModePerm)
if paths := files.paths(); len(paths) != 3 || paths[0] != path.Join(d, "1") || paths[1] != path.Join(d, "d2", "2") {
t.Error(paths)
}
}

View File

@@ -1,6 +1,8 @@
package ledger package ledger
import "regexp" import (
"regexp"
)
type Group func(Delta) Delta type Group func(Delta) Delta
@@ -17,6 +19,9 @@ func GroupDate(pattern string) Group {
p := regexp.MustCompile(pattern) p := regexp.MustCompile(pattern)
return func(d Delta) Delta { return func(d Delta) Delta {
d.Date = p.FindString(d.Date) d.Date = p.FindString(d.Date)
for i := range d.with {
d.with[i].Date = p.FindString(d.with[i].Date)
}
return d return d
} }
} }
@@ -25,6 +30,9 @@ func GroupName(pattern string) Group {
p := regexp.MustCompile(pattern) p := regexp.MustCompile(pattern)
return func(d Delta) Delta { return func(d Delta) Delta {
d.Name = p.FindString(d.Name) d.Name = p.FindString(d.Name)
for i := range d.with {
d.with[i].Name = p.FindString(d.with[i].Name)
}
return d return d
} }
} }

View File

@@ -8,6 +8,36 @@ type Like func(Delta) bool
type Likes []Like type Likes []Like
func LikeWith(pattern string) Like {
l := LikeName(pattern)
return func(d Delta) bool {
if l(d) {
return true
}
for _, d := range d.with {
if l(d) {
return true
}
}
return false
}
}
func LikeTransactions(deltas ...Delta) Like {
return func(d Delta) bool {
for i := range deltas {
if deltas[i].Transaction == d.Transaction {
return true
}
}
return false
}
}
func LikeTransaction(delta Delta) Like {
return LikeTransactions(delta)
}
func LikeBefore(date string) Like { func LikeBefore(date string) Like {
return func(d Delta) bool { return func(d Delta) bool {
return date >= d.Date return date >= d.Date
@@ -20,6 +50,22 @@ func LikeAfter(date string) Like {
} }
} }
func LikeNotName(pattern string) Like {
return func(d Delta) bool {
return !like(pattern, d.Name)
}
}
func LikeNot(like Like) Like {
return func(d Delta) bool {
return !like(d)
}
}
func NotLikeName(pattern string) Like {
return LikeNot(LikeName(pattern))
}
func LikeName(pattern string) Like { func LikeName(pattern string) Like {
return func(d Delta) bool { return func(d Delta) bool {
return like(pattern, d.Name) return like(pattern, d.Name)

View File

@@ -36,3 +36,20 @@ func TestLikesAll(t *testing.T) {
t.Error(likes.All(delta)) t.Error(likes.All(delta))
} }
} }
func TestLikeWith(t *testing.T) {
delta := Delta{
Name: "x",
with: []Delta{
Delta{Name: "y"},
Delta{Name: "z"},
},
}
if !LikeWith("x")(delta) {
t.Error("like with self not caught")
}
if !LikeWith("z")(delta) {
t.Error("like with reverse not caught")
}
}

View File

@@ -51,6 +51,26 @@ func (register Register) Names() []string {
return result return result
} }
func (register Register) Like(pattern string) Register {
result := make(Register)
for k, v := range register {
if got := v.Like(pattern); len(got) > 0 {
result[k] = got
}
}
return result
}
func (register Register) NotLike(pattern string) Register {
result := make(Register)
for k, v := range register {
if got := v.NotLike(pattern); len(got) > 0 {
result[k] = got
}
}
return result
}
func (register Register) Dates() []string { func (register Register) Dates() []string {
result := make([]string, 0, len(register)) result := make([]string, 0, len(register))
for k := range register { for k := range register {

View File

@@ -3,19 +3,116 @@ package ledger
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings"
"unicode" "unicode"
) )
type Transaction Deltas
type Transactions []Transaction
func (transactions Transactions) Deltas() Deltas {
result := make(Deltas, 0, len(transactions))
for _, transaction := range transactions {
result = append(result, transaction...)
}
return result
}
func (deltas Deltas) Transactions() Transactions {
m := make(map[string]Transaction)
for _, d := range deltas {
m[d.Transaction] = append(m[d.Transaction], d)
}
result := make(Transactions, 0, len(m))
for _, v := range m {
result = append(result, v)
}
slices.SortFunc(result, func(a, b Transaction) int {
if a[0].Date == b[0].Date {
if a[0].Description == b[0].Description {
return strings.Compare(a[0].Transaction, b[0].Transaction)
}
return strings.Compare(a[0].Description, b[0].Description)
}
return strings.Compare(a[0].Date, b[0].Date)
})
return result
}
func (transactions Transactions) NotLike(like ...Like) Transactions {
result := make(Transactions, 0, len(transactions))
for _, transaction := range transactions {
if matching := (Deltas)(transaction).Like(like...); len(matching) == 0 {
result = append(result, transaction)
}
}
return result
}
func (transactions Transactions) Like(like ...Like) Transactions {
result := make(Transactions, 0, len(transactions))
for _, transaction := range transactions {
if matching := (Deltas)(transaction).Like(like...); len(matching) > 0 {
result = append(result, transaction)
}
}
return result
}
func (transaction Transaction) Payee() string {
balances := Deltas(transaction).Balances()
candidates := []string{}
for name, balance := range balances {
deltas := Deltas(transaction).Like(LikeName(`^` + name + `$`))
if len(deltas) != 1 {
continue
}
everyoneElse := balances.NotLike(`^` + name + `$`).Group(`^`)[""]
matches := true
for currency, value := range balance {
matches = matches && everyoneElse[currency]*-1 == value
}
if matches {
candidates = append(candidates, name)
}
}
slices.Sort(candidates)
if len(candidates) == 0 {
panic(balances)
}
for _, candidate := range candidates {
if strings.HasPrefix(candidate, "Withdrawal") {
return candidate
}
}
return candidates[len(candidates)-1]
}
type transaction struct { type transaction struct {
date string date string
description string description string
payee string payee string
recipients []transactionRecipient recipients []transactionRecipient
name string
fileName string
lineNo int
} }
func (t transaction) empty() bool { func (t transaction) empty() bool {
@@ -29,14 +126,65 @@ type transactionRecipient struct {
isSet bool isSet bool
} }
func (t transaction) deltas() Deltas {
result := []Delta{}
sums := map[string]float64{}
for i, recipient := range t.recipients {
sums[recipient.currency] += recipient.value
result = append(result, newDelta(
t.name,
true,
t.date,
t.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
t.fileName,
t.lineNo+i,
))
}
for currency, value := range sums {
if value == 0 {
continue
}
if t.payee == "" {
//return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction)
} else {
result = append(result, newDelta(
t.name,
false,
t.date,
t.description,
t.payee,
-1.0*value,
currency,
false,
t.fileName,
t.lineNo,
))
}
}
for i := range result {
for j := range result {
if i != j {
result[i] = result[i].withWith(result[j])
}
}
}
return result
}
func (t transactionRecipient) empty() bool { func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{}) return t == (transactionRecipient{})
} }
func (files Files) transactions() ([]transaction, error) { func (files Files) transactions() ([]transaction, error) {
result := make([]transaction, 0) result := make([]transaction, 0)
for i := range files { for _, path := range files.paths() {
some, err := files._transactions(files[i]) some, err := files._transactions(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,8 +207,11 @@ func (files Files) _transactions(file string) ([]transaction, error) {
result := make([]transaction, 0) result := make([]transaction, 0)
for { for {
one, err := readTransaction(r) name := fmt.Sprintf("%s/%d", file, len(result))
one, err := readTransaction(name, r)
if !one.empty() { if !one.empty() {
one.fileName = file
one.lineNo = len(result)
result = append(result, one) result = append(result, one)
} }
if err == io.EOF { if err == io.EOF {
@@ -72,24 +223,63 @@ func (files Files) _transactions(file string) ([]transaction, error) {
} }
} }
func readTransaction(r *bufio.Reader) (transaction, error) { func readTransaction(name string, r *bufio.Reader) (transaction, error) {
result, err := _readTransaction(r) result, err := _readTransaction(name, r)
if err != nil { if err != nil && !errors.Is(err, io.EOF) {
return result, err return result, err
} }
if result.empty() { if result.empty() {
return result, nil return result, err
}
if result.payee == "" && len(result.recipients) < 2 {
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
} }
if result.payee != "" && len(result.recipients) < 1 { if result.payee != "" && len(result.recipients) < 1 {
return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result) return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result)
} }
return result, nil if result.payee == "" {
if len(result.recipients) < 2 {
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
}
func() {
sumPerRecipient := map[string]float64{}
recipients := []string{}
for _, recipient := range result.recipients {
recipients = append(recipients, recipient.name)
sumPerRecipient[recipient.name] += recipient.value
}
slices.Sort(recipients)
for _, k := range recipients {
n := 0
for i := range result.recipients {
if result.recipients[i].name == k {
n += 1
}
}
if n != 1 {
continue
}
v := sumPerRecipient[k]
everyoneElse := 0.0
for j := range sumPerRecipient {
if k != j {
everyoneElse += sumPerRecipient[j]
}
}
if -1.0*v == everyoneElse {
result.payee = k
result.recipients = slices.DeleteFunc(result.recipients, func(recipient transactionRecipient) bool {
return recipient.name == k
})
return
}
}
return
}()
}
return result, err
} }
func _readTransaction(r *bufio.Reader) (transaction, error) { func _readTransaction(name string, r *bufio.Reader) (transaction, error) {
readTransactionLeadingWhitespace(r) readTransactionLeadingWhitespace(r)
firstLine, err := readTransactionLine(r) firstLine, err := readTransactionLine(r)
@@ -107,6 +297,7 @@ func _readTransaction(r *bufio.Reader) (transaction, error) {
result := transaction{ result := transaction{
date: string(dateDescriptionMatches[0][1]), date: string(dateDescriptionMatches[0][1]),
description: string(dateDescriptionMatches[0][2]), description: string(dateDescriptionMatches[0][2]),
name: name,
} }
for { for {
@@ -193,6 +384,7 @@ func readTransactionName(r *bufio.Reader) (string, float64, string, bool, error)
return "", 0, "", false, nil return "", 0, "", false, nil
} }
line = bytes.Split(line, []byte(";"))[0] // comment-free
fields := bytes.Fields(line) fields := bytes.Fields(line)
isSet := false isSet := false

View File

@@ -8,6 +8,38 @@ import (
"testing" "testing"
) )
func TestTransactionPayee(t *testing.T) {
given := Transaction{
Delta{Name: "x", Transaction: "a", Value: 9},
Delta{Name: "Withdrawal:z", Transaction: "a", Value: -3},
Delta{Name: "Withdrawal:z", Transaction: "a", Value: -6},
}
got := given.Payee()
if got != "x" {
t.Error(got)
}
}
func TestDeltasTransactions(t *testing.T) {
given := Deltas{
Delta{Date: "2", Name: "x", Transaction: "a"},
Delta{Date: "2", Name: "y", Transaction: "a"},
Delta{Date: "1", Name: "z", Transaction: "b"},
}
got := given.Transactions()
if len(got) != 2 {
t.Error(len(got))
}
if len(got[0]) != 1 {
t.Error("first xaction is not earliest date", len(got[0]))
}
if len(got[1]) != 2 {
t.Error("second xaction is not latest date", len(got[1]))
}
}
func TestReadTransaction(t *testing.T) { func TestReadTransaction(t *testing.T) {
cases := map[string]struct { cases := map[string]struct {
input string input string
@@ -33,7 +65,28 @@ func TestReadTransaction(t *testing.T) {
want: transaction{ want: transaction{
date: "2003-04-05", date: "2003-04-05",
description: "Reasoning here", description: "Reasoning here",
payee: "", payee: "A:B",
recipients: []transactionRecipient{
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
"multi send": {
input: `
2003-04-05 Reasoning here
A:B $1.00
A:B $2.00
C:D
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "C:D",
recipients: []transactionRecipient{ recipients: []transactionRecipient{
{ {
name: "A:B", name: "A:B",
@@ -41,8 +94,8 @@ func TestReadTransaction(t *testing.T) {
currency: "$", currency: "$",
}, },
{ {
name: "C:D", name: "A:B",
value: -1.0, value: 2.0,
currency: "$", currency: "$",
}, },
}, },
@@ -55,7 +108,7 @@ func TestReadTransaction(t *testing.T) {
c := d c := d
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(c.input)) r := bufio.NewReader(strings.NewReader(c.input))
got, err := readTransaction(r) got, err := readTransaction("", r)
if err != c.err { if err != c.err {
t.Error(err) t.Error(err)
} }

160
src/view/chart.go Normal file
View File

@@ -0,0 +1,160 @@
package view
import (
"fmt"
"io"
"slices"
"strings"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/opts"
)
type Chart interface {
AddX(interface{})
AddY(string, []int)
Render(io.Writer) error
Overlap(Chart)
}
func NewChart(name string) Chart {
switch name {
case "line":
return NewLine()
case "bar":
return NewBar()
case "stack":
return NewStack()
case "pie":
return NewPie()
default:
panic("bad chart name " + name)
}
}
type Line struct {
*charts.Line
}
func NewLine() Line {
return Line{Line: charts.NewLine()}
}
func (line Line) AddX(v interface{}) {
line.SetXAxis(v)
}
func (line Line) AddY(name string, v []int) {
y := make([]opts.LineData, len(v))
for i := range y {
y[i].Value = v[i]
}
line.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackB",
}))
}
func (line Line) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
line.Line.Overlap(overlapper)
}
type Bar struct {
*charts.Bar
}
func NewBar() Bar {
return Bar{Bar: charts.NewBar()}
}
func (bar Bar) AddX(v interface{}) {
bar.SetXAxis(v)
}
func (bar Bar) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
bar.AddSeries(name, y)
}
func (bar Bar) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
bar.Bar.Overlap(overlapper)
}
type Stack struct {
Bar
}
func NewStack() Stack {
bar := NewBar()
bar.SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{Stack: "x"}))
return Stack{Bar: bar}
}
func (stack Stack) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
stack.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackA",
}))
}
type Pie struct {
*charts.Pie
series []opts.PieData
}
func NewPie() *Pie {
return &Pie{Pie: charts.NewPie()}
}
func (pie *Pie) AddX(v interface{}) {
}
func (pie *Pie) Render(w io.Writer) error {
slices.SortFunc(pie.series, func(a, b opts.PieData) int {
return strings.Compare(a.Name, b.Name)
})
commonPrefixLen := -1
for i := 0; i < len(pie.series[0].Name) && i < len(pie.series[len(pie.series)-1].Name); i++ {
if pie.series[0].Name[i] != pie.series[len(pie.series)-1].Name[i] {
break
}
commonPrefixLen = i
}
for i := range pie.series {
pie.series[i].Name = pie.series[i].Name[commonPrefixLen+1:]
}
pie.AddSeries("", pie.series)
pie.SetGlobalOptions(charts.WithLegendOpts(opts.Legend{
Show: false,
}))
return pie.Pie.Render(w)
}
func (pie *Pie) AddY(name string, v []int) {
for _, v := range v {
pie.series = append(pie.series, opts.PieData{
Name: fmt.Sprintf("%s ($%d)", name, v),
Value: v,
})
}
}
func (pie *Pie) Overlap(other Chart) {
panic("nope")
}

View File

@@ -1,8 +1,15 @@
todo: todo:
- cicd in gitea is hard to get it to build a docker image tho - |
- cicd on not gogs,gitness but gitea; registry-app.inhome.blapointe.com:5001/docker scale by salaries for proportional house contributions
stuff if bel makes 190k and contributes $190
- html version can accept new transactions for moolah equal to z making 140k and contri $140
so contrib/sal = %of_me_contributed
... is that fair tho?
i think it is--if we both give N%, then 100% of house is covered, so we were hit equally
- ui can create transactions
- combine transactions in /transactions.html
- amend /transactions.html
- cicd including run
scheduled: [] scheduled: []
done: done:
- todo: balances over time window - todo: balances over time window
@@ -70,3 +77,14 @@ done:
ts: Fri Oct 27 20:37:12 MDT 2023 ts: Fri Oct 27 20:37:12 MDT 2023
- todo: cicd on not gogs,gitness but gitea - todo: cicd on not gogs,gitness but gitea
ts: Fri Oct 27 22:22:04 MDT 2023 ts: Fri Oct 27 22:22:04 MDT 2023
- todo: cicd in gitea is hard to get it to build a docker image tho
ts: Sat Oct 28 09:44:55 MDT 2023
- todo: cicd on not gogs,gitness but gitea; registry-app.inhome.blapointe.com:5001/docker
stuff
ts: Sat Oct 28 09:44:55 MDT 2023
- todo: services_docker including gitea cicd
ts: Sat Oct 28 10:10:08 MDT 2023
- todo: go embed cmd/http/public dir?
ts: Sat Oct 28 10:40:50 MDT 2023
- todo: html version can accept new transactions for moolah
ts: Sun Oct 29 10:11:50 MDT 2023

21
vendor/github.com/go-echarts/go-echarts/v2/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019~now chenjiandongx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,29 @@
package actions
import (
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// Type kind of dispatch action
type Type string
// Areas means select-boxes. Multi-boxes can be specified.
// If Areas is empty, all of the select-boxes will be deleted.
// The first area.
type Areas struct {
//BrushType Optional: 'polygon', 'rect', 'lineX', 'lineY'
BrushType string `json:"brushType,omitempty"`
// CoordRange Only for "coordinate system area", define the area with the
// coordinates.
CoordRange []string `json:"coordRange,omitempty"`
// XAxisIndex Assigns which of the xAxisIndex can use Area selecting.
XAxisIndex interface{} `json:"xAxisIndex,omitempty"`
}

View File

@@ -0,0 +1,63 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Bar represents a bar chart.
type Bar struct {
RectChart
isXYReversal bool
}
// Type returns the chart type.
func (*Bar) Type() string { return types.ChartBar }
// NewBar creates a new bar chart instance.
func NewBar() *Bar {
c := &Bar{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// EnablePolarType enables the polar bar.
func (c *Bar) EnablePolarType() *Bar {
c.hasXYAxis = false
c.hasPolar = true
return c
}
// SetXAxis sets the X axis.
func (c *Bar) SetXAxis(x interface{}) *Bar {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *Bar) AddSeries(name string, data []opts.BarData, options ...SeriesOpts) *Bar {
series := SingleSeries{Name: name, Type: types.ChartBar, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// XYReversal checks if X axis and Y axis are reversed.
func (c *Bar) XYReversal() *Bar {
c.isXYReversal = true
return c
}
// Validate validates the given configuration.
func (c *Bar) Validate() {
c.XAxisList[0].Data = c.xAxisData
if c.isXYReversal {
c.YAxisList[0].Data = c.xAxisData
c.XAxisList[0].Data = nil
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,30 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Bar3D represents a 3D bar chart.
type Bar3D struct {
Chart3D
}
// Type returns the chart type.
func (*Bar3D) Type() string { return types.ChartBar3D }
// NewBar3D creates a new 3D bar chart.
func NewBar3D() *Bar3D {
c := &Bar3D{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.initChart3D()
return c
}
// AddSeries adds the new series.
func (c *Bar3D) AddSeries(name string, data []opts.Chart3DData, options ...SeriesOpts) *Bar3D {
c.addSeries(types.ChartBar3D, name, data, options...)
return c
}

View File

@@ -0,0 +1,430 @@
package charts
import (
"bytes"
"encoding/json"
"html/template"
"github.com/go-echarts/go-echarts/v2/actions"
"github.com/go-echarts/go-echarts/v2/datasets"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
)
// GlobalOpts sets the Global options for charts.
type GlobalOpts func(bc *BaseConfiguration)
// GlobalActions sets the Global actions for charts
type GlobalActions func(ba *BaseActions)
// BaseConfiguration represents an option set needed by all chart types.
type BaseConfiguration struct {
opts.Legend `json:"legend"`
opts.Tooltip `json:"tooltip"`
opts.Toolbox `json:"toolbox"`
opts.Title `json:"title"`
opts.Polar `json:"polar"`
opts.AngleAxis `json:"angleAxis"`
opts.RadiusAxis `json:"radiusAxis"`
opts.Brush `json:"brush"`
*opts.AxisPointer `json:"axisPointer"`
render.Renderer `json:"-"`
opts.Initialization `json:"-"`
opts.Assets `json:"-"`
opts.RadarComponent `json:"-"`
opts.GeoComponent `json:"-"`
opts.ParallelComponent `json:"-"`
opts.JSFunctions `json:"-"`
opts.SingleAxis `json:"-"`
MultiSeries
XYAxis
opts.XAxis3D
opts.YAxis3D
opts.ZAxis3D
opts.Grid3D
opts.Grid
legends []string
// Colors is the color list of palette.
// If no color is set in series, the colors would be adopted sequentially and circularly
// from this list as the colors of series.
Colors []string
appendColor []string // append customize color to the Colors(reverse order)
// Animation whether enable the animation, default true
Animation bool `json:"animation" default:"true"`
// Array of datasets, managed by AddDataset()
DatasetList []opts.Dataset `json:"dataset,omitempty"`
DataZoomList []opts.DataZoom `json:"datazoom,omitempty"`
VisualMapList []opts.VisualMap `json:"visualmap,omitempty"`
// ParallelAxisList represents the component list which is the coordinate axis for parallel coordinate.
ParallelAxisList []opts.ParallelAxis
has3DAxis bool
hasXYAxis bool
hasGeo bool
hasRadar bool
hasParallel bool
hasSingleAxis bool
hasPolar bool
hasBrush bool
GridList []opts.Grid `json:"grid,omitempty"`
}
// BaseActions represents a dispatchAction set needed by all chart types.
type BaseActions struct {
actions.Type `json:"type,omitempty"`
actions.Areas `json:"areas,omitempty"`
}
// JSON wraps all the options to a map so that it could be used in the base template
//
// Get data in bytes
// bs, _ : = json.Marshal(bar.JSON())
func (bc *BaseConfiguration) JSON() map[string]interface{} {
return bc.json()
}
// JSONNotEscaped works like method JSON, but it returns a marshaled object whose characters will not be escaped in the template
func (bc *BaseConfiguration) JSONNotEscaped() template.HTML {
obj := bc.json()
buff := bytes.NewBufferString("")
enc := json.NewEncoder(buff)
enc.SetEscapeHTML(false)
enc.Encode(obj)
return template.HTML(buff.String())
}
// JSONNotEscapedAction works like method JSON, but it returns a marshaled object whose characters will not be escaped in the template
func (ba *BaseActions) JSONNotEscapedAction() template.HTML {
obj := ba.json()
buff := bytes.NewBufferString("")
enc := json.NewEncoder(buff)
enc.SetEscapeHTML(false)
enc.Encode(obj)
return template.HTML(buff.String())
}
func (bc *BaseConfiguration) json() map[string]interface{} {
obj := map[string]interface{}{
"title": bc.Title,
"legend": bc.Legend,
"animation": bc.Animation,
"tooltip": bc.Tooltip,
"series": bc.MultiSeries,
}
// if only one item, use it directly instead of an Array
if len(bc.DatasetList) == 1 {
obj["dataset"] = bc.DatasetList[0]
} else if len(bc.DatasetList) > 1 {
obj["dataset"] = bc.DatasetList
}
if bc.AxisPointer != nil {
obj["axisPointer"] = bc.AxisPointer
}
if bc.hasPolar {
obj["polar"] = bc.Polar
obj["angleAxis"] = bc.AngleAxis
obj["radiusAxis"] = bc.RadiusAxis
}
if bc.hasGeo {
obj["geo"] = bc.GeoComponent
}
if bc.hasRadar {
obj["radar"] = bc.RadarComponent
}
if bc.hasParallel {
obj["parallel"] = bc.ParallelComponent
obj["parallelAxis"] = bc.ParallelAxisList
}
if bc.hasSingleAxis {
obj["singleAxis"] = bc.SingleAxis
}
if bc.Toolbox.Show {
obj["toolbox"] = bc.Toolbox
}
if len(bc.DataZoomList) > 0 {
obj["dataZoom"] = bc.DataZoomList
}
if len(bc.VisualMapList) > 0 {
obj["visualMap"] = bc.VisualMapList
}
if bc.hasXYAxis {
obj["xAxis"] = bc.XAxisList
obj["yAxis"] = bc.YAxisList
}
if bc.has3DAxis {
obj["xAxis3D"] = bc.XAxis3D
obj["yAxis3D"] = bc.YAxis3D
obj["zAxis3D"] = bc.ZAxis3D
obj["grid3D"] = bc.Grid3D
}
if bc.Theme == "white" {
obj["color"] = bc.Colors
}
if bc.BackgroundColor != "" {
obj["backgroundColor"] = bc.BackgroundColor
}
if len(bc.GridList) > 0 {
obj["grid"] = bc.GridList
}
if bc.hasBrush {
obj["brush"] = bc.Brush
}
return obj
}
// GetAssets returns the Assets options.
func (bc *BaseConfiguration) GetAssets() opts.Assets {
return bc.Assets
}
// AddDataset adds a Dataset to this chart
func (bc *BaseConfiguration) AddDataset(dataset ...opts.Dataset) {
bc.DatasetList = append(bc.DatasetList, dataset...)
}
// FillDefaultValues fill default values for chart options.
func (bc *BaseConfiguration) FillDefaultValues() {
opts.SetDefaultValue(bc)
}
func (bc *BaseConfiguration) initBaseConfiguration() {
bc.initSeriesColors()
bc.InitAssets()
bc.initXYAxis()
bc.Initialization.Validate()
bc.FillDefaultValues()
}
func (bc *BaseConfiguration) initSeriesColors() {
bc.Colors = []string{
"#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de",
"#3ba272", "#fc8452", "#9a60b4", "#ea7ccc",
}
}
func (bc *BaseConfiguration) insertSeriesColors(colors []string) {
reversed := reverseSlice(colors)
for i := 0; i < len(reversed); i++ {
bc.Colors = append(bc.Colors, "")
copy(bc.Colors[1:], bc.Colors[0:])
bc.Colors[0] = reversed[i]
}
}
func (bc *BaseConfiguration) setBaseGlobalOptions(opts ...GlobalOpts) {
for _, opt := range opts {
opt(bc)
}
}
func (ba *BaseActions) setBaseGlobalActions(opts ...GlobalActions) {
for _, opt := range opts {
opt(ba)
}
}
func (ba *BaseActions) json() map[string]interface{} {
obj := map[string]interface{}{
"type": ba.Type,
"areas": ba.Areas,
}
return obj
}
// WithAreas sets the areas of the action
func WithAreas(act actions.Areas) GlobalActions {
return func(ba *BaseActions) {
ba.Areas = act
}
}
// WithType sets the type of the action
func WithType(act actions.Type) GlobalActions {
return func(ba *BaseActions) {
ba.Type = act
}
}
// WithAngleAxisOps sets the angle of the axis.
func WithAngleAxisOps(opt opts.AngleAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.AngleAxis = opt
}
}
// WithRadiusAxisOps sets the radius of the axis.
func WithRadiusAxisOps(opt opts.RadiusAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.RadiusAxis = opt
}
}
// WithBrush sets the Brush.
func WithBrush(opt opts.Brush) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.hasBrush = true
bc.Brush = opt
}
}
// WithPolarOps sets the polar.
func WithPolarOps(opt opts.Polar) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Polar = opt
}
}
// WithTitleOpts sets the title.
func WithTitleOpts(opt opts.Title) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Title = opt
}
}
// WithAnimation enable or disable the animation.
func WithAnimation() GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Animation = false
}
}
// WithToolboxOpts sets the toolbox.
func WithToolboxOpts(opt opts.Toolbox) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Toolbox = opt
}
}
// WithSingleAxisOpts sets the single axis.
func WithSingleAxisOpts(opt opts.SingleAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.SingleAxis = opt
}
}
// WithTooltipOpts sets the tooltip.
func WithTooltipOpts(opt opts.Tooltip) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Tooltip = opt
}
}
// WithLegendOpts sets the legend.
func WithLegendOpts(opt opts.Legend) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Legend = opt
}
}
// WithInitializationOpts sets the initialization.
func WithInitializationOpts(opt opts.Initialization) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Initialization = opt
if bc.Initialization.Theme != "" &&
bc.Initialization.Theme != "white" &&
bc.Initialization.Theme != "dark" {
bc.JSAssets.Add("themes/" + opt.Theme + ".js")
}
bc.Initialization.Validate()
}
}
// WithDataZoomOpts sets the list of the zoom data.
func WithDataZoomOpts(opt ...opts.DataZoom) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.DataZoomList = append(bc.DataZoomList, opt...)
}
}
// WithVisualMapOpts sets the List of the visual map.
func WithVisualMapOpts(opt ...opts.VisualMap) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.VisualMapList = append(bc.VisualMapList, opt...)
}
}
// WithRadarComponentOpts sets the component of the radar.
func WithRadarComponentOpts(opt opts.RadarComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.RadarComponent = opt
}
}
// WithGeoComponentOpts sets the geo component.
func WithGeoComponentOpts(opt opts.GeoComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.GeoComponent = opt
bc.JSAssets.Add("maps/" + datasets.MapFileNames[opt.Map] + ".js")
}
}
// WithParallelComponentOpts sets the parallel component.
func WithParallelComponentOpts(opt opts.ParallelComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ParallelComponent = opt
}
}
// WithParallelAxisList sets the list of the parallel axis.
func WithParallelAxisList(opt []opts.ParallelAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ParallelAxisList = opt
}
}
// WithColorsOpts sets the color.
func WithColorsOpts(opt opts.Colors) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.insertSeriesColors(opt)
}
}
// reverseSlice reverses the string slice.
func reverseSlice(s []string) []string {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
return s
}
// WithGridOpts sets the List of the grid.
func WithGridOpts(opt ...opts.Grid) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.GridList = append(bc.GridList, opt...)
}
}
// WithAxisPointerOpts sets the axis pointer.
func WithAxisPointerOpts(opt *opts.AxisPointer) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.AxisPointer = opt
}
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// BoxPlot represents a boxplot chart.
type BoxPlot struct {
RectChart
}
// Type returns the chart type.
func (*BoxPlot) Type() string { return types.ChartBoxPlot }
// NewBoxPlot creates a new boxplot chart.
func NewBoxPlot() *BoxPlot {
c := &BoxPlot{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *BoxPlot) SetXAxis(x interface{}) *BoxPlot {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *BoxPlot) AddSeries(name string, data []opts.BoxPlotData, options ...SeriesOpts) *BoxPlot {
series := SingleSeries{Name: name, Type: types.ChartBoxPlot, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *BoxPlot) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,67 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/types"
)
// Chart3D is a chart in 3D coordinates.
type Chart3D struct {
BaseConfiguration
}
// WithXAxis3DOpts sets the X axis of the Chart3D instance.
func WithXAxis3DOpts(opt opts.XAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.XAxis3D = opt
}
}
// WithYAxis3DOpts sets the Y axis of the Chart3D instance.
func WithYAxis3DOpts(opt opts.YAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.YAxis3D = opt
}
}
// WithZAxis3DOpts sets the Z axis of the Chart3D instance.
func WithZAxis3DOpts(opt opts.ZAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ZAxis3D = opt
}
}
// WithGrid3DOpts sets the grid of the Chart3D instance.
func WithGrid3DOpts(opt opts.Grid3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Grid3D = opt
}
}
func (c *Chart3D) initChart3D() {
c.JSAssets.Add(opts.CompatibleEchartsJS)
c.JSAssets.Add("echarts-gl.min.js")
c.has3DAxis = true
}
func (c *Chart3D) addSeries(chartType, name string, data []opts.Chart3DData, options ...SeriesOpts) {
series := SingleSeries{
Name: name,
Type: chartType,
Data: data,
CoordSystem: types.ChartCartesian3D,
}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
}
// SetGlobalOptions sets options for the Chart3D instance.
func (c *Chart3D) SetGlobalOptions(options ...GlobalOpts) *Chart3D {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// Validate validates the given configuration.
func (c *Chart3D) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// EffectScatter represents an effect scatter chart.
type EffectScatter struct {
RectChart
}
// Type returns the chart type.
func (*EffectScatter) Type() string { return types.ChartEffectScatter }
// NewEffectScatter creates a new effect scatter chart.
func NewEffectScatter() *EffectScatter {
c := &EffectScatter{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *EffectScatter) SetXAxis(x interface{}) *EffectScatter {
c.xAxisData = x
return c
}
// AddSeries adds the Y axis.
func (c *EffectScatter) AddSeries(name string, data []opts.EffectScatterData, options ...SeriesOpts) *EffectScatter {
series := SingleSeries{Name: name, Type: types.ChartEffectScatter, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *EffectScatter) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Funnel represents a funnel chart.
type Funnel struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Funnel) Type() string { return types.ChartFunnel }
// NewFunnel creates a new funnel chart.
func NewFunnel() *Funnel {
c := &Funnel{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Funnel) AddSeries(name string, data []opts.FunnelData, options ...SeriesOpts) *Funnel {
series := SingleSeries{Name: name, Type: types.ChartFunnel, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Funnel instance.
func (c *Funnel) SetGlobalOptions(options ...GlobalOpts) *Funnel {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Gauge instance.
func (c *Funnel) SetDispatchActions(actions ...GlobalActions) *Funnel {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Funnel) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Gauge represents a gauge chart.
type Gauge struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Gauge) Type() string { return types.ChartGauge }
// NewGauge creates a new gauge chart.
func NewGauge() *Gauge {
c := &Gauge{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Gauge) AddSeries(name string, data []opts.GaugeData, options ...SeriesOpts) *Gauge {
series := SingleSeries{Name: name, Type: types.ChartGauge, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Gauge instance.
func (c *Gauge) SetGlobalOptions(options ...GlobalOpts) *Gauge {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Gauge instance.
func (c *Gauge) SetDispatchActions(actions ...GlobalActions) *Gauge {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Gauge) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,75 @@
package charts
import (
"log"
"github.com/go-echarts/go-echarts/v2/datasets"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Geo represents a geo chart.
type Geo struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Geo) Type() string { return types.ChartGeo }
var geoFormatter = `function (params) {
return params.name + ' : ' + params.value[2];
}`
// NewGeo creates a new geo chart.
func NewGeo() *Geo {
c := &Geo{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasGeo = true
return c
}
// AddSeries adds new data sets.
// geoType options:
// * types.ChartScatter
// * types.ChartEffectScatter
// * types.ChartHeatMap
func (c *Geo) AddSeries(name, geoType string, data []opts.GeoData, options ...SeriesOpts) *Geo {
series := SingleSeries{Name: name, Type: geoType, Data: data, CoordSystem: types.ChartGeo}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
func (c *Geo) extendValue(region string, v float32) []float32 {
res := make([]float32, 0)
tv := datasets.Coordinates[region]
if tv == [2]float32{0, 0} {
log.Printf("goecharts: No coordinate is specified for %s\n", region)
} else {
res = append(tv[:], v)
}
return res
}
// SetGlobalOptions sets options for the Geo instance.
func (c *Geo) SetGlobalOptions(options ...GlobalOpts) *Geo {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Geo instance.
func (c *Geo) SetDispatchActions(actions ...GlobalActions) *Geo {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Geo) Validate() {
if c.Tooltip.Formatter == "" {
c.Tooltip.Formatter = opts.FuncOpts(geoFormatter)
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,55 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Graph represents a graph chart.
type Graph struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Graph) Type() string { return types.ChartGraph }
// NewGraph creates a new graph chart.
func NewGraph() *Graph {
chart := new(Graph)
chart.initBaseConfiguration()
chart.Renderer = render.NewChartRender(chart, chart.Validate)
return chart
}
// AddSeries adds the new series.
func (c *Graph) AddSeries(name string, nodes []opts.GraphNode, links []opts.GraphLink, options ...SeriesOpts) *Graph {
series := SingleSeries{Name: name, Type: types.ChartGraph, Links: links, Data: nodes}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Graph instance.
func (c *Graph) SetGlobalOptions(options ...GlobalOpts) *Graph {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Graph instance.
func (c *Graph) SetDispatchActions(actions ...GlobalActions) *Graph {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Graph) Validate() {
// If there is no layout setting, default layout is set to "force".
for i := 0; i < len(c.MultiSeries); i++ {
if c.MultiSeries[i].Layout == "" {
c.MultiSeries[i].Layout = "force"
}
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,43 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// HeatMap represents a heatmap chart.
type HeatMap struct {
RectChart
}
// Type returns the chart type.
func (*HeatMap) Type() string { return types.ChartHeatMap }
// NewHeatMap creates a new heatmap chart.
func NewHeatMap() *HeatMap {
c := &HeatMap{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *HeatMap) SetXAxis(x interface{}) *HeatMap {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *HeatMap) AddSeries(name string, data []opts.HeatMapData, options ...SeriesOpts) *HeatMap {
series := SingleSeries{Name: name, Type: types.ChartHeatMap, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *HeatMap) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Kline represents a kline chart.
type Kline struct {
RectChart
}
// Type returns the chart type.
func (*Kline) Type() string { return types.ChartKline }
// NewKLine creates a new kline chart.
func NewKLine() *Kline {
c := &Kline{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *Kline) SetXAxis(xAxis interface{}) *Kline {
c.xAxisData = xAxis
return c
}
// AddSeries adds the new series.
func (c *Kline) AddSeries(name string, data []opts.KlineData, options ...SeriesOpts) *Kline {
series := SingleSeries{Name: name, Type: types.ChartKline, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *Kline) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,45 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Line represents a line chart.
type Line struct {
RectChart
}
// Type returns the chart type.
func (*Line) Type() string { return types.ChartLine }
// NewLine creates a new line chart.
func NewLine() *Line {
c := &Line{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *Line) SetXAxis(x interface{}) *Line {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *Line) AddSeries(name string, data []opts.LineData, options ...SeriesOpts) *Line {
series := SingleSeries{Name: name, Type: types.ChartLine, Data: data}
series.InitSeriesDefaultOpts(c.BaseConfiguration)
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *Line) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,30 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Line3D represents a 3D line chart.
type Line3D struct {
Chart3D
}
// Type returns the chart type.
func (*Line3D) Type() string { return types.ChartLine3D }
// NewLine3D creates a new 3D line chart.
func NewLine3D() *Line3D {
c := &Line3D{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.initChart3D()
return c
}
// AddSeries adds the new series.
func (c *Line3D) AddSeries(name string, data []opts.Chart3DData, options ...SeriesOpts) *Line3D {
c.addSeries(types.ChartLine3D, name, data, options...)
return c
}

View File

@@ -0,0 +1,51 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Liquid represents a liquid chart.
type Liquid struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Liquid) Type() string { return types.ChartLiquid }
// NewLiquid creates a new liquid chart.
func NewLiquid() *Liquid {
c := &Liquid{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.JSAssets.Add(opts.CompatibleEchartsJS)
c.JSAssets.Add("echarts-liquidfill.min.js")
return c
}
// AddSeries adds new data sets.
func (c *Liquid) AddSeries(name string, data []opts.LiquidData, options ...SeriesOpts) *Liquid {
series := SingleSeries{Name: name, Type: types.ChartLiquid, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Liquid instance.
func (c *Liquid) SetGlobalOptions(options ...GlobalOpts) *Liquid {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Liquid instance.
func (c *Liquid) SetDispatchActions(actions ...GlobalActions) *Liquid {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Liquid) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,58 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/datasets"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Map represents a map chart.
type Map struct {
BaseConfiguration
BaseActions
mapType string
}
// Type returns the chart type.
func (*Map) Type() string { return types.ChartMap }
// NewMap creates a new map chart.
func NewMap() *Map {
c := &Map{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// RegisterMapType registers the given mapType.
func (c *Map) RegisterMapType(mapType string) {
c.mapType = mapType
c.JSAssets.Add("maps/" + datasets.MapFileNames[mapType] + ".js")
}
// AddSeries adds new data sets.
func (c *Map) AddSeries(name string, data []opts.MapData, options ...SeriesOpts) *Map {
series := SingleSeries{Name: name, Type: types.ChartMap, MapType: c.mapType, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Map instance.
func (c *Map) SetGlobalOptions(options ...GlobalOpts) *Map {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Radar instance.
func (c *Map) SetDispatchActions(actions ...GlobalActions) *Map {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Map) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,50 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Parallel represents a parallel axis.
type Parallel struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Parallel) Type() string { return types.ChartParallel }
// NewParallel creates a new parallel instance.
func NewParallel() *Parallel {
c := &Parallel{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasParallel = true
return c
}
// AddSeries adds new data sets.
func (c *Parallel) AddSeries(name string, data []opts.ParallelData, options ...SeriesOpts) *Parallel {
series := SingleSeries{Name: name, Type: types.ChartParallel, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Parallel instance.
func (c *Parallel) SetGlobalOptions(options ...GlobalOpts) *Parallel {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Radar instance.
func (c *Parallel) SetDispatchActions(actions ...GlobalActions) *Parallel {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Parallel) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Pie represents a pie chart.
type Pie struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Pie) Type() string { return types.ChartPie }
// NewPie creates a new pie chart.
func NewPie() *Pie {
c := &Pie{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Pie) AddSeries(name string, data []opts.PieData, options ...SeriesOpts) *Pie {
series := SingleSeries{Name: name, Type: types.ChartPie, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Pie instance.
func (c *Pie) SetGlobalOptions(options ...GlobalOpts) *Pie {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Pie instance.
func (c *Pie) SetDispatchActions(actions ...GlobalActions) *Pie {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Pie) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,55 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Radar represents a radar chart.
type Radar struct {
BaseConfiguration
BaseActions
// SymbolKeepAspect is whether to keep aspect for symbols in the form of path://.
SymbolKeepAspect bool
}
// Type returns the chart type.
func (*Radar) Type() string { return types.ChartRadar }
// NewRadar creates a new radar chart.
func NewRadar() *Radar {
c := &Radar{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasRadar = true
return c
}
// AddSeries adds new data sets.
func (c *Radar) AddSeries(name string, data []opts.RadarData, options ...SeriesOpts) *Radar {
series := SingleSeries{Name: name, Type: types.ChartRadar, Data: data, SymbolKeepAspect: c.SymbolKeepAspect}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
c.legends = append(c.legends, name)
return c
}
// SetGlobalOptions sets options for the Radar instance.
func (c *Radar) SetGlobalOptions(options ...GlobalOpts) *Radar {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Radar instance.
func (c *Radar) SetDispatchActions(actions ...GlobalActions) *Radar {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Radar) Validate() {
c.Legend.Data = c.legends
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,111 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
)
type Overlaper interface {
overlap() MultiSeries
}
// XYAxis represent the X and Y axis in the rectangular coordinates.
type XYAxis struct {
XAxisList []opts.XAxis `json:"xaxis"`
YAxisList []opts.YAxis `json:"yaxis"`
}
func (xy *XYAxis) initXYAxis() {
xy.XAxisList = append(xy.XAxisList, opts.XAxis{})
xy.YAxisList = append(xy.YAxisList, opts.YAxis{})
}
// ExtendXAxis adds new X axes.
func (xy *XYAxis) ExtendXAxis(xAxis ...opts.XAxis) {
xy.XAxisList = append(xy.XAxisList, xAxis...)
}
// ExtendYAxis adds new Y axes.
func (xy *XYAxis) ExtendYAxis(yAxis ...opts.YAxis) {
xy.YAxisList = append(xy.YAxisList, yAxis...)
}
// WithXAxisOpts sets the X axis.
func WithXAxisOpts(opt opts.XAxis, index ...int) GlobalOpts {
return func(bc *BaseConfiguration) {
if len(index) == 0 {
index = []int{0}
}
for i := 0; i < len(index); i++ {
bc.XYAxis.XAxisList[index[i]] = opt
}
}
}
// WithYAxisOpts sets the Y axis.
func WithYAxisOpts(opt opts.YAxis, index ...int) GlobalOpts {
return func(bc *BaseConfiguration) {
if len(index) == 0 {
index = []int{0}
}
for i := 0; i < len(index); i++ {
bc.XYAxis.YAxisList[index[i]] = opt
}
}
}
// RectConfiguration contains options for the rectangular coordinates.
type RectConfiguration struct {
BaseConfiguration
BaseActions
}
func (rect *RectConfiguration) setRectGlobalOptions(options ...GlobalOpts) {
rect.BaseConfiguration.setBaseGlobalOptions(options...)
}
func (rect *RectConfiguration) setRectGlobalActions(options ...GlobalActions) {
rect.BaseActions.setBaseGlobalActions(options...)
}
// RectChart is a chart in RectChart coordinate.
type RectChart struct {
RectConfiguration
xAxisData interface{}
}
func (rc *RectChart) overlap() MultiSeries {
return rc.MultiSeries
}
// SetGlobalOptions sets options for the RectChart instance.
func (rc *RectChart) SetGlobalOptions(options ...GlobalOpts) *RectChart {
rc.RectConfiguration.setRectGlobalOptions(options...)
return rc
}
// SetDispatchActions sets actions for the RectChart instance.
func (rc *RectChart) SetDispatchActions(options ...GlobalActions) *RectChart {
rc.RectConfiguration.setRectGlobalActions(options...)
return rc
}
// Overlap composes multiple charts into one single canvas.
// It is only suited for some of the charts which are in rectangular coordinate.
// Supported charts: Bar/BoxPlot/Line/Scatter/EffectScatter/Kline/HeatMap
func (rc *RectChart) Overlap(a ...Overlaper) {
for i := 0; i < len(a); i++ {
rc.MultiSeries = append(rc.MultiSeries, a[i].overlap()...)
}
}
// Validate validates the given configuration.
func (rc *RectChart) Validate() {
// Make sure that the data of X axis won't be cleaned for XAxisOpts
rc.XAxisList[0].Data = rc.xAxisData
// Make sure that the labels of Y axis show correctly
for i := 0; i < len(rc.YAxisList); i++ {
rc.YAxisList[i].AxisLabel.Show = true
}
rc.Assets.Validate(rc.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Sankey represents a sankey chart.
type Sankey struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Sankey) Type() string { return types.ChartSankey }
// NewSankey creates a new sankey chart.
func NewSankey() *Sankey {
c := &Sankey{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Sankey) AddSeries(name string, nodes []opts.SankeyNode, links []opts.SankeyLink, options ...SeriesOpts) *Sankey {
series := SingleSeries{Name: name, Type: types.ChartSankey, Data: nodes, Links: links}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Sankey instance.
func (c *Sankey) SetGlobalOptions(options ...GlobalOpts) *Sankey {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Sankey instance.
func (c *Sankey) SetDispatchActions(actions ...GlobalActions) *Sankey {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Sankey) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Scatter represents a scatter chart.
type Scatter struct {
RectChart
}
// Type returns the chart type.
func (*Scatter) Type() string { return types.ChartScatter }
// NewScatter creates a new scatter chart.
func NewScatter() *Scatter {
c := &Scatter{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *Scatter) SetXAxis(x interface{}) *Scatter {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *Scatter) AddSeries(name string, data []opts.ScatterData, options ...SeriesOpts) *Scatter {
series := SingleSeries{Name: name, Type: types.ChartScatter, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *Scatter) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,30 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Scatter3D represents a 3D scatter chart.
type Scatter3D struct {
Chart3D
}
// Type returns the chart type.
func (*Scatter3D) Type() string { return types.ChartScatter3D }
// NewScatter3D creates a new 3D scatter chart.
func NewScatter3D() *Scatter3D {
c := &Scatter3D{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.initChart3D()
return c
}
// AddSeries adds the new series.
func (c *Scatter3D) AddSeries(name string, data []opts.Chart3DData, options ...SeriesOpts) *Scatter3D {
c.addSeries(types.ChartScatter3D, name, data, options...)
return c
}

View File

@@ -0,0 +1,549 @@
package charts
import "github.com/go-echarts/go-echarts/v2/opts"
type SingleSeries struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
// Rectangular charts
Stack string `json:"stack,omitempty"`
XAxisIndex int `json:"xAxisIndex,omitempty"`
YAxisIndex int `json:"yAxisIndex,omitempty"`
// Bar
BarGap string `json:"barGap,omitempty"`
BarCategoryGap string `json:"barCategoryGap,omitempty"`
ShowBackground bool `json:"showBackground,omitempty"`
RoundCap bool `json:"roundCap,omitempty"`
// Bar3D
Shading string `json:"shading,omitempty"`
// Graph
Links interface{} `json:"links,omitempty"`
Layout string `json:"layout,omitempty"`
Force interface{} `json:"force,omitempty"`
Categories interface{} `json:"categories,omitempty"`
Roam bool `json:"roam,omitempty"`
EdgeSymbol interface{} `json:"edgeSymbol,omitempty"`
EdgeSymbolSize interface{} `json:"edgeSymbolSize,omitempty"`
EdgeLabel interface{} `json:"edgeLabel,omitempty"`
Draggable bool `json:"draggable,omitempty"`
FocusNodeAdjacency bool `json:"focusNodeAdjacency,omitempty"`
SymbolKeepAspect bool `json:"symbolKeepAspect,omitempty"`
// KLine
BarWidth string `json:"barWidth,omitempty"`
BarMinWidth string `json:"barMinWidth,omitempty"`
BarMaxWidth string `json:"barMaxWidth,omitempty"`
// Line
Step interface{} `json:"step,omitempty"`
Smooth bool `json:"smooth"`
ConnectNulls bool `json:"connectNulls"`
ShowSymbol bool `json:"showSymbol"`
Symbol string `json:"symbol,omitempty"`
Color string `json:"color,omitempty"`
// Liquid
IsLiquidOutline bool `json:"outline,omitempty"`
IsWaveAnimation bool `json:"waveAnimation"`
// Map
MapType string `json:"map,omitempty"`
CoordSystem string `json:"coordinateSystem,omitempty"`
// Pie
RoseType interface{} `json:"roseType,omitempty"`
Center interface{} `json:"center,omitempty"`
Radius interface{} `json:"radius,omitempty"`
// Scatter
SymbolSize interface{} `json:"symbolSize,omitempty"`
// Tree
Orient string `json:"orient,omitempty"`
ExpandAndCollapse bool `json:"expandAndCollapse,omitempty"`
InitialTreeDepth int `json:"initialTreeDepth,omitempty"`
Leaves interface{} `json:"leaves,omitempty"`
Left string `json:"left,omitempty"`
Right string `json:"right,omitempty"`
Top string `json:"top,omitempty"`
Bottom string `json:"bottom,omitempty"`
// TreeMap
LeafDepth int `json:"leafDepth,omitempty"`
Levels interface{} `json:"levels,omitempty"`
UpperLabel interface{} `json:"upperLabel,omitempty"`
// WordCloud
Shape string `json:"shape,omitempty"`
SizeRange []float32 `json:"sizeRange,omitempty"`
RotationRange []float32 `json:"rotationRange,omitempty"`
// Sunburst
NodeClick string `json:"nodeClick,omitempty"`
Sort string `json:"sort,omitempty"`
RenderLabelForZeroData bool `json:"renderLabelForZeroData"`
SelectedMode bool `json:"selectedMode"`
Animation bool `json:"animation" default:"true"`
AnimationThreshold int `json:"animationThreshold,omitempty"`
AnimationDuration int `json:"animationDuration,omitempty"`
AnimationEasing string `json:"animationEasing,omitempty"`
AnimationDelay int `json:"animationDelay,omitempty"`
AnimationDurationUpdate int `json:"animationDurationUpdate,omitempty"`
AnimationEasingUpdate string `json:"animationEasingUpdate,omitempty"`
AnimationDelayUpdate int `json:"animationDelayUpdate,omitempty"`
// series data
Data interface{} `json:"data,omitempty"`
DatasetIndex int `json:"datasetIndex,omitempty"`
// series options
*opts.Encode `json:"encode,omitempty"`
*opts.ItemStyle `json:"itemStyle,omitempty"`
*opts.Label `json:"label,omitempty"`
*opts.LabelLine `json:"labelLine,omitempty"`
*opts.Emphasis `json:"emphasis,omitempty"`
*opts.MarkLines `json:"markLine,omitempty"`
*opts.MarkAreas `json:"markArea,omitempty"`
*opts.MarkPoints `json:"markPoint,omitempty"`
*opts.RippleEffect `json:"rippleEffect,omitempty"`
*opts.LineStyle `json:"lineStyle,omitempty"`
*opts.AreaStyle `json:"areaStyle,omitempty"`
*opts.TextStyle `json:"textStyle,omitempty"`
*opts.CircularStyle `json:"circular,omitempty"`
}
type SeriesOpts func(s *SingleSeries)
func WithSeriesAnimation(enable bool) SeriesOpts {
return func(s *SingleSeries) {
s.Animation = enable
}
}
// WithLabelOpts sets the label.
func WithLabelOpts(opt opts.Label) SeriesOpts {
return func(s *SingleSeries) {
s.Label = &opt
}
}
// WithEmphasisOpts sets the emphasis.
func WithEmphasisOpts(opt opts.Emphasis) SeriesOpts {
return func(s *SingleSeries) {
s.Emphasis = &opt
}
}
// WithAreaStyleOpts sets the area style.
func WithAreaStyleOpts(opt opts.AreaStyle) SeriesOpts {
return func(s *SingleSeries) {
s.AreaStyle = &opt
}
}
// WithItemStyleOpts sets the item style.
func WithItemStyleOpts(opt opts.ItemStyle) SeriesOpts {
return func(s *SingleSeries) {
s.ItemStyle = &opt
}
}
// WithRippleEffectOpts sets the ripple effect.
func WithRippleEffectOpts(opt opts.RippleEffect) SeriesOpts {
return func(s *SingleSeries) {
s.RippleEffect = &opt
}
}
// WithLineStyleOpts sets the line style.
func WithLineStyleOpts(opt opts.LineStyle) SeriesOpts {
return func(s *SingleSeries) {
s.LineStyle = &opt
}
}
// With CircularStyle Opts
func WithCircularStyleOpts(opt opts.CircularStyle) SeriesOpts {
return func(s *SingleSeries) {
s.CircularStyle = &opt
}
}
/* Chart Options */
// WithBarChartOpts sets the BarChart option.
func WithBarChartOpts(opt opts.BarChart) SeriesOpts {
return func(s *SingleSeries) {
s.Stack = opt.Stack
s.BarGap = opt.BarGap
s.BarCategoryGap = opt.BarCategoryGap
s.XAxisIndex = opt.XAxisIndex
s.YAxisIndex = opt.YAxisIndex
s.ShowBackground = opt.ShowBackground
s.RoundCap = opt.RoundCap
s.CoordSystem = opt.CoordSystem
}
}
// WithSunburstOpts sets the SunburstChart option.
func WithSunburstOpts(opt opts.SunburstChart) SeriesOpts {
return func(s *SingleSeries) {
s.NodeClick = opt.NodeClick
s.Sort = opt.Sort
s.RenderLabelForZeroData = opt.RenderLabelForZeroData
s.SelectedMode = opt.SelectedMode
s.Animation = opt.Animation
s.AnimationThreshold = opt.AnimationThreshold
s.AnimationDuration = opt.AnimationDuration
s.AnimationEasing = opt.AnimationEasing
s.AnimationDelay = opt.AnimationDelay
s.AnimationDurationUpdate = opt.AnimationDurationUpdate
s.AnimationEasingUpdate = opt.AnimationEasingUpdate
s.AnimationDelayUpdate = opt.AnimationDelayUpdate
}
}
// WithGraphChartOpts sets the GraphChart option.
func WithGraphChartOpts(opt opts.GraphChart) SeriesOpts {
return func(s *SingleSeries) {
s.Layout = opt.Layout
s.Force = opt.Force
s.Roam = opt.Roam
s.EdgeSymbol = opt.EdgeSymbol
s.EdgeSymbolSize = opt.EdgeSymbolSize
s.Draggable = opt.Draggable
s.FocusNodeAdjacency = opt.FocusNodeAdjacency
s.Categories = opt.Categories
s.EdgeLabel = opt.EdgeLabel
s.SymbolKeepAspect = opt.SymbolKeepAspect
}
}
// WithHeatMapChartOpts sets the HeatMapChart option.
func WithHeatMapChartOpts(opt opts.HeatMapChart) SeriesOpts {
return func(s *SingleSeries) {
s.XAxisIndex = opt.XAxisIndex
s.YAxisIndex = opt.YAxisIndex
}
}
// WithLineChartOpts sets the LineChart option.
func WithLineChartOpts(opt opts.LineChart) SeriesOpts {
return func(s *SingleSeries) {
s.YAxisIndex = opt.YAxisIndex
s.Stack = opt.Stack
s.Smooth = opt.Smooth
s.ShowSymbol = opt.ShowSymbol
s.Symbol = opt.Symbol
s.SymbolSize = opt.SymbolSize
s.Step = opt.Step
s.XAxisIndex = opt.XAxisIndex
s.YAxisIndex = opt.YAxisIndex
s.ConnectNulls = opt.ConnectNulls
s.Color = opt.Color
s.SymbolKeepAspect = opt.SymbolKeepAspect
}
}
// WithLineChartOpts sets the LineChart option.
func WithKlineChartOpts(opt opts.KlineChart) SeriesOpts {
return func(s *SingleSeries) {
s.BarWidth = opt.BarWidth
s.BarMinWidth = opt.BarMinWidth
s.BarMaxWidth = opt.BarMaxWidth
}
}
// WithPieChartOpts sets the PieChart option.
func WithPieChartOpts(opt opts.PieChart) SeriesOpts {
return func(s *SingleSeries) {
s.RoseType = opt.RoseType
s.Center = opt.Center
s.Radius = opt.Radius
}
}
// WithScatterChartOpts sets the ScatterChart option.
func WithScatterChartOpts(opt opts.ScatterChart) SeriesOpts {
return func(s *SingleSeries) {
s.XAxisIndex = opt.XAxisIndex
s.YAxisIndex = opt.YAxisIndex
s.SymbolKeepAspect = opt.SymbolKeepAspect
}
}
// WithLiquidChartOpts sets the LiquidChart option.
func WithLiquidChartOpts(opt opts.LiquidChart) SeriesOpts {
return func(s *SingleSeries) {
s.Shape = opt.Shape
s.IsLiquidOutline = opt.IsShowOutline
s.IsWaveAnimation = opt.IsWaveAnimation
}
}
// WithBar3DChartOpts sets the Bar3DChart option.
func WithBar3DChartOpts(opt opts.Bar3DChart) SeriesOpts {
return func(s *SingleSeries) {
s.Shading = opt.Shading
}
}
// WithTreeOpts sets the TreeChart option.
func WithTreeOpts(opt opts.TreeChart) SeriesOpts {
return func(s *SingleSeries) {
s.Layout = opt.Layout
s.Orient = opt.Orient
s.ExpandAndCollapse = opt.ExpandAndCollapse
s.InitialTreeDepth = opt.InitialTreeDepth
s.Roam = opt.Roam
s.Label = opt.Label
s.Leaves = opt.Leaves
s.Right = opt.Right
s.Left = opt.Left
s.Top = opt.Top
s.Bottom = opt.Bottom
s.SymbolKeepAspect = opt.SymbolKeepAspect
}
}
// WithTreeMapOpts sets the TreeMapChart options.
func WithTreeMapOpts(opt opts.TreeMapChart) SeriesOpts {
return func(s *SingleSeries) {
s.Animation = opt.Animation
s.LeafDepth = opt.LeafDepth
s.Roam = opt.Roam
s.Levels = opt.Levels
s.UpperLabel = opt.UpperLabel
s.Right = opt.Right
s.Left = opt.Left
s.Top = opt.Top
s.Bottom = opt.Bottom
}
}
// WithWorldCloudChartOpts sets the WorldCloudChart option.
func WithWorldCloudChartOpts(opt opts.WordCloudChart) SeriesOpts {
return func(s *SingleSeries) {
s.Shape = opt.Shape
s.SizeRange = opt.SizeRange
s.RotationRange = opt.RotationRange
}
}
// WithMarkLineNameTypeItemOpts sets the type of the MarkLine.
func WithMarkLineNameTypeItemOpts(opt ...opts.MarkLineNameTypeItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkLines == nil {
s.MarkLines = &opts.MarkLines{}
}
for _, o := range opt {
s.MarkLines.Data = append(s.MarkLines.Data, o)
}
}
}
// WithMarkLineStyleOpts sets the style of the MarkLine.
func WithMarkLineStyleOpts(opt opts.MarkLineStyle) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkLines == nil {
s.MarkLines = &opts.MarkLines{}
}
s.MarkLines.MarkLineStyle = opt
}
}
// WithMarkLineNameCoordItemOpts sets the coordinates of the MarkLine.
func WithMarkLineNameCoordItemOpts(opt ...opts.MarkLineNameCoordItem) SeriesOpts {
type MLNameCoord struct {
Name string `json:"name,omitempty"`
Coord []interface{} `json:"coord"`
}
return func(s *SingleSeries) {
if s.MarkLines == nil {
s.MarkLines = &opts.MarkLines{}
}
for _, o := range opt {
s.MarkLines.Data = append(
s.MarkLines.Data,
[]MLNameCoord{{Name: o.Name, Coord: o.Coordinate0}, {Coord: o.Coordinate1}},
)
}
}
}
// WithMarkLineNameXAxisItemOpts sets the X axis of the MarkLine.
func WithMarkLineNameXAxisItemOpts(opt ...opts.MarkLineNameXAxisItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkLines == nil {
s.MarkLines = &opts.MarkLines{}
}
for _, o := range opt {
s.MarkLines.Data = append(s.MarkLines.Data, o)
}
}
}
// WithMarkLineNameYAxisItemOpts sets the Y axis of the MarkLine.
func WithMarkLineNameYAxisItemOpts(opt ...opts.MarkLineNameYAxisItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkLines == nil {
s.MarkLines = &opts.MarkLines{}
}
for _, o := range opt {
s.MarkLines.Data = append(s.MarkLines.Data, o)
}
}
}
// WithMarkAreaNameTypeItemOpts sets the type of the MarkArea.
func WithMarkAreaNameTypeItemOpts(opt ...opts.MarkAreaNameTypeItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkAreas == nil {
s.MarkAreas = &opts.MarkAreas{}
}
for _, o := range opt {
s.MarkAreas.Data = append(s.MarkAreas.Data, o)
}
}
}
// WithMarkAreaStyleOpts sets the style of the MarkArea.
func WithMarkAreaStyleOpts(opt opts.MarkAreaStyle) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkAreas == nil {
s.MarkAreas = &opts.MarkAreas{}
}
s.MarkAreas.MarkAreaStyle = opt
}
}
// WithMarkAreaNameCoordItemOpts sets the coordinates of the MarkLine.
func WithMarkAreaNameCoordItemOpts(opt ...opts.MarkAreaNameCoordItem) SeriesOpts {
type MANameCoord struct {
Name string `json:"name,omitempty"`
ItemStyle *opts.ItemStyle `json:"itemStyle"`
Coord []interface{} `json:"coord"`
}
return func(s *SingleSeries) {
if s.MarkAreas == nil {
s.MarkAreas = &opts.MarkAreas{}
}
for _, o := range opt {
s.MarkAreas.Data = append(
s.MarkAreas.Data,
[]MANameCoord{
{Name: o.Name, ItemStyle: o.ItemStyle, Coord: o.Coordinate0},
{Coord: o.Coordinate1},
},
)
}
}
}
// WithMarkAreaNameXAxisItemOpts sets the X axis of the MarkLine.
func WithMarkAreaNameXAxisItemOpts(opt ...opts.MarkAreaNameXAxisItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkAreas == nil {
s.MarkAreas = &opts.MarkAreas{}
}
for _, o := range opt {
s.MarkAreas.Data = append(s.MarkAreas.Data, o)
}
}
}
// WithMarkAreaNameYAxisItemOpts sets the Y axis of the MarkLine.
func WithMarkAreaNameYAxisItemOpts(opt ...opts.MarkAreaNameYAxisItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkAreas == nil {
s.MarkAreas = &opts.MarkAreas{}
}
for _, o := range opt {
s.MarkAreas.Data = append(s.MarkAreas.Data, o)
}
}
}
// WithMarkPointNameTypeItemOpts sets the type of the MarkPoint.
func WithMarkPointNameTypeItemOpts(opt ...opts.MarkPointNameTypeItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkPoints == nil {
s.MarkPoints = &opts.MarkPoints{}
}
for _, o := range opt {
s.MarkPoints.Data = append(s.MarkPoints.Data, o)
}
}
}
// WithMarkPointStyleOpts sets the style of the MarkPoint.
func WithMarkPointStyleOpts(opt opts.MarkPointStyle) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkPoints == nil {
s.MarkPoints = &opts.MarkPoints{}
}
s.MarkPoints.MarkPointStyle = opt
}
}
// WithMarkPointNameCoordItemOpts sets the coordinated of the MarkPoint.
func WithMarkPointNameCoordItemOpts(opt ...opts.MarkPointNameCoordItem) SeriesOpts {
return func(s *SingleSeries) {
if s.MarkPoints == nil {
s.MarkPoints = &opts.MarkPoints{}
}
for _, o := range opt {
s.MarkPoints.Data = append(s.MarkPoints.Data, o)
}
}
}
func (s *SingleSeries) InitSeriesDefaultOpts(c BaseConfiguration) {
opts.SetDefaultValue(s)
// some special inherited options from BaseConfiguration
s.Animation = c.Animation
}
func (s *SingleSeries) ConfigureSeriesOpts(options ...SeriesOpts) {
for _, opt := range options {
opt(s)
}
}
// MultiSeries represents multiple series.
type MultiSeries []SingleSeries
// SetSeriesOptions sets options for all the series.
// Previous options will be overwrote every time hence setting them on the `AddSeries` if you want
// to customize each series individually
//
// here -> ↓ <-
//
// func (c *Bar) AddSeries(name string, data []opts.BarData, options ...SeriesOpts)
func (ms *MultiSeries) SetSeriesOptions(opts ...SeriesOpts) {
s := *ms
for i := 0; i < len(s); i++ {
s[i].ConfigureSeriesOpts(opts...)
}
}
// WithEncodeOpts Set encodes for dataSets
func WithEncodeOpts(opt opts.Encode) SeriesOpts {
return func(s *SingleSeries) {
s.Encode = &opt
}
}
// WithDatasetIndex sets the datasetIndex option.
func WithDatasetIndex(index int) SeriesOpts {
return func(s *SingleSeries) {
s.DatasetIndex = index
}
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Sunburst represents a sunburst chart.
type Sunburst struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Sunburst) Type() string { return types.ChartSunburst }
// NewSunburst creates a new sunburst chart instance.
func NewSunburst() *Sunburst {
c := &Sunburst{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Sunburst) AddSeries(name string, data []opts.SunBurstData, options ...SeriesOpts) *Sunburst {
series := SingleSeries{Name: name, Type: types.ChartSunburst, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Pie instance.
func (c *Sunburst) SetGlobalOptions(options ...GlobalOpts) *Sunburst {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Sunburst instance.
func (c *Sunburst) SetDispatchActions(actions ...GlobalActions) *Sunburst {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Sunburst) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,30 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Surface3D represents a 3D surface chart.
type Surface3D struct {
Chart3D
}
// Type returns the chart type.
func (*Surface3D) Type() string { return types.ChartSurface3D }
// NewSurface3D creates a new 3d surface chart.
func NewSurface3D() *Surface3D {
c := &Surface3D{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.initChart3D()
return c
}
// AddSeries adds the new series.
func (c *Surface3D) AddSeries(name string, data []opts.Chart3DData, options ...SeriesOpts) *Surface3D {
c.addSeries(types.ChartScatter3D, name, data, options...)
return c
}

View File

@@ -0,0 +1,54 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// ThemeRiver represents a theme river chart.
type ThemeRiver struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*ThemeRiver) Type() string { return types.ChartThemeRiver }
// NewThemeRiver creates a new theme river chart.
func NewThemeRiver() *ThemeRiver {
c := &ThemeRiver{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasSingleAxis = true
return c
}
// AddSeries adds new data sets.
func (c *ThemeRiver) AddSeries(name string, data []opts.ThemeRiverData, options ...SeriesOpts) *ThemeRiver {
cd := make([][3]interface{}, len(data))
for i := 0; i < len(data); i++ {
cd[i] = data[i].ToList()
}
series := SingleSeries{Name: name, Type: types.ChartThemeRiver, Data: cd}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the ThemeRiver instance.
func (c *ThemeRiver) SetGlobalOptions(options ...GlobalOpts) *ThemeRiver {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the ThemeRiver instance.
func (c *ThemeRiver) SetDispatchActions(actions ...GlobalActions) *ThemeRiver {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *ThemeRiver) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Tree represents a Tree chart.
type Tree struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Tree) Type() string { return types.ChartTree }
// NewTree creates a new Tree chart instance.
func NewTree() *Tree {
c := &Tree{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Tree) AddSeries(name string, data []opts.TreeData, options ...SeriesOpts) *Tree {
series := SingleSeries{Name: name, Type: types.ChartTree, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Tree instance.
func (c *Tree) SetGlobalOptions(options ...GlobalOpts) *Tree {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Tree instance.
func (c *Tree) SetDispatchActions(actions ...GlobalActions) *Tree {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Tree) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// TreeMap represents a TreeMap chart.
type TreeMap struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*TreeMap) Type() string { return types.ChartTreeMap }
// NewTreeMap creates a new TreeMap chart instance.
func NewTreeMap() *TreeMap {
c := &TreeMap{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *TreeMap) AddSeries(name string, data []opts.TreeMapNode, options ...SeriesOpts) *TreeMap {
series := SingleSeries{Name: name, Type: types.ChartTreeMap, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the TreeMap instance.
func (c *TreeMap) SetGlobalOptions(options ...GlobalOpts) *TreeMap {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the TreeMap instance.
func (c *TreeMap) SetDispatchActions(actions ...GlobalActions) *TreeMap {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *TreeMap) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,67 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// WordCloud represents a word cloud chart.
type WordCloud struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*WordCloud) Type() string { return types.ChartWordCloud }
var wcTextColor = `function () {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160)].join(',') + ')';
}`
// NewWordCloud creates a new word cloud chart.
func NewWordCloud() *WordCloud {
c := &WordCloud{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.JSAssets.Add(opts.CompatibleEchartsJS)
c.JSAssets.Add("echarts-wordcloud.min.js")
return c
}
// AddSeries adds new data sets.
func (c *WordCloud) AddSeries(name string, data []opts.WordCloudData, options ...SeriesOpts) *WordCloud {
series := SingleSeries{Name: name, Type: types.ChartWordCloud, Data: data}
series.ConfigureSeriesOpts(options...)
// set default random color for WordCloud chart
if series.TextStyle == nil {
series.TextStyle = &opts.TextStyle{Normal: &opts.TextStyle{}}
}
if series.TextStyle.Normal.Color == "" {
series.TextStyle.Normal.Color = opts.FuncOpts(wcTextColor)
}
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the WordCloud instance.
func (c *WordCloud) SetGlobalOptions(options ...GlobalOpts) *WordCloud {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the WordCloud instance.
func (c *WordCloud) SetDispatchActions(actions ...GlobalActions) *WordCloud {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *WordCloud) Validate() {
c.Assets.Validate(c.AssetsHost)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
package datasets
var MapFileNames = map[string]string{
"china": "china",
"world": "world",
"广东": "guangdong",
"安徽": "anhui",
"福建": "fujian",
"甘肃": "gansu",
"广西": "guangxi",
"贵州": "guizhou",
"海南": "hainan",
"河北": "hebei",
"黑龙江": "heilongjiang",
"河南": "henan",
"湖北": "hubei",
"湖南": "hunan",
"江苏": "jiangsu",
"江西": "jiangxi",
"吉林": "jilin",
"辽宁": "liaoning",
"内蒙古": "neimenggu",
"宁夏": "ningxia",
"青海": "qinghai",
"山东": "shandong",
"山西": "shanxi",
"陕西": "shanxi1",
"四川": "sichuan",
"台湾": "taiwan",
"新疆": "xinjiang",
"西藏": "xizang",
"云南": "yunnan",
"浙江": "zhejiang",
"七台河": "hei1_long2_jiang1_qi1_tai2_he2",
"万宁": "hai3_nan2_wan4_ning2",
"三亚": "hai3_nan2_san1_ya4",
"三明": "fu2_jian4_san1_ming2",
"三沙": "hai3_nan2_san1_sha1",
"三门峡": "he2_nan2_san1_men2_xia2",
"上海": "shanghai",
"上饶": "jiang1_xi1_shang4_rao2",
"东方": "hai3_nan2_dong1_fang1",
"东沙群岛": "guang3_dong1_dong1_sha1_qun2_dao3",
"东莞": "guang3_dong1_dong1_guan1",
"东营": "shan1_dong1_dong1_ying2",
"中卫": "ning2_xia4_zhong1_wei4",
"中山": "guang3_dong1_zhong1_shan1",
"临夏回族自治州": "gan1_su4_lin2_xia4_hui2_zu2_zi4_zhi4_zhou1",
"临汾": "shan1_xi1_lin2_fen2",
"临沂": "shan1_dong1_lin2_yi2",
"临沧": "yun2_nan2_lin2_cang1",
"临高县": "hai3_nan2_lin2_gao1_xian4",
"丹东": "liao2_ning2_dan1_dong1",
"丽水": "zhe4_jiang1_li4_shui3",
"丽江": "yun2_nan2_li4_jiang1",
"乌兰察布": "nei4_meng2_gu3_wu1_lan2_cha2_bu4",
"乌海": "nei4_meng2_gu3_wu1_hai3",
"乌鲁木齐": "xin1_jiang1_wu1_lu3_mu4_qi2",
"乐东黎族自治县": "hai3_nan2_le4_dong1_li2_zu2_zi4_zhi4_xian4",
"乐山": "si4_chuan1_le4_shan1",
"九江": "jiang1_xi1_jiu3_jiang1",
"云浮": "guang3_dong1_yun2_fu2",
"五家渠": "xin1_jiang1_wu3_jia1_qu2",
"五指山": "hai3_nan2_wu3_zhi3_shan1",
"亳州": "an1_hui1_bo2_zhou1",
"仙桃": "hu2_bei3_xian1_tao2",
"伊春": "hei1_long2_jiang1_yi1_chun1",
"伊犁哈萨克自治州": "xin1_jiang1_yi1_li2_ha1_sa4_ke4_zi4_zhi4_zhou1",
"佛山": "guang3_dong1_fo2_shan1",
"佳木斯": "hei1_long2_jiang1_jia1_mu4_si1",
"保亭黎族苗族自治县": "hai3_nan2_bao3_ting2_li2_zu2_miao2_zu2_zi4_zhi4_xian4",
"保定": "he2_bei3_bao3_ding4",
"保山": "yun2_nan2_bao3_shan1",
"信阳": "he2_nan2_xin4_yang2",
"儋州": "hai3_nan2_dan1_zhou1",
"克孜勒苏柯尔克孜自治州": "xin1_jiang1_ke4_zi1_le4_su1_ke1_er3_ke4_zi1_zi4_zhi4_zhou1",
"克拉玛依": "xin1_jiang1_ke4_la1_ma3_yi1",
"六安": "an1_hui1_liu4_an1",
"六盘水": "gui4_zhou1_liu4_pan2_shui3",
"兰州": "gan1_su4_lan2_zhou1",
"兴安盟": "nei4_meng2_gu3_xing1_an1_meng2",
"内江": "si4_chuan1_nei4_jiang1",
"凉山彝族自治州": "si4_chuan1_liang2_shan1_yi2_zu2_zi4_zhi4_zhou1",
"包头": "nei4_meng2_gu3_bao1_tou2",
"北京": "beijing",
"北屯": "xin1_jiang1_bei3_tun2",
"北海": "guang3_xi1_bei3_hai3",
"十堰": "hu2_bei3_shi2_yan4",
"南京": "jiang1_su1_nan2_jing1",
"南充": "si4_chuan1_nan2_chong1",
"南宁": "guang3_xi1_nan2_ning2",
"南平": "fu2_jian4_nan2_ping2",
"南昌": "jiang1_xi1_nan2_chang1",
"南通": "jiang1_su1_nan2_tong1",
"南阳": "he2_nan2_nan2_yang2",
"博尔塔拉蒙古自治州": "xin1_jiang1_bo2_er3_ta3_la1_meng2_gu3_zi4_zhi4_zhou1",
"厦门": "fu2_jian4_sha4_men2",
"双河": "xin1_jiang1_shuang1_he2",
"双鸭山": "hei1_long2_jiang1_shuang1_ya1_shan1",
"可克达拉": "xin1_jiang1_ke3_ke4_da2_la1",
"台州": "zhe4_jiang1_tai2_zhou1",
"合肥": "an1_hui1_he2_fei2",
"吉安": "jiang1_xi1_ji2_an1",
"吉林市": "ji2_lin2_ji2_lin2",
"吐鲁番": "xin1_jiang1_tu3_lu3_fan1",
"吕梁": "shan1_xi1_lv3_liang2",
"吴忠": "ning2_xia4_wu2_zhong1",
"周口": "he2_nan2_zhou1_kou3",
"呼伦贝尔": "nei4_meng2_gu3_hu1_lun2_bei4_er3",
"呼和浩特": "nei4_meng2_gu3_hu1_he2_hao4_te4",
"和田地区": "xin1_jiang1_he2_tian2_di4_qu1",
"咸宁": "hu2_bei3_xian2_ning2",
"咸阳": "shan3_xi1_xian2_yang2",
"哈密": "xin1_jiang1_ha1_mi4",
"哈尔滨": "hei1_long2_jiang1_ha1_er3_bin1",
"唐山": "he2_bei3_tang2_shan1",
"商丘": "he2_nan2_shang1_qiu1",
"商洛": "shan3_xi1_shang1_luo4",
"喀什地区": "xin1_jiang1_ka1_shi2_di4_qu1",
"嘉兴": "zhe4_jiang1_jia1_xing1",
"嘉峪关": "gan1_su4_jia1_yu4_guan1",
"四平": "ji2_lin2_si4_ping2",
"固原": "ning2_xia4_gu4_yuan2",
"图木舒克": "xin1_jiang1_tu2_mu4_shu1_ke4",
"塔城地区": "xin1_jiang1_ta3_cheng2_di4_qu1",
"大兴安岭地区": "hei1_long2_jiang1_da4_xing1_an1_ling2_di4_qu1",
"大同": "shan1_xi1_da4_tong2",
"大庆": "hei1_long2_jiang1_da4_qing4",
"大理白族自治州": "yun2_nan2_da4_li3_bai2_zu2_zi4_zhi4_zhou1",
"大连": "liao2_ning2_da4_lian2",
"天水": "gan1_su4_tian1_shui3",
"天津": "tianjin",
"天门": "hu2_bei3_tian1_men2",
"太原": "shan1_xi1_tai4_yuan2",
"威海": "shan1_dong1_wei1_hai3",
"娄底": "hu2_nan2_lou2_di3",
"孝感": "hu2_bei3_xiao4_gan3",
"宁德": "fu2_jian4_ning2_de2",
"宁波": "zhe4_jiang1_ning2_bo1",
"安庆": "an1_hui1_an1_qing4",
"安康": "shan3_xi1_an1_kang1",
"安阳": "he2_nan2_an1_yang2",
"安顺": "gui4_zhou1_an1_shun4",
"定安县": "hai3_nan2_ding4_an1_xian4",
"定西": "gan1_su4_ding4_xi1",
"宜宾": "si4_chuan1_yi2_bin1",
"宜昌": "hu2_bei3_yi2_chang1",
"宜春": "jiang1_xi1_yi2_chun1",
"宝鸡": "shan3_xi1_bao3_ji1",
"宣城": "an1_hui1_xuan1_cheng2",
"宿州": "an1_hui1_su4_zhou1",
"宿迁": "jiang1_su1_su4_qian1",
"屯昌县": "hai3_nan2_tun2_chang1_xian4",
"山南": "xi1_cang2_shan1_nan2",
"岳阳": "hu2_nan2_yue4_yang2",
"崇左": "guang3_xi1_chong2_zuo3",
"巴中": "si4_chuan1_ba1_zhong1",
"巴彦淖尔": "nei4_meng2_gu3_ba1_yan4_nao4_er3",
"巴音郭楞蒙古自治州": "xin1_jiang1_ba1_yin1_guo1_leng2_meng2_gu3_zi4_zhi4_zhou1",
"常州": "jiang1_su1_chang2_zhou1",
"常德": "hu2_nan2_chang2_de2",
"平凉": "gan1_su4_ping2_liang2",
"平顶山": "he2_nan2_ping2_ding3_shan1",
"广元": "si4_chuan1_guang3_yuan2",
"广安": "si4_chuan1_guang3_an1",
"广州": "guang3_dong1_guang3_zhou1",
"庆阳": "gan1_su4_qing4_yang2",
"廊坊": "he2_bei3_lang2_fang1",
"延安": "shan3_xi1_yan2_an1",
"延边朝鲜族自治州": "ji2_lin2_yan2_bian1_zhao1_xian1_zu2_zi4_zhi4_zhou1",
"开封": "he2_nan2_kai1_feng1",
"张家口": "he2_bei3_zhang1_jia1_kou3",
"张家界": "hu2_nan2_zhang1_jia1_jie4",
"张掖": "gan1_su4_zhang1_ye4",
"徐州": "jiang1_su1_xu2_zhou1",
"德宏傣族景颇族自治州": "yun2_nan2_de2_hong2_dai3_zu2_jing3_po3_zu2_zi4_zhi4_zhou1",
"德州": "shan1_dong1_de2_zhou1",
"德阳": "si4_chuan1_de2_yang2",
"忻州": "shan1_xi1_xin1_zhou1",
"怀化": "hu2_nan2_huai2_hua4",
"怒江傈僳族自治州": "yun2_nan2_nu4_jiang1_li4_su4_zu2_zi4_zhi4_zhou1",
"恩施土家族苗族自治州": "hu2_bei3_en1_shi1_tu3_jia1_zu2_miao2_zu2_zi4_zhi4_zhou1",
"惠州": "guang3_dong1_hui4_zhou1",
"成都": "si4_chuan1_cheng2_du1",
"扬州": "jiang1_su1_yang2_zhou1",
"承德": "he2_bei3_cheng2_de2",
"抚州": "jiang1_xi1_fu3_zhou1",
"抚顺": "liao2_ning2_fu3_shun4",
"拉萨": "xi1_cang2_la1_sa4",
"揭阳": "guang3_dong1_jie1_yang2",
"攀枝花": "si4_chuan1_pan1_zhi1_hua1",
"文山壮族苗族自治州": "yun2_nan2_wen2_shan1_zhuang4_zu2_miao2_zu2_zi4_zhi4_zhou1",
"文昌": "hai3_nan2_wen2_chang1",
"新乡": "he2_nan2_xin1_xiang1",
"新余": "jiang1_xi1_xin1_yu2",
"无锡": "jiang1_su1_wu2_xi2",
"日喀则": "xi1_cang2_ri4_ka1_ze2",
"日照": "shan1_dong1_ri4_zhao4",
"昆明": "yun2_nan2_kun1_ming2",
"昆玉": "xin1_jiang1_kun1_yu4",
"昌吉回族自治州": "xin1_jiang1_chang1_ji2_hui2_zu2_zi4_zhi4_zhou1",
"昌江黎族自治县": "hai3_nan2_chang1_jiang1_li2_zu2_zi4_zhi4_xian4",
"昌都": "xi1_cang2_chang1_du1",
"昭通": "yun2_nan2_zhao1_tong1",
"晋中": "shan1_xi1_jin4_zhong1",
"晋城": "shan1_xi1_jin4_cheng2",
"普洱": "yun2_nan2_pu3_er3",
"景德镇": "jiang1_xi1_jing3_de2_zhen4",
"曲靖": "yun2_nan2_qu1_jing4",
"朔州": "shan1_xi1_shuo4_zhou1",
"朝阳": "liao2_ning2_zhao1_yang2",
"本溪": "liao2_ning2_ben3_xi1",
"来宾": "guang3_xi1_lai2_bin1",
"杭州": "zhe4_jiang1_hang2_zhou1",
"松原": "ji2_lin2_song1_yuan2",
"林芝": "xi1_cang2_lin2_zhi1",
"果洛藏族自治州": "qing1_hai3_guo3_luo4_cang2_zu2_zi4_zhi4_zhou1",
"枣庄": "shan1_dong1_zao3_zhuang1",
"柳州": "guang3_xi1_liu3_zhou1",
"株洲": "hu2_nan2_zhu1_zhou1",
"桂林": "guang3_xi1_gui4_lin2",
"梅州": "guang3_dong1_mei2_zhou1",
"梧州": "guang3_xi1_wu2_zhou1",
"楚雄彝族自治州": "yun2_nan2_chu3_xiong2_yi2_zu2_zi4_zhi4_zhou1",
"榆林": "shan3_xi1_yu2_lin2",
"武威": "gan1_su4_wu3_wei1",
"武汉": "hu2_bei3_wu3_han4",
"毕节": "gui4_zhou1_bi4_jie2",
"永州": "hu2_nan2_yong3_zhou1",
"汉中": "shan3_xi1_han4_zhong1",
"汕头": "guang3_dong1_shan4_tou2",
"汕尾": "guang3_dong1_shan4_wei3",
"江门": "guang3_dong1_jiang1_men2",
"池州": "an1_hui1_chi2_zhou1",
"沈阳": "liao2_ning2_shen3_yang2",
"沧州": "he2_nan2_cang1_zhou1",
"河池": "guang3_xi1_he2_chi2",
"河源": "guang3_dong1_he2_yuan2",
"泉州": "fu2_jian4_quan2_zhou1",
"泰安": "shan1_dong1_tai4_an1",
"泰州": "jiang1_su1_tai4_zhou1",
"泸州": "si4_chuan1_lu2_zhou1",
"洛阳": "he2_nan2_luo4_yang2",
"济南": "shan1_dong1_ji4_nan2",
"济宁": "shan1_dong1_ji4_ning2",
"济源": "he2_nan2_ji4_yuan2",
"海东": "qing1_hai3_hai3_dong1",
"海北藏族自治州": "qing1_hai3_hai3_bei3_cang2_zu2_zi4_zhi4_zhou1",
"海南藏族自治州": "qing1_hai3_hai3_nan2_cang2_zu2_zi4_zhi4_zhou1",
"海口": "hai3_nan2_hai3_kou3",
"海西蒙古族藏族自治州": "qing1_hai3_hai3_xi1_meng2_gu3_zu2_cang2_zu2_zi4_zhi4_zhou1",
"淄博": "shan1_dong1_zi1_bo2",
"淮北": "an1_hui1_huai2_bei3",
"淮南": "an1_hui1_huai2_nan2",
"淮安": "jiang1_su1_huai2_an1",
"深圳": "guang3_dong1_shen1_zhen4",
"清远": "guang3_dong1_qing1_yuan3",
"温州": "zhe4_jiang1_wen1_zhou1",
"渭南": "shan3_xi1_wei4_nan2",
"湖州": "zhe4_jiang1_hu2_zhou1",
"湘潭": "hu2_nan2_xiang1_tan2",
"湘西土家族苗族自治州": "hu2_nan2_xiang1_xi1_tu3_jia1_zu2_miao2_zu2_zi4_zhi4_zhou1",
"湛江": "guang3_dong1_zhan4_jiang1",
"滁州": "an1_hui1_chu2_zhou1",
"滨州": "shan1_dong1_bin1_zhou1",
"漯河": "he2_nan2_ta4_he2",
"漳州": "fu2_jian4_zhang1_zhou1",
"潍坊": "shan1_dong1_wei2_fang1",
"潜江": "hu2_bei3_qian2_jiang1",
"潮州": "guang3_dong1_chao2_zhou1",
"澄迈县": "hai3_nan2_cheng2_mai4_xian4",
"澳门": "aomen",
"濮阳": "he2_nan2_pu2_yang2",
"烟台": "shan1_dong1_yan1_tai2",
"焦作": "he2_nan2_jiao1_zuo4",
"牡丹江": "hei1_long2_jiang1_mu3_dan1_jiang1",
"玉林": "guang3_xi1_yu4_lin2",
"玉树藏族自治州": "qing1_hai3_yu4_shu4_cang2_zu2_zi4_zhi4_zhou1",
"玉溪": "yun2_nan2_yu4_xi1",
"珠海": "guang3_dong1_zhu1_hai3",
"琼中黎族苗族自治县": "hai3_nan2_qiong2_zhong1_li2_zu2_miao2_zu2_zi4_zhi4_xian4",
"琼海": "hai3_nan2_qiong2_hai3",
"甘南藏族自治州": "gan1_su4_gan1_nan2_cang2_zu2_zi4_zhi4_zhou1",
"甘孜藏族自治州": "si4_chuan1_gan1_zi1_cang2_zu2_zi4_zhi4_zhou1",
"白城": "ji2_lin2_bai2_cheng2",
"白山": "ji2_lin2_bai2_shan1",
"白沙黎族自治县": "hai3_nan2_bai2_sha1_li2_zu2_zi4_zhi4_xian4",
"白银": "gan1_su4_bai2_yin2",
"百色": "guang3_xi1_bai3_se4",
"益阳": "hu2_nan2_yi4_yang2",
"盐城": "jiang1_su1_yan2_cheng2",
"盘锦": "liao2_ning2_pan2_jin3",
"眉山": "si4_chuan1_mei2_shan1",
"石嘴山": "ning2_xia4_shi2_zui3_shan1",
"石家庄": "he2_bei3_shi2_jia1_zhuang1",
"石河子": "xin1_jiang1_shi2_he2_zi3",
"神农架林区": "hu2_bei3_shen2_nong2_jia4_lin2_qu1",
"福州": "fu2_jian4_fu2_zhou1",
"秦皇岛": "he2_bei3_qin2_huang2_dao3",
"红河哈尼族彝族自治州": "yun2_nan2_hong2_he2_ha1_ni2_zu2_yi2_zu2_zi4_zhi4_zhou1",
"绍兴": "zhe4_jiang1_shao4_xing1",
"绥化": "hei1_long2_jiang1_sui1_hua4",
"绵阳": "si4_chuan1_mian2_yang2",
"聊城": "shan1_dong1_liao2_cheng2",
"肇庆": "guang3_dong1_zhao4_qing4",
"自贡": "si4_chuan1_zi4_gong4",
"舟山": "zhe4_jiang1_zhou1_shan1",
"芜湖": "an1_hui1_wu2_hu2",
"苏州": "jiang1_su1_su1_zhou1",
"茂名": "guang3_dong1_mao4_ming2",
"荆州": "hu2_bei3_jing1_zhou1",
"荆门": "hu2_bei3_jing1_men2",
"莆田": "fu2_jian4_fu3_tian2",
"莱芜": "shan1_dong1_lai2_wu2",
"菏泽": "shan1_dong1_he2_ze2",
"萍乡": "jiang1_xi1_ping2_xiang1",
"营口": "liao2_ning2_ying2_kou3",
"葫芦岛": "liao2_ning2_hu2_lu2_dao3",
"蚌埠": "an1_hui1_bang4_bu4",
"衡水": "he2_bei3_heng2_shui3",
"衡阳": "hu2_nan2_heng2_yang2",
"衢州": "zhe4_jiang1_qu2_zhou1",
"襄阳": "hu2_bei3_xiang1_yang2",
"西双版纳傣族自治州": "yun2_nan2_xi1_shuang1_ban3_na4_dai3_zu2_zi4_zhi4_zhou1",
"西宁": "qing1_hai3_xi1_ning2",
"西安": "shan3_xi1_xi1_an1",
"许昌": "he2_nan2_xu3_chang1",
"贵港": "guang3_xi1_gui4_gang3",
"贵阳": "gui4_zhou1_gui4_yang2",
"贺州": "guang3_xi1_he4_zhou1",
"资阳": "si4_chuan1_zi1_yang2",
"赣州": "jiang1_xi1_gan4_zhou1",
"赤峰": "nei4_meng2_gu3_chi4_feng1",
"辽源": "ji2_lin2_liao2_yuan2",
"辽阳": "liao2_ning2_liao2_yang2",
"达州": "si4_chuan1_da2_zhou1",
"运城": "shan1_xi1_yun4_cheng2",
"连云港": "jiang1_su1_lian2_yun2_gang3",
"迪庆藏族自治州": "yun2_nan2_di2_qing4_cang2_zu2_zi4_zhi4_zhou1",
"通化": "ji2_lin2_tong1_hua4",
"通辽": "nei4_meng2_gu3_tong1_liao2",
"遂宁": "si4_chuan1_sui4_ning2",
"遵义": "gui4_zhou1_zun1_yi4",
"邢台": "he2_bei3_xing2_tai2",
"那曲地区": "xi1_cang2_na4_qu1_di4_qu1",
"邯郸": "he2_bei3_han2_dan1",
"邵阳": "hu2_nan2_shao4_yang2",
"郑州": "he2_nan2_zheng4_zhou1",
"郴州": "hu2_nan2_chen1_zhou1",
"鄂尔多斯": "nei4_meng2_gu3_e4_er3_duo1_si1",
"鄂州": "hu2_bei3_e4_zhou1",
"酒泉": "gan1_su4_jiu3_quan2",
"重庆": "chongqing",
"金华": "zhe4_jiang1_jin1_hua2",
"金昌": "gan1_su4_jin1_chang1",
"钦州": "guang3_xi1_qin1_zhou1",
"铁岭": "liao2_ning2_tie3_ling2",
"铁门关": "xin1_jiang1_tie3_men2_guan1",
"铜仁": "gui4_zhou1_tong2_ren2",
"铜川": "shan3_xi1_tong2_chuan1",
"铜陵": "an1_hui1_tong2_ling2",
"银川": "ning2_xia4_yin2_chuan1",
"锡林郭勒盟": "nei4_meng2_gu3_xi2_lin2_guo1_le4_meng2",
"锦州": "liao2_ning2_jin3_zhou1",
"镇江": "jiang1_su1_zhen4_jiang1",
"长春": "ji2_lin2_chang2_chun1",
"长沙": "hu2_nan2_chang2_sha1",
"长治": "shan1_xi1_chang2_zhi4",
"阜新": "liao2_ning2_fu4_xin1",
"阜阳": "an1_hui1_fu4_yang2",
"防城港": "guang3_xi1_fang2_cheng2_gang3",
"阳江": "guang3_dong1_yang2_jiang1",
"阳泉": "shan1_xi1_yang2_quan2",
"阿克苏地区": "xin1_jiang1_a1_ke4_su1_di4_qu1",
"阿勒泰地区": "xin1_jiang1_a1_le4_tai4_di4_qu1",
"阿坝藏族羌族自治州": "si4_chuan1_a1_ba4_cang2_zu2_qiang1_zu2_zi4_zhi4_zhou1",
"阿拉善盟": "nei4_meng2_gu3_a1_la1_shan4_meng2",
"阿拉尔": "xin1_jiang1_a1_la1_er3",
"阿里地区": "xi1_cang2_a1_li3_di4_qu1",
"陇南": "gan1_su4_long3_nan2",
"陵水黎族自治县": "hai3_nan2_ling2_shui3_li2_zu2_zi4_zhi4_xian4",
"随州": "hu2_bei3_sui2_zhou1",
"雅安": "si4_chuan1_ya3_an1",
"青岛": "shan1_dong1_qing1_dao3",
"鞍山": "liao2_ning2_an1_shan1",
"韶关": "guang3_dong1_shao2_guan1",
"香港": "xianggang",
"马鞍山": "an1_hui1_ma3_an1_shan1",
"驻马店": "he2_nan2_zhu4_ma3_dian4",
"鸡西": "hei1_long2_jiang1_ji1_xi1",
"鹤壁": "he2_nan2_he4_bi4",
"鹤岗": "hei1_long2_jiang1_he4_gang3",
"鹰潭": "jiang1_xi1_ying1_tan2",
"黄冈": "hu2_bei3_huang2_gang1",
"黄南藏族自治州": "qing1_hai3_huang2_nan2_cang2_zu2_zi4_zhi4_zhou1",
"黄山": "an1_hui1_huang2_shan1",
"黄石": "hu2_bei3_huang2_shi2",
"黑河": "hei1_long2_jiang1_hei1_he2",
"黔东南苗族侗族自治州": "gui4_zhou1_qian2_dong1_nan2_miao2_zu2_tong1_zu2_zi4_zhi4_zhou1",
"黔南布依族苗族自治州": "gui4_zhou1_qian2_nan2_bu4_yi1_zu2_miao2_zu2_zi4_zhi4_zhou1",
"黔西南布依族苗族自治州": "gui4_zhou1_qian2_xi1_nan2_bu4_yi1_zu2_miao2_zu2_zi4_zhi4_zhou1",
"齐齐哈尔": "hei1_long2_jiang1_qi2_qi2_ha1_er3",
"龙岩": "fu2_jian4_long2_yan2",
}

View File

@@ -0,0 +1,738 @@
package opts
// BarChart
// https://echarts.apache.org/en/option.html#series-bar
type BarChart struct {
Type string
// Name of stack. On the same category axis, the series with the
// same stack name would be put on top of each other.
Stack string
// The gap between bars between different series, is a percent value like '30%',
// which means 30% of the bar width.
// Set barGap as '-100%' can overlap bars that belong to different series,
// which is useful when putting a series of bar as background.
// In a single coordinate system, this attribute is shared by multiple 'bar' series.
// This attribute should be set on the last 'bar' series in the coordinate system,
// then it will be adopted by all 'bar' series in the coordinate system.
BarGap string
// The bar gap of a single series, defaults to be 20% of the category gap,
// can be set as a fixed value.
// In a single coordinate system, this attribute is shared by multiple 'bar' series.
// This attribute should be set on the last 'bar' series in the coordinate system,
// then it will be adopted by all 'bar' series in the coordinate system.
BarCategoryGap string
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int
// Index of y axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int
ShowBackground bool
RoundCap bool
CoordSystem string
}
// SunburstChart
// https://echarts.apache.org/en/option.html#series-sunburst
type SunburstChart struct {
// The action of clicking a sector
NodeClick string `json:"nodeClick,omitempty"`
// Sorting method that sectors use based on value
Sort string `json:"sort,omitempty"`
// If there is no name, whether need to render it.
RenderLabelForZeroData bool `json:"renderLabelForZeroData"`
// Selected mode
SelectedMode bool `json:"selectedMode"`
// Whether to enable animation.
Animation bool `json:"animation"`
// Whether to set graphic number threshold to animation
AnimationThreshold int `json:"animationThreshold,omitempty"`
// Duration of the first animation
AnimationDuration int `json:"animationDuration,omitempty"`
// Easing method used for the first animation
AnimationEasing string `json:"animationEasing,omitempty"`
// Delay before updating the first animation
AnimationDelay int `json:"animationDelay,omitempty"`
// Time for animation to complete
AnimationDurationUpdate int `json:"animationDurationUpdate,omitempty"`
// Easing method used for animation.
AnimationEasingUpdate string `json:"animationEasingUpdate,omitempty"`
// Delay before updating animation
AnimationDelayUpdate int `json:"animationDelayUpdate,omitempty"`
}
// BarData
// https://echarts.apache.org/en/option.html#series-bar.data
type BarData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
// The style setting of the text label in a single bar.
Label *Label `json:"label,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// Tooltip settings in this series data.
Tooltip *Tooltip `json:"tooltip,omitempty"`
}
// Bar3DChart is the option set for a 3D bar chart.
type Bar3DChart struct {
// Shading is the coloring effect of 3D graphics in 3D Bar.
// The following three coloring methods are supported in echarts-gl:
// Options:
//
// * "color": Only display colors, not affected by other factors such as lighting.
// * "lambert": Through the classic [lambert] coloring, can express the light and dark that the light shows.
// * "realistic": Realistic rendering, combined with light.ambientCubemap and postEffect,
// can improve the quality and texture of the display.
// [Physical Based Rendering (PBR)] (https://www.marmoset.co/posts/physically-based-rendering-and-you-can-too/)
// is used in ECharts GL to represent realistic materials.
Shading string
}
// BoxPlotData
// https://echarts.apache.org/en/option.html#series-boxplot.data
type BoxPlotData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
// The style setting of the text label in a single bar.
Label *Label `json:"label,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// Emphasis settings in this series data.
Emphasis *Emphasis `json:"emphasis,omitempty"`
// Tooltip settings in this series data.
Tooltip *Tooltip `json:"tooltip,omitempty"`
}
// EffectScatterData
// https://echarts.apache.org/en/option.html#series-effectScatter.data
type EffectScatterData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// FunnelData
// https://echarts.apache.org/en/option.html#series-funnel.data
type FunnelData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// GeoData
type GeoData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// GaugeData
// https://echarts.apache.org/en/option.html#series-gauge.data
type GaugeData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// GraphChart is the option set for graph chart.
// https://echarts.apache.org/en/option.html#series-graph
type GraphChart struct {
// Graph layout.
// * 'none' No layout, use x, y provided in node as the position of node.
// * 'circular' Adopt circular layout, see the example Les Miserables.
// * 'force' Adopt force-directed layout, see the example Force, the
// detail about layout configurations are in graph.force
Layout string
// Force is the option set for graph force layout.
Force *GraphForce
// Whether to enable mouse zooming and translating. false by default.
// If either zooming or translating is wanted, it can be set to 'scale' or 'move'.
// Otherwise, set it to be true to enable both.
Roam bool
// EdgeSymbol is the symbols of two ends of edge line.
// * 'circle'
// * 'arrow'
// * 'none'
// example: ["circle", "arrow"] or "circle"
EdgeSymbol interface{}
// EdgeSymbolSize is size of symbol of two ends of edge line. Can be an array or a single number
// example: [5,10] or 5
EdgeSymbolSize interface{}
// Draggable allows you to move the nodes with the mouse if they are not fixed.
Draggable bool
// Whether to focus/highlight the hover node and it's adjacencies.
FocusNodeAdjacency bool
// The categories of node, which is optional. If there is a classification of nodes,
// the category of each node can be assigned through data[i].category.
// And the style of category will also be applied to the style of nodes. categories can also be used in legend.
Categories []*GraphCategory
// EdgeLabel is the properties of an label of edge.
EdgeLabel *EdgeLabel `json:"edgeLabel"`
// SymbolKeepAspect is whether to keep aspect for symbols in the form of path://.
SymbolKeepAspect bool
}
// GraphNode represents a data node in graph chart.
// https://echarts.apache.org/en/option.html#series-graph.data
type GraphNode struct {
// Name of data item.
Name string `json:"name,omitempty"`
// x value of node position.
X float32 `json:"x,omitempty"`
// y value of node position.
Y float32 `json:"y,omitempty"`
// Value of data item.
Value float32 `json:"value,omitempty"`
// If node are fixed when doing force directed layout.
Fixed bool `json:"fixed,omitempty"`
// Index of category which the data item belongs to.
Category interface{} `json:"category,omitempty"`
// Symbol of node of this category.
// Icon types provided by ECharts includes
// 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
// It can be set to an image with 'image://url' , in which URL is the link to an image, or dataURI of an image.
Symbol string `json:"symbol,omitempty"`
// node of this category symbol size. It can be set to single numbers like 10,
// or use an array to represent width and height. For example, [20, 10] means symbol width is 20, and height is10.
SymbolSize interface{} `json:"symbolSize,omitempty"`
// The style of this node.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// The tooltip of this node.
Tooltip *Tooltip `json:"tooltip,omitempty"`
}
// GraphLink represents relationship between two data nodes.
// https://echarts.apache.org/en/option.html#series-graph.links
type GraphLink struct {
// A string representing the name of source node on edge. Can also be a number representing the node index.
Source interface{} `json:"source,omitempty"`
// A string representing the name of target node on edge. Can also be a number representing node index.
Target interface{} `json:"target,omitempty"`
// value of edge, can be mapped to edge length in force graph.
Value float32 `json:"value,omitempty"`
// Label for this link.
Label *EdgeLabel `json:"label,omitempty"`
}
// GraphCategory represents a category for data nodes.
// The categories of node, which is optional. If there is a classification of nodes,
// the category of each node can be assigned through data[i].category.
// And the style of category will also be applied to the style of nodes. categories can also be used in legend.
// https://echarts.apache.org/en/option.html#series-graph.categories
type GraphCategory struct {
// Name of category, which is used to correspond with legend and the content of tooltip.
Name string `json:"name"`
// The label style of node in this category.
Label *Label `json:"label,omitempty"`
}
// HeatMapChart is the option set for a heatmap chart.
// https://echarts.apache.org/en/option.html#series-heatmap
type HeatMapChart struct {
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int
// Index of y axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int
}
// HeatMapData
// https://echarts.apache.org/en/option.html#series-heatmap.data
type HeatMapData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// KlineData
// https://echarts.apache.org/en/option.html#series-candlestick.data
type KlineData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// LineChart is the options set for a line chart.
// https://echarts.apache.org/en/option.html#series-line
type LineChart struct {
// If stack the value. On the same category axis, the series with the same stack name would be put on top of each other.
// The effect of the below example could be seen through stack switching of toolbox on the top right corner:
Stack string
// Whether to show as smooth curve.
// If is typed in boolean, then it means whether to enable smoothing. If is
// typed in number, valued from 0 to 1, then it means smoothness. A smaller value makes it less smooth.
Smooth bool
// Whether to show as a step line. It can be true, false. Or 'start', 'middle', 'end'.
// Which will configure the turn point of step line.
Step interface{}
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int
// Index of y axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int
// Whether to connect the line across null points.
ConnectNulls bool
// Whether to show symbol. It would be shown during tooltip hover.
ShowSymbol bool
// Icon types provided by ECharts includes
// 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
// Full documentation: https://echarts.apache.org/en/option.html#series-line.symbol
Symbol string
// symbol size. It can be set to single numbers like 10, or use an array to represent width and height. For example, [20, 10] means symbol width is 20, and height is10.
// Full documentation: https://echarts.apache.org/en/option.html#series-line.symbolSize
SymbolSize interface{}
// color for Line series. it affects Line series including symbols, unlike LineStyle.Color
Color string
// SymbolKeepAspect is whether to keep aspect for symbols in the form of path://.
SymbolKeepAspect bool
}
// LineChart is the options set for a chandlestick chart.
// https://echarts.apache.org/en/option.html#series-candlestick
type KlineChart struct {
// Specify bar width. Absolute value (like 10) or percentage (like '20%', according to band width) can be used. Auto adapt by default.
BarWidth string
// Specify bar min width. Absolute value (like 10) or percentage (like '20%', according to band width) can be used. Auto adapt by default.
BarMinWidth string
// Specify bar max width. Absolute value (like 10) or percentage (like '20%', according to band width) can be used. Auto adapt by default.
BarMaxWidth string
}
// LineData
// https://echarts.apache.org/en/option.html#series-line.data
type LineData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
// Symbol of single data.
// Icon types provided by ECharts includes 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
// It can be set to an image with 'image://url' , in which URL is the link to an image, or dataURI of an image.
Symbol string `json:"symbol,omitempty"`
// single data symbol size. It can be set to single numbers like 10, or
// use an array to represent width and height. For example, [20, 10] means symbol width is 20, and height is10
SymbolSize int `json:"symbolSize,omitempty"`
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int
// Index of y axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int
}
// LiquidChart
// reference https://github.com/ecomfe/echarts-liquidfill
type LiquidChart struct {
// Shape of single data.
// Icon types provided by ECharts includes 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
// It can be set to an image with 'image://url' , in which URL is the link to an image, or dataURI of an image.
Shape string
// Whether to show outline
IsShowOutline bool
// Whether to stop animation
IsWaveAnimation bool
}
// LiquidData
// reference https://github.com/ecomfe/echarts-liquidfill
type LiquidData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// MapData
// https://echarts.apache.org/en/option.html#series-map.data
type MapData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// ParallelData
// https://echarts.apache.org/en/option.html#series-parallel.data
type ParallelData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// PieChart is the option set for a pie chart.
// https://echarts.apache.org/en/option.html#series-pie
type PieChart struct {
// Whether to show as Nightingale chart, which distinguishes data through radius. There are 2 optional modes:
// * 'radius' Use central angle to show the percentage of data, radius to show data size.
// * 'area' All the sectors will share the same central angle, the data size is shown only through radiuses.
RoseType string
// Center position of Pie chart, the first of which is the horizontal position, and the second is the vertical position.
// Percentage is supported. When set in percentage, the item is relative to the container width,
// and the second item to the height.
//
// Example:
//
// Set to absolute pixel values ->> center: [400, 300]
// Set to relative percent ->> center: ['50%', '50%']
Center interface{}
// Radius of Pie chart. Value can be:
// * number: Specify outside radius directly.
// * string: For example, '20%', means that the outside radius is 20% of the viewport
// size (the little one between width and height of the chart container).
//
// Array.<number|string>: The first item specifies the inside radius, and the
// second item specifies the outside radius. Each item follows the definitions above.
Radius interface{}
}
// PieData
// https://echarts.apache.org/en/option.html#series-pie.data
type PieData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
// Whether the data item is selected.
Selected bool `json:"selected,omitempty"`
// The label configuration of a single sector.
Label *Label `json:"label,omitempty"`
// Graphic style of , emphasis is the style when it is highlighted, like being hovered by mouse, or highlighted via legend connect.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// tooltip settings in this series data.
Tooltip *Tooltip `json:"tooltip,omitempty"`
}
// RadarData
// https://echarts.apache.org/en/option.html#series-radar
type RadarData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
// SankeyLink represents relationship between two data nodes.
// https://echarts.apache.org/en/option.html#series-sankey.links
type SankeyLink struct {
// The name of source node of edge
Source interface{} `json:"source,omitempty"`
// The name of target node of edge
Target interface{} `json:"target,omitempty"`
// The value of edge, which decides the width of edge.
Value float32 `json:"value,omitempty"`
}
// SankeyNode represents a data node.
// https://echarts.apache.org/en/option.html#series-sankey.nodes
type SankeyNode struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value string `json:"value,omitempty"`
// Depth of the node within the chart
Depth *int `json:"depth,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// ScatterChart is the option set for a scatter chart.
// https://echarts.apache.org/en/option.html#series-scatter
type ScatterChart struct {
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int
// Index of x axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int
// SymbolKeepAspect is whether to keep aspect for symbols in the form of path://.
SymbolKeepAspect bool
}
// ScatterData
// https://echarts.apache.org/en/option.html#series-scatter.data
type ScatterData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
// Symbol
Symbol string `json:"symbol,omitempty"`
// SymbolSize
SymbolSize int `json:"symbolSize,omitempty"`
// SymbolRotate
SymbolRotate int `json:"symbolRotate,omitempty"`
// Index of x axis to combine with, which is useful for multiple x axes in one chart.
XAxisIndex int `json:"xAxisIndex,omitempty"`
// Index of y axis to combine with, which is useful for multiple y axes in one chart.
YAxisIndex int `json:"yAxisIndex,omitempty"`
}
// ThemeRiverData
// https://echarts.apache.org/en/option.html#series-themeRiver
type ThemeRiverData struct {
// the time attribute of time and theme.
Date string `json:"date,omitempty"`
// the value of an event or theme at a time point.
Value float64 `json:"value,omitempty"`
// the name of an event or theme.
Name string `json:"name,omitempty"`
}
// ToList converts the themeriver data to a list
func (trd ThemeRiverData) ToList() [3]interface{} {
return [3]interface{}{trd.Date, trd.Value, trd.Name}
}
// WordCloudChart is the option set for a word cloud chart.
type WordCloudChart struct {
// Shape of WordCloud
// Optional: "circle", "rect", "roundRect", "triangle", "diamond", "pin", "arrow"
Shape string
// range of font size
SizeRange []float32
// range of font rotation angle
RotationRange []float32
}
// WordCloudData
type WordCloudData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of a single data item.
Value interface{} `json:"value,omitempty"`
}
type Chart3DData struct {
// Name of the data item.
Name string `json:"name,omitempty"`
// Value of the data item.
// []interface{}{1, 2, 3}
Value []interface{} `json:"value,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// The style setting of the text label in a single bar.
Label *Label `json:"label,omitempty"`
}
type TreeChart struct {
// The layout of the tree, which can be orthogonal and radial.
// * 'orthogonal' refer to the horizontal and vertical direction.
// * 'radial' refers to the view that the root node as the center and each layer of nodes as the ring.
Layout string
// The direction of the orthogonal layout in the tree diagram.
// * 'from left to right' or 'LR'
// * 'from right to left' or 'RL'
// * 'from top to bottom' or 'TB'
// * 'from bottom to top' or 'BT'
Orient string `json:"orient,omitempty"`
// Whether to enable mouse zooming and translating. false by default.
// If either zooming or translating is wanted, it can be set to 'scale' or 'move'.
// Otherwise, set it to be true to enable both.
Roam bool `json:"roam"`
// Subtree collapses and expands interaction, default true.
ExpandAndCollapse bool `json:"expandAndCollapse,omitempty"`
// The initial level (depth) of the tree. The root node is the 0th layer, then the first layer, the second layer, ... , until the leaf node.
// This configuration item is primarily used in conjunction with collapsing and expansion interactions.
// The purpose is to prevent the nodes from obscuring each other. If set as -1 or null or undefined, all nodes are expanded.
InitialTreeDepth int `json:"initialTreeDepth,omitempty"`
// The style setting of the text label in a single bar.
Label *Label `json:"label,omitempty"`
// Leaf node special configuration, the leaf node and non-leaf node label location is different.
Leaves *TreeLeaves `json:"leaves,omitempty"`
// Distance between tree component and the sides of the container.
// value can be instant pixel value like 20;
// It can also be a percentage value relative to container width like '20%';
Left string `json:"left,omitempty"`
Right string `json:"right,omitempty"`
Top string `json:"top,omitempty"`
Bottom string `json:"bottom,omitempty"`
// SymbolKeepAspect is whether to keep aspect for symbols in the form of path://.
SymbolKeepAspect bool
}
type TreeData struct {
// Name of the data item.
Name string `json:"name,omitempty"`
// Value of the data item.
Value int `json:"value,omitempty"`
Children []*TreeData `json:"children,omitempty"`
// Symbol of node of this category.
// Icon types provided by ECharts includes
// 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
// It can be set to an image with 'image://url' , in which URL is the link to an image, or dataURI of an image.
Symbol string `json:"symbol,omitempty"`
// node of this category symbol size. It can be set to single numbers like 10,
// or use an array to represent width and height. For example, [20, 10] means symbol width is 20, and height is10.
SymbolSize interface{} `json:"symbolSize,omitempty"`
// If set as `true`, the node is collapsed in the initialization.
Collapsed bool `json:"collapsed,omitempty"`
// LineStyle settings in this series data.
LineStyle *LineStyle `json:"lineStyle,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
type TreeMapChart struct {
// Whether to enable animation.
Animation bool `json:"animation"`
// leafDepth represents how many levels are shown at most. For example, when leafDepth is set to 1, only one level will be shown.
// leafDepth is null/undefined by default, which means that "drill down" is disabled.
LeafDepth int `json:"leafDeapth,omitempty"`
// Roam describes whether to enable mouse zooming and translating. false by default.
Roam bool `json:"roam"`
// Label decribes the style of the label in each node.
Label *Label `json:"label,omitempty"`
// UpperLabel is used to specify whether show label when the treemap node has children.
UpperLabel *UpperLabel `json:"upperLabel,omitempty"`
// ColorMappingBy specifies the rule according to which each node obtain color from color list.
ColorMappingBy string `json:"colorMappingBy,omitempty"`
// Levels provide configration for each node level
Levels *[]TreeMapLevel `json:"levels,omitempty"`
// Distance between treemap component and the sides of the container.
// value can be instant pixel value like 20;
// It can also be a percentage value relative to container width like '20%';
Left string `json:"left,omitempty"`
Right string `json:"right,omitempty"`
Top string `json:"top,omitempty"`
Bottom string `json:"bottom,omitempty"`
}
type TreeMapNode struct {
// Name of the tree node item.
Name string `json:"name"`
// Value of the tree node item.
Value int `json:"value,omitempty"`
Children []TreeMapNode `json:"children,omitempty"`
}
// SunBurstData data
type SunBurstData struct {
// Name of data item.
Name string `json:"name,omitempty"`
// Value of data item.
Value float64 `json:"value,omitempty"`
// sub item of data item
Children []*SunBurstData `json:"children,omitempty"`
}

1521
vendor/github.com/go-echarts/go-echarts/v2/opts/global.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,699 @@
package opts
import (
"fmt"
)
// Label contains options for a label text.
// https://echarts.apache.org/en/option.html#series-line.label
type Label struct {
// Whether to show label.
Show bool `json:"show"`
// Color is the text color.
// If set as "auto", the color will assigned as visual color, such as series color.
Color string `json:"color,omitempty"`
// font style.
// Options are: 'normal', 'italic', 'oblique'
FontStyle string `json:"fontStyle,omitempty"`
// font thick weight.
// Options are: 'normal', 'bold', 'bolder', 'lighter', 100 | 200 | 300 | 400...
FontWeight string `json:"fontWeight,omitempty"`
// font family.
// Can also be 'serif' , 'monospace', ...
FontFamily string `json:"fontFamily,omitempty"`
// font size.
FontSize float32 `json:"fontSize,omitempty"`
// Horizontal alignment of text, automatic by default.
// Options are: 'left', 'center', 'right'
Align string `json:"align,omitempty"`
// Vertical alignment of text, automatic by default.
// Options are: 'top', 'middle', 'bottom'
VerticalAlign string `json:"verticalAlign,omitempty"`
// Line height of the text fragment.
LineHeight float32 `json:"lineHeight,omitempty"`
// Background color of the text fragment.
BackgroundColor string `json:"backgroundColor,omitempty"`
// Border color of the text fragment.
BorderColor string `json:"borderColor,omitempty"`
// Border width of the text fragment.
BorderWidth float32 `json:"borderWidth,omitempty"`
// the text fragment border type.
// Possible values are: 'solid', 'dashed', 'dotted'
BorderType string `json:"borderType,omitempty"`
// To set the line dash offset. With borderType , we can make the line style more flexible.
BorderDashOffset float32 `json:"borderDashOffset,omitempty"`
// Border radius of the text fragment.
BorderRadius float32 `json:"borderRadius,omitempty"`
// Padding of the text fragment, for example:
// padding: [3, 4, 5, 6]: represents padding of [top, right, bottom, left].
// padding: 4: represents padding: [4, 4, 4, 4].
// padding: [3, 4]: represents padding: [3, 4, 3, 4].
// Notice, width and height specifies the width and height of the content, without padding.
Padding string `json:"padding,omitempty"`
// Label position. Followings are the options:
//
// [x, y]
// Use relative percentage, or absolute pixel values to represent position of label
// relative to top-left corner of bounding box. For example:
//
// Absolute pixel values: position: [10, 10],
// Relative percentage: position: ["50%", "50%"]
//
// "top"
// "left"
// "right"
// "bottom"
// "inside"
// "insideLeft"
// "insideRight"
// "insideTop"
// "insideBottom"
// "insideTopLeft"
// "insideBottomLeft"
// "insideTopRight"
// "insideBottomRight"
Position string `json:"position,omitempty"`
// Data label formatter, which supports string template and callback function.
// In either form, \n is supported to represent a new line.
// String template, Model variation includes:
//
// {a}: series name.
// {b}: the name of a data item.
// {c}: the value of a data item.
// {@xxx}: the value of a dimension named"xxx", for example,{@product}refers the value of"product"` dimension.
// {@[n]}: the value of a dimension at the index ofn, for example,{@[3]}` refers the value at dimensions[3].
Formatter string `json:"formatter,omitempty"`
}
// LabelLine Configuration of label guide line.
type LabelLine struct {
// Whether to show the label guide line.
Show bool `json:"show"`
// Whether to show the label guide line above the corresponding element.
ShowAbove bool `json:"showAbove"`
// The length of the second segment of guide line.
Length2 float64 `json:"length2,omitempty"`
// smoothness of guide line.
Smooth bool `json:"smooth"`
// Minimum turn angle between two segments of guide line
MinTurnAngle float64 `json:"minTurnAngle,omitempty"`
// The style of label line
LineStyle *LineStyle `json:"lineStyle,omitempty"`
}
// Emphasis is the style when it is highlighted, like being hovered by mouse, or highlighted via legend connect.
type Emphasis struct {
// the emphasis style of label
Label *Label `json:"label,omitempty"`
// the emphasis style of item
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// ItemStyle represents a style of an item.
type ItemStyle struct {
// Color of chart
// Kline Up candle color
Color string `json:"color,omitempty"`
// Kline Down candle color
Color0 string `json:"color0,omitempty"`
// BorderColor is the hart border color
// Kline Up candle border color
BorderColor string `json:"borderColor,omitempty"`
// Kline Down candle border color
BorderColor0 string `json:"borderColor0,omitempty"`
// Color saturation of a border or gap.
BorderColorSaturation float32 `json:"borderColorSaturation,omitempty"`
// Border width of a node
BorderWidth float32 `json:"borderWidth,omitempty"`
// Gaps between child nodes.
GapWidth float32 `json:"gapWidth,omitempty"`
// Opacity of the component. Supports value from 0 to 1, and the component will not be drawn when set to 0.
Opacity float32 `json:"opacity,omitempty"`
}
// MarkLines represents a series of marklines.
type MarkLines struct {
Data []interface{} `json:"data,omitempty"`
MarkLineStyle
}
// MarkLineStyle contains styling options for a MarkLine.
type MarkLineStyle struct {
// Symbol type at the two ends of the mark line. It can be an array for two ends, or assigned separately.
// Options: "circle", "rect", "roundRect", "triangle", "diamond", "pin", "arrow", "none"
Symbol []string `json:"symbol,omitempty"`
// Symbol size.
SymbolSize float32 `json:"symbolSize,omitempty"`
// Mark line text options.
Label *Label `json:"label,omitempty"`
}
// CircularStyle contains styling options for circular layout.
type CircularStyle struct {
RotateLabel bool `json:"rotateLabel,omitempty"`
}
// MarkLineNameTypeItem represents type for a MarkLine.
type MarkLineNameTypeItem struct {
// Mark line name.
Name string `json:"name,omitempty"`
// Mark line type, options: "average", "min", "max".
Type string `json:"type,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
}
// MarkLineNameYAxisItem defines a MarkLine on a Y axis.
type MarkLineNameYAxisItem struct {
// Mark line name
Name string `json:"name,omitempty"`
// Y axis data
YAxis interface{} `json:"yAxis,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
}
// MarkLineNameXAxisItem defines a MarkLine on a X axis.
type MarkLineNameXAxisItem struct {
// Mark line name
Name string `json:"name,omitempty"`
// X axis data
XAxis interface{} `json:"xAxis,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
}
// MarkLineNameCoordItem represents coordinates for a MarkLine.
type MarkLineNameCoordItem struct {
// Mark line name
Name string `json:"name,omitempty"`
// Mark line start coordinate
Coordinate0 []interface{}
// Mark line end coordinate
Coordinate1 []interface{}
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
}
// MarkAreas represents a series of markareas.
type MarkAreas struct {
Data []interface{} `json:"data,omitempty"`
MarkAreaStyle
}
// MarkAreaStyle contains styling options for a MarkArea.
type MarkAreaStyle struct {
// Mark area text options.
Label *Label `json:"label,omitempty"`
// ItemStyle settings
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// MarkAreaNameTypeItem represents type for a MarkArea.
type MarkAreaNameTypeItem struct {
// Mark area name.
Name string `json:"name,omitempty"`
// Mark area type, options: "average", "min", "max".
Type string `json:"type,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
// ItemStyle settings
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// MarkAreaNameYAxisItem defines a MarkArea on a Y axis.
type MarkAreaNameYAxisItem struct {
// Mark area name
Name string `json:"name,omitempty"`
// Y axis data
YAxis interface{} `json:"yAxis,omitempty"`
}
// MarkAreaNameXAxisItem defines a MarkArea on a X axis.
type MarkAreaNameXAxisItem struct {
// Mark area name
Name string `json:"name,omitempty"`
// X axis data
XAxis interface{} `json:"xAxis,omitempty"`
}
// MarkAreaNameCoordItem represents coordinates for a MarkArea.
type MarkAreaNameCoordItem struct {
// Mark area name
Name string `json:"name,omitempty"`
// Mark area start coordinate
Coordinate0 []interface{}
// Mark area end coordinate
Coordinate1 []interface{}
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
// Mark point text options.
Label *Label `json:"label,omitempty"`
// ItemStyle settings
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// MarkPoints represents a series of markpoints.
type MarkPoints struct {
Data []interface{} `json:"data,omitempty"`
MarkPointStyle
}
// MarkPointStyle contains styling options for a MarkPoint.
type MarkPointStyle struct {
// Symbol type at the two ends of the mark line. It can be an array for two ends, or assigned separately.
// Options: "circle", "rect", "roundRect", "triangle", "diamond", "pin", "arrow", "none"
Symbol []string `json:"symbol,omitempty"`
// Symbol size.
SymbolSize float32 `json:"symbolSize,omitempty"`
// Symbol rotate.
SymbolRotate float32 `json:"symbolRotate,omitempty"`
// Mark point text options.
Label *Label `json:"label,omitempty"`
}
// MarkPointNameTypeItem represents type for a MarkPoint.
type MarkPointNameTypeItem struct {
// Name of markpoint
Name string `json:"name,omitempty"`
// Mark point type, options: "average", "min", "max".
Type string `json:"type,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
// ItemStyle settings
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
}
// MarkPointNameCoordItem represents coordinates for a MarkPoint.
type MarkPointNameCoordItem struct {
// Name of markpoint
Name string `json:"name,omitempty"`
// Mark point coordinate
Coordinate []interface{} `json:"coord,omitempty"`
// Value in mark point
Value string `json:"value,omitempty"`
// Works only when type is assigned.
// It is used to state the dimension used to calculate maximum value or minimum value.
// It may be the direct name of a dimension, like x,
// or angle for line charts, or open, or close for candlestick charts.
ValueDim string `json:"valueDim,omitempty"`
// Mark point text options.
Label *Label `json:"label,omitempty"`
// ItemStyle settings
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// Symbol type
// Options: "circle", "rect", "roundRect", "triangle", "diamond", "pin", "arrow", "none"
Symbol string `json:"symbol,omitempty"`
// Symbol size.
SymbolSize float32 `json:"symbolSize,omitempty"`
// Symbol rotate.
SymbolRotate float32 `json:"symbolRotate,omitempty"`
}
// RippleEffect is the option set for the ripple effect.
type RippleEffect struct {
// The period duration of animation, in seconds.
// default 4(s)
Period float32 `json:"period,omitempty"`
// The maximum zooming scale of ripples in animation.
// default 2.5
Scale float32 `json:"scale,omitempty"`
// The brush type for ripples. options: "stroke" and "fill".
// default "fill"
BrushType string `json:"brushType,omitempty"`
}
// LineStyle is the option set for a link style component.
type LineStyle struct {
// Line color
Color string `json:"color,omitempty"`
// Width of line. default 1
Width float32 `json:"width,omitempty"`
// Type of lineoptions: "solid", "dashed", "dotted". default "solid"
Type string `json:"type,omitempty"`
// Opacity of the component. Supports value from 0 to 1, and the component will not be drawn when set to 0.
Opacity float32 `json:"opacity,omitempty"`
// Curveness of edge. The values from 0 to 1 could be set.
// it would be larger as the the value becomes larger. default 0
Curveness float32 `json:"curveness,omitempty"`
}
// AreaStyle is the option set for an area style component.
type AreaStyle struct {
// Fill area color.
Color string `json:"color,omitempty"`
// Opacity of the component. Supports value from 0 to 1, and the component will not be drawn when set to 0.
Opacity float32 `json:"opacity,omitempty"`
}
// Configuration items about force-directed layout. Force-directed layout simulates
// spring/charge model, which will add a repulsion between 2 nodes and add a attraction
// between 2 nodes of each edge. In each iteration nodes will move under the effect
// of repulsion and attraction. After several iterations, the nodes will be static in a
// balanced position. As a result, the energy local minimum of this whole model will be realized.
// The result of force-directed layout has a good symmetries and clustering, which is also aesthetically pleasing.
type GraphForce struct {
// The initial layout before force-directed layout, which will influence on the result of force-directed layout.
// It defaults not to do any layout and use x, y provided in node as the position of node.
// If it doesn't exist, the position will be generated randomly.
// You can also use circular layout "circular".
InitLayout string `json:"initLayout,omitempty"`
// The repulsion factor between nodes. The repulsion will be stronger and the distance
// between 2 nodes becomes further as this value becomes larger.
// It can be an array to represent the range of repulsion. In this case larger value have larger
// repulsion and smaller value will have smaller repulsion.
Repulsion float32 `json:"repulsion,omitempty"`
// The gravity factor enforcing nodes approach to the center. The nodes will be
// closer to the center as the value becomes larger. default 0.1
Gravity float32 `json:"gravity,omitempty"`
// The distance between 2 nodes on edge. This distance is also affected by repulsion.
// It can be an array to represent the range of edge length. In this case edge with larger
// value will be shorter, which means two nodes are closer. And edge with smaller value will be longer.
// default 30
EdgeLength float32 `json:"edgeLength,omitempty"`
}
// Leaf node special configuration, the leaf node and non-leaf node label location is different.
type TreeLeaves struct {
// The style setting of the text label in a single bar.
Label *Label `json:"label,omitempty"`
// LineStyle settings in this series data.
LineStyle *LineStyle `json:"lineStyle,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// Emphasis settings in this series data.
Emphasis *Emphasis `json:"emphasis,omitempty"`
}
// TreeMapLevel is level specific configuration.
type TreeMapLevel struct {
// Color defines a list for a node level, if empty, retreived from global color list.
Color []string `json:"color,omitempty"`
// ColorAlpha indicates the range of tranparent rate (color alpha) for nodes in a level.
ColorAlpha []float32 `json:"colorAlpha,omitempty"`
// ColorSaturation indicates the range of saturation (color alpha) for nodes in a level.
ColorSaturation []float32 `json:"colorSaturation,omitempty"`
// ColorMappingBy specifies the rule according to which each node obtain color from color list.
ColorMappingBy string `json:"colorMappingBy,omitempty"`
// UpperLabel is used to specify whether show label when the treemap node has children.
UpperLabel *UpperLabel `json:"upperLabel,omitempty"`
// ItemStyle settings in this series data.
ItemStyle *ItemStyle `json:"itemStyle,omitempty"`
// Emphasis settings in this series data.
Emphasis *Emphasis `json:"emphasis,omitempty"`
}
// UpperLabel is used to specify whether show label when the treemap node has children.
// https://echarts.apache.org/en/option.html#series-treemap.upperLabel
type UpperLabel struct {
// Show is true to show upper label.
Show bool `json:"show,omitempty"`
// Position is the label's position.
// * top
// * left
// * right
// * bottom
// * inside
// * insideLeft
// * insideRight
// * insideTop
// * insideBottom
// * insideTopLeft
// * insideBottomLeft
// * insideTopRight
// * insideBottomRight
Position string `json:"position,omitempty"`
// Distance to the host graphic element.
// It is valid only when position is string value (like 'top', 'insideRight').
Distance float32 `json:"distance,omitempty"`
// Rotate label, from -90 degree to 90, positive value represents rotate anti-clockwise.
Rotate float32 `json:"rotate,omitempty"`
// Whether to move text slightly. For example: [30, 40] means move 30 horizontally and move 40 vertically.
Offset []float32 `json:"offset,omitempty"`
// Color is the text color
Color string `json:"color,omitempty"`
// FontStyle
// * "normal"
// * "italic"
// * "oblique"
FontStyle string `json:"fontStyle,omitempty"`
// FontWeight can be the string or a number
// * "normal"
// * "bold"
// * "bolder"
// * "lighter"
// 100 | 200 | 300| 400 ...
FontWeight interface{} `json:"fontWeight,omitempty"`
// FontSize
FontSize float32 `json:"fontSize,omitempty"`
// Align is a horizontal alignment of text, automatic by default.
// * "left"
// * "center"
// * "right"
Align string `json:"align,omitempty"`
// Align is a horizontal alignment of text, automatic by default.
// * "top"
// * "middle"
// * "bottom"
VerticalAlign string `json:"verticalAlign,omitempty"`
// Padding of the text fragment, for example:
// Padding: [3, 4, 5, 6]: represents padding of [top, right, bottom, left].
// Padding: 4: represents padding: [4, 4, 4, 4].
// Padding: [3, 4]: represents padding: [3, 4, 3, 4].
Padding interface{} `json:"padding,omitempty"`
// Width of text block
Width float32 `json:"width,omitempty"`
// Height of text block
Height float32 `json:"height,omitempty"`
// Upper label formatter, which supports string template and callback function.
// In either form, \n is supported to represent a new line.
// String template, Model variation includes:
//
// {a}: series name.
// {b}: the name of a data item.
// {c}: the value of a data item.
// {@xxx}: the value of a dimension named"xxx", for example,{@product}refers the value of"product"` dimension.
// {@[n]}: the value of a dimension at the index ofn, for example,{@[3]}` refers the value at dimensions[3].
Formatter string `json:"formatter,omitempty"`
}
// RGBColor returns the color with RGB format
func RGBColor(r, g, b uint16) string {
return fmt.Sprintf("rgb(%d,%d,%d)", r, g, b)
}
// RGBAColor returns the color with RGBA format
func RGBAColor(r, g, b uint16, a float32) string {
return fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a)
}
// HSLColor returns the color with HSL format
func HSLColor(h, s, l float32) string {
return fmt.Sprintf("hsl(%f,%f%%,%f%%)", h, s, l)
}
// HSLAColor returns the color with HSLA format
func HSLAColor(h, s, l, a float32) string {
return fmt.Sprintf("hsla(%f,%f%%,%f%%,%f)", h, s, l, a)
}
// EdgeLabel is the properties of an label of edge.
// https://echarts.apache.org/en/option.html#series-graph.edgeLabel
type EdgeLabel struct {
// Show is true to show label on edge.
Show bool `json:"show,omitempty"`
// Position is the label's position in line of edge.
// * "start"
// * "middle"
// * "end"
Position string `json:"position,omitempty"`
// Color is the text color
Color string `json:"color,omitempty"`
// FontStyle
// * "normal"
// * "italic"
// * "oblique"
FontStyle string `json:"fontStyle,omitempty"`
// FontWeight can be the string or a number
// * "normal"
// * "bold"
// * "bolder"
// * "lighter"
// 100 | 200 | 300| 400 ...
FontWeight interface{} `json:"fontWeight,omitempty"`
// FontSize
FontSize float32 `json:"fontSize,omitempty"`
// Align is a horizontal alignment of text, automatic by default.
// * "left"
// * "center"
// * "right"
Align string `json:"align,omitempty"`
// Align is a horizontal alignment of text, automatic by default.
// * "top"
// * "middle"
// * "bottom"
VerticalAlign string `json:"verticalAlign,omitempty"`
// Padding of the text fragment, for example:
// Padding: [3, 4, 5, 6]: represents padding of [top, right, bottom, left].
// Padding: 4: represents padding: [4, 4, 4, 4].
// Padding: [3, 4]: represents padding: [3, 4, 3, 4].
Padding interface{} `json:"padding,omitempty"`
// Width of text block
Width float32 `json:"width,omitempty"`
// Height of text block
Height float32 `json:"height,omitempty"`
// Edge label formatter, which supports string template and callback function.
// In either form, \n is supported to represent a new line.
// String template, Model variation includes:
//
// {a}: series name.
// {b}: the name of a data item.
// {c}: the value of a data item.
// {@xxx}: the value of a dimension named"xxx", for example,{@product}refers the value of"product"` dimension.
// {@[n]}: the value of a dimension at the index ofn, for example,{@[3]}` refers the value at dimensions[3].
Formatter string `json:"formatter,omitempty"`
}
// Define what is encoded to for each dimension of data
// https://echarts.apache.org/en/option.html#series-candlestick.encode
type Encode struct {
X interface{} `json:"x"`
Y interface{} `json:"y"`
Tooltip interface{} `json:"tooltip,omitempty"`
SeriesName interface{} `json:"seriesName,omitempty"`
ItemID interface{} `json:"itemId,omitempty"`
ItemName interface{} `json:"itemName,omitempty"`
ItemGroupID interface{} `json:"itemGroupId,omitempty"`
}

View File

@@ -0,0 +1,118 @@
package render
import (
"bytes"
"fmt"
"html/template"
"io"
"reflect"
"regexp"
tpls "github.com/go-echarts/go-echarts/v2/templates"
)
// Renderer
// Any kinds of charts have their render implementation and
// you can define your own render logic easily.
type Renderer interface {
Render(w io.Writer) error
}
const (
ModChart = "chart"
ModPage = "page"
)
var pat = regexp.MustCompile(`(__f__")|("__f__)|(__f__)`)
type pageRender struct {
c interface{}
before []func()
}
// NewPageRender returns a render implementation for Page.
func NewPageRender(c interface{}, before ...func()) Renderer {
return &pageRender{c: c, before: before}
}
// Render renders the page into the given io.Writer.
func (r *pageRender) Render(w io.Writer) error {
for _, fn := range r.before {
fn()
}
contents := []string{tpls.HeaderTpl, tpls.BaseTpl, tpls.PageTpl}
tpl := MustTemplate(ModPage, contents)
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, ModPage, r.c); err != nil {
return err
}
content := pat.ReplaceAll(buf.Bytes(), []byte(""))
_, err := w.Write(content)
return err
}
type chartRender struct {
c interface{}
before []func()
}
// NewChartRender returns a render implementation for Chart.
func NewChartRender(c interface{}, before ...func()) Renderer {
return &chartRender{c: c, before: before}
}
// Render renders the chart into the given io.Writer.
func (r *chartRender) Render(w io.Writer) error {
for _, fn := range r.before {
fn()
}
contents := []string{tpls.HeaderTpl, tpls.BaseTpl, tpls.ChartTpl}
tpl := MustTemplate(ModChart, contents)
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, ModChart, r.c); err != nil {
return err
}
content := pat.ReplaceAll(buf.Bytes(), []byte(""))
_, err := w.Write(content)
return err
}
// isSet check if the field exist in the chart instance
// Shamed copy from https://stackoverflow.com/questions/44675087/golang-template-variable-isset
func isSet(name string, data interface{}) bool {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return false
}
return v.FieldByName(name).IsValid()
}
// MustTemplate creates a new template with the given name and parsed contents.
func MustTemplate(name string, contents []string) *template.Template {
tpl := template.New(name).Funcs(template.FuncMap{
"safeJS": func(s interface{}) template.JS {
return template.JS(fmt.Sprint(s))
},
"isSet": isSet,
})
tpl = template.Must(tpl.Parse(contents[0]))
for _, cont := range contents[1:] {
tpl = template.Must(tpl.Parse(cont))
}
return tpl
}

Some files were not shown because too many files have changed in this diff Show More