From 92b8e8c47f7394ce53ffc26712a25f67ea86eaa3 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 3 Jun 2026 22:49:34 +0200 Subject: [PATCH 01/93] attempt to refactor the logwriter to have a more clean surface --- pkg/datalogger/baselogger.go | 31 ++++++++++ pkg/datalogger/channel.go | 90 +++++++++++++++++++++++++++++ pkg/datalogger/datalogger.go | 2 +- pkg/datalogger/log.go | 4 +- pkg/datalogger/log_bpl.go | 29 +++------- pkg/datalogger/log_csv.go | 40 +++---------- pkg/datalogger/log_txl.go | 29 ++-------- pkg/datalogger/t5logger.go | 12 ++-- pkg/datalogger/t7logger.go | 46 +++++---------- pkg/datalogger/t8logger.go | 14 ++--- pkg/datalogger/txbridgelogger_t5.go | 12 ++-- pkg/datalogger/txbridgelogger_t7.go | 47 +++++++++++---- pkg/datalogger/txbridgelogger_t8.go | 10 ++-- 13 files changed, 221 insertions(+), 145 deletions(-) create mode 100644 pkg/datalogger/channel.go diff --git a/pkg/datalogger/baselogger.go b/pkg/datalogger/baselogger.go index 0de07ce1..56d3bff3 100644 --- a/pkg/datalogger/baselogger.go +++ b/pkg/datalogger/baselogger.go @@ -114,6 +114,37 @@ func (bl *BaseLogger) calculateCompensatedTimestamp() time.Time { return bl.firstTime.Add(time.Duration(bl.currtimestamp-bl.firstTimestamp) * time.Millisecond) } +// appendExtraSysvars appends the wideband and AD scanner pseudo-symbol names to +// a sysvar order slice when they are active, in the same order they are +// written to the log. +func (bl *BaseLogger) appendExtraSysvars(order []string) []string { + if bl.lamb != nil { + order = append(order, EXTERNALWBLSYM) + } + if bl.WidebandConfig.ADScanner && bl.WidebandConfig.Name == "ECU" { + order = append(order, LAMBDAADSCANNER) + } + return order +} + +// buildChannels assembles the standard log layout: every name in order becomes +// an asynchronous sysvar channel, followed by every polled symbol (Number >= +// 0) as a symbol channel. Symbols with a negative number are either replaced by +// a broadcast/derived sysvar or sourced elsewhere and are not log columns. +func (bl *BaseLogger) buildChannels(order []string) []Channel { + channels := make([]Channel, 0, len(order)+len(bl.Symbols)) + for _, name := range order { + channels = append(channels, newSysvarChannel(bl.sysvars, name)) + } + for _, sym := range bl.Symbols { + if sym.Number < 0 { + continue + } + channels = append(channels, newSymbolChannel(sym)) + } + return channels +} + func (bl *BaseLogger) setupWBL(ctx context.Context, cl *gocan.Client) error { cfg := &wbl.WBLConfig{ WBLType: bl.Config.WidebandConfig.Name, diff --git a/pkg/datalogger/channel.go b/pkg/datalogger/channel.go new file mode 100644 index 00000000..e347be14 --- /dev/null +++ b/pkg/datalogger/channel.go @@ -0,0 +1,90 @@ +package datalogger + +import ( + "math" + "strconv" + + symbol "github.com/roffe/ecusymbol" +) + +// Channel is one ordered column in a log. It pairs a name with a way to read +// its current value and a way to format that value as text. +// +// Whether the value comes from a polled ECU symbol, an asynchronous broadcast +// frame, the wideband controller or anywhere else is captured in the read +// closure and decided once when logging starts. Writers therefore only ever +// see a flat, ordered list of named channels and never need to know about +// sysvars vs symbols or sync vs async values. +type Channel struct { + Name string + read func() float64 + format func(float64) string +} + +// Value returns the current value of the channel. +func (c *Channel) Value() float64 { return c.read() } + +// String returns the current value formatted as text. +func (c *Channel) String() string { return c.format(c.read()) } + +// newSysvarChannel reads the latest value of a named entry in the shared sysvars +// map. Used for asynchronously updated values such as T7 broadcast frames, the +// wideband and AD scanner lambda and other derived values. +func newSysvarChannel(sysvars *ThreadSafeMap, name string) Channel { + return Channel{ + Name: name, + read: func() float64 { return sysvars.Get(name) }, + format: sysvarFormat(name), + } +} + +// newSymbolChannel reads the value decoded into the symbol on the most recent +// payload Read. +func newSymbolChannel(sym *symbol.Symbol) Channel { + return Channel{ + Name: sym.Name, + read: sym.Float64, + format: symbolFormat(sym.Correctionfactor), + } +} + +func newFunctionChannel(name string, read func() float64) Channel { + return Channel{ + Name: name, + read: read, + format: func(float64) string { return strconv.FormatFloat(read(), 'f', 2, 64) }, + } +} + +// sysvarFormat mirrors the precision rules the text writers used for sysvars: +// whole numbers print without decimals, the external wideband lambda prints +// with three decimals and everything else with two. +func sysvarFormat(name string) func(float64) string { + return func(v float64) string { + prec := 2 + switch { + case v == math.Trunc(v): + prec = 0 + case name == EXTERNALWBLSYM: + prec = 3 + } + return strconv.FormatFloat(v, 'f', prec, 64) + } +} + +// symbolFormat mirrors symbol.StringValue: the number of decimals is derived +// from the symbol correction factor. +func symbolFormat(correctionfactor float64) func(float64) string { + prec := 0 + switch correctionfactor { + case 0.1: + prec = 1 + case 0.01, 0.0078125, 0.0009765625, 0.00390625, 0.004: + prec = 2 + case 0.001: + prec = 3 + } + return func(v float64) string { + return strconv.FormatFloat(v, 'f', prec, 64) + } +} diff --git a/pkg/datalogger/datalogger.go b/pkg/datalogger/datalogger.go index 72eb00ab..c65a89bf 100644 --- a/pkg/datalogger/datalogger.go +++ b/pkg/datalogger/datalogger.go @@ -17,7 +17,7 @@ const ( ) type LogWriter interface { - Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error + Write(ts time.Time, channels []Channel) error Close() error } diff --git a/pkg/datalogger/log.go b/pkg/datalogger/log.go index cae00a60..d1fc4d00 100644 --- a/pkg/datalogger/log.go +++ b/pkg/datalogger/log.go @@ -7,7 +7,6 @@ import ( "strings" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/txlogger/pkg/common" ) @@ -70,7 +69,6 @@ func NewTXBinWriter(f *os.File) *TXBinWriter { } } -func (t *TXBinWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { - +func (t *TXBinWriter) Write(ts time.Time, channels []Channel) error { return nil } diff --git a/pkg/datalogger/log_bpl.go b/pkg/datalogger/log_bpl.go index c223928e..3fd958f2 100644 --- a/pkg/datalogger/log_bpl.go +++ b/pkg/datalogger/log_bpl.go @@ -6,8 +6,6 @@ import ( "math" "os" "time" - - symbol "github.com/roffe/ecusymbol" ) // BPL (Binary Packed Logfile) on-disk layout. All multi-byte integers are @@ -48,9 +46,9 @@ type BPLWriter struct { buf []byte // reusable per-record encode buffer } -func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { +func (b *BPLWriter) Write(ts time.Time, channels []Channel) error { if !b.headerWritten { - if err := b.writeHeader(vars, sysvarOrder); err != nil { + if err := b.writeHeader(channels); err != nil { return err } } @@ -59,15 +57,8 @@ func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []* binary.LittleEndian.PutUint64(b.buf[off:], uint64(ts.UnixNano())) off += 8 - for _, k := range sysvarOrder { - binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(sysvars.Get(k)))) - off += 4 - } - for _, va := range vars { - if va.Number < 0 { - continue - } - binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(va.Float64()))) + for i := range channels { + binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(channels[i].Value()))) off += 4 } @@ -75,14 +66,10 @@ func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []* return err } -func (b *BPLWriter) writeHeader(vars []*symbol.Symbol, sysvarOrder []string) error { - cols := make([]string, 0, len(sysvarOrder)+len(vars)) - cols = append(cols, sysvarOrder...) - for _, va := range vars { - if va.Number < 0 { - continue - } - cols = append(cols, va.Name) +func (b *BPLWriter) writeHeader(channels []Channel) error { + cols := make([]string, 0, len(channels)) + for i := range channels { + cols = append(cols, channels[i].Name) } if _, err := b.bw.WriteString(bplMagic); err != nil { diff --git a/pkg/datalogger/log_csv.go b/pkg/datalogger/log_csv.go index bc247f01..03352f94 100644 --- a/pkg/datalogger/log_csv.go +++ b/pkg/datalogger/log_csv.go @@ -2,12 +2,9 @@ package datalogger import ( "encoding/csv" - "math" "os" - "strconv" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/txlogger/pkg/logfile" ) @@ -22,46 +19,27 @@ type CSVWriter struct { file *os.File headerWritten bool cw *csv.Writer - precission int } -func (c *CSVWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { +func (c *CSVWriter) Write(ts time.Time, channels []Channel) error { if !c.headerWritten { - if err := c.writeHeader(vars, sysvarOrder); err != nil { + if err := c.writeHeader(channels); err != nil { return err } } - var record []string + record := make([]string, 0, len(channels)+1) record = append(record, ts.Format(logfile.ISONICO)) - for _, k := range sysvarOrder { - val := sysvars.Get(k) - if val == math.Trunc(val) { - c.precission = 0 - } else if k == "Lambda.External" { - c.precission = 3 - } else { - c.precission = 2 - } - record = append(record, strconv.FormatFloat(val, 'f', c.precission, 64)) - } - for _, va := range vars { - if va.Number < 0 { - continue - } - record = append(record, va.StringValue()) + for i := range channels { + record = append(record, channels[i].String()) } return c.cw.Write(record) } -func (c *CSVWriter) writeHeader(vars []*symbol.Symbol, sysvarOrder []string) error { - var header []string +func (c *CSVWriter) writeHeader(channels []Channel) error { + header := make([]string, 0, len(channels)+1) header = append(header, "Time") - header = append(header, sysvarOrder...) - for _, va := range vars { - if va.Number < 0 { - continue - } - header = append(header, va.Name) + for i := range channels { + header = append(header, channels[i].Name) } c.headerWritten = true return c.cw.Write(header) diff --git a/pkg/datalogger/log_txl.go b/pkg/datalogger/log_txl.go index 922c861e..e323cf3c 100644 --- a/pkg/datalogger/log_txl.go +++ b/pkg/datalogger/log_txl.go @@ -1,12 +1,8 @@ package datalogger import ( - "math" "os" - "strconv" "time" - - symbol "github.com/roffe/ecusymbol" ) func NewTXLWriter(f *os.File) *TXWriter { @@ -16,33 +12,16 @@ func NewTXLWriter(f *os.File) *TXWriter { } type TXWriter struct { - file *os.File - precission int + file *os.File } -func (t *TXWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { +func (t *TXWriter) Write(ts time.Time, channels []Channel) error { _, err := t.file.Write([]byte(ts.Format("02-01-2006 15:04:05.999") + "|")) if err != nil { return err } - for _, k := range sysvarOrder { - val := sysvars.Get(k) - if val == math.Trunc(val) { - t.precission = 0 - } else if k == "Lambda.External" { - t.precission = 3 - } else { - t.precission = 2 - } - if _, err := t.file.Write([]byte(k + "=" + replaceDot(strconv.FormatFloat(val, 'f', t.precission, 64)) + "|")); err != nil { - return err - } - } - for _, va := range vars { - if va.Number < 0 { - continue - } - if _, err := t.file.Write([]byte(va.Name + "=" + replaceDot(va.StringValue()) + "|")); err != nil { + for i := range channels { + if _, err := t.file.Write([]byte(channels[i].Name + "=" + replaceDot(channels[i].String()) + "|")); err != nil { return err } } diff --git a/pkg/datalogger/t5logger.go b/pkg/datalogger/t5logger.go index 8a828ec8..c6e606c9 100644 --- a/pkg/datalogger/t5logger.go +++ b/pkg/datalogger/t5logger.go @@ -7,7 +7,6 @@ import ( "math" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/t5can" @@ -57,11 +56,14 @@ func (c *T5Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) } + sysvarOrder = c.appendExtraSysvars(sysvarOrder) - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, len(sysvarOrder)) + for i, name := range sysvarOrder { + channels[i] = newSysvarChannel(c.sysvars, name) } tx := cl.Subscribe(ctx, gocan.SystemMsgDataResponse) @@ -131,7 +133,7 @@ func (c *T5Client) Start() error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, []*symbol.Symbol{}, ts); err != nil { + if err := c.lw.Write(ts, channels); err != nil { c.OnMessage("failed to write log: " + err.Error()) return } diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index fe7f2b04..8dde731b 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -122,12 +122,9 @@ func (c *T7Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) } - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) - } + sysvarOrder = c.appendExtraSysvars(sysvarOrder) for _, sym := range c.Symbols { if c.sysvars.Exists(sym.Name) { @@ -137,6 +134,19 @@ func (c *T7Client) Start() error { } } + // Broadcast/derived values resolved above become async sysvar channels; + // the remaining symbols (Number >= 0) are polled each tick. + channels := c.buildChannels(sysvarOrder) + /* + if c.lamb != nil { + channels = append(channels, newFunctionChannel(EXTERNALWBLSYM, func() float64 { + lamb := c.lamb.GetLambda() + ebus.Publish(EXTERNALWBLSYM, lamb) + return lamb + })) + } + */ + kwp := kwp2000.New(cl) adConverter := NewWBLInterpolator(c.WidebandConfig) @@ -333,31 +343,7 @@ func (c *T7Client) Start() error { ebus.Publish(EXTERNALWBLSYM, lambda) } - /* - // New shit ----- - if c.r != nil { - var values relayserver.LogValues - for _, name := range sysvarOrder { - val := c.sysvars.Get(name) - values = append(values, relayserver.LogValue{Name: name, Value: val}) - } - for _, va := range c.Symbols { - if va.Number < 0 { - continue - } - values = append(values, relayserver.LogValue{Name: va.Name, Value: va.Float64()}) - } - if err := c.r.Send(relayserver.Message{ - Kind: relayserver.MsgTypeData, - Body: values, - }); err != nil { - c.onError() - c.OnMessage("failed to send relay message: " + err.Error()) - } - } - */ - - if err := c.lw.Write(c.sysvars, sysvarOrder, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } @@ -380,7 +366,7 @@ func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.S } if !granted { - onMessage("Security access not granted!") + return errors.New("security access not granted") } else { onMessage("Security access granted") } diff --git a/pkg/datalogger/t8logger.go b/pkg/datalogger/t8logger.go index 7415b659..579b24ea 100644 --- a/pkg/datalogger/t8logger.go +++ b/pkg/datalogger/t8logger.go @@ -67,16 +67,14 @@ func (c *T8Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - order = append(order, EXTERNALWBLSYM) - } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - order = append(order, LAMBDAADSCANNER) } + order = c.appendExtraSysvars(order) // sort order sort.StringSlice(order).Sort() + channels := c.buildChannels(order) + opts := []gmlan.GMLanOption{gmlan.WithCanID(0x7E0), gmlan.WithRecvID(0x7E8)} if cl.AdapterName() == "ELM327" { opts = append(opts, gmlan.WithDefaultTimeout(400*time.Millisecond)) @@ -88,12 +86,12 @@ func (c *T8Client) Start() error { return fmt.Errorf("failed to init t8 logging: %w", err) } - go c.run(ctx, cl, gm, order) + go c.run(ctx, cl, gm, channels) return cl.Wait(ctx) } -func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, order []string) { +func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, channels []Channel) { defer cl.Close() var timeStamp time.Time @@ -215,7 +213,7 @@ func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, c.sysvars.Set(EXTERNALWBLSYM, c.lamb.GetLambda()) } - if err := c.lw.Write(c.sysvars, order, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } diff --git a/pkg/datalogger/txbridgelogger_t5.go b/pkg/datalogger/txbridgelogger_t5.go index fbf2b7da..b7ea30e6 100644 --- a/pkg/datalogger/txbridgelogger_t5.go +++ b/pkg/datalogger/txbridgelogger_t5.go @@ -8,7 +8,6 @@ import ( "log" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/gocan/pkg/serialcommand" "github.com/roffe/txlogger/pkg/ebus" @@ -26,11 +25,14 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) } + sysvarOrder = c.appendExtraSysvars(sysvarOrder) - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, len(sysvarOrder)) + for i, name := range sysvarOrder { + channels[i] = newSysvarChannel(c.sysvars, name) } expectedPayloadSize, err := c.configureT5Symbols(cl) @@ -191,7 +193,7 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, []*symbol.Symbol{}, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.OnMessage("failed to write log: " + err.Error()) return } diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index a0e3812e..e17edc4c 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -9,6 +9,7 @@ import ( "sort" "time" + symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/gocan/pkg/serialcommand" "github.com/roffe/txlogger/pkg/ebus" @@ -35,12 +36,8 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) - } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) } + sysvarOrder = c.appendExtraSysvars(sysvarOrder) for _, sym := range c.Symbols { if c.sysvars.Exists(sym.Name) { @@ -50,6 +47,8 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { } } + channels := c.buildChannels(sysvarOrder) + kwp := kwp2000.New(cl) if err := initT7logging(ctx, kwp, c.Symbols, c.OnMessage); err != nil { return fmt.Errorf("failed to init t7 logging: %w", err) @@ -72,6 +71,35 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { adConverter := NewWBLInterpolator(c.WidebandConfig) + router := map[string]func(s *symbol.Symbol) bool{ + "IgnKnk.fi_Offset": func(s *symbol.Symbol) bool { + data := s.Bytes() + if len(data) != 8 { + return false + } + + ioffCyl1 := int16(binary.BigEndian.Uint16(data[0:2])) + ioffCyl2 := int16(binary.BigEndian.Uint16(data[2:4])) + ioffCyl3 := int16(binary.BigEndian.Uint16(data[4:6])) + ioffCyl4 := int16(binary.BigEndian.Uint16(data[6:8])) + + ebus.Publish("IgnKnk.fi_Offset.Cyl1", float64(ioffCyl1)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl2", float64(ioffCyl2)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl3", float64(ioffCyl3)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl4", float64(ioffCyl4)/10) + return true + }, + } + + if c.WidebandConfig.ADScanner { + router[c.WidebandConfig.ADScannerSymbol] = func(s *symbol.Symbol) bool { + lambda := adConverter(s.Int()) + c.sysvars.Set(LAMBDAADSCANNER, lambda) + ebus.Publish(LAMBDAADSCANNER, lambda) + return true + } + } + go func() { defer cl.Close() defer func() { @@ -211,10 +239,9 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage(err.Error()) break } - if c.WidebandConfig.ADScanner && va.Name == c.WidebandConfig.ADScannerSymbol { - lambda := adConverter(va.Int()) - c.sysvars.Set(LAMBDAADSCANNER, lambda) - ebus.Publish(LAMBDAADSCANNER, lambda) + + if fn, ok := router[va.Name]; ok && fn(va) { + continue } ebus.Publish(va.Name, va.Float64()) @@ -230,7 +257,7 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } diff --git a/pkg/datalogger/txbridgelogger_t8.go b/pkg/datalogger/txbridgelogger_t8.go index ef945071..93cd6199 100644 --- a/pkg/datalogger/txbridgelogger_t8.go +++ b/pkg/datalogger/txbridgelogger_t8.go @@ -22,15 +22,13 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { order := c.sysvars.Keys() if c.lamb != nil { defer c.lamb.Stop() - order = append(order, EXTERNALWBLSYM) - } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - order = append(order, LAMBDAADSCANNER) } + order = c.appendExtraSysvars(order) sort.StringSlice(order).Sort() + channels := c.buildChannels(order) + gm := gmlan.New(cl, 0x7e0, 0x7e8) if err := initT8Logging(ctx, gm, c.Symbols, c.OnMessage); err != nil { @@ -145,7 +143,7 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, order, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } From 3d19825bc8635aa29f1fca358df62c2100e4e690 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 3 Jun 2026 23:53:04 +0200 Subject: [PATCH 02/93] update deps --- go.mod | 8 ++++---- go.sum | 17 ++++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 972bd6d3..ed150596 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,8 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.1.5 - github.com/roffe/gocan v1.3.9 + github.com/roffe/ecusymbol v1.1.7 + github.com/roffe/gocan v1.4.0 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 @@ -46,7 +46,7 @@ require ( github.com/creack/goselect v0.1.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect @@ -66,7 +66,7 @@ require ( github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect diff --git a/go.sum b/go.sum index 74c03543..4020c764 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/ github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= @@ -106,8 +106,8 @@ github.com/lusingander/colorpicker v0.7.5/go.mod h1:fSixgf1m1Hx7GZUTZhKfPoSrgqrL github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= @@ -136,10 +136,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.1.5 h1:mL/i1k8iY85+GLKOpa7JtkyrfDeQLa++89fIGD7XmpI= -github.com/roffe/ecusymbol v1.1.5/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.3.9 h1:6eQ6K4KSLqIQiYWSX4z64PLMcC3PGxZjWs0lv4+xSS8= -github.com/roffe/gocan v1.3.9/go.mod h1:AFv2PzvjSrxeyy2eJgvyDxpLMTTUf7Hx1HfIYhKySlc= +github.com/roffe/ecusymbol v1.1.7 h1:Ub6Dax1V19Jl5y3iXbuhPsPM/XCxEk77Bnal6QU43H4= +github.com/roffe/ecusymbol v1.1.7/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/gocan v1.4.0 h1:OSs//lr4vy/ozyMPUbgZaNFVZWMeXzOsXhCujpA4WRs= +github.com/roffe/gocan v1.4.0/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -213,7 +213,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From 618e30b8317ff89023068efd169addb6d73ea777 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 5 Jun 2026 22:55:28 +0200 Subject: [PATCH 03/93] get rid of sysvar order --- pkg/datalogger/baselogger.go | 14 +++++++++----- pkg/datalogger/t5logger.go | 17 +++++++---------- pkg/datalogger/t7logger.go | 25 ++++--------------------- pkg/datalogger/t8logger.go | 9 +-------- pkg/datalogger/txbridgelogger_t5.go | 17 +++++++---------- pkg/datalogger/txbridgelogger_t7.go | 11 ++++------- pkg/datalogger/txbridgelogger_t8.go | 7 +------ 7 files changed, 33 insertions(+), 67 deletions(-) diff --git a/pkg/datalogger/baselogger.go b/pkg/datalogger/baselogger.go index 56d3bff3..bce18dc1 100644 --- a/pkg/datalogger/baselogger.go +++ b/pkg/datalogger/baselogger.go @@ -127,11 +127,15 @@ func (bl *BaseLogger) appendExtraSysvars(order []string) []string { return order } -// buildChannels assembles the standard log layout: every name in order becomes -// an asynchronous sysvar channel, followed by every polled symbol (Number >= -// 0) as a symbol channel. Symbols with a negative number are either replaced by -// a broadcast/derived sysvar or sourced elsewhere and are not log columns. -func (bl *BaseLogger) buildChannels(order []string) []Channel { +// buildChannels assembles the standard log layout: every current sysvar +// (broadcast/derived values plus the active wideband / AD scanner pseudo-symbols) +// becomes an asynchronous sysvar channel, followed by every polled symbol +// (Number >= 0) as a symbol channel. Symbols with a negative number are either +// replaced by a broadcast/derived sysvar or sourced elsewhere and are not log +// columns. Column order is whatever the channel slice yields; the writer is +// consistent across the header and every row because it iterates this slice. +func (bl *BaseLogger) buildChannels() []Channel { + order := bl.appendExtraSysvars(bl.sysvars.Keys()) channels := make([]Channel, 0, len(order)+len(bl.Symbols)) for _, name := range order { channels = append(channels, newSysvarChannel(bl.sysvars, name)) diff --git a/pkg/datalogger/t5logger.go b/pkg/datalogger/t5logger.go index c6e606c9..f8bf8712 100644 --- a/pkg/datalogger/t5logger.go +++ b/pkg/datalogger/t5logger.go @@ -44,10 +44,12 @@ func (c *T5Client) Start() error { defer t.Stop() t5 := t5can.NewClient(cl) - sysvarOrder := make([]string, len(c.Symbols)) - for n, s := range c.Symbols { - sysvarOrder[n] = s.Name + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, 0, len(c.Symbols)+2) + for _, s := range c.Symbols { s.Correctionfactor = 0.1 + channels = append(channels, newSysvarChannel(c.sysvars, s.Name)) } if err := c.setupWBL(ctx, cl); err != nil { @@ -57,13 +59,8 @@ func (c *T5Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() } - sysvarOrder = c.appendExtraSysvars(sysvarOrder) - - // T5 decodes every value into sysvars (see newT5Converter), so all columns - // are sysvar channels. - channels := make([]Channel, len(sysvarOrder)) - for i, name := range sysvarOrder { - channels[i] = newSysvarChannel(c.sysvars, name) + for _, name := range c.appendExtraSysvars(nil) { + channels = append(channels, newSysvarChannel(c.sysvars, name)) } tx := cl.Subscribe(ctx, gocan.SystemMsgDataResponse) diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index 8dde731b..ff8309fd 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "log" - "sort" "strings" "time" @@ -97,7 +96,6 @@ func (c *T7Client) Start() error { checkBroadcast = false } - var sysvarOrder []string if checkBroadcast { bctx, bcancel := context.WithCancel(ctx) defer bcancel() @@ -105,13 +103,9 @@ func (c *T7Client) Start() error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) - sysvarOrder = c.sysvars.Keys() - sort.StringSlice(sysvarOrder).Sort() - if len(sysvarOrder) > 0 { - c.OnMessage(fmt.Sprintf("Found %s", sysvarOrder)) - } - - if len(sysvarOrder) == 0 { + if found := c.sysvars.Keys(); len(found) > 0 { + c.OnMessage(fmt.Sprintf("Found %s", found)) + } else { c.OnMessage("No broadcast messages found, stopping broadcast listener") bcancel() } @@ -124,8 +118,6 @@ func (c *T7Client) Start() error { defer c.lamb.Stop() } - sysvarOrder = c.appendExtraSysvars(sysvarOrder) - for _, sym := range c.Symbols { if c.sysvars.Exists(sym.Name) { log.Println("Skipping", sym.Name, "in broadcast") @@ -136,16 +128,7 @@ func (c *T7Client) Start() error { // Broadcast/derived values resolved above become async sysvar channels; // the remaining symbols (Number >= 0) are polled each tick. - channels := c.buildChannels(sysvarOrder) - /* - if c.lamb != nil { - channels = append(channels, newFunctionChannel(EXTERNALWBLSYM, func() float64 { - lamb := c.lamb.GetLambda() - ebus.Publish(EXTERNALWBLSYM, lamb) - return lamb - })) - } - */ + channels := c.buildChannels() kwp := kwp2000.New(cl) diff --git a/pkg/datalogger/t8logger.go b/pkg/datalogger/t8logger.go index 579b24ea..8b08fd8c 100644 --- a/pkg/datalogger/t8logger.go +++ b/pkg/datalogger/t8logger.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "math" - "sort" "time" symbol "github.com/roffe/ecusymbol" @@ -59,8 +58,6 @@ func (c *T8Client) Start() error { } defer cl.Close() - order := c.sysvars.Keys() - if err := c.setupWBL(ctx, cl); err != nil { return err } @@ -68,12 +65,8 @@ func (c *T8Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() } - order = c.appendExtraSysvars(order) - - // sort order - sort.StringSlice(order).Sort() - channels := c.buildChannels(order) + channels := c.buildChannels() opts := []gmlan.GMLanOption{gmlan.WithCanID(0x7E0), gmlan.WithRecvID(0x7E8)} if cl.AdapterName() == "ELM327" { diff --git a/pkg/datalogger/txbridgelogger_t5.go b/pkg/datalogger/txbridgelogger_t5.go index b7ea30e6..36fe53d6 100644 --- a/pkg/datalogger/txbridgelogger_t5.go +++ b/pkg/datalogger/txbridgelogger_t5.go @@ -17,22 +17,19 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { ctx, cancel := context.WithCancel(pctx) defer cancel() - sysvarOrder := make([]string, len(c.Symbols)) - for n, s := range c.Symbols { - sysvarOrder[n] = s.Name + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, 0, len(c.Symbols)+2) + for _, s := range c.Symbols { s.Correctionfactor = 0.1 + channels = append(channels, newSysvarChannel(c.sysvars, s.Name)) } if c.lamb != nil { defer c.lamb.Stop() } - sysvarOrder = c.appendExtraSysvars(sysvarOrder) - - // T5 decodes every value into sysvars (see newT5Converter), so all columns - // are sysvar channels. - channels := make([]Channel, len(sysvarOrder)) - for i, name := range sysvarOrder { - channels[i] = newSysvarChannel(c.sysvars, name) + for _, name := range c.appendExtraSysvars(nil) { + channels = append(channels, newSysvarChannel(c.sysvars, name)) } expectedPayloadSize, err := c.configureT5Symbols(cl) diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index e17edc4c..c8f88f63 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "fmt" "log" - "sort" "time" symbol "github.com/roffe/ecusymbol" @@ -26,18 +25,16 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) - sysvarOrder := c.sysvars.Keys() - sort.StringSlice(sysvarOrder).Sort() - c.OnMessage(fmt.Sprintf("Found %s", sysvarOrder)) + found := c.sysvars.Keys() + c.OnMessage(fmt.Sprintf("Found %s", found)) - if len(sysvarOrder) == 0 { + if len(found) == 0 { bcancel() } if c.lamb != nil { defer c.lamb.Stop() } - sysvarOrder = c.appendExtraSysvars(sysvarOrder) for _, sym := range c.Symbols { if c.sysvars.Exists(sym.Name) { @@ -47,7 +44,7 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { } } - channels := c.buildChannels(sysvarOrder) + channels := c.buildChannels() kwp := kwp2000.New(cl) if err := initT7logging(ctx, kwp, c.Symbols, c.OnMessage); err != nil { diff --git a/pkg/datalogger/txbridgelogger_t8.go b/pkg/datalogger/txbridgelogger_t8.go index 93cd6199..b4872bd2 100644 --- a/pkg/datalogger/txbridgelogger_t8.go +++ b/pkg/datalogger/txbridgelogger_t8.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "fmt" "log" - "sort" "time" "github.com/roffe/gocan" @@ -19,15 +18,11 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { ctx, cancel := context.WithCancel(pctx) defer cancel() - order := c.sysvars.Keys() if c.lamb != nil { defer c.lamb.Stop() } - order = c.appendExtraSysvars(order) - sort.StringSlice(order).Sort() - - channels := c.buildChannels(order) + channels := c.buildChannels() gm := gmlan.New(cl, 0x7e0, 0x7e8) From 59af7f5ba6e393bf67cb16ee3a06db3aa2889264 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 8 Jun 2026 22:54:17 +0200 Subject: [PATCH 04/93] add pprof under a debug tag --- main.go | 1 - pprof.go | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 pprof.go diff --git a/main.go b/main.go index 51b6f337..864c732b 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,6 @@ import ( "github.com/roffe/txlogger/pkg/presets" "github.com/roffe/txlogger/pkg/theme" "github.com/roffe/txlogger/pkg/windows" - // _ "net/http/pprof" ) var ( diff --git a/pprof.go b/pprof.go new file mode 100644 index 00000000..f86b4a01 --- /dev/null +++ b/pprof.go @@ -0,0 +1,15 @@ +//go:build pprof + +package main + +import ( + "log" + "net/http" + _ "net/http/pprof" +) + +func init() { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() +} From d7affb7139f3cd817d26635fdfad5818cc59e9db Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:56:30 +0200 Subject: [PATCH 05/93] move to experiments --- .../ebusmonitor/ebusmonitor.go | 0 {pkg => experiments}/eventbus/aggregators.go | 0 experiments/eventbus/bench_test.go | 140 +++++++ {pkg => experiments}/eventbus/eventbus.go | 0 experiments/eventbus/eventbus_test.go | 372 ++++++++++++++++++ {pkg => experiments}/eventbus/unbounded.go | 0 experiments/eventbus/unbounded_test.go | 109 +++++ 7 files changed, 621 insertions(+) rename {pkg/widgets => experiments}/ebusmonitor/ebusmonitor.go (100%) rename {pkg => experiments}/eventbus/aggregators.go (100%) create mode 100644 experiments/eventbus/bench_test.go rename {pkg => experiments}/eventbus/eventbus.go (100%) create mode 100644 experiments/eventbus/eventbus_test.go rename {pkg => experiments}/eventbus/unbounded.go (100%) create mode 100644 experiments/eventbus/unbounded_test.go diff --git a/pkg/widgets/ebusmonitor/ebusmonitor.go b/experiments/ebusmonitor/ebusmonitor.go similarity index 100% rename from pkg/widgets/ebusmonitor/ebusmonitor.go rename to experiments/ebusmonitor/ebusmonitor.go diff --git a/pkg/eventbus/aggregators.go b/experiments/eventbus/aggregators.go similarity index 100% rename from pkg/eventbus/aggregators.go rename to experiments/eventbus/aggregators.go diff --git a/experiments/eventbus/bench_test.go b/experiments/eventbus/bench_test.go new file mode 100644 index 00000000..e7a2ebf5 --- /dev/null +++ b/experiments/eventbus/bench_test.go @@ -0,0 +1,140 @@ +package eventbus_test + +import ( + "strconv" + "testing" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +// BenchmarkPublishNoSubscribers measures the bare cost of enqueueing a message +// when nobody is listening (the run loop still drains and processes it). +func BenchmarkPublishNoSubscribers(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } +} + +// BenchmarkPublishOneSubscriber measures publish throughput with a single +// active subscriber that immediately drains its channel. +func BenchmarkPublishOneSubscriber(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } +} + +// BenchmarkPublishManySubscribers measures fan-out cost across N subscribers. +func BenchmarkPublishManySubscribers(b *testing.B) { + for _, subs := range []int{1, 4, 16, 64} { + b.Run(strconv.Itoa(subs), func(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + for i := 0; i < subs; i++ { + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } + }) + } +} + +// BenchmarkPublishParallel measures publish throughput under contention from +// multiple concurrent publishers. +func BenchmarkPublishParallel(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Publish("topic", 1) + } + }) +} + +// BenchmarkSubscribeUnsubscribe measures the cost of the subscribe/unsubscribe +// round trip through the run loop. +func BenchmarkSubscribeUnsubscribe(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ch := c.Subscribe("topic") + c.Unsubscribe(ch) + } +} + +// BenchmarkAggregatorPublish measures publishing to topics watched by the +// default DIFF aggregators, exercising the aggregator index path. +func BenchmarkAggregatorPublish(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + out := c.Subscribe("VDIFFL") + go func() { + for range out { + } + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("ActualIn.v_Vehicle", float64(i)) + c.Publish("ActualIn.v_Vehicle2", float64(i)+1) + } +} + +// BenchmarkUnboundedChan measures round-trip throughput of the unbounded +// channel with a concurrent consumer. +func BenchmarkUnboundedChan(b *testing.B) { + ch := eventbus.NewUnboundedChan[int]() + done := make(chan struct{}) + go func() { + for range ch.Out() { + } + close(done) + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ch.In() <- i + } + b.StopTimer() + ch.Close() + <-done +} diff --git a/pkg/eventbus/eventbus.go b/experiments/eventbus/eventbus.go similarity index 100% rename from pkg/eventbus/eventbus.go rename to experiments/eventbus/eventbus.go diff --git a/experiments/eventbus/eventbus_test.go b/experiments/eventbus/eventbus_test.go new file mode 100644 index 00000000..1c000367 --- /dev/null +++ b/experiments/eventbus/eventbus_test.go @@ -0,0 +1,372 @@ +package eventbus_test + +import ( + "io" + "log" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +const testTimeout = 2 * time.Second + +// TestMain silences the package's drop logging ("publish channel full" etc.), +// which is expected under load and would otherwise flood benchmark output. +func TestMain(m *testing.M) { + log.SetOutput(io.Discard) + code := m.Run() + log.SetOutput(os.Stderr) + os.Exit(code) +} + +// recvWithin returns the next value from ch, failing if nothing arrives within d. +func recvWithin(t *testing.T, ch <-chan float64, d time.Duration) float64 { + t.Helper() + select { + case v, ok := <-ch: + if !ok { + t.Fatal("channel closed while waiting for a value") + } + return v + case <-time.After(d): + t.Fatal("timed out waiting for a value") + return 0 + } +} + +// publishUntilRecv repeatedly invokes pub until a value shows up on ch. Because +// Subscribe and Publish are processed asynchronously on separate channels, a +// single Publish right after Subscribe may race ahead of the subscription being +// registered. Re-publishing until delivery makes the test deterministic. pub +// must only ever publish the same value to the topic ch is subscribed to so the +// returned value is unambiguous. +func publishUntilRecv(t *testing.T, pub func(), ch <-chan float64) float64 { + t.Helper() + deadline := time.After(testTimeout) + for { + pub() + select { + case v, ok := <-ch: + if !ok { + t.Fatal("channel closed while waiting for delivery") + } + return v + case <-time.After(2 * time.Millisecond): + } + select { + case <-deadline: + t.Fatal("subscription never received a published value") + default: + } + } +} + +// drain discards any buffered values currently sitting on ch. +func drain(ch <-chan float64) { + for { + select { + case <-ch: + default: + return + } + } +} + +func TestNewNilConfigUsesDefaults(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + got := publishUntilRecv(t, func() { c.Publish("topic", 1.5) }, ch) + if got != 1.5 { + t.Fatalf("got %v, want 1.5", got) + } +} + +func TestNewCustomConfig(t *testing.T) { + cfg := &eventbus.Config{IncomingBuffer: 4, SubscribeBuffer: 2, UnsubscribeBuffer: 2} + c := eventbus.New(cfg) + defer c.Close() + + ch := c.Subscribe("topic") + got := publishUntilRecv(t, func() { c.Publish("topic", 42) }, ch) + if got != 42 { + t.Fatalf("got %v, want 42", got) + } +} + +func TestPublishDeliversToSubscriber(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("rpm") + got := publishUntilRecv(t, func() { c.Publish("rpm", 3000) }, ch) + if got != 3000 { + t.Fatalf("got %v, want 3000", got) + } +} + +func TestPublishToOtherTopicNotDelivered(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("a") + // Make sure the subscription is live first. + publishUntilRecv(t, func() { c.Publish("a", 1) }, ch) + drain(ch) + + c.Publish("b", 99) + select { + case v := <-ch: + t.Fatalf("received %v on topic a from a publish to topic b", v) + case <-time.After(50 * time.Millisecond): + } +} + +func TestMultipleSubscribersSameTopic(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch1 := c.Subscribe("temp") + ch2 := c.Subscribe("temp") + + // Keep publishing until both subscriptions are registered and have each + // received at least one value. + var got1, got2 bool + deadline := time.After(testTimeout) + for !(got1 && got2) { + c.Publish("temp", 7) + select { + case <-ch1: + got1 = true + case <-ch2: + got2 = true + case <-time.After(2 * time.Millisecond): + } + select { + case <-deadline: + t.Fatalf("both subscribers did not receive: got1=%v got2=%v", got1, got2) + default: + } + } +} + +func TestSubscribeFuncReceivesAndCancel(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var count int64 + cancel := c.SubscribeFunc("boost", func(v float64) { + atomic.AddInt64(&count, 1) + }) + + // Publish until the callback fires at least once. + deadline := time.After(testTimeout) + for atomic.LoadInt64(&count) == 0 { + c.Publish("boost", 1) + time.Sleep(2 * time.Millisecond) + select { + case <-deadline: + t.Fatal("SubscribeFunc callback never fired") + default: + } + } + + cancel() + // Give the unsubscribe time to propagate, then confirm no further calls. + time.Sleep(20 * time.Millisecond) + before := atomic.LoadInt64(&count) + for i := 0; i < 50; i++ { + c.Publish("boost", 1) + } + time.Sleep(50 * time.Millisecond) + if after := atomic.LoadInt64(&count); after != before { + t.Fatalf("callback fired after cancel: before=%d after=%d", before, after) + } +} + +func TestUnsubscribeClosesChannel(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("x") + publishUntilRecv(t, func() { c.Publish("x", 1) }, ch) + drain(ch) + + c.Unsubscribe(ch) + + // The run loop closes the channel on unsubscribe; reading should eventually + // observe a closed channel. + deadline := time.After(testTimeout) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed as expected + } + case <-deadline: + t.Fatal("channel was not closed after Unsubscribe") + } + } +} + +func TestUnsubscribeUnknownChannelNoPanic(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + // A channel that was never registered should be ignored without panicking. + ch := make(chan float64, 1) + c.Unsubscribe(ch) + // Confirm the controller is still functional afterwards. + sub := c.Subscribe("ok") + got := publishUntilRecv(t, func() { c.Publish("ok", 5) }, sub) + if got != 5 { + t.Fatalf("got %v, want 5", got) + } +} + +func TestSetOnMessageInvoked(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var mu sync.Mutex + var lastTopic string + var lastData float64 + var calls int + + c.SetOnMessage(func(topic string, data float64) { + mu.Lock() + lastTopic = topic + lastData = data + calls++ + mu.Unlock() + }) + + deadline := time.After(testTimeout) + for { + c.Publish("hook", 12.5) + time.Sleep(2 * time.Millisecond) + mu.Lock() + got := calls + mu.Unlock() + if got > 0 { + break + } + select { + case <-deadline: + t.Fatal("onMessage callback never fired") + default: + } + } + + mu.Lock() + defer mu.Unlock() + if lastTopic != "hook" || lastData != 12.5 { + t.Fatalf("got topic=%q data=%v, want hook/12.5", lastTopic, lastData) + } +} + +func TestSetOnMessageNilClears(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var calls int64 + c.SetOnMessage(func(string, float64) { atomic.AddInt64(&calls, 1) }) + + deadline := time.After(testTimeout) + for atomic.LoadInt64(&calls) == 0 { + c.Publish("hook", 1) + time.Sleep(2 * time.Millisecond) + select { + case <-deadline: + t.Fatal("callback never fired before clearing") + default: + } + } + + c.SetOnMessage(nil) + time.Sleep(20 * time.Millisecond) + before := atomic.LoadInt64(&calls) + for i := 0; i < 50; i++ { + c.Publish("hook", 1) + } + time.Sleep(50 * time.Millisecond) + if after := atomic.LoadInt64(&calls); after != before { + t.Fatalf("callback fired after SetOnMessage(nil): before=%d after=%d", before, after) + } +} + +func TestCloseClosesSubscriberChannels(t *testing.T) { + c := eventbus.New(nil) + + ch := c.Subscribe("y") + publishUntilRecv(t, func() { c.Publish("y", 1) }, ch) + drain(ch) + + c.Close() + + deadline := time.After(testTimeout) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed by cleanup + } + case <-deadline: + t.Fatal("subscriber channel not closed after Close") + } + } +} + +func TestCloseIsIdempotent(t *testing.T) { + c := eventbus.New(nil) + c.Close() + // A second Close must not panic (guarded by sync.Once). + c.Close() +} + +func TestDIFFAggregatorPublishesDifference(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + // Default aggregators include DIFFAggregator(v_Vehicle, v_Vehicle2, VDIFFL). + out := c.Subscribe("VDIFFL") + + // Publish both inputs repeatedly until the aggregated diff is delivered. + // Each completed pair yields (second - first) = 30 - 10 = 20. + got := publishUntilRecv(t, func() { + c.Publish("ActualIn.v_Vehicle", 10) + c.Publish("ActualIn.v_Vehicle2", 30) + }, out) + if got != 20 { + t.Fatalf("VDIFFL = %v, want 20", got) + } +} + +func TestConcurrentPublishersNoRace(t *testing.T) { + // Primarily intended to be run with -race. + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("hot") + go func() { + for range ch { + } + }() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 1000; j++ { + c.Publish("hot", float64(n)) + } + }(i) + } + wg.Wait() +} diff --git a/pkg/eventbus/unbounded.go b/experiments/eventbus/unbounded.go similarity index 100% rename from pkg/eventbus/unbounded.go rename to experiments/eventbus/unbounded.go diff --git a/experiments/eventbus/unbounded_test.go b/experiments/eventbus/unbounded_test.go new file mode 100644 index 00000000..fc337d04 --- /dev/null +++ b/experiments/eventbus/unbounded_test.go @@ -0,0 +1,109 @@ +package eventbus_test + +import ( + "sync" + "testing" + "time" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +func TestUnboundedChanPreservesOrder(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + + const n = 10000 + go func() { + for i := 0; i < n; i++ { + ch.In() <- i + } + }() + + for i := 0; i < n; i++ { + select { + case got := <-ch.Out(): + if got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + case <-time.After(testTimeout): + t.Fatalf("timed out waiting for value %d", i) + } + } + ch.Close() +} + +func TestUnboundedChanBuffersBeyondCapacity(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + + // Send many more values than the underlying in/out buffers (16 each) + // without anyone reading. The unbounded buffer must absorb them without + // blocking the sender. + const n = 5000 + done := make(chan struct{}) + go func() { + for i := 0; i < n; i++ { + ch.In() <- i + } + close(done) + }() + + select { + case <-done: + case <-time.After(testTimeout): + t.Fatal("sender blocked: unbounded channel did not buffer") + } + + for i := 0; i < n; i++ { + if got := <-ch.Out(); got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + } + ch.Close() +} + +func TestUnboundedChanCloseClosesOut(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + ch.In() <- 1 + ch.In() <- 2 + + // Read the buffered values, then close and confirm Out() is closed. + if got := <-ch.Out(); got != 1 { + t.Fatalf("got %d, want 1", got) + } + if got := <-ch.Out(); got != 2 { + t.Fatalf("got %d, want 2", got) + } + + ch.Close() + + select { + case _, ok := <-ch.Out(): + if ok { + t.Fatal("expected Out() to be drained/closed after Close") + } + case <-time.After(testTimeout): + t.Fatal("Out() was not closed after Close") + } +} + +func TestUnboundedChanConcurrentProducerConsumer(t *testing.T) { + // Primarily intended to be run with -race. + ch := eventbus.NewUnboundedChan[int]() + + const n = 20000 + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < n; i++ { + ch.In() <- i + } + }() + + for i := 0; i < n; i++ { + if got := <-ch.Out(); got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + } + wg.Wait() + ch.Close() +} From cda8006ea0b37fbcd8ea1bdd786e09e60eca6584 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:57:27 +0200 Subject: [PATCH 06/93] add new bus --- pkg/bus/aggregators.go | 74 +++++++++++++ pkg/bus/aggregators_test.go | 144 +++++++++++++++++++++++++ pkg/bus/bus.go | 140 ++++++++++++++++++++++++ pkg/bus/bus_bench_test.go | 40 +++++++ pkg/bus/bus_test.go | 210 ++++++++++++++++++++++++++++++++++++ pkg/ebus/ebus.go | 45 ++------ pkg/ebus/ebus.old | 72 +++++++++++++ 7 files changed, 690 insertions(+), 35 deletions(-) create mode 100644 pkg/bus/aggregators.go create mode 100644 pkg/bus/aggregators_test.go create mode 100644 pkg/bus/bus.go create mode 100644 pkg/bus/bus_bench_test.go create mode 100644 pkg/bus/bus_test.go create mode 100644 pkg/ebus/ebus.old diff --git a/pkg/bus/aggregators.go b/pkg/bus/aggregators.go new file mode 100644 index 00000000..bbebbf2c --- /dev/null +++ b/pkg/bus/aggregators.go @@ -0,0 +1,74 @@ +package bus + +import "sync" + +// Number is the set of value types DIFFAggregator can subtract. +type Number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 +} + +// DIFFAggregator subscribes to the first and second topics and, once both have +// produced a value, publishes their difference (second - first) to the output +// topic. The internal state resets after each emission, so a fresh value must +// arrive on both inputs before the next diff is published. +// +// Unlike the bus itself, Publish may be called concurrently from several +// goroutines, so the aggregator guards its own state with a mutex. The diff is +// published outside the lock to avoid stalling other publishers and to keep the +// output safe to feed back into the bus. +// +// The returned unsubscribe function removes both input subscriptions; calling +// it more than once is safe and has no further effect. +func DIFFAggregator[K comparable, V Number](c *Controller[K, V], first, second, output K) (unsubscribe func()) { + var ( + mu sync.Mutex + firstUpdated bool + secondUpdated bool + firstValue V + secondValue V + ) + + // combine reports the diff when both inputs are fresh, resetting state so the + // next diff waits for new values. Callers must hold mu. + combine := func() (V, bool) { + if firstUpdated && secondUpdated { + diff := secondValue - firstValue + firstUpdated, secondUpdated = false, false + return diff, true + } + var zero V + return zero, false + } + + stopFirst := c.SubscribeFunc(first, func(v V) { + mu.Lock() + firstValue = v + firstUpdated = true + diff, ok := combine() + mu.Unlock() + if ok { + c.Publish(output, diff) + } + }) + + stopSecond := c.SubscribeFunc(second, func(v V) { + mu.Lock() + secondValue = v + secondUpdated = true + diff, ok := combine() + mu.Unlock() + if ok { + c.Publish(output, diff) + } + }) + + var once sync.Once + return func() { + once.Do(func() { + stopFirst() + stopSecond() + }) + } +} diff --git a/pkg/bus/aggregators_test.go b/pkg/bus/aggregators_test.go new file mode 100644 index 00000000..9aee41f8 --- /dev/null +++ b/pkg/bus/aggregators_test.go @@ -0,0 +1,144 @@ +package bus + +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestDIFFAggregatorPublishesDifference(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("first", 5) + b.Publish("second", 25) + + if len(got) != 1 { + t.Fatalf("expected one diff, got %v", got) + } + if got[0] != 20 { // second - first + t.Fatalf("diff = %v, want 20", got[0]) + } +} + +// A diff is published only once both inputs have a fresh value; a single input +// updating must not emit anything. +func TestDIFFAggregatorWaitsForBothInputs(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var count int + b.SubscribeFunc("out", func(float64) { count++ }) + + b.Publish("first", 1) + b.Publish("first", 2) + b.Publish("first", 3) + + if count != 0 { + t.Fatalf("expected no emission with only one input, got %d", count) + } + + b.Publish("second", 10) + if count != 1 { + t.Fatalf("expected one emission once both inputs seen, got %d", count) + } +} + +// State resets after each emission: a new diff requires fresh values on both +// inputs again, not just one. +func TestDIFFAggregatorResetsAfterEmit(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("first", 1) + b.Publish("second", 4) // emits 3 + b.Publish("second", 9) // no second emit: first is stale + b.Publish("first", 2) // emits 9 - 2 = 7 + + want := []float64{3, 7} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestDIFFAggregatorUnsubscribeStops(t *testing.T) { + b := NewBus[string, float64]() + unsub := DIFFAggregator(b, "first", "second", "out") + + var count int + b.SubscribeFunc("out", func(float64) { count++ }) + + unsub() + unsub() // idempotent + + b.Publish("first", 1) + b.Publish("second", 2) + + if count != 0 { + t.Fatalf("expected no emissions after unsubscribe, got %d", count) + } +} + +// Two aggregators sharing an input topic and output topic keep independent +// state, mirroring the AirDIFF wiring in package ebus. +func TestDIFFAggregatorIndependentStateOnSharedTopics(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "shared", "a", "out") + DIFFAggregator(b, "shared", "b", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("shared", 10) // primes the shared input of both aggregators + b.Publish("a", 30) // first aggregator emits 20 + b.Publish("b", 100) // second aggregator emits 90 + + want := []float64{20, 90} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +// The aggregator must stay race-free when its inputs are published from +// multiple goroutines; meaningful only under -race. +func TestDIFFAggregatorConcurrentPublish(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var emitted atomic.Int64 + b.SubscribeFunc("out", func(float64) { emitted.Add(1) }) + + var wg sync.WaitGroup + for _, topic := range []string{"first", "second"} { + wg.Add(1) + go func() { + defer wg.Done() + for i := range 1000 { + b.Publish(topic, float64(i)) + } + }() + } + wg.Wait() + + // Exact count is non-deterministic under interleaving; just confirm the + // aggregator made progress without racing. + if emitted.Load() == 0 { + t.Fatal("expected at least one diff emission") + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go new file mode 100644 index 00000000..1b055285 --- /dev/null +++ b/pkg/bus/bus.go @@ -0,0 +1,140 @@ +// Package bus implements a small, type-safe publish/subscribe message bus. +// +// Topics are keyed by any comparable type K and carry values of type V. +// Subscribers register either a channel (Subscribe) or a callback +// (SubscribeFunc) and receive every value published to their topic until +// they unsubscribe. +// +// The bus is optimised for a publish-heavy, subscription-stable workload: the +// subscriber table is held as an immutable snapshot behind an atomic pointer, +// so Publish reads it lock-free and allocation-free and scales linearly across +// goroutines. Subscribe/Unsubscribe copy the table (copy-on-write), so they +// cost O(topics + subscribers) — cheap when subscriptions change rarely, which +// is the intended use. +// +// Callbacks run synchronously in the publishing goroutine, so they must be +// fast and non-blocking. A slow callback stalls that Publish call; if you need +// blocking work, hand off to your own goroutine or use Subscribe's buffered +// channel. +package bus + +import ( + "maps" + "sync" + "sync/atomic" +) + +type subscriber[V any] struct { + id uint64 + fn func(V) +} + +// Controller is a concurrency-safe pub/sub bus. The zero value is not usable; +// create one with NewBus. +type Controller[K comparable, V any] struct { + mu sync.Mutex // serialises writers (Subscribe/Unsubscribe) only + nextID uint64 + state atomic.Pointer[map[K][]subscriber[V]] +} + +// NewBus creates an empty bus for topics of type K carrying values of type V. +func NewBus[K comparable, V any]() *Controller[K, V] { + c := &Controller[K, V]{} + empty := make(map[K][]subscriber[V]) + c.state.Store(&empty) + return c +} + +// SubscribeFunc registers fn to be called for every value published to topic. +// It returns an unsubscribe function that removes the subscription; calling it +// more than once is safe and has no further effect. +// +// fn runs synchronously in the goroutine that calls Publish and must not block. +func (c *Controller[K, V]) SubscribeFunc(topic K, fn func(V)) (unsubscribe func()) { + c.mu.Lock() + id := c.nextID + c.nextID++ + c.replace(func(m map[K][]subscriber[V]) { + m[topic] = append(cloneSlice(m[topic]), subscriber[V]{id: id, fn: fn}) + }) + c.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + c.unsubscribe(topic, id) + }) + } +} + +// Subscribe registers a buffered channel that receives every value published +// to topic. The returned unsubscribe function removes the subscription and +// closes the channel. +// +// buffer sets the channel's capacity. If the channel is full when a value is +// published, Publish skips this subscriber rather than blocking, so size the +// buffer for your expected burst rate or drain the channel promptly. +func (c *Controller[K, V]) Subscribe(topic K, buffer int) (ch <-chan V, unsubscribe func()) { + out := make(chan V, buffer) + stop := c.SubscribeFunc(topic, func(v V) { + // Non-blocking send: a slow consumer must not stall the publisher. + select { + case out <- v: + default: + } + }) + + var once sync.Once + return out, func() { + once.Do(func() { + stop() + close(out) + }) + } +} + +// Publish delivers v to every current subscriber of topic. Callbacks run +// synchronously in the caller's goroutine in an unspecified order. This is the +// hot path: it takes no locks and allocates nothing. +func (c *Controller[K, V]) Publish(topic K, v V) { + for _, s := range (*c.state.Load())[topic] { + s.fn(v) + } +} + +func (c *Controller[K, V]) unsubscribe(topic K, id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + c.replace(func(m map[K][]subscriber[V]) { + subs := m[topic] + out := make([]subscriber[V], 0, len(subs)) + for _, s := range subs { + if s.id != id { + out = append(out, s) + } + } + if len(out) == 0 { + delete(m, topic) + } else { + m[topic] = out + } + }) +} + +// replace builds a shallow copy of the current subscriber table, applies mutate +// to the copy, and atomically swaps it in. Callers must hold c.mu so writers +// don't race each other; readers (Publish) never block. The previous table is +// never mutated, so any in-flight Publish keeps iterating a consistent view. +func (c *Controller[K, V]) replace(mutate func(map[K][]subscriber[V])) { + old := *c.state.Load() + next := make(map[K][]subscriber[V], len(old)+1) + maps.Copy(next, old) // slices are immutable once published; share them + mutate(next) + c.state.Store(&next) +} + +func cloneSlice[V any](s []subscriber[V]) []subscriber[V] { + out := make([]subscriber[V], len(s), len(s)+1) + copy(out, s) + return out +} diff --git a/pkg/bus/bus_bench_test.go b/pkg/bus/bus_bench_test.go new file mode 100644 index 00000000..b5cfaa3c --- /dev/null +++ b/pkg/bus/bus_bench_test.go @@ -0,0 +1,40 @@ +package bus + +import ( + "sync/atomic" + "testing" +) + +func benchPublish(b *testing.B, nSubs int) { + bus := NewBus[string, int]() + var sink atomic.Int64 + for range nSubs { + bus.SubscribeFunc("t", func(v int) { sink.Add(int64(v)) }) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + bus.Publish("t", 1) + } +} + +func BenchmarkPublish1(b *testing.B) { benchPublish(b, 1) } +func BenchmarkPublish10(b *testing.B) { benchPublish(b, 10) } +func BenchmarkPublish100(b *testing.B) { benchPublish(b, 100) } +func BenchmarkPublish1000(b *testing.B) { benchPublish(b, 1000) } + +// Contended: many goroutines publishing to the same topic concurrently. +func BenchmarkPublishParallel(b *testing.B) { + bus := NewBus[string, int]() + var sink atomic.Int64 + for range 100 { + bus.SubscribeFunc("t", func(v int) { sink.Add(int64(v)) }) + } + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + bus.Publish("t", 1) + } + }) +} diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go new file mode 100644 index 00000000..3881b7b0 --- /dev/null +++ b/pkg/bus/bus_test.go @@ -0,0 +1,210 @@ +package bus + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSubscribeFuncReceivesPublishes(t *testing.T) { + b := NewBus[string, int]() + var got []int + b.SubscribeFunc("t", func(v int) { got = append(got, v) }) + + for _, v := range []int{1, 2, 3} { + b.Publish("t", v) + } + + want := []int{1, 2, 3} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestPublishToUnknownTopicIsNoop(t *testing.T) { + b := NewBus[string, int]() + b.SubscribeFunc("a", func(int) { t.Fatal("subscriber on topic a should not fire") }) + b.Publish("b", 1) // different topic — must not panic or deliver +} + +func TestMultipleSubscribersEachReceive(t *testing.T) { + b := NewBus[string, int]() + var c1, c2 int + b.SubscribeFunc("t", func(int) { c1++ }) + b.SubscribeFunc("t", func(int) { c2++ }) + + b.Publish("t", 1) + b.Publish("t", 1) + + if c1 != 2 || c2 != 2 { + t.Fatalf("each subscriber should see 2 messages, got c1=%d c2=%d", c1, c2) + } +} + +func TestUnsubscribeStopsDelivery(t *testing.T) { + b := NewBus[string, int]() + var count int + unsub := b.SubscribeFunc("t", func(int) { count++ }) + + b.Publish("t", 1) + unsub() + b.Publish("t", 1) + + if count != 1 { + t.Fatalf("expected 1 delivery before unsubscribe, got %d", count) + } +} + +func TestUnsubscribeIsIdempotent(t *testing.T) { + b := NewBus[string, int]() + var count int + unsub := b.SubscribeFunc("t", func(int) { count++ }) + + unsub() + unsub() // second call must be a safe no-op + b.Publish("t", 1) + + if count != 0 { + t.Fatalf("expected no deliveries after unsubscribe, got %d", count) + } +} + +// Unsubscribing one subscriber must not affect the others on the same topic. +func TestUnsubscribeLeavesOthersIntact(t *testing.T) { + b := NewBus[string, int]() + var a, c int + unsubA := b.SubscribeFunc("t", func(int) { a++ }) + b.SubscribeFunc("t", func(int) { c++ }) + + unsubA() + b.Publish("t", 1) + + if a != 0 { + t.Fatalf("unsubscribed handler fired %d times", a) + } + if c != 1 { + t.Fatalf("remaining handler should fire once, got %d", c) + } +} + +// A Publish already iterating its snapshot must complete against a consistent +// view even if a handler unsubscribes mid-dispatch. +func TestUnsubscribeFromWithinCallback(t *testing.T) { + b := NewBus[string, int]() + var unsub func() + var calls int + unsub = b.SubscribeFunc("t", func(int) { + calls++ + unsub() // remove self during dispatch + }) + b.SubscribeFunc("t", func(int) {}) // a second sub keeps the topic alive + + b.Publish("t", 1) + b.Publish("t", 1) + + if calls != 1 { + t.Fatalf("self-unsubscribing handler should fire exactly once, got %d", calls) + } +} + +func TestSubscribeChannelDelivers(t *testing.T) { + b := NewBus[string, string]() + ch, unsub := b.Subscribe("t", 4) + defer unsub() + + b.Publish("t", "hello") + select { + case got := <-ch: + if got != "hello" { + t.Fatalf("got %q, want %q", got, "hello") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel delivery") + } +} + +func TestSubscribeChannelClosesOnUnsubscribe(t *testing.T) { + b := NewBus[string, int]() + ch, unsub := b.Subscribe("t", 1) + unsub() + + if _, ok := <-ch; ok { + t.Fatal("channel should be closed after unsubscribe") + } +} + +// A full channel must not block the publisher; excess messages are dropped. +func TestSubscribeChannelDropsWhenFull(t *testing.T) { + b := NewBus[string, int]() + ch, unsub := b.Subscribe("t", 1) + defer unsub() + + b.Publish("t", 1) // fills the buffer + b.Publish("t", 2) // dropped, must not block + + if got := <-ch; got != 1 { + t.Fatalf("got %d, want 1", got) + } + select { + case v := <-ch: + t.Fatalf("expected no second message, got %d", v) + default: + } +} + +// Hammer subscribe/unsubscribe/publish concurrently; meaningful only under -race. +func TestConcurrentChurn(t *testing.T) { + b := NewBus[int, int]() + var wg sync.WaitGroup + var delivered atomic.Int64 + + const topics = 8 + stop := make(chan struct{}) + + // Publishers. + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + for tpc := range topics { + b.Publish(tpc, 1) + } + } + } + }() + } + + // Subscribers churning in and out. + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + for tpc := range topics { + unsub := b.SubscribeFunc(tpc, func(int) { delivered.Add(1) }) + unsub() + } + } + } + }() + } + + time.Sleep(100 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/pkg/ebus/ebus.go b/pkg/ebus/ebus.go index 32e091f4..764a97da 100644 --- a/pkg/ebus/ebus.go +++ b/pkg/ebus/ebus.go @@ -1,16 +1,15 @@ package ebus import ( - "context" "sync" "fyne.io/fyne/v2" - "github.com/roffe/txlogger/pkg/eventbus" + "github.com/roffe/txlogger/pkg/bus" ) var ( once sync.Once - CONTROLLER *eventbus.Controller + CONTROLLER *bus.Controller[string, float64] ) const ( @@ -20,7 +19,12 @@ const ( func init() { once.Do(func() { - CONTROLLER = eventbus.New(eventbus.DefaultConfig) + CONTROLLER = bus.NewBus[string, float64]() + + // AirDIFF: m_AirInlet vs the requested air mass. Two instances cover the + // differing request topic names across ECU types; both publish AirDIFF. + bus.DIFFAggregator(CONTROLLER, "MAF.m_AirInlet", "m_Request", "AirDIFF") + bus.DIFFAggregator(CONTROLLER, "MAF.m_AirInlet", "AirMassMast.m_Request", "AirDIFF") }) } @@ -28,19 +32,6 @@ func Publish(topic string, data float64) { CONTROLLER.Publish(topic, data) } -/* - func SubscribeAll() chan eventbus.EBusMessage { - return eb.SubscribeAll() - } - - func SubscribeAllFunc(f func(topic string, value float64)) func() { - return eb.SubscribeAllFunc(f) - } - - func UnsubscribeAll(channel chan eventbus.EBusMessage) { - eb.UnsubscribeAll(channel) - } -*/ func SubscribeFunc(topic string, f func(float64)) func() { wrapFN := func(v float64) { fyne.Do(func() { @@ -50,23 +41,7 @@ func SubscribeFunc(topic string, f func(float64)) func() { return CONTROLLER.SubscribeFunc(topic, wrapFN) } -func Subscribe(topic string) chan float64 { - return CONTROLLER.Subscribe(topic) -} - -func SubscribeWithContext(ctx context.Context, topic string) (chan float64, error) { - ch := CONTROLLER.Subscribe(topic) - go func() { - <-ctx.Done() - CONTROLLER.Unsubscribe(ch) - }() - return ch, nil -} - -func Unsubscribe(channel chan float64) { - CONTROLLER.Unsubscribe(channel) -} - func SetOnMessage(f func(string, float64)) { - CONTROLLER.SetOnMessage(f) + // CONTROLLER.SetOnMessage(f) + // noop for now, the bus doesn't support this and we don't need it yet. If we do, we can add it to the bus package and call it here. } diff --git a/pkg/ebus/ebus.old b/pkg/ebus/ebus.old new file mode 100644 index 00000000..32e091f4 --- /dev/null +++ b/pkg/ebus/ebus.old @@ -0,0 +1,72 @@ +package ebus + +import ( + "context" + "sync" + + "fyne.io/fyne/v2" + "github.com/roffe/txlogger/pkg/eventbus" +) + +var ( + once sync.Once + CONTROLLER *eventbus.Controller +) + +const ( + TOPIC_COLORBLINDMODE = "color_blind_mode" + TOPIC_ECU = "selected_ecu" +) + +func init() { + once.Do(func() { + CONTROLLER = eventbus.New(eventbus.DefaultConfig) + }) +} + +func Publish(topic string, data float64) { + CONTROLLER.Publish(topic, data) +} + +/* + func SubscribeAll() chan eventbus.EBusMessage { + return eb.SubscribeAll() + } + + func SubscribeAllFunc(f func(topic string, value float64)) func() { + return eb.SubscribeAllFunc(f) + } + + func UnsubscribeAll(channel chan eventbus.EBusMessage) { + eb.UnsubscribeAll(channel) + } +*/ +func SubscribeFunc(topic string, f func(float64)) func() { + wrapFN := func(v float64) { + fyne.Do(func() { + f(v) + }) + } + return CONTROLLER.SubscribeFunc(topic, wrapFN) +} + +func Subscribe(topic string) chan float64 { + return CONTROLLER.Subscribe(topic) +} + +func SubscribeWithContext(ctx context.Context, topic string) (chan float64, error) { + ch := CONTROLLER.Subscribe(topic) + go func() { + <-ctx.Done() + CONTROLLER.Unsubscribe(ch) + }() + return ch, nil +} + +func Unsubscribe(channel chan float64) { + CONTROLLER.Unsubscribe(channel) +} + +func SetOnMessage(f func(string, float64)) { + CONTROLLER.SetOnMessage(f) +} From 62e07bbeba743a5daf3fdbe4a62867ded98dac58 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:57:38 +0200 Subject: [PATCH 07/93] remove ebusmonitor --- pkg/windows/mainWindow.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 1d3106d7..a5636b4a 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -286,7 +286,6 @@ func (mw *MainWindow) render() { mw.counters.errorCounterLabel, mw.counters.fpsCounterLabel, ), - widget.NewButtonWithIcon("", theme.ComputerIcon(), mw.openEBUSMonitor), mw.buttons.debugBtn, ), mw.statusText, From 8f74ee350aaf87f5c53d2c96c75217ba1f7b06f2 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:57:59 +0200 Subject: [PATCH 08/93] remove ebusmonitor --- pkg/windows/mainWindow_internal.go | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/pkg/windows/mainWindow_internal.go b/pkg/windows/mainWindow_internal.go index a1711202..894addf4 100644 --- a/pkg/windows/mainWindow_internal.go +++ b/pkg/windows/mainWindow_internal.go @@ -16,8 +16,6 @@ import ( xwidget "fyne.io/x/fyne/widget" "github.com/roffe/gocan/proto" "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/ebus" - "github.com/roffe/txlogger/pkg/widgets/ebusmonitor" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -126,27 +124,6 @@ func listLayouts() []string { return opts } -func (mw *MainWindow) openEBUSMonitor() { - if w := mw.wm.HasWindow("EBUS Monitor"); w != nil { - mw.wm.Raise(w) - return - } - mon := ebusmonitor.New() - eb := multiwindow.NewSystemWindow("EBUS Monitor", mon) - eb.Icon = theme.ComputerIcon() - ebus.SetOnMessage( - func(topic string, data float64) { - fyne.Do(func() { - mon.SetText(topic, data) - }) - }, - ) - eb.OnClose = func() { - ebus.SetOnMessage(nil) - } - mw.wm.Add(eb) -} - func (mw *MainWindow) openSettings() { if w := mw.wm.HasWindow("Settings"); w != nil { mw.wm.Raise(w) From 1cf5892e05f2d388922d87e623e55305ab63f52c Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:58:19 +0200 Subject: [PATCH 09/93] some concepts for t7logger --- pkg/datalogger/t7logger.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index ff8309fd..d238c6ca 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -175,6 +175,9 @@ func (c *T7Client) Start() error { } } } + // In.v_Vehicle Left front wheel speed + // In.v_Vehicle2 Vehicle speed, measured on the rear wheel + // In.v_Vehicle3 Right front wheel speed specialFN := map[string]func(string, float64){ "In.v_Vehicle": wheelSlipFN, @@ -212,6 +215,17 @@ func (c *T7Client) Start() error { } } + /* + if c.lamb != nil { + lambdbaChan := newFunctionChannel(EXTERNALWBLSYM, func() float64 { + lambda := c.lamb.GetLambda() + ebus.Publish(EXTERNALWBLSYM, lambda) + return lambda + }) + channels = append(channels, lambdbaChan) + } + */ + go func() { defer cl.Close() defer func() { @@ -219,10 +233,6 @@ func (c *T7Client) Start() error { time.Sleep(50 * time.Millisecond) }() - // In.v_Vehicle Left front wheel speed - // In.v_Vehicle2 Vehicle speed, measured on the rear wheel - // In.v_Vehicle3 Right front wheel speed - for { select { case <-ctx.Done(): From 3dbc4fac6a586ab1eacbec8ad5f3f82f0d1dba90 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:58:31 +0200 Subject: [PATCH 10/93] use new bus --- pkg/widgets/combinedlogplayer/combinedlogplayer.go | 8 ++++---- pkg/widgets/logplayer/logplayer.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/widgets/combinedlogplayer/combinedlogplayer.go b/pkg/widgets/combinedlogplayer/combinedlogplayer.go index 127ebd85..da9e95c0 100644 --- a/pkg/widgets/combinedlogplayer/combinedlogplayer.go +++ b/pkg/widgets/combinedlogplayer/combinedlogplayer.go @@ -6,7 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/eventbus" + "github.com/roffe/txlogger/pkg/bus" "github.com/roffe/txlogger/pkg/logfile" "github.com/roffe/txlogger/pkg/widgets/dashboard" "github.com/roffe/txlogger/pkg/widgets/logplayer" @@ -36,12 +36,12 @@ func New(cfg *CombinedLogplayerConfig) *Widget { return "Undefined" } } - bus := eventbus.New(eventbus.DefaultConfig) + buz := bus.NewBus[string, float64]() db := dashboard.NewDashboard(cfg.DBcfg) for _, name := range db.GetMetricNames() { - cancel := bus.SubscribeFunc(name, func(f float64) { + cancel := buz.SubscribeFunc(name, func(f float64) { fyne.Do(func() { db.SetValue(name, f) }) @@ -51,7 +51,7 @@ func New(cfg *CombinedLogplayerConfig) *Widget { cp.db = db cp.lp = logplayer.New(&logplayer.Config{ - EBus: bus, + EBus: buz, Logfile: cfg.Logfile, TimeSetter: db.SetTime, }) diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index 0a6d55c0..da7f558a 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -9,8 +9,8 @@ import ( "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/bus" "github.com/roffe/txlogger/pkg/capture" - "github.com/roffe/txlogger/pkg/eventbus" "github.com/roffe/txlogger/pkg/layout" "github.com/roffe/txlogger/pkg/logfile" "github.com/roffe/txlogger/pkg/widgets/plotter" @@ -73,7 +73,7 @@ type logplayerObjects struct { } type Config struct { - EBus *eventbus.Controller + EBus *bus.Controller[string, float64] Logfile logfile.Logfile TimeSetter func(time.Time) } From b31da7cbc1ced095c4ae714d15e3d76ae45c8361 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:58:46 +0200 Subject: [PATCH 11/93] update multiplewindows --- pkg/widgets/multiwindow/arrange.go | 3 +++ pkg/widgets/multiwindow/innerwindow.go | 7 ++++-- pkg/widgets/multiwindow/multiplewindows.go | 29 ++++++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/pkg/widgets/multiwindow/arrange.go b/pkg/widgets/multiwindow/arrange.go index 9d2e1584..60c74f51 100644 --- a/pkg/widgets/multiwindow/arrange.go +++ b/pkg/widgets/multiwindow/arrange.go @@ -211,6 +211,9 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { } func (p *PackArranger) findSpace(node *packNode, size fyne.Size) *packNode { + if node == nil { + return nil + } if node.used { if right := p.findSpace(node.right, size); right != nil { return right diff --git a/pkg/widgets/multiwindow/innerwindow.go b/pkg/widgets/multiwindow/innerwindow.go index be0b03ba..d01185dc 100644 --- a/pkg/widgets/multiwindow/innerwindow.go +++ b/pkg/widgets/multiwindow/innerwindow.go @@ -255,6 +255,7 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { ShadowingRenderer: NewShadowingRenderer(objects, SubmergedContentLevel), win: w, bar: bar, + title: title, buttons: []*borderButton{min, max, close}, bg: w.bg, topBorder: topBorder, @@ -299,6 +300,7 @@ var _ fyne.WidgetRenderer = (*innerWindowRenderer)(nil) type innerWindowRenderer struct { win *InnerWindow bar *fyne.Container + title *draggableLabel buttons []*borderButton bg, contentBG *canvas.Rectangle @@ -399,8 +401,9 @@ func (i *innerWindowRenderer) Refresh() { b.setTheme(th, i.win.active) } i.bar.Refresh() - title := i.bar.Objects[0].(*fyne.Container).Objects[0].(*draggableLabel) - title.SetText(i.win.title) + if i.title.Text != i.win.title { + i.title.SetText(i.win.title) + } i.ShadowingRenderer.RefreshShadow() } diff --git a/pkg/widgets/multiwindow/multiplewindows.go b/pkg/widgets/multiwindow/multiplewindows.go index 460300a6..201d3e33 100644 --- a/pkg/widgets/multiwindow/multiplewindows.go +++ b/pkg/widgets/multiwindow/multiplewindows.go @@ -2,6 +2,7 @@ package multiwindow import ( "encoding/json" + "errors" "time" "fyne.io/fyne/v2" @@ -32,7 +33,8 @@ type MultipleWindows struct { windows []*InnerWindow - content *fyne.Container + content *fyne.Container + childBuf []fyne.CanvasObject // reused backing slice for content.Objects openOffset fyne.Position @@ -193,11 +195,15 @@ func (m *MultipleWindows) refreshChildren() { if m.content == nil { return } - objs := make([]fyne.CanvasObject, len(m.windows)) + if cap(m.childBuf) < len(m.windows) { + m.childBuf = make([]fyne.CanvasObject, len(m.windows)) + } else { + m.childBuf = m.childBuf[:len(m.windows)] + } for i, w := range m.windows { - objs[i] = w + m.childBuf[i] = w } - m.content.Objects = objs + m.content.Objects = m.childBuf m.content.Refresh() } @@ -205,8 +211,13 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { w.OnDragged = func(ev *fyne.DragEvent) { if w.maximized { w.maximized = false - w.Move(ev.AbsolutePosition.SubtractXY(w.preMaximizedSize.Width*0.5, 78)) w.Resize(w.preMaximizedSize) + // Convert the cursor's canvas-relative position into a position + // relative to our container, so this works regardless of where the + // container sits on the canvas (e.g. below a toolbar/menu). + local := ev.AbsolutePosition.Subtract(fyne.CurrentApp().Driver().AbsolutePositionForObject(m.content)) + barHeight := w.Theme().Size(theme.SizeNameWindowTitleBarHeight) + w.Move(local.SubtractXY(w.preMaximizedSize.Width*0.5, barHeight*0.5)) return } @@ -354,6 +365,10 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { func (m *MultipleWindows) LoadLayout(windows []WindowProperties) error { viewportSize := m.Size() + if viewportSize.Width == 0 || viewportSize.Height == 0 { + // Viewport not laid out yet; positions/sizes would all collapse to zero. + return nil + } for _, h := range windows { for _, w := range m.windows { if w.Title() == h.Title { @@ -388,6 +403,10 @@ func (m *MultipleWindows) LoadLayout(windows []WindowProperties) error { func (wm *MultipleWindows) JsonLayout() ([]byte, error) { var history []WindowProperties viewportSize := wm.Size() + if viewportSize.Width == 0 || viewportSize.Height == 0 { + // Avoid Inf/NaN ratios (which json.Marshal rejects) when not yet sized. + return nil, errors.New("multiwindow: cannot save layout before viewport is sized") + } for _, w := range wm.windows { if w.IgnoreSave { From f79537c21d82babe5b46de68e6979d2059569b9b Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 20:58:51 +0200 Subject: [PATCH 12/93] deps and whatsnew --- go.mod | 4 ++-- go.sum | 8 ++++---- pkg/assets/WHATSNEW.md | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index ed150596..2d126f8f 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210 + fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 @@ -37,7 +37,7 @@ require ( ) require ( - fyne.io/systray v1.12.1 // indirect + fyne.io/systray v1.12.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/FyshOS/fancyfs v0.0.1 // indirect github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7 // indirect diff --git a/go.sum b/go.sum index 4020c764..a780694d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210 h1:V+6+GZq6jtDw2AzRfKhbBP7Yg7oCQPQKOl+oEhbSGLs= -fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210/go.mod h1:MEWwWTgsffhd9B79f31GXZLPfnrlXJVavETgNPzrsG0= -fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= -fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35 h1:SRutBcy6SbGtnaKk3VqAYnyoOlwp9JDE6wHZKMZfHiU= +fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e/go.mod h1:TyPwb4pDTB8+btHM20AJpPUNAF8FqEq136+vcGQhcI8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 981218fe..860fad62 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -2,7 +2,11 @@ - Performance optimization for the log plotter and meshgrid - force layouts to be loaded and saved in users home directory under the txlogger folder - update ecusymbol to be able to read T5 versions -- Added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab +- added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab +- refactored the bus implementation to use less CPU and have less allocations +- added support for BPL files ( binary packed logfile ) +- removed support for creating legacy TXL log files. (you can still load them but might cause crashes) +- removed ebusmonitor, it has served it's purpose # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet From 76d6ba2372ffe40d9876a0a6bacc245ba0a3ff58 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 10 Jun 2026 21:01:55 +0200 Subject: [PATCH 13/93] update ecusymbol --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2d126f8f..48633a61 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.1.7 + github.com/roffe/ecusymbol v1.1.8 github.com/roffe/gocan v1.4.0 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index a780694d..c152d11b 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.1.7 h1:Ub6Dax1V19Jl5y3iXbuhPsPM/XCxEk77Bnal6QU43H4= -github.com/roffe/ecusymbol v1.1.7/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/ecusymbol v1.1.8 h1:c145IldWHTnzy4y2cInpHRDYYvehnqFxsKQZ2YPyzNw= +github.com/roffe/ecusymbol v1.1.8/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.0 h1:OSs//lr4vy/ozyMPUbgZaNFVZWMeXzOsXhCujpA4WRs= github.com/roffe/gocan v1.4.0/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= From fd7312b86cb1542cca0f9ced2c966fa6437dbcf8 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 11 Jun 2026 21:22:18 +0200 Subject: [PATCH 14/93] WBL refactor to reconnect port if it's dropped mid logging --- pkg/wbl/aem/aem_uego.go | 109 +++++++++++++++++++--- pkg/wbl/innovate/innovate.go | 138 +++++++++++++++++++++++----- pkg/wbl/stag/stag.go | 160 ++++++++++++++++++++++++++------- pkg/wbl/zeitronix/zeitronix.go | 115 +++++++++++++++++++++--- 4 files changed, 445 insertions(+), 77 deletions(-) diff --git a/pkg/wbl/aem/aem_uego.go b/pkg/wbl/aem/aem_uego.go index 98379771..30e88324 100644 --- a/pkg/wbl/aem/aem_uego.go +++ b/pkg/wbl/aem/aem_uego.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -14,6 +15,10 @@ import ( const ProductString = "AEM Uego" +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + type AEMuego struct { port string sp serial.Port @@ -25,6 +30,8 @@ type AEMuego struct { log func(string) closeOnce sync.Once + closed atomic.Bool + done chan struct{} mu sync.Mutex dataBuff []byte @@ -41,13 +48,26 @@ func NewAEMuegoClient(port string, logFunc func(string)) (*AEMuego, error) { return &AEMuego{ port: port, log: logFunc, + done: make(chan struct{}), dataBuff: make([]byte, 8), //debugLog: f, }, nil } func (a *AEMuego) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := a.openPort(); err != nil { + return err + } + go a.run(ctx) + + return nil +} + +// openPort opens the serial port and stores it on the client. +func (a *AEMuego) openPort() error { mode := &serial.Mode{ BaudRate: 9600, } @@ -55,13 +75,59 @@ func (a *AEMuego) Start(ctx context.Context) error { if err != nil { return err } + sp.SetReadTimeout(5 * time.Millisecond) + + a.mu.Lock() a.sp = sp + a.mu.Unlock() + return nil +} - a.sp.SetReadTimeout(5 * time.Millisecond) +// getPort returns the current serial port, or nil if none is open. +func (a *AEMuego) getPort() serial.Port { + a.mu.Lock() + defer a.mu.Unlock() + return a.sp +} - go a.run(ctx) +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (a *AEMuego) closePort() { + a.mu.Lock() + sp := a.sp + a.sp = nil + a.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + a.log("AEM: " + err.Error()) + } + } +} - return nil +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (a *AEMuego) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || a.closed.Load() { + return false + } + a.log("AEM: reconnecting to " + a.port) + if err := a.openPort(); err != nil { + a.log("AEM: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-a.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + a.log("AEM: reconnected to " + a.port) + a.dataPos = 0 + return true + } } func (a *AEMuego) GetLambda() float64 { @@ -77,16 +143,35 @@ func (a *AEMuego) SetLambda(value float64) { } func (a *AEMuego) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer a.closePort() + buf := make([]byte, 8) for { + if ctx.Err() != nil || a.closed.Load() { + return + } + + sp := a.getPort() + if sp == nil { + if !a.reconnect(ctx) { + return + } + continue + } + // read from serial - n, err := a.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if ctx.Err() != nil || a.closed.Load() { return } if err != nil { a.log("AEM: " + err.Error()) - return + a.closePort() + if !a.reconnect(ctx) { + return + } + continue } if n == 0 { continue @@ -126,12 +211,12 @@ func (a *AEMuego) run(ctx context.Context) { func (a *AEMuego) Stop() { a.closeOnce.Do(func() { - if a.sp != nil { - a.log("Stopping AEM serial client") - if err := a.sp.Close(); err != nil { - a.log(err.Error()) - } - } + a.log("Stopping AEM serial client") + // Signal run()/reconnect() to stop before closing the port so a port + // drop isn't mistaken for a reconnect opportunity. + a.closed.Store(true) + close(a.done) + a.closePort() //if a.debugLog != nil { // a.debugLog.Sync() // a.debugLog.Close() diff --git a/pkg/wbl/innovate/innovate.go b/pkg/wbl/innovate/innovate.go index 56cf884e..8d336ad8 100644 --- a/pkg/wbl/innovate/innovate.go +++ b/pkg/wbl/innovate/innovate.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -16,6 +17,10 @@ const ( ProductString = "Innovate Serial Protocol v2" ) +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + const ( ISP2_NORMAL uint8 = iota ISP2_O2 @@ -57,7 +62,10 @@ type ISP2Client struct { syncBuffer []byte // New field for synchronization - mu sync.Mutex + closeOnce sync.Once + closed atomic.Bool + done chan struct{} + mu sync.Mutex log func(string) } @@ -66,39 +74,104 @@ func NewISP2Client(port string, logFunc func(string)) (*ISP2Client, error) { return &ISP2Client{ port: port, buff: bytes.NewBuffer(nil), + done: make(chan struct{}), log: logFunc, }, nil } func (c *ISP2Client) Start(ctx context.Context) error { - if c.port != "txbridge" { - c.log("Starting ISP2 client") - mode := &serial.Mode{ - BaudRate: 9600, - } - sp, err := serial.Open(c.port, mode) - if err != nil { - return err - } - c.sp = sp - - c.sp.SetReadTimeout(20 * time.Millisecond) + if c.port == "txbridge" { + return nil + } + c.log("Starting ISP2 client") + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := c.openPort(); err != nil { + return err + } + go c.run(ctx) + return nil +} - // c.syncBuffer = make([]byte, 4) // Initialize syncBuffer - go c.run(ctx) +// openPort opens the serial port and stores it on the client. +func (c *ISP2Client) openPort() error { + mode := &serial.Mode{ + BaudRate: 9600, } + sp, err := serial.Open(c.port, mode) + if err != nil { + return err + } + sp.SetReadTimeout(20 * time.Millisecond) + + c.mu.Lock() + c.sp = sp + c.mu.Unlock() return nil } -func (c *ISP2Client) Stop() { - if c.sp != nil { - c.log("Stopping ISP2 client") - if err := c.sp.Close(); err != nil { - c.log(err.Error()) +// getPort returns the current serial port, or nil if none is open. +func (c *ISP2Client) getPort() serial.Port { + c.mu.Lock() + defer c.mu.Unlock() + return c.sp +} + +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (c *ISP2Client) closePort() { + c.mu.Lock() + sp := c.sp + c.sp = nil + c.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + c.log("isp2: " + err.Error()) } } } +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (c *ISP2Client) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || c.closed.Load() { + return false + } + c.log("isp2: reconnecting to " + c.port) + if err := c.openPort(); err != nil { + c.log("isp2: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-c.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + c.log("isp2: reconnected to " + c.port) + // Drop any partially-parsed frame from before the drop. + c.mu.Lock() + c.syncBuffer = nil + c.wordIndex = 0 + c.mu.Unlock() + return true + } +} + +func (c *ISP2Client) Stop() { + c.closeOnce.Do(func() { + c.log("Stopping ISP2 client") + // Signal run()/reconnect() to stop before closing the port so a port + // drop isn't mistaken for a reconnect opportunity. + c.closed.Store(true) + close(c.done) + c.closePort() + }) +} + func (c *ISP2Client) SetData(data []byte) { c.processBytes(data) } @@ -163,16 +236,35 @@ func (c *ISP2Client) String() string { } func (c *ISP2Client) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer c.closePort() + buf := make([]byte, 16) for { + if ctx.Err() != nil || c.closed.Load() { + return + } + + sp := c.getPort() + if sp == nil { + if !c.reconnect(ctx) { + return + } + continue + } + // read from serial - n, err := c.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if ctx.Err() != nil || c.closed.Load() { return } if err != nil { c.log("isp2: " + err.Error()) - return + c.closePort() + if !c.reconnect(ctx) { + return + } + continue } if n == 0 { continue diff --git a/pkg/wbl/stag/stag.go b/pkg/wbl/stag/stag.go index 2ba85287..04169696 100644 --- a/pkg/wbl/stag/stag.go +++ b/pkg/wbl/stag/stag.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -11,6 +12,10 @@ import ( const ProductString = "Stag AFR" +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + type STAG struct { port string sp serial.Port @@ -21,19 +26,33 @@ type STAG struct { log func(string) closeOnce sync.Once + closed atomic.Bool + done chan struct{} mu sync.Mutex - worker *workerInfo } func NewSTAGClient(port string, logFunc func(string)) (*STAG, error) { return &STAG{ port: port, + done: make(chan struct{}), log: logFunc, }, nil } func (a *STAG) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := a.openPort(); err != nil { + return err + } + + go a.run(ctx) + return nil +} + +// openPort opens the serial port and stores it on the client. +func (a *STAG) openPort() error { mode := &serial.Mode{ BaudRate: 57600, } @@ -41,21 +60,58 @@ func (a *STAG) Start(ctx context.Context) error { if err != nil { return err } + sp.SetReadTimeout(5 * time.Millisecond) + + a.mu.Lock() a.sp = sp + a.mu.Unlock() + return nil +} - a.sp.SetReadTimeout(5 * time.Millisecond) +// getPort returns the current serial port, or nil if none is open. +func (a *STAG) getPort() serial.Port { + a.mu.Lock() + defer a.mu.Unlock() + return a.sp +} - ctx, cancel := context.WithCancel(context.Background()) - a.worker = &workerInfo{ - cancel: cancel, - done: make(chan struct{}), +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (a *STAG) closePort() { + a.mu.Lock() + sp := a.sp + a.sp = nil + a.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + a.log(err.Error()) + } } - go func() { - a.run(ctx) - close(a.worker.done) - }() +} - return nil +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (a *STAG) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || a.closed.Load() { + return false + } + a.log("Stag: reconnecting to " + a.port) + if err := a.openPort(); err != nil { + a.log("Stag: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-a.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + a.log("Stag: reconnected to " + a.port) + return true + } } func (a *STAG) GetLambda() float64 { @@ -65,6 +121,42 @@ func (a *STAG) GetLambda() float64 { } func (a *STAG) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer a.closePort() + + for { + if ctx.Err() != nil || a.closed.Load() { + return + } + + sp := a.getPort() + if sp == nil { + if !a.reconnect(ctx) { + return + } + continue + } + + // session() runs until the port errors or the client is stopped. + a.session(ctx, sp) + + if ctx.Err() != nil || a.closed.Load() { + return + } + a.closePort() + if !a.reconnect(ctx) { + return + } + } +} + +// session drives one connection: it reads from sp and parses packets until the +// port errors or the client is stopped. +func (a *STAG) session(ctx context.Context, sp serial.Port) { + // sessionCtx tears down the reader goroutine when this session ends. + sessionCtx, cancel := context.WithCancel(ctx) + defer cancel() + packetContentBuffer := make([]byte, 0, 64) buf := make([]byte, 8) packetStarted := false @@ -80,19 +172,26 @@ func (a *STAG) run(ctx context.Context) { go func() { for { // read from serial - n, err := a.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if sessionCtx.Err() != nil { + return + } + if err != nil { + select { + case errChan <- err: + case <-sessionCtx.Done(): + } return } if n == 0 { continue } - - if err != nil { - errChan <- err - } for _, b := range buf[:n] { - byteChan <- b + select { + case byteChan <- b: + case <-sessionCtx.Done(): + return + } } } }() @@ -101,9 +200,11 @@ func (a *STAG) run(ctx context.Context) { select { case <-ctx.Done(): return + case <-a.done: + return case err := <-errChan: a.log(err.Error()) - // Handle errorfunc + // Port dropped; let run() handle reconnection. return case aByte := <-byteChan: if !packetStarted && aByte == 0x32 { @@ -128,12 +229,12 @@ func (a *STAG) run(ctx context.Context) { func (a *STAG) Stop() { a.closeOnce.Do(func() { - if a.sp != nil { - a.log("Stopping Stag serial client") - if err := a.sp.Close(); err != nil { - a.log(err.Error()) - } - } + a.log("Stopping Stag serial client") + // Signal run()/session()/reconnect() to stop before closing the port so + // a port drop isn't mistaken for a reconnect opportunity. + a.closed.Store(true) + close(a.done) + a.closePort() }) } func (a *STAG) processPacket(packetContentBuffer []byte) { @@ -158,7 +259,9 @@ func (a *STAG) processPacket(packetContentBuffer []byte) { func (a *STAG) sendRequest(data []byte) { time.Sleep(100 * time.Millisecond) - a.sp.Write(data) + if sp := a.getPort(); sp != nil { + sp.Write(data) + } } func (a *STAG) SetData(data []byte) error { @@ -184,8 +287,3 @@ func (a *STAG) SetData(data []byte) error { func (a *STAG) String() string { return fmt.Sprintf("Lambda: %.4f, Oxygen: %.3f", a.lambda, a.oxygen) } - -type workerInfo struct { - cancel context.CancelFunc - done chan struct{} -} diff --git a/pkg/wbl/zeitronix/zeitronix.go b/pkg/wbl/zeitronix/zeitronix.go index 7cbb53de..c979d1de 100644 --- a/pkg/wbl/zeitronix/zeitronix.go +++ b/pkg/wbl/zeitronix/zeitronix.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" - "log" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -13,6 +13,10 @@ import ( const ProductString = "Zeitronix ZT-2" +// reconnectDelay is how long the serial handler waits between reconnect +// attempts after the serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + /* Zeitronix Packet format, []byte [0] always 0 @@ -41,18 +45,34 @@ type Zeitronix struct { p serial.Port closeOnce sync.Once + closed atomic.Bool + done chan struct{} + mu sync.Mutex logFunc func(string) } func NewZeitronixClient(port string, logFunc func(string)) (*Zeitronix, error) { z := &Zeitronix{ Port: port, + done: make(chan struct{}), logFunc: logFunc, } return z, nil } func (z *Zeitronix) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by serialHandler(). + if err := z.openPort(); err != nil { + return err + } + go z.serialHandler(ctx) + + return nil +} + +// openPort opens the serial port and stores it on the client. +func (z *Zeitronix) openPort() error { mode := &serial.Mode{ BaudRate: 9600, Parity: serial.NoParity, @@ -63,23 +83,94 @@ func (z *Zeitronix) Start(ctx context.Context) error { if err != nil { return err } - z.p = sp - z.p.SetReadTimeout(500 * time.Millisecond) - go z.serialHandler() + sp.SetReadTimeout(5 * time.Millisecond) + z.mu.Lock() + z.p = sp + z.mu.Unlock() return nil } -func (z *Zeitronix) serialHandler() { +// getPort returns the current serial port, or nil if none is open. +func (z *Zeitronix) getPort() serial.Port { + z.mu.Lock() + defer z.mu.Unlock() + return z.p +} + +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (z *Zeitronix) closePort() { + z.mu.Lock() + sp := z.p + z.p = nil + z.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + z.logFunc("Zeitronix: " + err.Error()) + } + } +} + +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (z *Zeitronix) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || z.closed.Load() { + return false + } + z.logFunc("Zeitronix: reconnecting to " + z.Port) + if err := z.openPort(); err != nil { + z.logFunc("Zeitronix: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-z.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + z.logFunc("Zeitronix: reconnected to " + z.Port) + return true + } +} + +func (z *Zeitronix) serialHandler(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when this returns. + defer z.closePort() + buff := make([]byte, 14) cmd := make([]byte, 14) step := 0 for { - n, err := z.p.Read(buff) - if err != nil { - log.Println("Zeitronix read error:", err) + if ctx.Err() != nil || z.closed.Load() { + return + } + + sp := z.getPort() + if sp == nil { + if !z.reconnect(ctx) { + return + } + step = 0 + continue + } + + n, err := sp.Read(buff) + if ctx.Err() != nil || z.closed.Load() { return } + if err != nil { + z.logFunc("Zeitronix read error: " + err.Error()) + z.closePort() + step = 0 + if !z.reconnect(ctx) { + return + } + continue + } if n == 0 { continue } @@ -128,9 +219,11 @@ func (z *Zeitronix) SetData(data []byte) error { func (z *Zeitronix) Stop() { z.closeOnce.Do(func() { z.logFunc("Closing Zeitronix client") - if z.p != nil { - z.p.Close() - } + // Signal serialHandler()/reconnect() to stop before closing the port so + // a port drop isn't mistaken for a reconnect opportunity. + z.closed.Store(true) + close(z.done) + z.closePort() }) } From 28efe96347203b68f6cba12ad372f16526ba0a48 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 11 Jun 2026 22:49:59 +0200 Subject: [PATCH 15/93] widget improvements --- pkg/widgets/cbar/cbar.go | 30 +++++---- pkg/widgets/dashboard/dashboard.go | 38 +++++------- pkg/widgets/dashboard/setters.go | 15 ++++- pkg/widgets/hbar/hbar.go | 59 +++++++++--------- pkg/widgets/icon/icon.go | 21 ++++--- pkg/widgets/symbollist/symbollist.go | 91 ++++++++++++++++++++++------ pkg/widgets/vbar/vbar.go | 16 +++-- pkg/windows/mainWindow.go | 4 +- 8 files changed, 173 insertions(+), 101 deletions(-) diff --git a/pkg/widgets/cbar/cbar.go b/pkg/widgets/cbar/cbar.go index b51acb9b..774530f1 100644 --- a/pkg/widgets/cbar/cbar.go +++ b/pkg/widgets/cbar/cbar.go @@ -43,6 +43,9 @@ type CBar struct { // Fast float formatting fmtPrec int buf []byte + + // Cached monospace glyph width for the current display TextSize + charWidth float32 } func New(cfg *widgets.GaugeConfig) *CBar { @@ -122,10 +125,11 @@ func (s *CBar) initializeVisualElements() { } func (s *CBar) SetValue(value float64) { + value = max(s.cfg.Min, min(s.cfg.Max, value)) if value == s.value { return } - s.value = max(s.cfg.Min, min(s.cfg.Max, value)) + s.value = value barPosition := s.center var pxWidth float32 @@ -170,7 +174,6 @@ func (s *CBar) updateDisplayTextPosition() { if len(text) == 0 { return } - minSize := s.displayText.MinSize() dotIdx := -1 for i := 0; i < len(text); i++ { @@ -182,10 +185,9 @@ func (s *CBar) updateDisplayTextPosition() { var x float32 if dotIdx >= 0 { - charWidth := minSize.Width / float32(len(text)) - x = s.lastSize.Width*0.5 - charWidth*(float32(dotIdx)+0.5) + x = s.lastSize.Width*0.5 - s.charWidth*(float32(dotIdx)+0.5) } else { - x = s.lastSize.Width*0.5 - minSize.Width*0.5 + x = s.lastSize.Width*0.5 - s.charWidth*float32(len(text))*0.5 } s.displayText.Move(fyne.Position{X: x, Y: s.displayY}) @@ -198,11 +200,12 @@ func (s *CBar) SetValue2(value float64) { func (s *CBar) CreateRenderer() fyne.WidgetRenderer { // Initialize visual elements s.initializeVisualElements() - return &CBarRenderer{s} + return &CBarRenderer{CBar: s} } type CBarRenderer struct { *CBar + objects []fyne.CanvasObject } func (r *CBarRenderer) MinSize() fyne.Size { @@ -265,6 +268,7 @@ func (r *CBarRenderer) Layout(space fyne.Size) { r.bar.Resize(fyne.Size{Width: r.barWidth * r.widthFactor, Height: r.barHeight}) r.displayText.TextSize = r.bar.Size().Height - 8 + r.charWidth = fyne.MeasureText("0", r.displayText.TextSize, r.displayText.TextStyle).Width var y float32 switch r.cfg.TextPosition { @@ -282,11 +286,13 @@ func (r *CBarRenderer) Layout(space fyne.Size) { } func (r *CBarRenderer) Objects() []fyne.CanvasObject { - objs := []fyne.CanvasObject{} - for _, line := range r.bars { - objs = append(objs, line) + if r.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(r.bars)+4) + for _, line := range r.bars { + objs = append(objs, line) + } + objs = append(objs, r.bar, r.face, r.titleText, r.displayText) + r.objects = objs } - - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/widgets/dashboard/dashboard.go b/pkg/widgets/dashboard/dashboard.go index 91c7e9c4..cc82947e 100644 --- a/pkg/widgets/dashboard/dashboard.go +++ b/pkg/widgets/dashboard/dashboard.go @@ -6,8 +6,6 @@ import ( "log" "time" - _ "embed" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/theme" @@ -80,11 +78,11 @@ type Config struct { AirDemToString func(float64) string UseMPH bool SwapRPMandSpeed bool - Low float64 - High float64 - WidebandSymbol string - MetricRouter map[string]func(float64) - FullscreenFunc func(bool) + // Low/High set the wideband lambda bar display range (defaults 0.5–1.5). + Low float64 + High float64 + WidebandSymbol string + FullscreenFunc func(bool) } func NewDashboard(cfg *Config) *Dashboard { @@ -99,6 +97,11 @@ func NewDashboard(cfg *Config) *Dashboard { speedometerText = "mph" } + wbLow, wbHigh := cfg.Low, cfg.High + if wbLow >= wbHigh { + wbLow, wbHigh = 0.5, 1.5 + } + db := &Dashboard{ cfg: cfg, logplayer: cfg.Logplayer, @@ -167,9 +170,9 @@ func NewDashboard(cfg *Config) *Dashboard { }), wblambda: cbar.New(&widgets.GaugeConfig{ Title: "", - Min: 0.50, - Center: 1, - Max: 1.50, + Min: wbLow, + Center: (wbLow + wbHigh) * 0.5, + Max: wbHigh, Steps: 20, MinSize: fyne.NewSize(50, 35), DisplayString: "λ %.2f", @@ -242,10 +245,6 @@ func NewDashboard(cfg *Config) *Dashboard { } db.ExtendBaseWidget(db) - db.text.cruise.Hide() - db.image.checkEngine.Hide() - db.image.limpMode.Hide() - db.metricRouter = db.createRouter() var isFullscreen bool @@ -414,10 +413,8 @@ type dims struct { sixthWidth float32 thirdHeight float32 tenthHeight float32 - halfHeight float32 centerX float32 centerY float32 - bottomY float32 textSize float32 smallTextSize float32 } @@ -642,12 +639,8 @@ func (dr *DashboardRenderer) Layout(space fyne.Size) { sixthWidth: space.Width * common.OneSixth, thirdHeight: (space.Height - 50) * .33, tenthHeight: (space.Height - 50) * .1, - halfHeight: (space.Height - 50) * .5, centerX: space.Width * 0.5, centerY: space.Height * 0.5, - bottomY: space.Height - 55, - - // textSize: max(min(space.Height, space.Width)*0.07, 20), } // Layout horizontal bars dr.db.layoutBars(dims) @@ -661,10 +654,7 @@ func (dr *DashboardRenderer) Layout(space fyne.Size) { dr.db.fullscreenBtn.Resize(fyne.NewSize(btnWidth, btnHeigh)) dr.db.fullscreenBtn.Move(fyne.NewPos(space.Width-btnWidth, space.Height-btnHeigh)) - dims.textSize = dr.db.gauges.nblambda.Size().Height - 2 - dims.smallTextSize = dims.textSize * 0.5 - - // Layout text elements + // Layout text elements (computes its own textSize/smallTextSize) dr.db.layoutTexts(dims) // Layout icons diff --git a/pkg/widgets/dashboard/setters.go b/pkg/widgets/dashboard/setters.go index 57f1b3fb..0b211280 100644 --- a/pkg/widgets/dashboard/setters.go +++ b/pkg/widgets/dashboard/setters.go @@ -1,8 +1,8 @@ package dashboard import ( - "fmt" "image/color" + "math" "strconv" "time" @@ -139,13 +139,24 @@ func textSetter(obj *canvas.Text, text, unit string, precision int) func(float64 } func idcSetter(obj *canvas.Text, text string) func(float64) { + var buf []byte oldValue := -1.0 return func(value float64) { if value == oldValue { return } oldValue = value - obj.Text = fmt.Sprintf(text+": %02.0f%%", value) + buf = buf[:0] + buf = append(buf, text...) + buf = append(buf, ": "...) + // Matches the old "%02.0f%%" format: rounded, zero-padded to two digits. + iv := int64(math.Round(value)) + if iv >= 0 && iv < 10 { + buf = append(buf, '0') + } + buf = strconv.AppendInt(buf, iv, 10) + buf = append(buf, '%') + obj.Text = string(buf) switch { case value > 60 && value < 85: obj.Color = color.RGBA{R: 0xFF, G: 0xA5, B: 0, A: 0xFF} diff --git a/pkg/widgets/hbar/hbar.go b/pkg/widgets/hbar/hbar.go index 64add695..633f733e 100644 --- a/pkg/widgets/hbar/hbar.go +++ b/pkg/widgets/hbar/hbar.go @@ -188,7 +188,7 @@ func (s *HBar) CreateRenderer() fyne.WidgetRenderer { s.lines[i] = line } - return &HBarRenderer{s} + return &HBarRenderer{HBar: s} } // getColorForValue returns fill & stroke color for an arbitrary gauge value. @@ -229,11 +229,12 @@ func (s *HBar) getColorForValue(value float64) (fillColor, strokeColor color.RGB } type HBarRenderer struct { - *HBar + HBar *HBar + objects []fyne.CanvasObject } func (r *HBarRenderer) MinSize() fyne.Size { - return r.cfg.MinSize + return r.HBar.cfg.MinSize } func (r *HBarRenderer) Refresh() { @@ -245,41 +246,41 @@ func (r *HBarRenderer) Destroy() { } func (r *HBarRenderer) Layout(space fyne.Size) { - if r.size == space { + if r.HBar.size == space { return } - r.size = space + r.HBar.size = space - r.layoutValues.middle = space.Height * 0.5 + r.HBar.layoutValues.middle = space.Height * 0.5 - stepFactor := float32(space.Width) / float32(r.cfg.Steps) + stepFactor := float32(space.Width) / float32(r.HBar.cfg.Steps) // Face layout - r.face.Move(fyne.Position{X: -2, Y: 0}) - r.face.Resize(space.AddWidthHeight(3, 1)) + r.HBar.face.Move(fyne.Position{X: -2, Y: 0}) + r.HBar.face.Resize(space.AddWidthHeight(3, 1)) // Title centered horizontally, just below bar - titleMinSize := r.titleText.MinSize() - r.titleText.Resize(fyne.Size{Width: space.Width, Height: titleMinSize.Height}) - r.titleText.Move(fyne.Position{ + titleMinSize := r.HBar.titleText.MinSize() + r.HBar.titleText.Resize(fyne.Size{Width: space.Width, Height: titleMinSize.Height}) + r.HBar.titleText.Move(fyne.Position{ X: 0, Y: space.Height - titleMinSize.Height, }) // Display text in the middle, centered vertically - displayMinSize := r.displayText.MinSize() - r.displayText.Resize(fyne.Size{Width: space.Width, Height: displayMinSize.Height}) - r.displayText.Move(fyne.Position{ + displayMinSize := r.HBar.displayText.MinSize() + r.HBar.displayText.Resize(fyne.Size{Width: space.Width, Height: displayMinSize.Height}) + r.HBar.displayText.Move(fyne.Position{ X: 0, - Y: r.layoutValues.middle - displayMinSize.Height*0.5, + Y: r.HBar.layoutValues.middle - displayMinSize.Height*0.5, }) // Tick lines layout (vertical lines across width) oneThird := space.Height * common.OneThird oneSeventh := space.Height * common.OneSeventh - middle := r.layoutValues.middle + middle := r.HBar.layoutValues.middle - for i, line := range r.lines { + for i, line := range r.HBar.lines { x := float32(i) * stepFactor if i%2 == 0 { line.Position1 = fyne.Position{X: x, Y: middle - oneThird} @@ -291,19 +292,23 @@ func (r *HBarRenderer) Layout(space fyne.Size) { } // Bar position is fixed at origin; set once here so SetValue can skip it. - r.bar.Move(fyne.Position{X: 0, Y: 0}) + r.HBar.bar.Move(fyne.Position{X: 0, Y: 0}) // Recompute bar geometry for current value using new size - norm := r.clampNorm(r.value) - barWidth := norm * float32(r.size.Width) - r.bar.Resize(fyne.Size{Width: barWidth, Height: r.size.Height}) + norm := r.HBar.clampNorm(r.HBar.value) + barWidth := norm * float32(r.HBar.size.Width) + r.HBar.bar.Resize(fyne.Size{Width: barWidth, Height: r.HBar.size.Height}) } func (r *HBarRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) - for _, line := range r.lines { - objs = append(objs, line) + if r.objects == nil { + + objs := make([]fyne.CanvasObject, 0, len(r.HBar.lines)+4) + for _, line := range r.HBar.lines { + objs = append(objs, line) + } + objs = append(objs, r.HBar.bar, r.HBar.face, r.HBar.titleText, r.HBar.displayText) + r.objects = objs } - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/widgets/icon/icon.go b/pkg/widgets/icon/icon.go index d55fc347..0debccb7 100644 --- a/pkg/widgets/icon/icon.go +++ b/pkg/widgets/icon/icon.go @@ -46,22 +46,23 @@ func (ic *Icon) SetText(text string) { } func (ic *Icon) CreateRenderer() fyne.WidgetRenderer { - return &IconRenderer{ic} + return &IconRenderer{IC: ic} } type IconRenderer struct { - *Icon + IC *Icon + objects []fyne.CanvasObject } func (ic *IconRenderer) Layout(size fyne.Size) { - ic.cfg.Image.Move(fyne.NewPos(0, 0)) - ic.cfg.Image.Resize(ic.cfg.Minsize) - ic.text.Resize(fyne.NewSize(size.Width, 30)) - ic.text.Move(fyne.NewPos(14, 87)) + ic.IC.cfg.Image.Move(fyne.NewPos(0, 0)) + ic.IC.cfg.Image.Resize(ic.IC.cfg.Minsize) + ic.IC.text.Resize(fyne.NewSize(size.Width, 30)) + ic.IC.text.Move(fyne.NewPos(14, 87)) } func (ic *IconRenderer) MinSize() fyne.Size { - return ic.cfg.Minsize + return ic.IC.cfg.Minsize } func (ic *IconRenderer) Refresh() { @@ -71,5 +72,9 @@ func (ic *IconRenderer) Destroy() { } func (ic *IconRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{ic.cfg.Image, ic.text} + if ic.objects == nil { + ic.objects = []fyne.CanvasObject{ic.IC.cfg.Image, ic.IC.text} + } + return ic.objects + // return []fyne.CanvasObject{ic.IC.cfg.Image, ic.IC.text} --- IGNORE --- } diff --git a/pkg/widgets/symbollist/symbollist.go b/pkg/widgets/symbollist/symbollist.go index 590c9020..e524328a 100644 --- a/pkg/widgets/symbollist/symbollist.go +++ b/pkg/widgets/symbollist/symbollist.go @@ -2,9 +2,9 @@ package symbollist import ( "image/color" - "sort" + "math" + "slices" "strconv" - "strings" "sync" "fyne.io/fyne/v2" @@ -65,22 +65,50 @@ func (s *Widget) SetColorBlindMode(mode colors.ColorBlindMode) { } func (s *Widget) UpdateBars(enabled bool) { + s.mu.Lock() + defer s.mu.Unlock() s.updateBars = enabled } func (s *Widget) Names() []string { - names := make([]string, len(s.cfg.Symbols)+1) - for i, s := range s.cfg.Symbols { - names[i] = s.Name + s.mu.Lock() + defer s.mu.Unlock() + names := make([]string, 0, len(s.cfg.Symbols)+1) + hasWBL := false + for _, sym := range s.cfg.Symbols { + if sym.Name == datalogger.EXTERNALWBLSYM { + hasWBL = true + } + names = append(names, sym.Name) } - names[len(names)-1] = datalogger.EXTERNALWBLSYM - sort.Slice(names, func(i, j int) bool { - return strings.ToLower(names[i]) < strings.ToLower(names[j]) - }) + if !hasWBL { + names = append(names, datalogger.EXTERNALWBLSYM) + } + slices.SortFunc(names, compareFold) return names } +// compareFold orders ASCII strings case-insensitively without the per-comparison +// allocations of strings.ToLower. +func compareFold(a, b string) int { + for i := 0; i < len(a) && i < len(b); i++ { + ca, cb := a[i], b[i] + if 'A' <= ca && ca <= 'Z' { + ca += 'a' - 'A' + } + if 'A' <= cb && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return int(ca) - int(cb) + } + } + return len(a) - len(b) +} + func (s *Widget) SetValue(name string, value float64) { + s.mu.Lock() + defer s.mu.Unlock() val, found := s.entryMap[name] if found { if value == val.value { @@ -93,22 +121,28 @@ func (s *Widget) SetValue(name string, value float64) { val.max = value } if s.updateBars { - val.valueBarFactor = float32((value - val.min) / (val.max - val.min)) + if span := val.max - val.min; span > 0 { + val.valueBarFactor = float32((value - val.min) / span) + } else { + val.valueBarFactor = 0 + } col := colors.GetColorInterpolation(val.min, val.max, value, s.cfg.ColorBlindMode) col.A = barAlpha val.valueBar.FillColor = col totalWidth := val.symbolName.Size().Width val.valueBar.Resize(fyne.Size{Width: val.valueBarFactor * totalWidth, Height: 26}) } - textValue := strconv.FormatFloat(value, 'f', val.prec, 64) - if textValue != val.lastText { - val.lastText = textValue - val.symbolValue.SetText(textValue) + val.buf = strconv.AppendFloat(val.buf[:0], value, 'f', val.prec, 64) + if string(val.buf) != val.lastText { + val.lastText = string(val.buf) + val.symbolValue.SetText(val.lastText) } } } func (s *Widget) Disable() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { // e.symbolCorrectionfactor.Disable() e.deleteBTN.Disable() @@ -116,6 +150,8 @@ func (s *Widget) Disable() { } func (s *Widget) Enable() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { // e.symbolCorrectionfactor.Enable() e.deleteBTN.Enable() @@ -126,6 +162,7 @@ func (s *Widget) Add(symbols ...*symbol.Symbol) { s.mu.Lock() defer s.mu.Unlock() + added := false for _, sym := range symbols { if _, found := s.entryMap[sym.Name]; found { continue @@ -156,13 +193,26 @@ func (s *Widget) Add(symbols ...*symbol.Symbol) { entry := s.newSymbolWidgetEntry(sym, deleteFunc) s.cfg.Symbols = append(s.cfg.Symbols, sym) s.entries = append(s.entries, entry) - s.container.Add(entry) + // append directly instead of container.Add to avoid a layout+refresh per entry + s.container.Objects = append(s.container.Objects, entry) s.entryMap[sym.Name] = entry + added = true + } + if added { + s.container.Refresh() } } func (s *Widget) Clear() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { + // NaN sentinel so the next sample always renders, even if it equals + // the last value seen before the clear + e.value = math.NaN() + e.min = 0 + e.max = 0 + e.valueBarFactor = 0 e.lastText = "---" e.symbolValue.SetText("---") } @@ -241,6 +291,9 @@ func (s *Widget) newSymbolWidgetEntry(sym *symbol.Symbol, deleteFunc func(*Symbo symbol: sym, prec: symbol.GetPrecision(sym.Correctionfactor), deleteFunc: deleteFunc, + // NaN compares unequal to everything, so the first sample always + // renders — including an initial value of exactly 0 + value: math.NaN(), } sw.ExtendBaseWidget(sw) sw.symbolName = widget.NewLabel(sw.symbol.Name) @@ -306,6 +359,7 @@ type SymbolWidgetEntry struct { min, max float64 prec int lastText string + buf []byte // scratch for AppendFloat, avoids an allocation per update oldSize fyne.Size @@ -357,15 +411,12 @@ func (s *symbolWidgetEntryRenderer) MinSize() fyne.Size { } func (s *symbolWidgetEntryRenderer) Refresh() { - s.e.symbolName.Refresh() - s.e.symbolValue.Refresh() - // s.e.symbolNumber.Refresh() - // s.e.symbolCorrectionfactor.Refresh() col := colors.GetColorInterpolation(s.e.min, s.e.max, s.e.value, s.e.w.cfg.ColorBlindMode) col.A = barAlpha s.e.valueBar.FillColor = col s.e.valueBar.StrokeColor = col - s.e.valueBar.Refresh() + // cascades to the labels, delete button and value bar + s.e.container.Refresh() } func (s *symbolWidgetEntryRenderer) Objects() []fyne.CanvasObject { diff --git a/pkg/widgets/vbar/vbar.go b/pkg/widgets/vbar/vbar.go index 3df1563c..93bb2057 100644 --- a/pkg/widgets/vbar/vbar.go +++ b/pkg/widgets/vbar/vbar.go @@ -195,7 +195,7 @@ func (s *VBar) CreateRenderer() fyne.WidgetRenderer { s.lines[maxSteps-i] = line } - return &VBarRenderer{s} + return &VBarRenderer{VBar: s} } // getColorForValue returns fill & stroke color for an arbitrary gauge value. @@ -237,6 +237,7 @@ func (s *VBar) getColorForValue(value float64) (fillColor, strokeColor color.RGB type VBarRenderer struct { *VBar + objects []fyne.CanvasObject } func (r *VBarRenderer) MinSize() fyne.Size { @@ -302,10 +303,13 @@ func (r *VBarRenderer) Layout(space fyne.Size) { } func (r *VBarRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) - for _, line := range r.lines { - objs = append(objs, line) + if r.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) + for _, line := range r.lines { + objs = append(objs, line) + } + objs = append(objs, r.bar, r.face, r.titleText, r.displayText) + r.objects = objs } - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index a5636b4a..f4d44b50 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -313,8 +313,8 @@ func (mw *MainWindow) LoadLogfileCombined(filename string, reader io.ReadCloser, Logplayer: true, UseMPH: mw.settings.GetUseMPH(), SwapRPMandSpeed: mw.settings.GetSwapRPMandSpeed(), - High: 0.5, - Low: 1.5, + High: 1.5, + Low: 0.5, WidebandSymbol: mw.settings.GetWidebandSymbolName(), } From 8ec418f012b427d160f379117cb60edd69648182 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 11 Jun 2026 22:51:51 +0200 Subject: [PATCH 16/93] mapviewer optimization --- pkg/widgets/mapviewer/mapviewer.go | 73 ++++++++++--------- pkg/widgets/mapviewer/mapviewer_keyhandler.go | 18 +++-- pkg/widgets/mapviewer/mapviewer_mouse.go | 15 ++-- 3 files changed, 59 insertions(+), 47 deletions(-) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 0641660c..a157296a 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -75,6 +75,9 @@ type MapViewer struct { inputBuffer strings.Builder restoreValues bool + // scratch buffer for formatting cell values without allocating + scratch []byte + popup *widget.PopUpMenu widthFactor float32 @@ -272,10 +275,11 @@ func (mv *MapViewer) SetY(yValue float64) { } func (mv *MapViewer) setCellText(idx int, value float64) { - textValue := strconv.FormatFloat(value, 'f', mv.cfg.ZPrecision, 64) - if mv.textValues[idx].Text != textValue { - mv.textValues[idx].Text = textValue - mv.textValues[idx].Refresh() + mv.scratch = strconv.AppendFloat(mv.scratch[:0], value, 'f', mv.cfg.ZPrecision, 64) + text := mv.textValues[idx] + if string(mv.scratch) != text.Text { + text.Text = string(mv.scratch) + text.Refresh() } } @@ -312,39 +316,38 @@ func (mv *MapViewer) Refresh() { } func (mv *MapViewer) createYAxis() { - mv.yAxisLabelContainer = container.New(&layout.Vertical{}) - if mv.numRows >= 1 { - for i := mv.numRows - 1; i >= 0; i-- { - text := &canvas.Text{ - Alignment: fyne.TextAlignCenter, - Text: strconv.FormatFloat(mv.cfg.YData[i], 'f', mv.cfg.YPrecision, 64), - TextSize: minTextSize + 2, - } - mv.yAxisTexts = append(mv.yAxisTexts, text) - mv.yAxisLabelContainer.Add(text) + mv.yAxisTexts = make([]*canvas.Text, 0, mv.numRows) + objs := make([]fyne.CanvasObject, 0, mv.numRows) + for i := mv.numRows - 1; i >= 0; i-- { + text := &canvas.Text{ + Alignment: fyne.TextAlignCenter, + Text: strconv.FormatFloat(mv.cfg.YData[i], 'f', mv.cfg.YPrecision, 64), + TextSize: minTextSize + 2, } - return + mv.yAxisTexts = append(mv.yAxisTexts, text) + objs = append(objs, text) } + mv.yAxisLabelContainer = container.New(&layout.Vertical{}, objs...) } func (mv *MapViewer) createXAxis() { - mv.xAxisLabelContainer = container.New(&layout.Horizontal{Offset: mv.yAxisLabelContainer}) - if mv.numColumns >= 1 { - for i := 0; i < mv.numColumns; i++ { - text := &canvas.Text{ - Alignment: fyne.TextAlignCenter, - Text: strconv.FormatFloat(mv.cfg.XData[i], 'f', mv.cfg.XPrecision, 64), - TextSize: minTextSize + 2, - } - mv.xAxisTexts = append(mv.xAxisTexts, text) - mv.xAxisLabelContainer.Add(text) + mv.xAxisTexts = make([]*canvas.Text, 0, mv.numColumns) + objs := make([]fyne.CanvasObject, 0, mv.numColumns) + for i := 0; i < mv.numColumns; i++ { + text := &canvas.Text{ + Alignment: fyne.TextAlignCenter, + Text: strconv.FormatFloat(mv.cfg.XData[i], 'f', mv.cfg.XPrecision, 64), + TextSize: minTextSize + 2, } - return + mv.xAxisTexts = append(mv.xAxisTexts, text) + objs = append(objs, text) } + mv.xAxisLabelContainer = container.New(&layout.Horizontal{Offset: mv.yAxisLabelContainer}, objs...) } func (mv *MapViewer) createTextValues() { - mv.valueTexts = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32)) + mv.textValues = make([]*canvas.Text, 0, mv.numData) + objs := make([]fyne.CanvasObject, 0, mv.numData) for _, v := range mv.cfg.ZData { text := &canvas.Text{ Text: strconv.FormatFloat(v, 'f', mv.cfg.ZPrecision, 64), @@ -353,19 +356,22 @@ func (mv *MapViewer) createTextValues() { Alignment: fyne.TextAlignCenter, } mv.textValues = append(mv.textValues, text) - mv.valueTexts.Add(text) + objs = append(objs, text) } + mv.valueTexts = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) } func (mv *MapViewer) createZdata() { - mv.valueRects = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32)) + mv.zDataRects = make([]*canvas.Rectangle, 0, mv.numData) + objs := make([]fyne.CanvasObject, 0, mv.numData) for _, value := range mv.cfg.ZData { color := colors.GetColorInterpolation(mv.zMin, mv.zMax, value, mv.colorMode) rect := &canvas.Rectangle{FillColor: color, StrokeColor: color, StrokeWidth: 0} rect.SetMinSize(fyne.NewSize(34, 14)) mv.zDataRects = append(mv.zDataRects, rect) - mv.valueRects.Add(rect) + objs = append(objs, rect) } + mv.valueRects = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) } func (mv *MapViewer) setXY() error { @@ -426,21 +432,18 @@ func (mv *MapViewer) resizeSelectionRect() { // Handle multiple cell selection if len(mv.selectedCells) > 1 { - // Pre-calculate divisor to avoid repeated division operations - colDivisor := float32(mv.numColumns) - // Initialize bounds using first cell to avoid unnecessary comparisons firstCell := mv.selectedCells[0] minX := firstCell % mv.numColumns maxX := minX - minY := int(float32(firstCell) / colDivisor) + minY := firstCell / mv.numColumns maxY := minY // Find bounds in a single pass for i := 1; i < len(mv.selectedCells); i++ { cell := mv.selectedCells[i] x := cell % mv.numColumns - y := int(float32(cell) / colDivisor) + y := cell / mv.numColumns if x < minX { minX = x diff --git a/pkg/widgets/mapviewer/mapviewer_keyhandler.go b/pkg/widgets/mapviewer/mapviewer_keyhandler.go index f52a1a3e..27363c11 100644 --- a/pkg/widgets/mapviewer/mapviewer_keyhandler.go +++ b/pkg/widgets/mapviewer/mapviewer_keyhandler.go @@ -137,19 +137,27 @@ func (mv *MapViewer) smooth() { } func (mv *MapViewer) updateCursor(goroutine bool) { - mv.selectedCells = []int{mv.SelectedY*mv.numColumns + mv.selectedX} + cell := mv.SelectedY*mv.numColumns + mv.selectedX + if len(mv.selectedCells) != 1 || mv.selectedCells[0] != cell { + mv.selectedCells = append(mv.selectedCells[:0], cell) + } xPosFactor := float32(mv.selectedX) yPosFactor := float32(float64(mv.numRows-1) - float64(mv.SelectedY)) xPos := xPosFactor * mv.widthFactor yPos := yPosFactor * mv.heightFactor + size := fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1} + pos := fyne.Position{X: xPos - 1, Y: yPos - 1} + if mv.selectionRect.Size() == size && mv.selectionRect.Position() == pos { + return + } if goroutine { fyne.Do(func() { - mv.selectionRect.Resize(fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1}) - mv.selectionRect.Move(fyne.Position{X: xPos - 1, Y: yPos - 1}) + mv.selectionRect.Resize(size) + mv.selectionRect.Move(pos) }) } else { - mv.selectionRect.Resize(fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1}) - mv.selectionRect.Move(fyne.Position{X: xPos - 1, Y: yPos - 1}) + mv.selectionRect.Resize(size) + mv.selectionRect.Move(pos) } } diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index 929c57b2..c667cf70 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -206,16 +206,17 @@ func (mv *MapViewer) finalizeSelection(eventPos fyne.Position) { nselectedX, nSelectedY := mv.calculateSelectionBounds(eventPos) mv.updateSelection(nselectedX, nSelectedY) - // For Ctrl selections, we don't want to clear existing selections - if mv.lastModifier != fyne.KeyModifierControl { - mv.selectedCells = make([]int, 0) - } - topLeftX := min(mv.selectedX, nselectedX) bottomRightX := max(mv.selectedX, nselectedX) topLeftY := min(mv.SelectedY, nSelectedY) bottomRightY := max(mv.SelectedY, nSelectedY) + // For Ctrl selections, we don't want to clear existing selections + if mv.lastModifier != fyne.KeyModifierControl { + mv.selectedCells = make([]int, 0, (bottomRightX-topLeftX+1)*(bottomRightY-topLeftY+1)) + } + + selectedColor := theme.Color(theme.ColorNameForegroundOnPrimary) for y := topLeftY; y <= bottomRightY; y++ { for x := topLeftX; x <= bottomRightX; x++ { zIndex := y*mv.numColumns + x @@ -226,11 +227,11 @@ func (mv *MapViewer) finalizeSelection(eventPos fyne.Position) { mv.zDataRects[zIndex].FillColor = mv.zDataRects[zIndex].StrokeColor } else { mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) + mv.zDataRects[zIndex].FillColor = selectedColor } } else { mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) + mv.zDataRects[zIndex].FillColor = selectedColor } mv.zDataRects[zIndex].Refresh() } From c27591a273b10dfa330d826954816cb82270dc94 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 11 Jun 2026 22:52:03 +0200 Subject: [PATCH 17/93] optimize meshgrid --- pkg/widgets/meshgrid/meshgrid_axis.go | 19 -------- pkg/widgets/meshgrid/meshgrid_draw.go | 25 +++++------ pkg/widgets/meshgrid/meshgrid_widget.go | 58 ++++++++++++------------- 3 files changed, 39 insertions(+), 63 deletions(-) diff --git a/pkg/widgets/meshgrid/meshgrid_axis.go b/pkg/widgets/meshgrid/meshgrid_axis.go index 71537489..7c79d19d 100644 --- a/pkg/widgets/meshgrid/meshgrid_axis.go +++ b/pkg/widgets/meshgrid/meshgrid_axis.go @@ -9,25 +9,6 @@ import ( "golang.org/x/image/math/fixed" ) -// Add new struct for axis indicator -type AxisIndicator struct { - origin Vertex - xAxis Vertex - yAxis Vertex - zAxis Vertex - axisScale float64 -} - -func NewAxisIndicator(scale float64) AxisIndicator { - return AxisIndicator{ - origin: Vertex{X: 0, Y: 0, Z: 0}, - xAxis: Vertex{X: scale, Y: 0, Z: 0}, - yAxis: Vertex{X: 0, Y: scale, Z: 0}, - zAxis: Vertex{X: 0, Y: 0, Z: scale}, - axisScale: scale, - } -} - func (m *Meshgrid) drawAxisIndicator(img *image.RGBA) { cornerOffset := 60.0 indicatorScale := 60.0 diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index d4f023ae..c41ea6c5 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -33,7 +33,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { img = image.NewRGBA(image.Rect(0, 0, w, h)) m.scratchImg = img } else { - clearPix(img.Pix) + clear(img.Pix) } // Find min/max of the view-space Z for depth shading. @@ -143,12 +143,6 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { return img } -func clearPix(p []uint8) { - for i := range p { - p[i] = 0 - } -} - // getColorWithDepth combines color interpolation and depth enhancement in one step func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { // Get base color from value @@ -184,13 +178,15 @@ func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { } } -// Fade a color by a factor (used for diagonals) +// Fade a color by a factor (used for diagonals). Alpha is left untouched: +// the buffer uses straight alpha, so dimming RGB and A together would fade +// the line twice over once composited. func fadeColor(c color.RGBA, factor float64) color.RGBA { return color.RGBA{ R: uint8(float64(c.R) * factor), G: uint8(float64(c.G) * factor), B: uint8(float64(c.B) * factor), - A: uint8(float64(c.A) * factor), + A: c.A, } } @@ -201,8 +197,11 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { return // fully outside } - // Translate to image origin for indexing - ox, oy := r.Min.X, r.Min.Y + // Translate to image origin once so the pixel loop indexes directly. + x0 -= r.Min.X + x1 -= r.Min.X + y0 -= r.Min.Y + y1 -= r.Min.Y stride := img.Stride pix := img.Pix @@ -225,7 +224,7 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { total = -dy } if total == 0 { - setPix(pix, stride, x0-ox, y0-oy, c1) + setPix(pix, stride, x0, y0, c1) return } @@ -242,7 +241,7 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { // Draw for i := 0; ; i++ { - setPixRGBAFixed(pix, stride, x0-ox, y0-oy, accR, accG, accB, accA) + setPixRGBAFixed(pix, stride, x0, y0, accR, accG, accB, accA) if x0 == x1 && y0 == y1 { break diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 63b6f4d2..549e409f 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -230,19 +230,12 @@ func (m *Meshgrid) updateVertexPositions() { } } +// SetFloat64 updates a single cell value. The whole mesh is rebuilt since a +// new value can shift zmin/zmax and with it every vertex's normalized height. func (m *Meshgrid) SetFloat64(idx int, value float64) { - log.Println("SetFloat64", idx, value) - m.values[idx] = value - m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) - zrange := m.zrange - if zrange == 0 { - zrange = 1 + if idx < 0 || idx >= len(m.values) { + return } - m.vertices[idx/m.cols][idx%m.cols].Z = ((value - m.zmin) / zrange) * m.depth - m.refresh() -} - -func (m *Meshgrid) SetFloat642(idx int, value float64) { m.values[idx] = value m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) @@ -252,14 +245,15 @@ func (m *Meshgrid) SetFloat642(idx int, value float64) { // Update LoadFloat64s to use the new vertex position update method func (m *Meshgrid) LoadFloat64s(min, max float64, floats []float64) { + if len(floats) != m.rows*m.cols { + log.Printf("meshgrid: LoadFloat64s got %d values, want %d (%dx%d)", len(floats), m.rows*m.cols, m.cols, m.rows) + return + } m.zmin = min m.zmax = max m.zrange = m.zmax - m.zmin m.values = floats - if len(floats) == 0 { - return - } m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) m.updateVertexPositions() @@ -280,14 +274,6 @@ func findMinMaxRange(values []float64) (float64, float64, float64) { return minZ, maxZ, maxZ - minZ } -func (m *Meshgrid) project(v Vertex) (int, int) { - centerX := float64(m.size.Width) * 0.5 - centerY := float64(m.size.Height) * 0.5 - screenX := centerX + v.X - screenY := centerY + v.Y - return int(screenX), int(screenY) -} - func (m *Meshgrid) Refresh() { m.refresh() } @@ -304,24 +290,31 @@ func (m *Meshgrid) throttledRefresh() { } m.refreshPending = true time.AfterFunc(10*time.Millisecond, func() { // ~100fps - m.refresh() - m.refreshPending = false + // AfterFunc fires on a timer goroutine; hop back to the fyne thread. + // refreshPending is then only ever touched on the fyne thread. + fyne.Do(func() { + m.refresh() + m.refreshPending = false + }) }) } + func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { - return &meshgridRenderer{m} + return &meshgridRenderer{MG: m} } type meshgridRenderer struct { - *Meshgrid + MG *Meshgrid + objects []fyne.CanvasObject } func (m *meshgridRenderer) Layout(size fyne.Size) { - if size == m.size { + if size == m.MG.size { return } - m.size = size - m.throttledRefresh() + m.MG.size = size + m.MG.image.Resize(size) + m.MG.throttledRefresh() } func (m *meshgridRenderer) MinSize() fyne.Size { @@ -329,12 +322,15 @@ func (m *meshgridRenderer) MinSize() fyne.Size { } func (m *meshgridRenderer) Refresh() { - m.Meshgrid.refresh() + m.MG.refresh() } func (m *meshgridRenderer) Destroy() { } func (m *meshgridRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{m.image} + if m.objects == nil { + m.objects = []fyne.CanvasObject{m.MG.image} + } + return m.objects } From 72ccc102d76c31ba1c07a2dddbdf3e5ebd6056a4 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 11 Jun 2026 22:56:26 +0200 Subject: [PATCH 18/93] update deps --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 48633a61..e208d982 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35 + fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 diff --git a/go.sum b/go.sum index c152d11b..b9f4f322 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35 h1:SRutBcy6SbGtnaKk3VqAYnyoOlwp9JDE6wHZKMZfHiU= -fyne.io/fyne/v2 v2.7.5-0.20260610120109-657f1cc54c35/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998 h1:t9rEs4rXZMTSt80TC4saVmfeSxyiUw3eMeu5kBYaF68= +fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= From 7a96bd1e60f2335b95451ec9f93d63a59e4016c8 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 00:39:54 +0200 Subject: [PATCH 19/93] better meshgrid --- pkg/assets/WHATSNEW.md | 18 +- pkg/widgets/mapviewer/mapviewer.go | 20 +- pkg/widgets/meshgrid/meshgrid_draw.go | 14 +- pkg/widgets/meshgrid/meshgrid_render_test.go | 104 +++++++++++ pkg/widgets/meshgrid/meshgrid_surface.go | 187 +++++++++++++++++++ pkg/widgets/meshgrid/meshgrid_widget.go | 22 +++ 6 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 pkg/widgets/meshgrid/meshgrid_render_test.go create mode 100644 pkg/widgets/meshgrid/meshgrid_surface.go diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 860fad62..1930bcd6 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -1,12 +1,16 @@ # 2.1.10 - Performance optimization for the log plotter and meshgrid -- force layouts to be loaded and saved in users home directory under the txlogger folder -- update ecusymbol to be able to read T5 versions -- added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab -- refactored the bus implementation to use less CPU and have less allocations -- added support for BPL files ( binary packed logfile ) -- removed support for creating legacy TXL log files. (you can still load them but might cause crashes) -- removed ebusmonitor, it has served it's purpose +- Force layouts to be loaded and saved in users home directory under the txlogger folder +- Update ecusymbol to be able to read T5 versions +- Added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab +- Refactored the bus implementation to use less CPU and have less allocations +- Added support for BPL files ( binary packed logfile ) +- Removed support for creating legacy TXL log files. (you can still load them but might cause crashes) +- Removed ebusmonitor, it has served it's purpose +- Improved drag handler in logplayer, when zoomed in we drag fewer frames increasing as we zoom out +- We now have 3 render modes for viewing 3d maps, Solid Wireframe, Solid & Wireframe. Press the little square icon in the mesh viewer to switch between them +- WBL reconnect COM port while logging. If the COM port dies for a reason during logging it will try to re-connect +- Many widget performance improvements to allow slower computers to run txlogger better # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index a157296a..0b192295 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -14,6 +14,8 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" + fynelayout "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" "github.com/roffe/txlogger/pkg/interpolate" @@ -114,7 +116,9 @@ func (mv *MapViewer) SetColorBlindMode(mode colors.ColorBlindMode) { if mv.colorMode != mode { mv.colorMode = mode mv.Refresh() - mv.mesh.SetColorBlindMode(mode) + if mv.mesh != nil { + mv.mesh.SetColorBlindMode(mode) + } } } @@ -229,6 +233,8 @@ func (mv *MapViewer) render() fyne.CanvasObject { } if err == nil { + meshModeBtn := widget.NewButtonWithIcon("", theme.GridIcon(), mv.mesh.CycleRenderMode) + meshModeBtn.Importance = widget.LowImportance split := container.NewVSplit( mapview, container.NewBorder( @@ -236,7 +242,12 @@ func (mv *MapViewer) render() fyne.CanvasObject { buttons, nil, nil, - mv.mesh, + container.NewStack( + mv.mesh, + container.NewVBox( + container.NewHBox(fynelayout.NewSpacer(), meshModeBtn), + ), + ), ), ) split.Offset = 0.2 @@ -295,6 +306,11 @@ func (mv *MapViewer) SetZData(zData []float64) error { func (mv *MapViewer) Refresh() { mv.zMin, mv.zMax = widgets.FindMinMax(mv.cfg.ZData) + if len(mv.textValues) == 0 { + // renderer not created yet; createTextValues/createZdata pick up + // the current ZData and color mode when it is + return + } for idx, value := range mv.cfg.ZData { mv.setCellText(idx, value) col := colors.GetColorInterpolation( diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index c41ea6c5..64ae4b0c 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -81,6 +81,18 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { } } + mode := m.renderMode + if m.rows < 2 || m.cols < 2 { + // A 1D mesh has no cells to fill; lines are all we can draw. + mode = RenderModeWireframe + } + + if mode != RenderModeWireframe { + m.drawSurface(img, projX, projY, vertCol, mode == RenderModeSolidWireframe) + m.drawAxisIndicator(img) + return img + } + // Collect line segments using cached projections. segs := m.scratchLines[:0] for i := 0; i < m.rows; i++ { @@ -105,7 +117,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { y1: y1, x2: x2, y2: y2, - depth: -(m.vertices[i][j].Z + m.vertices[ni][nj].Z) * 0.5, + depth: (m.vertices[i][j].Z + m.vertices[ni][nj].Z) * 0.5, diagonal: x1 != x2 && y1 != y2, }) } diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go new file mode 100644 index 00000000..39948738 --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -0,0 +1,104 @@ +package meshgrid + +import ( + "image/png" + "os" + "testing" + + "fyne.io/fyne/v2" + "github.com/roffe/txlogger/pkg/colors" +) + +func testGrid(t testing.TB) *Meshgrid { + t.Helper() + cols, rows := 16, 16 + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + x := float64(j-cols/2) / 3 + y := float64(i-rows/2) / 3 + values[i*cols+j] = 100 / (1 + x*x + y*y) // central hump + } + } + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + return m +} + +// TestRenderRotated renders an asymmetric surface (tall corner spike) from +// four yaw angles so painter's-order mistakes show up as the spike being +// overdrawn by cells that are behind it. +func TestRenderRotated(t *testing.T) { + if os.Getenv("MESHGRID_DUMP") == "" { + t.Skip("set MESHGRID_DUMP=1 to dump rotation PNGs") + } + cols, rows := 16, 16 + for n, yaw := range []float64{0, 90, 180, 270} { + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + x := float64(j-2) / 1.5 + y := float64(i-2) / 1.5 + values[i*cols+j] = 10 + 100/(1+x*x+y*y) // spike near one corner + } + } + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + m.rotateMeshgrid(0, yaw, 0) + img := m.drawMeshgridLines() + f, err := os.Create("/tmp/meshgrid_rot_" + string(rune('0'+n)) + ".png") + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + f.Close() + } +} + +func BenchmarkDrawSurface(b *testing.B) { + m := testGrid(b) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.drawMeshgridLines() + } +} + +func TestRenderModes(t *testing.T) { + for _, tc := range []struct { + name string + mode RenderMode + }{ + {"solidwire", RenderModeSolidWireframe}, + {"solid", RenderModeSolid}, + {"wireframe", RenderModeWireframe}, + } { + t.Run(tc.name, func(t *testing.T) { + m := testGrid(t) + m.renderMode = tc.mode + img := m.drawMeshgridLines() + if img.Bounds().Dx() != 800 || img.Bounds().Dy() != 500 { + t.Fatalf("unexpected bounds %v", img.Bounds()) + } + if os.Getenv("MESHGRID_DUMP") != "" { + f, err := os.Create("/tmp/meshgrid_" + tc.name + ".png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_surface.go b/pkg/widgets/meshgrid/meshgrid_surface.go new file mode 100644 index 00000000..3b722f75 --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_surface.go @@ -0,0 +1,187 @@ +package meshgrid + +import ( + "image" + "image/color" + "math" + "slices" +) + +// RenderMode selects how the mesh is drawn. +type RenderMode int + +const ( + // RenderModeSolidWireframe draws the filled surface with grid lines on top. + RenderModeSolidWireframe RenderMode = iota + // RenderModeSolid draws only the filled surface. + RenderModeSolid + // RenderModeWireframe draws the classic line mesh. + RenderModeWireframe + + renderModeCount +) + +// quadRef identifies one grid cell for the painter's-order surface pass. +type quadRef struct { + i, j int + depth float64 +} + +// Grid lines drawn on top of the filled surface are dimmed so they read as a +// grid instead of blending into the identically colored fill. +const surfaceEdgeFade = 0.45 + +// drawSurface fills each grid cell with two Gouraud-shaded triangles, +// back-to-front. A fixed directional light flat-shades each quad so the +// surface relief stays visible even where the value color barely changes. +// When edges is true the cell outline is drawn right after its fill, which +// keeps lines on hidden faces correctly occluded by nearer quads. +func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []color.RGBA, edges bool) { + quads := m.scratchQuads[:0] + for i := 0; i < m.rows-1; i++ { + for j := 0; j < m.cols-1; j++ { + z := m.vertices[i][j].Z + m.vertices[i][j+1].Z + m.vertices[i+1][j].Z + m.vertices[i+1][j+1].Z + quads = append(quads, quadRef{i: i, j: j, depth: z * 0.25}) + } + } + m.scratchQuads = quads + + // Back-to-front: larger view-space Z is nearer the viewer (the depth + // shading brightens large Z), so draw small-Z quads first. + slices.SortFunc(quads, func(a, b quadRef) int { + switch { + case a.depth < b.depth: + return -1 + case a.depth > b.depth: + return 1 + default: + return 0 + } + }) + + // Fixed light direction in view space, normalized once per frame. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + for _, q := range quads { + ai := q.i*m.cols + q.j // top-left + bi := ai + 1 // top-right + di := ai + m.cols // bottom-left + ci := di + 1 // bottom-right + + shade := m.quadShade(q.i, q.j, lx, ly, lz) + ca := fadeColor(vertCol[ai], shade) + cb := fadeColor(vertCol[bi], shade) + cc := fadeColor(vertCol[ci], shade) + cd := fadeColor(vertCol[di], shade) + + fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[ci], projY[ci], ca, cb, cc) + fillTriangle(img, projX[ai], projY[ai], projX[ci], projY[ci], projX[di], projY[di], ca, cc, cd) + + if edges { + ea := fadeColor(vertCol[ai], surfaceEdgeFade) + eb := fadeColor(vertCol[bi], surfaceEdgeFade) + ec := fadeColor(vertCol[ci], surfaceEdgeFade) + ed := fadeColor(vertCol[di], surfaceEdgeFade) + drawBresenhamLine(img, projX[ai], projY[ai], projX[bi], projY[bi], ea, eb) + drawBresenhamLine(img, projX[bi], projY[bi], projX[ci], projY[ci], eb, ec) + drawBresenhamLine(img, projX[ci], projY[ci], projX[di], projY[di], ec, ed) + drawBresenhamLine(img, projX[di], projY[di], projX[ai], projY[ai], ed, ea) + } + } +} + +// quadShade computes a flat Lambert term for the cell at (i,j) from its +// view-space normal (cross product of the diagonals, which is robust for +// non-planar quads). The absolute dot product is used since the surface is +// single-sided and may be viewed from below. +func (m *Meshgrid) quadShade(i, j int, lx, ly, lz float64) float64 { + a := m.vertices[i][j] + b := m.vertices[i][j+1] + c := m.vertices[i+1][j+1] + d := m.vertices[i+1][j] + + ux, uy, uz := c.X-a.X, c.Y-a.Y, c.Z-a.Z + vx, vy, vz := d.X-b.X, d.Y-b.Y, d.Z-b.Z + + nx := uy*vz - uz*vy + ny := uz*vx - ux*vz + nz := ux*vy - uy*vx + + nl := math.Sqrt(nx*nx + ny*ny + nz*nz) + if nl == 0 { + return 1 + } + dot := (nx*lx + ny*ly + nz*lz) / nl + if dot < 0 { + dot = -dot + } + return 0.6 + 0.4*dot +} + +// fillTriangle rasterizes a triangle with per-vertex (Gouraud) color +// interpolation using incremental integer edge functions, clipped to the +// image bounds via the bounding box. +func fillTriangle(img *image.RGBA, x0, y0, x1, y1, x2, y2 int, c0, c1, c2 color.RGBA) { + area := (x1-x0)*(y2-y0) - (y1-y0)*(x2-x0) + if area == 0 { + return + } + if area < 0 { + x1, y1, x2, y2 = x2, y2, x1, y1 + c1, c2 = c2, c1 + area = -area + } + + r := img.Rect + minX := max(min(x0, min(x1, x2)), r.Min.X) + maxX := min(max(x0, max(x1, x2)), r.Max.X-1) + minY := max(min(y0, min(y1, y2)), r.Min.Y) + maxY := min(max(y0, max(y1, y2)), r.Max.Y-1) + if minX > maxX || minY > maxY { + return + } + + // Edge function values at (minX, minY); stepping +1 in x adds the A term, + // +1 in y adds the B term. + ef := func(ax, ay, bx, by, px, py int) int { + return (bx-ax)*(py-ay) - (by-ay)*(px-ax) + } + w0Row := ef(x1, y1, x2, y2, minX, minY) + w1Row := ef(x2, y2, x0, y0, minX, minY) + w2Row := ef(x0, y0, x1, y1, minX, minY) + a0, b0 := y1-y2, x2-x1 + a1, b1 := y2-y0, x0-x2 + a2, b2 := y0-y1, x1-x0 + + invArea := 1.0 / float64(area) + r0, g0, bl0 := float64(c0.R), float64(c0.G), float64(c0.B) + r1, g1, bl1 := float64(c1.R), float64(c1.G), float64(c1.B) + r2, g2, bl2 := float64(c2.R), float64(c2.G), float64(c2.B) + + stride := img.Stride + pix := img.Pix + for y := minY; y <= maxY; y++ { + w0, w1, w2 := w0Row, w1Row, w2Row + idx := (y-r.Min.Y)*stride + (minX-r.Min.X)*4 + for x := minX; x <= maxX; x++ { + if w0 >= 0 && w1 >= 0 && w2 >= 0 { + fw0 := float64(w0) * invArea + fw1 := float64(w1) * invArea + fw2 := float64(w2) * invArea + pix[idx+0] = uint8(fw0*r0 + fw1*r1 + fw2*r2) + pix[idx+1] = uint8(fw0*g0 + fw1*g1 + fw2*g2) + pix[idx+2] = uint8(fw0*bl0 + fw1*bl1 + fw2*bl2) + pix[idx+3] = 255 + } + w0 += a0 + w1 += a1 + w2 += a2 + idx += 4 + } + w0Row += b0 + w1Row += b1 + w2Row += b2 + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 549e409f..27e9bad5 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -40,6 +40,9 @@ type Meshgrid struct { scratchProjY []int scratchColors []color.RGBA scratchLines []lineSegment + scratchQuads []quadRef + + renderMode RenderMode lastMouseX, lastMouseY float32 @@ -121,6 +124,25 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int return m, nil } +// SetRenderMode switches between solid surface, solid+wireframe and pure +// wireframe rendering. +func (m *Meshgrid) SetRenderMode(mode RenderMode) { + if m.renderMode != mode { + m.renderMode = mode + m.refresh() + } +} + +func (m *Meshgrid) RenderMode() RenderMode { + return m.renderMode +} + +// CycleRenderMode steps to the next render mode (surface → solid → wireframe). +func (m *Meshgrid) CycleRenderMode() { + m.renderMode = (m.renderMode + 1) % renderModeCount + m.refresh() +} + func (m *Meshgrid) SetColorBlindMode(mode colors.ColorBlindMode) { if m.colorMode != mode { m.colorMode = mode From ca275e2ebdb514a260003eddaa9a602bb51acd45 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 01:11:12 +0200 Subject: [PATCH 20/93] update plotter --- pkg/widgets/plotter/bresenham.go | 25 +++++ pkg/widgets/plotter/plotter.go | 107 ++++++++++++++++++---- pkg/widgets/plotter/plotter_bench_test.go | 63 +++++++++++++ pkg/widgets/plotter/plotter_render.go | 21 +++-- 4 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 pkg/widgets/plotter/plotter_bench_test.go diff --git a/pkg/widgets/plotter/bresenham.go b/pkg/widgets/plotter/bresenham.go index 3404b207..a237bdbc 100644 --- a/pkg/widgets/plotter/bresenham.go +++ b/pkg/widgets/plotter/bresenham.go @@ -91,6 +91,31 @@ func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA } } +// fillVRun draws a vertical run of pixels in column x from y0 to y1 (inclusive, +// in either order), clipped to the image, with the same max-blend as +// bresenhamCore. +func fillVRun(pix []uint8, stride, w, h, x, y0, y1 int, col color.RGBA) { + if uint(x) >= uint(w) { + return + } + if y0 > y1 { + y0, y1 = y1, y0 + } + if y1 < 0 || y0 >= h { + return + } + y0 = max(y0, 0) + y1 = min(y1, h-1) + i := y0*stride + x*4 + for y := y0; y <= y1; y++ { + pix[i+0] = max(pix[i+0], col.R) + pix[i+1] = max(pix[i+1], col.G) + pix[i+2] = max(pix[i+2], col.B) + pix[i+3] = max(pix[i+3], col.A) + i += stride + } +} + func fillCircle(pix []uint8, stride, w, h, centerX, centerY, radius int, col color.RGBA) { rr := radius * radius for y := -radius; y <= radius; y++ { diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index 975e3a13..a25c92e8 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -6,6 +6,8 @@ import ( "image/color" "log" "sort" + "sync" + "sync/atomic" "unicode" "unicode/utf8" @@ -60,6 +62,12 @@ type Plotter struct { hilightLine int + // seekPos is the latest position requested via Seek, which is called from + // the playback goroutine; the pending UI refresh reads it back out. + seekMu sync.Mutex + seekPos int + refreshPending atomic.Bool + OnDragged func(event *fyne.DragEvent) OnTapped func(event *fyne.PointEvent) } @@ -232,10 +240,38 @@ func lessCaseInsensitive(s, t string) bool { } func (p *Plotter) CreateRenderer() fyne.WidgetRenderer { - return &plotterRenderer{p} + return &plotterRenderer{PL: p} } +// Seek records the new playback position and schedules a redraw on the UI +// goroutine. Calls are coalesced: while a refresh is pending, further Seeks +// only overwrite seekPos and the pending refresh renders the latest position. +// This bounds drawing to the rate the UI goroutine can keep up with instead of +// the log record rate, so a fast log can never back up the event queue. func (p *Plotter) Seek(pos int) { + p.seekMu.Lock() + p.seekPos = pos + p.seekMu.Unlock() + + if !p.refreshPending.CompareAndSwap(false, true) { + return + } + fyne.Do(func() { + // Clear the flag before reading seekPos: a Seek arriving mid-draw then + // queues a new refresh rather than being dropped, so the final + // position is always rendered. + p.refreshPending.Store(false) + p.seekMu.Lock() + pos := p.seekPos + p.seekMu.Unlock() + p.seekTo(pos) + }) +} + +// seekTo applies a seek on the UI goroutine: it recomputes the view window, +// updates the legend values, redraws the plot and refreshes the changed +// canvas objects. +func (p *Plotter) seekTo(pos int) { halfDataPointsToShow := int(float64(p.dataPointsToShow) * .5) offsetPosition := pos - halfDataPointsToShow if pos <= p.dataLength-halfDataPointsToShow { @@ -246,10 +282,7 @@ func (p *Plotter) Seek(pos int) { } p.cursorPos = pos - // Update legend values, collecting the ones that actually changed so we - // can refresh them together in a single fyne.Do below. valueIndex := min(p.dataLength, p.cursorPos) - var changed []*TappableText for i, v := range p.valueOrder { obj := p.legendTexts[i] newValue := fmt.Sprintf("%.4g", p.values[v][valueIndex]) @@ -258,21 +291,13 @@ func (p *Plotter) Seek(pos int) { continue } obj.value.Text = newValue - changed = append(changed, obj) + obj.Refresh() } p.drawImage() p.layoutCursor() - - // Collapse all refreshes for this frame into one dispatch onto the main - // goroutine instead of one per changed legend item plus cursor plus image. - fyne.Do(func() { - for _, obj := range changed { - obj.Refresh() - } - p.cursor.Refresh() - p.canvasImage.Refresh() - }) + p.cursor.Refresh() + p.canvasImage.Refresh() } // drawImage renders the enabled time series into the (reused) plot buffer and @@ -395,10 +420,16 @@ func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, st heightFactor := float64(hh) / ts.valueRange widthFactor := float64(w) / float64(dataLen) - // start at 1 since we need to draw a line from the previous point data := values[ts.Name][startN:endN] + + if dataLen > w { + ts.plotImageDecimated(img, data, thickness) + return + } + dle := dataLen - 1 + // start at 1 since we need to draw a line from the previous point for x := 1; x < dataLen; x++ { fx := float64(x) x0 := int(((fx - 1) * widthFactor)) @@ -412,6 +443,50 @@ func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, st } } +// plotImageDecimated renders the series when there are more visible points +// than pixel columns. Connecting consecutive points with Bresenham lines would +// overdraw each column once per point landing on it; instead each column gets +// a single vertical run spanning the min/max of its points, capping the work +// at O(width) regardless of how far out the view is zoomed. +func (ts *TimeSeries) plotImageDecimated(img *image.RGBA, data []float64, thickness int) { + pix := img.Pix + stride := img.Stride + s := img.Bounds().Size() + w := s.X + h := s.Y + hh := h - 1 + heightFactor := float64(hh) / ts.valueRange + dataLen := len(data) + + halfThick := 0 + if thickness > 1 { + halfThick = thickness / 2 + } + + lo := 0 + for x := 0; x < w; x++ { + hi := (x + 1) * dataLen / w + // Re-scan the previous column's last point so adjacent runs always + // overlap vertically and the plot stays gap-free. + scanFrom := max(lo-1, 0) + minV, maxV := data[scanFrom], data[scanFrom] + for _, v := range data[scanFrom+1 : hi] { + if v < minV { + minV = v + } + if v > maxV { + maxV = v + } + } + y0 := int(float64(hh) - (maxV-ts.Min)*heightFactor) + y1 := int(float64(hh) - (minV-ts.Min)*heightFactor) + for t := -halfThick; t <= halfThick; t++ { + fillVRun(pix, stride, w, h, x+t, y0-halfThick, y1+halfThick, ts.Color) + } + lo = hi + } +} + // layoutCursor recomputes the cursor line position for the current view. It // does not trigger a Refresh. func (p *Plotter) layoutCursor() { diff --git a/pkg/widgets/plotter/plotter_bench_test.go b/pkg/widgets/plotter/plotter_bench_test.go new file mode 100644 index 00000000..9c974aa3 --- /dev/null +++ b/pkg/widgets/plotter/plotter_bench_test.go @@ -0,0 +1,63 @@ +package plotter + +import ( + "image" + "image/color" + "math/rand" + "testing" +) + +func benchValues(numSeries, numPoints int) map[string][]float64 { + r := rand.New(rand.NewSource(1)) + values := make(map[string][]float64, numSeries) + for i := 0; i < numSeries; i++ { + name := string(rune('A'+i)) + "series" + data := make([]float64, numPoints) + v := 0.0 + for j := range data { + v += r.Float64()*2 - 1 + data[j] = v + } + values[name] = data + } + return values +} + +func benchPlot(b *testing.B, w, h, numSeries, pointsShown int) { + values := benchValues(numSeries, pointsShown+10) + img := image.NewRGBA(image.Rect(0, 0, w, h)) + series := make([]*TimeSeries, 0, numSeries) + for name := range values { + series = append(series, NewTimeSeries(name, values)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + clear(img.Pix) + for _, ts := range series { + ts.PlotImage(img, values, 0, pointsShown, 1) + } + } +} + +// Default zoom: 250 points visible +func BenchmarkPlot_1080p_10series_250pts(b *testing.B) { benchPlot(b, 1920, 1080, 10, 250) } +func BenchmarkPlot_1080p_30series_250pts(b *testing.B) { benchPlot(b, 1920, 1080, 30, 250) } + +// Zoomed out: 10000 points visible (zoom slider at max = 25*400) +func BenchmarkPlot_1080p_10series_10kpts(b *testing.B) { benchPlot(b, 1920, 1080, 10, 10000) } +func BenchmarkPlot_1080p_30series_10kpts(b *testing.B) { benchPlot(b, 1920, 1080, 30, 10000) } + +func BenchmarkClearOnly_1080p(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1920, 1080)) + for i := 0; i < b.N; i++ { + clear(img.Pix) + } +} + +func BenchmarkBresenhamSingleLine(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1920, 1080)) + col := color.RGBA{255, 0, 0, 255} + for i := 0; i < b.N; i++ { + BresenhamThick(img, 0, 0, 1919, 1079, 1, col) + } +} diff --git a/pkg/widgets/plotter/plotter_render.go b/pkg/widgets/plotter/plotter_render.go index d70a70e5..a228b12e 100644 --- a/pkg/widgets/plotter/plotter_render.go +++ b/pkg/widgets/plotter/plotter_render.go @@ -5,20 +5,21 @@ import ( ) type plotterRenderer struct { - *Plotter + PL *Plotter + objects []fyne.CanvasObject } func (p *plotterRenderer) MinSize() fyne.Size { - return p.split.MinSize() + return p.PL.split.MinSize() } func (p *plotterRenderer) Layout(size fyne.Size) { - if p.size == size { + if p.PL.size == size { return } - p.size = size + p.PL.size = size - p.split.Resize(size) + p.PL.split.Resize(size) } func (p *plotterRenderer) Refresh() { @@ -28,10 +29,14 @@ func (p *plotterRenderer) Destroy() { } func (p *plotterRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{p.split, - p.overlayText, - p.cursor, + if p.objects == nil { + p.objects = []fyne.CanvasObject{ + p.PL.split, + p.PL.overlayText, + p.PL.cursor, + } } + return p.objects } type plotLayout struct { From d68d95514441348af1ff92bddb2722bece83f76e Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 01:11:52 +0200 Subject: [PATCH 21/93] cache objects --- pkg/widgets/grid/grid.go | 33 +++++++++++++++------------- pkg/widgets/icon/icon.go | 1 - pkg/widgets/ledicon/ledicon.go | 9 +++++--- pkg/widgets/logplayer/logplayer.go | 8 +++++-- pkg/widgets/settings/wbleditor.go | 33 ++++++++++++++++------------ pkg/widgets/symbollist/symbollist.go | 10 ++++++--- 6 files changed, 56 insertions(+), 38 deletions(-) diff --git a/pkg/widgets/grid/grid.go b/pkg/widgets/grid/grid.go index 674ddf4c..55126139 100644 --- a/pkg/widgets/grid/grid.go +++ b/pkg/widgets/grid/grid.go @@ -35,11 +35,12 @@ func New(cols, rows int) *Grid { } func (g *Grid) CreateRenderer() fyne.WidgetRenderer { - return &gridRenderer{g} + return &gridRenderer{G: g} } type gridRenderer struct { - *Grid + G *Grid + objects []fyne.CanvasObject } func (g *gridRenderer) MinSize() fyne.Size { @@ -47,17 +48,17 @@ func (g *gridRenderer) MinSize() fyne.Size { } func (g *gridRenderer) Layout(size fyne.Size) { - if size == g.lastSize { + if size == g.G.lastSize { return } - g.lastSize = size + g.G.lastSize = size - cellWidth := size.Width / float32(g.cols) - cellHeight := size.Height / float32(g.rows) + cellWidth := size.Width / float32(g.G.cols) + cellHeight := size.Height / float32(g.G.rows) // update vertical lines - for i := 0; i < g.cols; i++ { - l := g.lines[i] + for i := 0; i < g.G.cols; i++ { + l := g.G.lines[i] x := float32(i) * cellWidth l.Position1 = fyne.NewPos(x, 0) l.Position2 = fyne.NewPos(x, size.Height) @@ -65,9 +66,9 @@ func (g *gridRenderer) Layout(size fyne.Size) { } // update horizontal lines - offset := g.cols - for i := 0; i < g.rows; i++ { - l := g.lines[offset+i] + offset := g.G.cols + for i := 0; i < g.G.rows; i++ { + l := g.G.lines[offset+i] y := float32(i) * cellHeight l.Position1 = fyne.NewPos(0, y) l.Position2 = fyne.NewPos(size.Width, y) @@ -82,9 +83,11 @@ func (g *gridRenderer) Destroy() { } func (g *gridRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, len(g.lines)) - for i, l := range g.lines { - objs[i] = l + if g.objects == nil { + g.objects = make([]fyne.CanvasObject, len(g.G.lines)) + for i, l := range g.G.lines { + g.objects[i] = l + } } - return objs + return g.objects } diff --git a/pkg/widgets/icon/icon.go b/pkg/widgets/icon/icon.go index 0debccb7..81704f88 100644 --- a/pkg/widgets/icon/icon.go +++ b/pkg/widgets/icon/icon.go @@ -76,5 +76,4 @@ func (ic *IconRenderer) Objects() []fyne.CanvasObject { ic.objects = []fyne.CanvasObject{ic.IC.cfg.Image, ic.IC.text} } return ic.objects - // return []fyne.CanvasObject{ic.IC.cfg.Image, ic.IC.text} --- IGNORE --- } diff --git a/pkg/widgets/ledicon/ledicon.go b/pkg/widgets/ledicon/ledicon.go index f6e5cea6..f3de64a9 100644 --- a/pkg/widgets/ledicon/ledicon.go +++ b/pkg/widgets/ledicon/ledicon.go @@ -53,13 +53,13 @@ func (w *Widget) SetState(state bool) { func (w *Widget) CreateRenderer() fyne.WidgetRenderer { return &iconRenderer{w: w} - } var _ fyne.WidgetRenderer = (*iconRenderer)(nil) type iconRenderer struct { - w *Widget + w *Widget + objects []fyne.CanvasObject } func (r *iconRenderer) MinSize() fyne.Size { @@ -82,5 +82,8 @@ func (r *iconRenderer) Destroy() { } func (r *iconRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.w.ledicon, r.w.label} + if r.objects == nil { + r.objects = []fyne.CanvasObject{r.w.ledicon, r.w.label} + } + return r.objects } diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index da7f558a..9dfc717e 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -300,7 +300,8 @@ func (l *Logplayer) CreateRenderer() fyne.WidgetRenderer { } type LogplayerRenderer struct { - l *Logplayer + l *Logplayer + objects []fyne.CanvasObject } func (lr *LogplayerRenderer) Layout(space fyne.Size) { @@ -315,7 +316,10 @@ func (lr *LogplayerRenderer) Refresh() { } func (lr *LogplayerRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{lr.l.container} + if lr.objects == nil { + lr.objects = []fyne.CanvasObject{lr.l.container} + } + return lr.objects } func (lr *LogplayerRenderer) Destroy() { diff --git a/pkg/widgets/settings/wbleditor.go b/pkg/widgets/settings/wbleditor.go index 43672ccf..78a25a8d 100644 --- a/pkg/widgets/settings/wbleditor.go +++ b/pkg/widgets/settings/wbleditor.go @@ -515,6 +515,8 @@ type graphRenderer struct { x0, y0, x1, y1 float32 minYv, maxYv int minZv, maxZv float64 + + objects []fyne.CanvasObject } func (r *graphRenderer) rebuild() { @@ -667,21 +669,24 @@ func (r *graphRenderer) Refresh() { } func (r *graphRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) - objs = append(objs, r.bg) - for _, l := range r.gridLines { - objs = append(objs, l) - } - for _, l := range r.axes { - objs = append(objs, l) - } - for _, l := range r.dataLines { - objs = append(objs, l) - } - for _, p := range r.points { - objs = append(objs, p) + if r.objects == nil { + + r.objects = make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) + r.objects = append(r.objects, r.bg) + for _, l := range r.gridLines { + r.objects = append(r.objects, l) + } + for _, l := range r.axes { + r.objects = append(r.objects, l) + } + for _, l := range r.dataLines { + r.objects = append(r.objects, l) + } + for _, p := range r.points { + r.objects = append(r.objects, p) + } } - return objs + return r.objects } func (r *graphRenderer) MinSize() fyne.Size { diff --git a/pkg/widgets/symbollist/symbollist.go b/pkg/widgets/symbollist/symbollist.go index e524328a..a29b849d 100644 --- a/pkg/widgets/symbollist/symbollist.go +++ b/pkg/widgets/symbollist/symbollist.go @@ -386,12 +386,13 @@ func (sw *SymbolWidgetEntry) SetCorrectionFactor(f float64) { */ func (sw *SymbolWidgetEntry) CreateRenderer() fyne.WidgetRenderer { - return &symbolWidgetEntryRenderer{sw} + return &symbolWidgetEntryRenderer{e: sw} // return widget.NewSimpleRenderer(sw.container) } type symbolWidgetEntryRenderer struct { - e *SymbolWidgetEntry + e *SymbolWidgetEntry + objects []fyne.CanvasObject } func (s *symbolWidgetEntryRenderer) Destroy() { @@ -420,5 +421,8 @@ func (s *symbolWidgetEntryRenderer) Refresh() { } func (s *symbolWidgetEntryRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{s.e.container} + if s.objects == nil { + s.objects = []fyne.CanvasObject{s.e.container} + } + return s.objects } From b63e81737166a0f5ee642e7e5e1ae239aa51812c Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 01:23:04 +0200 Subject: [PATCH 22/93] save --- .github/workflows/windows-release.yml | 2 +- .github/workflows/windows.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 6036d745..d6f9eb8c 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -24,7 +24,7 @@ jobs: submodules: recursive - name: Install NSIS - uses: repolevedavaj/install-nsis@v1.2.0 + uses: repolevedavaj/install-nsis@v1.2.1 with: nsis-version: '3.11' diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 27afe2fc..6bb72a7d 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -27,7 +27,7 @@ jobs: submodules: recursive - name: Install NSIS - uses: repolevedavaj/install-nsis@v1.2.0 + uses: repolevedavaj/install-nsis@v1.2.1 with: nsis-version: '3.11' From a20073e8e01c35056360eb00afec9018c259cac5 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 16:56:33 +0200 Subject: [PATCH 23/93] 2d graph --- pkg/widgets/graph2d/graph2d.go | 578 +++++++++++++++++++++ pkg/widgets/graph2d/graph2d_render_test.go | 101 ++++ pkg/widgets/mapviewer/mapviewer.go | 74 ++- pkg/widgets/meshgrid/meshgrid_mouse.go | 5 +- pkg/widgets/meshgrid/meshgrid_widget.go | 22 +- 5 files changed, 754 insertions(+), 26 deletions(-) create mode 100644 pkg/widgets/graph2d/graph2d.go create mode 100644 pkg/widgets/graph2d/graph2d_render_test.go diff --git a/pkg/widgets/graph2d/graph2d.go b/pkg/widgets/graph2d/graph2d.go new file mode 100644 index 00000000..008a8975 --- /dev/null +++ b/pkg/widgets/graph2d/graph2d.go @@ -0,0 +1,578 @@ +// Package graph2d provides a T7Suite style 2D graph for one dimensional +// maps. The map values are plotted as a line with one marker per cell, +// value callouts above the markers and the axis values along the bottom. +package graph2d + +import ( + "image/color" + "math" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" +) + +const ( + tickTextSize = 12 + calloutTextSize = 11 + + markerRadius = 4 + + padRight = 12 + axisGapX = 6 // gap between the y tick labels and the plot area + + calloutPadX = 4 + calloutPadY = 2 + + // pool size for y gridlines/labels/bands, must fit maxYTicks plus the + // extra ticks added when the range is extended to nice boundaries + gridPool = 16 + + maxYTicks = 8 +) + +var ( + plotBgColor = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} + bandColor = color.RGBA{0xEF, 0xED, 0xD8, 0xFF} + gridLineColor = color.RGBA{0xC9, 0xC9, 0xC9, 0xFF} + plotLineColor = color.RGBA{0xD8, 0x40, 0x28, 0xFF} + markerStrokeCol = color.RGBA{0x8A, 0x22, 0x12, 0xFF} + calloutBorderCol = color.RGBA{0x99, 0x99, 0x99, 0xFF} + calloutTextColor = color.RGBA{0x00, 0x00, 0x00, 0xFF} + // same color as the mapviewer crosshair so the live cursor is + // recognizable; NRGBA since the value is not alpha-premultiplied + cursorColor = color.NRGBA{165, 55, 253, 180} +) + +var ( + _ fyne.Widget = (*Graph)(nil) + _ desktop.Mouseable = (*Graph)(nil) +) + +type Config struct { + AxisData []float64 // axis value per cell, shown along the x axis + Values []float64 + AxisPrecision int + ValuePrecision int + AxisLabel string // optional axis description shown below the x axis + ColorblindMode colors.ColorBlindMode +} + +type Graph struct { + widget.BaseWidget + + cfg *Config + + axis []float64 + values []float64 + + zMin, zMax float64 + + colorMode colors.ColorBlindMode + + cursorIdx float64 + showCursor bool + + OnMouseDown func() + + renderer *graphRenderer +} + +func New(cfg *Config) *Graph { + g := &Graph{ + cfg: cfg, + axis: cfg.AxisData, + values: cfg.Values, + colorMode: cfg.ColorblindMode, + } + if len(g.axis) != len(g.values) { + g.axis = make([]float64, len(g.values)) + for i := range g.axis { + g.axis[i] = float64(i) + } + } + g.zMin, g.zMax = findMinMax(g.values) + g.ExtendBaseWidget(g) + return g +} + +// SetValues updates the plotted values. The number of values must match the +// map dimensions the graph was created with. +func (g *Graph) SetValues(min, max float64, values []float64) { + if len(values) != len(g.values) { + return + } + g.values = values + g.zMin = min + g.zMax = max + g.Refresh() +} + +func (g *Graph) SetColorBlindMode(mode colors.ColorBlindMode) { + if g.colorMode != mode { + g.colorMode = mode + g.Refresh() + } +} + +// SetCursor positions the live cursor at the (fractional) cell index, +// mirroring the crosshair in the map above. +func (g *Graph) SetCursor(idx float64) { + if idx < 0 { + idx = 0 + } else if max := float64(len(g.values) - 1); idx > max { + idx = max + } + g.cursorIdx = idx + g.showCursor = true + if g.renderer != nil { + g.renderer.positionCursor() + g.renderer.cursor.Refresh() + } +} + +func (g *Graph) MouseDown(_ *desktop.MouseEvent) { + if g.OnMouseDown != nil { + g.OnMouseDown() + } +} + +func (g *Graph) MouseUp(_ *desktop.MouseEvent) {} + +func (g *Graph) CreateRenderer() fyne.WidgetRenderer { + n := len(g.values) + r := &graphRenderer{g: g} + + r.plotBg = &canvas.Rectangle{FillColor: plotBgColor} + + for i := 0; i < gridPool; i++ { + band := &canvas.Rectangle{FillColor: bandColor} + band.Hide() + r.bands = append(r.bands, band) + + line := &canvas.Line{StrokeColor: gridLineColor, StrokeWidth: 1} + line.Hide() + r.gridLines = append(r.gridLines, line) + + label := &canvas.Text{TextSize: tickTextSize} + label.Hide() + r.yLabels = append(r.yLabels, label) + } + + for i := 0; i < n-1; i++ { + r.segments = append(r.segments, &canvas.Line{StrokeColor: plotLineColor, StrokeWidth: 2}) + } + + r.cursor = &canvas.Line{StrokeColor: cursorColor, StrokeWidth: 3} + r.cursor.Hide() + + for i := 0; i < n; i++ { + r.markers = append(r.markers, &canvas.Circle{StrokeColor: markerStrokeCol, StrokeWidth: 1.5}) + r.xLabels = append(r.xLabels, &canvas.Text{TextSize: tickTextSize}) + r.calloutBoxes = append(r.calloutBoxes, &canvas.Rectangle{ + FillColor: plotBgColor, + StrokeColor: calloutBorderCol, + StrokeWidth: 1, + CornerRadius: 2, + }) + r.calloutTexts = append(r.calloutTexts, &canvas.Text{ + TextSize: calloutTextSize, + Color: calloutTextColor, + Alignment: fyne.TextAlignCenter, + }) + } + + if g.cfg.AxisLabel != "" { + r.axisLabel = &canvas.Text{Text: g.cfg.AxisLabel, TextSize: tickTextSize} + } + + // z-order: background, bands, gridlines, cursor, line, markers, + // callouts, labels + r.objects = append(r.objects, r.plotBg) + for _, o := range r.bands { + r.objects = append(r.objects, o) + } + for _, o := range r.gridLines { + r.objects = append(r.objects, o) + } + r.objects = append(r.objects, r.cursor) + for _, o := range r.segments { + r.objects = append(r.objects, o) + } + for _, o := range r.markers { + r.objects = append(r.objects, o) + } + for i := 0; i < n; i++ { + r.objects = append(r.objects, r.calloutBoxes[i], r.calloutTexts[i]) + } + for _, o := range r.yLabels { + r.objects = append(r.objects, o) + } + for _, o := range r.xLabels { + r.objects = append(r.objects, o) + } + if r.axisLabel != nil { + r.objects = append(r.objects, r.axisLabel) + } + + g.renderer = r + return r +} + +var _ fyne.WidgetRenderer = (*graphRenderer)(nil) + +type graphRenderer struct { + g *Graph + + plotBg *canvas.Rectangle + + bands []*canvas.Rectangle + gridLines []*canvas.Line + yLabels []*canvas.Text + + segments []*canvas.Line + markers []*canvas.Circle + + calloutBoxes []*canvas.Rectangle + calloutTexts []*canvas.Text + + xLabels []*canvas.Text + axisLabel *canvas.Text + + cursor *canvas.Line + + objects []fyne.CanvasObject + + size fyne.Size + + // plot geometry, kept so the live cursor can be moved without a relayout + plotTop, plotBottom float32 + plotLeft float32 + xStep float32 +} + +func (r *graphRenderer) Layout(size fyne.Size) { + if size == r.size { + return + } + r.size = size + r.relayout() + // the tick labels can change content with the available size so a plain + // reposition is not enough + r.refreshObjects() +} + +func (r *graphRenderer) MinSize() fyne.Size { + return fyne.NewSize(200, 250) +} + +func (r *graphRenderer) Refresh() { + r.relayout() + r.refreshObjects() +} + +func (r *graphRenderer) Destroy() {} + +func (r *graphRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *graphRenderer) refreshObjects() { + for _, o := range r.objects { + if !o.Visible() { + continue + } + o.Refresh() + } +} + +func (r *graphRenderer) relayout() { + g := r.g + n := len(g.values) + size := r.size + if n == 0 || size.Width <= 0 || size.Height <= 0 { + return + } + + style := fyne.TextStyle{} + + // measure the value callouts so headroom can be reserved above the plot + calloutWidths := make([]float32, n) + var maxCalloutW, calloutH float32 + for i, v := range g.values { + text := strconv.FormatFloat(v, 'f', g.cfg.ValuePrecision, 64) + r.calloutTexts[i].Text = text + s := fyne.MeasureText(text, calloutTextSize, style) + calloutWidths[i] = s.Width + 2*calloutPadX + if calloutWidths[i] > maxCalloutW { + maxCalloutW = calloutWidths[i] + } + if h := s.Height + 2*calloutPadY; h > calloutH { + calloutH = h + } + } + + padTop := 2*(calloutH+2) + markerRadius + 4 + + xLabelH := fyne.MeasureText("0", tickTextSize, style).Height + padBottom := xLabelH + 8 + if r.axisLabel != nil { + padBottom += xLabelH + 2 + } + + plotH := size.Height - padTop - padBottom + if plotH < 24 { + plotH = 24 + } + plotTop := padTop + plotBottom := plotTop + plotH + + // y scale with "nice" tick steps, extended to tick boundaries + maxTicks := int(plotH / 36) + if maxTicks < 2 { + maxTicks = 2 + } else if maxTicks > maxYTicks { + maxTicks = maxYTicks + } + rng := g.zMax - g.zMin + if rng <= 0 { + rng = math.Abs(g.zMax) + if rng == 0 { + rng = 1 + } + } + step := niceStep(rng, maxTicks) + yStart := math.Floor(g.zMin/step) * step + yEnd := math.Ceil(g.zMax/step) * step + if yEnd-yStart < step { + yEnd = yStart + step + } + ticks := int(math.Round((yEnd-yStart)/step)) + 1 + if ticks > len(r.gridLines) { + ticks = len(r.gridLines) + } + + decimals := 0 + if e := math.Floor(math.Log10(step)); e < 0 { + decimals = int(-e) + } + + tickTexts := make([]string, ticks) + var maxYLabelW float32 + for i := range tickTexts { + tickTexts[i] = strconv.FormatFloat(yStart+float64(i)*step, 'f', decimals, 64) + if s := fyne.MeasureText(tickTexts[i], tickTextSize, style); s.Width > maxYLabelW { + maxYLabelW = s.Width + } + } + padLeft := maxYLabelW + axisGapX + 4 + plotW := size.Width - padLeft - padRight + if plotW < 10 { + plotW = 10 + } + + scale := plotH / float32(yEnd-yStart) + yFor := func(v float64) float32 { + return plotBottom - float32(v-yStart)*scale + } + + labelColor := theme.Color(theme.ColorNameForeground) + + r.plotBg.Move(fyne.NewPos(padLeft, plotTop)) + r.plotBg.Resize(fyne.NewSize(plotW, plotH)) + + for i := 0; i < len(r.gridLines); i++ { + if i >= ticks { + r.gridLines[i].Hide() + r.yLabels[i].Hide() + r.bands[i].Hide() + continue + } + y := yFor(yStart + float64(i)*step) + + line := r.gridLines[i] + line.Position1 = fyne.NewPos(padLeft, y) + line.Position2 = fyne.NewPos(padLeft+plotW, y) + line.Show() + + label := r.yLabels[i] + label.Text = tickTexts[i] + label.Color = labelColor + s := fyne.MeasureText(label.Text, tickTextSize, style) + label.Resize(s) + label.Move(fyne.NewPos(padLeft-axisGapX-s.Width, y-s.Height/2)) + label.Show() + + // alternating band between this gridline and the one above it + band := r.bands[i] + if i+1 < ticks && i%2 == 0 { + yAbove := yFor(yStart + float64(i+1)*step) + band.Move(fyne.NewPos(padLeft, yAbove)) + band.Resize(fyne.NewSize(plotW, y-yAbove)) + band.Show() + } else { + band.Hide() + } + } + + xStep := plotW / float32(n) + cx := func(i int) float32 { + return padLeft + (float32(i)+0.5)*xStep + } + + cys := make([]float32, n) + for i, v := range g.values { + cys[i] = yFor(v) + } + + for i := 0; i < n; i++ { + marker := r.markers[i] + marker.FillColor = colors.GetColorInterpolation(g.zMin, g.zMax, g.values[i], g.colorMode) + marker.Position1 = fyne.NewPos(cx(i)-markerRadius, cys[i]-markerRadius) + marker.Position2 = fyne.NewPos(cx(i)+markerRadius, cys[i]+markerRadius) + } + + for i := 0; i < n-1; i++ { + segment := r.segments[i] + segment.Position1 = fyne.NewPos(cx(i), cys[i]) + segment.Position2 = fyne.NewPos(cx(i+1), cys[i+1]) + } + + // stagger the callouts on two levels when they would overlap and skip + // some of them when even that is not enough + levels := 1 + if maxCalloutW+4 > xStep { + levels = 2 + } + skip := 1 + if needed := maxCalloutW + 4; needed > xStep*float32(levels) { + skip = int(math.Ceil(float64(needed / (xStep * float32(levels))))) + } + shown := 0 + for i := 0; i < n; i++ { + box, text := r.calloutBoxes[i], r.calloutTexts[i] + if i%skip != 0 { + box.Hide() + text.Hide() + continue + } + level := 0 + if levels == 2 { + level = shown % 2 + } + shown++ + + w := calloutWidths[i] + bottom := cys[i] - markerRadius - 3 - float32(level)*(calloutH+2) + x := cx(i) - w/2 + if x < padLeft+1 { + x = padLeft + 1 + } + if x+w > padLeft+plotW-1 { + x = padLeft + plotW - 1 - w + } + box.Move(fyne.NewPos(x, bottom-calloutH)) + box.Resize(fyne.NewSize(w, calloutH)) + box.Show() + text.Resize(fyne.NewSize(w, calloutH-2*calloutPadY)) + text.Move(fyne.NewPos(x, bottom-calloutH+calloutPadY)) + text.Show() + } + + xLabelTexts := make([]string, n) + var maxXLabelW float32 + for i, v := range g.axis { + xLabelTexts[i] = strconv.FormatFloat(v, 'f', g.cfg.AxisPrecision, 64) + if s := fyne.MeasureText(xLabelTexts[i], tickTextSize, style); s.Width > maxXLabelW { + maxXLabelW = s.Width + } + } + labelSkip := 1 + if maxXLabelW+6 > xStep { + labelSkip = int(math.Ceil(float64((maxXLabelW + 6) / xStep))) + } + for i := 0; i < n; i++ { + label := r.xLabels[i] + if i%labelSkip != 0 { + label.Hide() + continue + } + label.Text = xLabelTexts[i] + label.Color = labelColor + s := fyne.MeasureText(label.Text, tickTextSize, style) + label.Resize(s) + x := cx(i) - s.Width/2 + if x < 0 { + x = 0 + } else if x+s.Width > size.Width { + x = size.Width - s.Width + } + label.Move(fyne.NewPos(x, plotBottom+4)) + label.Show() + } + + if r.axisLabel != nil { + r.axisLabel.Color = labelColor + s := fyne.MeasureText(r.axisLabel.Text, tickTextSize, style) + r.axisLabel.Resize(s) + r.axisLabel.Move(fyne.NewPos(padLeft+(plotW-s.Width)/2, plotBottom+4+xLabelH+2)) + } + + r.plotTop = plotTop + r.plotBottom = plotBottom + r.plotLeft = padLeft + r.xStep = xStep + r.positionCursor() +} + +func (r *graphRenderer) positionCursor() { + if !r.g.showCursor { + return + } + x := r.plotLeft + (float32(r.g.cursorIdx)+0.5)*r.xStep + r.cursor.Position1 = fyne.NewPos(x, r.plotTop) + r.cursor.Position2 = fyne.NewPos(x, r.plotBottom) + if r.cursor.Hidden { + r.cursor.Show() + } +} + +// niceStep returns a 1/2/5*10^n step so that the range is covered by at most +// maxTicks intervals. +func niceStep(rng float64, maxTicks int) float64 { + if maxTicks < 1 { + maxTicks = 1 + } + raw := rng / float64(maxTicks) + mag := math.Pow(10, math.Floor(math.Log10(raw))) + switch norm := raw / mag; { + case norm <= 1: + return mag + case norm <= 2: + return 2 * mag + case norm <= 5: + return 5 * mag + default: + return 10 * mag + } +} + +func findMinMax(values []float64) (float64, float64) { + if len(values) == 0 { + return 0, 0 + } + min, max := values[0], values[0] + for _, v := range values { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} diff --git a/pkg/widgets/graph2d/graph2d_render_test.go b/pkg/widgets/graph2d/graph2d_render_test.go new file mode 100644 index 00000000..d207ae66 --- /dev/null +++ b/pkg/widgets/graph2d/graph2d_render_test.go @@ -0,0 +1,101 @@ +package graph2d + +import ( + "image/png" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "github.com/roffe/txlogger/pkg/colors" +) + +// battery correction table from the T7Suite screenshot the widget mimics +func testGraph(t testing.TB) *Graph { + t.Helper() + return New(&Config{ + AxisData: []float64{5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, + Values: []float64{4590, 3605, 2785, 2145, 1755, 1495, 1295, 1145, 1025, 925, 845, 765}, + AxisPrecision: 0, + ValuePrecision: 0, + AxisLabel: "Battery voltage", + ColorblindMode: colors.ModeNormal, + }) +} + +func TestRender(t *testing.T) { + test.NewApp() + g := testGraph(t) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(640, 420)) + + g.SetCursor(3.5) + g.SetValues(700, 4700, []float64{4700, 3605, 2785, 2145, 1755, 1495, 1295, 1145, 1025, 925, 845, 700}) + + img := w.Canvas().Capture() + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + t.Fatal("captured empty image") + } + + if os.Getenv("GRAPH2D_DUMP") != "" { + f, err := os.Create("/tmp/graph2d.png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } +} + +// many cells in a small window exercise the callout stagger/skip and the +// x label thinning +func TestRenderCrowded(t *testing.T) { + test.NewApp() + values := make([]float64, 32) + for i := range values { + values[i] = 1000 + 500*float64(i%5) + } + g := New(&Config{ + Values: values, + ValuePrecision: 0, + ColorblindMode: colors.ModeNormal, + }) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(400, 300)) + + img := w.Canvas().Capture() + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + t.Fatal("captured empty image") + } + + if os.Getenv("GRAPH2D_DUMP") != "" { + f, err := os.Create("/tmp/graph2d_crowded.png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } +} + +// a mismatched axis must fall back to index labels and a flat line of equal +// values must not divide by zero +func TestRenderDegenerate(t *testing.T) { + test.NewApp() + g := New(&Config{ + Values: []float64{42, 42, 42, 42}, + ColorblindMode: colors.ModeNormal, + }) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(300, 200)) + if img := w.Canvas().Capture(); img.Bounds().Dx() == 0 { + t.Fatal("captured empty image") + } +} diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 0b192295..ae9ca5ab 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -21,6 +21,7 @@ import ( "github.com/roffe/txlogger/pkg/interpolate" "github.com/roffe/txlogger/pkg/layout" "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/graph2d" "github.com/roffe/txlogger/pkg/widgets/meshgrid" ) @@ -65,7 +66,8 @@ type MapViewer struct { selectedX, SelectedY int - mesh *meshgrid.Meshgrid + mesh *meshgrid.Meshgrid + graph *graph2d.Graph // Mouse mousePos fyne.Position @@ -119,6 +121,9 @@ func (mv *MapViewer) SetColorBlindMode(mode colors.ColorBlindMode) { if mv.mesh != nil { mv.mesh.SetColorBlindMode(mode) } + if mv.graph != nil { + mv.graph.SetColorBlindMode(mode) + } } } @@ -192,30 +197,57 @@ func (mv *MapViewer) render() fyne.CanvasObject { buttons := mv.createButtons() + mapview := container.NewBorder( + mv.xAxisLabelContainer, + nil, + mv.yAxisLabelContainer, + nil, + mv.innerView, + ) + if mv.numColumns == 1 || mv.numRows == 1 { + if mv.cfg.MeshView && mv.numData > 1 { + axisData := mv.cfg.XData + axisPrecision := mv.cfg.XPrecision + axisLabel := mv.cfg.XLabel + if mv.numColumns == 1 { + axisData = mv.cfg.YData + axisPrecision = mv.cfg.YPrecision + axisLabel = mv.cfg.YLabel + } + mv.graph = graph2d.New(&graph2d.Config{ + AxisData: axisData, + Values: mv.cfg.ZData, + AxisPrecision: axisPrecision, + ValuePrecision: mv.cfg.ZPrecision, + AxisLabel: axisLabel, + ColorblindMode: mv.colorMode, + }) + if mv.cfg.OnMouseDown != nil { + mv.graph.OnMouseDown = mv.cfg.OnMouseDown + } + split := container.NewVSplit( + mapview, + container.NewBorder( + nil, + buttons, + nil, + nil, + mv.graph, + ), + ) + split.Offset = 0.2 + return split + } return container.NewBorder( nil, buttons, nil, nil, - container.NewBorder( - mv.xAxisLabelContainer, - nil, - mv.yAxisLabelContainer, - nil, - mv.innerView, - ), + mapview, ) } - mapview := container.NewBorder( - mv.xAxisLabelContainer, - nil, - mv.yAxisLabelContainer, - nil, - mv.innerView, - ) - if mv.cfg.MeshView { var err error mv.mesh, err = meshgrid.NewMeshgrid( @@ -329,6 +361,9 @@ func (mv *MapViewer) Refresh() { if mv.mesh != nil { mv.mesh.LoadFloat64s(mv.zMin, mv.zMax, mv.cfg.ZData) } + if mv.graph != nil { + mv.graph.SetValues(mv.zMin, mv.zMax, mv.cfg.ZData) + } } func (mv *MapViewer) createYAxis() { @@ -414,6 +449,13 @@ func (mv *MapViewer) setXY() error { } mv.crosshair.Move(crosshairPos) + if mv.graph != nil { + if mv.numRows == 1 { + mv.graph.SetCursor(xIdx) + } else { + mv.graph.SetCursor(yIdx) + } + } if mv.cfg.CursorFollowCrosshair { mv.selectedX = int(math.Round(xIdx)) mv.SelectedY = int(math.Round(yIdx)) diff --git a/pkg/widgets/meshgrid/meshgrid_mouse.go b/pkg/widgets/meshgrid/meshgrid_mouse.go index de35a652..9d640a2a 100644 --- a/pkg/widgets/meshgrid/meshgrid_mouse.go +++ b/pkg/widgets/meshgrid/meshgrid_mouse.go @@ -18,8 +18,9 @@ func (m *Meshgrid) MouseMoved(event *desktop.MouseEvent) { dy := float64(event.Position.Y - m.lastMouseY) if m.dragging { if event.Button&desktop.MouseButtonPrimary == desktop.MouseButtonPrimary { - //m.orbit(dx*rotationScale, -dy*rotationScale) - m.rotateMeshgrid(-dy*rotationScale, dx*rotationScale, 0) + // Drag left spins clockwise, drag right counter-clockwise; + // drag up tilts backwards, drag down tilts forward. + m.orbit(-dx*rotationScale, -dy*rotationScale) m.throttledRefresh() } else if event.Button&desktop.MouseButtonSecondary == desktop.MouseButtonSecondary { roll := (dx + dy) * rollScale diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 27e9bad5..a2ca8793 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -111,7 +111,12 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int if cols == 1 { m.rotateMeshgrid(0, 90, 0) } else { - m.rotateMeshgrid(60, 0, -30) + // T7Suite-style starting view: ~30° elevation (pitch 60° from + // top-down) with the mesh spun 35° around its vertical axis. Starting + // from identity, the RotZ term is a model-space turntable spin (the + // same composition orbit() applies), not a camera roll. + m.cameraRotation = RotationMatrixX(60).Multiply(m.cameraRotation).Multiply(RotationMatrixZ(-35)) + m.updateVertexPositions() } m.ExtendBaseWidget(m) @@ -199,14 +204,15 @@ func (m *Meshgrid) scaleMeshgrid(factor float64) { m.updateVertexPositions() } -// orbit performs a Fusion 360-style "turntable" orbit. Yaw is applied around -// the world Y axis (right-multiplied so it rotates the world before the -// camera), pitch is applied around the camera-local X axis (left-multiplied). -// Composing the two this way prevents roll from sneaking in on diagonal drags. -func (m *Meshgrid) orbit(yawDelta, pitchDelta float64) { +// orbit performs a Fusion 360-style "turntable" orbit. Spin is applied around +// the mesh's own vertical axis — data Z, the height axis (right-multiplied so +// it rotates the model before the camera) — pitch around the camera-local X +// axis (left-multiplied). Composing the two this way prevents roll from +// sneaking in on diagonal drags. +func (m *Meshgrid) orbit(spinDelta, pitchDelta float64) { pitchRot := RotationMatrixX(pitchDelta) - yawRot := RotationMatrixY(yawDelta) - m.cameraRotation = pitchRot.Multiply(m.cameraRotation).Multiply(yawRot) + spinRot := RotationMatrixZ(spinDelta) + m.cameraRotation = pitchRot.Multiply(m.cameraRotation).Multiply(spinRot) m.updateVertexPositions() } From 604619088a794953eac8e01b9b4d34a3c170f705 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 16:56:45 +0200 Subject: [PATCH 24/93] build pipeline --- .github/workflows/linux-release.yml | 2 +- .github/workflows/linux.yml | 2 +- .github/workflows/windows-release.yml | 2 +- .github/workflows/windows.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 549f566b..7270da24 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Get dependencies run: sudo apt-get update && sudo apt-get install 7zip gcc libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libx11-dev xorg-dev libusb-1.0-0-dev libgtk-3-dev libasound2-dev libftdi1 libftdi1-dev diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 495e9710..033b7b39 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Get dependencies run: sudo apt-get update && sudo apt-get install 7zip gcc libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libx11-dev xorg-dev libusb-1.0-0-dev libgtk-3-dev libasound2-dev libftdi1 libftdi1-dev diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index d6f9eb8c..48245c7f 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Install dependencies diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 6bb72a7d..0ccd16c9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -34,7 +34,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Install dependencies From 8ec8166e5832d0fb268495f34ea623b15e7e0dff Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 17:43:25 +0200 Subject: [PATCH 25/93] save meshgrid with setcursor --- pkg/assets/WHATSNEW.md | 10 +- pkg/widgets/mapviewer/mapviewer.go | 3 + pkg/widgets/meshgrid/meshgrid_draw.go | 63 ++++++--- pkg/widgets/meshgrid/meshgrid_render_test.go | 18 +++ pkg/widgets/meshgrid/meshgrid_surface.go | 14 +- pkg/widgets/meshgrid/meshgrid_widget.go | 130 ++++++++++++++++--- 6 files changed, 198 insertions(+), 40 deletions(-) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 1930bcd6..d8f250a1 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -1,5 +1,7 @@ # 2.1.10 -- Performance optimization for the log plotter and meshgrid +- Live tracking marker in the 3d mesh viewer showing where the ECU is reading from, mirroring the crosshair in the map above +- Fixed the 3d mesh showing one cell less than the table in each direction; values are now cell-centered so an 18x16 map renders 18x16 cells +- Performance optimization for the meshgrid - Force layouts to be loaded and saved in users home directory under the txlogger folder - Update ecusymbol to be able to read T5 versions - Added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab @@ -10,7 +12,11 @@ - Improved drag handler in logplayer, when zoomed in we drag fewer frames increasing as we zoom out - We now have 3 render modes for viewing 3d maps, Solid Wireframe, Solid & Wireframe. Press the little square icon in the mesh viewer to switch between them - WBL reconnect COM port while logging. If the COM port dies for a reason during logging it will try to re-connect -- Many widget performance improvements to allow slower computers to run txlogger better +- Performance improvements in many widget to allow slower computers to run txlogger better +- Improved camera handling in the 3d mesh viewer - now behaves like the t5/7/8 suites +- Added 2d graph for viewing flat maps +- Rewrote logplayer plotter to use about 50% less CPU on zoomed out views +- Big refactor of the log writing logic to be simpler to maintain and be more performant # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index ae9ca5ab..97cd5c56 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -456,6 +456,9 @@ func (mv *MapViewer) setXY() error { mv.graph.SetCursor(yIdx) } } + if mv.mesh != nil { + mv.mesh.SetCursor(xIdx, yIdx) + } if mv.cfg.CursorFollowCrosshair { mv.selectedX = int(math.Round(xIdx)) mv.SelectedY = int(math.Round(yIdx)) diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index 64ae4b0c..47db30fc 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -36,11 +36,15 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { clear(img.Pix) } + // Vertices sit on cell corners, so the grid is one larger than the data + // in each direction (one quad per table cell). + vRows, vCols := m.rows+1, m.cols+1 + // Find min/max of the view-space Z for depth shading. minZ, maxZ := math.Inf(1), math.Inf(-1) - for i := 0; i < m.rows; i++ { + for i := 0; i < vRows; i++ { row := m.vertices[i] - for j := 0; j < m.cols; j++ { + for j := 0; j < vCols; j++ { z := row[j].Z if z < minZ { minZ = z @@ -56,7 +60,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { } // Precompute screen-space projection and color for each vertex once. - n := m.rows * m.cols + n := vRows * vCols if cap(m.scratchProjX) < n { m.scratchProjX = make([]int, n) m.scratchProjY = make([]int, n) @@ -68,24 +72,20 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { cx := float64(m.size.Width) * 0.5 cy := float64(m.size.Height) * 0.5 - for i := 0; i < m.rows; i++ { + for i := 0; i < vRows; i++ { row := m.vertices[i] - base := i * m.cols - for j := 0; j < m.cols; j++ { + base := i * vCols + for j := 0; j < vCols; j++ { v := row[j] idx := base + j projX[idx] = int(cx + v.X) projY[idx] = int(cy + v.Y) depth := (v.Z - minZ) / zRange - vertCol[idx] = m.getColorWithDepth(m.values[idx], depth) + vertCol[idx] = m.getColorWithDepth(v.V, depth) } } mode := m.renderMode - if m.rows < 2 || m.cols < 2 { - // A 1D mesh has no cells to fill; lines are all we can draw. - mode = RenderModeWireframe - } if mode != RenderModeWireframe { m.drawSurface(img, projX, projY, vertCol, mode == RenderModeSolidWireframe) @@ -95,16 +95,16 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { // Collect line segments using cached projections. segs := m.scratchLines[:0] - for i := 0; i < m.rows; i++ { - for j := 0; j < m.cols; j++ { - idx := i*m.cols + j + for i := 0; i < vRows; i++ { + for j := 0; j < vCols; j++ { + idx := i*vCols + j x1, y1 := projX[idx], projY[idx] // neighbors: (+1,0) down, (0,+1) right, (+1,-1) diagonal tryAddSeg := func(ni, nj int) { - if ni >= m.rows || nj < 0 || nj >= m.cols { + if ni >= vRows || nj < 0 || nj >= vCols { return } - nidx := ni*m.cols + nj + nidx := ni*vCols + nj x2, y2 := projX[nidx], projY[nidx] dx, dy := x2-x1, y2-y1 if dx*dx+dy*dy < 4 { @@ -155,10 +155,39 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { return img } +// cursorScreenPosition projects the tracking-marker cell position set by +// SetCursor onto the screen. The camera transform is linear, so bilinearly +// interpolating the transformed vertex coordinates lands on the same point +// as transforming the interpolated one. +func (m *Meshgrid) cursorScreenPosition() (float32, float32) { + // MapViewer indices are cell-centered while mesh vertices sit on cell + // corners; +0.5 lands the marker mid-cell on the corner grid. SetCursor + // clamps the indices, so sx/sy stay within [0.5, cols-0.5]/[0.5, rows-0.5]. + sx := m.cursorX + 0.5 + sy := m.cursorY + 0.5 + + x0 := int(sx) + y0 := int(sy) + x1 := min(x0+1, m.cols) + y1 := min(y0+1, m.rows) + fx := sx - float64(x0) + fy := sy - float64(y0) + + v00 := m.vertices[y0][x0] + v01 := m.vertices[y0][x1] + v10 := m.vertices[y1][x0] + v11 := m.vertices[y1][x1] + + vx := (1-fy)*((1-fx)*v00.X+fx*v01.X) + fy*((1-fx)*v10.X+fx*v11.X) + vy := (1-fy)*((1-fx)*v00.Y+fx*v01.Y) + fy*((1-fx)*v10.Y+fx*v11.Y) + + return float32(float64(m.size.Width)*0.5 + vx), float32(float64(m.size.Height)*0.5 + vy) +} + // getColorWithDepth combines color interpolation and depth enhancement in one step func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { // Get base color from value - //baseColor := m.getColorInterpolation(value) + // baseColor := m.getColorInterpolation(value) baseColor := colors.GetColorInterpolation( m.zmin, m.zmax, diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index 39948738..992f62a3 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -63,6 +63,24 @@ func TestRenderRotated(t *testing.T) { } } +// the tracking marker overlay must land on the surface: a cursor centered on +// a vertex (cell 7.5 + the 0.5 corner offset = vertex 8) must project to +// exactly that vertex's screen position +func TestCursorScreenPosition(t *testing.T) { + m := testGrid(t) + m.cursorX, m.cursorY, m.showCursor = 7.5, 7.5, true + px, py := m.cursorScreenPosition() + v := m.vertices[8][8] + wantX := float32(float64(m.size.Width)*0.5 + v.X) + wantY := float32(float64(m.size.Height)*0.5 + v.Y) + if px != wantX || py != wantY { + t.Fatalf("cursor at (%v, %v), want vertex projection (%v, %v)", px, py, wantX, wantY) + } + if px < 0 || px > m.size.Width || py < 0 || py > m.size.Height { + t.Fatalf("cursor (%v, %v) outside widget %v", px, py, m.size) + } +} + func BenchmarkDrawSurface(b *testing.B) { m := testGrid(b) m.renderMode = RenderModeSolidWireframe diff --git a/pkg/widgets/meshgrid/meshgrid_surface.go b/pkg/widgets/meshgrid/meshgrid_surface.go index 3b722f75..ddbb5406 100644 --- a/pkg/widgets/meshgrid/meshgrid_surface.go +++ b/pkg/widgets/meshgrid/meshgrid_surface.go @@ -37,9 +37,10 @@ const surfaceEdgeFade = 0.45 // When edges is true the cell outline is drawn right after its fill, which // keeps lines on hidden faces correctly occluded by nearer quads. func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []color.RGBA, edges bool) { + // One quad per data cell; the corner-vertex grid is (rows+1) x (cols+1). quads := m.scratchQuads[:0] - for i := 0; i < m.rows-1; i++ { - for j := 0; j < m.cols-1; j++ { + for i := 0; i < m.rows; i++ { + for j := 0; j < m.cols; j++ { z := m.vertices[i][j].Z + m.vertices[i][j+1].Z + m.vertices[i+1][j].Z + m.vertices[i+1][j+1].Z quads = append(quads, quadRef{i: i, j: j, depth: z * 0.25}) } @@ -64,11 +65,12 @@ func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []co il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) lx, ly, lz = lx*il, ly*il, lz*il + vCols := m.cols + 1 for _, q := range quads { - ai := q.i*m.cols + q.j // top-left - bi := ai + 1 // top-right - di := ai + m.cols // bottom-left - ci := di + 1 // bottom-right + ai := q.i*vCols + q.j // top-left + bi := ai + 1 // top-right + di := ai + vCols // bottom-left + ci := di + 1 // bottom-right shade := m.quadShade(q.i, q.j, lx, ly, lz) ca := fadeColor(vertCol[ai], shade) diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index a2ca8793..5975e969 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -13,9 +13,14 @@ import ( "github.com/roffe/txlogger/pkg/colors" ) +// Vertex is a corner of the mesh. Values are cell-centered (one per table +// cell) while vertices sit on cell corners, so the vertex grid is one larger +// than the data grid in each direction and V holds the average of the +// adjacent cell values. type Vertex struct { Ox, Oy, Oz float64 // Original coordinates X, Y, Z float64 // Transformed coordinates for rendering + V float64 // Data value at this corner (average of adjacent cells) } var _ fyne.Widget = (*Meshgrid)(nil) @@ -59,6 +64,14 @@ type Meshgrid struct { cameraPosition [3]float64 // Camera's position in world space mousePosition image.Point + // Live tracking marker (fractional cell indices), mirroring the + // mapviewer crosshair. Hidden until SetCursor is first called. The marker + // is a canvas primitive overlaid on the mesh image so moving it only + // repaints the scene instead of re-rasterizing the whole mesh. + cursorX, cursorY float64 + showCursor bool + cursor *canvas.Circle + xlabel, ylabel, zlabel string refreshPending bool @@ -70,10 +83,21 @@ type Meshgrid struct { OnMouseDown func() } +// Marker colors match the mapviewer crosshair so the two tracking +// indicators read as the same thing. +var ( + cursorFillColor = color.RGBA{R: 165, G: 55, B: 253, A: 255} + cursorRingColor = color.RGBA{R: 255, G: 255, B: 255, A: 255} +) + +const cursorRadius = 6 + // NewMeshgrid creates a new Meshgrid given width, height, depth and spacing. func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, colorBlindMode colors.ColorBlindMode) (*Meshgrid, error) { + cols = max(1, cols) + rows = max(1, rows) // Check if the provided values slice has the correct number of elements - if len(values) != max(1, cols)*max(1, rows) { + if len(values) != cols*rows { return nil, fmt.Errorf("the number of Z values does not match the meshgrid dimensions") } // Find min and max Z values for normalization @@ -104,7 +128,7 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int colorMode: colorBlindMode, } - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.scaleMeshgrid(0.3) @@ -126,6 +150,16 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int m.image.FillMode = canvas.ImageFillOriginal m.image.ScaleMode = canvas.ImageScaleFastest + m.cursor = &canvas.Circle{ + // Position2 sets the bounds directly; Resize would trigger a canvas + // refresh before the widget is even shown. + Position2: fyne.NewPos(cursorRadius*2, cursorRadius*2), + FillColor: cursorFillColor, + StrokeColor: cursorRingColor, + StrokeWidth: 2, + Hidden: true, + } + return m, nil } @@ -155,7 +189,11 @@ func (m *Meshgrid) SetColorBlindMode(mode colors.ColorBlindMode) { m.refresh() } -func (m *Meshgrid) createVertices(width, height float32) { +// createVertices builds the corner-vertex grid: one quad per table cell, so +// the mesh shows exactly cols x rows cells like the map above. The grid is +// (rows+1) x (cols+1); each corner takes the average of the 1-4 cell values +// touching it. +func (m *Meshgrid) createVertices() { // Guard against a zero range (e.g. all values identical / all zero) so we // produce a flat mesh at Z=0 instead of NaN from a div-by-zero. zrange := m.zrange @@ -163,16 +201,19 @@ func (m *Meshgrid) createVertices(width, height float32) { zrange = 1 } - vertices := make([][]Vertex, 0, m.rows) - valueIndex := 0 + vRows, vCols := m.rows+1, m.cols+1 + vertices := make([][]Vertex, 0, vRows) var sumX, sumY, sumZ float64 var count int - for i := m.rows; i > 0; i-- { - row := make([]Vertex, 0, m.cols) - for j := 0; j < m.cols; j++ { - x := -float64(width)*.5 + float64(j)*float64(m.cellWidth) - y := -float64(height)*.5 + float64(i)*float64(m.cellHeight) - z := ((m.values[valueIndex] - m.zmin) / zrange) * m.depth + for i := 0; i < vRows; i++ { + row := make([]Vertex, 0, vCols) + for j := 0; j < vCols; j++ { + value := m.cornerValue(i, j) + x := float64(j) * float64(m.cellWidth) + // Data row 0 is the bottom row in the map; keep it at the high-Y + // end of the mesh like before, so the orientation is unchanged. + y := float64(vRows-i) * float64(m.cellHeight) + z := ((value - m.zmin) / zrange) * m.depth row = append(row, Vertex{ Ox: x, Oy: y, @@ -180,12 +221,12 @@ func (m *Meshgrid) createVertices(width, height float32) { X: x, Y: y, Z: z, + V: value, }) sumX += x sumY += y sumZ += z count++ - valueIndex++ } vertices = append(vertices, row) } @@ -199,6 +240,25 @@ func (m *Meshgrid) createVertices(width, height float32) { } } +// cornerValue averages the cell values adjacent to corner (vi, vj): four in +// the interior, two along edges and one at the outer corners. +func (m *Meshgrid) cornerValue(vi, vj int) float64 { + r0 := max(vi-1, 0) + r1 := min(vi, m.rows-1) + c0 := max(vj-1, 0) + c1 := min(vj, m.cols-1) + + var sum float64 + var n int + for r := r0; r <= r1; r++ { + for c := c0; c <= c1; c++ { + sum += m.values[r*m.cols+c] + n++ + } + } + return sum / float64(n) +} + func (m *Meshgrid) scaleMeshgrid(factor float64) { m.scale = m.scale * factor m.updateVertexPositions() @@ -266,7 +326,7 @@ func (m *Meshgrid) SetFloat64(idx int, value float64) { } m.values[idx] = value m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.updateVertexPositions() m.refresh() } @@ -283,11 +343,48 @@ func (m *Meshgrid) LoadFloat64s(min, max float64, floats []float64) { m.values = floats - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.updateVertexPositions() m.refresh() } +// SetCursor positions the tracking marker at the (fractional) cell index, +// mirroring the crosshair in the map above. The marker rides on the mesh +// surface, interpolated between the four surrounding vertices. +func (m *Meshgrid) SetCursor(xIdx, yIdx float64) { + if xIdx < 0 { + xIdx = 0 + } else if max := float64(m.cols - 1); xIdx > max { + xIdx = max + } + if yIdx < 0 { + yIdx = 0 + } else if max := float64(m.rows - 1); yIdx > max { + yIdx = max + } + if m.showCursor && xIdx == m.cursorX && yIdx == m.cursorY { + return + } + m.cursorX = xIdx + m.cursorY = yIdx + m.showCursor = true + m.moveCursor() +} + +// moveCursor repositions the overlay marker on the projected surface point. +// Moving a canvas primitive only repaints the scene from cached textures, +// so cursor updates don't re-rasterize the mesh. +func (m *Meshgrid) moveCursor() { + if !m.showCursor { + return + } + px, py := m.cursorScreenPosition() + m.cursor.Move(fyne.NewPos(px-cursorRadius, py-cursorRadius)) + if m.cursor.Hidden { + m.cursor.Show() + } +} + // returns the min, max and range across the data func findMinMaxRange(values []float64) (float64, float64, float64) { minZ, maxZ := values[0], values[0] @@ -310,6 +407,9 @@ func (m *Meshgrid) refresh() { m.image.Image = m.drawMeshgridLines() m.image.Resize(m.size) m.image.Refresh() + // Rotation, scale, resize and data changes all move the projected + // surface point under the marker. + m.moveCursor() } func (m *Meshgrid) throttledRefresh() { @@ -358,7 +458,7 @@ func (m *meshgridRenderer) Destroy() { func (m *meshgridRenderer) Objects() []fyne.CanvasObject { if m.objects == nil { - m.objects = []fyne.CanvasObject{m.MG.image} + m.objects = []fyne.CanvasObject{m.MG.image, m.MG.cursor} } return m.objects } From b5153462bcacc32779271d0b8858fc72be4d613c Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 19:11:36 +0200 Subject: [PATCH 26/93] meshgrid poly support --- pkg/widgets/meshgrid/meshgrid_poly.go | 308 +++++++++++++++++++ pkg/widgets/meshgrid/meshgrid_render_test.go | 134 ++++++++ pkg/widgets/meshgrid/meshgrid_widget.go | 40 +++ 3 files changed, 482 insertions(+) create mode 100644 pkg/widgets/meshgrid/meshgrid_poly.go diff --git a/pkg/widgets/meshgrid/meshgrid_poly.go b/pkg/widgets/meshgrid/meshgrid_poly.go new file mode 100644 index 00000000..fc4f69bc --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_poly.go @@ -0,0 +1,308 @@ +package meshgrid + +import ( + "image/color" + "math" + "slices" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" +) + +// Polygon-based renderer experiment: instead of rasterizing the mesh into an +// RGBA image (and re-uploading that texture) every frame, each grid cell is a +// reusable canvas.ArbitraryPolygon that fyne's GL painter draws as a single +// SDF shader quad. A frame update only mutates points, colors and the +// painter's order of the object list — nothing is rasterized on the CPU. +// +// Trade-offs vs the image renderer: +// - Flat shading: a polygon has one fill color, so the four corner colors +// are averaged instead of Gouraud-interpolated across the quad. +// - Wireframe mode uses the quad outlines (stroke), so the cell diagonals +// are not drawn. +// - fyne does not clip renderer objects to the widget bounds, so a zoomed +// mesh can spill outside the widget area. + +// polyPad expands each polygon's bounding box so the 1px stroke and the +// shader's antialiased edge aren't clipped at the object bounds (the painter +// clamps points to the object's own size). +const polyPad float32 = 1.5 + +// initPolygons creates the reusable canvas objects: one polygon per data cell +// plus the axis-indicator lines and labels. Geometry and colors are filled in +// by updatePolygons; nothing here needs a driver, so it is safe to call from +// the constructor (and from tests without an app). +func (m *Meshgrid) initPolygons() { + m.polys = make([]*canvas.ArbitraryPolygon, m.rows*m.cols) + for i := range m.polys { + m.polys[i] = &canvas.ArbitraryPolygon{Points: make([]fyne.Position, 4)} + } + + axisColors := [3]color.RGBA{ + {R: 255, A: 255}, // X red + {G: 255, A: 255}, // Y green + {B: 255, A: 255}, // Z blue + } + labels := [3]string{m.xlabel, m.ylabel, m.zlabel} + for i := range m.axisLines { + m.axisLines[i] = &canvas.Line{StrokeColor: axisColors[i], StrokeWidth: 1} + t := canvas.NewText(labels[i], axisColors[i]) + t.TextSize = 11 + m.axisLabels[i] = t + } +} + +// updatePolygons projects the mesh and updates the reusable canvas objects: +// quad geometry, flat fill/stroke colors and the back-to-front painter's +// order of the renderer's object list. +func (m *Meshgrid) updatePolygons() { + w, h := m.size.Width, m.size.Height + if w <= 0 || h <= 0 || len(m.polys) == 0 { + return + } + + vRows, vCols := m.rows+1, m.cols+1 + + // View-space depth range for the depth shading, same as the image path. + minZ, maxZ := math.Inf(1), math.Inf(-1) + for i := 0; i < vRows; i++ { + row := m.vertices[i] + for j := 0; j < vCols; j++ { + z := row[j].Z + if z < minZ { + minZ = z + } + if z > maxZ { + maxZ = z + } + } + } + zRange := maxZ - minZ + if zRange == 0 { + zRange = 1 + } + + // Per-vertex screen projection and color. Float projection keeps subpixel + // precision, which the GPU edges make visible (the image path rounds to + // whole pixels). + n := vRows * vCols + if cap(m.scratchFX) < n { + m.scratchFX = make([]float32, n) + m.scratchFY = make([]float32, n) + } + if cap(m.scratchColors) < n { + m.scratchColors = make([]color.RGBA, n) + } + fxs := m.scratchFX[:n] + fys := m.scratchFY[:n] + vertCol := m.scratchColors[:n] + + cx := float64(w) * 0.5 + cy := float64(h) * 0.5 + for i := 0; i < vRows; i++ { + row := m.vertices[i] + base := i * vCols + for j := 0; j < vCols; j++ { + v := row[j] + idx := base + j + fxs[idx] = float32(cx + v.X) + fys[idx] = float32(cy + v.Y) + depth := (v.Z - minZ) / zRange + vertCol[idx] = m.getColorWithDepth(v.V, depth) + } + } + + // Fixed light direction in view space, normalized once per frame. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + mode := m.renderMode + + quads := m.scratchQuads[:0] + for i := 0; i < m.rows; i++ { + for j := 0; j < m.cols; j++ { + ai := i*vCols + j // top-left + bi := ai + 1 // top-right + di := ai + vCols // bottom-left + ci := di + 1 // bottom-right + + z := m.vertices[i][j].Z + m.vertices[i][j+1].Z + m.vertices[i+1][j].Z + m.vertices[i+1][j+1].Z + quads = append(quads, quadRef{i: i, j: j, depth: z * 0.25}) + + poly := m.polys[i*m.cols+j] + + ax, ay := fxs[ai], fys[ai] + bx, by := fxs[bi], fys[bi] + ccx, ccy := fxs[ci], fys[ci] + dx, dy := fxs[di], fys[di] + + minx := min(min(ax, bx), min(ccx, dx)) + maxx := max(max(ax, bx), max(ccx, dx)) + miny := min(min(ay, by), min(ccy, dy)) + maxy := max(max(ay, by), max(ccy, dy)) + + x0, y0 := ax-minx+polyPad, ay-miny+polyPad + x1, y1 := bx-minx+polyPad, by-miny+polyPad + x2, y2 := ccx-minx+polyPad, ccy-miny+polyPad + x3, y3 := dx-minx+polyPad, dy-miny+polyPad + + // A quad folding over itself at the mesh silhouette projects to a + // self-intersecting outline, which both fyne painters mangle (the + // software stroker floods the whole bounding box). Swap a corner + // pair to make the polygon simple again. + if segmentsCross(x0, y0, x1, y1, x2, y2, x3, y3) { + x1, y1, x2, y2 = x2, y2, x1, y1 + } else if segmentsCross(x1, y1, x2, y2, x3, y3, x0, y0) { + x2, y2, x3, y3 = x3, y3, x2, y2 + } + + // Corners of an edge-on cell project onto each other, giving the + // polygon zero-length edges. The GL shader normalize()s the edge + // vectors, so those become NaN and flood the bounding box with + // color. Drop collapsed corners (the quad degrades to a triangle); + // under three distinct corners the cell is invisible anyway. + pts := appendDistinct(poly.Points[:0], [4]fyne.Position{ + {X: x0, Y: y0}, {X: x1, Y: y1}, {X: x2, Y: y2}, {X: x3, Y: y3}, + }) + poly.Points = pts + if len(pts) < 3 { + if !poly.Hidden { + poly.Hide() + } + continue + } + if poly.Hidden { + poly.Show() + } + + // Flat shade: average the four corner colors, then apply the same + // Lambert term the image renderer uses per quad. + shade := m.quadShade(i, j, lx, ly, lz) + fill := avgQuadColor(vertCol[ai], vertCol[bi], vertCol[ci], vertCol[di], shade) + + switch mode { + case RenderModeSolidWireframe: + poly.FillColor = fill + poly.StrokeColor = fadeColor(fill, surfaceEdgeFade) + poly.StrokeWidth = 1 + case RenderModeSolid: + poly.FillColor = fill + poly.StrokeColor = color.Transparent + poly.StrokeWidth = 0 + case RenderModeWireframe: + poly.FillColor = color.Transparent + poly.StrokeColor = fill + poly.StrokeWidth = 1 + } + + // The painter clamps points to the object's own bounds, so size + // the object to the quad's padded bbox and make points relative. + poly.Move(fyne.NewPos(minx-polyPad, miny-polyPad)) + poly.Resize(fyne.NewSize(maxx-minx+2*polyPad, maxy-miny+2*polyPad)) + } + } + m.scratchQuads = quads + + // Back-to-front painter's order, applied by reordering the object list. + slices.SortFunc(quads, func(a, b quadRef) int { + switch { + case a.depth < b.depth: + return -1 + case a.depth > b.depth: + return 1 + default: + return 0 + } + }) + + objs := m.polyObjects[:0] + for _, q := range quads { + objs = append(objs, m.polys[q.i*m.cols+q.j]) + } + m.updateAxisObjects() + for i := range m.axisLines { + objs = append(objs, m.axisLines[i], m.axisLabels[i]) + } + objs = append(objs, m.cursor) + m.polyObjects = objs +} + +// updateAxisObjects mirrors drawAxisIndicator with canvas primitives. +func (m *Meshgrid) updateAxisObjects() { + const indicatorScale = 60.0 + origin := fyne.NewPos(60, m.size.Height-m.size.Height/4) + + r := m.cameraRotation + ends := [3][3]float64{ + r.MultiplyVector([3]float64{indicatorScale, 0, 0}), + r.MultiplyVector([3]float64{0, -indicatorScale, 0}), + r.MultiplyVector([3]float64{0, 0, indicatorScale}), + } + for i, e := range ends { + end := fyne.NewPos(origin.X+float32(e[0]), origin.Y+float32(e[1])) + m.axisLines[i].Position1 = origin + m.axisLines[i].Position2 = end + // Text positions by its top-left; the image path draws at the + // baseline, so nudge up to roughly match. + m.axisLabels[i].Move(fyne.NewPos(end.X+5, end.Y-8)) + } +} + +// minEdgeSq is the squared minimum polygon edge length below which two +// corners count as collapsed onto each other. The GL painter rounds every +// point to whole device pixels before the shader sees it, so corners must +// stay far enough apart that they can't land on the same pixel after +// rounding: distance d keeps the larger coordinate delta ≥ d/√2, which +// survives rounding while d·pixScale > √2 — at d=2 that covers any +// pixScale ≥ 0.75. +const minEdgeSq float32 = 4 + +// appendDistinct appends the corners to dst, skipping ones that collapse +// onto the previously kept corner (including last-onto-first wrap-around), +// so every edge of the resulting polygon has a usable direction vector. +func appendDistinct(dst []fyne.Position, corners [4]fyne.Position) []fyne.Position { + for _, p := range corners { + if len(dst) > 0 { + last := dst[len(dst)-1] + dx, dy := p.X-last.X, p.Y-last.Y + if dx*dx+dy*dy < minEdgeSq { + continue + } + } + dst = append(dst, p) + } + for len(dst) >= 2 { + first, last := dst[0], dst[len(dst)-1] + dx, dy := first.X-last.X, first.Y-last.Y + if dx*dx+dy*dy >= minEdgeSq { + break + } + dst = dst[:len(dst)-1] + } + return dst +} + +// segmentsCross reports whether segments ab and cd properly cross (shared or +// collinear endpoints don't count, which is fine for untwisting quads). +func segmentsCross(ax, ay, bx, by, cx, cy, dx, dy float32) bool { + orient := func(px, py, qx, qy, rx, ry float32) float32 { + return (qx-px)*(ry-py) - (qy-py)*(rx-px) + } + d1 := orient(ax, ay, bx, by, cx, cy) + d2 := orient(ax, ay, bx, by, dx, dy) + d3 := orient(cx, cy, dx, dy, ax, ay) + d4 := orient(cx, cy, dx, dy, bx, by) + return (d1 > 0) != (d2 > 0) && (d3 > 0) != (d4 > 0) +} + +// avgQuadColor averages the four corner colors and applies the flat Lambert +// shade. shade is <= 1 so the components can't overflow. +func avgQuadColor(a, b, c, d color.RGBA, shade float64) color.RGBA { + return color.RGBA{ + R: uint8(float64((int(a.R)+int(b.R)+int(c.R)+int(d.R))>>2) * shade), + G: uint8(float64((int(a.G)+int(b.G)+int(c.G)+int(d.G))>>2) * shade), + B: uint8(float64((int(a.B)+int(b.B)+int(c.B)+int(d.B))>>2) * shade), + A: 255, + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index 992f62a3..b28f197f 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -6,6 +6,7 @@ import ( "testing" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" "github.com/roffe/txlogger/pkg/colors" ) @@ -91,6 +92,139 @@ func BenchmarkDrawSurface(b *testing.B) { } } +// CPU-side cost of a polygon-renderer frame (projection, colors, painter's +// sort, object updates). The GPU draw calls aren't measurable here; compare +// against BenchmarkDrawSurface, which also excludes that path's per-frame +// texture upload. +func BenchmarkUpdatePolygons(b *testing.B) { + m := testGrid(b) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.updatePolygons() + } +} + +// TestPolygonDegenerateQuads sweeps the camera across many angles over a +// surface with flat plateaus (rows of identical values, like real boost maps) +// and asserts every visible polygon is well-formed: at least three corners, +// no near-zero edges (those become NaN in the GL shader's normalize() and +// flood the bounding box with color) and no self-intersecting outlines. +func TestPolygonDegenerateQuads(t *testing.T) { + cols, rows := 16, 16 + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + // Stepped plateaus: blocks of identical values produce coplanar + // cells that collapse to slivers when viewed edge-on. + values[i*cols+j] = float64((i / 4) * 100) + } + } + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + m.renderMode = RenderModeSolidWireframe + + for pitch := 0; pitch < 180; pitch += 15 { + for yaw := 0; yaw < 360; yaw += 15 { + m.cameraRotation = RotationMatrixX(float64(pitch)).Multiply(NewMatrix3x3()).Multiply(RotationMatrixZ(float64(yaw))) + m.updateVertexPositions() + m.updatePolygons() + + for idx, p := range m.polys { + if p.Hidden { + continue + } + pts := p.Points + if len(pts) < 3 { + t.Fatalf("pitch=%d yaw=%d cell %d: visible polygon with %d points", pitch, yaw, idx, len(pts)) + } + for k := range pts { + a, b := pts[k], pts[(k+1)%len(pts)] + dx, dy := b.X-a.X, b.Y-a.Y + if dx*dx+dy*dy < minEdgeSq { + t.Fatalf("pitch=%d yaw=%d cell %d: collapsed edge %d: %v", pitch, yaw, idx, k, pts) + } + } + if len(pts) == 4 { + if segmentsCross(pts[0].X, pts[0].Y, pts[1].X, pts[1].Y, pts[2].X, pts[2].Y, pts[3].X, pts[3].Y) || + segmentsCross(pts[1].X, pts[1].Y, pts[2].X, pts[2].Y, pts[3].X, pts[3].Y, pts[0].X, pts[0].Y) { + t.Fatalf("pitch=%d yaw=%d cell %d: self-intersecting polygon: %v", pitch, yaw, idx, pts) + } + } + } + } + } +} + +func TestAppendDistinct(t *testing.T) { + p := func(x, y float32) fyne.Position { return fyne.NewPos(x, y) } + for _, tc := range []struct { + name string + corners [4]fyne.Position + want int + }{ + {"all distinct", [4]fyne.Position{p(0, 0), p(10, 0), p(10, 10), p(0, 10)}, 4}, + {"one collapsed edge", [4]fyne.Position{p(0, 0), p(10, 0), p(10.1, 0.1), p(0, 10)}, 3}, + {"wrap-around collapse", [4]fyne.Position{p(0, 0), p(10, 0), p(10, 10), p(0.1, 0.1)}, 3}, + {"line", [4]fyne.Position{p(0, 0), p(10, 0), p(10.1, 0), p(0.2, 0.1)}, 2}, + {"point", [4]fyne.Position{p(5, 5), p(5.1, 5), p(5, 5.1), p(5.1, 5.1)}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got := appendDistinct(make([]fyne.Position, 0, 4), tc.corners) + if len(got) != tc.want { + t.Fatalf("got %d points %v, want %d", len(got), got, tc.want) + } + for k := 0; len(got) >= 2 && k < len(got); k++ { + a, b := got[k], got[(k+1)%len(got)] + dx, dy := b.X-a.X, b.Y-a.Y + if dx*dx+dy*dy < minEdgeSq { + t.Fatalf("result has collapsed edge %d: %v", k, got) + } + } + }) + } +} + +// TestPolygonRender captures the polygon renderer through the software +// painter so the output can be eyeballed without a GL window. +func TestPolygonRender(t *testing.T) { + if os.Getenv("MESHGRID_DUMP") == "" { + t.Skip("set MESHGRID_DUMP=1 to dump polygon render PNGs") + } + test.NewApp() + for _, tc := range []struct { + name string + mode RenderMode + }{ + {"solidwire", RenderModeSolidWireframe}, + {"solid", RenderModeSolid}, + {"wireframe", RenderModeWireframe}, + } { + t.Run(tc.name, func(t *testing.T) { + m := testGrid(t) + m.usePolygons = true + m.renderMode = tc.mode + w := test.NewWindow(m) + defer w.Close() + w.Resize(fyne.NewSize(820, 520)) + m.refresh() + img := w.Canvas().Capture() + f, err := os.Create("/tmp/meshgrid_poly_" + tc.name + ".png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + }) + } +} + func TestRenderModes(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 5975e969..6276c1e1 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -5,6 +5,7 @@ import ( "image" "image/color" "log" + "os" "time" "fyne.io/fyne/v2" @@ -47,6 +48,16 @@ type Meshgrid struct { scratchLines []lineSegment scratchQuads []quadRef + // Polygon-renderer experiment (see meshgrid_poly.go): one reusable + // canvas.ArbitraryPolygon per cell instead of rasterizing into an image. + usePolygons bool + polys []*canvas.ArbitraryPolygon + polyObjects []fyne.CanvasObject + axisLines [3]*canvas.Line + axisLabels [3]*canvas.Text + scratchFX []float32 + scratchFY []float32 + renderMode RenderMode lastMouseX, lastMouseY float32 @@ -126,6 +137,10 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int zlabel: zlabel, colorMode: colorBlindMode, + + // Polygon-renderer experiment toggle: set TXLOGGER_MESH_POLY=0 to + // fall back to the image rasterizer for comparison. + usePolygons: os.Getenv("TXLOGGER_MESH_POLY") != "0", } m.createVertices() @@ -160,6 +175,8 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int Hidden: true, } + m.initPolygons() + return m, nil } @@ -404,6 +421,14 @@ func (m *Meshgrid) Refresh() { } func (m *Meshgrid) refresh() { + if m.usePolygons { + // Geometry/color updates on the reusable polygons; the canvas.Refresh + // marks the scene dirty so color-only changes repaint too. + m.updatePolygons() + m.moveCursor() + canvas.Refresh(m) + return + } m.image.Image = m.drawMeshgridLines() m.image.Resize(m.size) m.image.Refresh() @@ -428,6 +453,13 @@ func (m *Meshgrid) throttledRefresh() { } func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { + if m.usePolygons { + // Text measuring needs a driver, so the labels can't be sized in the + // constructor (tests build widgets without an app). + for _, t := range m.axisLabels { + t.Resize(t.MinSize()) + } + } return &meshgridRenderer{MG: m} } @@ -457,6 +489,14 @@ func (m *meshgridRenderer) Destroy() { } func (m *meshgridRenderer) Objects() []fyne.CanvasObject { + if m.MG.usePolygons { + // updatePolygons rebuilds the list in painter's order every frame; + // populate it here for the first paint. + if len(m.MG.polyObjects) == 0 { + m.MG.updatePolygons() + } + return m.MG.polyObjects + } if m.objects == nil { m.objects = []fyne.CanvasObject{m.MG.image, m.MG.cursor} } From 99fcc2112c377d1c7404a66bb6d756f590231444 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 21:34:28 +0200 Subject: [PATCH 27/93] meshgrid shader --- pkg/widgets/meshgrid/meshgrid_axis.go | 41 ++ pkg/widgets/meshgrid/meshgrid_poly.go | 41 +- pkg/widgets/meshgrid/meshgrid_render_test.go | 11 +- pkg/widgets/meshgrid/meshgrid_shader.go | 497 +++++++++++++++++++ pkg/widgets/meshgrid/meshgrid_shader_test.go | 140 ++++++ pkg/widgets/meshgrid/meshgrid_widget.go | 112 ++++- 6 files changed, 780 insertions(+), 62 deletions(-) create mode 100644 pkg/widgets/meshgrid/meshgrid_shader.go create mode 100644 pkg/widgets/meshgrid/meshgrid_shader_test.go diff --git a/pkg/widgets/meshgrid/meshgrid_axis.go b/pkg/widgets/meshgrid/meshgrid_axis.go index 7c79d19d..bcb8c626 100644 --- a/pkg/widgets/meshgrid/meshgrid_axis.go +++ b/pkg/widgets/meshgrid/meshgrid_axis.go @@ -4,11 +4,52 @@ import ( "image" "image/color" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" ) +// initAxisObjects creates the axis-indicator overlay (lines and labels) used +// by the shader and polygon backends; the image backend instead draws the +// indicator into its raster (drawAxisIndicator below). +func (m *Meshgrid) initAxisObjects() { + axisColors := [3]color.RGBA{ + {R: 255, A: 255}, // X red + {G: 255, A: 255}, // Y green + {B: 255, A: 255}, // Z blue + } + labels := [3]string{m.xlabel, m.ylabel, m.zlabel} + for i := range m.axisLines { + m.axisLines[i] = &canvas.Line{StrokeColor: axisColors[i], StrokeWidth: 1} + t := canvas.NewText(labels[i], axisColors[i]) + t.TextSize = 11 + m.axisLabels[i] = t + } +} + +// updateAxisObjects mirrors drawAxisIndicator with canvas primitives. +func (m *Meshgrid) updateAxisObjects() { + const indicatorScale = 60.0 + origin := fyne.NewPos(60, m.size.Height-m.size.Height/4) + + r := m.cameraRotation + ends := [3][3]float64{ + r.MultiplyVector([3]float64{indicatorScale, 0, 0}), + r.MultiplyVector([3]float64{0, -indicatorScale, 0}), + r.MultiplyVector([3]float64{0, 0, indicatorScale}), + } + for i, e := range ends { + end := fyne.NewPos(origin.X+float32(e[0]), origin.Y+float32(e[1])) + m.axisLines[i].Position1 = origin + m.axisLines[i].Position2 = end + // Text positions by its top-left; the image path draws at the + // baseline, so nudge up to roughly match. + m.axisLabels[i].Move(fyne.NewPos(end.X+5, end.Y-8)) + } +} + func (m *Meshgrid) drawAxisIndicator(img *image.RGBA) { cornerOffset := 60.0 indicatorScale := 60.0 diff --git a/pkg/widgets/meshgrid/meshgrid_poly.go b/pkg/widgets/meshgrid/meshgrid_poly.go index fc4f69bc..709d8d95 100644 --- a/pkg/widgets/meshgrid/meshgrid_poly.go +++ b/pkg/widgets/meshgrid/meshgrid_poly.go @@ -28,28 +28,14 @@ import ( // clamps points to the object's own size). const polyPad float32 = 1.5 -// initPolygons creates the reusable canvas objects: one polygon per data cell -// plus the axis-indicator lines and labels. Geometry and colors are filled in -// by updatePolygons; nothing here needs a driver, so it is safe to call from -// the constructor (and from tests without an app). +// initPolygons creates the reusable cell polygons. Geometry and colors are +// filled in by updatePolygons; nothing here needs a driver, so it is safe to +// call from the constructor (and from tests without an app). func (m *Meshgrid) initPolygons() { m.polys = make([]*canvas.ArbitraryPolygon, m.rows*m.cols) for i := range m.polys { m.polys[i] = &canvas.ArbitraryPolygon{Points: make([]fyne.Position, 4)} } - - axisColors := [3]color.RGBA{ - {R: 255, A: 255}, // X red - {G: 255, A: 255}, // Y green - {B: 255, A: 255}, // Z blue - } - labels := [3]string{m.xlabel, m.ylabel, m.zlabel} - for i := range m.axisLines { - m.axisLines[i] = &canvas.Line{StrokeColor: axisColors[i], StrokeWidth: 1} - t := canvas.NewText(labels[i], axisColors[i]) - t.TextSize = 11 - m.axisLabels[i] = t - } } // updatePolygons projects the mesh and updates the reusable canvas objects: @@ -228,27 +214,6 @@ func (m *Meshgrid) updatePolygons() { m.polyObjects = objs } -// updateAxisObjects mirrors drawAxisIndicator with canvas primitives. -func (m *Meshgrid) updateAxisObjects() { - const indicatorScale = 60.0 - origin := fyne.NewPos(60, m.size.Height-m.size.Height/4) - - r := m.cameraRotation - ends := [3][3]float64{ - r.MultiplyVector([3]float64{indicatorScale, 0, 0}), - r.MultiplyVector([3]float64{0, -indicatorScale, 0}), - r.MultiplyVector([3]float64{0, 0, indicatorScale}), - } - for i, e := range ends { - end := fyne.NewPos(origin.X+float32(e[0]), origin.Y+float32(e[1])) - m.axisLines[i].Position1 = origin - m.axisLines[i].Position2 = end - // Text positions by its top-left; the image path draws at the - // baseline, so nudge up to roughly match. - m.axisLabels[i].Move(fyne.NewPos(end.X+5, end.Y-8)) - } -} - // minEdgeSq is the squared minimum polygon edge length below which two // corners count as collapsed onto each other. The GL painter rounds every // point to whole device pixels before the shader sees it, so corners must diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index b28f197f..56524fb6 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -92,12 +92,20 @@ func BenchmarkDrawSurface(b *testing.B) { } } +// usePolyBackend switches a test grid to the polygon renderer (the default +// is the shader backend, whose per-frame work happens on the GPU). +func usePolyBackend(m *Meshgrid) { + m.backend = backendPolygons + m.initPolygons() +} + // CPU-side cost of a polygon-renderer frame (projection, colors, painter's // sort, object updates). The GPU draw calls aren't measurable here; compare // against BenchmarkDrawSurface, which also excludes that path's per-frame // texture upload. func BenchmarkUpdatePolygons(b *testing.B) { m := testGrid(b) + usePolyBackend(m) m.renderMode = RenderModeSolidWireframe b.ReportAllocs() b.ResetTimer() @@ -126,6 +134,7 @@ func TestPolygonDegenerateQuads(t *testing.T) { t.Fatal(err) } m.size = fyne.NewSize(800, 500) + usePolyBackend(m) m.renderMode = RenderModeSolidWireframe for pitch := 0; pitch < 180; pitch += 15 { @@ -206,7 +215,7 @@ func TestPolygonRender(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { m := testGrid(t) - m.usePolygons = true + usePolyBackend(m) m.renderMode = tc.mode w := test.NewWindow(m) defer w.Close() diff --git a/pkg/widgets/meshgrid/meshgrid_shader.go b/pkg/widgets/meshgrid/meshgrid_shader.go new file mode 100644 index 00000000..79f0e7b9 --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_shader.go @@ -0,0 +1,497 @@ +package meshgrid + +import ( + "fmt" + "image" + "image/color" + "math" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" + "github.com/roffe/txlogger/pkg/colors" +) + +// GPU renderer: the whole mesh is drawn by a single canvas.Shader. The corner +// values live in a small data texture and the camera in a handful of float +// uniforms; the fragment shader reconstructs each pixel's orthographic view +// ray, walks the grid with a 2D DDA and intersects the two triangles of each +// visited cell. Rotating, zooming and panning therefore cost the CPU nothing +// but a uniform update - no projection, sorting or rasterization per frame - +// and a data edit re-uploads only the (cols+1)x(rows+1) texture. +// +// The grid-space conventions shared between the Go side and the GLSL below: +// - one grid unit = one cell = cellWidth (32) logical px before scaling +// - corner (vertex row i, col j) sits at (j, rows-i); +Y is the low-index +// data rows, exactly like the Oy = (vRows-i)*cellHeight CPU layout +// - corner height = z_off + z_gain * value16, reproducing +// Oz = (V - zmin)/zrange * depth in grid units +// - view = R * ((grid - center) * scale_px) - cam; the viewer sits at +Z +// looking along -Z, so the nearest surface has the largest view Z + +// shaderSeq makes each widget's Shader.Name unique: the painter caches both +// the compiled program and the bound textures per name, so two open map +// windows sharing a name would evict each other's data texture every frame. +var shaderSeq atomic.Int64 + +const meshShaderPreludeGL = "#version 110\n" + +const meshShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const meshShaderBody = ` +#define MAX_STEPS 160 + +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform sampler2D mesh_tex; // (cols+1)x(rows+1) corner values, 16 bit in RG +uniform sampler2D colormap_tex; // 256x1 value -> base color lookup + +// camera rotation R, row major; view = R * model +uniform float r0; +uniform float r1; +uniform float r2; +uniform float r3; +uniform float r4; +uniform float r5; +uniform float r6; +uniform float r7; +uniform float r8; + +uniform float grid_cols; // cells in X +uniform float grid_rows; // cells in Y +uniform float scale_px; // logical px per grid unit +uniform float height_units; // full value range height, grid units +uniform float z_off; // corner height = z_off + z_gain * value16 +uniform float z_gain; +uniform float center_gx; // mesh center, grid units +uniform float center_gy; +uniform float center_gz; +uniform float cam_x; // camera pan, logical px +uniform float cam_y; +uniform float size_w; // widget size, logical px +uniform float size_h; +uniform float view_zmin; // view-space depth extent of the mesh, logical px +uniform float view_zrange; +uniform float render_mode; // 0 solid+wire, 1 solid, 2 wireframe +uniform float light_x; // light direction in model space, unit length +uniform float light_y; +uniform float light_z; + +const float BIG = 100000.0; + +float corner_height(float gx, float gy) { + vec2 uv = vec2((gx + 0.5) / (grid_cols + 1.0), (gy + 0.5) / (grid_rows + 1.0)); + vec4 t = texture2D(mesh_tex, uv); + return z_off + z_gain * (t.r * 65280.0 + t.g * 255.0) / 65535.0; +} + +// narrow the line/AABB overlap [umin, umax] by one slab +void slab(float o, float d, float lo, float hi, inout float umin, inout float umax) { + if (abs(d) < 0.00000001) { + if (o < lo || o > hi) { + umin = BIG; + umax = -BIG; + } + return; + } + float t1 = (lo - o) / d; + float t2 = (hi - o) / d; + if (t1 > t2) { + float tmp = t1; + t1 = t2; + t2 = tmp; + } + umin = max(umin, t1); + umax = min(umax, t2); +} + +// Moeller-Trumbore; on a hit t is the ray parameter and bary the (a, b, c) weights +bool ray_tri(vec3 ro, vec3 rd, vec3 a, vec3 b, vec3 c, out float t, out vec3 bary) { + t = 0.0; + bary = vec3(0.0); + vec3 e1 = b - a; + vec3 e2 = c - a; + vec3 pv = cross(rd, e2); + float det = dot(e1, pv); + if (abs(det) < 0.0000000001) { + return false; + } + float inv_det = 1.0 / det; + vec3 tv = ro - a; + float u = dot(tv, pv) * inv_det; + if (u < -0.0001 || u > 1.0001) { + return false; + } + vec3 qv = cross(tv, e1); + float v = dot(rd, qv) * inv_det; + if (v < -0.0001 || u + v > 1.0001) { + return false; + } + t = dot(e2, qv) * inv_det; + bary = vec3(1.0 - u - v, u, v); + return true; +} + +// grid point -> (device px x, device px y, view-space z in logical px) +vec3 project_grid(mat3 rot, vec3 g, float pix_scale) { + vec3 v = rot * ((g - vec3(center_gx, center_gy, center_gz)) * scale_px) - vec3(cam_x, cam_y, 0.0); + return vec3((v.xy + 0.5 * vec2(size_w, size_h)) * pix_scale, v.z); +} + +// value color with the depth shading of getColorWithDepth; h in grid units +vec3 height_color(float h, float view_z) { + float val = clamp(h / height_units, 0.0, 1.0); + vec4 base = texture2D(colormap_tex, vec2(val * 0.99609375 + 0.001953125, 0.5)); + float df = clamp((view_z - view_zmin) / view_zrange, 0.0, 1.0); + vec3 rgb = base.rgb * (0.6 + 0.4 * df); + rgb.b = min(1.0, rgb.b + (1.0 - df) * 0.05882353); + if (base.r > 0.784 && base.g > 0.784 && base.b < 0.196) { + rgb.r = min(1.0, rgb.r * 1.1); + rgb.g = min(1.0, rgb.g * 1.1); + } + return rgb; +} + +// closest-point parameter of p on segment a-b +float seg_param(vec2 p, vec2 a, vec2 b) { + vec2 e = b - a; + float ee = dot(e, e); + if (ee < 0.000001) { + return 0.0; + } + return clamp(dot(p - a, e) / ee, 0.0, 1.0); +} + +// anti-aliased coverage of a line of the given half width at distance d +float line_mask(float d, float half_w) { + return 1.0 - smoothstep(half_w - 0.6, half_w + 0.6, d); +} + +// front-to-back "under" compositing of one wireframe segment +void wire_seg(vec2 p_dev, mat3 rot, float pix_scale, float half_w, vec3 a, vec3 b, float fade, inout vec3 acc, inout float acc_a) { + vec3 pa = project_grid(rot, a, pix_scale); + vec3 pb = project_grid(rot, b, pix_scale); + float h = seg_param(p_dev, pa.xy, pb.xy); + float d = distance(p_dev, mix(pa.xy, pb.xy, h)); + float mask = line_mask(d, half_w); + if (mask <= 0.0) { + return; + } + vec3 rgb = height_color(mix(a.z, b.z, h), mix(pa.z, pb.z, h)) * fade; + acc += (1.0 - acc_a) * mask * rgb; + acc_a += (1.0 - acc_a) * mask; +} + +// track the nearest cell border for the solid+wireframe grid lines +void edge_check(vec2 p_dev, vec3 pa, vec3 pb, float ha, float hb, inout float best_d, inout float best_h, inout float best_z) { + float t = seg_param(p_dev, pa.xy, pb.xy); + float d = distance(p_dev, mix(pa.xy, pb.xy, t)); + if (d < best_d) { + best_d = d; + best_h = mix(ha, hb, t); + best_z = mix(pa.z, pb.z, t); + } +} + +void main() { + mat3 rot = mat3(r0, r3, r6, r1, r4, r7, r2, r5, r8); + + float pix_scale = (rect_coords.y - rect_coords.x) / max(size_w, 1.0); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > rect_coords.y - rect_coords.x || p_dev.y > rect_coords.w - rect_coords.z) { + discard; + } + + vec2 view_xy = p_dev / pix_scale - 0.5 * vec2(size_w, size_h) + vec2(cam_x, cam_y); + + // pixel ray in grid space: g(u) = g0 + u * dg with u the view-space + // depth; g0 = transpose(R) * (view_xy, 0) / scale + center + vec3 g0 = vec3( + (r0 * view_xy.x + r3 * view_xy.y) / scale_px + center_gx, + (r1 * view_xy.x + r4 * view_xy.y) / scale_px + center_gy, + (r2 * view_xy.x + r5 * view_xy.y) / scale_px + center_gz); + vec3 dg = vec3(r6, r7, r8) / scale_px; + + float z_lo = min(z_off, z_off + z_gain) - 0.05; + float z_hi = max(z_off, z_off + z_gain) + 0.05; + + float umin = -BIG; + float umax = BIG; + slab(g0.x, dg.x, 0.0, grid_cols, umin, umax); + slab(g0.y, dg.y, 0.0, grid_rows, umin, umax); + slab(g0.z, dg.z, z_lo, z_hi, umin, umax); + if (umax <= umin) { + discard; + } + + // march from the near side (largest view z) toward the far side + vec3 ro = g0 + umax * dg; + vec3 rd = -dg; + float tend = umax - umin; + ro += rd * 0.0001; + + float cx = clamp(floor(ro.x), 0.0, grid_cols - 1.0); + float cy = clamp(floor(ro.y), 0.0, grid_rows - 1.0); + + float step_x = rd.x > 0.0 ? 1.0 : -1.0; + float step_y = rd.y > 0.0 ? 1.0 : -1.0; + float td_x = abs(rd.x) < 0.00000001 ? BIG : 1.0 / abs(rd.x); + float td_y = abs(rd.y) < 0.00000001 ? BIG : 1.0 / abs(rd.y); + float tm_x = abs(rd.x) < 0.00000001 ? BIG : (rd.x > 0.0 ? cx + 1.0 - ro.x : ro.x - cx) / abs(rd.x); + float tm_y = abs(rd.y) < 0.00000001 ? BIG : (rd.y > 0.0 ? cy + 1.0 - ro.y : ro.y - cy) / abs(rd.y); + + int mode = int(render_mode + 0.5); + float half_w = 0.5 * pix_scale; + vec3 light = vec3(light_x, light_y, light_z); + + vec3 acc = vec3(0.0); + float acc_a = 0.0; + + for (int i = 0; i < MAX_STEPS; i++) { + float h_bl = corner_height(cx, cy); + float h_br = corner_height(cx + 1.0, cy); + float h_tl = corner_height(cx, cy + 1.0); + float h_tr = corner_height(cx + 1.0, cy + 1.0); + + // cell corners; the fill splits along A-C like the CPU rasterizer + // while the wireframe diagonal runs B-D like the CPU line mesh + vec3 A = vec3(cx, cy + 1.0, h_tl); + vec3 B = vec3(cx + 1.0, cy + 1.0, h_tr); + vec3 C = vec3(cx + 1.0, cy, h_br); + vec3 D = vec3(cx, cy, h_bl); + + if (mode == 2) { + wire_seg(p_dev, rot, pix_scale, half_w, A, B, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, B, C, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, C, D, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, D, A, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, B, D, 0.7, acc, acc_a); + if (acc_a > 0.995) { + break; + } + } else { + float t1; + vec3 bc1; + float t2; + vec3 bc2; + bool hit1 = ray_tri(ro, rd, A, B, C, t1, bc1); + bool hit2 = ray_tri(ro, rd, A, C, D, t2, bc2); + float best_t = BIG; + float hit_h = 0.0; + if (hit1) { + best_t = t1; + hit_h = bc1.x * A.z + bc1.y * B.z + bc1.z * C.z; + } + if (hit2 && t2 < best_t) { + best_t = t2; + hit_h = bc2.x * A.z + bc2.y * C.z + bc2.z * D.z; + } + if (best_t < BIG) { + float view_z = umax - best_t; + vec3 rgb = height_color(hit_h, view_z); + + vec3 n = cross(C - A, D - B); + float nl = length(n); + if (nl > 0.0) { + rgb *= 0.6 + 0.4 * abs(dot(n / nl, light)); + } + + if (mode == 0) { + vec3 pa = project_grid(rot, A, pix_scale); + vec3 pb = project_grid(rot, B, pix_scale); + vec3 pc = project_grid(rot, C, pix_scale); + vec3 pd = project_grid(rot, D, pix_scale); + float best_d = BIG; + float line_h = 0.0; + float line_z = 0.0; + edge_check(p_dev, pa, pb, A.z, B.z, best_d, line_h, line_z); + edge_check(p_dev, pb, pc, B.z, C.z, best_d, line_h, line_z); + edge_check(p_dev, pc, pd, C.z, D.z, best_d, line_h, line_z); + edge_check(p_dev, pd, pa, D.z, A.z, best_d, line_h, line_z); + float lm = line_mask(best_d, half_w); + if (lm > 0.0) { + rgb = mix(rgb, height_color(line_h, line_z) * 0.45, lm); + } + } + + gl_FragColor = vec4(rgb, 1.0); + return; + } + } + + if (min(tm_x, tm_y) >= tend) { + break; + } + if (tm_x < tm_y) { + cx += step_x; + tm_x += td_x; + } else { + cy += step_y; + tm_y += td_y; + } + if (cx < -0.5 || cx > grid_cols - 0.5 || cy < -0.5 || cy > grid_rows - 0.5) { + break; + } + } + + if (mode == 2 && acc_a > 0.003) { + gl_FragColor = vec4(acc / acc_a, acc_a); + return; + } + discard; +} +` + +func (m *Meshgrid) initShader() { + m.shader = canvas.NewShader( + fmt.Sprintf("meshgrid-%d", shaderSeq.Add(1)), + []byte(meshShaderPreludeGL+meshShaderBody), + []byte(meshShaderPreludeES+meshShaderBody), + ) + m.shader.Textures = make(map[string]image.Image, 2) + m.shader.Uniforms = make(map[string]float32, 32) + m.updateShaderData() + m.updateShaderColormap() + m.updateShaderUniforms() +} + +// updateShaderData re-encodes the corner values into the mesh data texture +// and the height-mapping uniforms. A fresh image is allocated on purpose: +// the painter re-uploads a texture only when the map entry points at a new +// image. +func (m *Meshgrid) updateShaderData() { + if m.shader == nil { + return + } + vRows, vCols := m.rows+1, m.cols+1 + + // Values are normalized against the actual data extent so the 16-bit + // quantization keeps full resolution even when zmin/zmax (set by + // LoadFloat64s) span a wider range than the data. + dataMin, dataMax := math.Inf(1), math.Inf(-1) + for i := range m.vertices { + row := m.vertices[i] + for j := range row { + v := row[j].V + if v < dataMin { + dataMin = v + } + if v > dataMax { + dataMax = v + } + } + } + dataRange := dataMax - dataMin + + img := image.NewRGBA(image.Rect(0, 0, vCols, vRows)) + for i := 0; i < vRows; i++ { + row := m.vertices[i] + for j := 0; j < vCols; j++ { + norm := 0.0 + if dataRange > 0 { + norm = (row[j].V - dataMin) / dataRange + } + q := uint16(norm*65535 + 0.5) + // vertex row i sits at grid Y rows-i (data row 0 is the far + // edge), which is also its texture row + img.SetRGBA(j, m.rows-i, color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff}) + } + } + m.shader.Textures["mesh_tex"] = img + + // Heights in grid units: z_off + z_gain*norm reproduces the CPU + // Oz = (V - zmin)/zrange * depth, in units of one cell. + zr := m.zrange + if zr == 0 { + zr = 1 + } + hmax := m.depth / float64(m.cellWidth) + m.shader.Uniforms["z_off"] = float32((dataMin - m.zmin) / zr * hmax) + m.shader.Uniforms["z_gain"] = float32(dataRange / zr * hmax) +} + +// updateShaderColormap rebuilds the 256x1 value-to-color lookup texture. +func (m *Meshgrid) updateShaderColormap() { + if m.shader == nil { + return + } + lut := image.NewRGBA(image.Rect(0, 0, 256, 1)) + for k := 0; k < 256; k++ { + var c color.RGBA + if m.zrange == 0 { + // GetColorInterpolation resolves 0/0 to gray; match it + c = color.RGBA{R: 128, G: 128, B: 128, A: 255} + } else { + c = colors.GetColorInterpolation(0, 255, float64(k), m.colorMode) + } + lut.SetRGBA(k, 0, c) + } + m.shader.Textures["colormap_tex"] = lut +} + +// updateShaderUniforms pushes the camera and shading state; this is the whole +// per-frame CPU cost of the shader backend. +func (m *Meshgrid) updateShaderUniforms() { + if m.shader == nil { + return + } + + // View-space depth extent of the transformed mesh, for the same + // depth-shading ramp the CPU rasterizer applies. + minZ, maxZ := math.Inf(1), math.Inf(-1) + for i := range m.vertices { + row := m.vertices[i] + for j := range row { + z := row[j].Z + if z < minZ { + minZ = z + } + if z > maxZ { + maxZ = z + } + } + } + zRange := maxZ - minZ + if zRange == 0 { + zRange = 1 + } + + // Fixed view-space light from drawSurface, moved to model space so the + // shader can shade with grid-space normals directly. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + r := m.cameraRotation + cw := float64(m.cellWidth) + + u := m.shader.Uniforms + u["r0"], u["r1"], u["r2"] = float32(r[0][0]), float32(r[0][1]), float32(r[0][2]) + u["r3"], u["r4"], u["r5"] = float32(r[1][0]), float32(r[1][1]), float32(r[1][2]) + u["r6"], u["r7"], u["r8"] = float32(r[2][0]), float32(r[2][1]), float32(r[2][2]) + u["grid_cols"] = float32(m.cols) + u["grid_rows"] = float32(m.rows) + u["scale_px"] = float32(cw * m.scale) + u["height_units"] = float32(m.depth / cw) + u["center_gx"] = float32(m.centerX / cw) + u["center_gy"] = float32(m.centerY/cw - 1) + u["center_gz"] = float32(m.centerZ / cw) + u["cam_x"] = float32(m.cameraPosition[0]) + u["cam_y"] = float32(m.cameraPosition[1]) + u["size_w"] = float32(m.size.Width) + u["size_h"] = float32(m.size.Height) + u["view_zmin"] = float32(minZ) + u["view_zrange"] = float32(zRange) + u["render_mode"] = float32(m.renderMode) + u["light_x"] = float32(r[0][0]*lx + r[1][0]*ly + r[2][0]*lz) + u["light_y"] = float32(r[0][1]*lx + r[1][1]*ly + r[2][1]*lz) + u["light_z"] = float32(r[0][2]*lx + r[1][2]*ly + r[2][2]*lz) +} diff --git a/pkg/widgets/meshgrid/meshgrid_shader_test.go b/pkg/widgets/meshgrid/meshgrid_shader_test.go new file mode 100644 index 00000000..493472fd --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_shader_test.go @@ -0,0 +1,140 @@ +package meshgrid + +import ( + "image" + "math" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/roffe/txlogger/pkg/colors" +) + +// The mesh data texture plus z_off/z_gain must reproduce every corner height +// (Oz in grid units) and the colormap index the CPU renderer would use. +func TestShaderDataEncoding(t *testing.T) { + m := testGrid(t) + tex, ok := m.shader.Textures["mesh_tex"].(*image.RGBA) + if !ok { + t.Fatal("mesh_tex missing or not RGBA") + } + if tex.Bounds().Dx() != m.cols+1 || tex.Bounds().Dy() != m.rows+1 { + t.Fatalf("mesh_tex is %v, want %dx%d", tex.Bounds(), m.cols+1, m.rows+1) + } + + zOff := float64(m.shader.Uniforms["z_off"]) + zGain := float64(m.shader.Uniforms["z_gain"]) + cw := float64(m.cellWidth) + + for i := 0; i <= m.rows; i++ { + for j := 0; j <= m.cols; j++ { + // vertex row i lives at texture row rows-i (grid Y up) + c := tex.RGBAAt(j, m.rows-i) + norm := (float64(c.R)*256 + float64(c.G)) / 65535 + gotH := zOff + zGain*norm + wantH := m.vertices[i][j].Oz / cw + // 16-bit quantization of a ~12.5 unit range + if math.Abs(gotH-wantH) > zGain/65535+1e-6 { + t.Fatalf("corner (%d,%d): height %v, want %v", i, j, gotH, wantH) + } + } + } +} + +func TestShaderColormap(t *testing.T) { + m := testGrid(t) + lut, ok := m.shader.Textures["colormap_tex"].(*image.RGBA) + if !ok { + t.Fatal("colormap_tex missing or not RGBA") + } + if lut.Bounds().Dx() != 256 || lut.Bounds().Dy() != 1 { + t.Fatalf("colormap_tex is %v, want 256x1", lut.Bounds()) + } + for k := 0; k < 256; k++ { + want := colors.GetColorInterpolation(0, 255, float64(k), m.colorMode) + if got := lut.RGBAAt(k, 0); got != want { + t.Fatalf("lut[%d] = %v, want %v", k, got, want) + } + } +} + +// The shader projects grid points with +// +// view = R*((g - center)*scale_px) - cam; screen = view.xy + size/2 +// +// which must land on the same screen position as the CPU-transformed +// vertices, for any camera. Otherwise the mesh and the cursor/axis overlays +// drift apart. +func TestShaderProjectionMatchesVertices(t *testing.T) { + m := testGrid(t) + m.orbit(23, -41) + m.panMeshgrid(15, -7) + m.scaleMeshgrid(1.3) + m.updateShaderUniforms() + + u := m.shader.Uniforms + r := [3][3]float64{ + {float64(u["r0"]), float64(u["r1"]), float64(u["r2"])}, + {float64(u["r3"]), float64(u["r4"]), float64(u["r5"])}, + {float64(u["r6"]), float64(u["r7"]), float64(u["r8"])}, + } + cg := [3]float64{float64(u["center_gx"]), float64(u["center_gy"]), float64(u["center_gz"])} + scalePx := float64(u["scale_px"]) + cw := float64(m.cellWidth) + + for i := 0; i <= m.rows; i += 4 { + for j := 0; j <= m.cols; j += 4 { + v := m.vertices[i][j] + g := [3]float64{float64(j), float64(m.rows - i), v.Oz / cw} + + var view [3]float64 + for a := 0; a < 3; a++ { + view[a] = r[a][0]*(g[0]-cg[0])*scalePx + + r[a][1]*(g[1]-cg[1])*scalePx + + r[a][2]*(g[2]-cg[2])*scalePx + } + view[0] -= float64(u["cam_x"]) + view[1] -= float64(u["cam_y"]) + + if math.Abs(view[0]-v.X) > 1e-3 || math.Abs(view[1]-v.Y) > 1e-3 || math.Abs(view[2]-v.Z) > 1e-3 { + t.Fatalf("vertex (%d,%d): shader view (%v,%v,%v), CPU view (%v,%v,%v)", + i, j, view[0], view[1], view[2], v.X, v.Y, v.Z) + } + } + } +} + +// CPU-side cost of a shader-backend frame: this plus the axis/cursor overlay +// moves is everything the CPU does per rotation/zoom frame. Compare against +// BenchmarkDrawSurface (image backend) and BenchmarkUpdatePolygons. +func BenchmarkUpdateShaderUniforms(b *testing.B) { + m := testGrid(b) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.updateShaderUniforms() + } +} + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": meshShaderPreludeGL + meshShaderBody, + "es.frag": meshShaderPreludeES + meshShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 6276c1e1..2fc4a904 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -26,6 +26,33 @@ type Vertex struct { var _ fyne.Widget = (*Meshgrid)(nil) +// renderBackend selects how the mesh is drawn. The GPU shader is the +// default; TXLOGGER_MESH_RENDERER=poly|image selects the older paths. +type renderBackend int + +const ( + // backendShader ray-casts the whole mesh in one fragment shader + // (meshgrid_shader.go); per-frame CPU cost is a uniform update. + backendShader renderBackend = iota + // backendPolygons draws one canvas.ArbitraryPolygon per cell + // (meshgrid_poly.go). + backendPolygons + // backendImage rasterizes on the CPU into a canvas.Image + // (meshgrid_draw.go). + backendImage +) + +func backendFromEnv() renderBackend { + switch os.Getenv("TXLOGGER_MESH_RENDERER") { + case "poly": + return backendPolygons + case "image": + return backendImage + default: + return backendShader + } +} + type Meshgrid struct { widget.BaseWidget @@ -48,16 +75,23 @@ type Meshgrid struct { scratchLines []lineSegment scratchQuads []quadRef - // Polygon-renderer experiment (see meshgrid_poly.go): one reusable + backend renderBackend + + // Shader backend (meshgrid_shader.go): the whole mesh in one object. + shader *canvas.Shader + + // Polygon backend (meshgrid_poly.go): one reusable // canvas.ArbitraryPolygon per cell instead of rasterizing into an image. - usePolygons bool polys []*canvas.ArbitraryPolygon polyObjects []fyne.CanvasObject - axisLines [3]*canvas.Line - axisLabels [3]*canvas.Text scratchFX []float32 scratchFY []float32 + // Axis-indicator overlay shared by the shader and polygon backends (the + // image backend draws its own into the raster). + axisLines [3]*canvas.Line + axisLabels [3]*canvas.Text + renderMode RenderMode lastMouseX, lastMouseY float32 @@ -138,9 +172,7 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int colorMode: colorBlindMode, - // Polygon-renderer experiment toggle: set TXLOGGER_MESH_POLY=0 to - // fall back to the image rasterizer for comparison. - usePolygons: os.Getenv("TXLOGGER_MESH_POLY") != "0", + backend: backendFromEnv(), } m.createVertices() @@ -175,7 +207,13 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int Hidden: true, } - m.initPolygons() + m.initAxisObjects() + switch m.backend { + case backendShader: + m.initShader() + case backendPolygons: + m.initPolygons() + } return m, nil } @@ -202,6 +240,7 @@ func (m *Meshgrid) CycleRenderMode() { func (m *Meshgrid) SetColorBlindMode(mode colors.ColorBlindMode) { if m.colorMode != mode { m.colorMode = mode + m.updateShaderColormap() } m.refresh() } @@ -345,6 +384,7 @@ func (m *Meshgrid) SetFloat64(idx int, value float64) { m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) m.createVertices() m.updateVertexPositions() + m.updateShaderData() m.refresh() } @@ -362,6 +402,7 @@ func (m *Meshgrid) LoadFloat64s(min, max float64, floats []float64) { m.createVertices() m.updateVertexPositions() + m.updateShaderData() m.refresh() } @@ -421,20 +462,28 @@ func (m *Meshgrid) Refresh() { } func (m *Meshgrid) refresh() { - if m.usePolygons { + switch m.backend { + case backendShader: + // All per-frame state lives in shader uniforms; the GPU re-renders + // from them on the next paint. Only the overlays move on the CPU. + m.updateShaderUniforms() + m.updateAxisObjects() + m.moveCursor() + canvas.Refresh(m) + case backendPolygons: // Geometry/color updates on the reusable polygons; the canvas.Refresh // marks the scene dirty so color-only changes repaint too. m.updatePolygons() m.moveCursor() canvas.Refresh(m) - return + default: + m.image.Image = m.drawMeshgridLines() + m.image.Resize(m.size) + m.image.Refresh() + // Rotation, scale, resize and data changes all move the projected + // surface point under the marker. + m.moveCursor() } - m.image.Image = m.drawMeshgridLines() - m.image.Resize(m.size) - m.image.Refresh() - // Rotation, scale, resize and data changes all move the projected - // surface point under the marker. - m.moveCursor() } func (m *Meshgrid) throttledRefresh() { @@ -453,7 +502,7 @@ func (m *Meshgrid) throttledRefresh() { } func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { - if m.usePolygons { + if m.backend != backendImage { // Text measuring needs a driver, so the labels can't be sized in the // constructor (tests build widgets without an app). for _, t := range m.axisLabels { @@ -473,7 +522,12 @@ func (m *meshgridRenderer) Layout(size fyne.Size) { return } m.MG.size = size - m.MG.image.Resize(size) + switch m.MG.backend { + case backendShader: + m.MG.shader.Resize(size) + case backendImage: + m.MG.image.Resize(size) + } m.MG.throttledRefresh() } @@ -489,16 +543,28 @@ func (m *meshgridRenderer) Destroy() { } func (m *meshgridRenderer) Objects() []fyne.CanvasObject { - if m.MG.usePolygons { + switch m.MG.backend { + case backendShader: + if m.objects == nil { + m.MG.updateAxisObjects() + objs := []fyne.CanvasObject{m.MG.shader} + for i := range m.MG.axisLines { + objs = append(objs, m.MG.axisLines[i], m.MG.axisLabels[i]) + } + m.objects = append(objs, m.MG.cursor) + } + return m.objects + case backendPolygons: // updatePolygons rebuilds the list in painter's order every frame; // populate it here for the first paint. if len(m.MG.polyObjects) == 0 { m.MG.updatePolygons() } return m.MG.polyObjects + default: + if m.objects == nil { + m.objects = []fyne.CanvasObject{m.MG.image, m.MG.cursor} + } + return m.objects } - if m.objects == nil { - m.objects = []fyne.CanvasObject{m.MG.image, m.MG.cursor} - } - return m.objects } From 44243f70a990fdb844e449067242277650a65e41 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 22:29:16 +0200 Subject: [PATCH 28/93] resolve issue with wbledit object cache --- pkg/widgets/settings/wbleditor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/widgets/settings/wbleditor.go b/pkg/widgets/settings/wbleditor.go index 78a25a8d..ddcf3f31 100644 --- a/pkg/widgets/settings/wbleditor.go +++ b/pkg/widgets/settings/wbleditor.go @@ -555,6 +555,9 @@ func (r *graphRenderer) rebuild() { for i, row := range rows { r.points[i].row = row } + + // dataLines/points may have changed length; force Objects() to rebuild. + r.objects = nil } func (r *graphRenderer) Layout(size fyne.Size) { From 9f78467fd2673240b8a8c36882206652f30029d7 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 22:30:25 +0200 Subject: [PATCH 29/93] remove unused --- pkg/datalogger/log.go | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/pkg/datalogger/log.go b/pkg/datalogger/log.go index d1fc4d00..5efd88cd 100644 --- a/pkg/datalogger/log.go +++ b/pkg/datalogger/log.go @@ -36,7 +36,7 @@ func NewWriter(cfg Config) (string, LogWriter, error) { func createLog(path, prefix, extension string) (*os.File, string, error) { if _, err := os.Stat(path); os.IsNotExist(err) { - if err := os.Mkdir(path, 0755); err != nil { + if err := os.Mkdir(path, 0o755); err != nil { if err != os.ErrExist { return nil, "", fmt.Errorf("failed to create logs dir: %w", err) } @@ -48,7 +48,7 @@ func createLog(path, prefix, extension string) (*os.File, string, error) { fullFilename := filepath.Join(path, filename) - file, err := os.OpenFile(fullFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + file, err := os.OpenFile(fullFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { return nil, "", fmt.Errorf("failed to open file: %w", err) } @@ -58,17 +58,3 @@ func createLog(path, prefix, extension string) (*os.File, string, error) { func replaceDot(s string) string { return strings.Replace(s, ".", ",", 1) } - -type TXBinWriter struct { - file *os.File -} - -func NewTXBinWriter(f *os.File) *TXBinWriter { - return &TXBinWriter{ - file: f, - } -} - -func (t *TXBinWriter) Write(ts time.Time, channels []Channel) error { - return nil -} From 2cef514be1867502e62118462496dd792996239f Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 22:33:40 +0200 Subject: [PATCH 30/93] update ecusymbol --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e208d982..d90b439f 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.1.8 + github.com/roffe/ecusymbol v1.1.9 github.com/roffe/gocan v1.4.0 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index b9f4f322..af30dca5 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.1.8 h1:c145IldWHTnzy4y2cInpHRDYYvehnqFxsKQZ2YPyzNw= -github.com/roffe/ecusymbol v1.1.8/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/ecusymbol v1.1.9 h1:muLJ5ihTK2LTWHrbfB/Dlk3pDAh5d0/UHKX2PE60fcE= +github.com/roffe/ecusymbol v1.1.9/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.0 h1:OSs//lr4vy/ozyMPUbgZaNFVZWMeXzOsXhCujpA4WRs= github.com/roffe/gocan v1.4.0/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= From aadb98456acc6b6f3d6c8cef0d345108a0ea3167 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 12 Jun 2026 23:25:10 +0200 Subject: [PATCH 31/93] use opengl shader to draw log plotter --- pkg/widgets/plotter/plotter.go | 48 ++- pkg/widgets/plotter/plotter_mouse.go | 2 +- pkg/widgets/plotter/plotter_render.go | 2 +- pkg/widgets/plotter/plotter_shader.go | 356 +++++++++++++++++++++ pkg/widgets/plotter/plotter_shader_test.go | 175 ++++++++++ 5 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 pkg/widgets/plotter/plotter_shader.go create mode 100644 pkg/widgets/plotter/plotter_shader_test.go diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index a25c92e8..baed1c01 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -5,6 +5,7 @@ import ( "image" "image/color" "log" + "os" "sort" "sync" "sync/atomic" @@ -29,9 +30,25 @@ type PlotterControl interface { Seek(int) } +// plotBackend selects how the plot is drawn. The GPU shader is the default; +// TXLOGGER_PLOT_RENDERER=image selects the CPU rasterizer, which is also the +// automatic fallback when the log does not fit the shader's texture layout. +type plotBackend int + +const ( + plotBackendImage plotBackend = iota + plotBackendShader +) + type Plotter struct { widget.BaseWidget + backend plotBackend + shader *canvas.Shader + // plotObj is the canvas object showing the plot: shader on the GPU + // backend, canvasImage on the image backend. + plotObj fyne.CanvasObject + cursor *canvas.Line canvasImage *canvas.Image split *container.Split @@ -182,12 +199,18 @@ func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { p.dataPointsToShow = min(p.dataLength, 250.0) + p.plotObj = p.canvasImage + if os.Getenv("TXLOGGER_PLOT_RENDERER") != "image" && p.initShader() { + p.backend = plotBackendShader + p.plotObj = p.shader + } + leading := container.NewBorder( nil, nil, p.zoom, nil, - container.New(&plotLayout{p: p}, p.canvasImage), + container.New(&plotLayout{p: p}, p.plotObj), ) p.split = container.NewHSplit( leading, @@ -294,6 +317,14 @@ func (p *Plotter) seekTo(pos int) { obj.Refresh() } + if p.backend == plotBackendShader { + // The view window moved; the GPU re-renders from two uniforms. + p.updateShaderView() + p.layoutCursor() + p.cursor.Refresh() + p.shader.Refresh() + return + } p.drawImage() p.layoutCursor() p.cursor.Refresh() @@ -337,6 +368,19 @@ func (p *Plotter) drawImage() { } func (p *Plotter) refreshImage(goroutine bool) { + if p.backend == plotBackendShader { + // Legend toggles/recolors, hover and zoom all funnel through here; + // the metadata texture is 4xN so rebuilding it unconditionally is + // cheap, and the painter uploads it only because it is a new image. + p.updateShaderMeta() + p.updateShaderView() + if goroutine { + fyne.Do(p.shader.Refresh) + } else { + p.shader.Refresh() + } + return + } p.drawImage() if goroutine { fyne.Do(p.canvasImage.Refresh) @@ -492,7 +536,7 @@ func (ts *TimeSeries) plotImageDecimated(img *image.RGBA, data []float64, thickn func (p *Plotter) layoutCursor() { var x float32 halfDataPointsToShow := int(float64(p.dataPointsToShow) * .5) - plotSize := p.canvasImage.Size() + plotSize := p.plotObj.Size() if p.cursorPos >= p.dataLength-halfDataPointsToShow { // Handle cursor position near the end of data diff --git a/pkg/widgets/plotter/plotter_mouse.go b/pkg/widgets/plotter/plotter_mouse.go index 89562091..6ab4c5d3 100644 --- a/pkg/widgets/plotter/plotter_mouse.go +++ b/pkg/widgets/plotter/plotter_mouse.go @@ -58,7 +58,7 @@ func (p *Plotter) onZoom(value float64) { if p.plotStartPos < 0 { p.plotStartPos = 0 } - p.widthFactor = p.canvasImage.Size().Width / float32(p.dataPointsToShow) + p.widthFactor = p.plotObj.Size().Width / float32(p.dataPointsToShow) p.updateCursor(false) p.refreshImage(false) } diff --git a/pkg/widgets/plotter/plotter_render.go b/pkg/widgets/plotter/plotter_render.go index a228b12e..825932c4 100644 --- a/pkg/widgets/plotter/plotter_render.go +++ b/pkg/widgets/plotter/plotter_render.go @@ -52,7 +52,7 @@ func (t *plotLayout) Layout(_ []fyne.CanvasObject, plotSize fyne.Size) { t.p.overlayText.Move(fyne.NewPos(t.p.zoom.Size().Width, 20)) - t.p.canvasImage.Resize(plotSize) // Calculate new plot dimensions + t.p.plotObj.Resize(plotSize) // Calculate new plot dimensions t.p.plotResolution = fyne.NewSize(plotSize.Width*t.p.plotResolutionFactor, plotSize.Height*t.p.plotResolutionFactor) // Update width factor based on the new size t.p.widthFactor = plotSize.Width / float32(t.p.dataPointsToShow) diff --git a/pkg/widgets/plotter/plotter_shader.go b/pkg/widgets/plotter/plotter_shader.go new file mode 100644 index 00000000..de4ef813 --- /dev/null +++ b/pkg/widgets/plotter/plotter_shader.go @@ -0,0 +1,356 @@ +package plotter + +import ( + "fmt" + "image" + "image/color" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" +) + +// GPU renderer: the whole plot is drawn by a single canvas.Shader. The log is +// immutable once loaded, so every sample is uploaded to the GPU exactly once +// (16-bit packed, one texel per sample) together with a small min/max +// decimation level; after that a playback seek, zoom or drag only updates a +// couple of float uniforms and the fragment shader re-renders the view. The +// CPU never rasterizes a plot frame again - compare drawImage, which redraws +// and re-uploads the full plot image on every coalesced Seek. +// +// Per pixel the shader walks the enabled series; when zoomed in it computes +// the anti-aliased distance to the polyline segments crossing the pixel's +// column, when zoomed out it min/maxes the samples covering the column (via +// the 16:1 min/max texture when even that is too many fetches) and draws the +// vertical run, mirroring plotImageDecimated. Series are combined with the +// same order-independent max-blend as bresenhamCore. +// +// Sample encoding: values are normalized to the series' display range +// [Min, Max] with one full range of headroom on each side - texel 0..1 spans +// [Min-range, Max+range] - so overshooting samples keep their slope off +// screen like the Bresenham clipping does instead of flattening at the plot +// edge. 16 bits give the on-screen third ~21800 steps, far below a pixel. + +// plotShaderSeq makes each widget's Shader.Name unique: the painter caches +// the compiled program and its textures per name, so two plotters sharing a +// name would evict each other's data textures every frame. +var plotShaderSeq atomic.Int64 + +const ( + // plotTexW caps the data-texture width; sample index s of series i lives + // at texel (s mod plotTexW, i*rowsPerSeries + s/plotTexW). + plotTexW = 4096 + // plotTexMaxH bounds texture heights to what every desktop GPU handles. + plotTexMaxH = 4096 + // plotMaxSeries matches MAX_SERIES in the shader source. + plotMaxSeries = 64 + // mmGroup is the min/max decimation factor, matching the shader's + // group-16 lookups. + mmGroup = 16 +) + +const plotShaderPreludeGL = "#version 110\n" + +const plotShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const plotShaderBody = ` +#define MAX_SERIES 64 +#define MAX_SEG 4 +#define MAX_RAW 16 +#define MAX_GROUPS 24 + +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform sampler2D data_tex; // one texel per sample, 16-bit value in RG +uniform sampler2D mm_tex; // min/max per 16 samples: RG=min, BA=max +uniform sampler2D meta_tex; // per series: x0 color, x1 enabled, x2 length + +uniform float series_count; +uniform float highlight; // hovered series index, -1 for none +uniform float plot_start; // first visible sample +uniform float points_shown; // visible sample count +uniform float tex_w; // texel columns in data_tex and mm_tex +uniform float rows_raw; // data_tex rows per series +uniform float rows_mm; // mm_tex rows per series +uniform float data_h; // data_tex height in rows +uniform float mm_h; // mm_tex height in rows +uniform float meta_h; // meta_tex height = series count +uniform float size_w; // widget size, logical px +uniform float size_h; + +const float BIG = 100000.0; + +float decode16(float hi, float lo) { + return (hi * 65280.0 + lo * 255.0) / 65535.0; +} + +vec4 meta_at(float x, float si) { + return texture2D(meta_tex, vec2((x + 0.5) / 4.0, (si + 0.5) / meta_h)); +} + +// normalized sample value; idx is clamped to the series +float sample_val(float si, float idx, float len) { + idx = clamp(idx, 0.0, len - 1.0); + float row = floor(idx / tex_w); + float colm = idx - row * tex_w; + vec2 uv = vec2((colm + 0.5) / tex_w, (si * rows_raw + row + 0.5) / data_h); + vec4 t = texture2D(data_tex, uv); + return decode16(t.r, t.g); +} + +// normalized (min, max) of sample group gidx +vec2 sample_mm(float si, float gidx, float glen) { + gidx = clamp(gidx, 0.0, glen - 1.0); + float row = floor(gidx / tex_w); + float colm = gidx - row * tex_w; + vec2 uv = vec2((colm + 0.5) / tex_w, (si * rows_mm + row + 0.5) / mm_h); + vec4 t = texture2D(mm_tex, uv); + return vec2(decode16(t.r, t.g), decode16(t.b, t.a)); +} + +// device y of a normalized value: texel range 0..1 spans one display range +// of headroom on each side of [Min, Max] +float val_y(float v, float h_dev) { + float frac = v * 3.0 - 1.0; + return (1.0 - frac) * (h_dev - 1.0); +} + +float seg_dist(vec2 p, vec2 a, vec2 b) { + vec2 e = b - a; + float ee = dot(e, e); + float h = ee > 0.000001 ? clamp(dot(p - a, e) / ee, 0.0, 1.0) : 0.0; + return distance(p, a + e * h); +} + +void main() { + float pix_scale = (rect_coords.y - rect_coords.x) / max(size_w, 1.0); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + float w_dev = rect_coords.y - rect_coords.x; + float h_dev = rect_coords.w - rect_coords.z; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > w_dev || p_dev.y > h_dev) { + discard; + } + + float ppd = points_shown / max(w_dev, 1.0); // samples per device px + float spos = plot_start + p_dev.x / w_dev * points_shown; // sample at this px + float aa = 0.6; + + vec3 acc = vec3(0.0); + float acc_a = 0.0; + + for (int i = 0; i < MAX_SERIES; i++) { + if (i >= int(series_count + 0.5)) { + break; + } + float si = float(i); + if (meta_at(1.0, si).r < 0.5) { + continue; // disabled via the legend + } + vec4 m2 = meta_at(2.0, si); + float len = m2.r * 16711680.0 + m2.g * 65280.0 + m2.b * 255.0; + if (len < 2.0) { + continue; + } + // hovered series renders at 4 logical px like PlotImage thickness 4 + float half_w = (abs(si - highlight) < 0.5 ? 2.0 : 0.5) * pix_scale; + + float mask = 0.0; + if (ppd <= 1.5) { + // zoomed in: true polyline, distance to the segments around + // this pixel's column + float i0 = floor(spos); + float dmin = BIG; + for (int k = -MAX_SEG; k < MAX_SEG; k++) { + float j = i0 + float(k); + float x0 = (j - plot_start) / points_shown * w_dev; + float x1 = (j + 1.0 - plot_start) / points_shown * w_dev; + float y0 = val_y(sample_val(si, j, len), h_dev); + float y1 = val_y(sample_val(si, j + 1.0, len), h_dev); + dmin = min(dmin, seg_dist(p_dev, vec2(x0, y0), vec2(x1, y1))); + } + mask = 1.0 - smoothstep(half_w - aa, half_w + aa, dmin); + } else { + // zoomed out: vertical min/max run per column, like + // plotImageDecimated (including the one-sample overlap into the + // previous column that keeps runs connected) + float s_a = spos - ppd * 0.5 - 1.0; + float s_b = spos + ppd * 0.5; + float lo = BIG; + float hi = -BIG; + if (ppd <= 14.0) { + for (int k = 0; k < MAX_RAW; k++) { + float idx = s_a + float(k); + if (idx > s_b) { + break; + } + float v = sample_val(si, idx, len); + lo = min(lo, v); + hi = max(hi, v); + } + } else { + float g0 = floor(s_a / 16.0); + float glen = ceil(len / 16.0); + for (int k = 0; k < MAX_GROUPS; k++) { + float g = g0 + float(k); + if (g * 16.0 > s_b) { + break; + } + vec2 mm = sample_mm(si, g, glen); + lo = min(lo, mm.x); + hi = max(hi, mm.y); + } + } + float d = max(val_y(hi, h_dev) - p_dev.y, p_dev.y - val_y(lo, h_dev)); + mask = 1.0 - smoothstep(half_w - aa, half_w + aa, d); + } + + // max blend, same as bresenhamCore, so overlap is order independent + vec4 col = meta_at(0.0, si); + acc = max(acc, col.rgb * mask * col.a); + acc_a = max(acc_a, mask * col.a); + } + + if (acc_a < 0.004) { + discard; + } + gl_FragColor = vec4(acc / acc_a, acc_a); +} +` + +// initShader builds the shader object and uploads the immutable log data. +// It reports false when the data does not fit the GPU layout (no series, too +// many series, or a log too long for the texture budget); the caller then +// stays on the image backend. +func (p *Plotter) initShader() bool { + if len(p.ts) == 0 || len(p.ts) > plotMaxSeries { + return false + } + + maxLen := 0 + for _, ts := range p.ts { + if ts == nil { + return false + } + maxLen = max(maxLen, len(p.values[ts.Name])) + } + if maxLen < 2 { + return false + } + + texW := min(maxLen, plotTexW) + rowsRaw := (maxLen + texW - 1) / texW + groups := (maxLen + mmGroup - 1) / mmGroup + rowsMM := (groups + texW - 1) / texW + if len(p.ts)*rowsRaw > plotTexMaxH || len(p.ts)*rowsMM > plotTexMaxH { + return false + } + + p.shader = canvas.NewShader( + fmt.Sprintf("plotter-%d", plotShaderSeq.Add(1)), + []byte(plotShaderPreludeGL+plotShaderBody), + []byte(plotShaderPreludeES+plotShaderBody), + ) + p.shader.Textures = make(map[string]image.Image, 3) + p.shader.Uniforms = make(map[string]float32, 16) + + data := image.NewRGBA(image.Rect(0, 0, texW, len(p.ts)*rowsRaw)) + mm := image.NewRGBA(image.Rect(0, 0, texW, len(p.ts)*rowsMM)) + for i, ts := range p.ts { + encodeSeries(data, mm, texW, i*rowsRaw, i*rowsMM, ts, p.values[ts.Name]) + } + p.shader.Textures["data_tex"] = data + p.shader.Textures["mm_tex"] = mm + + u := p.shader.Uniforms + u["series_count"] = float32(len(p.ts)) + u["tex_w"] = float32(texW) + u["rows_raw"] = float32(rowsRaw) + u["rows_mm"] = float32(rowsMM) + u["data_h"] = float32(len(p.ts) * rowsRaw) + u["mm_h"] = float32(len(p.ts) * rowsMM) + u["meta_h"] = float32(len(p.ts)) + + p.updateShaderMeta() + p.updateShaderView() + return true +} + +// encodeValue maps a sample to the 16-bit texel value: 0..1 spans +// [Min-range, Max+range] so overshoot keeps its slope (clamped a full plot +// height off screen). +func encodeValue(ts *TimeSeries, v float64) uint16 { + r := ts.valueRange + if r <= 0 { + r = 1 + } + norm := ((v-ts.Min)/r + 1) / 3 + norm = min(1, max(0, norm)) + return uint16(norm*65535 + 0.5) +} + +// encodeSeries packs one series' samples (and its 16:1 min/max groups) into +// the texture rows starting at rawRow/mmRow. +func encodeSeries(data, mm *image.RGBA, texW, rawRow, mmRow int, ts *TimeSeries, values []float64) { + for s, v := range values { + q := encodeValue(ts, v) + data.SetRGBA(s%texW, rawRow+s/texW, color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff}) + } + for g := 0; g*mmGroup < len(values); g++ { + end := min((g+1)*mmGroup, len(values)) + lo, hi := values[g*mmGroup], values[g*mmGroup] + for _, v := range values[g*mmGroup+1 : end] { + lo = min(lo, v) + hi = max(hi, v) + } + ql, qh := encodeValue(ts, lo), encodeValue(ts, hi) + mm.SetRGBA(g%texW, mmRow+g/texW, color.RGBA{ + R: uint8(ql >> 8), G: uint8(ql), + B: uint8(qh >> 8), A: uint8(qh), + }) + } +} + +// updateShaderMeta rebuilds the per-series metadata texture (color, enabled, +// length). Called when the legend toggles or recolors a series; a fresh image +// is allocated because the painter re-uploads only when the map entry points +// at a new image. +func (p *Plotter) updateShaderMeta() { + if p.shader == nil { + return + } + meta := image.NewRGBA(image.Rect(0, 0, 4, len(p.ts))) + for i, ts := range p.ts { + meta.SetRGBA(0, i, ts.Color) + var enabled uint8 + if ts.Enabled { + enabled = 0xff + } + meta.SetRGBA(1, i, color.RGBA{R: enabled, A: 0xff}) + n := len(p.values[ts.Name]) + meta.SetRGBA(2, i, color.RGBA{R: uint8(n >> 16), G: uint8(n >> 8), B: uint8(n), A: 0xff}) + } + p.shader.Textures["meta_tex"] = meta +} + +// updateShaderView pushes the view window and hover state; this is the whole +// per-frame CPU cost of a playback seek on the shader backend. +func (p *Plotter) updateShaderView() { + if p.shader == nil { + return + } + size := p.plotObj.Size() + u := p.shader.Uniforms + u["plot_start"] = float32(p.plotStartPos) + u["points_shown"] = float32(p.dataPointsToShow) + u["highlight"] = float32(p.hilightLine) + u["size_w"] = size.Width + u["size_h"] = size.Height +} diff --git a/pkg/widgets/plotter/plotter_shader_test.go b/pkg/widgets/plotter/plotter_shader_test.go new file mode 100644 index 00000000..9d30e17b --- /dev/null +++ b/pkg/widgets/plotter/plotter_shader_test.go @@ -0,0 +1,175 @@ +package plotter + +import ( + "image" + "math" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func shaderPlotter(t testing.TB, numSeries, numPoints int) *Plotter { + t.Helper() + p := NewPlotter(benchValues(numSeries, numPoints)) + if p.backend != plotBackendShader { + t.Fatal("shader backend not selected") + } + return p +} + +// decode16 mirrors the shader's texel decode. +func decode16(hi, lo uint8) float64 { + return (float64(hi)*256 + float64(lo)) / 65535 +} + +// Every sample must decode from the data texture to its display-normalized +// value: texel 0..1 spans [Min-range, Max+range]. +func TestPlotShaderDataEncoding(t *testing.T) { + p := shaderPlotter(t, 3, 5000) + + tex := p.shader.Textures["data_tex"].(*image.RGBA) + texW := int(p.shader.Uniforms["tex_w"]) + rowsRaw := int(p.shader.Uniforms["rows_raw"]) + if texW != 4096 || rowsRaw != 2 { + t.Fatalf("layout texW=%d rowsRaw=%d, want 4096/2", texW, rowsRaw) + } + + for i, ts := range p.ts { + data := p.values[ts.Name] + for s, v := range data { + c := tex.RGBAAt(s%texW, i*rowsRaw+s/texW) + got := decode16(c.R, c.G) + want := ((v-ts.Min)/ts.valueRange + 1) / 3 + if math.Abs(got-want) > 1.0/65535 { + t.Fatalf("series %d sample %d: %v, want %v", i, s, got, want) + } + } + } +} + +// The min/max texture must hold the exact extremes of each 16-sample group. +func TestPlotShaderMinMax(t *testing.T) { + p := shaderPlotter(t, 2, 5000) + + mm := p.shader.Textures["mm_tex"].(*image.RGBA) + texW := int(p.shader.Uniforms["tex_w"]) + rowsMM := int(p.shader.Uniforms["rows_mm"]) + + for i, ts := range p.ts { + data := p.values[ts.Name] + for g := 0; g*mmGroup < len(data); g++ { + end := min((g+1)*mmGroup, len(data)) + lo, hi := data[g*mmGroup], data[g*mmGroup] + for _, v := range data[g*mmGroup+1 : end] { + lo = min(lo, v) + hi = max(hi, v) + } + c := mm.RGBAAt(g%texW, i*rowsMM+g/texW) + wantLo := ((lo-ts.Min)/ts.valueRange + 1) / 3 + wantHi := ((hi-ts.Min)/ts.valueRange + 1) / 3 + if math.Abs(decode16(c.R, c.G)-wantLo) > 1.0/65535 || math.Abs(decode16(c.B, c.A)-wantHi) > 1.0/65535 { + t.Fatalf("series %d group %d: (%v,%v), want (%v,%v)", + i, g, decode16(c.R, c.G), decode16(c.B, c.A), wantLo, wantHi) + } + } + } +} + +// Out-of-range samples must clamp a full display range off screen, not at +// the plot edge, so overshooting lines keep their slope like the Bresenham +// clipping does. +func TestPlotShaderEncodeHeadroom(t *testing.T) { + ts := &TimeSeries{Min: 0, Max: 10, valueRange: 10} + for _, tc := range []struct { + v float64 + want float64 + }{ + {0, 1.0 / 3}, // Min -> bottom of display band + {10, 2.0 / 3}, // Max -> top of display band + {-10, 0}, // one range below -> texel floor + {20, 1}, // one range above -> texel ceil + {-100, 0}, {99, 1}, // far overshoot clamps + } { + got := float64(encodeValue(ts, tc.v)) / 65535 + if math.Abs(got-tc.want) > 1.0/65535 { + t.Fatalf("encode(%v) = %v, want %v", tc.v, got, tc.want) + } + } +} + +// The metadata texture carries color, enabled flag and series length, and a +// legend toggle must produce a fresh image holding the new state. +func TestPlotShaderMeta(t *testing.T) { + p := shaderPlotter(t, 2, 100) + + meta := p.shader.Textures["meta_tex"].(*image.RGBA) + for i, ts := range p.ts { + if c := meta.RGBAAt(0, i); c != ts.Color { + t.Fatalf("series %d color %v, want %v", i, c, ts.Color) + } + if c := meta.RGBAAt(1, i); c.R != 0xff { + t.Fatalf("series %d not flagged enabled", i) + } + n := len(p.values[ts.Name]) + c := meta.RGBAAt(2, i) + if got := int(c.R)<<16 | int(c.G)<<8 | int(c.B); got != n { + t.Fatalf("series %d length %d, want %d", i, got, n) + } + } + + p.ts[1].Enabled = false + p.updateShaderMeta() + meta2 := p.shader.Textures["meta_tex"].(*image.RGBA) + if meta2 == meta { + t.Fatal("meta texture not replaced; painter would not re-upload") + } + if c := meta2.RGBAAt(1, 1); c.R != 0 { + t.Fatal("disabled series still flagged enabled") + } +} + +// Logs that exceed the texture budget must fall back to the image backend. +func TestPlotShaderFallback(t *testing.T) { + p := NewPlotter(benchValues(2, plotTexW*plotTexMaxH/2+1)) + if p.backend != plotBackendImage { + t.Fatal("oversized log did not fall back to the image backend") + } + if p.plotObj != p.canvasImage { + t.Fatal("fallback must keep drawing through canvasImage") + } +} + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestPlotShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": plotShaderPreludeGL + plotShaderBody, + "es.frag": plotShaderPreludeES + plotShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} + +// CPU-side cost of a playback seek on the shader backend; compare against +// the BenchmarkPlot_* figures for the image backend (which additionally +// re-uploads the whole plot texture every frame). +func BenchmarkUpdateShaderView(b *testing.B) { + p := shaderPlotter(b, 30, 10000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.plotStartPos = i % 1000 + p.updateShaderView() + } +} From 69284e78c1f37a244d55119efeac762cad67bcca Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 00:45:14 +0200 Subject: [PATCH 32/93] shaders in dials --- pkg/widgets/dial/dial.go | 203 ++++++------------ pkg/widgets/dial/dial_shader.go | 126 +++++++++++ pkg/widgets/dial/dial_shader_test.go | 29 +++ pkg/widgets/dualdial/dual_dial.go | 183 ++++++---------- pkg/widgets/dualdial/dual_dial_shader.go | 119 ++++++++++ pkg/widgets/dualdial/dual_dial_shader_test.go | 29 +++ 6 files changed, 424 insertions(+), 265 deletions(-) create mode 100644 pkg/widgets/dial/dial_shader.go create mode 100644 pkg/widgets/dial/dial_shader_test.go create mode 100644 pkg/widgets/dualdial/dual_dial_shader.go create mode 100644 pkg/widgets/dualdial/dual_dial_shader_test.go diff --git a/pkg/widgets/dial/dial.go b/pkg/widgets/dial/dial.go index 45c54f63..acf10f0a 100644 --- a/pkg/widgets/dial/dial.go +++ b/pkg/widgets/dial/dial.go @@ -23,29 +23,26 @@ type Dial struct { value float64 highestObserved float64 - needle *canvas.Line - highestObservedMarker *canvas.Line - lastHighestObserved time.Time + lastHighestObserved time.Time + + // All dial geometry (face, pips, needle, marker, center) in one object + shader *canvas.Shader - pips []*canvas.Line pipLabels []*canvas.Text - face *canvas.Arc - center *canvas.Circle displayText *canvas.Text titleText *canvas.Text size fyne.Size minsize fyne.Size - diameter float32 - radius float32 - middle fyne.Position - needleOffset, needleLength float32 - needleRotConst float64 // = common.Pi15/(Max-Min) - lineRotConst float64 // = common.Pi15/steps + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 // = common.Pi15/(Max-Min) + lineRotConst float64 // = common.Pi15/steps - // Precomputed trig for pips: angle_i = lineRotConst*float64(i) - common.Pi43 + // Precomputed trig for pip labels: angle_i = lineRotConst*float64(i) - common.Pi43 pipSin []float32 pipCos []float32 @@ -95,10 +92,21 @@ func New(cfg *widgets.GaugeConfig) *Dial { c.factor = c.cfg.Max / steps - c.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) - c.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} - c.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 3} - c.highestObservedMarker = &canvas.Line{StrokeColor: color.RGBA{R: 216, G: 250, B: 8, A: 0xFF}, StrokeWidth: 6} + // Constants + c.needleRotConst = common.Pi15 / totalRange + c.lineRotConst = common.Pi15 / steps + + c.shader = canvas.NewShader( + "txlogger-dial", + []byte(dialShaderPreludeGL+dialShaderBody), + []byte(dialShaderPreludeES+dialShaderBody), + ) + c.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(cfg.Steps), + "needle_a": c.needleAngle(0), + "marker_a": c.needleAngle(0), + } c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} c.titleText.TextStyle.Monospace = true @@ -107,27 +115,8 @@ func New(cfg *widgets.GaugeConfig) *Dial { c.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} c.displayText.Alignment = fyne.TextAlignCenter - // Pip color gradient - // Green to Yellow to Red gradient - // 0% - 50% Green to Yellow - // 50% - 100% Yellow to Red - halfSteps := float64(c.cfg.Steps) / 2.0 - - // Build pips + labels once; also track the longest label length - var col color.RGBA + // Labels at every other pip; also track the longest label length for i := 0; i < c.cfg.Steps+1; i++ { - if float64(i) <= halfSteps { - // Green to Yellow - ratio := float64(i) / halfSteps - col = color.RGBA{R: byte(255 * ratio), G: 255, B: 0, A: 255} - } else { - // Yellow to Red - ratio := (float64(i) - halfSteps) / halfSteps - col = color.RGBA{R: 255, G: byte(255 * (1 - ratio)), B: 0, A: 255} - } - pip := &canvas.Line{StrokeColor: col, StrokeWidth: 2} - c.pips = append(c.pips, pip) - if i%2 == 0 { val := c.cfg.Min + (float64(i)/float64(c.cfg.Steps))*(c.cfg.Max-c.cfg.Min)*c.cfg.GaugeFactor txt := strconv.FormatFloat(val, 'f', c.gaugePrec, 64) @@ -137,7 +126,6 @@ func New(cfg *widgets.GaugeConfig) *Dial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } - // lbl.TextStyle.Monospace = true if n := len(txt); n > c.maxLabelChars { c.maxLabelChars = n } @@ -147,11 +135,7 @@ func New(cfg *widgets.GaugeConfig) *Dial { } } - // Constants - c.needleRotConst = common.Pi15 / totalRange - c.lineRotConst = common.Pi15 / steps - - // Precompute pip sin/cos (size-independent) + // Precompute pip sin/cos for label placement (size-independent) c.pipSin = make([]float32, c.cfg.Steps+1) c.pipCos = make([]float32, c.cfg.Steps+1) for i := 0; i <= c.cfg.Steps; i++ { @@ -166,29 +150,14 @@ func New(cfg *widgets.GaugeConfig) *Dial { func (c *Dial) GetConfig() *widgets.GaugeConfig { return c.cfg } -// rotate angle (in radians) without refreshing per-object -func (c *Dial) rotateNoRefresh(hand *canvas.Line, rotation float64, offset, length float32) { - s, co := math.Sincos(rotation) - c.applySinCos(hand, float32(s), float32(co), offset, length) -} - -func (c *Dial) applySinCos(hand *canvas.Line, sinRot, cosRot float32, offset, length float32) { - x2 := length * sinRot - y2 := -length * cosRot - offX := offset * sinRot - offY := -offset * cosRot - midxOffX := c.middle.X + offX - midY := c.middle.Y + offY - hand.Position1 = fyne.Position{X: midxOffX, Y: midY} - hand.Position2 = fyne.Position{X: midxOffX + x2, Y: midY + y2} -} - -func (c *Dial) rotateNeedleNoRefresh(hand *canvas.Line, facePosition float64, offset, length float32) { - normalized := facePosition - c.cfg.Min +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *Dial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min if normalized < 0 { normalized = 0 } - c.rotateNoRefresh(hand, c.needleRotConst*normalized-common.Pi43, offset, length) + return float32(c.needleRotConst*normalized - common.Pi43) } func (c *Dial) SetValue(value float64) { @@ -197,21 +166,13 @@ func (c *Dial) SetValue(value float64) { } c.value = value - // Update needle position (no immediate refresh) - c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) + c.shader.Uniforms["needle_a"] = c.needleAngle(value) - // Highest observed marker with lazy reset; only refresh when it actually moves - markerMoved := false - if value > c.highestObserved { - c.highestObserved = value - c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true - } else if time.Since(c.lastHighestObserved) > 10*time.Second { + // Highest observed marker with lazy reset + if value > c.highestObserved || time.Since(c.lastHighestObserved) > 10*time.Second { c.highestObserved = value c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true + c.shader.Uniforms["marker_a"] = c.needleAngle(value) } // Update text with minimal allocs; skip refresh if formatted output is unchanged @@ -221,18 +182,12 @@ func (c *Dial) SetValue(value float64) { } else { c.buf = common.AppendFormatFloat(c.buf, c.displayString, value) } - textChanged := !common.SameTextBytes(c.displayText.Text, c.buf) - if textChanged { + if !common.SameTextBytes(c.displayText.Text, c.buf) { c.displayText.Text = string(c.buf) - } - - canvas.Refresh(c.needle) - if markerMoved { - canvas.Refresh(c.highestObservedMarker) - } - if textChanged { canvas.Refresh(c.displayText) } + + canvas.Refresh(c.shader) } func (c *Dial) SetValue2(value float64) { c.SetValue(value) } @@ -253,45 +208,26 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.diameter = fyne.Min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) - c.needleOffset = -c.radius * .15 - c.needleLength = c.radius * 1.14 - - // Stroke sizes - stroke := c.diameter * common.OneSixthieth - midStroke := c.diameter * common.OneEighthieth - smallStroke := c.diameter * common.OneTwohundredth size := fyne.Size{Width: c.diameter, Height: c.diameter} topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + // Shader quad: the dial square padded so the marker overhang isn't clipped + c.shader.Move(topleft.SubtractXY(dialPad, dialPad)) + c.shader.Resize(fyne.Size{Width: c.diameter + 2*dialPad, Height: c.diameter + 2*dialPad}) + c.shader.Uniforms["size_d"] = c.diameter + // Title (no rounding needed) c.titleText.TextSize = float32(int(c.radius * common.OneFourth)) c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) - // Center element - center := c.radius * common.OneFourth - c.center.Move(c.middle.SubtractXY(center*common.OneHalf, center*common.OneHalf)) - c.center.Resize(fyne.Size{Width: center, Height: center}) - // Display text c.displayText.TextSize = float32(int(c.radius * common.OneThird)) c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) c.displayText.Resize(size) - // Face + needle - c.needle.StrokeWidth = stroke - c.rotateNeedleNoRefresh(c.needle, c.value, c.needleOffset, c.needleLength) - - c.face.Move(c.middle.SubtractXY(c.radius, c.radius)) - c.face.Resize(fyne.Size{Width: c.diameter, Height: c.diameter}) - - // Pips: reuse precomputed sin/cos, scale with current radii - fourthRadius := c.radius * common.OneFourth - eightRadius := c.radius * common.OneEight + // Labels: reuse precomputed sin/cos, scale with current radius radius43 := c.radius * common.OneFourth * 3 - radius87 := c.radius * common.OneEight * 7 - - // Label padding and cached box dims (avoid lbl.MinSize per label) labelPad := max(float32(6.0), c.radius*0.14) // Assume monospace, digits only: width ≈ chars * 0.62 * TextSize; height ≈ 1.15 * TextSize @@ -303,35 +239,20 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize c.labelBoxH = float32(heightFactor) * labelTextSize - for i, p := range c.pips { - if i%2 == 0 { - p.StrokeWidth = max(2.0, midStroke) - c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius43, fourthRadius-1) - - // Label for long pip - lbl := c.pipLabels[i] - if lbl != nil { - lbl.TextSize = labelTextSize - - // Place label on the INSIDE of the gauge - labelRadius := radius43 - labelPad - cx := c.middle.X + c.pipSin[i]*labelRadius - cy := c.middle.Y - c.pipCos[i]*labelRadius - - boxW := c.labelBoxW - boxH := c.labelBoxH - lbl.Resize(fyne.NewSize(boxW, boxH)) - // lbl.Move(fyne.NewPos(cx-boxW/2, cy-boxH/2)) - lbl.Move(fyne.Position{X: cx - boxW/2, Y: cy - boxH/2}) - } - } else { - p.StrokeWidth = max(2.0, smallStroke) - c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) + for i, lbl := range c.pipLabels { + if lbl == nil { + continue } - } + lbl.TextSize = labelTextSize - c.highestObservedMarker.StrokeWidth = max(2.0, midStroke) - c.rotateNeedleNoRefresh(c.highestObservedMarker, c.highestObserved, c.radius-2, 6) + // Place label on the INSIDE of the gauge + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.Position{X: cx - c.labelBoxW/2, Y: cy - c.labelBoxH/2}) + } } func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } @@ -340,17 +261,14 @@ func (c *DialRenderer) Destroy() {} func (c *DialRenderer) Objects() []fyne.CanvasObject { if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) - for _, v := range c.pips { - objs = append(objs, v) - } + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+3) + objs = append(objs, c.shader) for _, t := range c.pipLabels { if t != nil { objs = append(objs, t) } } - objs = append(objs, c.face, c.titleText, c.center, - c.highestObservedMarker, c.needle, c.displayText) + objs = append(objs, c.titleText, c.displayText) c.objects = objs } return c.objects @@ -365,4 +283,3 @@ func max(a, b float32) float32 { } return b } - diff --git a/pkg/widgets/dial/dial_shader.go b/pkg/widgets/dial/dial_shader.go new file mode 100644 index 00000000..604b6c65 --- /dev/null +++ b/pkg/widgets/dial/dial_shader.go @@ -0,0 +1,126 @@ +package dial + +// GPU renderer: the dial geometry (face rim, pips, needle, highest-observed +// marker and center cap) is drawn by a single canvas.Shader, collapsing ~30 +// canvas objects per dial into one draw call; SetValue only writes a float +// uniform. Text (title, value, pip labels) stays as canvas.Text layered on +// top - rasterizing glyphs in a fragment shader buys nothing. +// +// All dials share one Shader.Name: the painter caches the compiled program +// per name and the dial needs no textures, so every instance reuses the same +// program with its own uniforms. +// +// Conventions shared between the Go side and the GLSL below: +// - angles are radians, 0 pointing up, clockwise positive, matching the +// needle direction (sin a, -cos a) of the CPU renderer +// - the needle sweeps Pi15 (270 degrees) from -3/4 pi at Min to +3/4 pi +// at Max; pip i sits at i*Pi15/steps - 3/4 pi +// - the shader quad is the dial square padded by dialPad logical px per +// side, because the marker pokes 4 px past the rim like the CPU line did +const dialPad = 4 + +const dialShaderPreludeGL = "#version 110\n" + +const dialShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const dialShaderBody = ` +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform float size_d; // dial diameter, logical px +uniform float steps; // pip intervals; steps+1 pips are drawn +uniform float needle_a; // needle angle, radians +uniform float marker_a; // highest-observed marker angle, radians + +const float PIP_ANG = 2.35619449; // 3/4 pi, the first/last pip angle +const float FACE_ANG = 2.3693; // rim ends just past the end pips, like the canvas.Arc face +const float PAD = 4.0; // logical px, keep in sync with dialPad + +const vec3 FACE_COL = vec3(0.502, 0.502, 0.502); // 0x808080 +const vec3 CENTER_COL = vec3(0.0039, 0.0431, 0.0745); // 0x010B13 +const vec3 NEEDLE_COL = vec3(1.0, 0.4039, 0.0); // 0xFF6700 +const vec3 MARKER_COL = vec3(0.8471, 0.9804, 0.0314); // 0xD8FA08 + +// distance to the radial bar at angle a covering radius [r0, r1], half width hw +float radial_d(vec2 p, float a, float r0, float r1, float hw) { + vec2 dir = vec2(sin(a), -cos(a)); + float u = dot(p, dir); + float v = dot(p, vec2(-dir.y, dir.x)); + return length(vec2(u - clamp(u, r0, r1), v)) - hw; +} + +// 1 px anti-aliased coverage of signed distance d (device px) +float aa(float d) { + return clamp(0.5 - d, 0.0, 1.0); +} + +// src-over: lay coverage a of colour c on top; col stays premultiplied +void over(inout vec3 col, inout float alpha, vec3 c, float a) { + col = col * (1.0 - a) + c * a; + alpha = alpha * (1.0 - a) + a; +} + +void main() { + vec2 ext = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > ext.x || p_dev.y > ext.y) { + discard; + } + + float px = ext.x / (size_d + 2.0 * PAD); // device px per logical px + float r = 0.5 * size_d * px; // dial radius, device px + vec2 p = p_dev - 0.5 * ext; + + float len = length(p); + float theta = atan(p.x, -p.y); // 0 up, clockwise positive + + vec3 col = vec3(0.0); + float alpha = 0.0; + + // pips: strokes are far thinner than the pip spacing, so only the + // nearest pip can cover this pixel - no loop needed + float n = max(steps, 1.0); + float step_a = 4.71238898 / n; // Pi15 between first and last pip + float i = clamp(floor((theta + PIP_ANG) / step_a + 0.5), 0.0, n); + float odd = mod(i, 2.0); + float hw = 0.5 * px * mix(max(2.0, size_d / 80.0), max(2.0, size_d / 200.0), odd); + float rin = mix(0.75, 0.875, odd) * r; + // intersect with a disc one logical px inside the rim edge: the round + // end cap must not poke past the rim, and the AA fringe of the cut has + // to stay under the opaque part of the rim + float d = max(radial_d(p, i * step_a - PIP_ANG, rin, r - px, hw), len - (r - px)); + // green -> yellow -> red, like the CPU pip gradient + float t = i / n; + vec3 pip_col = vec3(clamp(2.0 * t, 0.0, 1.0), clamp(2.0 - 2.0 * t, 0.0, 1.0), 0.0); + over(col, alpha, pip_col, aa(d)); + + // face rim: the ring [0.985r, r] over the pip arc; the angular term is + // the arc length past the rim ends + d = max(abs(len - 0.9925 * r) - 0.0075 * r, (abs(theta) - FACE_ANG) * len); + over(col, alpha, FACE_COL, aa(d)); + + // center cap, diameter r/4 + over(col, alpha, CENTER_COL, aa(len - 0.125 * r)); + + // highest-observed marker: radius-2 .. radius+4 like the CPU line + float mhw = 0.5 * px * max(2.0, size_d / 80.0); + over(col, alpha, MARKER_COL, aa(radial_d(p, marker_a, r - 2.0 * px, r + 4.0 * px, mhw))); + + // needle: offset -0.15r, length 1.14r, tip pulled in 2 logical px + float nhw = 0.5 * px * (size_d / 60.0); + over(col, alpha, NEEDLE_COL, aa(radial_d(p, needle_a, -0.15 * r, 0.99 * r - 2.0 * px, nhw))); + + if (alpha < 0.004) { + discard; + } + gl_FragColor = vec4(col / alpha, alpha); +} +` diff --git a/pkg/widgets/dial/dial_shader_test.go b/pkg/widgets/dial/dial_shader_test.go new file mode 100644 index 00000000..31e6e0e3 --- /dev/null +++ b/pkg/widgets/dial/dial_shader_test.go @@ -0,0 +1,29 @@ +package dial + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestDialShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": dialShaderPreludeGL + dialShaderBody, + "es.frag": dialShaderPreludeES + dialShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/dualdial/dual_dial.go b/pkg/widgets/dualdial/dual_dial.go index 8dc44673..85f9920d 100644 --- a/pkg/widgets/dualdial/dual_dial.go +++ b/pkg/widgets/dualdial/dual_dial.go @@ -23,15 +23,11 @@ type DualDial struct { value float64 value2 float64 - needle *canvas.Line - needle2 *canvas.Line + // All gauge geometry (face, pips, both needles, center) in one object + shader *canvas.Shader - pips []*canvas.Line pipLabels []*canvas.Text - face *canvas.Arc - center *canvas.Circle - displayText *canvas.Text displayText2 *canvas.Text @@ -41,14 +37,13 @@ type DualDial struct { size fyne.Size minsize fyne.Size - diameter float32 - radius float32 - middle fyne.Position - needleOffset, needleLength float32 - needleRotConst float64 - lineRotConst float64 + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 + lineRotConst float64 - // cached sin/cos for pips (angle_i = lineRotConst*i - common.Pi43) + // cached sin/cos for pip labels (angle_i = lineRotConst*i - common.Pi43) pipSin []float32 pipCos []float32 @@ -94,10 +89,24 @@ func New(cfg *widgets.GaugeConfig) *DualDial { s.factor = s.cfg.Max / s.steps - s.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) - s.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} - s.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 2} - s.needle2 = &canvas.Line{StrokeColor: color.RGBA{R: 249, G: 27, B: 2, A: 255}, StrokeWidth: 2} + totalRange := s.cfg.Max - s.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + s.needleRotConst = common.Pi15 / totalRange + s.lineRotConst = common.Pi15 / s.steps + + s.shader = canvas.NewShader( + "txlogger-dualdial", + []byte(dualDialShaderPreludeGL+dualDialShaderBody), + []byte(dualDialShaderPreludeES+dualDialShaderBody), + ) + s.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(s.steps), + "needle_a": s.needleAngle(0), + "needle2_a": s.needleAngle(0), + } s.titleText = &canvas.Text{Text: s.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} s.titleText.TextStyle.Monospace = true @@ -109,25 +118,8 @@ func New(cfg *widgets.GaugeConfig) *DualDial { s.displayText2 = &canvas.Text{Text: "0", Color: color.RGBA{R: 0xff, G: 0x0, B: 0, A: 0xFF}, TextSize: 35} s.displayText2.Alignment = fyne.TextAlignCenter - // Pip color gradient - // Green to Yellow to Red gradient - // 0% - 50% Green to Yellow - // 50% - 100% Yellow to Red - halfSteps := float64(s.cfg.Steps) / 2.0 - var col color.RGBA + // Labels at every other pip; also track the longest label length for i := 0; i <= int(s.steps); i++ { - if float64(i) <= halfSteps { - // Green to Yellow - ratio := float64(i) / halfSteps - col = color.RGBA{R: byte(255 * ratio), G: 255, B: 0, A: 255} - } else { - // Yellow to Red - ratio := (float64(i) - halfSteps) / halfSteps - col = color.RGBA{R: 255, G: byte(255 * (1 - ratio)), B: 0, A: 255} - } - pip := &canvas.Line{StrokeColor: col, StrokeWidth: 2} - s.pips = append(s.pips, pip) - if i%2 == 0 { val := s.cfg.Min + (float64(i)/float64(s.cfg.Steps))*(s.cfg.Max-s.cfg.Min) txt := strconv.FormatFloat(val, 'f', s.gaugePrec, 64) @@ -136,7 +128,6 @@ func New(cfg *widgets.GaugeConfig) *DualDial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } - // lbl.TextStyle.Monospace = true if n := len(txt); n > s.maxLabelChars { s.maxLabelChars = n } @@ -146,14 +137,7 @@ func New(cfg *widgets.GaugeConfig) *DualDial { } } - totalRange := s.cfg.Max - s.cfg.Min - if totalRange <= 0 { - totalRange = 1 - } - s.needleRotConst = common.Pi15 / totalRange - s.lineRotConst = common.Pi15 / s.steps - - // precompute pip trig (size independent) + // precompute pip trig for label placement (size independent) s.pipSin = make([]float32, int(s.steps)+1) s.pipCos = make([]float32, int(s.steps)+1) for i := 0; i <= int(s.steps); i++ { @@ -168,24 +152,14 @@ func New(cfg *widgets.GaugeConfig) *DualDial { func (c *DualDial) GetConfig() *widgets.GaugeConfig { return c.cfg } -func (c *DualDial) rotateNeedleNoRefresh(hand *canvas.Line, facePosition float64, offset, length float32) { - normalized := facePosition - c.cfg.Min +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *DualDial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min if normalized < 0 { normalized = 0 } - s, co := math.Sincos(c.needleRotConst*normalized - common.Pi43) - c.applySinCos(hand, float32(s), float32(co), offset, length) -} - -func (c *DualDial) applySinCos(hand *canvas.Line, sinRot, cosRot float32, offset, length float32) { - x2 := length * sinRot - y2 := -length * cosRot - offX := offset * sinRot - offY := -offset * cosRot - midxOffX := c.middle.X + offX - midY := c.middle.Y + offY - hand.Position1 = fyne.Position{X: midxOffX, Y: midY} - hand.Position2 = fyne.Position{X: midxOffX + x2, Y: midY + y2} + return float32(c.needleRotConst*normalized - common.Pi43) } func (c *DualDial) SetValue(value float64) { @@ -194,7 +168,7 @@ func (c *DualDial) SetValue(value float64) { } c.value = value - c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) + c.shader.Uniforms["needle_a"] = c.needleAngle(value) c.buf1 = c.buf1[:0] if c.fmtPrec >= 0 { @@ -202,15 +176,12 @@ func (c *DualDial) SetValue(value float64) { } else { c.buf1 = common.AppendFormatFloat(c.buf1, c.displayString, value) } - textChanged := !common.SameTextBytes(c.displayText.Text, c.buf1) - if textChanged { + if !common.SameTextBytes(c.displayText.Text, c.buf1) { c.displayText.Text = string(c.buf1) - } - - canvas.Refresh(c.needle) - if textChanged { canvas.Refresh(c.displayText) } + + canvas.Refresh(c.shader) } func (c *DualDial) SetValue2(value float64) { @@ -219,7 +190,7 @@ func (c *DualDial) SetValue2(value float64) { } c.value2 = value - c.rotateNeedleNoRefresh(c.needle2, value, c.needleOffset, c.needleLength) + c.shader.Uniforms["needle2_a"] = c.needleAngle(value) c.buf2 = c.buf2[:0] if c.fmtPrec >= 0 { @@ -227,15 +198,12 @@ func (c *DualDial) SetValue2(value float64) { } else { c.buf2 = common.AppendFormatFloat(c.buf2, c.displayString, value) } - textChanged := !common.SameTextBytes(c.displayText2.Text, c.buf2) - if textChanged { + if !common.SameTextBytes(c.displayText2.Text, c.buf2) { c.displayText2.Text = string(c.buf2) - } - - canvas.Refresh(c.needle2) - if textChanged { canvas.Refresh(c.displayText2) } + + canvas.Refresh(c.shader) } func (c *DualDial) CreateRenderer() fyne.WidgetRenderer { return &DualDialRenderer{DualDial: c} } @@ -255,24 +223,17 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) - c.needleOffset = -c.radius * .15 - c.needleLength = c.radius * 1.14 - - stroke := c.diameter * common.OneSixthieth - midStroke := c.diameter * common.OneEighthieth - smallStroke := c.diameter * common.OneTwohundredth - size := fyne.Size{Width: c.diameter, Height: c.diameter} topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + c.shader.Move(topleft) + c.shader.Resize(size) + c.shader.Uniforms["size_d"] = c.diameter + // Title & display text sizes (no math.Round needed) c.titleText.TextSize = c.radius * common.OneFourth c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) - center := c.radius * common.OneFourth - c.center.Move(c.middle.SubtractXY(center*common.OneHalf, center*common.OneHalf)) - c.center.Resize(fyne.Size{Width: center, Height: center}) - sixthDiameter := c.diameter * common.OneSixth c.displayText.TextSize = c.radius * common.OneThird @@ -283,20 +244,8 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.displayText2.Move(topleft.AddXY(0, -sixthDiameter)) c.displayText2.Resize(size) - // Needles & face - c.needle.StrokeWidth = stroke - c.needle2.StrokeWidth = stroke - c.rotateNeedleNoRefresh(c.needle, c.value, c.needleOffset, c.needleLength) - c.rotateNeedleNoRefresh(c.needle2, c.value2, c.needleOffset, c.needleLength) - - c.face.Move(c.middle.SubtractXY(c.radius, c.radius)) - c.face.Resize(fyne.Size{Width: c.diameter, Height: c.diameter}) - - // Pips using precomputed trig scaled by current radius - fourthRadius := c.radius * common.OneFourth - eightRadius := c.radius * common.OneEight + // Labels: reuse precomputed trig scaled by current radius radius43 := c.radius * common.OneFourth * 3 - radius87 := c.radius * common.OneEight * 7 // Label padding and cached box dims (avoid lbl.MinSize per label) labelPad := max(float32(6.0), c.radius*0.14) @@ -306,27 +255,19 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize c.labelBoxH = float32(heightFactor) * labelTextSize - for i, p := range c.pips { - if i%2 == 0 { - p.StrokeWidth = max(2.0, midStroke) - c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius43, fourthRadius-1) - // Label for long pip (uniform box; no MinSize) - lbl := c.pipLabels[i] - if lbl != nil { - lbl.TextSize = labelTextSize - // place inside the gauge slightly inward from long pip inner end - labelRadius := radius43 - labelPad - cx := c.middle.X + c.pipSin[i]*labelRadius - cy := c.middle.Y - c.pipCos[i]*labelRadius - boxW := c.labelBoxW - boxH := c.labelBoxH - lbl.Resize(fyne.NewSize(boxW, boxH)) - lbl.Move(fyne.NewPos(cx-boxW/2, cy-boxH/2)) - } - } else { - p.StrokeWidth = max(2.0, smallStroke) - c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) + for i, lbl := range c.pipLabels { + if lbl == nil { + continue } + lbl.TextSize = labelTextSize + + // place inside the gauge slightly inward from long pip inner end + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.NewPos(cx-c.labelBoxW/2, cy-c.labelBoxH/2)) } } @@ -336,16 +277,14 @@ func (c *DualDialRenderer) Destroy() {} func (c *DualDialRenderer) Objects() []fyne.CanvasObject { if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) - for _, v := range c.pips { - objs = append(objs, v) - } + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+4) + objs = append(objs, c.shader) for _, v := range c.pipLabels { if v != nil { objs = append(objs, v) } } - objs = append(objs, c.face, c.titleText, c.center, c.needle2, c.needle, c.displayText, c.displayText2) + objs = append(objs, c.titleText, c.displayText, c.displayText2) c.objects = objs } return c.objects diff --git a/pkg/widgets/dualdial/dual_dial_shader.go b/pkg/widgets/dualdial/dual_dial_shader.go new file mode 100644 index 00000000..54b784b7 --- /dev/null +++ b/pkg/widgets/dualdial/dual_dial_shader.go @@ -0,0 +1,119 @@ +package dualdial + +// GPU renderer: like the dial widget, the whole gauge geometry (face rim, +// pips, both needles and center cap) is drawn by a single canvas.Shader; +// a SetValue/SetValue2 only writes that needle's angle uniform. Text +// (title, both values, pip labels) stays as canvas.Text layered on top. +// +// All dual dials share one Shader.Name: the painter caches the compiled +// program per name and the widget needs no textures, so every instance +// reuses the same program with its own uniforms. +// +// Conventions match the dial shader: angles are radians, 0 pointing up, +// clockwise positive; the needles sweep Pi15 (270 degrees) from -3/4 pi at +// Min to +3/4 pi at Max. There is no marker overhanging the rim, so the +// shader quad is exactly the dial square. + +const dualDialShaderPreludeGL = "#version 110\n" + +const dualDialShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const dualDialShaderBody = ` +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform float size_d; // dial diameter, logical px +uniform float steps; // pip intervals; steps+1 pips are drawn +uniform float needle_a; // primary needle angle, radians +uniform float needle2_a; // secondary needle angle, radians + +const float PIP_ANG = 2.35619449; // 3/4 pi, the first/last pip angle +const float FACE_ANG = 2.3693; // rim ends just past the end pips, like the canvas.Arc face + +const vec3 FACE_COL = vec3(0.502, 0.502, 0.502); // 0x808080 +const vec3 CENTER_COL = vec3(0.0039, 0.0431, 0.0745); // 0x010B13 +const vec3 NEEDLE_COL = vec3(1.0, 0.4039, 0.0); // 0xFF6700 +const vec3 NEEDLE2_COL = vec3(0.9765, 0.1059, 0.0078); // 0xF91B02 + +// distance to the radial bar at angle a covering radius [r0, r1], half width hw +float radial_d(vec2 p, float a, float r0, float r1, float hw) { + vec2 dir = vec2(sin(a), -cos(a)); + float u = dot(p, dir); + float v = dot(p, vec2(-dir.y, dir.x)); + return length(vec2(u - clamp(u, r0, r1), v)) - hw; +} + +// 1 px anti-aliased coverage of signed distance d (device px) +float aa(float d) { + return clamp(0.5 - d, 0.0, 1.0); +} + +// src-over: lay coverage a of colour c on top; col stays premultiplied +void over(inout vec3 col, inout float alpha, vec3 c, float a) { + col = col * (1.0 - a) + c * a; + alpha = alpha * (1.0 - a) + a; +} + +void main() { + vec2 ext = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > ext.x || p_dev.y > ext.y) { + discard; + } + + float px = ext.x / max(size_d, 1.0); // device px per logical px + float r = 0.5 * ext.x; // dial radius, device px + vec2 p = p_dev - 0.5 * ext; + + float len = length(p); + float theta = atan(p.x, -p.y); // 0 up, clockwise positive + + vec3 col = vec3(0.0); + float alpha = 0.0; + + // pips: strokes are far thinner than the pip spacing, so only the + // nearest pip can cover this pixel - no loop needed + float n = max(steps, 1.0); + float step_a = 4.71238898 / n; // Pi15 between first and last pip + float i = clamp(floor((theta + PIP_ANG) / step_a + 0.5), 0.0, n); + float odd = mod(i, 2.0); + float hw = 0.5 * px * mix(max(2.0, size_d / 80.0), max(2.0, size_d / 200.0), odd); + float rin = mix(0.75, 0.875, odd) * r; + // intersect with a disc one logical px inside the rim edge: the round + // end cap must not poke past the rim, and the AA fringe of the cut has + // to stay under the opaque part of the rim + float d = max(radial_d(p, i * step_a - PIP_ANG, rin, r - px, hw), len - (r - px)); + // green -> yellow -> red, like the CPU pip gradient + float t = i / n; + vec3 pip_col = vec3(clamp(2.0 * t, 0.0, 1.0), clamp(2.0 - 2.0 * t, 0.0, 1.0), 0.0); + over(col, alpha, pip_col, aa(d)); + + // face rim: the ring [0.985r, r] over the pip arc; the angular term is + // the arc length past the rim ends + d = max(abs(len - 0.9925 * r) - 0.0075 * r, (abs(theta) - FACE_ANG) * len); + over(col, alpha, FACE_COL, aa(d)); + + // center cap, diameter r/4 + over(col, alpha, CENTER_COL, aa(len - 0.125 * r)); + + // needles: offset -0.15r, length 1.14r, tips pulled in 2 logical px; + // the primary draws on top + float nhw = 0.5 * px * (size_d / 60.0); + float ntip = 0.99 * r - 2.0 * px; + over(col, alpha, NEEDLE2_COL, aa(radial_d(p, needle2_a, -0.15 * r, ntip, nhw))); + over(col, alpha, NEEDLE_COL, aa(radial_d(p, needle_a, -0.15 * r, ntip, nhw))); + + if (alpha < 0.004) { + discard; + } + gl_FragColor = vec4(col / alpha, alpha); +} +` diff --git a/pkg/widgets/dualdial/dual_dial_shader_test.go b/pkg/widgets/dualdial/dual_dial_shader_test.go new file mode 100644 index 00000000..eb6d6f63 --- /dev/null +++ b/pkg/widgets/dualdial/dual_dial_shader_test.go @@ -0,0 +1,29 @@ +package dualdial + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestDualDialShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": dualDialShaderPreludeGL + dualDialShaderBody, + "es.frag": dualDialShaderPreludeES + dualDialShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} From 988e9ac3192b5c1efec2b17f3047cddc99f6ae3e Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 01:02:31 +0200 Subject: [PATCH 33/93] update whatsnew --- pkg/assets/WHATSNEW.md | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index d8f250a1..259030a9 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -17,6 +17,7 @@ - Added 2d graph for viewing flat maps - Rewrote logplayer plotter to use about 50% less CPU on zoomed out views - Big refactor of the log writing logic to be simpler to maintain and be more performant +- Dial and Dual Dial now uses shaders to draw the faces, dials and pips # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet From 905822c854a2ad8897d68b0ff64b3f2be1d29ffa Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 11:42:09 +0200 Subject: [PATCH 34/93] revert dial shaders --- pkg/widgets/dial/dial.go | 202 ++++++++---- pkg/widgets/dial/shader/dial.go | 285 +++++++++++++++++ pkg/widgets/dial/{ => shader}/dial_shader.go | 0 .../dial/{ => shader}/dial_shader_test.go | 0 pkg/widgets/dualdial/dual_dial.go | 183 +++++++---- pkg/widgets/dualdial/shader/dual_dial.go | 300 ++++++++++++++++++ .../dualdial/{ => shader}/dual_dial_shader.go | 0 .../{ => shader}/dual_dial_shader_test.go | 0 8 files changed, 849 insertions(+), 121 deletions(-) create mode 100644 pkg/widgets/dial/shader/dial.go rename pkg/widgets/dial/{ => shader}/dial_shader.go (100%) rename pkg/widgets/dial/{ => shader}/dial_shader_test.go (100%) create mode 100644 pkg/widgets/dualdial/shader/dual_dial.go rename pkg/widgets/dualdial/{ => shader}/dual_dial_shader.go (100%) rename pkg/widgets/dualdial/{ => shader}/dual_dial_shader_test.go (100%) diff --git a/pkg/widgets/dial/dial.go b/pkg/widgets/dial/dial.go index acf10f0a..28057828 100644 --- a/pkg/widgets/dial/dial.go +++ b/pkg/widgets/dial/dial.go @@ -23,26 +23,29 @@ type Dial struct { value float64 highestObserved float64 - lastHighestObserved time.Time - - // All dial geometry (face, pips, needle, marker, center) in one object - shader *canvas.Shader + needle *canvas.Line + highestObservedMarker *canvas.Line + lastHighestObserved time.Time + pips []*canvas.Line pipLabels []*canvas.Text + face *canvas.Arc + center *canvas.Circle displayText *canvas.Text titleText *canvas.Text size fyne.Size minsize fyne.Size - diameter float32 - radius float32 - middle fyne.Position - needleRotConst float64 // = common.Pi15/(Max-Min) - lineRotConst float64 // = common.Pi15/steps + diameter float32 + radius float32 + middle fyne.Position + needleOffset, needleLength float32 + needleRotConst float64 // = common.Pi15/(Max-Min) + lineRotConst float64 // = common.Pi15/steps - // Precomputed trig for pip labels: angle_i = lineRotConst*float64(i) - common.Pi43 + // Precomputed trig for pips: angle_i = lineRotConst*float64(i) - common.Pi43 pipSin []float32 pipCos []float32 @@ -92,21 +95,10 @@ func New(cfg *widgets.GaugeConfig) *Dial { c.factor = c.cfg.Max / steps - // Constants - c.needleRotConst = common.Pi15 / totalRange - c.lineRotConst = common.Pi15 / steps - - c.shader = canvas.NewShader( - "txlogger-dial", - []byte(dialShaderPreludeGL+dialShaderBody), - []byte(dialShaderPreludeES+dialShaderBody), - ) - c.shader.Uniforms = map[string]float32{ - "size_d": 100, - "steps": float32(cfg.Steps), - "needle_a": c.needleAngle(0), - "marker_a": c.needleAngle(0), - } + c.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) + c.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} + c.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 3} + c.highestObservedMarker = &canvas.Line{StrokeColor: color.RGBA{R: 216, G: 250, B: 8, A: 0xFF}, StrokeWidth: 6} c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} c.titleText.TextStyle.Monospace = true @@ -115,8 +107,27 @@ func New(cfg *widgets.GaugeConfig) *Dial { c.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} c.displayText.Alignment = fyne.TextAlignCenter - // Labels at every other pip; also track the longest label length + // Pip color gradient + // Green to Yellow to Red gradient + // 0% - 50% Green to Yellow + // 50% - 100% Yellow to Red + halfSteps := float64(c.cfg.Steps) / 2.0 + + // Build pips + labels once; also track the longest label length + var col color.RGBA for i := 0; i < c.cfg.Steps+1; i++ { + if float64(i) <= halfSteps { + // Green to Yellow + ratio := float64(i) / halfSteps + col = color.RGBA{R: byte(255 * ratio), G: 255, B: 0, A: 255} + } else { + // Yellow to Red + ratio := (float64(i) - halfSteps) / halfSteps + col = color.RGBA{R: 255, G: byte(255 * (1 - ratio)), B: 0, A: 255} + } + pip := &canvas.Line{StrokeColor: col, StrokeWidth: 2} + c.pips = append(c.pips, pip) + if i%2 == 0 { val := c.cfg.Min + (float64(i)/float64(c.cfg.Steps))*(c.cfg.Max-c.cfg.Min)*c.cfg.GaugeFactor txt := strconv.FormatFloat(val, 'f', c.gaugePrec, 64) @@ -126,6 +137,7 @@ func New(cfg *widgets.GaugeConfig) *Dial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } + // lbl.TextStyle.Monospace = true if n := len(txt); n > c.maxLabelChars { c.maxLabelChars = n } @@ -135,7 +147,11 @@ func New(cfg *widgets.GaugeConfig) *Dial { } } - // Precompute pip sin/cos for label placement (size-independent) + // Constants + c.needleRotConst = common.Pi15 / totalRange + c.lineRotConst = common.Pi15 / steps + + // Precompute pip sin/cos (size-independent) c.pipSin = make([]float32, c.cfg.Steps+1) c.pipCos = make([]float32, c.cfg.Steps+1) for i := 0; i <= c.cfg.Steps; i++ { @@ -150,14 +166,29 @@ func New(cfg *widgets.GaugeConfig) *Dial { func (c *Dial) GetConfig() *widgets.GaugeConfig { return c.cfg } -// needle angle for a face value; clamped below Min like the CPU renderer, -// free to overshoot above Max -func (c *Dial) needleAngle(value float64) float32 { - normalized := value - c.cfg.Min +// rotate angle (in radians) without refreshing per-object +func (c *Dial) rotateNoRefresh(hand *canvas.Line, rotation float64, offset, length float32) { + s, co := math.Sincos(rotation) + c.applySinCos(hand, float32(s), float32(co), offset, length) +} + +func (c *Dial) applySinCos(hand *canvas.Line, sinRot, cosRot float32, offset, length float32) { + x2 := length * sinRot + y2 := -length * cosRot + offX := offset * sinRot + offY := -offset * cosRot + midxOffX := c.middle.X + offX + midY := c.middle.Y + offY + hand.Position1 = fyne.Position{X: midxOffX, Y: midY} + hand.Position2 = fyne.Position{X: midxOffX + x2, Y: midY + y2} +} + +func (c *Dial) rotateNeedleNoRefresh(hand *canvas.Line, facePosition float64, offset, length float32) { + normalized := facePosition - c.cfg.Min if normalized < 0 { normalized = 0 } - return float32(c.needleRotConst*normalized - common.Pi43) + c.rotateNoRefresh(hand, c.needleRotConst*normalized-common.Pi43, offset, length) } func (c *Dial) SetValue(value float64) { @@ -166,13 +197,21 @@ func (c *Dial) SetValue(value float64) { } c.value = value - c.shader.Uniforms["needle_a"] = c.needleAngle(value) + // Update needle position (no immediate refresh) + c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) - // Highest observed marker with lazy reset - if value > c.highestObserved || time.Since(c.lastHighestObserved) > 10*time.Second { + // Highest observed marker with lazy reset; only refresh when it actually moves + markerMoved := false + if value > c.highestObserved { c.highestObserved = value c.lastHighestObserved = time.Now() - c.shader.Uniforms["marker_a"] = c.needleAngle(value) + c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) + markerMoved = true + } else if time.Since(c.lastHighestObserved) > 10*time.Second { + c.highestObserved = value + c.lastHighestObserved = time.Now() + c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) + markerMoved = true } // Update text with minimal allocs; skip refresh if formatted output is unchanged @@ -182,12 +221,18 @@ func (c *Dial) SetValue(value float64) { } else { c.buf = common.AppendFormatFloat(c.buf, c.displayString, value) } - if !common.SameTextBytes(c.displayText.Text, c.buf) { + textChanged := !common.SameTextBytes(c.displayText.Text, c.buf) + if textChanged { c.displayText.Text = string(c.buf) - canvas.Refresh(c.displayText) } - canvas.Refresh(c.shader) + canvas.Refresh(c.needle) + if markerMoved { + canvas.Refresh(c.highestObservedMarker) + } + if textChanged { + canvas.Refresh(c.displayText) + } } func (c *Dial) SetValue2(value float64) { c.SetValue(value) } @@ -208,26 +253,45 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.diameter = fyne.Min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + c.needleOffset = -c.radius * .15 + c.needleLength = c.radius * 1.14 + + // Stroke sizes + stroke := c.diameter * common.OneSixthieth + midStroke := c.diameter * common.OneEighthieth + smallStroke := c.diameter * common.OneTwohundredth size := fyne.Size{Width: c.diameter, Height: c.diameter} topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) - // Shader quad: the dial square padded so the marker overhang isn't clipped - c.shader.Move(topleft.SubtractXY(dialPad, dialPad)) - c.shader.Resize(fyne.Size{Width: c.diameter + 2*dialPad, Height: c.diameter + 2*dialPad}) - c.shader.Uniforms["size_d"] = c.diameter - // Title (no rounding needed) c.titleText.TextSize = float32(int(c.radius * common.OneFourth)) c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + // Center element + center := c.radius * common.OneFourth + c.center.Move(c.middle.SubtractXY(center*common.OneHalf, center*common.OneHalf)) + c.center.Resize(fyne.Size{Width: center, Height: center}) + // Display text c.displayText.TextSize = float32(int(c.radius * common.OneThird)) c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) c.displayText.Resize(size) - // Labels: reuse precomputed sin/cos, scale with current radius + // Face + needle + c.needle.StrokeWidth = stroke + c.rotateNeedleNoRefresh(c.needle, c.value, c.needleOffset, c.needleLength) + + c.face.Move(c.middle.SubtractXY(c.radius, c.radius)) + c.face.Resize(fyne.Size{Width: c.diameter, Height: c.diameter}) + + // Pips: reuse precomputed sin/cos, scale with current radii + fourthRadius := c.radius * common.OneFourth + eightRadius := c.radius * common.OneEight radius43 := c.radius * common.OneFourth * 3 + radius87 := c.radius * common.OneEight * 7 + + // Label padding and cached box dims (avoid lbl.MinSize per label) labelPad := max(float32(6.0), c.radius*0.14) // Assume monospace, digits only: width ≈ chars * 0.62 * TextSize; height ≈ 1.15 * TextSize @@ -239,20 +303,35 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize c.labelBoxH = float32(heightFactor) * labelTextSize - for i, lbl := range c.pipLabels { - if lbl == nil { - continue + for i, p := range c.pips { + if i%2 == 0 { + p.StrokeWidth = max(2.0, midStroke) + c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius43, fourthRadius-1) + + // Label for long pip + lbl := c.pipLabels[i] + if lbl != nil { + lbl.TextSize = labelTextSize + + // Place label on the INSIDE of the gauge + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + boxW := c.labelBoxW + boxH := c.labelBoxH + lbl.Resize(fyne.NewSize(boxW, boxH)) + // lbl.Move(fyne.NewPos(cx-boxW/2, cy-boxH/2)) + lbl.Move(fyne.Position{X: cx - boxW/2, Y: cy - boxH/2}) + } + } else { + p.StrokeWidth = max(2.0, smallStroke) + c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) } - lbl.TextSize = labelTextSize - - // Place label on the INSIDE of the gauge - labelRadius := radius43 - labelPad - cx := c.middle.X + c.pipSin[i]*labelRadius - cy := c.middle.Y - c.pipCos[i]*labelRadius - - lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) - lbl.Move(fyne.Position{X: cx - c.labelBoxW/2, Y: cy - c.labelBoxH/2}) } + + c.highestObservedMarker.StrokeWidth = max(2.0, midStroke) + c.rotateNeedleNoRefresh(c.highestObservedMarker, c.highestObserved, c.radius-2, 6) } func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } @@ -261,14 +340,17 @@ func (c *DialRenderer) Destroy() {} func (c *DialRenderer) Objects() []fyne.CanvasObject { if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+3) - objs = append(objs, c.shader) + objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) + for _, v := range c.pips { + objs = append(objs, v) + } for _, t := range c.pipLabels { if t != nil { objs = append(objs, t) } } - objs = append(objs, c.titleText, c.displayText) + objs = append(objs, c.face, c.titleText, c.center, + c.highestObservedMarker, c.needle, c.displayText) c.objects = objs } return c.objects diff --git a/pkg/widgets/dial/shader/dial.go b/pkg/widgets/dial/shader/dial.go new file mode 100644 index 00000000..acf10f0a --- /dev/null +++ b/pkg/widgets/dial/shader/dial.go @@ -0,0 +1,285 @@ +package dial + +import ( + "image/color" + "math" + "strconv" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/widgets" +) + +type Dial struct { + widget.BaseWidget + displayString string + + cfg *widgets.GaugeConfig + + factor float64 + value float64 + highestObserved float64 + + lastHighestObserved time.Time + + // All dial geometry (face, pips, needle, marker, center) in one object + shader *canvas.Shader + + pipLabels []*canvas.Text + + displayText *canvas.Text + titleText *canvas.Text + + size fyne.Size + minsize fyne.Size + + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 // = common.Pi15/(Max-Min) + lineRotConst float64 // = common.Pi15/steps + + // Precomputed trig for pip labels: angle_i = lineRotConst*float64(i) - common.Pi43 + pipSin []float32 + pipCos []float32 + + // Fast float formatting + fmtPrec int // precision extracted from displayString like "%.0f", "%.1f", defaults to -1 + gaugePrec int // precision extracted from GaugeTextString like "%.0f", "%.1f", defaults to -1 + buf []byte + + // Label sizing cache (avoids MinSize calls every layout) + maxLabelChars int // longest label length at construction + labelBoxW float32 // computed each layout from TextSize and maxLabelChars + labelBoxH float32 // computed each layout from TextSize +} + +func New(cfg *widgets.GaugeConfig) *Dial { + c := &Dial{ + cfg: cfg, + displayString: "%.0f", + minsize: fyne.NewSize(100, 100), + fmtPrec: -1, + } + c.ExtendBaseWidget(c) + + if cfg.DisplayString != "" { + c.displayString = cfg.DisplayString + if n := common.ParseFixedPrec(c.displayString); n >= 0 { + c.fmtPrec = n + } + } + if cfg.GaugeTextString != "" { + if n := common.ParseFixedPrec(cfg.GaugeTextString); n >= 0 { + c.gaugePrec = n + } + } + if cfg.GaugeFactor == 0 { + cfg.GaugeFactor = 1.0 + } + if cfg.MinSize.Width > 0 && cfg.MinSize.Height > 0 { + c.minsize = cfg.MinSize + } + + steps := float64(cfg.Steps) + totalRange := c.cfg.Max - c.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + + c.factor = c.cfg.Max / steps + + // Constants + c.needleRotConst = common.Pi15 / totalRange + c.lineRotConst = common.Pi15 / steps + + c.shader = canvas.NewShader( + "txlogger-dial", + []byte(dialShaderPreludeGL+dialShaderBody), + []byte(dialShaderPreludeES+dialShaderBody), + ) + c.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(cfg.Steps), + "needle_a": c.needleAngle(0), + "marker_a": c.needleAngle(0), + } + + c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} + c.titleText.TextStyle.Monospace = true + c.titleText.Alignment = fyne.TextAlignCenter + + c.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} + c.displayText.Alignment = fyne.TextAlignCenter + + // Labels at every other pip; also track the longest label length + for i := 0; i < c.cfg.Steps+1; i++ { + if i%2 == 0 { + val := c.cfg.Min + (float64(i)/float64(c.cfg.Steps))*(c.cfg.Max-c.cfg.Min)*c.cfg.GaugeFactor + txt := strconv.FormatFloat(val, 'f', c.gaugePrec, 64) + + lbl := &canvas.Text{ + Text: txt, + Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, + Alignment: fyne.TextAlignCenter, + } + if n := len(txt); n > c.maxLabelChars { + c.maxLabelChars = n + } + c.pipLabels = append(c.pipLabels, lbl) + } else { + c.pipLabels = append(c.pipLabels, nil) + } + } + + // Precompute pip sin/cos for label placement (size-independent) + c.pipSin = make([]float32, c.cfg.Steps+1) + c.pipCos = make([]float32, c.cfg.Steps+1) + for i := 0; i <= c.cfg.Steps; i++ { + ang := c.lineRotConst*float64(i) - common.Pi43 + s, co := math.Sincos(ang) + c.pipSin[i] = float32(s) + c.pipCos[i] = float32(co) + } + + return c +} + +func (c *Dial) GetConfig() *widgets.GaugeConfig { return c.cfg } + +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *Dial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min + if normalized < 0 { + normalized = 0 + } + return float32(c.needleRotConst*normalized - common.Pi43) +} + +func (c *Dial) SetValue(value float64) { + if value == c.value { + return + } + c.value = value + + c.shader.Uniforms["needle_a"] = c.needleAngle(value) + + // Highest observed marker with lazy reset + if value > c.highestObserved || time.Since(c.lastHighestObserved) > 10*time.Second { + c.highestObserved = value + c.lastHighestObserved = time.Now() + c.shader.Uniforms["marker_a"] = c.needleAngle(value) + } + + // Update text with minimal allocs; skip refresh if formatted output is unchanged + c.buf = c.buf[:0] + if c.fmtPrec >= 0 { + c.buf = strconv.AppendFloat(c.buf, value, 'f', c.fmtPrec, 64) + } else { + c.buf = common.AppendFormatFloat(c.buf, c.displayString, value) + } + if !common.SameTextBytes(c.displayText.Text, c.buf) { + c.displayText.Text = string(c.buf) + canvas.Refresh(c.displayText) + } + + canvas.Refresh(c.shader) +} + +func (c *Dial) SetValue2(value float64) { c.SetValue(value) } + +func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{Dial: c} } + +type DialRenderer struct { + *Dial + objects []fyne.CanvasObject +} + +func (c *DialRenderer) Layout(space fyne.Size) { + if c.size == space { + return + } + c.size = space + + c.diameter = fyne.Min(space.Width, space.Height) + c.radius = c.diameter * common.OneHalf + c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + + size := fyne.Size{Width: c.diameter, Height: c.diameter} + topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + + // Shader quad: the dial square padded so the marker overhang isn't clipped + c.shader.Move(topleft.SubtractXY(dialPad, dialPad)) + c.shader.Resize(fyne.Size{Width: c.diameter + 2*dialPad, Height: c.diameter + 2*dialPad}) + c.shader.Uniforms["size_d"] = c.diameter + + // Title (no rounding needed) + c.titleText.TextSize = float32(int(c.radius * common.OneFourth)) + c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + + // Display text + c.displayText.TextSize = float32(int(c.radius * common.OneThird)) + c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) + c.displayText.Resize(size) + + // Labels: reuse precomputed sin/cos, scale with current radius + radius43 := c.radius * common.OneFourth * 3 + labelPad := max(float32(6.0), c.radius*0.14) + + // Assume monospace, digits only: width ≈ chars * 0.62 * TextSize; height ≈ 1.15 * TextSize + // This keeps alignment stable and removes per-label measuring. + const charWidthFactor = 0.62 + const heightFactor = 1.15 + + labelTextSize := c.radius * 0.10 + c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize + c.labelBoxH = float32(heightFactor) * labelTextSize + + for i, lbl := range c.pipLabels { + if lbl == nil { + continue + } + lbl.TextSize = labelTextSize + + // Place label on the INSIDE of the gauge + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.Position{X: cx - c.labelBoxW/2, Y: cy - c.labelBoxH/2}) + } +} + +func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } +func (c *DialRenderer) Refresh() {} +func (c *DialRenderer) Destroy() {} + +func (c *DialRenderer) Objects() []fyne.CanvasObject { + if c.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+3) + objs = append(objs, c.shader) + for _, t := range c.pipLabels { + if t != nil { + objs = append(objs, t) + } + } + objs = append(objs, c.titleText, c.displayText) + c.objects = objs + } + return c.objects +} + +// --- helpers --- + +// max helper that matches your float32 usage +func max(a, b float32) float32 { + if a > b { + return a + } + return b +} diff --git a/pkg/widgets/dial/dial_shader.go b/pkg/widgets/dial/shader/dial_shader.go similarity index 100% rename from pkg/widgets/dial/dial_shader.go rename to pkg/widgets/dial/shader/dial_shader.go diff --git a/pkg/widgets/dial/dial_shader_test.go b/pkg/widgets/dial/shader/dial_shader_test.go similarity index 100% rename from pkg/widgets/dial/dial_shader_test.go rename to pkg/widgets/dial/shader/dial_shader_test.go diff --git a/pkg/widgets/dualdial/dual_dial.go b/pkg/widgets/dualdial/dual_dial.go index 85f9920d..8dc44673 100644 --- a/pkg/widgets/dualdial/dual_dial.go +++ b/pkg/widgets/dualdial/dual_dial.go @@ -23,11 +23,15 @@ type DualDial struct { value float64 value2 float64 - // All gauge geometry (face, pips, both needles, center) in one object - shader *canvas.Shader + needle *canvas.Line + needle2 *canvas.Line + pips []*canvas.Line pipLabels []*canvas.Text + face *canvas.Arc + center *canvas.Circle + displayText *canvas.Text displayText2 *canvas.Text @@ -37,13 +41,14 @@ type DualDial struct { size fyne.Size minsize fyne.Size - diameter float32 - radius float32 - middle fyne.Position - needleRotConst float64 - lineRotConst float64 + diameter float32 + radius float32 + middle fyne.Position + needleOffset, needleLength float32 + needleRotConst float64 + lineRotConst float64 - // cached sin/cos for pip labels (angle_i = lineRotConst*i - common.Pi43) + // cached sin/cos for pips (angle_i = lineRotConst*i - common.Pi43) pipSin []float32 pipCos []float32 @@ -89,24 +94,10 @@ func New(cfg *widgets.GaugeConfig) *DualDial { s.factor = s.cfg.Max / s.steps - totalRange := s.cfg.Max - s.cfg.Min - if totalRange <= 0 { - totalRange = 1 - } - s.needleRotConst = common.Pi15 / totalRange - s.lineRotConst = common.Pi15 / s.steps - - s.shader = canvas.NewShader( - "txlogger-dualdial", - []byte(dualDialShaderPreludeGL+dualDialShaderBody), - []byte(dualDialShaderPreludeES+dualDialShaderBody), - ) - s.shader.Uniforms = map[string]float32{ - "size_d": 100, - "steps": float32(s.steps), - "needle_a": s.needleAngle(0), - "needle2_a": s.needleAngle(0), - } + s.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) + s.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} + s.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 2} + s.needle2 = &canvas.Line{StrokeColor: color.RGBA{R: 249, G: 27, B: 2, A: 255}, StrokeWidth: 2} s.titleText = &canvas.Text{Text: s.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} s.titleText.TextStyle.Monospace = true @@ -118,8 +109,25 @@ func New(cfg *widgets.GaugeConfig) *DualDial { s.displayText2 = &canvas.Text{Text: "0", Color: color.RGBA{R: 0xff, G: 0x0, B: 0, A: 0xFF}, TextSize: 35} s.displayText2.Alignment = fyne.TextAlignCenter - // Labels at every other pip; also track the longest label length + // Pip color gradient + // Green to Yellow to Red gradient + // 0% - 50% Green to Yellow + // 50% - 100% Yellow to Red + halfSteps := float64(s.cfg.Steps) / 2.0 + var col color.RGBA for i := 0; i <= int(s.steps); i++ { + if float64(i) <= halfSteps { + // Green to Yellow + ratio := float64(i) / halfSteps + col = color.RGBA{R: byte(255 * ratio), G: 255, B: 0, A: 255} + } else { + // Yellow to Red + ratio := (float64(i) - halfSteps) / halfSteps + col = color.RGBA{R: 255, G: byte(255 * (1 - ratio)), B: 0, A: 255} + } + pip := &canvas.Line{StrokeColor: col, StrokeWidth: 2} + s.pips = append(s.pips, pip) + if i%2 == 0 { val := s.cfg.Min + (float64(i)/float64(s.cfg.Steps))*(s.cfg.Max-s.cfg.Min) txt := strconv.FormatFloat(val, 'f', s.gaugePrec, 64) @@ -128,6 +136,7 @@ func New(cfg *widgets.GaugeConfig) *DualDial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } + // lbl.TextStyle.Monospace = true if n := len(txt); n > s.maxLabelChars { s.maxLabelChars = n } @@ -137,7 +146,14 @@ func New(cfg *widgets.GaugeConfig) *DualDial { } } - // precompute pip trig for label placement (size independent) + totalRange := s.cfg.Max - s.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + s.needleRotConst = common.Pi15 / totalRange + s.lineRotConst = common.Pi15 / s.steps + + // precompute pip trig (size independent) s.pipSin = make([]float32, int(s.steps)+1) s.pipCos = make([]float32, int(s.steps)+1) for i := 0; i <= int(s.steps); i++ { @@ -152,14 +168,24 @@ func New(cfg *widgets.GaugeConfig) *DualDial { func (c *DualDial) GetConfig() *widgets.GaugeConfig { return c.cfg } -// needle angle for a face value; clamped below Min like the CPU renderer, -// free to overshoot above Max -func (c *DualDial) needleAngle(value float64) float32 { - normalized := value - c.cfg.Min +func (c *DualDial) rotateNeedleNoRefresh(hand *canvas.Line, facePosition float64, offset, length float32) { + normalized := facePosition - c.cfg.Min if normalized < 0 { normalized = 0 } - return float32(c.needleRotConst*normalized - common.Pi43) + s, co := math.Sincos(c.needleRotConst*normalized - common.Pi43) + c.applySinCos(hand, float32(s), float32(co), offset, length) +} + +func (c *DualDial) applySinCos(hand *canvas.Line, sinRot, cosRot float32, offset, length float32) { + x2 := length * sinRot + y2 := -length * cosRot + offX := offset * sinRot + offY := -offset * cosRot + midxOffX := c.middle.X + offX + midY := c.middle.Y + offY + hand.Position1 = fyne.Position{X: midxOffX, Y: midY} + hand.Position2 = fyne.Position{X: midxOffX + x2, Y: midY + y2} } func (c *DualDial) SetValue(value float64) { @@ -168,7 +194,7 @@ func (c *DualDial) SetValue(value float64) { } c.value = value - c.shader.Uniforms["needle_a"] = c.needleAngle(value) + c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) c.buf1 = c.buf1[:0] if c.fmtPrec >= 0 { @@ -176,12 +202,15 @@ func (c *DualDial) SetValue(value float64) { } else { c.buf1 = common.AppendFormatFloat(c.buf1, c.displayString, value) } - if !common.SameTextBytes(c.displayText.Text, c.buf1) { + textChanged := !common.SameTextBytes(c.displayText.Text, c.buf1) + if textChanged { c.displayText.Text = string(c.buf1) - canvas.Refresh(c.displayText) } - canvas.Refresh(c.shader) + canvas.Refresh(c.needle) + if textChanged { + canvas.Refresh(c.displayText) + } } func (c *DualDial) SetValue2(value float64) { @@ -190,7 +219,7 @@ func (c *DualDial) SetValue2(value float64) { } c.value2 = value - c.shader.Uniforms["needle2_a"] = c.needleAngle(value) + c.rotateNeedleNoRefresh(c.needle2, value, c.needleOffset, c.needleLength) c.buf2 = c.buf2[:0] if c.fmtPrec >= 0 { @@ -198,12 +227,15 @@ func (c *DualDial) SetValue2(value float64) { } else { c.buf2 = common.AppendFormatFloat(c.buf2, c.displayString, value) } - if !common.SameTextBytes(c.displayText2.Text, c.buf2) { + textChanged := !common.SameTextBytes(c.displayText2.Text, c.buf2) + if textChanged { c.displayText2.Text = string(c.buf2) - canvas.Refresh(c.displayText2) } - canvas.Refresh(c.shader) + canvas.Refresh(c.needle2) + if textChanged { + canvas.Refresh(c.displayText2) + } } func (c *DualDial) CreateRenderer() fyne.WidgetRenderer { return &DualDialRenderer{DualDial: c} } @@ -223,17 +255,24 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + c.needleOffset = -c.radius * .15 + c.needleLength = c.radius * 1.14 + + stroke := c.diameter * common.OneSixthieth + midStroke := c.diameter * common.OneEighthieth + smallStroke := c.diameter * common.OneTwohundredth + size := fyne.Size{Width: c.diameter, Height: c.diameter} topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) - c.shader.Move(topleft) - c.shader.Resize(size) - c.shader.Uniforms["size_d"] = c.diameter - // Title & display text sizes (no math.Round needed) c.titleText.TextSize = c.radius * common.OneFourth c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + center := c.radius * common.OneFourth + c.center.Move(c.middle.SubtractXY(center*common.OneHalf, center*common.OneHalf)) + c.center.Resize(fyne.Size{Width: center, Height: center}) + sixthDiameter := c.diameter * common.OneSixth c.displayText.TextSize = c.radius * common.OneThird @@ -244,8 +283,20 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.displayText2.Move(topleft.AddXY(0, -sixthDiameter)) c.displayText2.Resize(size) - // Labels: reuse precomputed trig scaled by current radius + // Needles & face + c.needle.StrokeWidth = stroke + c.needle2.StrokeWidth = stroke + c.rotateNeedleNoRefresh(c.needle, c.value, c.needleOffset, c.needleLength) + c.rotateNeedleNoRefresh(c.needle2, c.value2, c.needleOffset, c.needleLength) + + c.face.Move(c.middle.SubtractXY(c.radius, c.radius)) + c.face.Resize(fyne.Size{Width: c.diameter, Height: c.diameter}) + + // Pips using precomputed trig scaled by current radius + fourthRadius := c.radius * common.OneFourth + eightRadius := c.radius * common.OneEight radius43 := c.radius * common.OneFourth * 3 + radius87 := c.radius * common.OneEight * 7 // Label padding and cached box dims (avoid lbl.MinSize per label) labelPad := max(float32(6.0), c.radius*0.14) @@ -255,19 +306,27 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize c.labelBoxH = float32(heightFactor) * labelTextSize - for i, lbl := range c.pipLabels { - if lbl == nil { - continue + for i, p := range c.pips { + if i%2 == 0 { + p.StrokeWidth = max(2.0, midStroke) + c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius43, fourthRadius-1) + // Label for long pip (uniform box; no MinSize) + lbl := c.pipLabels[i] + if lbl != nil { + lbl.TextSize = labelTextSize + // place inside the gauge slightly inward from long pip inner end + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + boxW := c.labelBoxW + boxH := c.labelBoxH + lbl.Resize(fyne.NewSize(boxW, boxH)) + lbl.Move(fyne.NewPos(cx-boxW/2, cy-boxH/2)) + } + } else { + p.StrokeWidth = max(2.0, smallStroke) + c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) } - lbl.TextSize = labelTextSize - - // place inside the gauge slightly inward from long pip inner end - labelRadius := radius43 - labelPad - cx := c.middle.X + c.pipSin[i]*labelRadius - cy := c.middle.Y - c.pipCos[i]*labelRadius - - lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) - lbl.Move(fyne.NewPos(cx-c.labelBoxW/2, cy-c.labelBoxH/2)) } } @@ -277,14 +336,16 @@ func (c *DualDialRenderer) Destroy() {} func (c *DualDialRenderer) Objects() []fyne.CanvasObject { if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+4) - objs = append(objs, c.shader) + objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) + for _, v := range c.pips { + objs = append(objs, v) + } for _, v := range c.pipLabels { if v != nil { objs = append(objs, v) } } - objs = append(objs, c.titleText, c.displayText, c.displayText2) + objs = append(objs, c.face, c.titleText, c.center, c.needle2, c.needle, c.displayText, c.displayText2) c.objects = objs } return c.objects diff --git a/pkg/widgets/dualdial/shader/dual_dial.go b/pkg/widgets/dualdial/shader/dual_dial.go new file mode 100644 index 00000000..85f9920d --- /dev/null +++ b/pkg/widgets/dualdial/shader/dual_dial.go @@ -0,0 +1,300 @@ +package dualdial + +import ( + "image/color" + "math" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/widgets" +) + +type DualDial struct { + widget.BaseWidget + + cfg *widgets.GaugeConfig + + titleText *canvas.Text + displayString string + + value float64 + value2 float64 + + // All gauge geometry (face, pips, both needles, center) in one object + shader *canvas.Shader + + pipLabels []*canvas.Text + + displayText *canvas.Text + displayText2 *canvas.Text + + steps float64 + factor float64 + + size fyne.Size + minsize fyne.Size + + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 + lineRotConst float64 + + // cached sin/cos for pip labels (angle_i = lineRotConst*i - common.Pi43) + pipSin []float32 + pipCos []float32 + + // fast float formatting buffers + fmtPrec int + gaugePrec int + buf1 []byte + buf2 []byte + + // Label sizing cache (avoid per-label MinSize on each layout) + maxLabelChars int + labelBoxW float32 + labelBoxH float32 +} + +func New(cfg *widgets.GaugeConfig) *DualDial { + s := &DualDial{ + cfg: cfg, + steps: 10, + displayString: "%.0f", + minsize: fyne.NewSize(100, 100), + fmtPrec: -1, + } + s.ExtendBaseWidget(s) + + if cfg.Steps > 0 { + s.steps = float64(cfg.Steps) + } + if cfg.DisplayString != "" { + s.displayString = cfg.DisplayString + if n := common.ParseFixedPrec(s.displayString); n >= 0 { + s.fmtPrec = n + } + } + if cfg.GaugeTextString != "" { + if n := common.ParseFixedPrec(cfg.GaugeTextString); n >= 0 { + s.gaugePrec = n + } + } + if cfg.MinSize.Width > 0 && cfg.MinSize.Height > 0 { + s.minsize = cfg.MinSize + } + + s.factor = s.cfg.Max / s.steps + + totalRange := s.cfg.Max - s.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + s.needleRotConst = common.Pi15 / totalRange + s.lineRotConst = common.Pi15 / s.steps + + s.shader = canvas.NewShader( + "txlogger-dualdial", + []byte(dualDialShaderPreludeGL+dualDialShaderBody), + []byte(dualDialShaderPreludeES+dualDialShaderBody), + ) + s.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(s.steps), + "needle_a": s.needleAngle(0), + "needle2_a": s.needleAngle(0), + } + + s.titleText = &canvas.Text{Text: s.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} + s.titleText.TextStyle.Monospace = true + s.titleText.Alignment = fyne.TextAlignCenter + + s.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} + s.displayText.Alignment = fyne.TextAlignCenter + + s.displayText2 = &canvas.Text{Text: "0", Color: color.RGBA{R: 0xff, G: 0x0, B: 0, A: 0xFF}, TextSize: 35} + s.displayText2.Alignment = fyne.TextAlignCenter + + // Labels at every other pip; also track the longest label length + for i := 0; i <= int(s.steps); i++ { + if i%2 == 0 { + val := s.cfg.Min + (float64(i)/float64(s.cfg.Steps))*(s.cfg.Max-s.cfg.Min) + txt := strconv.FormatFloat(val, 'f', s.gaugePrec, 64) + lbl := &canvas.Text{ + Text: txt, + Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, + Alignment: fyne.TextAlignCenter, + } + if n := len(txt); n > s.maxLabelChars { + s.maxLabelChars = n + } + s.pipLabels = append(s.pipLabels, lbl) + } else { + s.pipLabels = append(s.pipLabels, nil) + } + } + + // precompute pip trig for label placement (size independent) + s.pipSin = make([]float32, int(s.steps)+1) + s.pipCos = make([]float32, int(s.steps)+1) + for i := 0; i <= int(s.steps); i++ { + ang := s.lineRotConst*float64(i) - common.Pi43 + sinA, cosA := math.Sincos(ang) + s.pipSin[i] = float32(sinA) + s.pipCos[i] = float32(cosA) + } + + return s +} + +func (c *DualDial) GetConfig() *widgets.GaugeConfig { return c.cfg } + +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *DualDial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min + if normalized < 0 { + normalized = 0 + } + return float32(c.needleRotConst*normalized - common.Pi43) +} + +func (c *DualDial) SetValue(value float64) { + if value == c.value { + return + } + c.value = value + + c.shader.Uniforms["needle_a"] = c.needleAngle(value) + + c.buf1 = c.buf1[:0] + if c.fmtPrec >= 0 { + c.buf1 = strconv.AppendFloat(c.buf1, value, 'f', c.fmtPrec, 64) + } else { + c.buf1 = common.AppendFormatFloat(c.buf1, c.displayString, value) + } + if !common.SameTextBytes(c.displayText.Text, c.buf1) { + c.displayText.Text = string(c.buf1) + canvas.Refresh(c.displayText) + } + + canvas.Refresh(c.shader) +} + +func (c *DualDial) SetValue2(value float64) { + if value == c.value2 { + return + } + c.value2 = value + + c.shader.Uniforms["needle2_a"] = c.needleAngle(value) + + c.buf2 = c.buf2[:0] + if c.fmtPrec >= 0 { + c.buf2 = strconv.AppendFloat(c.buf2, value, 'f', c.fmtPrec, 64) + } else { + c.buf2 = common.AppendFormatFloat(c.buf2, c.displayString, value) + } + if !common.SameTextBytes(c.displayText2.Text, c.buf2) { + c.displayText2.Text = string(c.buf2) + canvas.Refresh(c.displayText2) + } + + canvas.Refresh(c.shader) +} + +func (c *DualDial) CreateRenderer() fyne.WidgetRenderer { return &DualDialRenderer{DualDial: c} } + +type DualDialRenderer struct { + *DualDial + objects []fyne.CanvasObject +} + +func (c *DualDialRenderer) Layout(space fyne.Size) { + if c.size == space { + return + } + c.size = space + + c.diameter = fyne.Min(space.Width, space.Height) + c.radius = c.diameter * common.OneHalf + c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + + size := fyne.Size{Width: c.diameter, Height: c.diameter} + topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + + c.shader.Move(topleft) + c.shader.Resize(size) + c.shader.Uniforms["size_d"] = c.diameter + + // Title & display text sizes (no math.Round needed) + c.titleText.TextSize = c.radius * common.OneFourth + c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + + sixthDiameter := c.diameter * common.OneSixth + + c.displayText.TextSize = c.radius * common.OneThird + c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) + c.displayText.Resize(size) + + c.displayText2.TextSize = c.radius * common.OneThird + c.displayText2.Move(topleft.AddXY(0, -sixthDiameter)) + c.displayText2.Resize(size) + + // Labels: reuse precomputed trig scaled by current radius + radius43 := c.radius * common.OneFourth * 3 + + // Label padding and cached box dims (avoid lbl.MinSize per label) + labelPad := max(float32(6.0), c.radius*0.14) + const charWidthFactor = 0.62 + const heightFactor = 1.15 + labelTextSize := c.radius * 0.10 + c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize + c.labelBoxH = float32(heightFactor) * labelTextSize + + for i, lbl := range c.pipLabels { + if lbl == nil { + continue + } + lbl.TextSize = labelTextSize + + // place inside the gauge slightly inward from long pip inner end + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.NewPos(cx-c.labelBoxW/2, cy-c.labelBoxH/2)) + } +} + +func (c *DualDialRenderer) MinSize() fyne.Size { return c.minsize } +func (c *DualDialRenderer) Refresh() {} +func (c *DualDialRenderer) Destroy() {} + +func (c *DualDialRenderer) Objects() []fyne.CanvasObject { + if c.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+4) + objs = append(objs, c.shader) + for _, v := range c.pipLabels { + if v != nil { + objs = append(objs, v) + } + } + objs = append(objs, c.titleText, c.displayText, c.displayText2) + c.objects = objs + } + return c.objects +} + +// --- helpers --- + +func max(a, b float32) float32 { + if a > b { + return a + } + return b +} diff --git a/pkg/widgets/dualdial/dual_dial_shader.go b/pkg/widgets/dualdial/shader/dual_dial_shader.go similarity index 100% rename from pkg/widgets/dualdial/dual_dial_shader.go rename to pkg/widgets/dualdial/shader/dual_dial_shader.go diff --git a/pkg/widgets/dualdial/dual_dial_shader_test.go b/pkg/widgets/dualdial/shader/dual_dial_shader_test.go similarity index 100% rename from pkg/widgets/dualdial/dual_dial_shader_test.go rename to pkg/widgets/dualdial/shader/dual_dial_shader_test.go From e2f8a857303c46cb3bb7a097f83f1c9c90d59957 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 11:42:39 +0200 Subject: [PATCH 35/93] make render mode configurable --- pkg/widgets/logplayer/logplayer.go | 8 +- pkg/widgets/mapviewer/mapviewer.go | 1 + pkg/widgets/mapviewer/mapviewer_opts.go | 5 +- pkg/widgets/meshgrid/meshgrid_render_test.go | 8 +- pkg/widgets/meshgrid/meshgrid_widget.go | 40 +-- pkg/widgets/plotter/plotter.go | 22 +- pkg/widgets/plotter/plotter_shader.go | 3 + pkg/widgets/plotter/plotter_shader_test.go | 4 +- pkg/widgets/settings/settings.go | 244 +----------------- pkg/widgets/settings/settings_getters.go | 251 +++++++++++++++++++ pkg/widgets/settings/settings_internal.go | 33 +++ pkg/widgets/settings/settings_tabs.go | 85 ++++--- pkg/windows/mainWindow.go | 5 +- pkg/windows/mainWindow_menu.go | 1 + 14 files changed, 404 insertions(+), 306 deletions(-) create mode 100644 pkg/widgets/settings/settings_getters.go diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index 9dfc717e..b2be8c96 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -73,9 +73,10 @@ type logplayerObjects struct { } type Config struct { - EBus *bus.Controller[string, float64] - Logfile logfile.Logfile - TimeSetter func(time.Time) + EBus *bus.Controller[string, float64] + Logfile logfile.Logfile + TimeSetter func(time.Time) + PlotterRenderer plotter.PlotBackend } func New(cfg *Config) *Logplayer { @@ -258,6 +259,7 @@ func (l *Logplayer) render() { l.objs.positionSlider.Refresh() l.control(&controlMsg{Op: OpSeek, Pos: int(pos)}) }), + plotter.WithRenderer(l.cfg.PlotterRenderer), ) } diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 97cd5c56..b92b9494 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -258,6 +258,7 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.numColumns, mv.numRows, mv.colorMode, + mv.cfg.MeshRenderer, ) if mv.cfg.OnMouseDown != nil { diff --git a/pkg/widgets/mapviewer/mapviewer_opts.go b/pkg/widgets/mapviewer/mapviewer_opts.go index f7065139..d1ea6361 100644 --- a/pkg/widgets/mapviewer/mapviewer_opts.go +++ b/pkg/widgets/mapviewer/mapviewer_opts.go @@ -3,6 +3,7 @@ package mapviewer import ( "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" ) type Config struct { @@ -27,7 +28,9 @@ type Config struct { OnUpdateCell func(idx int, value []float64) OnMouseDown func() - MeshView bool + MeshView bool + MeshRenderer meshgrid.RenderBackend + Editable bool CursorFollowCrosshair bool diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index 56524fb6..3b04bf0e 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -21,7 +21,7 @@ func testGrid(t testing.TB) *Meshgrid { values[i*cols+j] = 100 / (1 + x*x + y*y) // central hump } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func TestRenderRotated(t *testing.T) { values[i*cols+j] = 10 + 100/(1+x*x+y*y) // spike near one corner } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func BenchmarkDrawSurface(b *testing.B) { // usePolyBackend switches a test grid to the polygon renderer (the default // is the shader backend, whose per-frame work happens on the GPU). func usePolyBackend(m *Meshgrid) { - m.backend = backendPolygons + m.backend = BackendPolygons m.initPolygons() } @@ -129,7 +129,7 @@ func TestPolygonDegenerateQuads(t *testing.T) { values[i*cols+j] = float64((i / 4) * 100) } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 2fc4a904..a7382700 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -28,28 +28,28 @@ var _ fyne.Widget = (*Meshgrid)(nil) // renderBackend selects how the mesh is drawn. The GPU shader is the // default; TXLOGGER_MESH_RENDERER=poly|image selects the older paths. -type renderBackend int +type RenderBackend int const ( // backendShader ray-casts the whole mesh in one fragment shader // (meshgrid_shader.go); per-frame CPU cost is a uniform update. - backendShader renderBackend = iota + BackendShader RenderBackend = iota // backendPolygons draws one canvas.ArbitraryPolygon per cell // (meshgrid_poly.go). - backendPolygons + BackendPolygons // backendImage rasterizes on the CPU into a canvas.Image // (meshgrid_draw.go). - backendImage + BackendImage ) -func backendFromEnv() renderBackend { +func backendFromEnv() RenderBackend { switch os.Getenv("TXLOGGER_MESH_RENDERER") { case "poly": - return backendPolygons + return BackendPolygons case "image": - return backendImage + return BackendImage default: - return backendShader + return BackendShader } } @@ -75,7 +75,7 @@ type Meshgrid struct { scratchLines []lineSegment scratchQuads []quadRef - backend renderBackend + backend RenderBackend // Shader backend (meshgrid_shader.go): the whole mesh in one object. shader *canvas.Shader @@ -138,7 +138,7 @@ var ( const cursorRadius = 6 // NewMeshgrid creates a new Meshgrid given width, height, depth and spacing. -func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, colorBlindMode colors.ColorBlindMode) (*Meshgrid, error) { +func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, colorBlindMode colors.ColorBlindMode, backend RenderBackend) (*Meshgrid, error) { cols = max(1, cols) rows = max(1, rows) // Check if the provided values slice has the correct number of elements @@ -172,7 +172,7 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int colorMode: colorBlindMode, - backend: backendFromEnv(), + backend: backend, } m.createVertices() @@ -209,9 +209,9 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int m.initAxisObjects() switch m.backend { - case backendShader: + case BackendShader: m.initShader() - case backendPolygons: + case BackendPolygons: m.initPolygons() } @@ -463,14 +463,14 @@ func (m *Meshgrid) Refresh() { func (m *Meshgrid) refresh() { switch m.backend { - case backendShader: + case BackendShader: // All per-frame state lives in shader uniforms; the GPU re-renders // from them on the next paint. Only the overlays move on the CPU. m.updateShaderUniforms() m.updateAxisObjects() m.moveCursor() canvas.Refresh(m) - case backendPolygons: + case BackendPolygons: // Geometry/color updates on the reusable polygons; the canvas.Refresh // marks the scene dirty so color-only changes repaint too. m.updatePolygons() @@ -502,7 +502,7 @@ func (m *Meshgrid) throttledRefresh() { } func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { - if m.backend != backendImage { + if m.backend != BackendImage { // Text measuring needs a driver, so the labels can't be sized in the // constructor (tests build widgets without an app). for _, t := range m.axisLabels { @@ -523,9 +523,9 @@ func (m *meshgridRenderer) Layout(size fyne.Size) { } m.MG.size = size switch m.MG.backend { - case backendShader: + case BackendShader: m.MG.shader.Resize(size) - case backendImage: + case BackendImage: m.MG.image.Resize(size) } m.MG.throttledRefresh() @@ -544,7 +544,7 @@ func (m *meshgridRenderer) Destroy() { func (m *meshgridRenderer) Objects() []fyne.CanvasObject { switch m.MG.backend { - case backendShader: + case BackendShader: if m.objects == nil { m.MG.updateAxisObjects() objs := []fyne.CanvasObject{m.MG.shader} @@ -554,7 +554,7 @@ func (m *meshgridRenderer) Objects() []fyne.CanvasObject { m.objects = append(objs, m.MG.cursor) } return m.objects - case backendPolygons: + case BackendPolygons: // updatePolygons rebuilds the list in painter's order every frame; // populate it here for the first paint. if len(m.MG.polyObjects) == 0 { diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index baed1c01..4439f4bb 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -5,7 +5,6 @@ import ( "image" "image/color" "log" - "os" "sort" "sync" "sync/atomic" @@ -33,17 +32,17 @@ type PlotterControl interface { // plotBackend selects how the plot is drawn. The GPU shader is the default; // TXLOGGER_PLOT_RENDERER=image selects the CPU rasterizer, which is also the // automatic fallback when the log does not fit the shader's texture layout. -type plotBackend int +type PlotBackend int const ( - plotBackendImage plotBackend = iota - plotBackendShader + PlotBackendImage PlotBackend = iota + PlotBackendShader ) type Plotter struct { widget.BaseWidget - backend plotBackend + backend PlotBackend shader *canvas.Shader // plotObj is the canvas object showing the plot: shader on the GPU // backend, canvasImage on the image backend. @@ -109,6 +108,12 @@ func WithOrder(order []string) PlotterOpt { } } +func WithRenderer(renderer PlotBackend) PlotterOpt { + return func(p *Plotter) { + p.backend = renderer + } +} + func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { p := &Plotter{ values: values, @@ -200,8 +205,7 @@ func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { p.dataPointsToShow = min(p.dataLength, 250.0) p.plotObj = p.canvasImage - if os.Getenv("TXLOGGER_PLOT_RENDERER") != "image" && p.initShader() { - p.backend = plotBackendShader + if p.backend == PlotBackendShader && p.initShader() { p.plotObj = p.shader } @@ -317,7 +321,7 @@ func (p *Plotter) seekTo(pos int) { obj.Refresh() } - if p.backend == plotBackendShader { + if p.backend == PlotBackendShader { // The view window moved; the GPU re-renders from two uniforms. p.updateShaderView() p.layoutCursor() @@ -368,7 +372,7 @@ func (p *Plotter) drawImage() { } func (p *Plotter) refreshImage(goroutine bool) { - if p.backend == plotBackendShader { + if p.backend == PlotBackendShader { // Legend toggles/recolors, hover and zoom all funnel through here; // the metadata texture is 4xN so rebuilding it unconditionally is // cheap, and the painter uploads it only because it is a new image. diff --git a/pkg/widgets/plotter/plotter_shader.go b/pkg/widgets/plotter/plotter_shader.go index de4ef813..5b828140 100644 --- a/pkg/widgets/plotter/plotter_shader.go +++ b/pkg/widgets/plotter/plotter_shader.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "image/color" + "log" "sync/atomic" "fyne.io/fyne/v2/canvas" @@ -230,6 +231,8 @@ void main() { // many series, or a log too long for the texture budget); the caller then // stays on the image backend. func (p *Plotter) initShader() bool { + log.Println("Init plotter shaders") + if len(p.ts) == 0 || len(p.ts) > plotMaxSeries { return false } diff --git a/pkg/widgets/plotter/plotter_shader_test.go b/pkg/widgets/plotter/plotter_shader_test.go index 9d30e17b..649ec92a 100644 --- a/pkg/widgets/plotter/plotter_shader_test.go +++ b/pkg/widgets/plotter/plotter_shader_test.go @@ -12,7 +12,7 @@ import ( func shaderPlotter(t testing.TB, numSeries, numPoints int) *Plotter { t.Helper() p := NewPlotter(benchValues(numSeries, numPoints)) - if p.backend != plotBackendShader { + if p.backend != PlotBackendShader { t.Fatal("shader backend not selected") } return p @@ -132,7 +132,7 @@ func TestPlotShaderMeta(t *testing.T) { // Logs that exceed the texture budget must fall back to the image backend. func TestPlotShaderFallback(t *testing.T) { p := NewPlotter(benchValues(2, plotTexW*plotTexMaxH/2+1)) - if p.backend != plotBackendImage { + if p.backend != PlotBackendImage { t.Fatal("oversized log did not fall back to the image backend") } if p.plotObj != p.canvasImage { diff --git a/pkg/widgets/settings/settings.go b/pkg/widgets/settings/settings.go index 81417649..eb93c302 100644 --- a/pkg/widgets/settings/settings.go +++ b/pkg/widgets/settings/settings.go @@ -1,13 +1,10 @@ package settings import ( - "errors" "fmt" - "log" "os" "slices" "sort" - "strconv" "strings" "sync" @@ -17,16 +14,6 @@ import ( "fyne.io/fyne/v2/widget" "github.com/roffe/gocan" "github.com/roffe/gocan/proto" - "github.com/roffe/txlogger/pkg/colors" - "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/datalogger" - "github.com/roffe/txlogger/pkg/ota" - "github.com/roffe/txlogger/pkg/wbl/aem" - "github.com/roffe/txlogger/pkg/wbl/ecumaster" - "github.com/roffe/txlogger/pkg/wbl/innovate" - "github.com/roffe/txlogger/pkg/wbl/plx" - "github.com/roffe/txlogger/pkg/wbl/stag" - "github.com/roffe/txlogger/pkg/wbl/zeitronix" "github.com/roffe/txlogger/pkg/widgets/txconfigurator" "go.bug.st/serial/enumerator" ) @@ -56,6 +43,9 @@ const ( prefsUseADScanner = "useADScanner" prefsColorBlindMode = "colorBlindMode" + prefsPlotterRenderer = "plotterRenderer" + prefsMeshRenderer = "meshRenderer" + // CAN prefsAdapter = "adapter" prefsPort = "port" @@ -104,6 +94,10 @@ type Widget struct { useMPH *widget.Check swapRPMandSpeed *widget.Check colorBlindMode *widget.Select + // Graphics + plotRendererSelect *widget.Select + meshRendererSelect *widget.Select + // can settings debugCheckbox *widget.Check adapterSelector *widget.Select @@ -187,6 +181,10 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { sw.colorBlindMode = sw.newColorBlindMode() sw.wblSelectContainer = sw.newWBLSelector() + // Graphics + sw.plotRendererSelect = sw.newPlotRendererSelect() + sw.meshRendererSelect = sw.newMeshRendererSelect() + // CAN sw.adapterSelector = sw.newAdapterSelector() sw.portSelector = sw.newPortSelector() @@ -210,6 +208,7 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { tabs := container.NewAppTabs() tabs.Append(sw.generalTab()) + tabs.Append(sw.graphicsTab()) tabs.Append(sw.canTab()) tabs.Append(sw.loggingTab()) tabs.Append(sw.dashboardTab()) @@ -302,222 +301,3 @@ func (c *Widget) Enable() { } } } - -func (cs *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { - return cs.GetAdapterWithExtraFilters(ecuType, []uint32{}) -} - -func (cs *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { - debug := fyne.CurrentApp().Preferences().Bool(prefsDebug) - port := fyne.CurrentApp().Preferences().String(prefsPort) - - baudstring := fyne.CurrentApp().Preferences().String(prefsSpeed) - switch baudstring { - case "1mbit": - baudstring = "1000000" - case "2mbit": - baudstring = "2000000" - case "3mbit": - baudstring = "3000000" - } - - if baudstring == "" { - baudstring = "1000000" - } - - baudrate, err := strconv.Atoi(baudstring) - if err != nil { - return nil, err - } - adapterName := fyne.CurrentApp().Preferences().String(prefsAdapter) - - if adapterName == "" { - return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok - } - - if ad, found := cs.adapters[adapterName]; found { - if ad.RequiresSerialPort { - if port == "" { - return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok - } - if baudstring == "" { - return nil, errors.New("Select port speed in settings") //lint:ignore ST1005 This is ok - } - } - } - - var canFilter []uint32 - var canRate float64 - - switch ecuType { - case "T5", "Trionic 5": - canFilter = []uint32{0xC} - canRate = 615.384 - case "T7", "Trionic 7": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") || strings.HasSuffix(adapterName, "Wifi") { - canFilter = []uint32{0x238, 0x258, 0x270} - } else { - canFilter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canRate = 500 - case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") { - canFilter = []uint32{0x5E8, 0x7E8} - } else { - canFilter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canFilter = append(canFilter, filters...) - - canRate = 500 - } - - cfg := &gocan.AdapterConfig{ - Port: port, - PortBaudrate: baudrate, - CANRate: canRate, - CANFilter: canFilter, - Debug: debug, - PrintVersion: true, - } - - if strings.HasPrefix(adapterName, "J2534") { // || strings.HasPrefix(adapterName, "CANlib") { - return gocan.NewGWClient(adapterName, cfg) - } - - if adapterName == "txbridge wifi" { - //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - //defer cancel() - //addr, err := mdns.Query(ctx, "txbridge.local") - //if err != nil { - // cs.cfg.Logger(fmt.Sprintf("Failed to resolve txbridge address via mDNS: %v", err)) - //} else { - cfg.AdditionalConfig = map[string]string{ - "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), - "minversion": ota.MinimumtxbridgeVersion, - } - //} - } - return gocan.NewAdapter(adapterName, cfg) -} - -func (sw *Widget) GetADScannerSymbolName() string { - return fyne.CurrentApp().Preferences().String(prefsWBLADScannerSymbol) -} - -func (sw *Widget) GetWidebandName() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") -} - -func (sw *Widget) GetWidebandSymbolName() string { - useADScanner := fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) - switch sw.GetWidebandName() { - case "ECU": - switch sw.cfg.SelectedEcuFunc() { - case "T5": - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - case "T7": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "DisplProt.LambdaScanner" - case "T8": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "LambdaScan.LambdaScanner" - default: - return "None" - } - case aem.ProductString, - "CombiAdapter", - ecumaster.ProductString, - innovate.ProductString, - plx.ProductString, - stag.ProductString, - zeitronix.ProductString: - return datalogger.EXTERNALWBLSYM // Lambda.External - default: - return "None" - } -} - -func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { - return colors.StringToColorBlindMode(fyne.CurrentApp().Preferences().StringWithFallback(prefsColorBlindMode, "Normal")) -} - -func (sw *Widget) GetWidebandPort() string { - return fyne.CurrentApp().Preferences().String(prefsWBLPort) -} - -func (sw *Widget) GetWBLSupportPoints() []int { - return fyne.CurrentApp().Preferences().IntListWithFallback(prefsWBLSupportPoints, []int{0, 1024}) -} - -func (sw *Widget) GetWBLLambdaValues() []float64 { - return fyne.CurrentApp().Preferences().FloatListWithFallback(prefsWBLLambdaValues, []float64{0.5, 1.5}) -} - -func (sw *Widget) GetUseADScanner() bool { - if fyne.CurrentApp().Preferences().String(prefsWblSource) != "ECU" { - return false - } - return fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) -} - -func (sw *Widget) GetFreq() int { - return int(fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25)) -} - -func (sw *Widget) GetAutoSave() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateSaveEcu) -} - -func (sw *Widget) GetAutoLoad() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateLoadEcu) -} - -func (sw *Widget) GetLivePreview() bool { - return fyne.CurrentApp().Preferences().Bool(prefsLivePreview) -} - -func (sw *Widget) GetRealtimeBars() bool { - return fyne.CurrentApp().Preferences().Bool(prefsRealtimeBars) -} - -func (sw *Widget) GetMeshView() bool { - return fyne.CurrentApp().Preferences().Bool(prefsMeshView) -} - -func (sw *Widget) GetLogFormat() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsLogFormat, "CSV") -} - -func (sw *Widget) GetLogPath() string { - p := fyne.CurrentApp().Preferences().String(prefsLogPath) - if p == "" { - var err error - p, err = common.GetLogPath() - if err != nil { - log.Println("GetLogPath: ", err) - } - } - return p -} - -func (sw *Widget) GetUseMPH() bool { - return fyne.CurrentApp().Preferences().Bool(prefsUseMPH) -} - -func (sw *Widget) GetSwapRPMandSpeed() bool { - return fyne.CurrentApp().Preferences().Bool(prefsSwapRPMandSpeed) -} - -func (sw *Widget) GetCursorFollowCrosshair() bool { - return fyne.CurrentApp().Preferences().Bool(prefsCursorFollowCrosshair) -} diff --git a/pkg/widgets/settings/settings_getters.go b/pkg/widgets/settings/settings_getters.go new file mode 100644 index 00000000..d7154a9e --- /dev/null +++ b/pkg/widgets/settings/settings_getters.go @@ -0,0 +1,251 @@ +package settings + +import ( + "errors" + "fmt" + "log" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/datalogger" + "github.com/roffe/txlogger/pkg/ota" + "github.com/roffe/txlogger/pkg/wbl/aem" + "github.com/roffe/txlogger/pkg/wbl/ecumaster" + "github.com/roffe/txlogger/pkg/wbl/innovate" + "github.com/roffe/txlogger/pkg/wbl/plx" + "github.com/roffe/txlogger/pkg/wbl/stag" + "github.com/roffe/txlogger/pkg/wbl/zeitronix" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +func (cs *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { + return cs.GetAdapterWithExtraFilters(ecuType, []uint32{}) +} + +func (cs *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { + debug := fyne.CurrentApp().Preferences().Bool(prefsDebug) + port := fyne.CurrentApp().Preferences().String(prefsPort) + + baudstring := fyne.CurrentApp().Preferences().String(prefsSpeed) + switch baudstring { + case "1mbit": + baudstring = "1000000" + case "2mbit": + baudstring = "2000000" + case "3mbit": + baudstring = "3000000" + } + + if baudstring == "" { + baudstring = "1000000" + } + + baudrate, err := strconv.Atoi(baudstring) + if err != nil { + return nil, err + } + adapterName := fyne.CurrentApp().Preferences().String(prefsAdapter) + + if adapterName == "" { + return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok + } + + if ad, found := cs.adapters[adapterName]; found { + if ad.RequiresSerialPort { + if port == "" { + return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok + } + if baudstring == "" { + return nil, errors.New("Select port speed in settings") //lint:ignore ST1005 This is ok + } + } + } + + var canFilter []uint32 + var canRate float64 + + switch ecuType { + case "T5", "Trionic 5": + canFilter = []uint32{0xC} + canRate = 615.384 + case "T7", "Trionic 7": + if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") || strings.HasSuffix(adapterName, "Wifi") { + canFilter = []uint32{0x238, 0x258, 0x270} + } else { + canFilter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} + } + if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { + canFilter = append(canFilter, 0x180) + } + canRate = 500 + case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": + if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") { + canFilter = []uint32{0x5E8, 0x7E8} + } else { + canFilter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} + } + if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { + canFilter = append(canFilter, 0x180) + } + canFilter = append(canFilter, filters...) + + canRate = 500 + } + + cfg := &gocan.AdapterConfig{ + Port: port, + PortBaudrate: baudrate, + CANRate: canRate, + CANFilter: canFilter, + Debug: debug, + PrintVersion: true, + } + + if strings.HasPrefix(adapterName, "J2534") { // || strings.HasPrefix(adapterName, "CANlib") { + return gocan.NewGWClient(adapterName, cfg) + } + + if adapterName == "txbridge wifi" { + //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + //defer cancel() + //addr, err := mdns.Query(ctx, "txbridge.local") + //if err != nil { + // cs.cfg.Logger(fmt.Sprintf("Failed to resolve txbridge address via mDNS: %v", err)) + //} else { + cfg.AdditionalConfig = map[string]string{ + "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), + "minversion": ota.MinimumtxbridgeVersion, + } + //} + } + return gocan.NewAdapter(adapterName, cfg) +} + +func (sw *Widget) GetADScannerSymbolName() string { + return fyne.CurrentApp().Preferences().String(prefsWBLADScannerSymbol) +} + +func (sw *Widget) GetWidebandName() string { + return fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") +} + +func (sw *Widget) GetWidebandSymbolName() string { + useADScanner := fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) + switch sw.GetWidebandName() { + case "ECU": + switch sw.cfg.SelectedEcuFunc() { + case "T5": + return datalogger.LAMBDAADSCANNER // Lambda.ADScanner + case "T7": + if useADScanner { + return datalogger.LAMBDAADSCANNER // Lambda.ADScanner + } + return "DisplProt.LambdaScanner" + case "T8": + if useADScanner { + return datalogger.LAMBDAADSCANNER // Lambda.ADScanner + } + return "LambdaScan.LambdaScanner" + default: + return "None" + } + case aem.ProductString, + "CombiAdapter", + ecumaster.ProductString, + innovate.ProductString, + plx.ProductString, + stag.ProductString, + zeitronix.ProductString: + return datalogger.EXTERNALWBLSYM // Lambda.External + default: + return "None" + } +} + +func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { + return colors.StringToColorBlindMode(fyne.CurrentApp().Preferences().StringWithFallback(prefsColorBlindMode, "Normal")) +} + +func (sw *Widget) GetWidebandPort() string { + return fyne.CurrentApp().Preferences().String(prefsWBLPort) +} + +func (sw *Widget) GetWBLSupportPoints() []int { + return fyne.CurrentApp().Preferences().IntListWithFallback(prefsWBLSupportPoints, []int{0, 1024}) +} + +func (sw *Widget) GetWBLLambdaValues() []float64 { + return fyne.CurrentApp().Preferences().FloatListWithFallback(prefsWBLLambdaValues, []float64{0.5, 1.5}) +} + +func (sw *Widget) GetUseADScanner() bool { + if fyne.CurrentApp().Preferences().String(prefsWblSource) != "ECU" { + return false + } + return fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) +} + +func (sw *Widget) GetFreq() int { + return int(fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25)) +} + +func (sw *Widget) GetAutoSave() bool { + return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateSaveEcu) +} + +func (sw *Widget) GetAutoLoad() bool { + return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateLoadEcu) +} + +func (sw *Widget) GetLivePreview() bool { + return fyne.CurrentApp().Preferences().Bool(prefsLivePreview) +} + +func (sw *Widget) GetRealtimeBars() bool { + return fyne.CurrentApp().Preferences().Bool(prefsRealtimeBars) +} + +func (sw *Widget) GetMeshView() bool { + return fyne.CurrentApp().Preferences().Bool(prefsMeshView) +} + +func (sw *Widget) GetLogFormat() string { + return fyne.CurrentApp().Preferences().StringWithFallback(prefsLogFormat, "CSV") +} + +func (sw *Widget) GetLogPath() string { + p := fyne.CurrentApp().Preferences().String(prefsLogPath) + if p == "" { + var err error + p, err = common.GetLogPath() + if err != nil { + log.Println("GetLogPath: ", err) + } + } + return p +} + +func (sw *Widget) GetUseMPH() bool { + return fyne.CurrentApp().Preferences().Bool(prefsUseMPH) +} + +func (sw *Widget) GetSwapRPMandSpeed() bool { + return fyne.CurrentApp().Preferences().Bool(prefsSwapRPMandSpeed) +} + +func (sw *Widget) GetCursorFollowCrosshair() bool { + return fyne.CurrentApp().Preferences().Bool(prefsCursorFollowCrosshair) +} + +func (sw *Widget) GetPlotterRenderer() plotter.PlotBackend { + return plotter.PlotBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsPlotterRenderer, 0)) +} + +func (sw *Widget) GetMeshRenderer() meshgrid.RenderBackend { + return meshgrid.RenderBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 0)) +} diff --git a/pkg/widgets/settings/settings_internal.go b/pkg/widgets/settings/settings_internal.go index 09c2fd2a..9704ff30 100644 --- a/pkg/widgets/settings/settings_internal.go +++ b/pkg/widgets/settings/settings_internal.go @@ -220,6 +220,34 @@ func (sw *Widget) newColorBlindMode() *widget.Select { }) } +func (sw *Widget) newPlotRendererSelect() *widget.Select { + return widget.NewSelect([]string{"Software", "Shader"}, func(s string) { + var mode int + switch s { + case "Software": + mode = 0 + case "Shader": + mode = 1 + } + fyne.CurrentApp().Preferences().SetInt(prefsPlotterRenderer, mode) + }) +} + +func (sw *Widget) newMeshRendererSelect() *widget.Select { + return widget.NewSelect([]string{"Software", "Polygons", "Shader"}, func(s string) { + var mode int + switch s { + case "Shader": + mode = 0 + case "Polygons": + mode = 1 + case "Software": + mode = 2 + } + fyne.CurrentApp().Preferences().SetInt(prefsMeshRenderer, mode) + }) +} + func (sw *Widget) newAdapterSelector() *widget.Select { return widget.NewSelect(gocan.ListAdapterNames(), func(s string) { if info, found := sw.adapters[s]; found { @@ -319,6 +347,11 @@ func (sw *Widget) loadPreferences() { loadPrefsSelect(sw.portSelector, prefsPort, "") loadPrefsSelect(sw.speedSelector, prefsSpeed, "115200") loadPrefsCheck(sw.debugCheckbox, prefsDebug, false) + + // graphics settings + + sw.plotRendererSelect.SetSelectedIndex(fyne.CurrentApp().Preferences().IntWithFallback(prefsPlotterRenderer, 0)) + sw.meshRendererSelect.SetSelectedIndex(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 2)) } func loadPrefsSelect(s *widget.Select, prefKey string, fallback string) { diff --git a/pkg/widgets/settings/settings_tabs.go b/pkg/widgets/settings/settings_tabs.go index bf4ab6ee..eccfa5da 100644 --- a/pkg/widgets/settings/settings_tabs.go +++ b/pkg/widgets/settings/settings_tabs.go @@ -68,6 +68,58 @@ func (sw *Widget) generalTab() *container.TabItem { )) } +func (sw *Widget) graphicsTab() *container.TabItem { + return container.NewTabItem("Graphics", container.NewVBox( + container.NewBorder( + nil, + nil, + widget.NewLabel("Plot renderer"), + nil, + sw.plotRendererSelect, + ), + container.NewBorder( + nil, + nil, + widget.NewLabel("Mesh renderer"), + nil, + sw.meshRendererSelect, + ), + )) +} + +func (sw *Widget) canTab() *container.TabItem { + return container.NewTabItem("CAN", container.NewVBox( + container.NewBorder( + nil, + nil, + xlayout.NewFixedWidth(70, widget.NewLabel("Adapter")), + sw.debugCheckbox, + sw.adapterSelector, + ), + container.NewBorder( + nil, + nil, + xlayout.NewFixedWidth(70, widget.NewLabel("Port")), + sw.refreshBtn, + sw.portSelector, + ), + container.NewBorder( + nil, + nil, + xlayout.NewFixedWidth(70, widget.NewLabel("Info")), + nil, + sw.portDescription, + ), + container.NewBorder( + nil, + nil, + xlayout.NewFixedWidth(70, widget.NewLabel("Speed")), + nil, + sw.speedSelector, + ), + )) +} + func (sw *Widget) loggingTab() *container.TabItem { return container.NewTabItem("Logging", container.NewVBox( container.NewBorder( @@ -188,36 +240,3 @@ func (sw *Widget) adScannerTab() *container.TabItem { sw.wbleditor.Hide() return container.NewTabItem("AD Scanner", sw.wbleditor) } - -func (sw *Widget) canTab() *container.TabItem { - return container.NewTabItem("CAN", container.NewVBox( - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Adapter")), - sw.debugCheckbox, - sw.adapterSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Port")), - sw.refreshBtn, - sw.portSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Info")), - nil, - sw.portDescription, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Speed")), - nil, - sw.speedSelector, - ), - )) -} diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index f4d44b50..90e0cb32 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -416,8 +416,9 @@ func (mw *MainWindow) LoadLogfile(filename string, r io.Reader, pos fyne.Positio mw.Log("loaded log file " + filename) lp := logplayer.New(&logplayer.Config{ - EBus: ebus.CONTROLLER, - Logfile: logz, + EBus: ebus.CONTROLLER, + Logfile: logz, + PlotterRenderer: mw.settings.GetPlotterRenderer(), }) /* content := container.NewBorder( diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 7e9083f5..483a4861 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -500,6 +500,7 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) OnUpdateCell: updateFunc, MeshView: mw.settings.GetMeshView(), + MeshRenderer: mw.settings.GetMeshRenderer(), Editable: true, CursorFollowCrosshair: mw.settings.GetCursorFollowCrosshair(), ColorblindMode: mw.settings.GetColorBlindMode(), From ef9c1a648ec9bedae6ace56fbc9bdea6bb4710ca Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 12:06:46 +0200 Subject: [PATCH 36/93] fix poly meshgrid --- pkg/widgets/meshgrid/meshgrid_poly.go | 16 +++++++++++----- pkg/widgets/meshgrid/meshgrid_shader.go | 13 +++++++++---- pkg/widgets/settings/settings_internal.go | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/pkg/widgets/meshgrid/meshgrid_poly.go b/pkg/widgets/meshgrid/meshgrid_poly.go index 709d8d95..fd78cb3c 100644 --- a/pkg/widgets/meshgrid/meshgrid_poly.go +++ b/pkg/widgets/meshgrid/meshgrid_poly.go @@ -68,9 +68,15 @@ func (m *Meshgrid) updatePolygons() { zRange = 1 } - // Per-vertex screen projection and color. Float projection keeps subpixel - // precision, which the GPU edges make visible (the image path rounds to - // whole pixels). + // Per-vertex screen projection and color. Each vertex is snapped to a whole + // pixel: the GL painter renders a cell's corner at + // round(origin*scale) + round((corner-origin)*scale), and since every cell + // uses its own bounding-box origin, an un-snapped (fractional) corner rounds + // to a different device pixel for each of the two cells that share it - the + // gaps/overlaps that made cell spacing look uneven. Snapping the projection + // to integer pixels (as the image path also does) cancels the per-cell + // origin out at integer pixel-scales, so a shared corner lands identically + // for both cells and their edges line up. n := vRows * vCols if cap(m.scratchFX) < n { m.scratchFX = make([]float32, n) @@ -91,8 +97,8 @@ func (m *Meshgrid) updatePolygons() { for j := 0; j < vCols; j++ { v := row[j] idx := base + j - fxs[idx] = float32(cx + v.X) - fys[idx] = float32(cy + v.Y) + fxs[idx] = float32(math.Round(cx + v.X)) + fys[idx] = float32(math.Round(cy + v.Y)) depth := (v.Z - minZ) / zRange vertCol[idx] = m.getColorWithDepth(v.V, depth) } diff --git a/pkg/widgets/meshgrid/meshgrid_shader.go b/pkg/widgets/meshgrid/meshgrid_shader.go index 79f0e7b9..b6f81140 100644 --- a/pkg/widgets/meshgrid/meshgrid_shader.go +++ b/pkg/widgets/meshgrid/meshgrid_shader.go @@ -151,10 +151,15 @@ vec3 height_color(float h, float view_z) { float df = clamp((view_z - view_zmin) / view_zrange, 0.0, 1.0); vec3 rgb = base.rgb * (0.6 + 0.4 * df); rgb.b = min(1.0, rgb.b + (1.0 - df) * 0.05882353); - if (base.r > 0.784 && base.g > 0.784 && base.b < 0.196) { - rgb.r = min(1.0, rgb.r * 1.1); - rgb.g = min(1.0, rgb.g * 1.1); - } + // Yellow emphasis from getColorWithDepth, but ramped smoothly: the CPU + // path applies the 10% boost per vertex and lets Gouraud blur its edge, + // while this shader runs per pixel, so a hard threshold would draw a + // visible band where the boost switches on. smoothstep fades it in around + // pure yellow so the mid-range stays a continuous gradient. + float yellow = smoothstep(0.6, 0.95, base.r) * smoothstep(0.6, 0.95, base.g) * (1.0 - smoothstep(0.2, 0.4, base.b)); + float boost = 1.0 + 0.1 * yellow; + rgb.r = min(1.0, rgb.r * boost); + rgb.g = min(1.0, rgb.g * boost); return rgb; } diff --git a/pkg/widgets/settings/settings_internal.go b/pkg/widgets/settings/settings_internal.go index 9704ff30..10fc216f 100644 --- a/pkg/widgets/settings/settings_internal.go +++ b/pkg/widgets/settings/settings_internal.go @@ -234,7 +234,7 @@ func (sw *Widget) newPlotRendererSelect() *widget.Select { } func (sw *Widget) newMeshRendererSelect() *widget.Select { - return widget.NewSelect([]string{"Software", "Polygons", "Shader"}, func(s string) { + return widget.NewSelect([]string{"Shader", "Polygons", "Software"}, func(s string) { var mode int switch s { case "Shader": From 24af0ea58d46d30f7778d0dc807425786a87de05 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 18:56:58 +0200 Subject: [PATCH 37/93] meshgrid improvements --- pkg/widgets/meshgrid/meshgrid_shader.go | 45 ++++++++- pkg/widgets/meshgrid/meshgrid_widget.go | 122 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/pkg/widgets/meshgrid/meshgrid_shader.go b/pkg/widgets/meshgrid/meshgrid_shader.go index b6f81140..23db6bd9 100644 --- a/pkg/widgets/meshgrid/meshgrid_shader.go +++ b/pkg/widgets/meshgrid/meshgrid_shader.go @@ -204,6 +204,41 @@ void edge_check(vec2 p_dev, vec3 pa, vec3 pb, float ha, float hb, inout float be } } +// fake ambient occlusion: darken concave cells (valleys, creases) using the +// height-field Laplacian sampled at the cell centre and its four neighbours. +// A positive Laplacian means the centre sits below its surroundings, so it +// would be shadowed by them; convex ridges (negative) are left untouched. +float cell_ao(float cx, float cy) { + float cxm = max(cx - 0.5, 0.0); + float cxp = min(cx + 1.5, grid_cols); + float cym = max(cy - 0.5, 0.0); + float cyp = min(cy + 1.5, grid_rows); + float hc = corner_height(cx + 0.5, cy + 0.5); + float lap = corner_height(cxp, cy + 0.5) + corner_height(cxm, cy + 0.5) + + corner_height(cx + 0.5, cyp) + corner_height(cx + 0.5, cym) - 4.0 * hc; + float c = clamp(lap / max(height_units, 0.0001), 0.0, 1.0); + return 1.0 - 0.4 * c; +} + +// Blinn-Phong shading with an ambient floor and fake AO. n is the raw cell +// normal from cross(C-A, D-B), which points along -Z for a flat cell, so it is +// flipped to face up. light and view_dir are unit vectors in grid space; the +// specular term is gated to the lit side and the ambient term keeps shadowed +// faces readable instead of black. +vec3 shade_surface(vec3 base, vec3 n, vec3 light, vec3 view_dir, float ao) { + float nl = length(n); + if (nl <= 0.0) { + return base * ao; + } + vec3 N = -n / nl; + float diff = max(dot(N, light), 0.0); + vec3 H = normalize(light + view_dir); + float spec = (diff > 0.0) ? pow(max(dot(N, H), 0.0), 32.0) : 0.0; + vec3 col = base * ((0.32 + 0.68 * diff) * ao); + col += vec3(0.25 * spec); + return col; +} + void main() { mat3 rot = mat3(r0, r3, r6, r1, r4, r7, r2, r5, r8); @@ -256,6 +291,10 @@ void main() { int mode = int(render_mode + 0.5); float half_w = 0.5 * pix_scale; vec3 light = vec3(light_x, light_y, light_z); + // grid-space direction from the surface toward the camera: the view ray + // marches from near to far along rd = -(r6,r7,r8), so the viewer lies along + // +(r6,r7,r8), already unit length since the rotation is orthonormal + vec3 view_dir = vec3(r6, r7, r8); vec3 acc = vec3(0.0); float acc_a = 0.0; @@ -304,10 +343,8 @@ void main() { vec3 rgb = height_color(hit_h, view_z); vec3 n = cross(C - A, D - B); - float nl = length(n); - if (nl > 0.0) { - rgb *= 0.6 + 0.4 * abs(dot(n / nl, light)); - } + float ao = cell_ao(cx, cy); + rgb = shade_surface(rgb, n, light, view_dir, ao); if (mode == 0) { vec3 pa = project_grid(rot, A, pix_scale); diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index a7382700..a637a94f 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -5,6 +5,7 @@ import ( "image" "image/color" "log" + "math" "os" "time" @@ -105,6 +106,11 @@ type Meshgrid struct { rotationMatrix Matrix3x3 scale float64 + // fitted is set once the widget has received its first real size and the + // mesh has been auto-scaled to fit. Subsequent resizes scale relative to + // the size change so the user's manual zoom is preserved. + fitted bool + cameraRotation Matrix3x3 // Camera's rotation matrix cameraPosition [3]float64 // Camera's position in world space mousePosition image.Point @@ -320,6 +326,118 @@ func (m *Meshgrid) scaleMeshgrid(factor float64) { m.updateVertexPositions() } +// fitMargin leaves a border around the mesh so it isn't drawn flush to the +// widget edges (and so the axis indicator, which extends past the mesh, has +// some room). +const fitMargin = 0.85 + +// projectedBounds returns the min/max of the mesh's current projected +// (orthographic) screen positions. Both reflect the current scale, rotation +// and pan. +func (m *Meshgrid) projectedBounds() (minX, maxX, minY, maxY float64) { + minX, maxX = math.Inf(1), math.Inf(-1) + minY, maxY = math.Inf(1), math.Inf(-1) + for i := range m.vertices { + row := m.vertices[i] + for j := range row { + v := &row[j] + if v.X < minX { + minX = v.X + } + if v.X > maxX { + maxX = v.X + } + if v.Y < minY { + minY = v.Y + } + if v.Y > maxY { + maxY = v.Y + } + } + } + return +} + +// projectedExtent returns the width and height, in logical pixels, of the +// mesh's current projected bounding box. It reflects the current scale and +// rotation; panning shifts every vertex equally so the extent is +// pan-invariant. +func (m *Meshgrid) projectedExtent() (w, h float64) { + minX, maxX, minY, maxY := m.projectedBounds() + if math.IsInf(minX, 0) || math.IsInf(minY, 0) { + return 0, 0 + } + return maxX - minX, maxY - minY +} + +// centerInView pans the camera so the mesh's projected bounding box is +// centered in the widget. Used on the initial fit only; the camera offset it +// produces scales with the mesh on later resizes, so the centering (and any +// user pan applied on top) is preserved. +func (m *Meshgrid) centerInView() { + minX, maxX, minY, maxY := m.projectedBounds() + if math.IsInf(minX, 0) || math.IsInf(minY, 0) { + return + } + // v.{X,Y} = base - cam, so adding the current box center to the camera + // moves the box center to the view origin (which maps to screen center). + m.cameraPosition[0] += (minX + maxX) / 2 + m.cameraPosition[1] += (minY + maxY) / 2 + m.updateVertexPositions() +} + +// fitScaleForSize returns the m.scale value that makes the mesh's projected +// bounding box fill the given widget size (minus fitMargin) at the current +// rotation. The bounding box is linear in m.scale, so it is normalized to a +// unit scale first. +func (m *Meshgrid) fitScaleForSize(size fyne.Size) float64 { + w, h := m.projectedExtent() + if w <= 0 || h <= 0 || m.scale == 0 { + return m.scale + } + wPer := w / m.scale + hPer := h / m.scale + sx := float64(size.Width) * fitMargin / wPer + sy := float64(size.Height) * fitMargin / hPer + return math.Min(sx, sy) +} + +// adaptZoom keeps the mesh sized to the widget across layout changes. The +// first real layout fits the mesh to the view and centers it; later resizes +// scale the mesh (and the camera pan) by the ratio of the new fit-scale to +// the old. This grows and shrinks the mesh with the window while preserving +// whatever zoom and pan the user has applied — it adjusts the existing scale +// and pan multiplicatively rather than resetting them. +func (m *Meshgrid) adaptZoom(oldSize, newSize fyne.Size) { + if newSize.Width <= 0 || newSize.Height <= 0 { + return + } + if !m.fitted { + m.scale = m.fitScaleForSize(newSize) + m.fitted = true + m.updateVertexPositions() + m.centerInView() + return + } + if oldSize.Width <= 0 || oldSize.Height <= 0 { + return + } + oldFit := m.fitScaleForSize(oldSize) + if oldFit == 0 { + return + } + ratio := m.fitScaleForSize(newSize) / oldFit + if ratio <= 0 || math.IsInf(ratio, 0) || math.IsNaN(ratio) { + return + } + m.scale *= ratio + // Pan lives in the same scaled view space, so scale it alongside the mesh + // to keep the centering and any user pan in the same relative spot. + m.cameraPosition[0] *= ratio + m.cameraPosition[1] *= ratio + m.updateVertexPositions() +} + // orbit performs a Fusion 360-style "turntable" orbit. Spin is applied around // the mesh's own vertical axis — data Z, the height axis (right-multiplied so // it rotates the model before the camera) — pitch around the camera-local X @@ -521,7 +639,11 @@ func (m *meshgridRenderer) Layout(size fyne.Size) { if size == m.MG.size { return } + oldSize := m.MG.size m.MG.size = size + // Auto-fit on the first real size and scale with the window thereafter, + // preserving any zoom the user has applied. + m.MG.adaptZoom(oldSize, size) switch m.MG.backend { case BackendShader: m.MG.shader.Resize(size) From 8031da553e7fcb4a3db2eb2abdc41b77e29ba8e2 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 21:01:02 +0200 Subject: [PATCH 38/93] set default mode for mesh --- pkg/widgets/settings/settings_getters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/widgets/settings/settings_getters.go b/pkg/widgets/settings/settings_getters.go index d7154a9e..f12404e4 100644 --- a/pkg/widgets/settings/settings_getters.go +++ b/pkg/widgets/settings/settings_getters.go @@ -247,5 +247,5 @@ func (sw *Widget) GetPlotterRenderer() plotter.PlotBackend { } func (sw *Widget) GetMeshRenderer() meshgrid.RenderBackend { - return meshgrid.RenderBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 0)) + return meshgrid.RenderBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 2)) } From 59bfd11f3b504a83acb71a6027c7a8fbc1705f33 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 22:52:12 +0200 Subject: [PATCH 39/93] save settings --- pkg/widgets/settings/settings_internal.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/widgets/settings/settings_internal.go b/pkg/widgets/settings/settings_internal.go index 10fc216f..53c36798 100644 --- a/pkg/widgets/settings/settings_internal.go +++ b/pkg/widgets/settings/settings_internal.go @@ -221,9 +221,9 @@ func (sw *Widget) newColorBlindMode() *widget.Select { } func (sw *Widget) newPlotRendererSelect() *widget.Select { - return widget.NewSelect([]string{"Software", "Shader"}, func(s string) { + return widget.NewSelect([]string{"Software", "Shader"}, func(selection string) { var mode int - switch s { + switch selection { case "Software": mode = 0 case "Shader": @@ -234,9 +234,9 @@ func (sw *Widget) newPlotRendererSelect() *widget.Select { } func (sw *Widget) newMeshRendererSelect() *widget.Select { - return widget.NewSelect([]string{"Shader", "Polygons", "Software"}, func(s string) { + return widget.NewSelect([]string{"Shader", "Polygons", "Software"}, func(selection string) { var mode int - switch s { + switch selection { case "Shader": mode = 0 case "Polygons": From 3c66f3f5aa43e7d10e623560b12d3362c648c5d6 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 13 Jun 2026 23:59:28 +0200 Subject: [PATCH 40/93] refactor settings --- .../loggingsettings/loggingsettings.go | 0 pkg/widgets/settings/adapter.go | 115 +++++ pkg/widgets/settings/controls.go | 212 ++++++++ pkg/widgets/settings/getters.go | 106 ++++ pkg/widgets/settings/images.go | 39 ++ pkg/widgets/settings/prefs.go | 110 +++++ pkg/widgets/settings/settings.go | 196 +++----- pkg/widgets/settings/settings_getters.go | 251 ---------- pkg/widgets/settings/settings_internal.go | 383 --------------- pkg/widgets/settings/settings_tabs.go | 242 ---------- pkg/widgets/settings/tabs.go | 128 +++++ pkg/widgets/settings/wbleditor.go | 457 +++--------------- pkg/widgets/settings/wblgraph.go | 325 +++++++++++++ 13 files changed, 1151 insertions(+), 1413 deletions(-) rename {pkg/widgets/settings => experiments}/loggingsettings/loggingsettings.go (100%) create mode 100644 pkg/widgets/settings/adapter.go create mode 100644 pkg/widgets/settings/controls.go create mode 100644 pkg/widgets/settings/getters.go create mode 100644 pkg/widgets/settings/images.go create mode 100644 pkg/widgets/settings/prefs.go delete mode 100644 pkg/widgets/settings/settings_getters.go delete mode 100644 pkg/widgets/settings/settings_internal.go delete mode 100644 pkg/widgets/settings/settings_tabs.go create mode 100644 pkg/widgets/settings/tabs.go create mode 100644 pkg/widgets/settings/wblgraph.go diff --git a/pkg/widgets/settings/loggingsettings/loggingsettings.go b/experiments/loggingsettings/loggingsettings.go similarity index 100% rename from pkg/widgets/settings/loggingsettings/loggingsettings.go rename to experiments/loggingsettings/loggingsettings.go diff --git a/pkg/widgets/settings/adapter.go b/pkg/widgets/settings/adapter.go new file mode 100644 index 00000000..dbf4545f --- /dev/null +++ b/pkg/widgets/settings/adapter.go @@ -0,0 +1,115 @@ +package settings + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/ota" +) + +func (sw *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { + return sw.GetAdapterWithExtraFilters(ecuType, nil) +} + +func (sw *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { + baudrate, err := parseBaudrate(prefSpeed.getOr("")) + if err != nil { + return nil, err + } + + adapterName := prefAdapter.get() + if adapterName == "" { + return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok + } + + port := prefPort.get() + if ad, found := sw.adapters[adapterName]; found && ad.RequiresSerialPort && port == "" { + return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok + } + + canFilter, canRate := canFilterAndRate(ecuType, adapterName, filters) + + cfg := &gocan.AdapterConfig{ + Port: port, + PortBaudrate: baudrate, + CANRate: canRate, + CANFilter: canFilter, + Debug: prefDebug.get(), + PrintVersion: true, + } + + if strings.HasPrefix(adapterName, "J2534") { + return gocan.NewGWClient(adapterName, cfg) + } + + if adapterName == "txbridge wifi" { + cfg.AdditionalConfig = map[string]string{ + "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), + "minversion": ota.MinimumtxbridgeVersion, + } + } + + return gocan.NewAdapter(adapterName, cfg) +} + +// parseBaudrate converts a stored port speed (which may use the "Nmbit" +// shorthand or be empty) into a numeric baudrate. +func parseBaudrate(speed string) (int, error) { + switch speed { + case "1mbit": + speed = "1000000" + case "2mbit": + speed = "2000000" + case "3mbit": + speed = "3000000" + case "": + speed = "1000000" + } + return strconv.Atoi(speed) +} + +// canFilterAndRate returns the CAN acceptance filter and bus rate for the given +// ECU type and adapter. extraFilters are appended for the Trionic 8 family. +func canFilterAndRate(ecuType, adapterName string, extraFilters []uint32) ([]uint32, float64) { + wblOnCAN := prefWblSource.get() == "CAN" + + switch ecuType { + case "T5", "Trionic 5": + return []uint32{0xC}, 615.384 + + case "T7", "Trionic 7": + var filter []uint32 + if isOBDAdapter(adapterName) || strings.HasSuffix(adapterName, "Wifi") { + filter = []uint32{0x238, 0x258, 0x270} + } else { + filter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} + } + if wblOnCAN { + filter = append(filter, 0x180) + } + return filter, 500 + + case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": + var filter []uint32 + if isOBDAdapter(adapterName) { + filter = []uint32{0x5E8, 0x7E8} + } else { + filter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} + } + if wblOnCAN { + filter = append(filter, 0x180) + } + return append(filter, extraFilters...), 500 + } + + return nil, 0 +} + +func isOBDAdapter(name string) bool { + return strings.Contains(name, "ELM327") || + strings.Contains(name, "STN") || + strings.Contains(name, "OBDLink") +} diff --git a/pkg/widgets/settings/controls.go b/pkg/widgets/settings/controls.go new file mode 100644 index 00000000..3be2f7a9 --- /dev/null +++ b/pkg/widgets/settings/controls.go @@ -0,0 +1,212 @@ +package settings + +import ( + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/ebus" + "github.com/roffe/txlogger/pkg/wbl/aem" + "github.com/roffe/txlogger/pkg/wbl/ecumaster" + "github.com/roffe/txlogger/pkg/wbl/innovate" + "github.com/roffe/txlogger/pkg/wbl/plx" + "github.com/roffe/txlogger/pkg/wbl/stag" + "github.com/roffe/txlogger/pkg/wbl/zeitronix" +) + +var ( + logFormats = []string{"CSV", "BPL" /*"TXL"*/} + portSpeeds = []string{"9600", "19200", "38400", "57600", "115200", "230400", "460800", "500000", "921600", "1mbit", "2mbit", "3mbit"} +) + +// --- generic control helpers ----------------------------------------------- + +// checkBox builds a checkbox bound to a boolean preference. +func checkBox(label string, p boolPref) *widget.Check { + return widget.NewCheck(label, p.set) +} + +// indexSelect builds a select whose chosen option index is stored in an +// integer preference (the option order defines the stored value). +func indexSelect(options []string, p intPref) *widget.Select { + sel := widget.NewSelect(options, nil) + sel.OnChanged = func(string) { + p.set(sel.SelectedIndex()) + } + return sel +} + +// setVisible shows or hides a group of canvas objects. +func setVisible(show bool, objs ...fyne.CanvasObject) { + for _, o := range objs { + if show { + o.Show() + } else { + o.Hide() + } + } +} + +// --- wideband source table ------------------------------------------------- + +// wblSource describes a selectable wideband source and the UI it enables. +type wblSource struct { + name string + image string // image resource key, "" for none + portSelect bool // show the WBL port selector + adScanner bool // show the AD scanner controls +} + +var wblSources = []wblSource{ + {name: "None"}, + {name: "ECU", image: "t7", adScanner: true}, // T7 image used as a placeholder + {name: aem.ProductString, image: "uego", portSelect: true}, + {name: "CombiAdapter", image: "combi", adScanner: true}, + {name: ecumaster.ProductString, image: "lambdatocan"}, + {name: innovate.ProductString, image: "mtx-l", portSelect: true}, + {name: plx.ProductString, image: "plx", portSelect: true}, + {name: stag.ProductString, image: "stagafr", portSelect: true}, + {name: zeitronix.ProductString, image: "zeitronix", portSelect: true}, +} + +func (sw *Widget) newWBLSelector() *fyne.Container { + names := make([]string, len(wblSources)) + byName := make(map[string]wblSource, len(wblSources)) + for i, s := range wblSources { + names[i] = s.name + byName[s.name] = s + } + + sw.wblSource = widget.NewSelect(names, func(s string) { + prefWblSource.set(s) + prefWidebandSymbolName.set(sw.GetWidebandSymbolName()) + + src := byName[s] + if src.image != "" { + if img := newImageFromResource(src.image); img != nil { + sw.wblImage.Resource = img.Resource + sw.wblImage.SetMinSize(img.MinSize()) + sw.wblImage.Refresh() + } + } + setVisible(src.portSelect, sw.wblPortLabel, sw.wblPortSelect, sw.wblPortRefreshButton) + setVisible(src.adScanner, sw.wblADscanner, sw.wblADScannerSymbol) + }) + + return container.NewBorder(nil, nil, widget.NewLabel("Source"), nil, sw.wblSource) +} + +// --- individual controls --------------------------------------------------- + +func (sw *Widget) newFreqSlider() *widget.Slider { + slider := widget.NewSlider(5, 300) + slider.Step = 5 + slider.OnChanged = func(f float64) { + sw.freqValue.SetText(strconv.FormatFloat(f, 'f', 0, 64)) + } + slider.OnChangeEnded = func(f float64) { + prefFreq.set(int(f)) + } + return slider +} + +func (sw *Widget) newLogFormat() *widget.Select { + return widget.NewSelect(logFormats, prefLogFormat.set) +} + +func (sw *Widget) newADscannerCheck() *widget.Check { + return widget.NewCheck("use AD Scanner (don't forget to add symbol)", func(b bool) { + setVisible(b, sw.wblADScannerSymbol) + prefUseADScanner.set(b) + }) +} + +func (sw *Widget) newColorBlindMode() *widget.Select { + return widget.NewSelect(colors.SupportedColorBlindModes[:], func(s string) { + prefColorBlindMode.set(s) + ebus.Publish(ebus.TOPIC_COLORBLINDMODE, float64(sw.colorBlindMode.SelectedIndex())) + }) +} + +func (sw *Widget) newAdapterSelector() *widget.Select { + return widget.NewSelect(nil, func(s string) { + info, found := sw.adapters[s] + if !found { + return + } + prefAdapter.set(s) + if info.RequiresSerialPort { + sw.portSelector.Enable() + sw.speedSelector.Enable() + return + } + sw.portDescription.SetText("") + sw.portSelector.Disable() + sw.speedSelector.Disable() + }) +} + +func (sw *Widget) newPortSelector() *widget.Select { + return widget.NewSelect(sw.ListPorts(), func(s string) { + prefPort.set(s) + if itm, ok := portCache[s]; ok { + sw.portDescription.SetText(itm.SerialNumber) + } else { + sw.portDescription.SetText("") + } + }) +} + +func (sw *Widget) newSpeedSelector() *widget.Select { + return widget.NewSelect(portSpeeds, prefSpeed.set) +} + +func (sw *Widget) newPortRefreshButton() *widget.Button { + return widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + sw.portSelector.Options = sw.ListPorts() + sw.portSelector.Refresh() + }) +} + +// --- preference hydration -------------------------------------------------- + +// loadPreferences pushes persisted values into the controls once every widget +// has been constructed. +func (sw *Widget) loadPreferences() { + sw.freqSlider.SetValue(float64(prefFreq.get())) + sw.autoLoad.SetChecked(prefAutoLoad.get()) + sw.autoSave.SetChecked(prefAutoSave.get()) + sw.cursorFollowCrosshair.SetChecked(prefCursorFollowCrosshair.get()) + sw.livePreview.SetChecked(prefLivePreview.get()) + sw.meshView.SetChecked(prefMeshView.get()) + sw.realtimeBars.SetChecked(prefRealtimeBars.get()) + sw.logFormat.SetSelected(prefLogFormat.get()) + + logPath, err := common.GetLogPath() + if err != nil { + fyne.LogError("Could not get log path", err) + } + sw.logPath.SetText(prefLogPath.getOr(logPath)) + + sw.wblSource.SetSelected(prefWblSource.get()) + sw.wblADscanner.SetChecked(prefUseADScanner.get()) + setVisible(sw.wblADscanner.Checked && sw.wblSource.Selected == "ECU", sw.wblADScannerSymbol) + + sw.useMPH.SetChecked(prefUseMPH.get()) + sw.swapRPMandSpeed.SetChecked(prefSwapRPMandSpeed.get()) + sw.wblPortSelect.SetSelected(prefWBLPort.get()) + sw.colorBlindMode.SetSelected(prefColorBlindMode.get()) + + sw.adapterSelector.SetSelected(prefAdapter.get()) + sw.portSelector.SetSelected(prefPort.get()) + sw.speedSelector.SetSelected(prefSpeed.get()) + sw.debugCheckbox.SetChecked(prefDebug.get()) + + // Graphics + sw.plotRendererSelect.SetSelectedIndex(prefPlotterRenderer.get()) + sw.meshRendererSelect.SetSelectedIndex(prefMeshRenderer.get()) +} diff --git a/pkg/widgets/settings/getters.go b/pkg/widgets/settings/getters.go new file mode 100644 index 00000000..c73667ad --- /dev/null +++ b/pkg/widgets/settings/getters.go @@ -0,0 +1,106 @@ +package settings + +import ( + "log" + + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/datalogger" + "github.com/roffe/txlogger/pkg/wbl/aem" + "github.com/roffe/txlogger/pkg/wbl/ecumaster" + "github.com/roffe/txlogger/pkg/wbl/innovate" + "github.com/roffe/txlogger/pkg/wbl/plx" + "github.com/roffe/txlogger/pkg/wbl/stag" + "github.com/roffe/txlogger/pkg/wbl/zeitronix" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +// General +func (sw *Widget) GetFreq() int { return prefFreq.get() } +func (sw *Widget) GetAutoSave() bool { return prefAutoSave.get() } +func (sw *Widget) GetAutoLoad() bool { return prefAutoLoad.get() } +func (sw *Widget) GetLivePreview() bool { return prefLivePreview.get() } +func (sw *Widget) GetRealtimeBars() bool { return prefRealtimeBars.get() } +func (sw *Widget) GetMeshView() bool { return prefMeshView.get() } +func (sw *Widget) GetCursorFollowCrosshair() bool { return prefCursorFollowCrosshair.get() } + +func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { + return colors.StringToColorBlindMode(prefColorBlindMode.get()) +} + +// Graphics +func (sw *Widget) GetPlotterRenderer() plotter.PlotBackend { + return plotter.PlotBackend(prefPlotterRenderer.get()) +} + +func (sw *Widget) GetMeshRenderer() meshgrid.RenderBackend { + return meshgrid.RenderBackend(prefMeshRenderer.get()) +} + +// Logging +func (sw *Widget) GetLogFormat() string { return prefLogFormat.get() } + +func (sw *Widget) GetLogPath() string { + if p := prefLogPath.get(); p != "" { + return p + } + p, err := common.GetLogPath() + if err != nil { + log.Println("GetLogPath: ", err) + } + return p +} + +// Dashboard +func (sw *Widget) GetUseMPH() bool { return prefUseMPH.get() } +func (sw *Widget) GetSwapRPMandSpeed() bool { return prefSwapRPMandSpeed.get() } + +// Wideband +func (sw *Widget) GetADScannerSymbolName() string { return prefWBLADScannerSymbol.get() } +func (sw *Widget) GetWidebandName() string { return prefWblSource.get() } +func (sw *Widget) GetWidebandPort() string { return prefWBLPort.get() } +func (sw *Widget) GetWBLSupportPoints() []int { return prefWBLSupportPoints.get() } +func (sw *Widget) GetWBLLambdaValues() []float64 { return prefWBLLambdaValues.get() } + +func (sw *Widget) GetUseADScanner() bool { + if prefWblSource.get() != "ECU" { + return false + } + return prefUseADScanner.get() +} + +// GetWidebandSymbolName resolves the symbol to log for the selected wideband +// source, taking the connected ECU and AD scanner mode into account. +func (sw *Widget) GetWidebandSymbolName() string { + switch sw.GetWidebandName() { + case "ECU": + useADScanner := prefUseADScanner.get() + switch sw.cfg.SelectedEcuFunc() { + case "T5": + return datalogger.LAMBDAADSCANNER + case "T7": + if useADScanner { + return datalogger.LAMBDAADSCANNER + } + return "DisplProt.LambdaScanner" + case "T8": + if useADScanner { + return datalogger.LAMBDAADSCANNER + } + return "LambdaScan.LambdaScanner" + default: + return "None" + } + case aem.ProductString, + "CombiAdapter", + ecumaster.ProductString, + innovate.ProductString, + plx.ProductString, + stag.ProductString, + zeitronix.ProductString: + return datalogger.EXTERNALWBLSYM + default: + return "None" + } +} diff --git a/pkg/widgets/settings/images.go b/pkg/widgets/settings/images.go new file mode 100644 index 00000000..2ed62f2d --- /dev/null +++ b/pkg/widgets/settings/images.go @@ -0,0 +1,39 @@ +package settings + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "github.com/roffe/txlogger/pkg/assets" +) + +// imageSpec describes a bundled wideband product image and its display size. +type imageSpec struct { + content []byte + w, h float32 +} + +var imageSpecs = map[string]imageSpec{ + "mtx-l": {assets.MtxL, 224, 224}, + "lc-2": {assets.Lc2, 400, 224}, + "uego": {assets.Uego, 315, 224}, + "lambdatocan": {assets.LambdaToCan, 481, 224}, + "t7": {assets.T7, 320, 224}, + "plx": {assets.PLX, 470, 224}, + "combi": {assets.CombiV2, 360, 245}, + "zeitronix": {assets.ZeitronixZT2, 252, 252}, + "stagafr": {assets.STAGAfr, 252, 252}, +} + +// newImageFromResource builds a contained, fast-scaling image for the named +// product. Returns nil if the name is unknown. +func newImageFromResource(name string) *canvas.Image { + spec, ok := imageSpecs[name] + if !ok { + return nil + } + img := canvas.NewImageFromResource(fyne.NewStaticResource(name, spec.content)) + img.SetMinSize(fyne.NewSize(spec.w, spec.h)) + img.FillMode = canvas.ImageFillContain + img.ScaleMode = canvas.ImageScaleFastest + return img +} diff --git a/pkg/widgets/settings/prefs.go b/pkg/widgets/settings/prefs.go new file mode 100644 index 00000000..5b996fb9 --- /dev/null +++ b/pkg/widgets/settings/prefs.go @@ -0,0 +1,110 @@ +package settings + +import "fyne.io/fyne/v2" + +// prefs returns the application preference store. Every setting in this package +// is read and written through the typed descriptors below so that storage keys +// and default values live in exactly one place. +func prefs() fyne.Preferences { + return fyne.CurrentApp().Preferences() +} + +// Exported preference keys read directly by other packages (flash settings). +const ( + PrefsNvdm = "nvdm" + PrefsBoot = "boot" +) + +// --- typed preference descriptors ------------------------------------------ +// +// Each descriptor bundles a storage key with its default value and exposes +// get/set helpers, so adding a new setting is a single declaration. + +type boolPref struct { + key string + def bool +} + +func (p boolPref) get() bool { return prefs().BoolWithFallback(p.key, p.def) } +func (p boolPref) set(v bool) { prefs().SetBool(p.key, v) } + +type stringPref struct { + key string + def string +} + +func (p stringPref) get() string { return prefs().StringWithFallback(p.key, p.def) } +func (p stringPref) set(v string) { prefs().SetString(p.key, v) } + +// getOr reads the value but uses the supplied fallback instead of the +// descriptor's default (used when the default must be computed at runtime). +func (p stringPref) getOr(fallback string) string { + return prefs().StringWithFallback(p.key, fallback) +} + +type intPref struct { + key string + def int +} + +func (p intPref) get() int { return prefs().IntWithFallback(p.key, p.def) } +func (p intPref) set(v int) { prefs().SetInt(p.key, v) } + +type intListPref struct { + key string + def []int +} + +func (p intListPref) get() []int { return prefs().IntListWithFallback(p.key, p.def) } +func (p intListPref) set(v []int) { prefs().SetIntList(p.key, v) } + +type floatListPref struct { + key string + def []float64 +} + +func (p floatListPref) get() []float64 { return prefs().FloatListWithFallback(p.key, p.def) } +func (p floatListPref) set(v []float64) { prefs().SetFloatList(p.key, v) } + +// --- preference declarations ----------------------------------------------- + +var ( + // General + prefFreq = intPref{"freq", 25} + prefAutoLoad = boolPref{"autoUpdateLoadEcu", true} + prefAutoSave = boolPref{"autoUpdateSaveEcu", false} + prefCursorFollowCrosshair = boolPref{"cursorFollowCrosshair", false} + prefLivePreview = boolPref{"livePreview", true} + prefMeshView = boolPref{"liveMeshView", true} + prefRealtimeBars = boolPref{"realtimeBars", true} + prefColorBlindMode = stringPref{"colorBlindMode", "Normal"} + + // Graphics + prefPlotterRenderer = intPref{"plotterRenderer", 0} + prefMeshRenderer = intPref{"meshRenderer", 2} + + // Logging + prefLogFormat = stringPref{"logFormat", "CSV"} + prefLogPath = stringPref{"logPath", ""} + + // Dashboard + prefUseMPH = boolPref{"useMPH", false} + prefSwapRPMandSpeed = boolPref{"swapRPMandSpeed", false} + + // Wideband + prefWblSource = stringPref{"wblSource", "None"} + prefWidebandSymbolName = stringPref{"widebandSymbolName", ""} + prefWBLPort = stringPref{"wblPort", ""} + prefUseADScanner = boolPref{"useADScanner", false} + prefWBLADScannerSymbol = stringPref{"wblADScannerSymbol", ""} + prefWBLSupportPoints = intListPref{"wblSupportPoints", []int{0, 1024}} + prefWBLLambdaValues = floatListPref{"wblLambdaValues", []float64{0.5, 1.5}} + prefLastADScannerPreset = stringPref{"lastADScannerPreset", ""} + prefLastADScannerECU = stringPref{"lastADScannerECU", "T7"} + + // CAN + prefAdapter = stringPref{"adapter", ""} + prefPort = stringPref{"port", ""} + prefSpeed = stringPref{"speed", "115200"} + prefDebug = boolPref{"debug", false} +) diff --git a/pkg/widgets/settings/settings.go b/pkg/widgets/settings/settings.go index eb93c302..a312ef6a 100644 --- a/pkg/widgets/settings/settings.go +++ b/pkg/widgets/settings/settings.go @@ -18,61 +18,15 @@ import ( "go.bug.st/serial/enumerator" ) -const ( - prefsFreq = "freq" - prefsAutoUpdateLoadEcu = "autoUpdateLoadEcu" - prefsAutoUpdateSaveEcu = "autoUpdateSaveEcu" - prefsLivePreview = "livePreview" - prefsMeshView = "liveMeshView" - prefsRealtimeBars = "realtimeBars" - prefsLogFormat = "logFormat" - prefsLogPath = "logPath" - prefsWblSource = "wblSource" - prefsWidebandSymbolName = "widebandSymbolName" - prefsUseMPH = "useMPH" - prefsSwapRPMandSpeed = "swapRPMandSpeed" - prefsCursorFollowCrosshair = "cursorFollowCrosshair" - prefsWBLPort = "wblPort" - - prefsLastADScannerPreset = "lastADScannerPreset" - prefsWBLSupportPoints = "wblSupportPoints" - prefsWBLLambdaValues = "wblLambdaValues" - prefsLastADScannerECU = "lastADScannerECU" - prefsWBLADScannerSymbol = "wblADScannerSymbol" - - prefsUseADScanner = "useADScanner" - prefsColorBlindMode = "colorBlindMode" - - prefsPlotterRenderer = "plotterRenderer" - prefsMeshRenderer = "meshRenderer" - - // CAN - prefsAdapter = "adapter" - prefsPort = "port" - prefsSpeed = "speed" - prefsDebug = "debug" - - // Flash - PrefsNvdm = "nvdm" - PrefsBoot = "boot" -) - -var portSpeeds = []string{"9600", "19200", "38400", "57600", "115200", "230400", "460800", "500000", "921600", "1mbit", "2mbit", "3mbit"} - -type SettingsWidgetInterface interface { - Get(key string) (string, error) - Widget() fyne.Widget -} - -type SetText interface { - SetText(string) -} - +// Config carries callbacks the settings widget needs from its host. type Config struct { Logger func(string) SelectedEcuFunc func() string } +// Widget is the settings panel. All persisted state lives in the application +// preferences (see prefs.go); the fields below are just the UI controls bound +// to those preferences. type Widget struct { widget.BaseWidget @@ -80,7 +34,7 @@ type Widget struct { workDir *widget.Label - // CANSettings *cansettings.Widget + // General freqSlider *widget.Slider freqValue *widget.Label autoSave *widget.Check @@ -94,24 +48,22 @@ type Widget struct { useMPH *widget.Check swapRPMandSpeed *widget.Check colorBlindMode *widget.Select + // Graphics plotRendererSelect *widget.Select meshRendererSelect *widget.Select - // can settings + // CAN debugCheckbox *widget.Check adapterSelector *widget.Select refreshBtn *widget.Button portSelector *widget.Select portDescription *widget.Label speedSelector *widget.Select + adapters map[string]*gocan.AdapterInfo - adapters map[string]*gocan.AdapterInfo - - // WBL Specific - - wbleditor *WBLEditor - + // Wideband + wbleditor *WBLEditor wblADscanner *widget.Check wblADScannerSymbol *widget.Select wblSelectContainer *fyne.Container @@ -119,22 +71,7 @@ type Widget struct { wblPortLabel *widget.Label wblPortSelect *widget.Select wblPortRefreshButton *widget.Button - - images struct { - wblImage *canvas.Image - - /* - mtxl *canvas.Image - lc2 *canvas.Image - uego *canvas.Image - lambdatocan *canvas.Image - t7 *canvas.Image - plx *canvas.Image - combi *canvas.Image - zeitronix *canvas.Image - stagafr *canvas.Image - */ - } + wblImage *canvas.Image mu sync.Mutex } @@ -143,14 +80,13 @@ func New(cfg *Config) *Widget { sw := &Widget{ cfg: cfg, adapters: make(map[string]*gocan.AdapterInfo), + wblImage: newImageFromResource("t7"), } for _, adapter := range gocan.ListAdapters() { sw.adapters[adapter.Name] = &adapter } - sw.images.wblImage = newImageFromResource("t7") - sw.ExtendBaseWidget(sw) return sw } @@ -158,32 +94,32 @@ func New(cfg *Config) *Widget { func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { sw.workDir = widget.NewLabel("") sw.workDir.Selectable = true - wd, err := os.Getwd() - if err != nil { + if wd, err := os.Getwd(); err != nil { sw.workDir.SetText(fmt.Sprintf("Error getting working directory: %v", err)) } else { sw.workDir.SetText(wd) } + // General sw.freqSlider = sw.newFreqSlider() sw.freqValue = widget.NewLabel("") - sw.autoLoad = sw.newAutoUpdateLoad() - sw.autoSave = sw.newAutoUpdateSave() - sw.cursorFollowCrosshair = sw.newCursorFollowCrosshair() - sw.livePreview = sw.newLivePreview() - sw.meshView = sw.newMeshView() - sw.realtimeBars = sw.newRealtimeBars() + sw.autoLoad = checkBox("Load maps from ECU when connected", prefAutoLoad) + sw.autoSave = checkBox("Save changes automaticly if connected to ECU (requires open bin)", prefAutoSave) + sw.cursorFollowCrosshair = checkBox("Cursor follows crosshair in MapViewer (one hand mapping)", prefCursorFollowCrosshair) + sw.livePreview = checkBox("Live preview values in symbollist (uncheck if you have a slow pc)", prefLivePreview) + sw.meshView = checkBox("3D Mesh on map viewing", prefMeshView) + sw.realtimeBars = checkBox("Bars on live preview values (uncheck if you have a slow pc)", prefRealtimeBars) sw.logFormat = sw.newLogFormat() sw.logPath = widget.NewLabel("") sw.logPath.Truncation = fyne.TextTruncateEllipsis - sw.useMPH = sw.newUserMPH() - sw.swapRPMandSpeed = sw.newSwapRPMandSpeed() + sw.useMPH = checkBox("Use mph instead of km/h", prefUseMPH) + sw.swapRPMandSpeed = checkBox("Swap RPM and speed gauge position", prefSwapRPMandSpeed) sw.colorBlindMode = sw.newColorBlindMode() sw.wblSelectContainer = sw.newWBLSelector() // Graphics - sw.plotRendererSelect = sw.newPlotRendererSelect() - sw.meshRendererSelect = sw.newMeshRendererSelect() + sw.plotRendererSelect = indexSelect([]string{"Software", "Shader"}, prefPlotterRenderer) + sw.meshRendererSelect = indexSelect([]string{"Shader", "Polygons", "Software"}, prefMeshRenderer) // CAN sw.adapterSelector = sw.newAdapterSelector() @@ -191,18 +127,11 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { sw.portDescription = widget.NewLabel("") sw.portDescription.Importance = widget.LowImportance sw.speedSelector = sw.newSpeedSelector() - sw.debugCheckbox = sw.newDebugCheckbox() + sw.debugCheckbox = checkBox("Debug", prefDebug) sw.refreshBtn = sw.newPortRefreshButton() - names := make([]string, 0, len(sw.adapters)) - for name := range sw.adapters { - names = append(names, name) - } - slices.SortFunc(names, func(i, j string) int { - return strings.Compare(strings.ToLower(i), strings.ToLower(j)) - }) - sw.adapterSelector.SetOptions(names) - if ad := fyne.CurrentApp().Preferences().String(prefsAdapter); ad != "" { + sw.adapterSelector.SetOptions(sw.sortedAdapterNames()) + if ad := prefAdapter.get(); ad != "" { sw.adapterSelector.SetSelected(ad) } @@ -216,34 +145,32 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { tabs.Append(sw.adScannerTab()) tabs.Append(container.NewTabItem("txbridge", txconfigurator.NewConfigurator())) - for _, adapter := range gocan.ListAdapters() { - sw.adapters[adapter.Name] = &adapter - } - sw.loadPreferences() return widget.NewSimpleRenderer(tabs) } -// Public API +func (sw *Widget) sortedAdapterNames() []string { + names := make([]string, 0, len(sw.adapters)) + for name := range sw.adapters { + names = append(names, name) + } + slices.SortFunc(names, func(i, j string) int { + return strings.Compare(strings.ToLower(i), strings.ToLower(j)) + }) + return names +} + +// --- public API ------------------------------------------------------------ var portCache = make(map[string]*enumerator.PortDetails) func (sw *Widget) ListPorts() []string { - var portsList []string ports, err := enumerator.GetDetailedPortsList() - if err != nil { - // m.output(err.Error()) - return []string{} - } - if len(ports) == 0 { - // m.output("No serial ports found!") + if err != nil || len(ports) == 0 { return []string{} } + portsList := make([]string, 0, len(ports)) for _, port := range ports { - // m.output(fmt.Sprintf("Found port: %s", port.Name)) - // if port.IsUSB { - // m.output(fmt.Sprintf(" USB ID %s:%s", port.VID, port.PID)) - // m.output(fmt.Sprintf(" USB serial %s", port.SerialNumber)) portsList = append(portsList, port.Name) portCache[port.Name] = port } @@ -258,7 +185,7 @@ func (sw *Widget) AddAdapters(adapters []*proto.AdapterInfo) { sw.mu.Lock() defer sw.mu.Unlock() for _, adapter := range adapters { - adapter := &gocan.AdapterInfo{ + info := &gocan.AdapterInfo{ Name: adapter.GetName(), Description: adapter.GetDescription(), Capabilities: gocan.AdapterCapabilities{ @@ -268,36 +195,35 @@ func (sw *Widget) AddAdapters(adapters []*proto.AdapterInfo) { }, RequiresSerialPort: adapter.GetRequireSerialPort(), } - - if _, found := sw.adapters[adapter.Name]; found { + if _, found := sw.adapters[info.Name]; found { continue } - sw.adapters[adapter.Name] = adapter + sw.adapters[info.Name] = info } } -func (c *Widget) Disable() { - c.adapterSelector.Disable() - c.portSelector.Disable() - c.speedSelector.Disable() - c.debugCheckbox.Disable() - c.refreshBtn.Disable() +func (sw *Widget) Disable() { + sw.adapterSelector.Disable() + sw.portSelector.Disable() + sw.speedSelector.Disable() + sw.debugCheckbox.Disable() + sw.refreshBtn.Disable() } -func (c *Widget) Enable() { - c.adapterSelector.Enable() - c.portSelector.Enable() - c.speedSelector.Enable() - c.debugCheckbox.Enable() - c.refreshBtn.Enable() +func (sw *Widget) Enable() { + sw.adapterSelector.Enable() + sw.portSelector.Enable() + sw.speedSelector.Enable() + sw.debugCheckbox.Enable() + sw.refreshBtn.Enable() - if info, found := c.adapters[c.adapterSelector.Selected]; found { + if info, found := sw.adapters[sw.adapterSelector.Selected]; found { if info.RequiresSerialPort { - c.portSelector.Enable() - c.speedSelector.Enable() + sw.portSelector.Enable() + sw.speedSelector.Enable() } else { - c.portSelector.Disable() - c.speedSelector.Disable() + sw.portSelector.Disable() + sw.speedSelector.Disable() } } } diff --git a/pkg/widgets/settings/settings_getters.go b/pkg/widgets/settings/settings_getters.go deleted file mode 100644 index f12404e4..00000000 --- a/pkg/widgets/settings/settings_getters.go +++ /dev/null @@ -1,251 +0,0 @@ -package settings - -import ( - "errors" - "fmt" - "log" - "strconv" - "strings" - - "fyne.io/fyne/v2" - "github.com/roffe/gocan" - "github.com/roffe/txlogger/pkg/colors" - "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/datalogger" - "github.com/roffe/txlogger/pkg/ota" - "github.com/roffe/txlogger/pkg/wbl/aem" - "github.com/roffe/txlogger/pkg/wbl/ecumaster" - "github.com/roffe/txlogger/pkg/wbl/innovate" - "github.com/roffe/txlogger/pkg/wbl/plx" - "github.com/roffe/txlogger/pkg/wbl/stag" - "github.com/roffe/txlogger/pkg/wbl/zeitronix" - "github.com/roffe/txlogger/pkg/widgets/meshgrid" - "github.com/roffe/txlogger/pkg/widgets/plotter" -) - -func (cs *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { - return cs.GetAdapterWithExtraFilters(ecuType, []uint32{}) -} - -func (cs *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { - debug := fyne.CurrentApp().Preferences().Bool(prefsDebug) - port := fyne.CurrentApp().Preferences().String(prefsPort) - - baudstring := fyne.CurrentApp().Preferences().String(prefsSpeed) - switch baudstring { - case "1mbit": - baudstring = "1000000" - case "2mbit": - baudstring = "2000000" - case "3mbit": - baudstring = "3000000" - } - - if baudstring == "" { - baudstring = "1000000" - } - - baudrate, err := strconv.Atoi(baudstring) - if err != nil { - return nil, err - } - adapterName := fyne.CurrentApp().Preferences().String(prefsAdapter) - - if adapterName == "" { - return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok - } - - if ad, found := cs.adapters[adapterName]; found { - if ad.RequiresSerialPort { - if port == "" { - return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok - } - if baudstring == "" { - return nil, errors.New("Select port speed in settings") //lint:ignore ST1005 This is ok - } - } - } - - var canFilter []uint32 - var canRate float64 - - switch ecuType { - case "T5", "Trionic 5": - canFilter = []uint32{0xC} - canRate = 615.384 - case "T7", "Trionic 7": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") || strings.HasSuffix(adapterName, "Wifi") { - canFilter = []uint32{0x238, 0x258, 0x270} - } else { - canFilter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canRate = 500 - case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") { - canFilter = []uint32{0x5E8, 0x7E8} - } else { - canFilter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canFilter = append(canFilter, filters...) - - canRate = 500 - } - - cfg := &gocan.AdapterConfig{ - Port: port, - PortBaudrate: baudrate, - CANRate: canRate, - CANFilter: canFilter, - Debug: debug, - PrintVersion: true, - } - - if strings.HasPrefix(adapterName, "J2534") { // || strings.HasPrefix(adapterName, "CANlib") { - return gocan.NewGWClient(adapterName, cfg) - } - - if adapterName == "txbridge wifi" { - //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - //defer cancel() - //addr, err := mdns.Query(ctx, "txbridge.local") - //if err != nil { - // cs.cfg.Logger(fmt.Sprintf("Failed to resolve txbridge address via mDNS: %v", err)) - //} else { - cfg.AdditionalConfig = map[string]string{ - "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), - "minversion": ota.MinimumtxbridgeVersion, - } - //} - } - return gocan.NewAdapter(adapterName, cfg) -} - -func (sw *Widget) GetADScannerSymbolName() string { - return fyne.CurrentApp().Preferences().String(prefsWBLADScannerSymbol) -} - -func (sw *Widget) GetWidebandName() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") -} - -func (sw *Widget) GetWidebandSymbolName() string { - useADScanner := fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) - switch sw.GetWidebandName() { - case "ECU": - switch sw.cfg.SelectedEcuFunc() { - case "T5": - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - case "T7": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "DisplProt.LambdaScanner" - case "T8": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "LambdaScan.LambdaScanner" - default: - return "None" - } - case aem.ProductString, - "CombiAdapter", - ecumaster.ProductString, - innovate.ProductString, - plx.ProductString, - stag.ProductString, - zeitronix.ProductString: - return datalogger.EXTERNALWBLSYM // Lambda.External - default: - return "None" - } -} - -func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { - return colors.StringToColorBlindMode(fyne.CurrentApp().Preferences().StringWithFallback(prefsColorBlindMode, "Normal")) -} - -func (sw *Widget) GetWidebandPort() string { - return fyne.CurrentApp().Preferences().String(prefsWBLPort) -} - -func (sw *Widget) GetWBLSupportPoints() []int { - return fyne.CurrentApp().Preferences().IntListWithFallback(prefsWBLSupportPoints, []int{0, 1024}) -} - -func (sw *Widget) GetWBLLambdaValues() []float64 { - return fyne.CurrentApp().Preferences().FloatListWithFallback(prefsWBLLambdaValues, []float64{0.5, 1.5}) -} - -func (sw *Widget) GetUseADScanner() bool { - if fyne.CurrentApp().Preferences().String(prefsWblSource) != "ECU" { - return false - } - return fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) -} - -func (sw *Widget) GetFreq() int { - return int(fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25)) -} - -func (sw *Widget) GetAutoSave() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateSaveEcu) -} - -func (sw *Widget) GetAutoLoad() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateLoadEcu) -} - -func (sw *Widget) GetLivePreview() bool { - return fyne.CurrentApp().Preferences().Bool(prefsLivePreview) -} - -func (sw *Widget) GetRealtimeBars() bool { - return fyne.CurrentApp().Preferences().Bool(prefsRealtimeBars) -} - -func (sw *Widget) GetMeshView() bool { - return fyne.CurrentApp().Preferences().Bool(prefsMeshView) -} - -func (sw *Widget) GetLogFormat() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsLogFormat, "CSV") -} - -func (sw *Widget) GetLogPath() string { - p := fyne.CurrentApp().Preferences().String(prefsLogPath) - if p == "" { - var err error - p, err = common.GetLogPath() - if err != nil { - log.Println("GetLogPath: ", err) - } - } - return p -} - -func (sw *Widget) GetUseMPH() bool { - return fyne.CurrentApp().Preferences().Bool(prefsUseMPH) -} - -func (sw *Widget) GetSwapRPMandSpeed() bool { - return fyne.CurrentApp().Preferences().Bool(prefsSwapRPMandSpeed) -} - -func (sw *Widget) GetCursorFollowCrosshair() bool { - return fyne.CurrentApp().Preferences().Bool(prefsCursorFollowCrosshair) -} - -func (sw *Widget) GetPlotterRenderer() plotter.PlotBackend { - return plotter.PlotBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsPlotterRenderer, 0)) -} - -func (sw *Widget) GetMeshRenderer() meshgrid.RenderBackend { - return meshgrid.RenderBackend(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 2)) -} diff --git a/pkg/widgets/settings/settings_internal.go b/pkg/widgets/settings/settings_internal.go deleted file mode 100644 index 53c36798..00000000 --- a/pkg/widgets/settings/settings_internal.go +++ /dev/null @@ -1,383 +0,0 @@ -package settings - -import ( - "strconv" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "github.com/roffe/gocan" - "github.com/roffe/txlogger/pkg/assets" - "github.com/roffe/txlogger/pkg/colors" - "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/ebus" - "github.com/roffe/txlogger/pkg/wbl/aem" - "github.com/roffe/txlogger/pkg/wbl/ecumaster" - "github.com/roffe/txlogger/pkg/wbl/innovate" - "github.com/roffe/txlogger/pkg/wbl/plx" - "github.com/roffe/txlogger/pkg/wbl/stag" - "github.com/roffe/txlogger/pkg/wbl/zeitronix" -) - -func newImageFromResource(name string) *canvas.Image { - var img *canvas.Image - switch name { - case "mtx-l": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.MtxL)) - img.SetMinSize(fyne.NewSize(224, 224)) - case "lc-2": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.Lc2)) - img.SetMinSize(fyne.NewSize(400, 224)) - case "uego": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.Uego)) - img.SetMinSize(fyne.NewSize(315, 224)) - case "lambdatocan": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.LambdaToCan)) - img.SetMinSize(fyne.NewSize(481, 224)) - case "t7": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.T7)) - img.SetMinSize(fyne.NewSize(320, 224)) - case "plx": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.PLX)) - img.SetMinSize(fyne.NewSize(470, 224)) - case "combi": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.CombiV2)) - img.SetMinSize(fyne.NewSize(360, 245)) - case "zeitronix": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.ZeitronixZT2)) - img.SetMinSize(fyne.NewSize(252, 252)) - case "stagafr": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.STAGAfr)) - img.SetMinSize(fyne.NewSize(252, 252)) - } - img.FillMode = canvas.ImageFillContain - img.ScaleMode = canvas.ImageScaleFastest - - return img -} - -var logFormats = []string{"CSV", "BPL" /*"TXL"*/} - -func (sw *Widget) newLogFormat() *widget.Select { - return widget.NewSelect(logFormats, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsLogFormat, s) - }) -} - -var wblAdapters = []string{ - "None", - "ECU", - aem.ProductString, - "CombiAdapter", - ecumaster.ProductString, - innovate.ProductString, - plx.ProductString, - stag.ProductString, - zeitronix.ProductString, -} - -func (sw *Widget) newWBLSelector() *fyne.Container { - wblImages := map[string]*canvas.Image{ - "ECU": newImageFromResource("t7"), // Using T7 image for ECU as a placeholder - ecumaster.ProductString: newImageFromResource("lambdatocan"), - innovate.ProductString: newImageFromResource("mtx-l"), // Using MTX-L image for Innovate as a placeholder - aem.ProductString: newImageFromResource("uego"), - plx.ProductString: newImageFromResource("plx"), - "CombiAdapter": newImageFromResource("combi"), - zeitronix.ProductString: newImageFromResource("zeitronix"), - stag.ProductString: newImageFromResource("stagafr"), - } - - sw.wblSource = widget.NewSelect(wblAdapters, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWblSource, s) - fyne.CurrentApp().Preferences().SetString(prefsWidebandSymbolName, sw.GetWidebandSymbolName()) - - var adScanner, portSelect bool - - img, found := wblImages[s] - if found && img != nil { - sw.images.wblImage.Resource = img.Resource - sw.images.wblImage.SetMinSize(img.MinSize()) - sw.images.wblImage.Refresh() - } - - switch s { - case "ECU", "CombiAdapter": - adScanner = true - portSelect = false - case aem.ProductString, innovate.ProductString, plx.ProductString, stag.ProductString, zeitronix.ProductString: - portSelect = true - case ecumaster.ProductString: - portSelect = false - default: - portSelect = false - } - - if portSelect { - sw.wblPortLabel.Show() - sw.wblPortSelect.Show() - sw.wblPortRefreshButton.Show() - } else { - sw.wblPortLabel.Hide() - sw.wblPortSelect.Hide() - sw.wblPortRefreshButton.Hide() - } - - if adScanner { - sw.wblADscanner.Show() - sw.wblADScannerSymbol.Show() - } else { - sw.wblADscanner.Hide() - sw.wblADScannerSymbol.Hide() - } - }) - return container.NewBorder( - nil, - nil, - widget.NewLabel("Source"), - nil, - sw.wblSource, - ) -} - -func (sw *Widget) newFreqSlider() *widget.Slider { - slider := widget.NewSlider(5, 300) - slider.Step = 5 - slider.OnChanged = func(f float64) { - sw.freqValue.SetText(strconv.FormatFloat(f, 'f', 0, 64)) - } - slider.OnChangeEnded = func(f float64) { - fyne.CurrentApp().Preferences().SetInt(prefsFreq, int(f)) - } - return slider -} - -func (sw *Widget) newADscannerCheck() *widget.Check { - return widget.NewCheck("use AD Scanner (don't forget to add symbol)", func(b bool) { - if b { - sw.wblADScannerSymbol.Show() - } else { - sw.wblADScannerSymbol.Hide() - } - fyne.CurrentApp().Preferences().SetBool(prefsUseADScanner, b) - }) -} - -func (sw *Widget) newMeshView() *widget.Check { - return widget.NewCheck("3D Mesh on map viewing", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsMeshView, b) - }) -} - -func (sw *Widget) newAutoUpdateLoad() *widget.Check { - return widget.NewCheck("Load maps from ECU when connected", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsAutoUpdateLoadEcu, b) - }) -} - -func (sw *Widget) newAutoUpdateSave() *widget.Check { - return widget.NewCheck("Save changes automaticly if connected to ECU (requires open bin)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsAutoUpdateSaveEcu, b) - }) -} - -func (sw *Widget) newCursorFollowCrosshair() *widget.Check { - return widget.NewCheck("Cursor follows crosshair in MapViewer (one hand mapping)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsCursorFollowCrosshair, b) - }) -} - -func (sw *Widget) newLivePreview() *widget.Check { - return widget.NewCheck("Live preview values in symbollist (uncheck if you have a slow pc)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsLivePreview, b) - }) -} - -func (sw *Widget) newRealtimeBars() *widget.Check { - return widget.NewCheck("Bars on live preview values (uncheck if you have a slow pc)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsRealtimeBars, b) - }) -} - -func (sw *Widget) newUserMPH() *widget.Check { - return widget.NewCheck("Use mph instead of km/h", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsUseMPH, b) - }) -} - -func (sw *Widget) newSwapRPMandSpeed() *widget.Check { - return widget.NewCheck("Swap RPM and speed gauge position", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsSwapRPMandSpeed, b) - }) -} - -func (sw *Widget) newColorBlindMode() *widget.Select { - return widget.NewSelect(colors.SupportedColorBlindModes[:], func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsColorBlindMode, s) - ebus.Publish(ebus.TOPIC_COLORBLINDMODE, float64(sw.colorBlindMode.SelectedIndex())) - }) -} - -func (sw *Widget) newPlotRendererSelect() *widget.Select { - return widget.NewSelect([]string{"Software", "Shader"}, func(selection string) { - var mode int - switch selection { - case "Software": - mode = 0 - case "Shader": - mode = 1 - } - fyne.CurrentApp().Preferences().SetInt(prefsPlotterRenderer, mode) - }) -} - -func (sw *Widget) newMeshRendererSelect() *widget.Select { - return widget.NewSelect([]string{"Shader", "Polygons", "Software"}, func(selection string) { - var mode int - switch selection { - case "Shader": - mode = 0 - case "Polygons": - mode = 1 - case "Software": - mode = 2 - } - fyne.CurrentApp().Preferences().SetInt(prefsMeshRenderer, mode) - }) -} - -func (sw *Widget) newAdapterSelector() *widget.Select { - return widget.NewSelect(gocan.ListAdapterNames(), func(s string) { - if info, found := sw.adapters[s]; found { - fyne.CurrentApp().Preferences().SetString(prefsAdapter, s) - if info.RequiresSerialPort { - sw.portSelector.Enable() - sw.speedSelector.Enable() - return - } else { - sw.portDescription.SetText("") - } - sw.portSelector.Disable() - sw.speedSelector.Disable() - } - }) -} - -func (sw *Widget) newPortSelector() *widget.Select { - return widget.NewSelect(sw.ListPorts(), func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsPort, s) - itm, ok := portCache[s] - if ok { - /* - var desc string - if itm.Manufacturer != "" { - desc += itm.Manufacturer - - if itm.Product != "" { - if desc != "" { - desc += " " - } - desc += itm.Product - } - if itm.SerialNumber != "" { - if desc != "" { - desc += " " - } - desc += itm.SerialNumber - } - */ - sw.portDescription.SetText(itm.SerialNumber) - } else { - sw.portDescription.SetText("") - } - }) -} - -func (sw *Widget) newSpeedSelector() *widget.Select { - return widget.NewSelect(portSpeeds, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsSpeed, s) - }) -} - -func (sw *Widget) newDebugCheckbox() *widget.Check { - return widget.NewCheck("Debug", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsDebug, b) - }) -} - -func (sw *Widget) newPortRefreshButton() *widget.Button { - return widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - sw.portSelector.Options = sw.ListPorts() - sw.portSelector.Refresh() - }) -} - -func (sw *Widget) loadPreferences() { - freq := fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25) - sw.freqSlider.SetValue(float64(freq)) - loadPrefsCheck(sw.autoLoad, prefsAutoUpdateLoadEcu, true) - loadPrefsCheck(sw.autoSave, prefsAutoUpdateSaveEcu, false) - loadPrefsCheck(sw.cursorFollowCrosshair, prefsCursorFollowCrosshair, false) - loadPrefsCheck(sw.livePreview, prefsLivePreview, true) - loadPrefsCheck(sw.meshView, prefsMeshView, true) - loadPrefsCheck(sw.realtimeBars, prefsRealtimeBars, true) - loadPrefsSelect(sw.logFormat, prefsLogFormat, "CSV") - logPath, err := common.GetLogPath() - if err != nil { - fyne.LogError("Could not get log path", err) - } - loadPrefsText(sw.logPath, prefsLogPath, logPath) - loadPrefsText(sw.logPath, prefsLogPath, logPath) - loadPrefsSelect(sw.wblSource, prefsWblSource, "None") - loadPrefsCheck(sw.wblADscanner, prefsUseADScanner, false) - if sw.wblADscanner.Checked && sw.wblSource.Selected == "ECU" { - sw.wblADScannerSymbol.Show() - } else { - sw.wblADScannerSymbol.Hide() - } - - loadPrefsCheck(sw.useMPH, prefsUseMPH, false) - loadPrefsCheck(sw.swapRPMandSpeed, prefsSwapRPMandSpeed, false) - loadPrefsSelect(sw.wblPortSelect, prefsWBLPort, "") - loadPrefsSelect(sw.colorBlindMode, prefsColorBlindMode, "Normal") - - loadPrefsSelect(sw.adapterSelector, prefsAdapter, "") - loadPrefsSelect(sw.portSelector, prefsPort, "") - loadPrefsSelect(sw.speedSelector, prefsSpeed, "115200") - loadPrefsCheck(sw.debugCheckbox, prefsDebug, false) - - // graphics settings - - sw.plotRendererSelect.SetSelectedIndex(fyne.CurrentApp().Preferences().IntWithFallback(prefsPlotterRenderer, 0)) - sw.meshRendererSelect.SetSelectedIndex(fyne.CurrentApp().Preferences().IntWithFallback(prefsMeshRenderer, 2)) -} - -func loadPrefsSelect(s *widget.Select, prefKey string, fallback string) { - s.SetSelected(fyne.CurrentApp().Preferences().StringWithFallback(prefKey, fallback)) -} - -func loadPrefsCheck(box *widget.Check, prefKey string, fallback bool) { - box.SetChecked(fyne.CurrentApp().Preferences().BoolWithFallback(prefKey, fallback)) -} - -func loadPrefsText(obj SetText, prefKey string, fallback string) { - obj.SetText(fyne.CurrentApp().Preferences().StringWithFallback(prefKey, fallback)) -} - -/* -func positiveFloatValidator(s string) (float64, error) { - s = strings.ReplaceAll(s, ",", ".") - s = strings.TrimSuffix(s, ".") - - val, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0, errors.New("invalid number") - } - if val < 0 { - return 0, errors.New("must be positive") - } - return val, nil -} -*/ diff --git a/pkg/widgets/settings/settings_tabs.go b/pkg/widgets/settings/settings_tabs.go deleted file mode 100644 index eccfa5da..00000000 --- a/pkg/widgets/settings/settings_tabs.go +++ /dev/null @@ -1,242 +0,0 @@ -package settings - -import ( - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/common" - xlayout "github.com/roffe/txlogger/pkg/layout" - "github.com/roffe/txlogger/pkg/widgets" -) - -func (sw *Widget) generalTab() *container.TabItem { - return container.NewTabItem("General", container.NewVBox( - container.NewBorder( - nil, - nil, - widget.NewLabel("WD"), - nil, - sw.workDir, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.autoLoad, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.WarningIcon()), - nil, - sw.autoSave, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.MoveUpIcon()), - nil, - sw.cursorFollowCrosshair, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.SearchIcon()), - nil, - container.NewVBox( - sw.livePreview, - sw.realtimeBars, - ), - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.ViewFullScreenIcon()), - nil, - sw.meshView, - ), - container.NewBorder( - nil, - nil, - widget.NewLabel("Color blind mode"), - nil, - sw.colorBlindMode, - ), - )) -} - -func (sw *Widget) graphicsTab() *container.TabItem { - return container.NewTabItem("Graphics", container.NewVBox( - container.NewBorder( - nil, - nil, - widget.NewLabel("Plot renderer"), - nil, - sw.plotRendererSelect, - ), - container.NewBorder( - nil, - nil, - widget.NewLabel("Mesh renderer"), - nil, - sw.meshRendererSelect, - ), - )) -} - -func (sw *Widget) canTab() *container.TabItem { - return container.NewTabItem("CAN", container.NewVBox( - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Adapter")), - sw.debugCheckbox, - sw.adapterSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Port")), - sw.refreshBtn, - sw.portSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Info")), - nil, - sw.portDescription, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Speed")), - nil, - sw.speedSelector, - ), - )) -} - -func (sw *Widget) loggingTab() *container.TabItem { - return container.NewTabItem("Logging", container.NewVBox( - container.NewBorder( - nil, - nil, - widget.NewLabel("Logging rate (Hz)"), - sw.freqValue, - sw.freqSlider, - ), - widget.NewSeparator(), - container.NewBorder( - nil, - nil, - widget.NewLabel("Log format"), - nil, - sw.logFormat, - ), - container.NewBorder( - nil, - container.NewGridWithColumns(2, - widget.NewButtonWithIcon("Reset", theme.ContentClearIcon(), func() { - logPath, err := common.GetLogPath() - if err != nil { - fyne.LogError("Could not get log path", err) - } - sw.logPath.SetText(logPath) - fyne.CurrentApp().Preferences().SetString(prefsLogPath, logPath) - }), - widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() { - cb := func(dir string) { - sw.logPath.SetText(dir) - fyne.CurrentApp().Preferences().SetString(prefsLogPath, dir) - } - widgets.SelectFolder(cb) - }), - ), - widget.NewLabel("Log folder"), - nil, - sw.logPath, - ), - )) -} - -func (sw *Widget) wblTab() *container.TabItem { - sw.wblPortLabel = widget.NewLabel("WBL Port") - sw.wblPortSelect = widget.NewSelect(append([]string{"txbridge", "CAN"}, sw.ListPorts()...), func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWBLPort, s) - }) - - sw.wblPortRefreshButton = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - sw.wblPortSelect.Options = append([]string{"txbridge", "CAN"}, sw.ListPorts()...) - sw.wblPortSelect.Refresh() - }) - - sw.wblADscanner = sw.newADscannerCheck() - - adSymbols := []string{ - "AD_EGR", - "DisplProt.AD_Scanner", - "LambdaScan.AD_Scanner", - "LambdaScan.AD_Scanner2", - } - - sw.wblADScannerSymbol = widget.NewSelect(adSymbols, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWBLADScannerSymbol, s) - }) - sw.wblADScannerSymbol.SetSelected(sw.GetADScannerSymbolName()) - - return container.NewTabItem( - "WBL", - container.NewBorder( - container.NewHBox( - layout.NewSpacer(), - sw.images.wblImage, - layout.NewSpacer(), - ), - nil, - nil, - nil, - container.NewVBox( - sw.wblSelectContainer, - sw.wblADscanner, - container.NewBorder( - nil, - nil, - sw.wblPortLabel, - sw.wblPortRefreshButton, - sw.wblPortSelect, - ), - sw.wblADScannerSymbol, - ), - ), - ) -} - -func (sw *Widget) dashboardTab() *container.TabItem { - return container.NewTabItem("Dashboard", container.NewVBox( - widget.NewLabel("Dashboard settings"), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.swapRPMandSpeed, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.useMPH, - ), - )) -} - -func (sw *Widget) adScannerTab() *container.TabItem { - sw.wbleditor = NewWBLEditor(sw.GetWBLSupportPoints(), sw.GetWBLLambdaValues()) - sw.wbleditor.Hide() - return container.NewTabItem("AD Scanner", sw.wbleditor) -} diff --git a/pkg/widgets/settings/tabs.go b/pkg/widgets/settings/tabs.go new file mode 100644 index 00000000..6154761d --- /dev/null +++ b/pkg/widgets/settings/tabs.go @@ -0,0 +1,128 @@ +package settings + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + xlayout "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/widgets" +) + +// leftLabeled places a text label to the left of content. +func leftLabeled(label string, content fyne.CanvasObject) *fyne.Container { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, content) +} + +// leftIcon places an icon to the left of content. +func leftIcon(res fyne.Resource, content fyne.CanvasObject) *fyne.Container { + return container.NewBorder(nil, nil, widget.NewIcon(res), nil, content) +} + +func (sw *Widget) generalTab() *container.TabItem { + return container.NewTabItem("General", container.NewVBox( + leftLabeled("WD", sw.workDir), + leftIcon(theme.InfoIcon(), sw.autoLoad), + leftIcon(theme.WarningIcon(), sw.autoSave), + leftIcon(theme.MoveUpIcon(), sw.cursorFollowCrosshair), + leftIcon(theme.SearchIcon(), container.NewVBox(sw.livePreview, sw.realtimeBars)), + leftIcon(theme.ViewFullScreenIcon(), sw.meshView), + leftLabeled("Color blind mode", sw.colorBlindMode), + )) +} + +func (sw *Widget) graphicsTab() *container.TabItem { + return container.NewTabItem("Graphics", container.NewVBox( + leftLabeled("Plot renderer", sw.plotRendererSelect), + leftLabeled("Mesh renderer", sw.meshRendererSelect), + )) +} + +func (sw *Widget) canTab() *container.TabItem { + fixedLabel := func(text string) fyne.CanvasObject { + return xlayout.NewFixedWidth(70, widget.NewLabel(text)) + } + return container.NewTabItem("CAN", container.NewVBox( + container.NewBorder(nil, nil, fixedLabel("Adapter"), sw.debugCheckbox, sw.adapterSelector), + container.NewBorder(nil, nil, fixedLabel("Port"), sw.refreshBtn, sw.portSelector), + container.NewBorder(nil, nil, fixedLabel("Info"), nil, sw.portDescription), + container.NewBorder(nil, nil, fixedLabel("Speed"), nil, sw.speedSelector), + )) +} + +func (sw *Widget) loggingTab() *container.TabItem { + logFolderButtons := container.NewGridWithColumns(2, + widget.NewButtonWithIcon("Reset", theme.ContentClearIcon(), func() { + logPath, err := common.GetLogPath() + if err != nil { + fyne.LogError("Could not get log path", err) + } + sw.logPath.SetText(logPath) + prefLogPath.set(logPath) + }), + widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() { + widgets.SelectFolder(func(dir string) { + sw.logPath.SetText(dir) + prefLogPath.set(dir) + }) + }), + ) + + return container.NewTabItem("Logging", container.NewVBox( + container.NewBorder(nil, nil, widget.NewLabel("Logging rate (Hz)"), sw.freqValue, sw.freqSlider), + widget.NewSeparator(), + leftLabeled("Log format", sw.logFormat), + container.NewBorder(nil, logFolderButtons, widget.NewLabel("Log folder"), nil, sw.logPath), + )) +} + +func (sw *Widget) wblTab() *container.TabItem { + wblPorts := func() []string { + return append([]string{"txbridge", "CAN"}, sw.ListPorts()...) + } + + sw.wblPortLabel = widget.NewLabel("WBL Port") + sw.wblPortSelect = widget.NewSelect(wblPorts(), prefWBLPort.set) + sw.wblPortRefreshButton = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + sw.wblPortSelect.Options = wblPorts() + sw.wblPortSelect.Refresh() + }) + + sw.wblADscanner = sw.newADscannerCheck() + + adSymbols := []string{ + "AD_EGR", + "DisplProt.AD_Scanner", + "LambdaScan.AD_Scanner", + "LambdaScan.AD_Scanner2", + } + sw.wblADScannerSymbol = widget.NewSelect(adSymbols, prefWBLADScannerSymbol.set) + sw.wblADScannerSymbol.SetSelected(sw.GetADScannerSymbolName()) + + body := container.NewVBox( + sw.wblSelectContainer, + sw.wblADscanner, + container.NewBorder(nil, nil, sw.wblPortLabel, sw.wblPortRefreshButton, sw.wblPortSelect), + sw.wblADScannerSymbol, + ) + + image := container.NewHBox(layout.NewSpacer(), sw.wblImage, layout.NewSpacer()) + + return container.NewTabItem("WBL", container.NewBorder(image, nil, nil, nil, body)) +} + +func (sw *Widget) dashboardTab() *container.TabItem { + return container.NewTabItem("Dashboard", container.NewVBox( + widget.NewLabel("Dashboard settings"), + leftIcon(theme.InfoIcon(), sw.swapRPMandSpeed), + leftIcon(theme.InfoIcon(), sw.useMPH), + )) +} + +func (sw *Widget) adScannerTab() *container.TabItem { + sw.wbleditor = NewWBLEditor(sw.GetWBLSupportPoints(), sw.GetWBLLambdaValues()) + sw.wbleditor.Hide() + return container.NewTabItem("AD Scanner", sw.wbleditor) +} diff --git a/pkg/widgets/settings/wbleditor.go b/pkg/widgets/settings/wbleditor.go index ddcf3f31..b30774fe 100644 --- a/pkg/widgets/settings/wbleditor.go +++ b/pkg/widgets/settings/wbleditor.go @@ -3,7 +3,6 @@ package settings import ( "encoding/json" "fmt" - "image/color" "os" "path/filepath" "sort" @@ -11,10 +10,8 @@ import ( "strings" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/common" @@ -35,6 +32,20 @@ var builtInPresets = map[string]adScannerPreset{ }, } +type adScannerPreset struct { + ECU string `json:"ecu"` + Y []int `json:"y"` + Z []float64 `json:"z"` +} + +// adResolutionForECU returns the AD converter resolution for an ECU type. +func adResolutionForECU(ecu string) int { + if ecu == "T5" { + return 255 + } + return 1023 +} + type mapRow struct { y int z float64 @@ -55,7 +66,6 @@ type WBLEditor struct { presetSelect *widget.Select graph *graphView adresolution int - // lastEcu string } func NewWBLEditor(yAxis []int, zValues []float64) *WBLEditor { @@ -65,16 +75,7 @@ func NewWBLEditor(yAxis []int, zValues []float64) *WBLEditor { m.rows = append(m.rows, &mapRow{y: yAxis[i], z: zValues[i]}) } m.ExtendBaseWidget(m) - - switch fyne.CurrentApp().Preferences().String(prefsLastADScannerECU) { - case "T5": - m.adresolution = 255 - case "T7", "T8": - m.adresolution = 1023 - default: - m.adresolution = 1023 - } - + m.adresolution = adResolutionForECU(prefLastADScannerECU.get()) return m } @@ -179,6 +180,26 @@ func (m *WBLEditor) removeRow(r *mapRow) { m.refreshGraph() } +// setRows replaces all rows with the supplied y/z pairs, rebuilding their +// widgets and the rows container (used when loading presets at runtime). +func (m *WBLEditor) setRows(yAxis []int, zValues []float64) { + m.rows = nil + n := min(len(yAxis), len(zValues)) + for i := range n { + r := &mapRow{y: yAxis[i], z: zValues[i]} + m.buildRow(r) + m.rows = append(m.rows, r) + } + if m.rowsBox != nil { + m.rowsBox.Objects = nil + for _, r := range m.rows { + m.rowsBox.Add(r.hb) + } + m.rowsBox.Refresh() + } + m.refreshGraph() +} + func (m *WBLEditor) refreshGraph() { if m.graph != nil { m.graph.Refresh() @@ -186,8 +207,8 @@ func (m *WBLEditor) refreshGraph() { } func (m *WBLEditor) save() { - fyne.CurrentApp().Preferences().SetIntList(prefsWBLSupportPoints, m.YAxis()) - fyne.CurrentApp().Preferences().SetFloatList(prefsWBLLambdaValues, m.ZValues()) + prefWBLSupportPoints.set(m.YAxis()) + prefWBLLambdaValues.set(m.ZValues()) } // updateRowEntries writes a row's current y/z to its entries without @@ -240,20 +261,13 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { m.graph = newGraphView(m) m.presetSelect = widget.NewSelect(m.listPresets(), m.loadPreset) - - lastPreset := fyne.CurrentApp().Preferences().String(prefsLastADScannerPreset) - if lastPreset != "" { + if lastPreset := prefLastADScannerPreset.get(); lastPreset != "" { m.presetSelect.Selected = lastPreset } m.ecuSelect = widget.NewSelect([]string{"T5", "T7", "T8"}, func(s string) { - switch s { - case "T5": - m.adresolution = 255 - case "T7", "T8": - m.adresolution = 1023 - } - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerECU, s) + m.adresolution = adResolutionForECU(s) + prefLastADScannerECU.set(s) for _, r := range m.rows { if r.vo != nil { r.vo.Text = fmt.Sprintf("%.2f", m.voltFromY(r.y)) @@ -261,7 +275,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { } } }) - m.ecuSelect.Selected = fyne.CurrentApp().Preferences().StringWithFallback(prefsLastADScannerECU, "T7") + m.ecuSelect.Selected = prefLastADScannerECU.get() top := container.NewBorder( nil, @@ -275,11 +289,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { m.deletePreset(m.presetSelect.Selected) m.refreshPresets() }), - widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - presets := m.listPresets() - m.presetSelect.Options = presets - m.presetSelect.Refresh() - }), + widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), m.refreshPresets), ), m.presetSelect, ) @@ -290,10 +300,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { bottom, nil, nil, - container.NewHSplit( - left, - m.graph, - ), + container.NewHSplit(left, m.graph), ) return widget.NewSimpleRenderer(view) } @@ -329,12 +336,6 @@ func (m *WBLEditor) listPresets() []string { return presets } -type adScannerPreset struct { - ECU string `json:"ecu"` - Y []int `json:"y"` - Z []float64 `json:"z"` -} - func (m *WBLEditor) savePreset() { name := widget.NewEntry() @@ -351,23 +352,20 @@ func (m *WBLEditor) savePreset() { debug.Log(err.Error()) return } - fname := filepath.Join(layoutPath, name.Text+".json") - var preset adScannerPreset - - preset.ECU = m.ecuSelect.Selected - preset.Y = m.YAxis() - preset.Z = m.ZValues() + preset := adScannerPreset{ + ECU: m.ecuSelect.Selected, + Y: m.YAxis(), + Z: m.ZValues(), + } - f, err := os.Create(fname) + f, err := os.Create(filepath.Join(layoutPath, name.Text+".json")) if err != nil { debug.Log(err.Error()) return } defer f.Close() - encoder := json.NewEncoder(f) - // encoder.SetIndent("", " ") - if err := encoder.Encode(preset); err != nil { + if err := json.NewEncoder(f).Encode(preset); err != nil { debug.Log(err.Error()) return } @@ -386,24 +384,9 @@ func (m *WBLEditor) loadPreset(name string) { debug.Log("loading AD preset: " + name) if preset, ok := builtInPresets[name]; ok { - m.rows = nil - for i := range preset.Y { - r := &mapRow{y: preset.Y[i], z: preset.Z[i]} - m.buildRow(r) - m.rows = append(m.rows, r) - } - if m.rowsBox != nil { - m.rowsBox.Objects = nil - for _, r := range m.rows { - m.rowsBox.Add(r.hb) - } - m.rowsBox.Refresh() - } - m.refreshGraph() - + m.setRows(preset.Y, preset.Z) m.ecuSelect.SetSelected(preset.ECU) - - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerPreset, name) + prefLastADScannerPreset.set(name) m.save() return } @@ -428,21 +411,8 @@ func (m *WBLEditor) loadPreset(name string) { return } - m.rows = nil - for i := range preset.Y { - r := &mapRow{y: preset.Y[i], z: preset.Z[i]} - m.buildRow(r) - m.rows = append(m.rows, r) - } - if m.rowsBox != nil { - m.rowsBox.Objects = nil - for _, r := range m.rows { - m.rowsBox.Add(r.hb) - } - m.rowsBox.Refresh() - } - m.refreshGraph() - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerPreset, name) + m.setRows(preset.Y, preset.Z) + prefLastADScannerPreset.set(name) m.save() } @@ -452,325 +422,8 @@ func (m *WBLEditor) deletePreset(name string) { fyne.LogError("Could not get layout path", err) return } - err = os.Remove(filepath.Join(layoutPath, name+".json")) - if err != nil { + if err := os.Remove(filepath.Join(layoutPath, name+".json")); err != nil { fyne.LogError("Could not delete preset file", err) return } } - -// --- graph view (native fyne primitives) ----------------------------------- - -var ( - bgColor = color.NRGBA{R: 24, G: 24, B: 28, A: 255} - gridColor = color.NRGBA{R: 60, G: 60, B: 68, A: 255} - axisColor = color.NRGBA{R: 140, G: 140, B: 150, A: 255} - lineColor = color.NRGBA{R: 80, G: 200, B: 120, A: 255} - pointColor = color.NRGBA{R: 240, G: 200, B: 60, A: 255} - pointEdge = color.NRGBA{R: 0, G: 0, B: 0, A: 255} -) - -const ( - graphMargin = 16 - pointSize = 12 - minGraph = 280 - - yMin = 0 - yMax = 1023 - zMin = 0.0 - zMax = 1.5 -) - -type graphView struct { - widget.BaseWidget - editor *WBLEditor - r *graphRenderer -} - -func newGraphView(editor *WBLEditor) *graphView { - g := &graphView{editor: editor} - g.ExtendBaseWidget(g) - return g -} - -func (g *graphView) CreateRenderer() fyne.WidgetRenderer { - r := &graphRenderer{g: g} - r.bg = canvas.NewRectangle(bgColor) - g.r = r - r.rebuild() - return r -} - -type graphRenderer struct { - g *graphView - size fyne.Size - - bg *canvas.Rectangle - gridLines []*canvas.Line - axes []*canvas.Line - dataLines []*canvas.Line - points []*draggablePoint - - // cached mapping from last layout (used by drag math) - x0, y0, x1, y1 float32 - minYv, maxYv int - minZv, maxZv float64 - - objects []fyne.CanvasObject -} - -func (r *graphRenderer) rebuild() { - if len(r.gridLines) == 0 { - for range 6 { - l := canvas.NewLine(gridColor) - l.StrokeWidth = 1 - r.gridLines = append(r.gridLines, l) - } - } - if len(r.axes) == 0 { - for range 2 { - l := canvas.NewLine(axisColor) - l.StrokeWidth = 1 - r.axes = append(r.axes, l) - } - } - - rows := r.g.editor.rows - wantLines := 0 - if n := len(rows); n > 1 { - wantLines = n - 1 - } - for len(r.dataLines) < wantLines { - l := canvas.NewLine(lineColor) - l.StrokeWidth = 2 - r.dataLines = append(r.dataLines, l) - } - r.dataLines = r.dataLines[:wantLines] - - for len(r.points) < len(rows) { - p := newDraggablePoint(r.g) - r.points = append(r.points, p) - } - r.points = r.points[:len(rows)] - for i, row := range rows { - r.points[i].row = row - } - - // dataLines/points may have changed length; force Objects() to rebuild. - r.objects = nil -} - -func (r *graphRenderer) Layout(size fyne.Size) { - r.size = size - r.bg.Resize(size) - r.layoutGraph() -} - -func (r *graphRenderer) layoutGraph() { - w := r.size.Width - h := r.size.Height - if w <= 0 || h <= 0 { - return - } - - r.x0 = float32(graphMargin) - r.y0 = float32(graphMargin) - r.x1 = w - graphMargin - r.y1 = h - graphMargin - - for i := range 3 { - gy := r.y0 + (r.y1-r.y0)*float32(i+1)/4 - gh := r.gridLines[i] - gh.Position1 = fyne.NewPos(r.x0, gy) - gh.Position2 = fyne.NewPos(r.x1, gy) - gh.Refresh() - - gx := r.x0 + (r.x1-r.x0)*float32(i+1)/4 - gv := r.gridLines[3+i] - gv.Position1 = fyne.NewPos(gx, r.y0) - gv.Position2 = fyne.NewPos(gx, r.y1) - gv.Refresh() - } - - r.axes[0].Position1 = fyne.NewPos(r.x0, r.y1) - r.axes[0].Position2 = fyne.NewPos(r.x1, r.y1) - r.axes[0].Refresh() - r.axes[1].Position1 = fyne.NewPos(r.x0, r.y0) - r.axes[1].Position2 = fyne.NewPos(r.x0, r.y1) - r.axes[1].Refresh() - - rows := r.g.editor.rows - if len(rows) == 0 { - r.minYv, r.maxYv = yMin, yMax - r.minZv, r.maxZv = zMin, zMax - return - } - - minY, maxY := rows[0].y, rows[0].y - minZ, maxZ := rows[0].z, rows[0].z - for _, row := range rows { - if row.y < minY { - minY = row.y - } - if row.y > maxY { - maxY = row.y - } - if row.z < minZ { - minZ = row.z - } - if row.z > maxZ { - maxZ = row.z - } - } - // Enforce a minimum visible span so dragging stays responsive when - // points are clustered and so the graph doesn't degenerate to a point. - const minYSpan, minZSpan = 50, 0.2 - if maxY-minY < minYSpan { - mid := (maxY + minY) / 2 - minY = clampInt(mid-minYSpan/2, yMin, yMax-minYSpan) - maxY = minY + minYSpan - } - if maxZ-minZ < minZSpan { - mid := (maxZ + minZ) / 2 - minZ = clampFloat(mid-minZSpan/2, zMin, zMax-minZSpan) - maxZ = minZ + minZSpan - } - r.minYv, r.maxYv = minY, maxY - r.minZv, r.maxZv = minZ, maxZ - - type pt struct{ x, y float32 } - pts := make([]pt, len(rows)) - for i, row := range rows { - fx := float32(row.y-minY) / float32(maxY-minY) - fy := float32((row.z - minZ) / (maxZ - minZ)) - pts[i].x = r.x0 + fx*(r.x1-r.x0) - pts[i].y = r.y1 - fy*(r.y1-r.y0) - } - - for i := 1; i < len(pts); i++ { - l := r.dataLines[i-1] - l.Position1 = fyne.NewPos(pts[i-1].x, pts[i-1].y) - l.Position2 = fyne.NewPos(pts[i].x, pts[i].y) - l.Refresh() - } - - half := float32(pointSize) / 2 - for i, p := range pts { - dp := r.points[i] - dp.Resize(fyne.NewSize(pointSize, pointSize)) - dp.Move(fyne.NewPos(p.x-half, p.y-half)) - dp.Refresh() - } -} - -func (r *graphRenderer) Refresh() { - r.bg.FillColor = bgColor - r.bg.Refresh() - r.rebuild() - r.layoutGraph() - canvas.Refresh(r.g) -} - -func (r *graphRenderer) Objects() []fyne.CanvasObject { - if r.objects == nil { - - r.objects = make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) - r.objects = append(r.objects, r.bg) - for _, l := range r.gridLines { - r.objects = append(r.objects, l) - } - for _, l := range r.axes { - r.objects = append(r.objects, l) - } - for _, l := range r.dataLines { - r.objects = append(r.objects, l) - } - for _, p := range r.points { - r.objects = append(r.objects, p) - } - } - return r.objects -} - -func (r *graphRenderer) MinSize() fyne.Size { - return fyne.NewSize(minGraph, minGraph) -} - -func (r *graphRenderer) Destroy() {} - -// --- draggable point ------------------------------------------------------- - -type draggablePoint struct { - widget.BaseWidget - g *graphView - row *mapRow - accY float64 // fractional accumulator for integer y axis -} - -func newDraggablePoint(g *graphView) *draggablePoint { - p := &draggablePoint{g: g} - p.ExtendBaseWidget(p) - return p -} - -func (p *draggablePoint) CreateRenderer() fyne.WidgetRenderer { - rect := canvas.NewRectangle(pointColor) - rect.StrokeColor = pointEdge - rect.StrokeWidth = 1 - return widget.NewSimpleRenderer(rect) -} - -func (p *draggablePoint) Cursor() desktop.Cursor { - return desktop.PointerCursor -} - -func (p *draggablePoint) Dragged(e *fyne.DragEvent) { - r := p.g.r - if r == nil || p.row == nil { - return - } - w := r.x1 - r.x0 - h := r.y1 - r.y0 - if w <= 0 || h <= 0 { - return - } - - // pixel delta → value delta using last layout's data range - dY := float64(e.Dragged.DX) / float64(w) * float64(r.maxYv-r.minYv) - dZ := -float64(e.Dragged.DY) / float64(h) * (r.maxZv - r.minZv) - - p.accY += dY - step := int(p.accY) - p.accY -= float64(step) - - p.row.y = clampInt(p.row.y+step, yMin, yMax) - p.row.z = clampFloat(p.row.z+dZ, zMin, zMax) - - p.g.editor.updateRowEntries(p.row) - p.g.Refresh() -} - -func (p *draggablePoint) DragEnd() { - p.accY = 0 - p.g.editor.save() -} - -func clampInt(v, lo, hi int) int { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -func clampFloat(v, lo, hi float64) float64 { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} diff --git a/pkg/widgets/settings/wblgraph.go b/pkg/widgets/settings/wblgraph.go new file mode 100644 index 00000000..d311822a --- /dev/null +++ b/pkg/widgets/settings/wblgraph.go @@ -0,0 +1,325 @@ +package settings + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" +) + +// --- graph view (native fyne primitives) ----------------------------------- + +var ( + bgColor = color.NRGBA{R: 24, G: 24, B: 28, A: 255} + gridColor = color.NRGBA{R: 60, G: 60, B: 68, A: 255} + axisColor = color.NRGBA{R: 140, G: 140, B: 150, A: 255} + lineColor = color.NRGBA{R: 80, G: 200, B: 120, A: 255} + pointColor = color.NRGBA{R: 240, G: 200, B: 60, A: 255} + pointEdge = color.NRGBA{R: 0, G: 0, B: 0, A: 255} +) + +const ( + graphMargin = 16 + pointSize = 12 + minGraph = 280 + + yMin = 0 + yMax = 1023 + zMin = 0.0 + zMax = 1.5 +) + +type graphView struct { + widget.BaseWidget + editor *WBLEditor + r *graphRenderer +} + +func newGraphView(editor *WBLEditor) *graphView { + g := &graphView{editor: editor} + g.ExtendBaseWidget(g) + return g +} + +func (g *graphView) CreateRenderer() fyne.WidgetRenderer { + r := &graphRenderer{g: g} + r.bg = canvas.NewRectangle(bgColor) + g.r = r + r.rebuild() + return r +} + +type graphRenderer struct { + g *graphView + size fyne.Size + + bg *canvas.Rectangle + gridLines []*canvas.Line + axes []*canvas.Line + dataLines []*canvas.Line + points []*draggablePoint + + // cached mapping from last layout (used by drag math) + x0, y0, x1, y1 float32 + minYv, maxYv int + minZv, maxZv float64 + + objects []fyne.CanvasObject +} + +func (r *graphRenderer) rebuild() { + if len(r.gridLines) == 0 { + for range 6 { + l := canvas.NewLine(gridColor) + l.StrokeWidth = 1 + r.gridLines = append(r.gridLines, l) + } + } + if len(r.axes) == 0 { + for range 2 { + l := canvas.NewLine(axisColor) + l.StrokeWidth = 1 + r.axes = append(r.axes, l) + } + } + + rows := r.g.editor.rows + wantLines := 0 + if n := len(rows); n > 1 { + wantLines = n - 1 + } + for len(r.dataLines) < wantLines { + l := canvas.NewLine(lineColor) + l.StrokeWidth = 2 + r.dataLines = append(r.dataLines, l) + } + r.dataLines = r.dataLines[:wantLines] + + for len(r.points) < len(rows) { + p := newDraggablePoint(r.g) + r.points = append(r.points, p) + } + r.points = r.points[:len(rows)] + for i, row := range rows { + r.points[i].row = row + } + + // dataLines/points may have changed length; force Objects() to rebuild. + r.objects = nil +} + +func (r *graphRenderer) Layout(size fyne.Size) { + r.size = size + r.bg.Resize(size) + r.layoutGraph() +} + +func (r *graphRenderer) layoutGraph() { + w := r.size.Width + h := r.size.Height + if w <= 0 || h <= 0 { + return + } + + r.x0 = float32(graphMargin) + r.y0 = float32(graphMargin) + r.x1 = w - graphMargin + r.y1 = h - graphMargin + + for i := range 3 { + gy := r.y0 + (r.y1-r.y0)*float32(i+1)/4 + gh := r.gridLines[i] + gh.Position1 = fyne.NewPos(r.x0, gy) + gh.Position2 = fyne.NewPos(r.x1, gy) + gh.Refresh() + + gx := r.x0 + (r.x1-r.x0)*float32(i+1)/4 + gv := r.gridLines[3+i] + gv.Position1 = fyne.NewPos(gx, r.y0) + gv.Position2 = fyne.NewPos(gx, r.y1) + gv.Refresh() + } + + r.axes[0].Position1 = fyne.NewPos(r.x0, r.y1) + r.axes[0].Position2 = fyne.NewPos(r.x1, r.y1) + r.axes[0].Refresh() + r.axes[1].Position1 = fyne.NewPos(r.x0, r.y0) + r.axes[1].Position2 = fyne.NewPos(r.x0, r.y1) + r.axes[1].Refresh() + + rows := r.g.editor.rows + if len(rows) == 0 { + r.minYv, r.maxYv = yMin, yMax + r.minZv, r.maxZv = zMin, zMax + return + } + + minY, maxY := rows[0].y, rows[0].y + minZ, maxZ := rows[0].z, rows[0].z + for _, row := range rows { + if row.y < minY { + minY = row.y + } + if row.y > maxY { + maxY = row.y + } + if row.z < minZ { + minZ = row.z + } + if row.z > maxZ { + maxZ = row.z + } + } + // Enforce a minimum visible span so dragging stays responsive when + // points are clustered and so the graph doesn't degenerate to a point. + const minYSpan, minZSpan = 50, 0.2 + if maxY-minY < minYSpan { + mid := (maxY + minY) / 2 + minY = clampInt(mid-minYSpan/2, yMin, yMax-minYSpan) + maxY = minY + minYSpan + } + if maxZ-minZ < minZSpan { + mid := (maxZ + minZ) / 2 + minZ = clampFloat(mid-minZSpan/2, zMin, zMax-minZSpan) + maxZ = minZ + minZSpan + } + r.minYv, r.maxYv = minY, maxY + r.minZv, r.maxZv = minZ, maxZ + + type pt struct{ x, y float32 } + pts := make([]pt, len(rows)) + for i, row := range rows { + fx := float32(row.y-minY) / float32(maxY-minY) + fy := float32((row.z - minZ) / (maxZ - minZ)) + pts[i].x = r.x0 + fx*(r.x1-r.x0) + pts[i].y = r.y1 - fy*(r.y1-r.y0) + } + + for i := 1; i < len(pts); i++ { + l := r.dataLines[i-1] + l.Position1 = fyne.NewPos(pts[i-1].x, pts[i-1].y) + l.Position2 = fyne.NewPos(pts[i].x, pts[i].y) + l.Refresh() + } + + half := float32(pointSize) / 2 + for i, p := range pts { + dp := r.points[i] + dp.Resize(fyne.NewSize(pointSize, pointSize)) + dp.Move(fyne.NewPos(p.x-half, p.y-half)) + dp.Refresh() + } +} + +func (r *graphRenderer) Refresh() { + r.bg.FillColor = bgColor + r.bg.Refresh() + r.rebuild() + r.layoutGraph() + canvas.Refresh(r.g) +} + +func (r *graphRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + r.objects = make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) + r.objects = append(r.objects, r.bg) + for _, l := range r.gridLines { + r.objects = append(r.objects, l) + } + for _, l := range r.axes { + r.objects = append(r.objects, l) + } + for _, l := range r.dataLines { + r.objects = append(r.objects, l) + } + for _, p := range r.points { + r.objects = append(r.objects, p) + } + } + return r.objects +} + +func (r *graphRenderer) MinSize() fyne.Size { + return fyne.NewSize(minGraph, minGraph) +} + +func (r *graphRenderer) Destroy() {} + +// --- draggable point ------------------------------------------------------- + +type draggablePoint struct { + widget.BaseWidget + g *graphView + row *mapRow + accY float64 // fractional accumulator for integer y axis +} + +func newDraggablePoint(g *graphView) *draggablePoint { + p := &draggablePoint{g: g} + p.ExtendBaseWidget(p) + return p +} + +func (p *draggablePoint) CreateRenderer() fyne.WidgetRenderer { + rect := canvas.NewRectangle(pointColor) + rect.StrokeColor = pointEdge + rect.StrokeWidth = 1 + return widget.NewSimpleRenderer(rect) +} + +func (p *draggablePoint) Cursor() desktop.Cursor { + return desktop.PointerCursor +} + +func (p *draggablePoint) Dragged(e *fyne.DragEvent) { + r := p.g.r + if r == nil || p.row == nil { + return + } + w := r.x1 - r.x0 + h := r.y1 - r.y0 + if w <= 0 || h <= 0 { + return + } + + // pixel delta → value delta using last layout's data range + dY := float64(e.Dragged.DX) / float64(w) * float64(r.maxYv-r.minYv) + dZ := -float64(e.Dragged.DY) / float64(h) * (r.maxZv - r.minZv) + + p.accY += dY + step := int(p.accY) + p.accY -= float64(step) + + p.row.y = clampInt(p.row.y+step, yMin, yMax) + p.row.z = clampFloat(p.row.z+dZ, zMin, zMax) + + p.g.editor.updateRowEntries(p.row) + p.g.Refresh() +} + +func (p *draggablePoint) DragEnd() { + p.accY = 0 + p.g.editor.save() +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func clampFloat(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} From 55422fef2f06cec0fa4df75334231de8f7b9b3ec Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 14 Jun 2026 00:28:28 +0200 Subject: [PATCH 41/93] more settings --- go.mod | 2 +- go.sum | 4 +- pkg/widgets/newsettings/can.go | 54 ------------ pkg/widgets/newsettings/settings.go | 24 ------ pkg/widgets/numericentry/numericentry.go | 3 +- pkg/widgets/settings/settings.go | 4 +- pkg/widgets/settings/tabs.go | 100 ++++++++++++++--------- pkg/widgets/settings/wblgraph.go | 9 +- pkg/windows/mainwindow_newgauge.go | 14 +--- 9 files changed, 78 insertions(+), 136 deletions(-) delete mode 100644 pkg/widgets/newsettings/can.go delete mode 100644 pkg/widgets/newsettings/settings.go diff --git a/go.mod b/go.mod index d90b439f..ebca1ec2 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998 + fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 diff --git a/go.sum b/go.sum index af30dca5..876b9002 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998 h1:t9rEs4rXZMTSt80TC4saVmfeSxyiUw3eMeu5kBYaF68= -fyne.io/fyne/v2 v2.7.5-0.20260611121725-ccd9d3f45998/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7 h1:TNADRWLV+A9auHNWACCtB6l/5vBKBnivf/tm9OR1+C0= +fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= diff --git a/pkg/widgets/newsettings/can.go b/pkg/widgets/newsettings/can.go deleted file mode 100644 index 11c9fa87..00000000 --- a/pkg/widgets/newsettings/can.go +++ /dev/null @@ -1,54 +0,0 @@ -package settings - -import ( - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" -) - -type LoggingSettingsWidget struct { - widget.BaseWidget - - container *fyne.Container -} - -func NewTest(minSize fyne.Size) *LoggingSettingsWidget { - t := &LoggingSettingsWidget{} - t.ExtendBaseWidget(t) - t.render() - return t -} - -func (t *LoggingSettingsWidget) render() { - t.container = container.NewStack() -} - -func (t *LoggingSettingsWidget) CreateRenderer() fyne.WidgetRenderer { - return &LoggingSettingsWidgetRenderer{ - t: t, - } -} - -type LoggingSettingsWidgetRenderer struct { - t *LoggingSettingsWidget -} - -func (tr *LoggingSettingsWidgetRenderer) Layout(space fyne.Size) { - tr.t.container.Resize(space) - // do stuff -} - -func (tr *LoggingSettingsWidgetRenderer) MinSize() fyne.Size { - return tr.t.container.MinSize() -} - -func (tr *LoggingSettingsWidgetRenderer) Refresh() { - -} - -func (tr *LoggingSettingsWidgetRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{tr.t.container} -} - -func (tr *LoggingSettingsWidgetRenderer) Destroy() { -} diff --git a/pkg/widgets/newsettings/settings.go b/pkg/widgets/newsettings/settings.go deleted file mode 100644 index 8420f40a..00000000 --- a/pkg/widgets/newsettings/settings.go +++ /dev/null @@ -1,24 +0,0 @@ -package settings - -import "fyne.io/fyne/v2" - -type SettingsWidget interface { -} - -type SettingsDefinition struct { - Name string - Description string - Type string -} - -type Settings struct { - app fyne.App - CAN fyne.Widget -} - -func NewSettings(app fyne.App) *Settings { - w := &Settings{ - app: app, - } - return w -} diff --git a/pkg/widgets/numericentry/numericentry.go b/pkg/widgets/numericentry/numericentry.go index 3e78d7e7..ddb879a8 100644 --- a/pkg/widgets/numericentry/numericentry.go +++ b/pkg/widgets/numericentry/numericentry.go @@ -11,9 +11,10 @@ type Widget struct { widget.Entry } -func New() *Widget { +func New(text string) *Widget { entry := &Widget{} entry.ExtendBaseWidget(entry) + entry.SetText(text) return entry } diff --git a/pkg/widgets/settings/settings.go b/pkg/widgets/settings/settings.go index a312ef6a..a6536ca8 100644 --- a/pkg/widgets/settings/settings.go +++ b/pkg/widgets/settings/settings.go @@ -11,6 +11,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/gocan" "github.com/roffe/gocan/proto" @@ -140,10 +141,9 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { tabs.Append(sw.graphicsTab()) tabs.Append(sw.canTab()) tabs.Append(sw.loggingTab()) - tabs.Append(sw.dashboardTab()) tabs.Append(sw.wblTab()) tabs.Append(sw.adScannerTab()) - tabs.Append(container.NewTabItem("txbridge", txconfigurator.NewConfigurator())) + tabs.Append(container.NewTabItemWithIcon("txbridge", theme.DownloadIcon(), txconfigurator.NewConfigurator())) sw.loadPreferences() return widget.NewSimpleRenderer(tabs) diff --git a/pkg/widgets/settings/tabs.go b/pkg/widgets/settings/tabs.go index 6154761d..1bf538a8 100644 --- a/pkg/widgets/settings/tabs.go +++ b/pkg/widgets/settings/tabs.go @@ -21,35 +21,63 @@ func leftIcon(res fyne.Resource, content fyne.CanvasObject) *fyne.Container { return container.NewBorder(nil, nil, widget.NewIcon(res), nil, content) } +// section groups related controls under a titled card, the main visual +// building block of the settings panel. +func section(title string, rows ...fyne.CanvasObject) *widget.Card { + return widget.NewCard(title, "", container.NewVBox(rows...)) +} + +// tab wraps a stack of cards in a padded page. +func tab(title string, icon fyne.Resource, cards ...fyne.CanvasObject) *container.TabItem { + page := container.NewPadded(container.NewVBox(cards...)) + return container.NewTabItemWithIcon(title, icon, page) +} + func (sw *Widget) generalTab() *container.TabItem { - return container.NewTabItem("General", container.NewVBox( - leftLabeled("WD", sw.workDir), - leftIcon(theme.InfoIcon(), sw.autoLoad), - leftIcon(theme.WarningIcon(), sw.autoSave), - leftIcon(theme.MoveUpIcon(), sw.cursorFollowCrosshair), - leftIcon(theme.SearchIcon(), container.NewVBox(sw.livePreview, sw.realtimeBars)), - leftIcon(theme.ViewFullScreenIcon(), sw.meshView), - leftLabeled("Color blind mode", sw.colorBlindMode), - )) + return tab("General", theme.SettingsIcon(), + section("ECU connection", + leftIcon(theme.InfoIcon(), sw.autoLoad), + leftIcon(theme.WarningIcon(), sw.autoSave), + ), + section("Map editor & preview", + leftIcon(theme.MoveUpIcon(), sw.cursorFollowCrosshair), + leftIcon(theme.ViewFullScreenIcon(), sw.meshView), + leftIcon(theme.SearchIcon(), container.NewVBox(sw.livePreview, sw.realtimeBars)), + ), + section("Appearance", + leftLabeled("Color blind mode", sw.colorBlindMode), + ), + section("Working directory", + sw.workDir, + ), + ) } func (sw *Widget) graphicsTab() *container.TabItem { - return container.NewTabItem("Graphics", container.NewVBox( - leftLabeled("Plot renderer", sw.plotRendererSelect), - leftLabeled("Mesh renderer", sw.meshRendererSelect), - )) + return tab("Graphics", theme.ColorPaletteIcon(), + section("Renderers", + leftLabeled("Plot renderer", sw.plotRendererSelect), + leftLabeled("Mesh renderer", sw.meshRendererSelect), + ), + section("Dashboard", + leftIcon(theme.MoveUpIcon(), sw.swapRPMandSpeed), + leftIcon(theme.InfoIcon(), sw.useMPH), + ), + ) } func (sw *Widget) canTab() *container.TabItem { fixedLabel := func(text string) fyne.CanvasObject { return xlayout.NewFixedWidth(70, widget.NewLabel(text)) } - return container.NewTabItem("CAN", container.NewVBox( - container.NewBorder(nil, nil, fixedLabel("Adapter"), sw.debugCheckbox, sw.adapterSelector), - container.NewBorder(nil, nil, fixedLabel("Port"), sw.refreshBtn, sw.portSelector), - container.NewBorder(nil, nil, fixedLabel("Info"), nil, sw.portDescription), - container.NewBorder(nil, nil, fixedLabel("Speed"), nil, sw.speedSelector), - )) + return tab("CAN", theme.ComputerIcon(), + section("Adapter", + container.NewBorder(nil, nil, fixedLabel("Adapter"), sw.debugCheckbox, sw.adapterSelector), + container.NewBorder(nil, nil, fixedLabel("Port"), sw.refreshBtn, sw.portSelector), + container.NewBorder(nil, nil, fixedLabel("Info"), nil, sw.portDescription), + container.NewBorder(nil, nil, fixedLabel("Speed"), nil, sw.speedSelector), + ), + ) } func (sw *Widget) loggingTab() *container.TabItem { @@ -70,12 +98,15 @@ func (sw *Widget) loggingTab() *container.TabItem { }), ) - return container.NewTabItem("Logging", container.NewVBox( - container.NewBorder(nil, nil, widget.NewLabel("Logging rate (Hz)"), sw.freqValue, sw.freqSlider), - widget.NewSeparator(), - leftLabeled("Log format", sw.logFormat), - container.NewBorder(nil, logFolderButtons, widget.NewLabel("Log folder"), nil, sw.logPath), - )) + return tab("Logging", theme.DocumentSaveIcon(), + section("Capture", + container.NewBorder(nil, nil, widget.NewLabel("Logging rate (Hz)"), sw.freqValue, sw.freqSlider), + leftLabeled("Log format", sw.logFormat), + ), + section("Storage", + container.NewBorder(nil, logFolderButtons, widget.NewLabel("Log folder"), nil, sw.logPath), + ), + ) } func (sw *Widget) wblTab() *container.TabItem { @@ -101,28 +132,21 @@ func (sw *Widget) wblTab() *container.TabItem { sw.wblADScannerSymbol = widget.NewSelect(adSymbols, prefWBLADScannerSymbol.set) sw.wblADScannerSymbol.SetSelected(sw.GetADScannerSymbolName()) - body := container.NewVBox( + image := container.NewHBox(layout.NewSpacer(), sw.wblImage, layout.NewSpacer()) + + settings := section("Wideband source", sw.wblSelectContainer, sw.wblADscanner, container.NewBorder(nil, nil, sw.wblPortLabel, sw.wblPortRefreshButton, sw.wblPortSelect), sw.wblADScannerSymbol, ) - image := container.NewHBox(layout.NewSpacer(), sw.wblImage, layout.NewSpacer()) - - return container.NewTabItem("WBL", container.NewBorder(image, nil, nil, nil, body)) -} - -func (sw *Widget) dashboardTab() *container.TabItem { - return container.NewTabItem("Dashboard", container.NewVBox( - widget.NewLabel("Dashboard settings"), - leftIcon(theme.InfoIcon(), sw.swapRPMandSpeed), - leftIcon(theme.InfoIcon(), sw.useMPH), - )) + page := container.NewPadded(container.NewVBox(image, settings)) + return container.NewTabItemWithIcon("WBL", theme.MediaRecordIcon(), page) } func (sw *Widget) adScannerTab() *container.TabItem { sw.wbleditor = NewWBLEditor(sw.GetWBLSupportPoints(), sw.GetWBLLambdaValues()) sw.wbleditor.Hide() - return container.NewTabItem("AD Scanner", sw.wbleditor) + return container.NewTabItemWithIcon("AD Scanner", theme.SearchIcon(), sw.wbleditor) } diff --git a/pkg/widgets/settings/wblgraph.go b/pkg/widgets/settings/wblgraph.go index d311822a..b6f72f2b 100644 --- a/pkg/widgets/settings/wblgraph.go +++ b/pkg/widgets/settings/wblgraph.go @@ -21,9 +21,10 @@ var ( ) const ( - graphMargin = 16 - pointSize = 12 - minGraph = 280 + graphMargin = 16 + pointSize = 12 + minGraphWidth = 360 + minGraphHeight = 280 yMin = 0 yMax = 1023 @@ -242,7 +243,7 @@ func (r *graphRenderer) Objects() []fyne.CanvasObject { } func (r *graphRenderer) MinSize() fyne.Size { - return fyne.NewSize(minGraph, minGraph) + return fyne.NewSize(minGraphWidth, minGraphHeight) } func (r *graphRenderer) Destroy() {} diff --git a/pkg/windows/mainwindow_newgauge.go b/pkg/windows/mainwindow_newgauge.go index c8f21fd3..19183ffe 100644 --- a/pkg/windows/mainwindow_newgauge.go +++ b/pkg/windows/mainwindow_newgauge.go @@ -58,17 +58,11 @@ func NewGaugeCreator(mw *MainWindow) *GaugeCreator { g.entries.symbolNameSecondary = widget.NewSelect(symbols, func(s string) {}) g.entries.symbolNameSecondary.Disable() - g.entries.min = numericentry.New() - g.entries.min.SetText("0") - g.entries.max = numericentry.New() - g.entries.max.SetText("100") - - g.entries.center = numericentry.New() - g.entries.center.SetText("50") + g.entries.min = numericentry.New("0") + g.entries.max = numericentry.New("100") + g.entries.center = numericentry.New("50") g.entries.center.Disable() - - g.entries.steps = numericentry.New() - g.entries.steps.SetText("10") + g.entries.steps = numericentry.New("10") g.entries.typ = widget.NewSelect([]string{"Dial", "DualDial", "VBar", "HBar", "CBar"}, func(s string) { switch s { From 8762b8c60be3bed146a220684df70375726739d8 Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 14 Jun 2026 01:49:27 +0200 Subject: [PATCH 42/93] update secret --- pkg/widgets/secrettext/secrettext.go | 45 ++++-- pkg/widgets/tunnel/logo.png | Bin 0 -> 32575 bytes pkg/widgets/tunnel/tunnel.go | 104 ++++++++++++ pkg/widgets/tunnel/tunnel_crawl.go | 94 +++++++++++ pkg/widgets/tunnel/tunnel_shader.go | 233 +++++++++++++++++++++++++++ pkg/widgets/tunnel/tunnel_test.go | 91 +++++++++++ 6 files changed, 554 insertions(+), 13 deletions(-) create mode 100644 pkg/widgets/tunnel/logo.png create mode 100644 pkg/widgets/tunnel/tunnel.go create mode 100644 pkg/widgets/tunnel/tunnel_crawl.go create mode 100644 pkg/widgets/tunnel/tunnel_shader.go create mode 100644 pkg/widgets/tunnel/tunnel_test.go diff --git a/pkg/widgets/secrettext/secrettext.go b/pkg/widgets/secrettext/secrettext.go index 78e13041..40ef47b9 100644 --- a/pkg/widgets/secrettext/secrettext.go +++ b/pkg/widgets/secrettext/secrettext.go @@ -3,17 +3,15 @@ package secrettext import ( "bytes" "sync" - "time" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" "github.com/hajimehoshi/go-mp3" "github.com/roffe/txlogger/pkg/assets" "github.com/roffe/txlogger/pkg/sound" + "github.com/roffe/txlogger/pkg/widgets/tunnel" ) var _ fyne.Tappable = (*SecretText)(nil) @@ -37,10 +35,12 @@ func (s *SecretText) Tapped(*fyne.PointEvent) { // log.Println("tapped", s.tappedTimes) if s.tappedTimes >= 10 { - t := fyne.NewStaticResource("taz.png", assets.Taz) - cv := canvas.NewImageFromResource(t) - cv.ScaleMode = canvas.ImageScaleFastest - cv.SetMinSize(fyne.NewSize(0, 0)) + /* + t := fyne.NewStaticResource("taz.png", assets.Taz) + cv := canvas.NewImageFromResource(t) + cv.ScaleMode = canvas.ImageScaleFastest + cv.SetMinSize(fyne.NewSize(0, 0)) + */ fileBytesReader := bytes.NewReader(assets.Korvring) @@ -59,17 +59,36 @@ func (s *SecretText) Tapped(*fyne.PointEvent) { f() } - cont := container.NewStack(cv) - d := dialog.NewCustom("You found the secret", "Leif", cont, fyne.CurrentApp().Driver().AllWindows()[0]) + t := tunnel.New() + t.SetCredits([]string{ + "SAAB", + "MattiasC", + "Dilemma", + "J.K Nilsson", + "Manick", + "Artursson", + "Schottis", + "Chriva", + "Myrtilos", + "Mackan", + "Kalej", + "Bojer", + "TrionicTuning", + "o2o Crew", + }) + + d := dialog.NewCustom("You found the secret", "Leif", t, fyne.CurrentApp().Driver().AllWindows()[0]) d.SetOnClosed(func() { player.Pause() }) d.Show() - an := canvas.NewSizeAnimation(fyne.NewSize(0, 0), fyne.NewSize(370, 386), time.Second, func(size fyne.Size) { - cv.Resize(size) - }) + /* + an := canvas.NewSizeAnimation(fyne.NewSize(0, 0), fyne.NewSize(370, 386), time.Second, func(size fyne.Size) { + cv.Resize(size) + }) - an.Start() + an.Start() + */ } } diff --git a/pkg/widgets/tunnel/logo.png b/pkg/widgets/tunnel/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..31f60b004c533d838c6723647bf86d06f97d6847 GIT binary patch literal 32575 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 literal 0 HcmV?d00001 diff --git a/pkg/widgets/tunnel/tunnel.go b/pkg/widgets/tunnel/tunnel.go new file mode 100644 index 00000000..62ba9a00 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel.go @@ -0,0 +1,104 @@ +// Package tunnel renders an endless psychedelic 3D tunnel ride entirely on the +// GPU with a single canvas.Shader, modelled on the meshgrid shader backend. +// The viewer rides a retrowave wiremesh vortex like a rollercoaster; all motion +// comes from the "time" uniform that canvas.NewShaderAnimation advances, so the +// CPU does no per-frame work. +package tunnel + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" +) + +var _ fyne.Widget = (*Tunnel)(nil) + +// defaultSize is the requested 500x500 viewport; the widget keeps it as its +// minimum and fills whatever a container hands it, staying circular via the +// short-axis normalisation in the shader. +const defaultSize = 700 + +type Tunnel struct { + widget.BaseWidget + + shader *canvas.Shader + anim *fyne.Animation + + running bool +} + +// New builds a tunnel widget. The animation starts automatically once the +// widget is shown (CreateRenderer) and stops when it is removed (Destroy). +func New() *Tunnel { + t := &Tunnel{} + t.ExtendBaseWidget(t) + t.initShader() + t.anim = canvas.NewShaderAnimation(t.shader) + return t +} + +// Start begins (or resumes) the ride. Safe to call repeatedly. +func (t *Tunnel) Start() { + if t.running || t.anim == nil { + return + } + t.running = true + t.anim.Start() +} + +// Stop freezes the tunnel on its current frame. +func (t *Tunnel) Stop() { + if !t.running || t.anim == nil { + return + } + t.running = false + t.anim.Stop() +} + +// SetSpeed scales how fast the viewer rides into the tunnel. +func (t *Tunnel) SetSpeed(speed float32) { + if t.shader == nil { + return + } + t.shader.Uniforms["speed"] = speed + t.shader.Refresh() +} + +// SetLogoScale sets the bouncing logo's half-size in short-axis units (the +// short axis spans -1..1), e.g. 0.30 makes the logo ~30% of half the viewport. +func (t *Tunnel) SetLogoScale(scale float32) { + if t.shader == nil { + return + } + t.shader.Uniforms["logo_scale"] = scale + t.shader.Refresh() +} + +func (t *Tunnel) CreateRenderer() fyne.WidgetRenderer { + t.Start() + return &tunnelRenderer{t: t} +} + +type tunnelRenderer struct { + t *Tunnel +} + +func (r *tunnelRenderer) Layout(size fyne.Size) { + r.t.shader.Resize(size) +} + +func (r *tunnelRenderer) MinSize() fyne.Size { + return fyne.NewSize(defaultSize, defaultSize) +} + +func (r *tunnelRenderer) Refresh() { + r.t.shader.Refresh() +} + +func (r *tunnelRenderer) Destroy() { + r.t.Stop() +} + +func (r *tunnelRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.t.shader} +} diff --git a/pkg/widgets/tunnel/tunnel_crawl.go b/pkg/widgets/tunnel/tunnel_crawl.go new file mode 100644 index 00000000..0035fdde --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_crawl.go @@ -0,0 +1,94 @@ +package tunnel + +import ( + "image" + "image/color" + "log" + + "fyne.io/fyne/v2/theme" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +// Credits-strip texture layout. Each line gets a fixed-height band; the shader +// samples the strip with a perspective crawl and loops it with fract. +const ( + crawlTexWidth = 640 + crawlLineH = 56 + crawlFontSize = 40 +) + +// SetCredits sets the lines shown as a Star Wars-style crawl that rises from +// the bottom into the tunnel centre and loops forever. An empty string renders +// as a blank line, useful for spacing and section breaks - including a blank +// first/last line, which hides the loop seam. Passing an empty slice removes +// the crawl. +func (t *Tunnel) SetCredits(lines []string) { + if t.shader == nil { + return + } + img := renderCrawl(lines) + if img == nil { + delete(t.shader.Textures, "crawl_tex") + t.shader.Uniforms["crawl_lines"] = 0 + t.shader.Refresh() + return + } + if t.shader.Textures == nil { + t.shader.Textures = make(map[string]image.Image, 2) + } + t.shader.Textures["crawl_tex"] = img + t.shader.Uniforms["crawl_lines"] = float32(len(lines)) + t.shader.Refresh() +} + +// renderCrawl rasterises the credit lines into a tall, centred, transparent +// strip: band i holds line i, with band 0 (image row 0, texture t = 0) the +// first line. The painter uploads image rows verbatim, so the first line sits +// at the far end of the crawl. Returns nil for an empty list. +func renderCrawl(lines []string) *image.RGBA { + if len(lines) == 0 { + return nil + } + face, err := crawlFace() + if err != nil { + log.Printf("tunnel: crawl font: %v", err) + return nil + } + defer face.Close() + + img := image.NewRGBA(image.Rect(0, 0, crawlTexWidth, len(lines)*crawlLineH)) + + m := face.Metrics() + textH := (m.Ascent + m.Descent).Ceil() + topPad := (crawlLineH - textH) / 2 + + d := &font.Drawer{Dst: img, Src: image.NewUniform(color.White), Face: face} + for i, line := range lines { + if line == "" { + continue // blank spacer line + } + adv := font.MeasureString(face, line) + d.Dot = fixed.Point26_6{ + X: (fixed.I(crawlTexWidth) - adv) / 2, // centre horizontally + Y: fixed.I(i*crawlLineH+topPad) + m.Ascent, + } + d.DrawString(line) + } + return img +} + +// crawlFace builds a font face from the app's bundled text font so the crawl +// matches the rest of the UI. +func crawlFace() (font.Face, error) { + ft, err := opentype.Parse(theme.DefaultTextFont().Content()) + if err != nil { + return nil, err + } + return opentype.NewFace(ft, &opentype.FaceOptions{ + Size: crawlFontSize, + DPI: 72, + Hinting: font.HintingFull, + }) +} diff --git a/pkg/widgets/tunnel/tunnel_shader.go b/pkg/widgets/tunnel/tunnel_shader.go new file mode 100644 index 00000000..a0486477 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_shader.go @@ -0,0 +1,233 @@ +package tunnel + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/draw" + _ "image/png" + "log" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" +) + +//go:embed logo.png +var logoPNG []byte + +// The whole effect is a single procedural fragment shader: no geometry, no +// textures, no per-frame CPU work beyond the "time" uniform that +// canvas.NewShaderAnimation advances. Each pixel reconstructs an infinite +// tube in polar coordinates - angle around the tube, 1/radius into it - and +// draws a glowing retrowave wireframe of rings and ribs that streams toward +// the viewer. A swaying vanishing point plus a depth-dependent twist give the +// rollercoaster ride; hue cycling over depth and time keeps it psychedelic. + +// tunnelSeq makes each widget's Shader.Name unique so the painter caches one +// compiled program per live widget instead of evicting a shared one. +var tunnelSeq atomic.Int64 + +const tunnelPreludeGL = "#version 110\n" + +const tunnelPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const tunnelBody = ` +#define PI 3.14159265359 + +uniform vec2 frame_size; // output frame size, device px +uniform vec4 rect_coords; // this object's bounds (x1, x2, y1, y2), device px +uniform float time; // elapsed animation seconds + +// optional tuning knobs (default sensibly if left at 0 by the Go side) +uniform float speed; // forward ride speed +uniform float ring_freq; // rings per depth unit +uniform float rib_freq; // longitudinal ribs around the tube +uniform float logo_scale; // logo billboard half-size, short-axis units + +uniform float crawl_lines; // number of credit lines (0 = no crawl) +uniform float crawl_persp; // floor depth at the bottom edge +uniform float crawl_width; // text block half-width in world units +uniform float crawl_zlines; // credit lines spanned per unit of depth +uniform float crawl_speed; // scroll rate, lines per second + +uniform sampler2D logo_tex; // txlogger logo, premultiplied RGBA +uniform sampler2D crawl_tex; // credits strip, line 0 at the top (t = 0) + +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +// glowing line at the nearest multiple of 1.0 of x, half width hw +float grid_line(float x, float hw) { + float f = fract(x); + float d = min(f, 1.0 - f); + return smoothstep(hw, 0.0, d); +} + +// triangle wave in [-1, 1] with period 1, for DVD-screensaver bouncing +float tri(float x) { + return abs(fract(x) - 0.5) * 4.0 - 1.0; +} + +void main() { + vec2 size = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > size.x || p_dev.y > size.y) { + discard; + } + + float spd = speed > 0.0 ? speed : 1.1; + float rings = ring_freq > 0.0 ? ring_freq : 5.0; + float ribs = rib_freq > 0.0 ? rib_freq : 16.0; + + // centered, aspect-correct coordinates, roughly [-1, 1] on the short axis. + // sc stays pristine for the logo billboard; uv is warped into the tunnel. + vec2 sc = (p_dev - 0.5 * size) / min(size.x, size.y) * 2.0; + vec2 uv = sc; + + // rollercoaster: drift the vanishing point along a couple of detuned + // sinusoids so the track appears to bank and weave + uv -= vec2(sin(time * 0.7) * 0.34 + sin(time * 1.31) * 0.11, + cos(time * 0.53) * 0.30 + cos(time * 1.79) * 0.09); + + // bank the whole frame into the curves + float bank = sin(time * 0.61) * 0.55; + float cb = cos(bank), sb = sin(bank); + uv = mat2(cb, -sb, sb, cb) * uv; + + float r = length(uv); + float a = atan(uv.y, uv.x); + + // tube coordinates: depth grows toward the centre, ride forward over time, + // and add a spiral twist with depth for the psytrance vortex + float depth = 1.0 / max(r, 0.0008); + float v = depth + time * spd; + float u = a / PI + depth * 0.18 + time * 0.04; + + // wireframe: cyan rings across the tube, magenta ribs along it. The line + // width shrinks with depth so far geometry naturally thins toward the + // vanishing point. + float lw = clamp(0.02 + r * 0.10, 0.02, 0.22); + float ring = grid_line(v * rings, lw); + float rib = grid_line(u * ribs, lw * 0.9); + + float hue = fract(0.58 + v * 0.025 + u * 0.05 + time * 0.05); + vec3 ringCol = hsv2rgb(vec3(hue, 0.85, 1.0)); + vec3 ribCol = hsv2rgb(vec3(fract(hue + 0.45), 0.9, 1.0)); + + vec3 col = ring * ringCol * 1.3 + rib * ribCol * 1.0; + + // fade the grid out right at the centre so it doesn't smear into a blob + col *= smoothstep(0.0, 0.10, r); + + // pulsing neon "light at the end of the tunnel" + float pulse = 0.55 + 0.45 * sin(time * 2.3); + col += vec3(0.75, 0.30, 1.0) * smoothstep(0.45, 0.0, r) * pulse; + + // dark retrowave haze between the lines so the tube reads as a surface + vec3 haze = mix(vec3(0.03, 0.0, 0.07), vec3(0.12, 0.01, 0.20), + 0.5 + 0.5 * sin(v * 0.5 + time * 0.5)); + col += haze * (1.0 - clamp(ring + rib, 0.0, 1.0)) * smoothstep(0.0, 0.12, r); + + // vignette toward the outer edge + col *= 1.0 - 0.40 * smoothstep(0.7, 1.5, r); + + // --- Star Wars credits crawl: rises from the bottom into the tunnel centre --- + // A floor plane recedes from the bottom edge (sc.y ~ 1, near) toward the + // tunnel mouth (sc.y -> 0, far). z = persp/sc.y is the depth along it; the + // text narrows with depth (s uses sc.x*z) and scrolls in line units that + // wrap with fract, so a blank first/last line hides the loop seam. + if (crawl_lines > 0.5 && sc.y > 0.045) { + float cw = crawl_width > 0.0 ? crawl_width : 0.9; + float cz = crawl_zlines > 0.0 ? crawl_zlines : 3.0; + float cp = crawl_persp > 0.0 ? crawl_persp : 0.45; + float cs = crawl_speed > 0.0 ? crawl_speed : 0.8; + + float z = cp / sc.y; + float s = sc.x * z / cw + 0.5; + if (s >= 0.0 && s <= 1.0) { + float t = fract((time * cs - z * cz) / crawl_lines); + float a = texture2D(crawl_tex, vec2(s, t)).a; + a *= smoothstep(0.045, 0.18, sc.y) * smoothstep(1.5, 1.0, sc.y); + vec3 crawlCol = vec3(1.0, 0.85, 0.2); // classic crawl yellow + col = mix(col, crawlCol, a); + col += crawlCol * a * 0.25; // soft neon glow + } + } + + // --- spinning, DVD-bouncing txlogger logo billboard, composited on top --- + // DVD-screensaver bounce: detuned triangle waves keep it inside the frame + vec2 lc = vec2(tri(time * 0.27) * 0.58, tri(time * 0.21) * 0.52); + + // grow and shrink as it rides the tunnel toward and away from the viewer + float depthBob = 0.70 + 0.35 * sin(time * 0.8); + float lscale = (logo_scale > 0.0 ? logo_scale : 0.30) * depthBob; + + // rotation couples to the bounce path: the angle tracks position, so its + // spin rate and direction follow the travel heading and flip at every wall + // hit (lc.x reverses on the side walls, lc.y on the top/bottom). + float ang = lc.x * 10.0 + lc.y * 7.0; + float ca = cos(ang), sa = sin(ang); + vec2 luv = mat2(ca, -sa, sa, ca) * ((sc - lc) / lscale); + if (abs(luv.x) <= 1.0 && abs(luv.y) <= 1.0) { + vec4 logo = texture2D(logo_tex, luv * 0.5 + 0.5); + // source-over with premultiplied alpha, then a pulsing neon rim glow + col = col * (1.0 - logo.a) + logo.rgb; + col += logo.a * (0.4 + 0.4 * sin(time * 2.0)) * 0.25 * vec3(0.6, 0.25, 1.0); + } + + gl_FragColor = vec4(col, 1.0); +} +` + +func (t *Tunnel) initShader() { + t.shader = canvas.NewShader( + fmt.Sprintf("tunnel-%d", tunnelSeq.Add(1)), + []byte(tunnelPreludeGL+tunnelBody), + []byte(tunnelPreludeES+tunnelBody), + ) + t.shader.Uniforms = map[string]float32{ + "time": 0, + "speed": 1.1, + "ring_freq": 5.0, + "rib_freq": 16.0, + "logo_scale": 0.30, + "crawl_lines": 0, // no credits until SetCredits is called + "crawl_persp": 0.45, + "crawl_width": 0.9, + "crawl_zlines": 3.0, + "crawl_speed": 0.8, + } + if logo := loadLogo(); logo != nil { + t.shader.Textures = map[string]image.Image{"logo_tex": logo} + } +} + +// loadLogo decodes the embedded txlogger logo into a premultiplied *image.RGBA, +// the form the GL painter uploads verbatim (image row 0 -> texture t=0, so the +// logo samples upright). Returns nil if decoding fails, in which case the +// shader simply samples an unbound texture and draws no logo. +func loadLogo() image.Image { + img, _, err := image.Decode(bytes.NewReader(logoPNG)) + if err != nil { + log.Printf("tunnel: decoding logo.png: %v", err) + return nil + } + if rgba, ok := img.(*image.RGBA); ok { + return rgba + } + b := img.Bounds() + rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src) + return rgba +} diff --git a/pkg/widgets/tunnel/tunnel_test.go b/pkg/widgets/tunnel/tunnel_test.go new file mode 100644 index 00000000..69777120 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_test.go @@ -0,0 +1,91 @@ +package tunnel + +import ( + "image" + "os" + "os/exec" + "path/filepath" + "testing" + + "fyne.io/fyne/v2/test" +) + +// New must produce a widget whose shader carries the animation-driven "time" +// uniform and tuning knobs, plus a renderer that exposes the shader. +func TestNewTunnel(t *testing.T) { + test.NewTempApp(t) // CreateRenderer starts the animation, which needs a driver + tn := New() + if tn.shader == nil { + t.Fatal("shader not initialised") + } + if _, ok := tn.shader.Uniforms["time"]; !ok { + t.Fatal("time uniform missing") + } + if tn.anim == nil { + t.Fatal("animation not created") + } + if _, ok := tn.shader.Textures["logo_tex"]; !ok { + t.Fatal("embedded logo texture not loaded") + } + + r := tn.CreateRenderer() + objs := r.Objects() + if len(objs) != 1 || objs[0] != tn.shader { + t.Fatalf("renderer objects = %v, want [shader]", objs) + } + if min := r.MinSize(); min.Width != defaultSize || min.Height != defaultSize { + t.Fatalf("MinSize = %v, want %dx%d", min, defaultSize, defaultSize) + } + r.Destroy() +} + +// SetCredits must rasterise the lines into a crawl texture sized one band per +// line (blank lines included) and record the line count for the shader. +func TestSetCredits(t *testing.T) { + test.NewTempApp(t) + tn := New() + + lines := []string{"", "txlogger thanks", "", "Alice", "Bob", ""} + tn.SetCredits(lines) + + img, ok := tn.shader.Textures["crawl_tex"].(*image.RGBA) + if !ok { + t.Fatal("crawl texture not created") + } + if w := img.Bounds().Dy(); w != len(lines)*crawlLineH { + t.Fatalf("crawl height = %d, want %d", w, len(lines)*crawlLineH) + } + if got := tn.shader.Uniforms["crawl_lines"]; got != float32(len(lines)) { + t.Fatalf("crawl_lines = %v, want %d", got, len(lines)) + } + + // An empty slice removes the crawl again. + tn.SetCredits(nil) + if _, ok := tn.shader.Textures["crawl_tex"]; ok { + t.Fatal("crawl texture should be removed for empty credits") + } + if got := tn.shader.Uniforms["crawl_lines"]; got != 0 { + t.Fatalf("crawl_lines = %v, want 0", got) + } +} + +// Both shader variants must compile; glslangValidator checks them against the +// GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestTunnelShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": tunnelPreludeGL + tunnelBody, + "es.frag": tunnelPreludeES + tunnelBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} From 7c82074bacd2e84f0bcd15a3094f159ba92ae8a9 Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 14 Jun 2026 17:09:57 +0200 Subject: [PATCH 43/93] fix innovate serial logging --- pkg/wbl/innovate/innovate.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/wbl/innovate/innovate.go b/pkg/wbl/innovate/innovate.go index 8d336ad8..0918cfb0 100644 --- a/pkg/wbl/innovate/innovate.go +++ b/pkg/wbl/innovate/innovate.go @@ -96,13 +96,16 @@ func (c *ISP2Client) Start(ctx context.Context) error { // openPort opens the serial port and stores it on the client. func (c *ISP2Client) openPort() error { mode := &serial.Mode{ - BaudRate: 9600, + BaudRate: 19200, } sp, err := serial.Open(c.port, mode) if err != nil { - return err + return fmt.Errorf("failed to open serial port: %w", err) + } + if err := sp.SetReadTimeout(5 * time.Millisecond); err != nil { + sp.Close() + return fmt.Errorf("failed to set read timeout: %w", err) } - sp.SetReadTimeout(20 * time.Millisecond) c.mu.Lock() c.sp = sp @@ -126,7 +129,7 @@ func (c *ISP2Client) closePort() { c.mu.Unlock() if sp != nil { if err := sp.Close(); err != nil { - c.log("isp2: " + err.Error()) + c.log("isp2: failed to close serial port: " + err.Error()) } } } From e4a44f7f324794920d4afef5c0fa00665e09318e Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 14 Jun 2026 18:14:59 +0200 Subject: [PATCH 44/93] save tunnel shader --- pkg/widgets/tunnel/tunnel_shader.go | 49 ++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/pkg/widgets/tunnel/tunnel_shader.go b/pkg/widgets/tunnel/tunnel_shader.go index a0486477..0a8818a4 100644 --- a/pkg/widgets/tunnel/tunnel_shader.go +++ b/pkg/widgets/tunnel/tunnel_shader.go @@ -96,9 +96,13 @@ void main() { vec2 uv = sc; // rollercoaster: drift the vanishing point along a couple of detuned - // sinusoids so the track appears to bank and weave - uv -= vec2(sin(time * 0.7) * 0.34 + sin(time * 1.31) * 0.11, - cos(time * 0.53) * 0.30 + cos(time * 1.79) * 0.09); + // sinusoids so the track appears to bank and weave. vp is the hole's + // position in screen (sc) space; the bank below rotates around it, so the + // tunnel mouth always sits at sc == vp. The crawl reuses vp as the far + // point its text converges into. + vec2 vp = vec2(sin(time * 0.7) * 0.34 + sin(time * 1.31) * 0.11, + cos(time * 0.53) * 0.30 + cos(time * 1.79) * 0.09); + uv -= vp; // bank the whole frame into the curves float bank = sin(time * 0.61) * 0.55; @@ -142,23 +146,40 @@ void main() { // vignette toward the outer edge col *= 1.0 - 0.40 * smoothstep(0.7, 1.5, r); - // --- Star Wars credits crawl: rises from the bottom into the tunnel centre --- - // A floor plane recedes from the bottom edge (sc.y ~ 1, near) toward the - // tunnel mouth (sc.y -> 0, far). z = persp/sc.y is the depth along it; the - // text narrows with depth (s uses sc.x*z) and scrolls in line units that - // wrap with fract, so a blank first/last line hides the loop seam. - if (crawl_lines > 0.5 && sc.y > 0.045) { + // --- Star Wars credits crawl: flows up the floor into the moving hole --- + // A floor plane recedes from the bottom edge (near) toward the tunnel + // mouth (far), but its far end now converges on the drifting hole vp + // instead of the screen centre. yh is depth below the moving horizon + // (the floor lives where sc.y > vp.y); z = persp/yh is depth along it. + // The lane centre slides from x = 0 at the bottom to vp.x at the horizon, + // so the text stays anchored where it emerges yet leans and sways toward + // the wandering hole. s narrows the text with depth, and lf scrolls in + // line units that wrap with fract (a blank first/last line hides the loop + // seam) while staying < 0 on the first pass so the strip rises in from the + // bottom. + float yh = sc.y - vp.y; + if (crawl_lines > 0.5 && yh > 0.045) { float cw = crawl_width > 0.0 ? crawl_width : 0.9; float cz = crawl_zlines > 0.0 ? crawl_zlines : 3.0; float cp = crawl_persp > 0.0 ? crawl_persp : 0.45; float cs = crawl_speed > 0.0 ? crawl_speed : 0.8; - float z = cp / sc.y; - float s = sc.x * z / cw + 0.5; - if (s >= 0.0 && s <= 1.0) { - float t = fract((time * cs - z * cz) / crawl_lines); + float z = cp / yh; + // g runs 0 at the bottom anchor to 1 at the horizon. The lane centre + // first leans on a straight line from x = 0 to the hole (vp.x)... + float g = clamp((1.0 - sc.y) / (1.0 - vp.y), 0.0, 1.0); + float centerX = vp.x * g; + // ...then bows sideways so the path curves into the mouth along with + // the tunnel. g*(1-g) pins both ends (bottom anchor and hole) while the + // shared bank phase, plus a depth twist, make deeper sections lead the + // curve the same way the rings bank and spiral. + centerX += 0.9 * g * (1.0 - g) * sin(bank * 2.0 - g * 2.5); + float s = (sc.x - centerX) * z / cw + 0.5; + float lf = (time * cs - z * cz) / crawl_lines; + if (s >= 0.0 && s <= 1.0 && lf >= 0.0) { + float t = fract(lf); float a = texture2D(crawl_tex, vec2(s, t)).a; - a *= smoothstep(0.045, 0.18, sc.y) * smoothstep(1.5, 1.0, sc.y); + a *= smoothstep(0.045, 0.18, yh) * smoothstep(1.5, 1.0, sc.y); vec3 crawlCol = vec3(1.0, 0.85, 0.2); // classic crawl yellow col = mix(col, crawlCol, a); col += crawlCol * a * 0.25; // soft neon glow From 3f2c791635da3fb1b4ebc066d82c852734edaa72 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 15 Jun 2026 12:05:31 +0200 Subject: [PATCH 45/93] tidy --- console_linux.go | 3 +-- main.go | 8 -------- main_windows.go | 5 ++--- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/console_linux.go b/console_linux.go index dad6f1a4..7c7d7d9e 100644 --- a/console_linux.go +++ b/console_linux.go @@ -1,4 +1,3 @@ package main -func InitConsole() { -} +func InitConsole() {} diff --git a/main.go b/main.go index 864c732b..949ede54 100644 --- a/main.go +++ b/main.go @@ -121,14 +121,6 @@ func main() { mw.ShowAndRun() } -/* -func startpprof() { - go func() { - debug.Log(http.ListenAndServe("localhost:6060", nil)) - }() -} -*/ - func killProcess(p *os.Process) { if p != nil { p.Kill() diff --git a/main_windows.go b/main_windows.go index fbb883a9..eec22213 100644 --- a/main_windows.go +++ b/main_windows.go @@ -1,5 +1,4 @@ package main -func runFileChild() { - // This is a no-op on Windows and will never be called because the FP environment variable is only set in the Linux build, but we need it to exist to satisfy the linker. -} +// This is a no-op on Windows and will never be called because the FP environment variable is only set in the Linux build, but we need it to exist to satisfy the linker. +func runFileChild() {} From 96d49a279ac86dd990af5a51c165eeb7acb6b0c3 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 15 Jun 2026 12:42:28 +0200 Subject: [PATCH 46/93] improved selection and copy paste --- pkg/assets/WHATSNEW.md | 2 + pkg/widgets/mapviewer/mapviewer.go | 155 +++++++---------- pkg/widgets/mapviewer/mapviewer_keyhandler.go | 112 ++++++++---- pkg/widgets/mapviewer/mapviewer_mouse.go | 162 +++++++----------- 4 files changed, 200 insertions(+), 231 deletions(-) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 259030a9..85bb4f81 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -18,6 +18,8 @@ - Rewrote logplayer plotter to use about 50% less CPU on zoomed out views - Big refactor of the log writing logic to be simpler to maintain and be more performant - Dial and Dual Dial now uses shaders to draw the faces, dials and pips +- Improved cell selection in mapviewer +- Improved copy paste in mapviewer, added paste here function # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index b92b9494..c5336782 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -55,14 +55,15 @@ type MapViewer struct { content fyne.CanvasObject - innerView *fyne.Container - valueRects *fyne.Container - valueTexts *fyne.Container + innerView *fyne.Container + valueRects *fyne.Container + valueTexts *fyne.Container + selectionOverlay *fyne.Container - selectionRect *canvas.Rectangle - crosshair *canvas.Rectangle + crosshair *canvas.Rectangle - zDataRects []*canvas.Rectangle + zDataRects []*canvas.Rectangle + selectionRects []*canvas.Rectangle selectedX, SelectedY int @@ -70,10 +71,11 @@ type MapViewer struct { graph *graph2d.Graph // Mouse - mousePos fyne.Position - selecting bool - lastModifier fyne.KeyModifier - selectedCells []int + mousePos fyne.Position + selecting bool + dragCornerX, dragCornerY int + lastModifier fyne.KeyModifier + selectedCells []int // Keyboard inputBuffer strings.Builder @@ -92,13 +94,12 @@ type MapViewer struct { func New(config *Config) (*MapViewer, error) { mv := &MapViewer{ - cfg: config, - crosshair: NewCrosshair(color.RGBA{165, 55, 253, 180}, 3), - selectionRect: NewRectangle(color.RGBA{0x30, 0x70, 0xFF, 0xFF}, 3), - numColumns: len(config.XData), - numRows: len(config.YData), - numData: len(config.ZData), - colorMode: config.ColorblindMode, + cfg: config, + crosshair: NewCrosshair(color.RGBA{165, 55, 253, 180}, 3), + numColumns: len(config.XData), + numRows: len(config.YData), + numData: len(config.ZData), + colorMode: config.ColorblindMode, } mv.ExtendBaseWidget(mv) @@ -131,7 +132,11 @@ func (mv *MapViewer) CreateRenderer() fyne.WidgetRenderer { mv.createYAxis() mv.createXAxis() mv.createZdata() + mv.createSelectionOverlay() mv.createTextValues() + // Start with cell 0,0 selected so keyboard editing works before any click. + mv.selectedCells = []int{0} + mv.drawSelectionVisual() mv.content = mv.render() return widget.NewSimpleRenderer(mv.content) // return &mapViewerRenderer{mv: mv} @@ -173,8 +178,9 @@ func (mr *movingRectsLayout) Layout(_ []fyne.CanvasObject, size fyne.Size) { float32(float64(mr.mv.numRows)-1-mr.mv.yIndex)*mr.mv.heightFactor, ), ) - mr.mv.resizeSelectionRect() - mr.mv.updateCursor(false) + // The selection lives in selectionOverlay, which shares the cell grid and is + // repositioned automatically by the layout, so there is nothing to recompute + // for it here. } func (mv *MapViewer) render() fyne.CanvasObject { @@ -182,15 +188,11 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.crosshair.Resize(fyne.NewSize(34, 14)) mv.crosshair.Hide() - // mv.selectionRect.CornerRadius = 4 - mv.selectionRect.Resize(fyne.NewSize(34, 14)) - // mv.selectionRect.Hide() - mv.innerView = container.NewStack( mv.valueRects, + mv.selectionOverlay, container.New(&movingRectsLayout{mv: mv}, mv.crosshair, - mv.selectionRect, ), mv.valueTexts, ) @@ -426,6 +428,42 @@ func (mv *MapViewer) createZdata() { mv.valueRects = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) } +// createSelectionOverlay builds a dedicated highlight layer with one +// translucent rectangle per cell. The rectangles are hidden by default and +// only shown for cells present in mv.selectedCells. Keeping selection in its +// own layer decouples it from the value rects, so editing a cell's value never +// clears the selection visual and vice versa. +func (mv *MapViewer) createSelectionOverlay() { + mv.selectionRects = make([]*canvas.Rectangle, mv.numData) + objs := make([]fyne.CanvasObject, mv.numData) + for i := range mv.selectionRects { + rect := canvas.NewRectangle(color.RGBA{0xDE, 0xDF, 0xE4, 0xFF}) + rect.Hide() + mv.selectionRects[i] = rect + objs[i] = rect + } + mv.selectionOverlay = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) +} + +// clearSelectionVisual hides the highlight for every currently selected cell. +// Call it before mutating mv.selectedCells, then call drawSelectionVisual after. +func (mv *MapViewer) clearSelectionVisual() { + for _, cell := range mv.selectedCells { + if cell >= 0 && cell < len(mv.selectionRects) { + mv.selectionRects[cell].Hide() + } + } +} + +// drawSelectionVisual shows the highlight for every currently selected cell. +func (mv *MapViewer) drawSelectionVisual() { + for _, cell := range mv.selectedCells { + if cell >= 0 && cell < len(mv.selectionRects) { + mv.selectionRects[cell].Show() + } + } +} + func (mv *MapViewer) setXY() error { xIdx, yIdx, err := interpolate.Interpolate64S(mv.cfg.XData, mv.cfg.YData, mv.cfg.ZData, mv.xValue, mv.yValue) if err != nil { @@ -483,66 +521,6 @@ func (mv *MapViewer) createButtons() *fyne.Container { } } -func (mv *MapViewer) resizeSelectionRect() { - // Early return if no selection - if mv.selectedX < 0 { - return - } - - var pos fyne.Position - var size fyne.Size - - // Handle multiple cell selection - if len(mv.selectedCells) > 1 { - // Initialize bounds using first cell to avoid unnecessary comparisons - firstCell := mv.selectedCells[0] - minX := firstCell % mv.numColumns - maxX := minX - minY := firstCell / mv.numColumns - maxY := minY - - // Find bounds in a single pass - for i := 1; i < len(mv.selectedCells); i++ { - cell := mv.selectedCells[i] - x := cell % mv.numColumns - y := cell / mv.numColumns - - if x < minX { - minX = x - } else if x > maxX { - maxX = x - } - - if y < minY { - minY = y - } else if y > maxY { - maxY = y - } - } - - // Calculate position and size once - topLeftX := float32(minX) * mv.widthFactor - topLeftY := float32(mv.numRows-1-maxY) * mv.heightFactor - width := float32(maxX-minX+1) * mv.widthFactor - height := float32(maxY-minY+1) * mv.heightFactor - - pos = fyne.NewPos(topLeftX-1, topLeftY) - size = fyne.NewSize(width+1, height+1) - } else { - // Single cell selection - pos = fyne.NewPos( - (float32(mv.selectedX)*mv.widthFactor)-1, - (float32(mv.numRows-1-mv.SelectedY) * mv.heightFactor), - ) - size = fyne.NewSize(mv.widthFactor+1, mv.heightFactor+1) - } - - // Batch UI updates - mv.selectionRect.Move(pos) - mv.selectionRect.Resize(size) - // mv.selectionRect.MoveAndResize(pos, size) -} - /* var _ fyne.WidgetRenderer = (*mapViewerRenderer)(nil) @@ -598,12 +576,3 @@ func NewCrosshair(strokeColor color.RGBA, strokeWidth float32) *canvas.Rectangle CornerRadius: 4, } } - -func NewRectangle(strokeColor color.RGBA, strokeWidth float32) *canvas.Rectangle { - return &canvas.Rectangle{ - FillColor: color.RGBA{0, 0, 0, 0}, - StrokeColor: strokeColor, - StrokeWidth: strokeWidth, - CornerRadius: 4, - } -} diff --git a/pkg/widgets/mapviewer/mapviewer_keyhandler.go b/pkg/widgets/mapviewer/mapviewer_keyhandler.go index 27363c11..94958acd 100644 --- a/pkg/widgets/mapviewer/mapviewer_keyhandler.go +++ b/pkg/widgets/mapviewer/mapviewer_keyhandler.go @@ -52,18 +52,22 @@ func (mv *MapViewer) copy() { fyne.CurrentApp().Clipboard().SetContent(copyString.String()) } -func (mv *MapViewer) paste() { - if !mv.cfg.Editable { - return - } - cb := fyne.CurrentApp().Clipboard().Content() +type clipboardCell struct { + x, y int // storage coordinates (y already converted to storage row) + value float64 +} + +// parseClipboardCells parses the internal copy format into cells with storage +// coordinates. The first cell is the anchor; its x carries the +20/+200 marker, +// which is stripped here. +func (mv *MapViewer) parseClipboardCells(cb string) []clipboardCell { split := strings.Split(cb, copyPasteSeparator) + cells := make([]clipboardCell, 0, len(split)) for i, part := range split { if len(part) < 3 { continue } pp := strings.Split(part, ":") - if len(pp) < 3 { continue } @@ -73,7 +77,6 @@ func (mv *MapViewer) paste() { log.Println(err) continue } - if i == 0 && x >= 200 { x -= 200 } else if i == 0 && x >= 20 { @@ -85,27 +88,71 @@ func (mv *MapViewer) paste() { log.Println(err) continue } - value, err := strconv.Atoi(pp[2]) + value, err := strconv.ParseFloat(pp[2], 64) if err != nil { log.Println(err) continue } - y = mv.numRows - 1 - y + cells = append(cells, clipboardCell{x: x, y: mv.numRows - 1 - y, value: value}) + } + return cells +} +// applyPaste writes the parsed cells into ZData, offset by shiftX/shiftY. Cells +// that fall outside the map bounds are skipped. +func (mv *MapViewer) applyPaste(cells []clipboardCell, shiftX, shiftY int) { + changed := false + for _, c := range cells { + x := c.x + shiftX + y := c.y + shiftY + if x < 0 || x >= mv.numColumns || y < 0 || y >= mv.numRows { + continue + } index := y*mv.numColumns + x if index < 0 || index >= len(mv.cfg.ZData) { - log.Printf("Index out of range: %d", index) continue } - mv.cfg.ZData[index] = float64(value) - //if len(split) < 30 { - // mv.cfg.UpdateECUFunc(index, []float64{mv.cfg.ZData[index]}) - //} - } - //if len(split) >= 30 { - // mv.cfg.SaveECUFunc(mv.cfg.ZData) - //} - mv.Refresh() + mv.cfg.ZData[index] = c.value + changed = true + } + if changed { + mv.Refresh() + } +} + +// paste writes the clipboard back to the same coordinates it was copied from. +func (mv *MapViewer) paste() { + if !mv.cfg.Editable { + return + } + cells := mv.parseClipboardCells(fyne.CurrentApp().Clipboard().Content()) + mv.applyPaste(cells, 0, 0) +} + +// pasteHere writes the clipboard with its anchor cell landing on the currently +// selected cell, so the block is pasted starting where the user right-clicked. +func (mv *MapViewer) pasteHere() { + if !mv.cfg.Editable { + return + } + cells := mv.parseClipboardCells(fyne.CurrentApp().Clipboard().Content()) + if len(cells) == 0 { + return + } + // Anchor on the visual top-left of the copied block (smallest column, + // topmost row). Data row 0 is drawn at the bottom, so the topmost row is the + // largest storage y. This makes the block fill down and to the right from + // the clicked cell, matching the on-screen orientation. + minX, maxY := cells[0].x, cells[0].y + for _, c := range cells[1:] { + if c.x < minX { + minX = c.x + } + if c.y > maxY { + maxY = c.y + } + } + mv.applyPaste(cells, mv.selectedX-minX, mv.SelectedY-maxY) } func (mv *MapViewer) smooth() { @@ -136,28 +183,23 @@ func (mv *MapViewer) smooth() { mv.Refresh() } +// updateCursor collapses the selection to the single cell at selectedX/SelectedY +// and redraws the overlay highlight. Used by keyboard navigation and the +// crosshair-follow cursor. Pass goroutine=true when called off the main thread. func (mv *MapViewer) updateCursor(goroutine bool) { cell := mv.SelectedY*mv.numColumns + mv.selectedX - if len(mv.selectedCells) != 1 || mv.selectedCells[0] != cell { - mv.selectedCells = append(mv.selectedCells[:0], cell) - } - xPosFactor := float32(mv.selectedX) - yPosFactor := float32(float64(mv.numRows-1) - float64(mv.SelectedY)) - xPos := xPosFactor * mv.widthFactor - yPos := yPosFactor * mv.heightFactor - size := fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1} - pos := fyne.Position{X: xPos - 1, Y: yPos - 1} - if mv.selectionRect.Size() == size && mv.selectionRect.Position() == pos { + if len(mv.selectedCells) == 1 && mv.selectedCells[0] == cell { return } + apply := func() { + mv.clearSelectionVisual() + mv.selectedCells = append(mv.selectedCells[:0], cell) + mv.drawSelectionVisual() + } if goroutine { - fyne.Do(func() { - mv.selectionRect.Resize(size) - mv.selectionRect.Move(pos) - }) + fyne.Do(apply) } else { - mv.selectionRect.Resize(size) - mv.selectionRect.Move(pos) + apply() } } diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index c667cf70..e07055f6 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -1,12 +1,10 @@ package mapviewer import ( - "math" "slices" "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -38,8 +36,13 @@ func (mv *MapViewer) MouseMoved(event *desktop.MouseEvent) { } mv.mousePos = event.Position - nselectedX, nSelectedY := mv.calculateSelectionBounds(event.Position) - mv.updateSelection(nselectedX, nSelectedY) + nx, ny := mv.calculateSelectionBounds(event.Position) + // Only rebuild the selection when the drag crosses into a different cell. + if nx == mv.dragCornerX && ny == mv.dragCornerY { + return + } + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.selectRegion(nx, ny) } // MouseDown is called when a mouse button is pressed. @@ -54,37 +57,16 @@ func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { return } - if event.Modifier != fyne.KeyModifierControl { - for _, rect := range mv.zDataRects { - if rect.FillColor != rect.StrokeColor { - rect.FillColor = rect.StrokeColor - rect.Refresh() - } - } - } - mv.handleFocusAndInputBuffer() switch { case event.Button == desktop.MouseButtonPrimary && event.Modifier == 0: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryClick(event) case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierShift: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryClickWithShift(event) case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierControl: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryCtrlClick(event) case event.Button == desktop.MouseButtonSecondary && event.Modifier == 0: @@ -95,57 +77,77 @@ func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { // MouseUp is called when a mouse button is released. func (mv *MapViewer) MouseUp(event *desktop.MouseEvent) { // log.Println("MouseUp", event) - mv.selectionRect.Hide() if event.Button == desktop.MouseButtonPrimary && mv.selecting { mv.finalizeSelection(event.Position) } } -// handlePrimaryClick handles the primary click action. +// handlePrimaryClick starts a fresh single-cell selection and begins a drag. func (mv *MapViewer) handlePrimaryClick(event *desktop.MouseEvent) { + mv.clearSelectionVisual() mv.selectedX, mv.SelectedY = mv.calculateSelectionBounds(event.Position) - - cellWidth, cellHeight := mv.calculateCellDimensions() - x := (float32(mv.selectedX) * cellWidth) - y := (float32(mv.numRows-mv.SelectedY-1) * cellHeight) - - mv.updateCursorPositionAndSize(x, y, cellWidth, cellHeight) - mv.selectedCells = []int{mv.SelectedY*mv.numColumns + mv.selectedX} + mv.dragCornerX, mv.dragCornerY = mv.selectedX, mv.SelectedY + mv.selectedCells = append(mv.selectedCells[:0], mv.SelectedY*mv.numColumns+mv.selectedX) + mv.drawSelectionVisual() mv.selecting = true } func (mv *MapViewer) handlePrimaryCtrlClick(event *desktop.MouseEvent) { mv.selectedX, mv.SelectedY = mv.calculateSelectionBounds(event.Position) - - cellWidth, cellHeight := mv.calculateCellDimensions() - x := (float32(mv.selectedX) * cellWidth) - y := (float32(mv.numRows-mv.SelectedY-1) * cellHeight) - - mv.updateCursorPositionAndSize(x, y, cellWidth, cellHeight) - newCell := mv.SelectedY*mv.numColumns + mv.selectedX - // Check if cell is already selected + // Toggle the clicked cell while keeping the rest of the selection. if index := slices.Index(mv.selectedCells, newCell); index != -1 { - // Remove cell if already selected mv.selectedCells = append(mv.selectedCells[:index], mv.selectedCells[index+1:]...) - mv.zDataRects[newCell].FillColor = mv.zDataRects[newCell].StrokeColor + mv.selectionRects[newCell].Hide() } else { - // Add new cell mv.selectedCells = append(mv.selectedCells, newCell) - mv.zDataRects[newCell].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) + mv.selectionRects[newCell].Show() } - mv.zDataRects[newCell].Refresh() } -// handlePrimaryClickWithShift handles the primary click with shift action. +// handlePrimaryClickWithShift extends the selection from the current anchor to +// the clicked cell and begins a drag. func (mv *MapViewer) handlePrimaryClickWithShift(event *desktop.MouseEvent) { - nselectedX, nSelectedY := mv.calculateSelectionBounds(event.Position) - mv.updateSelection(nselectedX, nSelectedY) + nx, ny := mv.calculateSelectionBounds(event.Position) + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.selectRegion(nx, ny) mv.selecting = true } +// selectRegion replaces the current selection with the rectangular block +// between the anchor cell (selectedX/SelectedY) and the given corner, updating +// the overlay highlight live. +func (mv *MapViewer) selectRegion(cornerX, cornerY int) { + minX, maxX := min(mv.selectedX, cornerX), max(mv.selectedX, cornerX) + minY, maxY := min(mv.SelectedY, cornerY), max(mv.SelectedY, cornerY) + + mv.clearSelectionVisual() + mv.selectedCells = mv.selectedCells[:0] + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + mv.selectedCells = append(mv.selectedCells, y*mv.numColumns+x) + } + } + mv.drawSelectionVisual() +} + func (mv *MapViewer) handleSecondaryClick(event *desktop.MouseEvent) { + x, y := mv.calculateSelectionBounds(event.Position) + clickedCell := y*mv.numColumns + x + + // The clicked cell becomes the anchor for "Paste here" regardless. + mv.selectedX, mv.SelectedY = x, y + + // Right-clicking inside the current selection keeps it (so Copy/Smooth act + // on the whole block). Right-clicking outside it, or with no selection, + // selects just that cell. + if !slices.Contains(mv.selectedCells, clickedCell) { + mv.clearSelectionVisual() + mv.selectedCells = []int{clickedCell} + mv.drawSelectionVisual() + } + mv.showPopupMenu(event.AbsolutePosition) } @@ -172,24 +174,6 @@ func (mv *MapViewer) calculateSelectionBounds(eventPos fyne.Position) (int, int) return nselectedX, nSelectedY } -// updateSelection updates the selection based on the new cursor position. -func (mv *MapViewer) updateSelection(nselectedX, nSelectedY int) { - cellWidth, cellHeight := mv.calculateCellDimensions() - difX := int(math.Abs(float64(nselectedX - mv.selectedX))) - difY := int(math.Abs(float64(nSelectedY - mv.SelectedY))) - - topLeftX := float32(min(mv.selectedX, nselectedX)) * cellWidth - topLeftY := float32(mv.numRows-1-max(mv.SelectedY, nSelectedY)) * cellHeight - - mv.updateCursorPositionAndSize(topLeftX, topLeftY, float32(difX+1)*cellWidth, float32(difY+1)*cellHeight) -} - -// updateCursorPositionAndSize updates the cursor's position and size on the screen. -func (mv *MapViewer) updateCursorPositionAndSize(topLeftX, topLeftY, width, height float32) { - mv.selectionRect.Resize(fyne.NewSize(width+2, height+1)) - mv.selectionRect.Move(fyne.NewPos(topLeftX-1, topLeftY)) -} - // handleFocusAndInputBuffer focuses the MapViewer and clears the input buffer if necessary. func (mv *MapViewer) handleFocusAndInputBuffer() { if mv.inputBuffer.Len() > 0 { @@ -198,44 +182,13 @@ func (mv *MapViewer) handleFocusAndInputBuffer() { } } -// finalizeSelection finalizes the selection process. +// finalizeSelection ends a drag. The selection was already built live during +// MouseMoved, so this just snaps to the release position and stops selecting. func (mv *MapViewer) finalizeSelection(eventPos fyne.Position) { // log.Println("finalizeSelection") mv.selecting = false - - nselectedX, nSelectedY := mv.calculateSelectionBounds(eventPos) - mv.updateSelection(nselectedX, nSelectedY) - - topLeftX := min(mv.selectedX, nselectedX) - bottomRightX := max(mv.selectedX, nselectedX) - topLeftY := min(mv.SelectedY, nSelectedY) - bottomRightY := max(mv.SelectedY, nSelectedY) - - // For Ctrl selections, we don't want to clear existing selections - if mv.lastModifier != fyne.KeyModifierControl { - mv.selectedCells = make([]int, 0, (bottomRightX-topLeftX+1)*(bottomRightY-topLeftY+1)) - } - - selectedColor := theme.Color(theme.ColorNameForegroundOnPrimary) - for y := topLeftY; y <= bottomRightY; y++ { - for x := topLeftX; x <= bottomRightX; x++ { - zIndex := y*mv.numColumns + x - if mv.lastModifier == fyne.KeyModifierControl { - // For Ctrl, toggle selection - if index := slices.Index(mv.selectedCells, zIndex); index != -1 { - mv.selectedCells = append(mv.selectedCells[:index], mv.selectedCells[index+1:]...) - mv.zDataRects[zIndex].FillColor = mv.zDataRects[zIndex].StrokeColor - } else { - mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = selectedColor - } - } else { - mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = selectedColor - } - mv.zDataRects[zIndex].Refresh() - } - } + nx, ny := mv.calculateSelectionBounds(eventPos) + mv.selectRegion(nx, ny) } func (mv *MapViewer) showPopupMenu(pos fyne.Position) { @@ -250,6 +203,9 @@ func (mv *MapViewer) showPopupMenu(pos fyne.Position) { fyne.NewMenuItem("Paste", func() { mv.paste() }), + fyne.NewMenuItem("Paste here", func() { + mv.pasteHere() + }), fyne.NewMenuItem("Smooth", func() { mv.smooth() }), From a51146e18c723d8bd5f61b3af3d118dde10e0674 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 15 Jun 2026 21:20:27 +0200 Subject: [PATCH 47/93] fancier meshgrid --- pkg/widgets/meshgrid/meshgrid_axis.go | 373 +++++++++++++++---- pkg/widgets/meshgrid/meshgrid_draw.go | 4 +- pkg/widgets/meshgrid/meshgrid_poly.go | 7 +- pkg/widgets/meshgrid/meshgrid_render_test.go | 81 +++- pkg/widgets/meshgrid/meshgrid_widget.go | 66 +++- 5 files changed, 432 insertions(+), 99 deletions(-) diff --git a/pkg/widgets/meshgrid/meshgrid_axis.go b/pkg/widgets/meshgrid/meshgrid_axis.go index bcb8c626..e7525ded 100644 --- a/pkg/widgets/meshgrid/meshgrid_axis.go +++ b/pkg/widgets/meshgrid/meshgrid_axis.go @@ -3,6 +3,8 @@ package meshgrid import ( "image" "image/color" + "math" + "strconv" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -11,107 +13,322 @@ import ( "golang.org/x/image/math/fixed" ) -// initAxisObjects creates the axis-indicator overlay (lines and labels) used -// by the shader and polygon backends; the image backend instead draws the -// indicator into its raster (drawAxisIndicator below). +// T7Suite-style axis scales: instead of a small orientation gizmo in the +// corner, the X (column), Y (row) and Z (value) scales are drawn along three +// edges of the mesh's own bounding box, labeled with the table's real axis +// values. The geometry is computed once per refresh in original (untransformed) +// coordinates, projected with projectOriginal, and consumed either as canvas +// overlay objects (shader/polygon backends) or rasterized into the image +// (image backend). Both paths share computeAxisGeometry so the projection and +// the label thinning live in one place. + +const ( + axisTextSize float32 = 11 + // axisCharW is a rough per-character width at axisTextSize, used only to + // decide how many tick labels fit before they overlap. + axisCharW = 7.0 + // axisEdgeOffset lifts the whole axis (line, ticks, labels) off the mesh + // edge so it doesn't sit directly on the surface. The distances below are + // measured outward from this shifted line. + axisEdgeOffset = 8.0 + axisTickLen = 7.0 // tick-mark length, px, drawn outward from the edge + axisLabelPad = 24.0 // outward distance of a value label's center from the lifted edge + axisNameGap = 16.0 // extra clearance of the axis-name label past the value labels + // zDivisions is the number of intervals on the vertical value scale, so it + // shows zDivisions+1 ticks from zmin to zmax. + zDivisions = 5 +) + +// axisSeg is one screen-space line: a box edge or a tick mark. +type axisSeg struct { + x1, y1, x2, y2 float32 + col color.RGBA +} + +// axisLabel is one screen-space text placed centered on (x, y). +type axisLabel struct { + text string + x, y float32 + col color.RGBA +} + +// initAxisObjects allocates the canvas pools for the overlay backends. The +// pools are sized to the worst case (no thinning) so the per-frame update only +// has to position the active entries and hide the rest; nothing here needs a +// driver, so it is safe from the constructor and from tests. func (m *Meshgrid) initAxisObjects() { - axisColors := [3]color.RGBA{ - {R: 255, A: 255}, // X red - {G: 255, A: 255}, // Y green - {B: 255, A: 255}, // Z blue - } - labels := [3]string{m.xlabel, m.ylabel, m.zlabel} - for i := range m.axisLines { - m.axisLines[i] = &canvas.Line{StrokeColor: axisColors[i], StrokeWidth: 1} - t := canvas.NewText(labels[i], axisColors[i]) - t.TextSize = 11 - m.axisLabels[i] = t + // Worst case: every column/row/Z tick visible, plus a small headroom for + // the always-included last tick on each axis. + maxTicks := m.cols + m.rows + (zDivisions + 1) + 3 + maxLines := 3 + maxTicks // three labeled edges + one tick mark each + maxTexts := maxTicks + 3 // one value label each + three axis names + + m.axisLinePool = make([]*canvas.Line, maxLines) + for i := range m.axisLinePool { + m.axisLinePool[i] = &canvas.Line{StrokeWidth: 1, Hidden: true} + } + m.axisTextPool = make([]*canvas.Text, maxTexts) + for i := range m.axisTextPool { + t := canvas.NewText("", color.White) + t.TextSize = axisTextSize + t.Hidden = true + m.axisTextPool[i] = t } } -// updateAxisObjects mirrors drawAxisIndicator with canvas primitives. +// updateAxisObjects drives the canvas pools from the current axis geometry: +// active entries get positioned and shown, the rest hidden. func (m *Meshgrid) updateAxisObjects() { - const indicatorScale = 60.0 - origin := fyne.NewPos(60, m.size.Height-m.size.Height/4) - - r := m.cameraRotation - ends := [3][3]float64{ - r.MultiplyVector([3]float64{indicatorScale, 0, 0}), - r.MultiplyVector([3]float64{0, -indicatorScale, 0}), - r.MultiplyVector([3]float64{0, 0, indicatorScale}), - } - for i, e := range ends { - end := fyne.NewPos(origin.X+float32(e[0]), origin.Y+float32(e[1])) - m.axisLines[i].Position1 = origin - m.axisLines[i].Position2 = end - // Text positions by its top-left; the image path draws at the - // baseline, so nudge up to roughly match. - m.axisLabels[i].Move(fyne.NewPos(end.X+5, end.Y-8)) + segs, labels := m.computeAxisGeometry() + + for i, l := range m.axisLinePool { + if i < len(segs) { + s := segs[i] + l.StrokeColor = s.col + l.Position1 = fyne.NewPos(s.x1, s.y1) + l.Position2 = fyne.NewPos(s.x2, s.y2) + if l.Hidden { + l.Show() + } + } else if !l.Hidden { + l.Hide() + } + } + + for i, t := range m.axisTextPool { + if i < len(labels) { + lb := labels[i] + t.Text = lb.text + t.Color = lb.col + sz := t.MinSize() + t.Resize(sz) + // canvas.Text positions by its top-left; center it on the anchor. + t.Move(fyne.NewPos(lb.x-sz.Width/2, lb.y-sz.Height/2)) + if t.Hidden { + t.Show() + } + } else if !t.Hidden { + t.Hide() + } } } -func (m *Meshgrid) drawAxisIndicator(img *image.RGBA) { - cornerOffset := 60.0 - indicatorScale := 60.0 +// drawAxisScales rasterizes the same axis geometry into the image backend's +// frame, drawing edges/ticks as Bresenham lines and labels with the bitmap font. +func (m *Meshgrid) drawAxisScales(img *image.RGBA) { + segs, labels := m.computeAxisGeometry() + for _, s := range segs { + drawBresenhamLine(img, int(s.x1), int(s.y1), int(s.x2), int(s.y2), s.col, s.col) + } + for _, l := range labels { + // basicfont.Face7x13 is 7px wide per glyph; the drawer anchors at the + // baseline, so nudge so the label reads roughly centered on its anchor. + w := len(l.text) * 7 + m.drawText(img, l.text, int(l.x)-w/2, int(l.y)+4, l.col) + } +} - // Create the indicator at corner position - origin := Vertex{ - X: cornerOffset, - Y: float64(m.size.Height) - float64(m.size.Height/4), +// computeAxisGeometry builds the screen-space edges, tick marks and labels for +// the three axis scales. It reuses the scratch slices and returns them. +func (m *Meshgrid) computeAxisGeometry() ([]axisSeg, []axisLabel) { + segs := m.scratchAxisSegs[:0] + labels := m.scratchAxisLabels[:0] + if m.size.Width <= 0 || m.size.Height <= 0 { + m.scratchAxisSegs, m.scratchAxisLabels = segs, labels + return segs, labels } - // Instead of using just the rotation matrix, we should use the same - // camera transformation that's applied to the mesh vertices + cw, ch := float64(m.cellWidth), float64(m.cellHeight) + xMax := float64(m.cols) * cw + yMin, yMax := ch, float64(m.rows+1)*ch + zTop := m.depth + + // The four floor corners (z=0). The front-most (largest screen Y) carries + // the X and Y scales on its two outgoing floor edges. The vertical Z scale + // goes on the most side-on of the remaining corners (the leftmost), so its + // edge rides the silhouette in open space instead of being buried behind + // the surface the way the back corner was. + type pt struct{ ox, oy float64 } + floor := [4]pt{{0, yMin}, {xMax, yMin}, {0, yMax}, {xMax, yMax}} + frontIdx := 0 + frontY := float32(math.Inf(-1)) + for i, c := range floor { + _, sy, _ := m.projectOriginal(c.ox, c.oy, 0) + if sy > frontY { + frontY, frontIdx = sy, i + } + } + zIdx := -1 + zX := float32(math.Inf(1)) + for i, c := range floor { + if i == frontIdx { + continue + } + sx, _, _ := m.projectOriginal(c.ox, c.oy, 0) + if sx < zX { + zX, zIdx = sx, i + } + } + front, zCorner := floor[frontIdx], floor[zIdx] - // Use the camera's view matrix (same as in updateVertexPositions) - viewMatrix := m.cameraRotation + // "Inside" reference: screen centroid of all eight box corners. Labels are + // pushed outward from it so they sit outside the surface. + var sumX, sumY float32 + for _, oz := range [2]float64{0, zTop} { + for _, c := range floor { + sx, sy, _ := m.projectOriginal(c.ox, c.oy, oz) + sumX += sx + sumY += sy + } + } + inside := fyne.NewPos(sumX/8, sumY/8) - // Transform axis endpoints using the camera's view matrix - transformedX := viewMatrix.MultiplyVector([3]float64{indicatorScale, 0, 0}) - transformedY := viewMatrix.MultiplyVector([3]float64{0, -indicatorScale, 0}) // Negative Y scale - transformedZ := viewMatrix.MultiplyVector([3]float64{0, 0, indicatorScale}) + xCol := color.RGBA{R: 255, G: 90, B: 90, A: 255} + yCol := color.RGBA{R: 90, G: 220, B: 90, A: 255} + zCol := color.RGBA{R: 120, G: 170, B: 255, A: 255} - // Calculate endpoints - xEnd := Vertex{ - X: origin.X + transformedX[0], - Y: origin.Y + transformedX[1], + // X scale: front edge at constant Y, value per column at the cell center. + nx := 0 + if len(m.xData) >= m.cols { + nx = m.cols } - yEnd := Vertex{ - X: origin.X + transformedY[0], - Y: origin.Y + transformedY[1], + segs, labels = m.appendAxis(segs, labels, inside, xCol, + [3]float64{0, front.oy, 0}, [3]float64{xMax, front.oy, 0}, m.xlabel, nx, + func(k int) [3]float64 { return [3]float64{(float64(k) + 0.5) * cw, front.oy, 0} }, + func(k int) string { return strconv.FormatFloat(m.xData[k], 'f', m.xPrec, 64) }) + + // Y scale: side edge at constant X. Data row 0 sits at the high-Y (far) + // end, so row k maps to Oy = (rows+0.5-k)*ch. + ny := 0 + if len(m.yData) >= m.rows { + ny = m.rows } - zEnd := Vertex{ - X: origin.X + transformedZ[0], - Y: origin.Y + transformedZ[1], + segs, labels = m.appendAxis(segs, labels, inside, yCol, + [3]float64{front.ox, yMin, 0}, [3]float64{front.ox, yMax, 0}, m.ylabel, ny, + func(k int) [3]float64 { return [3]float64{front.ox, (float64(m.rows) + 0.5 - float64(k)) * ch, 0} }, + func(k int) string { return strconv.FormatFloat(m.yData[k], 'f', m.yPrec, 64) }) + + // Z scale: vertical edge at the side corner, zmin..zmax mapped to 0..zTop. + nz := 0 + if m.zrange > 0 && zTop > 0 { + nz = zDivisions + 1 } + segs, labels = m.appendAxis(segs, labels, inside, zCol, + [3]float64{zCorner.ox, zCorner.oy, 0}, [3]float64{zCorner.ox, zCorner.oy, zTop}, m.zlabel, nz, + func(k int) [3]float64 { return [3]float64{zCorner.ox, zCorner.oy, float64(k) / zDivisions * zTop} }, + func(k int) string { + return strconv.FormatFloat(m.zmin+float64(k)/zDivisions*m.zrange, 'f', m.zPrec, 64) + }) - // Draw the axes - ox, oy := int(origin.X), int(origin.Y) + m.scratchAxisSegs, m.scratchAxisLabels = segs, labels + return segs, labels +} + +// appendAxis appends one labeled axis: the edge line from p0 to p1 (original +// coords) lifted off the mesh by axisEdgeOffset, the axis name centered on the +// middle of that edge, and a thinned set of tick marks plus value labels at the +// original-space points returned by pointAt(k), k in [0,n). Everything is +// offset along one outward edge normal so the ticks stay parallel and the whole +// scale sits clear of the surface. The name rides the middle so it doesn't +// collide with the corner tick values. +func (m *Meshgrid) appendAxis(segs []axisSeg, labels []axisLabel, inside fyne.Position, col color.RGBA, + p0, p1 [3]float64, name string, n int, pointAt func(int) [3]float64, valueAt func(int) string, +) ([]axisSeg, []axisLabel) { + sx0, sy0, _ := m.projectOriginal(p0[0], p0[1], p0[2]) + sx1, sy1, _ := m.projectOriginal(p1[0], p1[1], p1[2]) + + // One outward normal for the whole edge keeps the ticks parallel. The mesh + // transform is affine, so the tick points stay collinear with the edge and + // land on the offset line after the same shift. + nx, ny := edgeOutwardNormal(sx0, sy0, sx1, sy1, inside) + ox, oy := nx*axisEdgeOffset, ny*axisEdgeOffset + + ex0, ey0 := sx0+ox, sy0+oy + ex1, ey1 := sx1+ox, sy1+oy + segs = append(segs, axisSeg{ex0, ey0, ex1, ey1, col}) + + if name != "" { + // Sit the name on the edge midpoint, just past the value-label band so + // it never overlaps the corner ticks (and tracks axisLabelPad changes). + mx, my := (sx0+sx1)*0.5, (sy0+sy1)*0.5 + nameDist := float32(axisEdgeOffset + axisLabelPad + axisNameGap) + labels = append(labels, axisLabel{name, mx + nx*nameDist, my + ny*nameDist, col}) + } + if n <= 0 { + return segs, labels + } - // X axis (red) - ex, ey := int(xEnd.X), int(xEnd.Y) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 255, G: 0, B: 0, A: 255}, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + // Thin labels to the count that fits along the projected edge length. + L := math.Hypot(float64(ex1-ex0), float64(ey1-ey0)) + maxChars := 1 + for k := 0; k < n; k++ { + if c := len(valueAt(k)); c > maxChars { + maxChars = c + } + } + step := axisLabelStep(n, L, maxChars) - m.drawText(img, m.xlabel, - int(ex+5), int(ey), - color.RGBA{R: 255, G: 0, B: 0, A: 255}) + appendTick := func(k int) { + p := pointAt(k) + sx, sy, _ := m.projectOriginal(p[0], p[1], p[2]) + bx, by := sx+ox, sy+oy // tick base sits on the lifted edge line + segs = append(segs, axisSeg{bx, by, bx + nx*axisTickLen, by + ny*axisTickLen, col}) + labels = append(labels, axisLabel{valueAt(k), bx + nx*axisLabelPad, by + ny*axisLabelPad, col}) + } + for k := 0; k < n; k += step { + appendTick(k) + } + // Always label the last tick so the axis' full extent is annotated. + if last := n - 1; last%step != 0 { + appendTick(last) + } + return segs, labels +} - // Y axis (green) - ey = int(yEnd.Y) - ex = int(yEnd.X) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 0, G: 255, B: 0, A: 255}, color.RGBA{R: 0, G: 255, B: 0, A: 255}) +// edgeOutwardNormal returns the unit screen-space normal of edge (s0->s1) that +// points away from the inside reference, used to lift the axis and its ticks +// outward in one consistent direction. +func edgeOutwardNormal(sx0, sy0, sx1, sy1 float32, inside fyne.Position) (float32, float32) { + dx, dy := float64(sx1-sx0), float64(sy1-sy0) + L := math.Hypot(dx, dy) + if L < 1e-6 { + return outward(sx0, sy0, inside) // degenerate edge: fall back to radial + } + nx, ny := -dy/L, dx/L + mx, my := float64(sx0+sx1)*0.5, float64(sy0+sy1)*0.5 + if nx*(mx-float64(inside.X))+ny*(my-float64(inside.Y)) < 0 { + nx, ny = -nx, -ny + } + return float32(nx), float32(ny) +} - m.drawText(img, m.ylabel, - int(ex+5), int(ey), - color.RGBA{R: 0, G: 255, B: 0, A: 255}) +// axisLabelStep returns the index stride that keeps drawn labels at least one +// label-width apart along a projected edge of screen length L. +func axisLabelStep(n int, L float64, maxChars int) int { + if n <= 1 || L <= 0 { + return 1 + } + minSpacing := float64(maxChars)*axisCharW + 8 + fit := int(L / minSpacing) + if fit < 1 { + fit = 1 + } + step := (n + fit - 1) / fit + if step < 1 { + step = 1 + } + return step +} - // Z axis (blue) - ex = int(zEnd.X) - ey = int(zEnd.Y) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 0, G: 0, B: 255, A: 255}, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - m.drawText(img, m.zlabel, - int(ex+5), int(ey), - color.RGBA{R: 0, G: 0, B: 255, A: 255}) +// outward returns the unit screen-space direction from the inside reference +// toward (px, py), i.e. the direction to push a label so it clears the surface. +func outward(px, py float32, inside fyne.Position) (float32, float32) { + dx, dy := float64(px-inside.X), float64(py-inside.Y) + d := math.Hypot(dx, dy) + if d < 1e-3 { + return 0, 1 + } + return float32(dx / d), float32(dy / d) } func (m *Meshgrid) drawText(img *image.RGBA, text string, x, y int, col color.RGBA) { diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index 47db30fc..49abf247 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -89,7 +89,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { if mode != RenderModeWireframe { m.drawSurface(img, projX, projY, vertCol, mode == RenderModeSolidWireframe) - m.drawAxisIndicator(img) + m.drawAxisScales(img) return img } @@ -150,7 +150,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { drawBresenhamLine(img, s.x1, s.y1, s.x2, s.y2, c1, c2) } - m.drawAxisIndicator(img) + m.drawAxisScales(img) return img } diff --git a/pkg/widgets/meshgrid/meshgrid_poly.go b/pkg/widgets/meshgrid/meshgrid_poly.go index fd78cb3c..7728d1cb 100644 --- a/pkg/widgets/meshgrid/meshgrid_poly.go +++ b/pkg/widgets/meshgrid/meshgrid_poly.go @@ -213,8 +213,11 @@ func (m *Meshgrid) updatePolygons() { objs = append(objs, m.polys[q.i*m.cols+q.j]) } m.updateAxisObjects() - for i := range m.axisLines { - objs = append(objs, m.axisLines[i], m.axisLabels[i]) + for _, l := range m.axisLinePool { + objs = append(objs, l) + } + for _, t := range m.axisTextPool { + objs = append(objs, t) } objs = append(objs, m.cursor) m.polyObjects = objs diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index 3b04bf0e..5dad007e 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -21,7 +21,8 @@ func testGrid(t testing.TB) *Meshgrid { values[i*cols+j] = 100 / (1 + x*x + y*y) // central hump } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } @@ -29,6 +30,19 @@ func testGrid(t testing.TB) *Meshgrid { return m } +// axisValues builds simple monotonic column/row axis ticks for tests. +func axisValues(cols, rows int) (xData, yData []float64) { + xData = make([]float64, cols) + for j := range xData { + xData[j] = float64(j * 500) + } + yData = make([]float64, rows) + for i := range yData { + yData[i] = float64(i * 10) + } + return xData, yData +} + // TestRenderRotated renders an asymmetric surface (tall corner spike) from // four yaw angles so painter's-order mistakes show up as the spike being // overdrawn by cells that are behind it. @@ -46,7 +60,8 @@ func TestRenderRotated(t *testing.T) { values[i*cols+j] = 10 + 100/(1+x*x+y*y) // spike near one corner } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } @@ -82,6 +97,65 @@ func TestCursorScreenPosition(t *testing.T) { } } +// the axis-scale geometry must contain the three labeled box edges, the axis +// names and the real first/last tick values for each axis, all projected inside +// a sane neighborhood of the widget. +func TestAxisGeometry(t *testing.T) { + m := testGrid(t) // 16x16, xData[j]=j*500, yData[i]=i*10, "RPM"/"Load"/"Fuel" + segs, labels := m.computeAxisGeometry() + + if len(segs) < 3 { + t.Fatalf("got %d axis segments, want at least the 3 box edges", len(segs)) + } + + have := make(map[string]bool, len(labels)) + for _, l := range labels { + have[l.text] = true + } + // axis names plus the first/last X and Y tick values, which appendAxis + // always labels regardless of thinning + for _, want := range []string{"RPM", "Load", "Fuel", "7500", "150"} { + if !have[want] { + t.Errorf("axis labels missing %q; have %v", want, have) + } + } + + // every label and tick endpoint must land near the widget (a broad sanity + // bound: outward-offset labels may sit a little outside the frame) + const margin = 200 + for _, l := range labels { + if l.x < -margin || l.x > m.size.Width+margin || l.y < -margin || l.y > m.size.Height+margin { + t.Errorf("label %q at (%v,%v) far outside widget %v", l.text, l.x, l.y, m.size) + } + } +} + +// updateAxisObjects must drive the canvas pools without a driver panic and +// leave at least the three edges and three axis names visible. +func TestUpdateAxisObjectsOverlay(t *testing.T) { + test.NewApp() // canvas.Text.MinSize needs a (test) driver + m := testGrid(t) + m.updateAxisObjects() + + visibleLines, visibleTexts := 0, 0 + for _, l := range m.axisLinePool { + if !l.Hidden { + visibleLines++ + } + } + for _, tx := range m.axisTextPool { + if !tx.Hidden { + visibleTexts++ + } + } + if visibleLines < 3 { + t.Errorf("want at least the 3 box-edge lines visible, got %d", visibleLines) + } + if visibleTexts < 3 { + t.Errorf("want at least the 3 axis-name labels visible, got %d", visibleTexts) + } +} + func BenchmarkDrawSurface(b *testing.B) { m := testGrid(b) m.renderMode = RenderModeSolidWireframe @@ -129,7 +203,8 @@ func TestPolygonDegenerateQuads(t *testing.T) { values[i*cols+j] = float64((i / 4) * 100) } } - m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, colors.ModeNormal, backendFromEnv()) + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) if err != nil { t.Fatal(err) } diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index a637a94f..9c330c0a 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -76,6 +76,11 @@ type Meshgrid struct { scratchLines []lineSegment scratchQuads []quadRef + // Scratch geometry for the axis-scale overlay, rebuilt each refresh by + // computeAxisGeometry and consumed by either the canvas pools or the raster. + scratchAxisSegs []axisSeg + scratchAxisLabels []axisLabel + backend RenderBackend // Shader backend (meshgrid_shader.go): the whole mesh in one object. @@ -88,10 +93,13 @@ type Meshgrid struct { scratchFX []float32 scratchFY []float32 - // Axis-indicator overlay shared by the shader and polygon backends (the - // image backend draws its own into the raster). - axisLines [3]*canvas.Line - axisLabels [3]*canvas.Text + // Axis-scale overlay shared by the shader and polygon backends (the image + // backend draws the same geometry into the raster). The line pool holds the + // three labeled box edges plus a tick mark per visible tick; the text pool + // holds a value label per visible tick plus the three axis-name labels. + // Both pools are sized to the worst case at init and Show/Hide per frame. + axisLinePool []*canvas.Line + axisTextPool []*canvas.Text renderMode RenderMode @@ -125,6 +133,14 @@ type Meshgrid struct { xlabel, ylabel, zlabel string + // Axis tick values (one per column / row) and their display precision, + // used to draw the T7Suite-style scales along the mesh edges. xData has + // cols entries, yData has rows; either may be nil/short, in which case that + // axis' value labels are skipped. zPrec formats the height (Z) scale, whose + // values run from zmin to zmax. + xData, yData []float64 + xPrec, yPrec, zPrec int + refreshPending bool colorMode colors.ColorBlindMode @@ -144,7 +160,10 @@ var ( const cursorRadius = 6 // NewMeshgrid creates a new Meshgrid given width, height, depth and spacing. -func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, colorBlindMode colors.ColorBlindMode, backend RenderBackend) (*Meshgrid, error) { +// xData/yData carry the per-column/row axis tick values (with xPrec/yPrec/zPrec +// display precision) used to draw the axis scales along the mesh edges; either +// may be nil to skip that axis' value labels. +func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, xData, yData []float64, xPrec, yPrec, zPrec int, colorBlindMode colors.ColorBlindMode, backend RenderBackend) (*Meshgrid, error) { cols = max(1, cols) rows = max(1, rows) // Check if the provided values slice has the correct number of elements @@ -176,6 +195,12 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int ylabel: ylabel, zlabel: zlabel, + xData: xData, + yData: yData, + xPrec: xPrec, + yPrec: yPrec, + zPrec: zPrec, + colorMode: colorBlindMode, backend: backend, @@ -492,6 +517,23 @@ func (m *Meshgrid) updateVertexPositions() { } } +// projectOriginal maps a point in the mesh's original (untransformed) coordinate +// space to screen pixels, applying the same camera transform as +// updateVertexPositions and the same screen mapping as cursorScreenPosition / +// updatePolygons. It returns the screen x/y and the view-space depth (z), where +// a larger z is nearer the viewer. This lets the axis overlay project arbitrary +// box-edge points, not just stored vertices. +func (m *Meshgrid) projectOriginal(ox, oy, oz float64) (sx, sy, vz float32) { + vx := (ox - m.centerX) * m.scale + vy := (oy - m.centerY) * m.scale + vz3 := (oz - m.centerZ) * m.scale + r := m.cameraRotation + x := r[0][0]*vx + r[0][1]*vy + r[0][2]*vz3 - m.cameraPosition[0] + y := r[1][0]*vx + r[1][1]*vy + r[1][2]*vz3 - m.cameraPosition[1] + z := r[2][0]*vx + r[2][1]*vy + r[2][2]*vz3 - m.cameraPosition[2] + return float32(float64(m.size.Width)*0.5 + x), float32(float64(m.size.Height)*0.5 + y), float32(z) +} + // SetFloat64 updates a single cell value. The whole mesh is rebuilt since a // new value can shift zmin/zmax and with it every vertex's normalized height. func (m *Meshgrid) SetFloat64(idx int, value float64) { @@ -620,13 +662,6 @@ func (m *Meshgrid) throttledRefresh() { } func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { - if m.backend != BackendImage { - // Text measuring needs a driver, so the labels can't be sized in the - // constructor (tests build widgets without an app). - for _, t := range m.axisLabels { - t.Resize(t.MinSize()) - } - } return &meshgridRenderer{MG: m} } @@ -670,8 +705,11 @@ func (m *meshgridRenderer) Objects() []fyne.CanvasObject { if m.objects == nil { m.MG.updateAxisObjects() objs := []fyne.CanvasObject{m.MG.shader} - for i := range m.MG.axisLines { - objs = append(objs, m.MG.axisLines[i], m.MG.axisLabels[i]) + for _, l := range m.MG.axisLinePool { + objs = append(objs, l) + } + for _, t := range m.MG.axisTextPool { + objs = append(objs, t) } m.objects = append(objs, m.MG.cursor) } From 1c641d86f971cbab9682ef073605a8f68525d1e1 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 15 Jun 2026 21:20:43 +0200 Subject: [PATCH 48/93] better copy paste on mapviewer --- pkg/widgets/mapviewer/mapviewer.go | 5 ++++ pkg/widgets/mapviewer/mapviewer_keyhandler.go | 28 +++++++++++++++---- pkg/widgets/mapviewer/mapviewer_mouse.go | 20 ++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index c5336782..8eb82724 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -259,6 +259,11 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.cfg.ZData, mv.numColumns, mv.numRows, + mv.cfg.XData, + mv.cfg.YData, + mv.cfg.XPrecision, + mv.cfg.YPrecision, + mv.cfg.ZPrecision, mv.colorMode, mv.cfg.MeshRenderer, ) diff --git a/pkg/widgets/mapviewer/mapviewer_keyhandler.go b/pkg/widgets/mapviewer/mapviewer_keyhandler.go index 94958acd..772995ee 100644 --- a/pkg/widgets/mapviewer/mapviewer_keyhandler.go +++ b/pkg/widgets/mapviewer/mapviewer_keyhandler.go @@ -25,7 +25,9 @@ func (mv *MapViewer) TypedShortcut(shortcut fyne.Shortcut) { case "Copy": mv.copy() case "Paste": - mv.paste() + // Ctrl+V pastes at the current cursor/selection position rather than + // the coordinates the data was originally copied from. + mv.pasteHere() } } @@ -99,8 +101,10 @@ func (mv *MapViewer) parseClipboardCells(cb string) []clipboardCell { } // applyPaste writes the parsed cells into ZData, offset by shiftX/shiftY. Cells -// that fall outside the map bounds are skipped. -func (mv *MapViewer) applyPaste(cells []clipboardCell, shiftX, shiftY int) { +// that fall outside the map bounds are skipped. When bounds is non-nil, only +// destination cells present in the set are written, so the paste stays inside +// the current selection. +func (mv *MapViewer) applyPaste(cells []clipboardCell, shiftX, shiftY int, bounds map[int]struct{}) { changed := false for _, c := range cells { x := c.x + shiftX @@ -112,6 +116,11 @@ func (mv *MapViewer) applyPaste(cells []clipboardCell, shiftX, shiftY int) { if index < 0 || index >= len(mv.cfg.ZData) { continue } + if bounds != nil { + if _, ok := bounds[index]; !ok { + continue + } + } mv.cfg.ZData[index] = c.value changed = true } @@ -126,7 +135,7 @@ func (mv *MapViewer) paste() { return } cells := mv.parseClipboardCells(fyne.CurrentApp().Clipboard().Content()) - mv.applyPaste(cells, 0, 0) + mv.applyPaste(cells, 0, 0, nil) } // pasteHere writes the clipboard with its anchor cell landing on the currently @@ -152,7 +161,16 @@ func (mv *MapViewer) pasteHere() { maxY = c.y } } - mv.applyPaste(cells, mv.selectedX-minX, mv.SelectedY-maxY) + // When more than a single cell is selected, confine the paste to that + // selection so values can't spill outside the highlighted block. + var bounds map[int]struct{} + if len(mv.selectedCells) > 1 { + bounds = make(map[int]struct{}, len(mv.selectedCells)) + for _, cell := range mv.selectedCells { + bounds[cell] = struct{}{} + } + } + mv.applyPaste(cells, mv.selectedX-minX, mv.SelectedY-maxY, bounds) } func (mv *MapViewer) smooth() { diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index e07055f6..60a5ebf4 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -26,7 +26,7 @@ func (mv *MapViewer) MouseOut() {} // MouseMoved is called when the mouse is moved over the map viewer. func (mv *MapViewer) MouseMoved(event *desktop.MouseEvent) { - //log.Println("MouseMoved", event) + // log.Println("MouseMoved", event) if !mv.selecting { return } @@ -47,7 +47,7 @@ func (mv *MapViewer) MouseMoved(event *desktop.MouseEvent) { // MouseDown is called when a mouse button is pressed. func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { - //log.Println("MouseDown") + // log.Println("MouseDown") mv.lastModifier = event.Modifier if mv.cfg.OnMouseDown != nil { mv.cfg.OnMouseDown() @@ -161,8 +161,8 @@ func (mv *MapViewer) calculateCellDimensions() (float32, float32) { // calculateSelectionBounds computes the bounding box of the selection area. func (mv *MapViewer) calculateSelectionBounds(eventPos fyne.Position) (int, int) { cellWidth, cellHeight := mv.calculateCellDimensions() - //xAxisOffset := mv.yAxisLabelContainer.Size().Width - //yAxisOffset := mv.xAxisLabelContainer.Size().Height + // xAxisOffset := mv.yAxisLabelContainer.Size().Width + // yAxisOffset := mv.xAxisLabelContainer.Size().Height // Adjust for inner view position relative to the parent container // This accounts for any extra padding or layout adjustments @@ -199,13 +199,19 @@ func (mv *MapViewer) showPopupMenu(pos fyne.Position) { }), ) if mv.cfg.Editable { - menu.Items = append(menu.Items, - fyne.NewMenuItem("Paste", func() { + + pasteMenu := fyne.NewMenuItem("Paste", nil) + pasteMenu.ChildMenu = fyne.NewMenu("Paste Options", + fyne.NewMenuItem("At original position", func() { mv.paste() }), - fyne.NewMenuItem("Paste here", func() { + fyne.NewMenuItem("At currently selected location", func() { mv.pasteHere() }), + ) + + menu.Items = append(menu.Items, + pasteMenu, fyne.NewMenuItem("Smooth", func() { mv.smooth() }), From 84653f1419212633e731c87a8c0db4007c238a00 Mon Sep 17 00:00:00 2001 From: roffe Date: Tue, 16 Jun 2026 00:12:11 +0200 Subject: [PATCH 49/93] save --- go.mod | 2 +- go.sum | 4 +- main_linux.go | 8 +- pkg/assets/WHATSNEW.md | 1 + pkg/common/common.go | 9 + pkg/native/dialog_linux.go | 51 +- pkg/native/dialog_windows.go | 94 +- pkg/native/native.go | 5 +- pkg/widgets/mapviewer/mapviewer_mouse.go | 7 + pkg/widgets/matrixbuilder/matrixbuilder.go | 978 +++++++++++++++++++++ pkg/widgets/plotter/plotter.go | 12 +- pkg/widgets/widget.go | 27 + pkg/widgets/widget_linux.go | 26 +- pkg/widgets/widget_windows.go | 5 + pkg/windows/mainWindow_menu.go | 73 +- pkg/windows/mainWindow_toolbar.go | 15 + 16 files changed, 1249 insertions(+), 68 deletions(-) create mode 100644 pkg/widgets/matrixbuilder/matrixbuilder.go diff --git a/go.mod b/go.mod index ebca1ec2..77979766 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7 + fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 diff --git a/go.sum b/go.sum index 876b9002..4299a795 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7 h1:TNADRWLV+A9auHNWACCtB6l/5vBKBnivf/tm9OR1+C0= -fyne.io/fyne/v2 v2.7.5-0.20260613155404-ebf0c95ebbb7/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7 h1:2auph/jcheGuecJjdA5JahXIFFeLjglROzorkhmLxiU= +fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= diff --git a/main_linux.go b/main_linux.go index ba83a19e..b40bb54e 100644 --- a/main_linux.go +++ b/main_linux.go @@ -24,6 +24,7 @@ func runFileChild() { } var path string + var paths []string switch req.Op { case "select_folder": path, err = native.OpenFolderDialog(req.Title) @@ -37,6 +38,11 @@ func runFileChild() { Description: req.Desc, Extensions: req.Exts, }) + case "open_files": + paths, err = native.OpenFilesDialog(req.Title, native.FileFilter{ + Description: req.Desc, + Extensions: req.Exts, + }) case "quit": return default: @@ -44,7 +50,7 @@ func runFileChild() { return } - resp := native.FileResponse{Path: path} + resp := native.FileResponse{Path: path, Paths: paths} if err != nil { resp.Err = err.Error() } diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 85bb4f81..e78a686b 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -20,6 +20,7 @@ - Dial and Dual Dial now uses shaders to draw the faces, dials and pips - Improved cell selection in mapviewer - Improved copy paste in mapviewer, added paste here function +- Added a Matrix builder from logfiles # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/common/common.go b/pkg/common/common.go index 20b591cf..27e35eef 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -89,6 +89,15 @@ func GetLayoutPath() (string, error) { return layoutPath, createDirIfNotExists(layoutPath) } +func GetMatrixBuilderPath() (string, error) { + dir, err := GetUserHomeDir() + if err != nil { + return "", err + } + matrixBuilderPath := GetComponentPath(dir, "matrixbuilder") + return matrixBuilderPath, createDirIfNotExists(matrixBuilderPath) +} + func GetBinPath() (string, error) { dir, err := GetUserHomeDir() if err != nil { diff --git a/pkg/native/dialog_linux.go b/pkg/native/dialog_linux.go index d955fc5b..da214046 100644 --- a/pkg/native/dialog_linux.go +++ b/pkg/native/dialog_linux.go @@ -46,9 +46,17 @@ func buildPortalFilters(filters []FileFilter) dbus.Variant { } func portalCall(method string, options map[string]dbus.Variant, args ...interface{}) (string, error) { + paths, err := portalCallMulti(method, options, args...) + if err != nil { + return "", err + } + return paths[0], nil +} + +func portalCallMulti(method string, options map[string]dbus.Variant, args ...interface{}) ([]string, error) { conn, err := dbus.SessionBus() if err != nil { - return "", fmt.Errorf("failed to connect to session bus: %w", err) + return nil, fmt.Errorf("failed to connect to session bus: %w", err) } defer conn.Close() @@ -65,7 +73,7 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac var handle dbus.ObjectPath if err := obj.Call(method, 0, callArgs...).Store(&handle); err != nil { - return "", fmt.Errorf("portal call %s failed: %w", method, err) + return nil, fmt.Errorf("portal call %s failed: %w", method, err) } if err := conn.AddMatchSignal( @@ -73,7 +81,7 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac dbus.WithMatchInterface("org.freedesktop.portal.Request"), dbus.WithMatchMember("Response"), ); err != nil { - return "", fmt.Errorf("failed to add match signal: %w", err) + return nil, fmt.Errorf("failed to add match signal: %w", err) } c := make(chan *dbus.Signal, 10) @@ -85,27 +93,35 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac continue } if len(sig.Body) < 2 { - return "", errors.New("unexpected signal body") + return nil, errors.New("unexpected signal body") } response, ok := sig.Body[0].(uint32) if !ok { - return "", fmt.Errorf("unexpected response type: %T", sig.Body[0]) + return nil, fmt.Errorf("unexpected response type: %T", sig.Body[0]) } if response != 0 { - return "", ErrCancelled + return nil, ErrCancelled } results, ok := sig.Body[1].(map[string]dbus.Variant) if !ok { - return "", fmt.Errorf("unexpected results type: %T", sig.Body[1]) + return nil, fmt.Errorf("unexpected results type: %T", sig.Body[1]) } uris, ok := results["uris"].Value().([]string) if !ok || len(uris) == 0 { - return "", errors.New("no files selected") + return nil, errors.New("no files selected") } - return uriToPath(uris[0]) + paths := make([]string, 0, len(uris)) + for _, uri := range uris { + p, err := uriToPath(uri) + if err != nil { + return nil, err + } + paths = append(paths, p) + } + return paths, nil } - return "", errors.New("signal channel closed unexpectedly") + return nil, errors.New("signal channel closed unexpectedly") } func uriToPath(uri string) (string, error) { @@ -131,6 +147,21 @@ func OpenFileDialog(title string, filters ...FileFilter) (string, error) { ) } +func OpenFilesDialog(title string, filters ...FileFilter) ([]string, error) { + options := map[string]dbus.Variant{ + "handle_token": dbus.MakeVariant(GenerateDBusToken()), + "multiple": dbus.MakeVariant(true), + } + if len(filters) > 0 { + options["filters"] = buildPortalFilters(filters) + } + return portalCallMulti( + "org.freedesktop.portal.FileChooser.OpenFile", + options, + title, + ) +} + func OpenFolderDialog(title string) (string, error) { options := map[string]dbus.Variant{ "handle_token": dbus.MakeVariant(GenerateDBusToken()), diff --git a/pkg/native/dialog_windows.go b/pkg/native/dialog_windows.go index 1fa25846..ac3c1619 100644 --- a/pkg/native/dialog_windows.go +++ b/pkg/native/dialog_windows.go @@ -2,6 +2,7 @@ package native import ( "fmt" + "path/filepath" "reflect" "strings" "syscall" @@ -26,12 +27,13 @@ var ( ) const ( - MAX_PATH = 260 - OFN_EXPLORER = 0x00080000 - OFN_FILEMUSTEXIST = 0x00001000 - OFN_PATHMUSTEXIST = 0x00000800 - OFN_OVERWRITEPROMPT = 0x00000002 - OFN_NOCHANGEDIR = 0x00000008 + MAX_PATH = 260 + OFN_EXPLORER = 0x00080000 + OFN_FILEMUSTEXIST = 0x00001000 + OFN_PATHMUSTEXIST = 0x00000800 + OFN_OVERWRITEPROMPT = 0x00000002 + OFN_NOCHANGEDIR = 0x00000008 + OFN_ALLOWMULTISELECT = 0x00000200 ) type openfilenameW struct { @@ -108,6 +110,86 @@ func OpenFileDialog(title string, filters ...FileFilter) (string, error) { return windows.UTF16PtrToString(ofn.lpstrFile), nil } +// OpenFilesDialog shows a native open dialog allowing multiple selections and +// returns the chosen paths. +func OpenFilesDialog(title string, filters ...FileFilter) ([]string, error) { + // Multi-select needs a large buffer: the result holds the directory plus + // every selected filename, NUL-separated, terminated by a double NUL. + fileBuf := make([]uint16, 64*1024) + + var filter []uint16 + for _, filt := range filters { + desc := fmt.Sprintf("%s (%s)", filt.Description, strings.Join(filt.Extensions, ",")) + filter = append(filter, utf16.Encode([]rune(desc))...) + filter = append(filter, 0x00) + for _, ext := range filt.Extensions { + s := fmt.Sprintf("*.%s;", ext) + filter = append(filter, utf16.Encode([]rune(s))...) + } + filter = append(filter, 0x00) + } + + filterPtr := utf16ptr(filter) + titlePtr, err := windows.UTF16PtrFromString(title) + if err != nil { + return nil, err + } + + ofn := openfilenameW{ + lStructSize: uint32(unsafe.Sizeof(openfilenameW{})), + lpstrFilter: filterPtr, + lpstrFile: &fileBuf[0], + nMaxFile: uint32(len(fileBuf)), + lpstrTitle: titlePtr, + Flags: OFN_EXPLORER | OFN_ALLOWMULTISELECT | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR, + nFilterIndex: 1, + } + + ret, _, err := procGetOpenFileNameW.Call(uintptr(unsafe.Pointer(&ofn))) + if ret == 0 { + if err != syscall.Errno(0) { + return nil, err + } + return nil, ErrCancelled + } + + paths := parseMultiSelect(fileBuf) + if len(paths) == 0 { + return nil, ErrCancelled + } + return paths, nil +} + +// parseMultiSelect decodes the OFN_ALLOWMULTISELECT result buffer. When a single +// file is chosen the buffer holds its full path; when several are chosen the +// first entry is the directory and the rest are bare filenames to join onto it. +func parseMultiSelect(buf []uint16) []string { + var parts []string + start := 0 + for i := 0; i < len(buf); i++ { + if buf[i] == 0 { + if i == start { + break // empty entry => terminating double NUL + } + parts = append(parts, string(utf16.Decode(buf[start:i]))) + start = i + 1 + } + } + switch len(parts) { + case 0: + return nil + case 1: + return parts + default: + dir := parts[0] + out := make([]string, 0, len(parts)-1) + for _, name := range parts[1:] { + out = append(out, filepath.Join(dir, name)) + } + return out + } +} + type browseinfoW struct { HwndOwner uintptr PidlRoot uintptr diff --git a/pkg/native/native.go b/pkg/native/native.go index f950ac59..a52de2f0 100644 --- a/pkg/native/native.go +++ b/pkg/native/native.go @@ -22,6 +22,7 @@ type FileRequest struct { } type FileResponse struct { - Path string - Err string + Path string + Paths []string + Err string } diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index 60a5ebf4..bb7fda1e 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -4,6 +4,7 @@ import ( "slices" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" ) @@ -104,6 +105,12 @@ func (mv *MapViewer) handlePrimaryCtrlClick(event *desktop.MouseEvent) { mv.selectedCells = append(mv.selectedCells, newCell) mv.selectionRects[newCell].Show() } + // Show()/Hide() only flip the Hidden flag, and canvas.Refresh on a rect + // that has never been painted (a hidden overlay cell) is a no-op because the + // object isn't in the canvas cache. Refresh the always-visible value rect for + // this cell instead to dirty the canvas and force an immediate repaint that + // draws the toggled highlight. + canvas.Refresh(mv.zDataRects[newCell]) } // handlePrimaryClickWithShift extends the selection from the current anchor to diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go new file mode 100644 index 00000000..32e58984 --- /dev/null +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -0,0 +1,978 @@ +// Package matrixbuilder provides a widget that learns a 2D map (matrix) from +// one or more log files. The widget loads the logs itself and merges their +// series, then builds the matrix from three selected series: one drives the X +// axis, one the Y axis and one supplies the Z value written into the cell the +// X/Y pair lands on. Every sample that maps to a cell is accumulated and the +// cell's final value is the average of all its hits. The resulting matrix is +// shown with a mapviewer (colored grid + 3D meshgrid), and both the axis +// breakpoints and the series can be edited or typed by hand. +package matrixbuilder + +import ( + "encoding/json" + "fmt" + "io" + "log" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + xlayout "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/logfile" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/progressmodal" +) + +const ( + minAxis = 1 + maxAxis = 40 + defaultCols = 8 + defaultRows = 8 + + // Tolerance is expressed as a percentage of a cell's half-spacing (the + // distance from a breakpoint to the midpoint between it and its neighbor). + // At 100% the whole nearest-neighbor region counts as a hit (the original + // behaviour); lower values reject samples that fall near the cell edges, + // keeping only those close to the breakpoint. + minTolerance = 1 + defaultTolerance = 100 +) + +var _ fyne.Widget = (*MatrixBuilder)(nil) + +type MatrixBuilder struct { + widget.BaseWidget + + // values holds every series merged across all loaded log files. All series + // share the same length (nrecords); gaps are padded with NaN so samples + // from different files stay row-aligned. + values map[string][]float64 + order []string + loadedFiles []string + nrecords int + + xSeries, ySeries, zSeries string + + cols, rows int + xAxis []float64 + yAxis []float64 + zData []float64 + + // xTolerance/yTolerance gate how close (as a percentage of the cell's + // half-spacing) a sample must be to its nearest breakpoint to count as a + // Z-hit on that axis. A sample is mapped only if it passes on both axes. + xTolerance, yTolerance float64 + + // built becomes true after the first successful analysis; until then the + // display area shows a placeholder instead of an all-zero grid. + built bool + + // widgets + colsLabel, rowsLabel *widget.Label + status *widget.Label + logsLabel *widget.Label + xBox, yBox *fyne.Container + xEntries, yEntries []*widget.Entry + xSel, ySel, zSel *widget.SelectEntry + xTolSlider, yTolSlider *widget.Slider + xTolLabel, yTolLabel *widget.Label + presetSelect *widget.Select + nameEntry *widget.Entry + display *fyne.Container + + content fyne.CanvasObject +} + +// Preset is the on-disk representation of a matrix builder configuration. It +// holds only settings (series, dimensions and axis breakpoints); the learned +// matrix values are never stored. +type Preset struct { + XSeries string `json:"x_series"` + YSeries string `json:"y_series"` + ZSeries string `json:"z_series"` + Cols int `json:"cols"` + Rows int `json:"rows"` + XAxis []float64 `json:"x_axis"` + YAxis []float64 `json:"y_axis"` + // XTolerance/YTolerance are percentages (1..100). Omitted in older presets, + // which decode to 0 and are treated as the default (no filtering) on load. + XTolerance float64 `json:"x_tolerance,omitempty"` + YTolerance float64 `json:"y_tolerance,omitempty"` +} + +// New creates an empty MatrixBuilder. Log files are loaded from within the +// widget via a native file dialog. +func New() *MatrixBuilder { + mb := &MatrixBuilder{ + values: make(map[string][]float64), + cols: defaultCols, + rows: defaultRows, + xTolerance: defaultTolerance, + yTolerance: defaultTolerance, + } + mb.ExtendBaseWidget(mb) + mb.xAxis = make([]float64, mb.cols) + mb.yAxis = make([]float64, mb.rows) + mb.zData = make([]float64, mb.cols*mb.rows) + mb.buildUI() + return mb +} + +func (mb *MatrixBuilder) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(mb.content) +} + +func (mb *MatrixBuilder) buildUI() { + // SelectEntry lets the user pick a loaded series from the dropdown or type a + // name manually. + mb.xSel = widget.NewSelectEntry(mb.order) + mb.xSel.OnChanged = func(s string) { mb.xSeries = s } + mb.ySel = widget.NewSelectEntry(mb.order) + mb.ySel.OnChanged = func(s string) { mb.ySeries = s } + mb.zSel = widget.NewSelectEntry(mb.order) + mb.zSel.OnChanged = func(s string) { mb.zSeries = s } + mb.xSel.PlaceHolder = "X series" + mb.ySel.PlaceHolder = "Y series" + mb.zSel.PlaceHolder = "Z series" + + mb.colsLabel = widget.NewLabel(strconv.Itoa(mb.cols)) + mb.rowsLabel = widget.NewLabel(strconv.Itoa(mb.rows)) + mb.status = widget.NewLabel("") + mb.status.Wrapping = fyne.TextWrapWord + + colsRow := container.NewBorder(nil, nil, + widget.NewLabel("Columns (X)"), + container.NewHBox( + widget.NewButton("-", func() { mb.setCols(mb.cols - 1) }), + mb.colsLabel, + widget.NewButton("+", func() { mb.setCols(mb.cols + 1) }), + widget.NewButton("Auto", func() { mb.autoFill(true) }), + ), + ) + rowsRow := container.NewBorder(nil, nil, + widget.NewLabel("Rows (Y)"), + container.NewHBox( + widget.NewButton("-", func() { mb.setRows(mb.rows - 1) }), + mb.rowsLabel, + widget.NewButton("+", func() { mb.setRows(mb.rows + 1) }), + widget.NewButton("Auto", func() { mb.autoFill(false) }), + ), + ) + + buildBtn := widget.NewButtonWithIcon("Build matrix", theme.GridIcon(), func() { + if err := mb.analyze(); err != nil { + mb.status.SetText(err.Error()) + return + } + mb.rebuildDisplay() + }) + buildBtn.Importance = widget.HighImportance + + mb.xBox = container.NewVBox() + mb.yBox = container.NewHBox() + mb.rebuildAxisEntries() + + controls := container.NewVBox( + // widget.NewLabelWithStyle("Series", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + colsRow, + rowsRow, + widget.NewSeparator(), + labeled("X", mb.xSel), + labeled("Y", mb.ySel), + labeled("Z", mb.zSel), + widget.NewSeparator(), + mb.buildToleranceSection(), + widget.NewSeparator(), + buildBtn, + widget.NewSeparator(), + mb.status, + widget.NewSeparator(), + xlayout.NewSpacer(), + mb.buildPresetSection(), + ) + + left := container.NewVScroll(controls) + left.SetMinSize(fyne.NewSize(240, 0)) + + right := container.NewVScroll( + mb.buildLogSection(), + ) + + mb.display = container.NewStack(mb.placeholder()) + + // The Y scale runs along the vertical axis of the map; its editor sits as a + // horizontal strip beneath the display. + yPanel := container.NewBorder(nil, nil, + widget.NewLabelWithStyle("Y axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + nil, + container.NewHScroll(mb.yBox), + ) + + mb.content = container.NewBorder( + nil, + nil, + right, + left, + container.NewBorder(nil, yPanel, container.NewVBox(widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.xBox), nil, mb.display), + ) +} + +func (mb *MatrixBuilder) placeholder() fyne.CanvasObject { + return container.NewCenter(widget.NewLabel("Select X, Y and Z series, then click \"Build matrix\"")) +} + +// buildToleranceSection builds the per-axis Z-hit tolerance sliders. Each +// slider sets the maximum distance (as a percentage of the cell's half-spacing) +// a sample may sit from its nearest breakpoint and still count as a hit. +func (mb *MatrixBuilder) buildToleranceSection() fyne.CanvasObject { + mb.xTolLabel = widget.NewLabel(tolText(mb.xTolerance)) + mb.yTolLabel = widget.NewLabel(tolText(mb.yTolerance)) + mb.xTolSlider = mb.newToleranceSlider(true) + mb.yTolSlider = mb.newToleranceSlider(false) + + return container.NewVBox( + widget.NewLabelWithStyle("Z-hit tolerance", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewBorder(nil, nil, widget.NewLabel("X"), layout.NewFixedWidth(44, mb.xTolLabel), mb.xTolSlider), + container.NewBorder(nil, nil, widget.NewLabel("Y"), layout.NewFixedWidth(44, mb.yTolLabel), mb.yTolSlider), + ) +} + +// newToleranceSlider builds a 1..100% slider bound to the X or Y tolerance. +// While dragging it only updates the value label; on release it re-runs the +// analysis (if a matrix is already built) so the user sees the effect live. +func (mb *MatrixBuilder) newToleranceSlider(isX bool) *widget.Slider { + cur := mb.yTolerance + if isX { + cur = mb.xTolerance + } + s := widget.NewSlider(minTolerance, 100) + s.Step = 1 + s.SetValue(cur) + s.OnChanged = func(f float64) { + if isX { + mb.xTolerance = f + mb.xTolLabel.SetText(tolText(f)) + } else { + mb.yTolerance = f + mb.yTolLabel.SetText(tolText(f)) + } + } + s.OnChangeEnded = func(float64) { + if !mb.built { + return + } + if err := mb.analyze(); err != nil { + mb.status.SetText(err.Error()) + return + } + mb.rebuildDisplay() + } + return s +} + +// rebuildAxisEntries regenerates the editable entry fields for the current +// column/row counts, seeding them from the current axis values. +func (mb *MatrixBuilder) rebuildAxisEntries() { + mb.xEntries = make([]*widget.Entry, mb.cols) + mb.xBox.Objects = mb.xBox.Objects[:0] + for i := 0; i < mb.cols; i++ { + mb.xBox.Add(mb.makeAxisEntry(true, i)) + } + mb.xBox.Refresh() + + mb.yEntries = make([]*widget.Entry, mb.rows) + mb.yBox.Objects = mb.yBox.Objects[:0] + for i := 0; i < mb.rows; i++ { + mb.yBox.Add(mb.makeAxisEntry(false, i)) + } + mb.yBox.Refresh() +} + +func (mb *MatrixBuilder) makeAxisEntry(isX bool, idx int) fyne.CanvasObject { + axis := mb.xAxis + prefix := "X" + if !isX { + axis = mb.yAxis + prefix = "Y" + } + e := widget.NewEntry() + e.SetText(formatFloat(axis[idx])) + e.OnChanged = func(s string) { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return + } + if isX { + mb.xAxis[idx] = v + } else { + mb.yAxis[idx] = v + } + } + // Apply (relabel the displayed map) when the user commits a value. + e.OnSubmitted = func(string) { + if mb.built { + mb.rebuildDisplay() + } + } + if isX { + mb.xEntries[idx] = e + // X breakpoints stack vertically in the side panel: label beside entry. + return container.NewBorder(nil, nil, widget.NewLabel(prefix+strconv.Itoa(idx)), nil, e) + } + mb.yEntries[idx] = e + // Y breakpoints run horizontally along the bottom: label above a + // fixed-width entry so the strip stays compact. + label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignCenter, fyne.TextStyle{}) + return container.NewVBox(label, layout.NewFixedWidth(64, e)) +} + +func (mb *MatrixBuilder) setCols(n int) { + n = clamp(n, minAxis, maxAxis) + if n == mb.cols { + return + } + mb.xAxis = resizeAxis(mb.xAxis, n) + mb.cols = n + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + mb.colsLabel.SetText(strconv.Itoa(n)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +func (mb *MatrixBuilder) setRows(n int) { + n = clamp(n, minAxis, maxAxis) + if n == mb.rows { + return + } + mb.yAxis = resizeAxis(mb.yAxis, n) + mb.rows = n + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + mb.rowsLabel.SetText(strconv.Itoa(n)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +// autoFill spreads the selected series' min..max evenly across the axis +// breakpoints. isX selects the X axis, otherwise the Y axis. +func (mb *MatrixBuilder) autoFill(isX bool) { + series := mb.ySeries + axis := mb.yAxis + if isX { + series = mb.xSeries + axis = mb.xAxis + } + data, ok := mb.values[series] + if !ok || len(data) == 0 { + return + } + lo, hi := minMax(data) + n := len(axis) + for i := 0; i < n; i++ { + if n == 1 { + axis[i] = lo + continue + } + axis[i] = lo + (hi-lo)*float64(i)/float64(n-1) + } + mb.syncAxisEntries() + if mb.built { + mb.rebuildDisplay() + } +} + +// syncAxisEntries pushes the current axis values back into the entry widgets. +// The entry/axis lengths track each other via rebuildAxisEntries, but the +// guard keeps a transient desync from panicking the UI thread. +func (mb *MatrixBuilder) syncAxisEntries() { + for i, e := range mb.xEntries { + if i >= len(mb.xAxis) { + break + } + e.SetText(formatFloat(mb.xAxis[i])) + } + for i, e := range mb.yEntries { + if i >= len(mb.yAxis) { + break + } + e.SetText(formatFloat(mb.yAxis[i])) + } +} + +// analyze walks the log and learns the matrix: each sample is assigned to the +// nearest cell and the cell's value becomes the average of its hits. +func (mb *MatrixBuilder) analyze() error { + if len(mb.values) == 0 { + return fmt.Errorf("load a log file first") + } + if mb.xSeries == "" || mb.ySeries == "" || mb.zSeries == "" { + return fmt.Errorf("select X, Y and Z series first") + } + xv, okX := mb.values[mb.xSeries] + yv, okY := mb.values[mb.ySeries] + zv, okZ := mb.values[mb.zSeries] + if !okX || !okY || !okZ { + return fmt.Errorf("selected series not found in the loaded logs") + } + n := min(len(xv), min(len(yv), len(zv))) + if n == 0 { + return fmt.Errorf("selected series contain no samples") + } + + // Sort the axes ascending so the learned map reads like a normal table. + sort.Float64s(mb.xAxis) + sort.Float64s(mb.yAxis) + mb.syncAxisEntries() + + size := mb.cols * mb.rows + sum := make([]float64, size) + cnt := make([]int, size) + used := 0 + skipped := 0 + for i := 0; i < n; i++ { + // Skip rows where any of the three series is missing (NaN padding from + // merging logs with differing channel sets). + if math.IsNaN(xv[i]) || math.IsNaN(yv[i]) || math.IsNaN(zv[i]) { + continue + } + c := nearestIndex(mb.xAxis, xv[i]) + r := nearestIndex(mb.yAxis, yv[i]) + // Reject samples sitting too far from their nearest breakpoint on + // either axis, so only values close to a cell count as a Z-hit. + if !withinTolerance(mb.xAxis, c, xv[i], mb.xTolerance) || + !withinTolerance(mb.yAxis, r, yv[i], mb.yTolerance) { + skipped++ + continue + } + idx := r*mb.cols + c + sum[idx] += zv[i] + cnt[idx]++ + used++ + } + + mb.zData = make([]float64, size) + filled := 0 + for i := range sum { + if cnt[i] > 0 { + mb.zData[i] = sum[i] / float64(cnt[i]) + filled++ + } + } + mb.built = true + msg := fmt.Sprintf("Mapped %d samples, %d/%d cells filled", used, filled, size) + if skipped > 0 { + msg += fmt.Sprintf(" (%d skipped by tolerance)", skipped) + } + mb.status.SetText(msg) + return nil +} + +// rebuildDisplay swaps a freshly built mapviewer (grid + 3D mesh) into the +// display area, reflecting the current axes and learned Z data. Before the +// first build it shows the placeholder instead. +func (mb *MatrixBuilder) rebuildDisplay() { + if !mb.built { + mb.display.Objects = []fyne.CanvasObject{mb.placeholder()} + mb.display.Refresh() + return + } + + noop := func([]float64) {} + mv, err := mapviewer.New(&mapviewer.Config{ + Name: mb.zSeries, + XData: mb.xAxis, + YData: mb.yAxis, + ZData: mb.zData, + XPrecision: precisionFor(mb.xAxis), + YPrecision: precisionFor(mb.yAxis), + ZPrecision: precisionFor(mb.zData), + XLabel: mb.xSeries, + YLabel: mb.ySeries, + ZLabel: mb.zSeries, + MeshView: true, + Editable: true, + ColorblindMode: colors.ModeNormal, + // The matrix is in-memory only; editing cells just mutates zData. + SaveECUFunc: noop, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + mb.display.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + mb.display.Refresh() + return + } + mb.display.Objects = []fyne.CanvasObject{mv} + mb.display.Refresh() +} + +// --- log files --- + +func (mb *MatrixBuilder) buildLogSection() fyne.CanvasObject { + mb.logsLabel = widget.NewLabel("No log files loaded") + mb.logsLabel.Wrapping = fyne.TextWrapWord + + addBtn := widget.NewButtonWithIcon("Add log files", theme.FolderOpenIcon(), mb.openLogDialog) + clearBtn := widget.NewButtonWithIcon("Clear", theme.ContentClearIcon(), mb.clearLogs) + + return container.NewVBox( + // widget.NewLabelWithStyle("Log files", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewGridWithColumns(2, addBtn, clearBtn), + mb.logsLabel, + ) +} + +// openLogDialog shows the native multi-file picker and loads the chosen logs. +// Parsing runs off the UI goroutine behind a progress modal, then the parsed +// series are merged on the UI goroutine. +func (mb *MatrixBuilder) openLogDialog() { + widgets.SelectFiles(func(readers []fyne.URIReadCloser) { + c := fyne.CurrentApp().Driver().CanvasForObject(mb) + if c == nil { + if wins := fyne.CurrentApp().Driver().AllWindows(); len(wins) > 0 { + c = wins[0].Canvas() + } + } + var pm *progressmodal.ProgressModal + if c != nil { + pm = progressmodal.New(c, fmt.Sprintf("Parsing %d log file(s)...", len(readers))) + pm.Show() + } + + go func() { + type parsed struct { + name string + local map[string][]float64 + n int + } + var ok []parsed + var failed int + for _, r := range readers { + name := r.URI().Name() + local, n, err := parseLog(name, r) + r.Close() + if err != nil { + log.Println("matrixbuilder:", err) + failed++ + continue + } + ok = append(ok, parsed{name, local, n}) + } + + fyne.Do(func() { + if pm != nil { + pm.Hide() + } + for _, p := range ok { + mb.mergeLog(p.name, p.local, p.n) + } + mb.rebuildOrder() + mb.refreshSeriesOptions() + mb.refreshLogList() + if failed > 0 { + mb.status.SetText(fmt.Sprintf("Loaded %d file(s), %d failed", len(ok), failed)) + } else { + mb.status.SetText(fmt.Sprintf("Loaded %d file(s), %d records total", len(ok), mb.nrecords)) + } + }) + }() + }, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") +} + +// parseLog reads a single log file into a row-aligned series map, padding gaps +// with NaN. It touches no shared state, so it is safe to call off the UI +// goroutine. +func parseLog(name string, r io.Reader) (map[string][]float64, int, error) { + lf, err := logfile.Open(name, r) + if err != nil { + return nil, 0, err + } + defer lf.Close() + + local := make(map[string][]float64) + n := 0 + for { + rec := lf.Next() + if rec.EOF { + break + } + for k, v := range rec.Values { + if k == "Pgm_status" { + continue + } + arr := local[k] + for len(arr) < n { // back-fill records before this key first appeared + arr = append(arr, math.NaN()) + } + local[k] = append(arr, v) + } + n++ + for k, arr := range local { // forward-fill keys missing from this record + for len(arr) < n { + arr = append(arr, math.NaN()) + } + local[k] = arr + } + } + if n == 0 { + return nil, 0, fmt.Errorf("%s contains no records", name) + } + return local, n, nil +} + +// mergeLog merges a parsed log into the global series set, padding so all +// series stay row-aligned. Must run on the UI goroutine. +func (mb *MatrixBuilder) mergeLog(name string, local map[string][]float64, n int) { + base := mb.nrecords + for k, arr := range local { + cur, ok := mb.values[k] + if !ok { + cur = nanSlice(base) + } + mb.values[k] = append(cur, arr...) + } + for k, cur := range mb.values { + if _, ok := local[k]; !ok { + mb.values[k] = append(cur, nanSlice(n)...) + } + } + mb.nrecords = base + n + mb.loadedFiles = append(mb.loadedFiles, name) +} + +func (mb *MatrixBuilder) clearLogs() { + mb.values = make(map[string][]float64) + mb.order = nil + mb.loadedFiles = nil + mb.nrecords = 0 + mb.built = false + mb.refreshSeriesOptions() + mb.refreshLogList() + mb.rebuildDisplay() + mb.status.SetText("Cleared loaded logs") +} + +// rebuildOrder refreshes the sorted list of available series names. +func (mb *MatrixBuilder) rebuildOrder() { + mb.order = make([]string, 0, len(mb.values)) + for k := range mb.values { + mb.order = append(mb.order, k) + } + sort.Slice(mb.order, func(i, j int) bool { + return strings.ToLower(mb.order[i]) < strings.ToLower(mb.order[j]) + }) +} + +func (mb *MatrixBuilder) refreshSeriesOptions() { + mb.xSel.SetOptions(mb.order) + mb.ySel.SetOptions(mb.order) + mb.zSel.SetOptions(mb.order) +} + +func (mb *MatrixBuilder) refreshLogList() { + if len(mb.loadedFiles) == 0 { + mb.logsLabel.SetText("No log files loaded") + return + } + mb.logsLabel.SetText(fmt.Sprintf("%d file(s), %d records:\n%s", + len(mb.loadedFiles), mb.nrecords, strings.Join(mb.loadedFiles, "\n"))) +} + +func nanSlice(n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = math.NaN() + } + return s +} + +// --- presets --- + +func (mb *MatrixBuilder) buildPresetSection() fyne.CanvasObject { + mb.presetSelect = widget.NewSelect(mb.listPresets(), func(name string) { + if name == "" { + return + } + if err := mb.loadPreset(name); err != nil { + mb.status.SetText(err.Error()) + } + }) + mb.presetSelect.PlaceHolder = "Load preset" + + refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), mb.refreshPresets) + + mb.nameEntry = widget.NewEntry() + mb.nameEntry.SetPlaceHolder("preset name") + saveBtn := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() { + name := strings.TrimSpace(mb.nameEntry.Text) + if name == "" { + mb.status.SetText("enter a preset name to save") + return + } + saved, err := mb.savePreset(name) + if err != nil { + mb.status.SetText(err.Error()) + return + } + mb.refreshPresets() + // Reflect the saved name in the picker without re-triggering a load. + mb.presetSelect.Selected = saved + mb.presetSelect.Refresh() + mb.status.SetText("Saved preset " + saved) + }) + + return container.NewVBox( + // widget.NewLabelWithStyle("Presets", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewBorder(nil, nil, nil, refreshBtn, mb.presetSelect), + container.NewBorder(nil, nil, nil, saveBtn, mb.nameEntry), + ) +} + +// listPresets returns the names (without extension) of the saved presets. +func (mb *MatrixBuilder) listPresets() []string { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return nil + } + files, err := common.ListFilesInPathByExtension(path, ".json") + if err != nil { + return nil + } + names := make([]string, len(files)) + for i, f := range files { + names[i] = strings.TrimSuffix(f, ".json") + } + return names +} + +func (mb *MatrixBuilder) refreshPresets() { + mb.presetSelect.Options = mb.listPresets() + mb.presetSelect.Refresh() +} + +// savePreset writes the current configuration (and learned matrix, if any) to +// name.json in the matrix builder directory. It returns the stored preset name +// (without extension), which may differ from name after sanitization. +func (mb *MatrixBuilder) savePreset(name string) (string, error) { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return "", err + } + p := Preset{ + XSeries: mb.xSeries, + YSeries: mb.ySeries, + ZSeries: mb.zSeries, + Cols: mb.cols, + Rows: mb.rows, + XAxis: mb.xAxis, + YAxis: mb.yAxis, + XTolerance: mb.xTolerance, + YTolerance: mb.yTolerance, + } + b, err := json.MarshalIndent(&p, "", " ") + if err != nil { + return "", err + } + stored := common.SanitizeFilename(name + ".json") + if err := os.WriteFile(filepath.Join(path, stored), b, 0o644); err != nil { + return "", err + } + return strings.TrimSuffix(stored, ".json"), nil +} + +// loadPreset reads name.json and applies it to the builder. +func (mb *MatrixBuilder) loadPreset(name string) error { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return err + } + b, err := os.ReadFile(filepath.Join(path, common.SanitizeFilename(name+".json"))) + if err != nil { + return err + } + var p Preset + if err := json.Unmarshal(b, &p); err != nil { + return fmt.Errorf("failed to decode preset: %w", err) + } + mb.applyPreset(&p) + mb.status.SetText("Loaded preset " + name) + return nil +} + +// applyPreset replaces the current state with the preset's, rebuilding the +// editor and display. +func (mb *MatrixBuilder) applyPreset(p *Preset) { + mb.cols = clamp(p.Cols, minAxis, maxAxis) + mb.rows = clamp(p.Rows, minAxis, maxAxis) + mb.xAxis = resizeAxis(p.XAxis, mb.cols) + mb.yAxis = resizeAxis(p.YAxis, mb.rows) + // A preset carries only settings, so the matrix must be rebuilt after load. + // Clear built first: Slider.SetValue below fires OnChangeEnded, and we must + // not let it re-run analyze() against the half-applied state. + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + + // Older presets predate the tolerance fields and decode to 0; fall back to + // the default (no filtering) rather than rejecting every sample. + mb.xTolerance = toleranceOrDefault(p.XTolerance) + mb.yTolerance = toleranceOrDefault(p.YTolerance) + mb.xTolSlider.SetValue(mb.xTolerance) + mb.yTolSlider.SetValue(mb.yTolerance) + mb.xTolLabel.SetText(tolText(mb.xTolerance)) + mb.yTolLabel.SetText(tolText(mb.yTolerance)) + + // SetText fires OnChanged, which just records the series name (no auto-fill), + // so this is safe and keeps mb.xSeries/etc. in sync. + mb.xSel.SetText(p.XSeries) + mb.ySel.SetText(p.YSeries) + mb.zSel.SetText(p.ZSeries) + + mb.colsLabel.SetText(strconv.Itoa(mb.cols)) + mb.rowsLabel.SetText(strconv.Itoa(mb.rows)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +// --- helpers --- + +func labeled(label string, obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// resizeAxis grows or shrinks an axis to length n, preserving existing values +// and padding new slots with the previous value (or zero for an empty axis). +func resizeAxis(old []float64, n int) []float64 { + out := make([]float64, n) + copy(out, old) + for i := len(old); i < n; i++ { + if i > 0 { + out[i] = out[i-1] + } + } + return out +} + +// withinTolerance reports whether v is close enough to breakpoint c on a sorted +// axis to count as a hit. tolPct is the allowed distance expressed as a +// percentage of the half-spacing to the neighbouring breakpoint on v's side, so +// 100% accepts the entire nearest-neighbor region and lower values reject +// samples sitting near the cell boundary. Edge cells use their inner spacing as +// the reference, so a sample far beyond the axis range is rejected too. +func withinTolerance(axis []float64, c int, v, tolPct float64) bool { + if tolPct >= 100 || len(axis) < 2 { + return true + } + bp := axis[c] + var spacing float64 + if v >= bp { // neighbour on the high side, falling back to the low side + switch { + case c+1 < len(axis): + spacing = axis[c+1] - bp + case c-1 >= 0: + spacing = bp - axis[c-1] + } + } else { // neighbour on the low side, falling back to the high side + switch { + case c-1 >= 0: + spacing = bp - axis[c-1] + case c+1 < len(axis): + spacing = axis[c+1] - bp + } + } + if spacing <= 0 { // duplicate/degenerate breakpoints: nothing to gate on + return true + } + maxDist := tolPct / 100.0 * (spacing / 2.0) + return math.Abs(v-bp) <= maxDist +} + +// toleranceOrDefault clamps a stored tolerance into the valid range, mapping the +// zero value (older presets without the field) to the default. +func toleranceOrDefault(v float64) float64 { + if v < minTolerance { + return defaultTolerance + } + if v > 100 { + return 100 + } + return v +} + +func tolText(v float64) string { + return strconv.Itoa(int(v)) + "%" +} + +// nearestIndex returns the index of the axis breakpoint closest to v. +func nearestIndex(axis []float64, v float64) int { + best := 0 + bestDist := math.Abs(axis[0] - v) + for i := 1; i < len(axis); i++ { + d := math.Abs(axis[i] - v) + if d < bestDist { + bestDist = d + best = i + } + } + return best +} + +func minMax(data []float64) (float64, float64) { + lo, hi := data[0], data[0] + for _, v := range data[1:] { + if v < lo { + lo = v + } + if v > hi { + hi = v + } + } + return lo, hi +} + +// precisionFor picks a sensible decimal precision: 0 for all-integer data, +// otherwise more decimals for small-magnitude values. +func precisionFor(data []float64) int { + allInt := true + maxAbs := 0.0 + for _, v := range data { + if v != math.Trunc(v) { + allInt = false + } + if a := math.Abs(v); a > maxAbs { + maxAbs = a + } + } + if allInt { + return 0 + } + if maxAbs < 10 { + return 3 + } + return 2 +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index 4439f4bb..bc15e00c 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -220,13 +220,11 @@ func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { leading, container.NewBorder( nil, - container.NewGridWithColumns(1, - widget.NewButton("Toggle visible", func() { - for _, ts := range p.legendTexts { - ts.Tapped(&fyne.PointEvent{}) - } - }), - ), + widget.NewButton("Toggle visible", func() { + for _, ts := range p.legendTexts { + ts.Tapped(&fyne.PointEvent{}) + } + }), nil, nil, container.NewVScroll(p.legend), diff --git a/pkg/widgets/widget.go b/pkg/widgets/widget.go index 7b3d68bf..32cd4171 100644 --- a/pkg/widgets/widget.go +++ b/pkg/widgets/widget.go @@ -48,6 +48,33 @@ func SelectFile(callback func(r fyne.URIReadCloser), desc string, exts ...string }() } +func SelectFiles(callback func(rc []fyne.URIReadCloser), desc string, exts ...string) { + go func() { + filenames, err := selectFiles(desc, exts...) + if err != nil { + if errors.Is(err, native.ErrCancelled) || err.Error() == "Cancelled" { + return + } + log.Println("Error selecting files:", err) + return + } + readers := make([]fyne.URIReadCloser, 0, len(filenames)) + for _, filename := range filenames { + uri := storage.NewFileURI(filename) + r, err := storage.Reader(uri) + if err != nil { + log.Println("Error reading file:", err) + continue + } + readers = append(readers, r) + } + if len(readers) == 0 { + return + } + fyne.Do(func() { callback(readers) }) + }() +} + func SaveFile(callback func(str string), desc string, ext string) { go func() { //filter := native.FileFilter{Description: desc, Extensions: []string{ext}} diff --git a/pkg/widgets/widget_linux.go b/pkg/widgets/widget_linux.go index 0899b382..c5ac771b 100644 --- a/pkg/widgets/widget_linux.go +++ b/pkg/widgets/widget_linux.go @@ -14,6 +14,14 @@ func selectFile(desc string, exts ...string) (string, error) { return runChild("open_file", "Open "+desc, desc, exts...) } +func selectFiles(desc string, exts ...string) ([]string, error) { + resp, err := runChildResp("open_files", "Open "+desc, desc, exts...) + if err != nil { + return nil, err + } + return resp.Paths, nil +} + func saveFile(desc string, ext string) (string, error) { return runChild("save_file", "Save "+desc, desc, ext) } @@ -23,6 +31,14 @@ func selectFolder() (string, error) { } func runChild(op, title, desc string, exts ...string) (string, error) { + resp, err := runChildResp(op, title, desc, exts...) + if err != nil { + return resp.Path, err + } + return resp.Path, nil +} + +func runChildResp(op, title, desc string, exts ...string) (native.FileResponse, error) { child := exec.Command("/proc/self/exe") // re-exec self child.Env = append(os.Environ(), "FP=1") childIn, _ := child.StdinPipe() @@ -31,7 +47,7 @@ func runChild(op, title, desc string, exts ...string) (string, error) { defer childIn.Close() if err := child.Start(); err != nil { - return "", fmt.Errorf("failed to start child: %w\n", err) + return native.FileResponse{}, fmt.Errorf("failed to start child: %w\n", err) } enc := json.NewEncoder(childIn) @@ -44,7 +60,7 @@ func runChild(op, title, desc string, exts ...string) (string, error) { Exts: exts, } if err := enc.Encode(req); err != nil { - return "", fmt.Errorf("error decoding response: %w", err) + return native.FileResponse{}, fmt.Errorf("error decoding response: %w", err) } var resp native.FileResponse @@ -53,12 +69,12 @@ func runChild(op, title, desc string, exts ...string) (string, error) { waitErr := child.Wait() if decodeErr != nil { - return "", decodeErr + return native.FileResponse{}, decodeErr } if resp.Err != "" { - return resp.Path, errors.New(resp.Err) + return resp, errors.New(resp.Err) } - return resp.Path, waitErr + return resp, waitErr } diff --git a/pkg/widgets/widget_windows.go b/pkg/widgets/widget_windows.go index 82adaac2..af76dd50 100644 --- a/pkg/widgets/widget_windows.go +++ b/pkg/widgets/widget_windows.go @@ -7,6 +7,11 @@ func selectFile(desc string, exts ...string) (string, error) { return native.OpenFileDialog("Open file", filter) } +func selectFiles(desc string, exts ...string) ([]string, error) { + filter := native.FileFilter{Description: desc, Extensions: exts} + return native.OpenFilesDialog("Open files", filter) +} + func saveFile(desc string, ext string) (string, error) { return native.SaveFileDialog("Save "+desc, ext, native.FileFilter{ Description: desc, diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 483a4861..f4c91367 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -174,6 +174,44 @@ func (mw *MainWindow) setupMenu() { }, } + openItem := fyne.NewMenuItemWithIcon("Open", theme.FolderIcon(), nil) + openItem.ChildMenu = fyne.NewMenu("File", + fyne.NewMenuItemWithIcon("Open binary", theme.DocumentIcon(), mw.loadBinary), + fyne.NewMenuItemWithIcon("Open log", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Name() + mw.Log("opening logfile " + filename) + sz := mw.Window.Content().Size() + p := fyne.NewPos(sz.Width/2, sz.Height/2) + mw.LoadLogfile(filename, r, p) + } + widgets.SelectFile(cb, "Log file", "csv", "bpl", "t5l", "t7l", "t8l") + }), + fyne.NewMenuItemWithIcon("Open log in new window", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Path() + mw.LoadLogfileCombined(filename, r, fyne.Position{}, true) + } + widgets.SelectFile(cb, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") + }), + fyne.NewMenuItemWithIcon("Open log folder", theme.FolderIcon(), func() { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("explorer", mw.settings.GetLogPath()) + case "darwin": + cmd = exec.Command("open", mw.settings.GetLogPath()) + default: + cmd = exec.Command("xdg-open", mw.settings.GetLogPath()) + } + if err := cmd.Start(); err != nil { + mw.Error(err) + } + }), + ) + leading := []*fyne.Menu{ fyne.NewMenu("File", fyne.NewMenuItemWithIcon("About", theme.HelpIcon(), func() { @@ -185,40 +223,7 @@ func (mw *MainWindow) setupMenu() { inner.Icon = theme.HelpIcon() mw.wm.Add(inner) }), - fyne.NewMenuItemWithIcon("Open binary", theme.DocumentIcon(), mw.loadBinary), - fyne.NewMenuItemWithIcon("Open log", theme.DocumentIcon(), func() { - cb := func(r fyne.URIReadCloser) { - defer r.Close() - filename := r.URI().Name() - mw.Log("opening logfile " + filename) - sz := mw.Window.Content().Size() - p := fyne.NewPos(sz.Width/2, sz.Height/2) - mw.LoadLogfile(filename, r, p) - } - widgets.SelectFile(cb, "Log file", "csv", "t5l", "t7l", "t8l") - }), - fyne.NewMenuItemWithIcon("Open log in new window", theme.DocumentIcon(), func() { - cb := func(r fyne.URIReadCloser) { - defer r.Close() - filename := r.URI().Path() - mw.LoadLogfileCombined(filename, r, fyne.Position{}, true) - } - widgets.SelectFile(cb, "logfile", "t5l", "t7l", "t8l", "csv") - }), - fyne.NewMenuItemWithIcon("Open log folder", theme.FolderIcon(), func() { - var cmd *exec.Cmd - switch runtime.GOOS { - case "windows": - cmd = exec.Command("explorer", mw.settings.GetLogPath()) - case "darwin": - cmd = exec.Command("open", mw.settings.GetLogPath()) - default: - cmd = exec.Command("xdg-open", mw.settings.GetLogPath()) - } - if err := cmd.Start(); err != nil { - mw.Error(err) - } - }), + openItem, fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), func() { mw.openSettings() }), diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index 022424e7..24cefa6c 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -6,9 +6,23 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/widgets/canflasher" + "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) +// openMatrixBuilder opens (or raises) the matrix builder window. The builder +// loads its own log files, so it is independent of any open log player. +func (mw *MainWindow) openMatrixBuilder() { + if w := mw.wm.HasWindow("Matrix builder"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New()) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1000, 720)) +} + func (mw *MainWindow) newToolbar() *fyne.Container { toolbar := container.NewHBox( container.NewBorder( @@ -22,6 +36,7 @@ func (mw *MainWindow) newToolbar() *fyne.Container { mw.buttons.symbolListBtn, mw.buttons.logBtn, mw.buttons.dashboardBtn, + widget.NewButtonWithIcon("Matrix", theme.GridIcon(), mw.openMatrixBuilder), widget.NewButtonWithIcon("", theme.GridIcon(), func() { mw.wm.Arrange(&multiwindow.GridArranger{}) }), From 2e22b3b26555088a790a7d980aed49483061f224 Mon Sep 17 00:00:00 2001 From: roffe Date: Tue, 16 Jun 2026 23:13:33 +0200 Subject: [PATCH 50/93] matrixbuilder and meshgrid updates --- pkg/assets/WHATSNEW.md | 9 +- pkg/widgets/mapviewer/mapviewer_mouse.go | 8 + pkg/widgets/matrixbuilder/filter_test.go | 135 ++++ pkg/widgets/matrixbuilder/matrixbuilder.go | 699 ++++++++++++++++++- pkg/widgets/matrixbuilder/query.go | 420 +++++++++++ pkg/widgets/matrixbuilder/query_test.go | 139 ++++ pkg/widgets/meshgrid/meshgrid_draw.go | 55 +- pkg/widgets/meshgrid/meshgrid_shader.go | 184 +++-- pkg/widgets/meshgrid/meshgrid_shader_test.go | 34 +- pkg/widgets/meshgrid/meshgrid_surface.go | 77 +- pkg/windows/mainWindow_toolbar.go | 2 +- 11 files changed, 1645 insertions(+), 117 deletions(-) create mode 100644 pkg/widgets/matrixbuilder/filter_test.go create mode 100644 pkg/widgets/matrixbuilder/query.go create mode 100644 pkg/widgets/matrixbuilder/query_test.go diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index e78a686b..23f33c9f 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -20,7 +20,14 @@ - Dial and Dual Dial now uses shaders to draw the faces, dials and pips - Improved cell selection in mapviewer - Improved copy paste in mapviewer, added paste here function -- Added a Matrix builder from logfiles +- Added a Matrix builder from logfiles. It learns a 2D map from one or more logs: pick which series drives the X axis, the Y axis and supplies the Z value, and every sample that lands on a cell is averaged into it. The result is shown live in a mapviewer (colored grid + 3D mesh) and the cells can be edited by hand + - Load and merge multiple log files at once (t5l, t7l, t8l, csv, bpl); series are row-aligned across files + - Pick X/Y/Z from a dropdown of the loaded series or type a name by hand + - Adjustable column/row counts and fully editable axis breakpoints, with an "Auto" button that spreads a series' min..max evenly across an axis + - Per-axis Z-hit tolerance sliders: reject samples that sit too far from a breakpoint so only values close to a cell count toward it + - Visual filter / query builder: add rules like "if " and a sample only counts as a hit when it satisfies every rule. Operators: >, >=, <, <=, ==, != and ~ (approximately equal) + - Filter query language: instead of the visual rules you can type a full query with and/or, () grouping and the same operators, e.g. "if (ActualIn.n_Engine > 3000 and Out.X_AccPedal > 50) or boost ~ 1.2". Series can be compared to numbers, to each other or to arithmetic of them; a non-empty query overrides the rules + - Save and load configurations as presets (series, dimensions, axis breakpoints, tolerances and filter rules) # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index bb7fda1e..3982ac46 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -183,6 +183,14 @@ func (mv *MapViewer) calculateSelectionBounds(eventPos fyne.Position) (int, int) // handleFocusAndInputBuffer focuses the MapViewer and clears the input buffer if necessary. func (mv *MapViewer) handleFocusAndInputBuffer() { + // Take keyboard focus so the key handler (cell editing, increment/decrement, + // arrow-key navigation) receives events. The multiwindow manager focuses a + // map when its inner window is raised, but a MapViewer placed directly in a + // container (e.g. the matrix builder) is never focused otherwise, so clicking + // a cell would leave key presses with nowhere to go. + if c := fyne.CurrentApp().Driver().CanvasForObject(mv); c != nil { + c.Focus(mv) + } if mv.inputBuffer.Len() > 0 { mv.inputBuffer.Reset() mv.restoreSelectedValues() diff --git a/pkg/widgets/matrixbuilder/filter_test.go b/pkg/widgets/matrixbuilder/filter_test.go new file mode 100644 index 00000000..5e074495 --- /dev/null +++ b/pkg/widgets/matrixbuilder/filter_test.go @@ -0,0 +1,135 @@ +package matrixbuilder + +import "testing" + +// leaf builds a comparison condition node. +func leaf(series, op string, value float64) *FilterNode { + return &FilterNode{Series: series, Operator: op, Value: value} +} + +func TestFilterNodeToQuery(t *testing.T) { + tests := []struct { + name string + node *FilterNode + want string // empty means ok=false (nothing to contribute) + }{ + { + "single leaf, no outer parens at root", + &FilterNode{Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000)}}, + "rpm > 3000", + }, + { + "two anded at root", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + leaf("rpm", ">", 3000), leaf("load", ">", 50), + }}, + "rpm > 3000 and load > 50", + }, + { + "nested group keeps its parens", + &FilterNode{Combinator: "or", Children: []*FilterNode{ + {Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000), leaf("load", ">", 50)}}, + {Series: "ECMStat.ST_ActiveAirDem", Operator: "in", Values: []float64{10, 20}}, + }}, + "(rpm > 3000 and load > 50) or ECMStat.ST_ActiveAirDem in [10, 20]", + }, + { + "negated group at root", + &FilterNode{Combinator: "and", Negate: true, Children: []*FilterNode{ + leaf("rpm", ">", 3000), leaf("load", ">", 50), + }}, + "not (rpm > 3000 and load > 50)", + }, + { + "negated leaf", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + {Series: "rpm", Operator: ">", Value: 3000, Negate: true}, + }}, + "not (rpm > 3000)", + }, + { + "incomplete leaves are dropped", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + leaf("rpm", ">", 3000), + {Series: "", Operator: ">", Value: 1}, // no series + {Series: "x", Operator: "in", Values: nil}, // empty list + }}, + "rpm > 3000", + }, + {"empty group contributes nothing", &FilterNode{Combinator: "and"}, ""}, + { + "group with only incomplete children contributes nothing", + &FilterNode{Combinator: "and", Children: []*FilterNode{{Series: ""}}}, + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := tt.node.toQuery(true) + if tt.want == "" { + if ok { + t.Fatalf("toQuery() = %q, want ok=false", got) + } + return + } + if !ok { + t.Fatalf("toQuery() ok=false, want %q", tt.want) + } + if got != tt.want { + t.Errorf("toQuery() = %q, want %q", got, tt.want) + } + // Whatever the builder emits must be a valid query. + if _, err := compileQuery(got, resolverFrom(nil)); err != nil { + t.Errorf("compileQuery(%q) error: %v", got, err) + } + }) + } +} + +// The builder is just a structured query author, so a tree and its hand-written +// query equivalent must filter identically. +func TestFilterNodeMatchesQuery(t *testing.T) { + series := map[string][]float64{ + "rpm": {1000, 4000, 4000, 4000}, + "load": {10, 60, 40, 60}, + "state": {10, 20, 30, 10}, + } + resolve := resolverFrom(series) + + // (rpm > 3000 and load > 50) or state in [10, 20] + tree := &FilterNode{Combinator: "or", Children: []*FilterNode{ + {Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000), leaf("load", ">", 50)}}, + {Series: "state", Operator: "in", Values: []float64{10, 20}}, + }} + + src, ok := tree.toQuery(true) + if !ok { + t.Fatal("toQuery() ok=false") + } + q, err := compileQuery(src, resolve) + if err != nil { + t.Fatalf("compileQuery(%q) error: %v", src, err) + } + want := [4]bool{true, true, false, true} // row0 state=10; row1 rpm&load; row3 state=10 + for i := 0; i < 4; i++ { + if got := q.Eval(i); got != want[i] { + t.Errorf("Eval(%d) = %v, want %v (query %q)", i, got, want[i], src) + } + } +} + +func TestRulesToNodeMigration(t *testing.T) { + rules := []Rule{ + {Series: "rpm", Operator: ">", Threshold: 3000}, + {Series: "load", Operator: "<=", Threshold: 80}, + } + got, ok := rulesToNode(rules).toQuery(true) + if !ok { + t.Fatal("toQuery() ok=false") + } + want := "rpm > 3000 and load <= 80" + if got != want { + t.Errorf("migrated query = %q, want %q", got, want) + } +} diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go index 32e58984..07c79982 100644 --- a/pkg/widgets/matrixbuilder/matrixbuilder.go +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -31,6 +31,7 @@ import ( "github.com/roffe/txlogger/pkg/logfile" "github.com/roffe/txlogger/pkg/widgets" "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" "github.com/roffe/txlogger/pkg/widgets/progressmodal" ) @@ -53,6 +54,7 @@ var _ fyne.Widget = (*MatrixBuilder)(nil) type MatrixBuilder struct { widget.BaseWidget + renderMode meshgrid.RenderBackend // values holds every series merged across all loaded log files. All series // share the same length (nrecords); gaps are padded with NaN so samples @@ -91,9 +93,57 @@ type MatrixBuilder struct { nameEntry *widget.Entry display *fyne.Container + // Filter tree: a nested group/condition builder. rootGroup is the live editor + // state (read into a FilterNode tree on demand); filterHolder is the stable + // container the root group is swapped into when a preset is loaded. + rootGroup *filterGroup + filterHolder *fyne.Container + + // Filter query: an optional text query in the matrix builder query language. + // When non-empty it overrides the visual rules above (see currentFilter). + queryEntry *widget.Entry + queryStatus *widget.Label + + // controls is the scrolling left/right column that holds the rule editor. + // Adding or removing rule rows only re-lays-out the rules box itself, so we + // refresh this ancestor to reposition everything below it (see + // refreshControls). + controls *fyne.Container + content fyne.CanvasObject } +// filterChild is one editable node in the visual filter tree: either a group or +// a leaf condition. node reads the widgets into a FilterNode (returning nil for +// an incomplete leaf); object is the canvas object the parent group lays out. +type filterChild interface { + node() *FilterNode + object() fyne.CanvasObject +} + +// filterGroup is a group node in the editor: a combinator (ALL/ANY), an optional +// "not", and an ordered list of child conditions and sub-groups. +type filterGroup struct { + mb *MatrixBuilder + parent *filterGroup // nil for the root group + combinator *widget.Select + negate *widget.Check + children []filterChild + inner *fyne.Container // holds the children plus the add-buttons footer + footer fyne.CanvasObject + container *fyne.Container +} + +// filterCond is a leaf condition in the editor: " ", where +// is a single number, or a comma-separated list when is "in". +type filterCond struct { + parent *filterGroup + seriesSel *widget.SelectEntry + opSel *widget.Select + value *widget.Entry + container *fyne.Container +} + // Preset is the on-disk representation of a matrix builder configuration. It // holds only settings (series, dimensions and axis breakpoints); the learned // matrix values are never stored. @@ -109,17 +159,147 @@ type Preset struct { // which decode to 0 and are treated as the default (no filtering) on load. XTolerance float64 `json:"x_tolerance,omitempty"` YTolerance float64 `json:"y_tolerance,omitempty"` + // Filter is the root of the visual builder's group/condition tree. Omitted in + // presets saved before the tree existed; those carry Rules instead. + Filter *FilterNode `json:"filter,omitempty"` + // Rules is the legacy flat filter list. Read-only now: still decoded for + // backward compatibility (migrated into an implicit ALL-of group on load), but + // no longer written. Older presets that predate filters decode to nil. + Rules []Rule `json:"rules,omitempty"` + // Query is an optional filter written in the query language. When non-empty + // it overrides the builder (matching the live behaviour). Omitted in older + // presets. + Query string `json:"query,omitempty"` +} + +// FilterNode is one node of the visual builder's filter tree, persisted in a +// preset. It is either a group (Series empty: combines Children with Combinator, +// optionally negated) or a leaf condition (Series set: " +// ", or " in " when Operator is "in"). +type FilterNode struct { + Combinator string `json:"combinator,omitempty"` // group: "and" | "or" + Negate bool `json:"negate,omitempty"` // wrap the node in not(...) + Children []*FilterNode `json:"children,omitempty"` // group members + + Series string `json:"series,omitempty"` // leaf series name + Operator string `json:"operator,omitempty"` // leaf comparison operator + Value float64 `json:"value,omitempty"` // leaf threshold (non-"in" ops) + Values []float64 `json:"values,omitempty"` // leaf membership list ("in" op) +} + +// isGroup reports whether n is a group rather than a leaf. A leaf always names a +// series; a group never does. +func (n *FilterNode) isGroup() bool { return n.Series == "" } + +// toQuery renders n as a query-language fragment, returning ok=false when the +// node contributes nothing (an incomplete leaf, or a group with no usable +// children). top suppresses the redundant outer parentheses on the root group. +func (n *FilterNode) toQuery(top bool) (string, bool) { + if n.isGroup() { + parts := make([]string, 0, len(n.Children)) + for _, c := range n.Children { + if s, ok := c.toQuery(false); ok { + parts = append(parts, s) + } + } + if len(parts) == 0 { + return "", false + } + joiner := " and " + if n.Combinator == "or" { + joiner = " or " + } + s := strings.Join(parts, joiner) + if (len(parts) > 1 && !top) || n.Negate { + s = "(" + s + ")" + } + if n.Negate { + s = "not " + s + } + return s, true + } + + if strings.TrimSpace(n.Series) == "" { + return "", false + } + var s string + if n.Operator == "in" { + if len(n.Values) == 0 { + return "", false + } + members := make([]string, len(n.Values)) + for i, v := range n.Values { + members[i] = formatFloat(v) + } + s = fmt.Sprintf("%s in [%s]", n.Series, strings.Join(members, ", ")) + } else { + op := n.Operator + if op == "" { + op = condOperators[0] + } + s = fmt.Sprintf("%s %s %s", n.Series, op, formatFloat(n.Value)) + } + if n.Negate { + s = "not (" + s + ")" + } + return s, true +} + +// rulesToNode migrates a legacy flat rule list into an implicit ALL-of group. +func rulesToNode(rules []Rule) *FilterNode { + root := &FilterNode{Combinator: "and"} + for _, r := range rules { + root.Children = append(root.Children, &FilterNode{ + Series: r.Series, Operator: r.Operator, Value: r.Threshold, + }) + } + return root } +// Rule is a single filter condition. A sample is only counted as a Z-hit when it +// satisfies every active rule: the named series' value at that sample, compared +// against Threshold with Operator, must hold. +type Rule struct { + Series string `json:"series"` + Operator string `json:"operator"` // one of ">", ">=", "<", "<=", "==", "!=", "~" + Threshold float64 `json:"threshold"` +} + +// condOperators lists the operators offered in a condition row, in display +// order. The first entry is the default for a new condition. "~" matches values +// approximately equal to the value (see ruleEpsilonFrac); "in" tests membership +// of a comma-separated list. +var condOperators = []string{">", ">=", "<", "<=", "==", "!=", "~", "in"} + +// combinator labels for a group, mapping to the query language's and/or. +const ( + combinatorAll = "ALL of" // every child must hold -> "and" + combinatorAny = "ANY of" // any child may hold -> "or" +) + +var combinatorOptions = []string{combinatorAll, combinatorAny} + +const ( + // ruleEpsilonFrac sets the half-width of the "~" (approximately-equal) + // operator as a fraction of the threshold's magnitude, so the window scales + // with the value being matched (e.g. 1% of an RPM target vs. 1% of a lambda + // target). + ruleEpsilonFrac = 0.01 + // ruleEpsilonMin is a small absolute floor so "~" stays usable when the + // threshold is at or near zero (where a relative window would collapse to 0). + ruleEpsilonMin = 1e-6 +) + // New creates an empty MatrixBuilder. Log files are loaded from within the // widget via a native file dialog. -func New() *MatrixBuilder { +func New(renderMode meshgrid.RenderBackend) *MatrixBuilder { mb := &MatrixBuilder{ values: make(map[string][]float64), cols: defaultCols, rows: defaultRows, xTolerance: defaultTolerance, yTolerance: defaultTolerance, + renderMode: renderMode, } mb.ExtendBaseWidget(mb) mb.xAxis = make([]float64, mb.cols) @@ -183,7 +363,7 @@ func (mb *MatrixBuilder) buildUI() { mb.yBox = container.NewHBox() mb.rebuildAxisEntries() - controls := container.NewVBox( + mb.controls = container.NewVBox( // widget.NewLabelWithStyle("Series", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), colsRow, rowsRow, @@ -194,19 +374,24 @@ func (mb *MatrixBuilder) buildUI() { widget.NewSeparator(), mb.buildToleranceSection(), widget.NewSeparator(), - buildBtn, - widget.NewSeparator(), - mb.status, - widget.NewSeparator(), + mb.buildFilterSection(), xlayout.NewSpacer(), - mb.buildPresetSection(), + mb.status, ) - left := container.NewVScroll(controls) - left.SetMinSize(fyne.NewSize(240, 0)) + controlScroll := container.NewVScroll(mb.controls) + controlScroll.SetMinSize(fyne.NewSize(240, 0)) - right := container.NewVScroll( - mb.buildLogSection(), + logList := container.NewBorder( + nil, + container.NewVBox( + mb.buildPresetSection(), + ), + nil, + nil, + container.NewVScroll( + mb.buildLogSection(), + ), ) mb.display = container.NewStack(mb.placeholder()) @@ -219,14 +404,33 @@ func (mb *MatrixBuilder) buildUI() { container.NewHScroll(mb.yBox), ) - mb.content = container.NewBorder( - nil, - nil, - right, - left, - container.NewBorder(nil, yPanel, container.NewVBox(widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - mb.xBox), nil, mb.display), + mainSplit := container.NewHSplit( + container.NewBorder( + yPanel, + buildBtn, + container.NewVBox( + widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.xBox, + ), + nil, + mb.display, + ), + logList, ) + mainSplit.Offset = 0.9 + + /* + mb.content = container.NewBorder( + nil, + nil, + controlScroll, + nil, + mainSplit, + ) + */ + split := container.NewHSplit(controlScroll, mainSplit) + split.Offset = 0.25 + mb.content = split } func (mb *MatrixBuilder) placeholder() fyne.CanvasObject { @@ -282,6 +486,400 @@ func (mb *MatrixBuilder) newToleranceSlider(isX bool) *widget.Slider { return s } +// --- filter tree --- + +// buildFilterSection builds the visual query builder: a nestable tree of groups +// (ALL-of / ANY-of, optionally negated) and leaf conditions, plus a free-text +// query box that overrides the tree when non-empty. +func (mb *MatrixBuilder) buildFilterSection() fyne.CanvasObject { + mb.rootGroup = mb.newFilterGroup(nil, &FilterNode{Combinator: "and"}) + mb.filterHolder = container.NewVBox(mb.rootGroup.object()) + + // The query box accepts the matrix builder query language: comparisons + // (> >= < <= == != ~), the "in [...]" membership test, joined with and/or/not + // and grouped with (). A leading "if" is optional. When non-empty it overrides + // the visual builder above. + mb.queryEntry = widget.NewMultiLineEntry() + mb.queryEntry.SetMinRowsVisible(2) + mb.queryEntry.Wrapping = fyne.TextWrapWord + mb.queryEntry.SetPlaceHolder("e.g. (ActualIn.n_Engine > 3000 and Out.X_AccPedal > 50) or ECMStat.ST_ActiveAirDem in [10, 20]") + mb.queryEntry.OnChanged = func(string) { mb.validateQuery() } + + mb.queryStatus = widget.NewLabel("") + mb.queryStatus.Wrapping = fyne.TextWrapWord + + fromTreeBtn := widget.NewButton("Builder->Query", func() { + if s, ok := mb.filterTree().toQuery(true); ok { + mb.queryEntry.SetText(s) + } else { + mb.queryEntry.SetText("") + } + }) + fromTreeBtn.Importance = widget.LowImportance + + return container.NewVBox( + widget.NewLabelWithStyle("Filters (count a hit when…)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.filterHolder, + widget.NewSeparator(), + widget.NewLabel("…or a query (overrides the builder when not empty):"), + mb.queryEntry, + container.NewBorder(nil, nil, nil, fromTreeBtn, mb.queryStatus), + ) +} + +// validateQuery parses the query box live and reports its status: a syntax +// error, any referenced series that aren't loaded, or "ok". An empty box defers +// to the visual builder. +func (mb *MatrixBuilder) validateQuery() { + src := strings.TrimSpace(mb.queryEntry.Text) + if src == "" { + mb.queryStatus.SetText("Empty — using the builder above") + return + } + q, err := compileQuery(src, mb.resolve) + if err != nil { + mb.queryStatus.SetText("⚠ " + err.Error()) + return + } + // Only warn about unknown series once logs are loaded; before that every + // name is "unknown" and the noise isn't helpful. + if len(mb.values) > 0 { + var unknown []string + for _, s := range q.Series() { + if _, ok := mb.values[s]; !ok { + unknown = append(unknown, s) + } + } + if len(unknown) > 0 { + mb.queryStatus.SetText("⚠ unknown series: " + strings.Join(unknown, ", ")) + return + } + } + mb.queryStatus.SetText("✓ query active") +} + +// filterTree reads the live editor into a FilterNode tree (the root group). +func (mb *MatrixBuilder) filterTree() *FilterNode { return mb.rootGroup.node() } + +// setFilterTree replaces the editor with a fresh tree built from model, swapping +// the new root group into the stable holder. A nil or non-group model becomes an +// empty (or single-condition) ALL-of root so there is always a group to edit. +func (mb *MatrixBuilder) setFilterTree(model *FilterNode) { + switch { + case model == nil: + model = &FilterNode{Combinator: "and"} + case !model.isGroup(): + model = &FilterNode{Combinator: "and", Children: []*FilterNode{model}} + } + mb.rootGroup = mb.newFilterGroup(nil, model) + mb.filterHolder.Objects = []fyne.CanvasObject{mb.rootGroup.object()} + mb.filterHolder.Refresh() + mb.refreshControls() +} + +// refreshControls re-lays-out the scrolling controls column after the filter +// tree changes shape. A nested Add/Remove only re-runs the affected group's own +// layout, leaving its ancestors (and everything below the filter section) at +// their old positions until the column is refreshed. +func (mb *MatrixBuilder) refreshControls() { + if mb.controls != nil { + mb.controls.Refresh() + } +} + +// indented wraps obj with a left margin so nested groups read as nested. +func indented(obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, layout.NewFixedWidth(14, xlayout.NewSpacer()), nil, obj) +} + +// --- group node --- + +// newFilterGroup builds a group widget seeded from model, recursively creating +// its child conditions and sub-groups. parent is nil for the root group. +func (mb *MatrixBuilder) newFilterGroup(parent *filterGroup, model *FilterNode) *filterGroup { + g := &filterGroup{mb: mb, parent: parent} + + g.combinator = widget.NewSelect(combinatorOptions, func(string) {}) + if model != nil && model.Combinator == "or" { + g.combinator.SetSelected(combinatorAny) + } else { + g.combinator.SetSelected(combinatorAll) + } + + g.negate = widget.NewCheck("not", nil) + if model != nil { + g.negate.SetChecked(model.Negate) + } + + addCond := widget.NewButton("+ condition", func() { g.addChild(mb.newFilterCond(g, nil)) }) + addCond.Importance = widget.LowImportance + addGroup := widget.NewButton("+ group", func() { g.addChild(mb.newFilterGroup(g, &FilterNode{Combinator: "and"})) }) + addGroup.Importance = widget.LowImportance + g.footer = container.NewHBox(addCond, addGroup) + + header := container.NewHBox(layout.NewFixedWidth(96, g.combinator), g.negate) + var headerRow fyne.CanvasObject = header + if parent != nil { + remove := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { parent.removeChild(g) }) + remove.Importance = widget.LowImportance + headerRow = container.NewBorder(nil, nil, nil, remove, header) + } + + g.inner = container.NewVBox() + g.container = container.NewVBox(headerRow, indented(g.inner)) + + if model != nil { + for _, ch := range model.Children { + if ch.isGroup() { + g.children = append(g.children, mb.newFilterGroup(g, ch)) + } else { + g.children = append(g.children, mb.newFilterCond(g, ch)) + } + } + } + g.rebuild() + return g +} + +func (g *filterGroup) object() fyne.CanvasObject { return g.container } + +// node reads the group (and, recursively, its children) into a FilterNode, +// dropping incomplete leaves. A group always yields a node, even when empty, so +// the tree's shape survives a save/load round-trip. +func (g *filterGroup) node() *FilterNode { + combinator := "and" + if g.combinator.Selected == combinatorAny { + combinator = "or" + } + n := &FilterNode{Combinator: combinator, Negate: g.negate.Checked} + for _, c := range g.children { + if cn := c.node(); cn != nil { + n.Children = append(n.Children, cn) + } + } + return n +} + +// addChild appends a new condition or sub-group and re-lays-out the group. +func (g *filterGroup) addChild(c filterChild) { + g.children = append(g.children, c) + g.rebuild() +} + +// removeChild drops c from the group and re-lays-out. +func (g *filterGroup) removeChild(c filterChild) { + for i, child := range g.children { + if child == c { + g.children = append(g.children[:i], g.children[i+1:]...) + break + } + } + g.rebuild() +} + +// rebuild repopulates the group's inner box with its children followed by the +// add-buttons footer, then refreshes the surrounding controls column. +func (g *filterGroup) rebuild() { + g.inner.Objects = g.inner.Objects[:0] + for _, c := range g.children { + g.inner.Add(c.object()) + } + g.inner.Add(g.footer) + g.inner.Refresh() + g.mb.refreshControls() +} + +// eachCond visits every leaf condition in the group's subtree, in order. +func (g *filterGroup) eachCond(fn func(*filterCond)) { + for _, c := range g.children { + switch v := c.(type) { + case *filterCond: + fn(v) + case *filterGroup: + v.eachCond(fn) + } + } +} + +// --- condition node --- + +// newFilterCond builds a leaf-condition widget seeded from model (nil for a +// fresh, empty condition). +func (mb *MatrixBuilder) newFilterCond(parent *filterGroup, model *FilterNode) *filterCond { + c := &filterCond{parent: parent} + + c.seriesSel = widget.NewSelectEntry(mb.order) + c.seriesSel.PlaceHolder = "Series" + + c.value = widget.NewEntry() + // The value field doubles as a list when "in" is selected, so its hint tracks + // the operator. + c.opSel = widget.NewSelect(condOperators, func(op string) { + c.value.SetPlaceHolder(valuePlaceholder(op)) + }) + + op := condOperators[0] + if model != nil && model.Operator != "" { + op = model.Operator + } + c.opSel.SetSelected(op) + c.value.SetPlaceHolder(valuePlaceholder(op)) + + if model != nil { + c.seriesSel.SetText(model.Series) + if model.Operator == "in" { + c.value.SetText(formatList(model.Values)) + } else if model.Series != "" { + c.value.SetText(formatFloat(model.Value)) + } + } + + remove := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { parent.removeChild(c) }) + remove.Importance = widget.LowImportance + + // Series stretches to fill; operator/value/remove are pinned to the right at + // fixed widths so the narrow panel stays readable. The operator box must fit + // the two-char operators plus the dropdown arrow or the Select truncates to "…". + c.container = container.NewBorder(nil, nil, nil, + container.NewHBox( + layout.NewFixedWidth(72, c.opSel), + layout.NewFixedWidth(72, c.value), + remove, + ), + c.seriesSel, + ) + return c +} + +func (c *filterCond) object() fyne.CanvasObject { return c.container } + +// node reads the condition into a FilterNode, returning nil when it is +// incomplete: no series, an empty/invalid value for a comparison, or an empty +// list for "in". Incomplete conditions are dropped rather than matched. +func (c *filterCond) node() *FilterNode { + series := strings.TrimSpace(c.seriesSel.Text) + if series == "" { + return nil + } + op := c.opSel.Selected + if op == "" { + op = condOperators[0] + } + n := &FilterNode{Series: series, Operator: op} + if op == "in" { + n.Values = parseList(c.value.Text) + if len(n.Values) == 0 { + return nil + } + return n + } + v, ok := parseFloatLoose(c.value.Text) + if !ok { + return nil + } + n.Value = v + return n +} + +// valuePlaceholder hints the value field's content for the selected operator. +func valuePlaceholder(op string) string { + if op == "in" { + return "10, 20, …" + } + return "Value" +} + +// formatList renders a membership list as comma-separated numbers. +func formatList(values []float64) string { + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = formatFloat(v) + } + return strings.Join(parts, ", ") +} + +// parseFloatLoose parses a single value, accepting a comma as the decimal point +// (European keyboards). It reports ok=false for blank or unparseable input. +func parseFloatLoose(s string) (float64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64) + if err != nil { + return 0, false + } + return v, true +} + +// parseList parses a comma-separated membership list, skipping blank or +// unparseable members. Here a comma separates values, so it is not treated as a +// decimal point. +func parseList(s string) []float64 { + var out []float64 + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if v, err := strconv.ParseFloat(part, 64); err == nil { + out = append(out, v) + } + } + return out +} + +// approxEqual reports whether v is approximately equal to threshold, using a +// window that scales with the threshold's magnitude (see ruleEpsilonFrac) with a +// small absolute floor (ruleEpsilonMin) so it stays usable near zero. Shared by +// the per-row "~" rule and the query language's "~" operator. +func approxEqual(v, threshold float64) bool { + eps := math.Abs(threshold) * ruleEpsilonFrac + if eps < ruleEpsilonMin { + eps = ruleEpsilonMin + } + return math.Abs(v-threshold) <= eps +} + +// resolve returns series' value at sample i, reporting ok=false when the series +// is absent or its reading is NaN. It is the bridge the compiled query uses to +// read log data. +func (mb *MatrixBuilder) resolve(series string, i int) (float64, bool) { + data, ok := mb.values[series] + if !ok || i < 0 || i >= len(data) { + return 0, false + } + v := data[i] + if math.IsNaN(v) { + return 0, false + } + return v, true +} + +// currentFilter returns the predicate that decides whether a sample counts as a +// hit. A non-empty query box takes precedence over the visual builder; +// otherwise the builder's tree is compiled to a query (an empty tree accepts +// every sample). A query that fails to compile is returned as an error so Build +// surfaces it instead of silently filtering nothing. +func (mb *MatrixBuilder) currentFilter() (func(i int) bool, error) { + src := strings.TrimSpace(mb.queryEntry.Text) + if src == "" { + // The builder is just a structured way to author a query, so compile it + // through the same path rather than maintaining a second evaluator. + if s, ok := mb.filterTree().toQuery(true); ok { + src = s + } + } + if src == "" { + return func(int) bool { return true }, nil + } + q, err := compileQuery(src, mb.resolve) + if err != nil { + return nil, fmt.Errorf("query: %w", err) + } + return q.Eval, nil +} + // rebuildAxisEntries regenerates the editable entry fields for the current // column/row counts, seeding them from the current axis values. func (mb *MatrixBuilder) rebuildAxisEntries() { @@ -296,6 +894,7 @@ func (mb *MatrixBuilder) rebuildAxisEntries() { mb.yBox.Objects = mb.yBox.Objects[:0] for i := 0; i < mb.rows; i++ { mb.yBox.Add(mb.makeAxisEntry(false, i)) + // mb.yBox.Add(xlayout.NewSpacer()) } mb.yBox.Refresh() } @@ -334,7 +933,7 @@ func (mb *MatrixBuilder) makeAxisEntry(isX bool, idx int) fyne.CanvasObject { mb.yEntries[idx] = e // Y breakpoints run horizontally along the bottom: label above a // fixed-width entry so the strip stays compact. - label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignCenter, fyne.TextStyle{}) + label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignLeading, fyne.TextStyle{}) return container.NewVBox(label, layout.NewFixedWidth(64, e)) } @@ -375,6 +974,7 @@ func (mb *MatrixBuilder) autoFill(isX bool) { series = mb.xSeries axis = mb.xAxis } + data, ok := mb.values[series] if !ok || len(data) == 0 { return @@ -437,17 +1037,29 @@ func (mb *MatrixBuilder) analyze() error { sort.Float64s(mb.yAxis) mb.syncAxisEntries() + filter, err := mb.currentFilter() + if err != nil { + return err + } + size := mb.cols * mb.rows sum := make([]float64, size) cnt := make([]int, size) used := 0 skipped := 0 + filtered := 0 for i := 0; i < n; i++ { // Skip rows where any of the three series is missing (NaN padding from // merging logs with differing channel sets). if math.IsNaN(xv[i]) || math.IsNaN(yv[i]) || math.IsNaN(zv[i]) { continue } + // Drop samples that fail any user-defined filter rule before they can + // contribute to a cell. + if !filter(i) { + filtered++ + continue + } c := nearestIndex(mb.xAxis, xv[i]) r := nearestIndex(mb.yAxis, yv[i]) // Reject samples sitting too far from their nearest breakpoint on @@ -476,6 +1088,9 @@ func (mb *MatrixBuilder) analyze() error { if skipped > 0 { msg += fmt.Sprintf(" (%d skipped by tolerance)", skipped) } + if filtered > 0 { + msg += fmt.Sprintf(" (%d filtered)", filtered) + } mb.status.SetText(msg) return nil } @@ -503,6 +1118,7 @@ func (mb *MatrixBuilder) rebuildDisplay() { YLabel: mb.ySeries, ZLabel: mb.zSeries, MeshView: true, + MeshRenderer: mb.renderMode, Editable: true, ColorblindMode: colors.ModeNormal, // The matrix is in-memory only; editing cells just mutates zData. @@ -522,7 +1138,7 @@ func (mb *MatrixBuilder) rebuildDisplay() { func (mb *MatrixBuilder) buildLogSection() fyne.CanvasObject { mb.logsLabel = widget.NewLabel("No log files loaded") - mb.logsLabel.Wrapping = fyne.TextWrapWord + mb.logsLabel.Truncation = fyne.TextTruncateEllipsis addBtn := widget.NewButtonWithIcon("Add log files", theme.FolderOpenIcon(), mb.openLogDialog) clearBtn := widget.NewButtonWithIcon("Clear", theme.ContentClearIcon(), mb.clearLogs) @@ -679,6 +1295,13 @@ func (mb *MatrixBuilder) refreshSeriesOptions() { mb.xSel.SetOptions(mb.order) mb.ySel.SetOptions(mb.order) mb.zSel.SetOptions(mb.order) + mb.rootGroup.eachCond(func(c *filterCond) { + c.seriesSel.SetOptions(mb.order) + }) + // Loaded series changed, so the query's unknown-series check may now differ. + if mb.queryEntry != nil { + mb.validateQuery() + } } func (mb *MatrixBuilder) refreshLogList() { @@ -687,7 +1310,25 @@ func (mb *MatrixBuilder) refreshLogList() { return } mb.logsLabel.SetText(fmt.Sprintf("%d file(s), %d records:\n%s", - len(mb.loadedFiles), mb.nrecords, strings.Join(mb.loadedFiles, "\n"))) + len(mb.loadedFiles), mb.nrecords, buildFilenamesList(mb.loadedFiles))) +} + +func buildFilenamesList(files []string) string { + if len(files) == 0 { + return "" + } + var b strings.Builder + for i, f := range files { + if i > 0 { + b.WriteString("\n") + } + base := filepath.Base(f) + //if len(base) > 25 { + // base = base[:25] + "…" + //} + b.WriteString(base) + } + return b.String() } func nanSlice(n int) []float64 { @@ -714,11 +1355,11 @@ func (mb *MatrixBuilder) buildPresetSection() fyne.CanvasObject { refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), mb.refreshPresets) mb.nameEntry = widget.NewEntry() - mb.nameEntry.SetPlaceHolder("preset name") + mb.nameEntry.SetPlaceHolder("Preset name") saveBtn := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() { name := strings.TrimSpace(mb.nameEntry.Text) if name == "" { - mb.status.SetText("enter a preset name to save") + mb.status.SetText("Enter a preset name to save") return } saved, err := mb.savePreset(name) @@ -780,6 +1421,8 @@ func (mb *MatrixBuilder) savePreset(name string) (string, error) { YAxis: mb.yAxis, XTolerance: mb.xTolerance, YTolerance: mb.yTolerance, + Filter: mb.filterTree(), + Query: strings.TrimSpace(mb.queryEntry.Text), } b, err := json.MarshalIndent(&p, "", " ") if err != nil { @@ -839,6 +1482,16 @@ func (mb *MatrixBuilder) applyPreset(p *Preset) { mb.ySel.SetText(p.YSeries) mb.zSel.SetText(p.ZSeries) + // Replace the filter tree with the preset's, falling back to the legacy flat + // rules for presets saved before the tree existed. Restoring the query box + // fires OnChanged, which re-runs validateQuery. + root := p.Filter + if root == nil && len(p.Rules) > 0 { + root = rulesToNode(p.Rules) + } + mb.setFilterTree(root) + mb.queryEntry.SetText(p.Query) + mb.colsLabel.SetText(strconv.Itoa(mb.cols)) mb.rowsLabel.SetText(strconv.Itoa(mb.rows)) mb.rebuildAxisEntries() diff --git a/pkg/widgets/matrixbuilder/query.go b/pkg/widgets/matrixbuilder/query.go new file mode 100644 index 00000000..ebf2f310 --- /dev/null +++ b/pkg/widgets/matrixbuilder/query.go @@ -0,0 +1,420 @@ +package matrixbuilder + +// This file implements the matrix builder's filter query language. Rather than +// hand-rolling a lexer and parser we lean on Go's own expression parser +// (go/parser) and walk the resulting syntax tree (go/ast). The query grammar is +// deliberately a subset of Go expressions, so once we normalise a few +// human-friendly tokens into their Go equivalents the standard library does the +// hard work of tokenising, applying operator precedence and handling () +// grouping for us. We then "compile" the AST into a tree of small closures that +// can be evaluated cheaply once per log sample. +// +// Normalisation maps the bits that aren't valid Go onto bits that are: +// +// if (rpm > 3000 and load > 50) or boost ~ 1.2 +// (rpm > 3000 && load > 50) || __approx(boost, 1.2) +// +// - a leading "if" is stripped (it reads nicely but carries no meaning) +// - the keywords "and"/"or"/"not" become "&&"/"||"/"!" +// - the approximately-equal operator "a ~ b" becomes a call "__approx(a, b)" +// - the membership test "a in [x, y, z]" becomes a call "__in(a, x, y, z)" +// +// Series names are written verbatim. A bare name (m_Request) parses as an +// *ast.Ident; a dotted name (ActualIn.n_Engine) parses as an *ast.SelectorExpr, +// which we flatten back into the original dotted string. + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "regexp" + "strconv" + "strings" +) + +// boolNode evaluates a condition for the sample at index i. +type boolNode func(i int) bool + +// numNode evaluates a value (a series reading, a literal or arithmetic of them) +// for the sample at index i. ok is false when a referenced series is missing or +// NaN at that sample, which makes every comparison using it fail — matching the +// "drop incomplete samples" behaviour of the older per-row rules. +type numNode func(i int) (float64, bool) + +// queryFilter is a compiled query. Eval reports whether a sample passes; Series +// lists every series the query references, so the UI can warn about names that +// aren't present in the loaded logs. +type queryFilter struct { + eval boolNode + series []string +} + +func (q *queryFilter) Eval(i int) bool { return q.eval(i) } +func (q *queryFilter) Series() []string { return q.series } + +var ( + // A leading "if " (any case) is cosmetic and dropped. \b keeps "iffy" intact. + leadingIfRe = regexp.MustCompile(`(?i)^if\b\s*`) + // "a ~ b" -> "__approx(a, b)". Both operands are simple atoms: a series name + // (optionally dotted) or a number (optionally negative/decimal). This runs + // before the keyword swaps so the operands are still in their raw form. + approxRe = regexp.MustCompile(`([A-Za-z_][\w.]*|-?\d[\d.]*)\s*~\s*(-?\d[\d.]*|[A-Za-z_][\w.]*)`) + // "a in [x, y, z]" -> "__in(a, x, y, z)". The left operand is a simple atom + // (a series name or number); the bracketed list is captured verbatim and + // dropped in as the remaining call args, so go/parser tokenises the members. + // Runs before the keyword swaps so the operand is still in its raw form. + inRe = regexp.MustCompile(`(?i)([A-Za-z_][\w.]*|-?\d[\d.]*)\s+in\s*\[([^\]]*)\]`) + // Keyword operators -> Go operators. \b stops us from rewriting these letters + // when they appear inside a series name (e.g. "Sensor" keeps its "or"). + andRe = regexp.MustCompile(`(?i)\band\b`) + orRe = regexp.MustCompile(`(?i)\bor\b`) + notRe = regexp.MustCompile(`(?i)\bnot\b`) + // go/parser prefixes messages with a "line:col: " position into our + // normalised string, which is meaningless to the user; strip it. + parserPosRe = regexp.MustCompile(`^\d+:\d+:\s*`) +) + +// normalizeQuery rewrites the human-friendly query into a valid Go expression +// string that go/parser can handle. +func normalizeQuery(src string) string { + s := strings.TrimSpace(src) + if s == "" { + return "" + } + s = leadingIfRe.ReplaceAllString(s, "") + s = approxRe.ReplaceAllString(s, "__approx($1, $2)") + s = inRe.ReplaceAllString(s, "__in($1, $2)") + s = andRe.ReplaceAllString(s, "&&") + s = orRe.ReplaceAllString(s, "||") + s = notRe.ReplaceAllString(s, "!") + return strings.TrimSpace(s) +} + +// compileQuery parses and compiles src into a queryFilter. resolve supplies a +// series' value at a sample (returning ok=false for a missing/NaN reading). +// Unknown series are not an error here — they simply never pass — so a query +// can be validated before any logs are loaded. +func compileQuery(src string, resolve func(series string, i int) (float64, bool)) (*queryFilter, error) { + norm := normalizeQuery(src) + if norm == "" { + return nil, fmt.Errorf("empty query") + } + expr, err := parser.ParseExpr(norm) + if err != nil { + return nil, fmt.Errorf("syntax error: %s", cleanParseError(err)) + } + c := &queryCompiler{resolve: resolve, seen: make(map[string]bool)} + node, err := c.compileBool(expr) + if err != nil { + return nil, err + } + return &queryFilter{eval: node, series: c.series}, nil +} + +// queryCompiler walks the AST, building closures and collecting referenced +// series names along the way. +type queryCompiler struct { + resolve func(series string, i int) (float64, bool) + seen map[string]bool + series []string +} + +// compileBool compiles an expression that must yield a boolean: a comparison, an +// approx() call, a && / || combination, a ! negation, or any of those grouped in +// parentheses. +func (c *queryCompiler) compileBool(e ast.Expr) (boolNode, error) { + switch v := e.(type) { + case *ast.ParenExpr: + return c.compileBool(v.X) + case *ast.UnaryExpr: + if v.Op != token.NOT { + return nil, fmt.Errorf("%q can't start a condition", v.Op) + } + inner, err := c.compileBool(v.X) + if err != nil { + return nil, err + } + return func(i int) bool { return !inner(i) }, nil + case *ast.CallExpr: + if id, ok := v.Fun.(*ast.Ident); ok && id.Name == "__in" { + return c.compileIn(v) + } + return c.compileApprox(v) + case *ast.BinaryExpr: + switch v.Op { + case token.LAND: + return c.combine(v, func(a, b bool) bool { return a && b }) + case token.LOR: + return c.combine(v, func(a, b bool) bool { return a || b }) + case token.EQL, token.NEQ, token.LSS, token.LEQ, token.GTR, token.GEQ: + return c.compileCompare(v) + default: + return nil, fmt.Errorf("operator %q can't join conditions; use and/or", v.Op) + } + } + return nil, fmt.Errorf("expected a condition (e.g. rpm > 3000), got %s", exprString(e)) +} + +// combine compiles both sides of a && / || node and merges them with op. +func (c *queryCompiler) combine(v *ast.BinaryExpr, op func(a, b bool) bool) (boolNode, error) { + l, err := c.compileBool(v.X) + if err != nil { + return nil, err + } + r, err := c.compileBool(v.Y) + if err != nil { + return nil, err + } + return func(i int) bool { return op(l(i), r(i)) }, nil +} + +// compileCompare compiles a comparison "value op value". If either side is +// missing/NaN at a sample the comparison is false. +func (c *queryCompiler) compileCompare(v *ast.BinaryExpr) (boolNode, error) { + l, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + r, err := c.compileNum(v.Y) + if err != nil { + return nil, err + } + op := v.Op + return func(i int) bool { + a, ok := l(i) + if !ok { + return false + } + b, ok := r(i) + if !ok { + return false + } + switch op { + case token.EQL: + return a == b + case token.NEQ: + return a != b + case token.LSS: + return a < b + case token.LEQ: + return a <= b + case token.GTR: + return a > b + case token.GEQ: + return a >= b + } + return false + }, nil +} + +// compileApprox compiles the synthesised __approx(a, b) call that backs the "~" +// operator, reusing the same epsilon window as the per-row "~" rule. +func (c *queryCompiler) compileApprox(call *ast.CallExpr) (boolNode, error) { + id, ok := call.Fun.(*ast.Ident) + if !ok || id.Name != "__approx" { + return nil, fmt.Errorf("unknown function %s(...)", exprString(call.Fun)) + } + if len(call.Args) != 2 { + return nil, fmt.Errorf("~ needs a value on each side") + } + l, err := c.compileNum(call.Args[0]) + if err != nil { + return nil, err + } + r, err := c.compileNum(call.Args[1]) + if err != nil { + return nil, err + } + return func(i int) bool { + a, ok := l(i) + if !ok { + return false + } + b, ok := r(i) + if !ok { + return false + } + return approxEqual(a, b) + }, nil +} + +// compileIn compiles the synthesised __in(value, a, b, ...) call that backs the +// "in [...]" membership test. It passes when value equals any list member. A +// missing/NaN value (or list member) is skipped, matching the "drop incomplete +// samples" behaviour of every other comparison. +func (c *queryCompiler) compileIn(call *ast.CallExpr) (boolNode, error) { + if len(call.Args) < 2 { + return nil, fmt.Errorf("in needs a value and a non-empty list, e.g. rpm in [800, 900]") + } + val, err := c.compileNum(call.Args[0]) + if err != nil { + return nil, err + } + set := make([]numNode, 0, len(call.Args)-1) + for _, arg := range call.Args[1:] { + member, err := c.compileNum(arg) + if err != nil { + return nil, err + } + set = append(set, member) + } + return func(i int) bool { + a, ok := val(i) + if !ok { + return false + } + for _, member := range set { + if b, ok := member(i); ok && a == b { + return true + } + } + return false + }, nil +} + +// compileNum compiles an expression that must yield a number: a literal, a +// series reference, arithmetic of those, or any of them grouped/negated. +func (c *queryCompiler) compileNum(e ast.Expr) (numNode, error) { + switch v := e.(type) { + case *ast.ParenExpr: + return c.compileNum(v.X) + case *ast.BasicLit: + if v.Kind != token.INT && v.Kind != token.FLOAT { + return nil, fmt.Errorf("expected a number, got %s", v.Value) + } + f, err := strconv.ParseFloat(v.Value, 64) + if err != nil { + return nil, fmt.Errorf("bad number %q", v.Value) + } + return func(int) (float64, bool) { return f, true }, nil + case *ast.Ident: + return c.seriesNode(v.Name), nil + case *ast.SelectorExpr: + name, ok := dottedName(v) + if !ok { + return nil, fmt.Errorf("unsupported series reference %s", exprString(v)) + } + return c.seriesNode(name), nil + case *ast.UnaryExpr: + switch v.Op { + case token.SUB: + inner, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + return func(i int) (float64, bool) { x, ok := inner(i); return -x, ok }, nil + case token.ADD: + return c.compileNum(v.X) + case token.NOT: + // "!" binds tighter than comparison, so "not rpm > 3000" parses as + // "(not rpm) > 3000". Point the user at the parenthesised form. + return nil, fmt.Errorf("not negates a condition — wrap it, e.g. not (rpm > 3000)") + } + return nil, fmt.Errorf("%q can't be applied to a value", v.Op) + case *ast.BinaryExpr: + return c.compileArith(v) + } + return nil, fmt.Errorf("expected a series name or number, got %s", exprString(e)) +} + +// compileArith compiles +, -, * and / between two values. Division by zero +// yields ok=false rather than an infinity. +func (c *queryCompiler) compileArith(v *ast.BinaryExpr) (numNode, error) { + op := v.Op + switch op { + case token.ADD, token.SUB, token.MUL, token.QUO: + default: + return nil, fmt.Errorf("operator %q can't be used in a value", op) + } + l, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + r, err := c.compileNum(v.Y) + if err != nil { + return nil, err + } + return func(i int) (float64, bool) { + a, ok := l(i) + if !ok { + return 0, false + } + b, ok := r(i) + if !ok { + return 0, false + } + switch op { + case token.ADD: + return a + b, true + case token.SUB: + return a - b, true + case token.MUL: + return a * b, true + case token.QUO: + if b == 0 { + return 0, false + } + return a / b, true + } + return 0, false + }, nil +} + +// seriesNode builds a value node that reads the named series at a sample, and +// records the name as referenced (deduplicated, in first-seen order). +func (c *queryCompiler) seriesNode(name string) numNode { + if !c.seen[name] { + c.seen[name] = true + c.series = append(c.series, name) + } + resolve := c.resolve + return func(i int) (float64, bool) { return resolve(name, i) } +} + +// dottedName flattens a selector chain (a.b.c) back into its original dotted +// string. Only plain identifiers may appear in the chain. +func dottedName(e ast.Expr) (string, bool) { + switch v := e.(type) { + case *ast.Ident: + return v.Name, true + case *ast.SelectorExpr: + base, ok := dottedName(v.X) + if !ok { + return "", false + } + return base + "." + v.Sel.Name, true + } + return "", false +} + +// exprString renders a small, friendly fragment of an expression for error +// messages, covering just the node kinds this grammar can produce. +func exprString(e ast.Expr) string { + switch v := e.(type) { + case *ast.Ident: + return v.Name + case *ast.BasicLit: + return v.Value + case *ast.SelectorExpr: + if n, ok := dottedName(v); ok { + return n + } + return "selector" + case *ast.ParenExpr: + return "(" + exprString(v.X) + ")" + case *ast.UnaryExpr: + return v.Op.String() + exprString(v.X) + case *ast.BinaryExpr: + return exprString(v.X) + " " + v.Op.String() + " " + exprString(v.Y) + case *ast.CallExpr: + return exprString(v.Fun) + "(...)" + } + return "expression" +} + +// cleanParseError trims go/parser's "line:col: " position prefix, which refers +// to our normalised string and would only confuse the user. +func cleanParseError(err error) string { + return parserPosRe.ReplaceAllString(err.Error(), "") +} diff --git a/pkg/widgets/matrixbuilder/query_test.go b/pkg/widgets/matrixbuilder/query_test.go new file mode 100644 index 00000000..c61f9d3b --- /dev/null +++ b/pkg/widgets/matrixbuilder/query_test.go @@ -0,0 +1,139 @@ +package matrixbuilder + +import ( + "math" + "testing" +) + +// resolverFrom builds a query resolver over an in-memory series map, mirroring +// MatrixBuilder.resolve (missing series or a NaN reading fail with ok=false). +func resolverFrom(series map[string][]float64) func(string, int) (float64, bool) { + return func(name string, i int) (float64, bool) { + data, ok := series[name] + if !ok || i < 0 || i >= len(data) { + return 0, false + } + v := data[i] + if math.IsNaN(v) { + return 0, false + } + return v, true + } +} + +func TestCompileQueryEval(t *testing.T) { + // One sample per row; index i selects the row under test. + series := map[string][]float64{ + "rpm": {1000, 4000, 4000, 4000, math.NaN()}, + "load": {10, 60, 40, 60, 60}, + "boost": {0.5, 1.2, 1.205, 0.8, 1.2}, + "ActualIn.n_Engine": {1000, 4000, 4000, 4000, 4000}, + } + resolve := resolverFrom(series) + + tests := []struct { + name string + query string + want [5]bool + }{ + {"simple gt", "rpm > 3000", [5]bool{false, true, true, true, false}}, + {"and", "rpm > 3000 and load > 50", [5]bool{false, true, false, true, false}}, + // Row 4 has rpm=NaN (so rpm<2000 fails) but load=60>50, so the OR holds. + {"or", "rpm < 2000 or load > 50", [5]bool{true, true, false, true, true}}, + { + // Grouping changes the result vs. default precedence (&& binds tighter). + "grouping", "(rpm == 4000 and load == 40) or rpm == 1000", + [5]bool{true, false, true, false, false}, + }, + { + "precedence no parens", "rpm == 1000 or rpm == 4000 and load == 40", + [5]bool{true, false, true, false, false}, + }, + {"if prefix stripped", "if rpm > 3000", [5]bool{false, true, true, true, false}}, + {"not parenthesized", "not (rpm > 3000)", [5]bool{true, false, false, false, true}}, + {"approx hit", "boost ~ 1.2", [5]bool{false, true, true, false, true}}, + {"ne", "load != 60", [5]bool{true, false, true, false, false}}, + {"dotted series", "ActualIn.n_Engine >= 4000", [5]bool{false, true, true, true, true}}, + // load = {10,60,40,60,60}; only the 40 and the three 60s are members. + {"in set", "load in [40, 60]", [5]bool{false, true, true, true, true}}, + {"in single", "load in [10]", [5]bool{true, false, false, false, false}}, + // Dotted name on the left, float members, combined with another clause. + {"in dotted and", "ActualIn.n_Engine in [1000, 4000] and load < 50", [5]bool{true, false, true, false, false}}, + // not wrapping an in-test; row 4's rpm=NaN value fails the test, so not-> true. + {"not in", "not (rpm in [1000, 4000])", [5]bool{false, false, false, false, true}}, + {"series vs series", "rpm > load", [5]bool{true, true, true, true, false}}, + // rpm/100 = {10,40,40,40,NaN}; load = {10,60,40,60,60}. Row 2 ties (40>40 + // false) and row 4's NaN rpm makes the value fail. + {"arithmetic rhs", "load > rpm / 100", [5]bool{false, true, false, true, false}}, + {"nan fails", "rpm == 4000", [5]bool{false, true, true, true, false}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, err := compileQuery(tt.query, resolve) + if err != nil { + t.Fatalf("compileQuery(%q) error: %v", tt.query, err) + } + for i := 0; i < 5; i++ { + if got := q.Eval(i); got != tt.want[i] { + t.Errorf("Eval(%d) = %v, want %v", i, got, tt.want[i]) + } + } + }) + } +} + +func TestCompileQuerySeries(t *testing.T) { + q, err := compileQuery("rpm > 3000 and ActualIn.n_Engine < 6000 or rpm == 0", resolverFrom(nil)) + if err != nil { + t.Fatalf("compileQuery error: %v", err) + } + got := q.Series() + want := []string{"rpm", "ActualIn.n_Engine"} // first-seen order, deduped + if len(got) != len(want) { + t.Fatalf("Series() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Series()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestCompileQueryErrors(t *testing.T) { + resolve := resolverFrom(nil) + for _, query := range []string{ + "", // empty + "rpm >", // dangling operator + "rpm", // not a condition + "rpm + 10", // value, not a condition + "rpm > 3000 and", // dangling and + "(rpm > 3000", // unbalanced paren + "rpm in []", // empty membership list + } { + if _, err := compileQuery(query, resolve); err == nil { + t.Errorf("compileQuery(%q) expected error, got nil", query) + } + } +} + +func TestNormalizeQuery(t *testing.T) { + tests := map[string]string{ + "if rpm > 3000": "rpm > 3000", + "a and b or c": "a && b || c", + "not (a > 1)": "! (a > 1)", + "boost ~ 1.2": "__approx(boost, 1.2)", + "In.p ~ 1.0 and rpm > 100": "__approx(In.p, 1.0) && rpm > 100", + "load in [40, 60]": "__in(load, 40, 60)", + "rpm in [800] or load > 5": "__in(rpm, 800) || load > 5", + // "in" inside a name must survive (no leading whitespace before "in"). + "MainInput > 1": "MainInput > 1", + // "and"/"or" inside names must survive. + "Sensor > 1 and Brand < 2": "Sensor > 1 && Brand < 2", + } + for in, want := range tests { + if got := normalizeQuery(in); got != want { + t.Errorf("normalizeQuery(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index 49abf247..df6a660b 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -156,13 +156,30 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { } // cursorScreenPosition projects the tracking-marker cell position set by -// SetCursor onto the screen. The camera transform is linear, so bilinearly -// interpolating the transformed vertex coordinates lands on the same point -// as transforming the interpolated one. +// SetCursor onto the screen so the marker rides the surface the shader draws. func (m *Meshgrid) cursorScreenPosition() (float32, float32) { - // MapViewer indices are cell-centered while mesh vertices sit on cell - // corners; +0.5 lands the marker mid-cell on the corner grid. SetCursor - // clamps the indices, so sx/sy stay within [0.5, cols-0.5]/[0.5, rows-0.5]. + if m.dataVertexMode() { + // The shader's vertices are the cell values themselves, so the marker + // rides the triangulated data surface: project the cell-centered data + // point at the (fractional) cursor, with the same Ox/Oy convention the + // axis uses. SetCursor clamps the indices to the data grid. + cw, ch := float64(m.cellWidth), float64(m.cellHeight) + ox := (m.cursorX + 0.5) * cw + oy := (float64(m.rows) + 0.5 - m.cursorY) * ch + zr := m.zrange + if zr == 0 { + zr = 1 + } + oz := (m.sampleValue(m.cursorX, m.cursorY) - m.zmin) / zr * m.depth + sx, sy, _ := m.projectOriginal(ox, oy, oz) + return sx, sy + } + + // Corner-averaged fallback: MapViewer indices are cell-centered while mesh + // vertices sit on cell corners; +0.5 lands the marker mid-cell on the corner + // grid. The camera transform is linear, so bilinearly interpolating the + // transformed corners lands on the same point as transforming the + // interpolated one. sx := m.cursorX + 0.5 sy := m.cursorY + 0.5 @@ -184,6 +201,32 @@ func (m *Meshgrid) cursorScreenPosition() (float32, float32) { return float32(float64(m.size.Width)*0.5 + vx), float32(float64(m.size.Height)*0.5 + vy) } +// sampleValue bilinearly interpolates the cell values at the fractional cell +// index (fx = column, fy = row), clamped to the data grid. +func (m *Meshgrid) sampleValue(fx, fy float64) float64 { + cx0 := min(max(int(math.Floor(fx)), 0), m.cols-1) + cy0 := min(max(int(math.Floor(fy)), 0), m.rows-1) + cx1 := min(cx0+1, m.cols-1) + cy1 := min(cy0+1, m.rows-1) + tx := fx - float64(cx0) + if tx < 0 { + tx = 0 + } else if tx > 1 { + tx = 1 + } + ty := fy - float64(cy0) + if ty < 0 { + ty = 0 + } else if ty > 1 { + ty = 1 + } + v00 := m.values[cy0*m.cols+cx0] + v01 := m.values[cy0*m.cols+cx1] + v10 := m.values[cy1*m.cols+cx0] + v11 := m.values[cy1*m.cols+cx1] + return (1-ty)*((1-tx)*v00+tx*v01) + ty*((1-tx)*v10+tx*v11) +} + // getColorWithDepth combines color interpolation and depth enhancement in one step func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { // Get base color from value diff --git a/pkg/widgets/meshgrid/meshgrid_shader.go b/pkg/widgets/meshgrid/meshgrid_shader.go index 23db6bd9..071ee970 100644 --- a/pkg/widgets/meshgrid/meshgrid_shader.go +++ b/pkg/widgets/meshgrid/meshgrid_shader.go @@ -11,20 +11,31 @@ import ( "github.com/roffe/txlogger/pkg/colors" ) -// GPU renderer: the whole mesh is drawn by a single canvas.Shader. The corner +// GPU renderer: the whole mesh is drawn by a single canvas.Shader. The mesh // values live in a small data texture and the camera in a handful of float // uniforms; the fragment shader reconstructs each pixel's orthographic view // ray, walks the grid with a 2D DDA and intersects the two triangles of each // visited cell. Rotating, zooming and panning therefore cost the CPU nothing // but a uniform update - no projection, sorting or rasterization per frame - -// and a data edit re-uploads only the (cols+1)x(rows+1) texture. +// and a data edit re-uploads only the value texture. +// +// The vertices are the raw cell values (dataVertexMode): the texture is cols x +// rows, the grid has (cols-1)x(rows-1) cells and each cell triangulates the +// four data points around it, so the surface passes exactly through every value +// the way T7Suite's mesh does - a low cell only drops the two triangles meeting +// at that corner, never the wider neighbourhood that averaging values onto a +// shared corner grid would. Degenerate 1xN / Nx1 maps fall back to a corner +// grid (cols+1 x rows+1, value averaged per corner); the GLSL is identical, only +// grid_cols/grid_rows, the texture and the centre uniforms differ. // // The grid-space conventions shared between the Go side and the GLSL below: // - one grid unit = one cell = cellWidth (32) logical px before scaling -// - corner (vertex row i, col j) sits at (j, rows-i); +Y is the low-index -// data rows, exactly like the Oy = (vRows-i)*cellHeight CPU layout -// - corner height = z_off + z_gain * value16, reproducing -// Oz = (V - zmin)/zrange * depth in grid units +// - in dataVertexMode the vertex for data cell (r, c) sits at grid (c, +// rows-1-r); +Y is the low-index data rows. center_gx = (cols-1)/2 places +// it half a cell in from the corner grid, aligning the mesh with the axis +// ticks (drawn at cell centres) and with projectOriginal +// - vertex height = z_off + z_gain * value16, reproducing +// (V - zmin)/zrange * depth in grid units // - view = R * ((grid - center) * scale_px) - cam; the viewer sits at +Z // looking along -Z, so the nearest surface has the largest view Z @@ -49,7 +60,7 @@ const meshShaderBody = ` uniform vec2 frame_size; uniform vec4 rect_coords; -uniform sampler2D mesh_tex; // (cols+1)x(rows+1) corner values, 16 bit in RG +uniform sampler2D mesh_tex; // cols x rows cell values (or corner grid), 16 bit in RG uniform sampler2D colormap_tex; // 256x1 value -> base color lookup // camera rotation R, row major; view = R * model @@ -305,7 +316,7 @@ void main() { float h_tl = corner_height(cx, cy + 1.0); float h_tr = corner_height(cx + 1.0, cy + 1.0); - // cell corners; the fill splits along A-C like the CPU rasterizer + // cell corners; the solid fill chooses its diagonal per cell (below) // while the wireframe diagonal runs B-D like the CPU line mesh vec3 A = vec3(cx, cy + 1.0, h_tl); vec3 B = vec3(cx + 1.0, cy + 1.0, h_tr); @@ -322,29 +333,50 @@ void main() { break; } } else { - float t1; - vec3 bc1; - float t2; - vec3 bc2; - bool hit1 = ray_tri(ro, rd, A, B, C, t1, bc1); - bool hit2 = ray_tri(ro, rd, A, C, D, t2, bc2); + // Two triangles per cell, but the diagonal is chosen per cell so the + // fold runs between the two closest corners (the smaller of the two + // diagonal height gaps). A lone outlier - a high peak or a low dip - + // then falls on a single triangle: the other triangle keeps its three + // similar corners as a near-flat plateau and only the outlier's + // triangle slopes. That is the "plateau triangle + sloping triangle" + // look of T7Suite, instead of the whole quad sagging toward the + // outlier. Per-triangle normals let the plateau read flat while the + // slope catches the light. + bool fold_ac = abs(h_tl - h_br) <= abs(h_tr - h_bl); float best_t = BIG; float hit_h = 0.0; - if (hit1) { - best_t = t1; - hit_h = bc1.x * A.z + bc1.y * B.z + bc1.z * C.z; - } - if (hit2 && t2 < best_t) { - best_t = t2; - hit_h = bc2.x * A.z + bc2.y * C.z + bc2.z * D.z; + vec3 hit_n = vec3(0.0, 0.0, -1.0); + float ts; + vec3 bcs; + if (fold_ac) { + if (ray_tri(ro, rd, A, B, C, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * B.z + bcs.z * C.z; + hit_n = cross(B - A, C - A); + } + if (ray_tri(ro, rd, A, C, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * C.z + bcs.z * D.z; + hit_n = cross(C - A, D - A); + } + } else { + if (ray_tri(ro, rd, A, B, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * B.z + bcs.z * D.z; + hit_n = cross(B - A, D - A); + } + if (ray_tri(ro, rd, B, C, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * B.z + bcs.y * C.z + bcs.z * D.z; + hit_n = cross(C - B, D - B); + } } if (best_t < BIG) { float view_z = umax - best_t; vec3 rgb = height_color(hit_h, view_z); - vec3 n = cross(C - A, D - B); float ao = cell_ao(cx, cy); - rgb = shade_surface(rgb, n, light, view_dir, ao); + rgb = shade_surface(rgb, hit_n, light, view_dir, ao); if (mode == 0) { vec3 pa = project_grid(rot, A, pix_scale); @@ -358,6 +390,8 @@ void main() { edge_check(p_dev, pb, pc, B.z, C.z, best_d, line_h, line_z); edge_check(p_dev, pc, pd, C.z, D.z, best_d, line_h, line_z); edge_check(p_dev, pd, pa, D.z, A.z, best_d, line_h, line_z); + // only the cell borders are drawn; the per-cell fold diagonal + // is left in the cell colour so the two triangles blend float lm = line_mask(best_d, half_w); if (lm > 0.0) { rgb = mix(rgb, height_color(line_h, line_z) * 0.45, lm); @@ -405,52 +439,76 @@ func (m *Meshgrid) initShader() { m.updateShaderUniforms() } -// updateShaderData re-encodes the corner values into the mesh data texture -// and the height-mapping uniforms. A fresh image is allocated on purpose: -// the painter re-uploads a texture only when the map entry points at a new -// image. +// dataVertexMode reports whether the shader treats raw cell values as the mesh +// vertices (the T7Suite-style triangulated surface) rather than averaging them +// onto a corner grid. It needs at least 2x2 cells to span a quad between data +// points; a 1xN / Nx1 map falls back to the corner-averaged path, which already +// renders those degenerate "ribbons" sensibly. +func (m *Meshgrid) dataVertexMode() bool { + return m.cols >= 2 && m.rows >= 2 +} + +// updateShaderData re-encodes the mesh values into the data texture and the +// height-mapping uniforms. In dataVertexMode the texture holds the raw cell +// values (one texel per data cell) so the shader's per-cell triangulation +// passes exactly through each value: a low cell only pulls down the two +// triangles touching that corner, never the wider neighbourhood that corner +// averaging would. Otherwise it falls back to the (cols+1)x(rows+1) averaged +// corner grid. A fresh image is allocated on purpose: the painter re-uploads a +// texture only when the map entry points at a new image. func (m *Meshgrid) updateShaderData() { if m.shader == nil { return } - vRows, vCols := m.rows+1, m.cols+1 // Values are normalized against the actual data extent so the 16-bit // quantization keeps full resolution even when zmin/zmax (set by // LoadFloat64s) span a wider range than the data. dataMin, dataMax := math.Inf(1), math.Inf(-1) - for i := range m.vertices { - row := m.vertices[i] - for j := range row { - v := row[j].V - if v < dataMin { - dataMin = v - } - if v > dataMax { - dataMax = v - } + for _, v := range m.values { + if v < dataMin { + dataMin = v + } + if v > dataMax { + dataMax = v } } dataRange := dataMax - dataMin + encode := func(v float64) color.RGBA { + norm := 0.0 + if dataRange > 0 { + norm = (v - dataMin) / dataRange + } + q := uint16(norm*65535 + 0.5) + return color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff} + } - img := image.NewRGBA(image.Rect(0, 0, vCols, vRows)) - for i := 0; i < vRows; i++ { - row := m.vertices[i] - for j := 0; j < vCols; j++ { - norm := 0.0 - if dataRange > 0 { - norm = (row[j].V - dataMin) / dataRange + var img *image.RGBA + if m.dataVertexMode() { + // One texel per data cell; cell (r, c) is the vertex at grid (c, + // rows-1-r), so data row 0 stays at the far (high-Y) edge. + img = image.NewRGBA(image.Rect(0, 0, m.cols, m.rows)) + for r := 0; r < m.rows; r++ { + for c := 0; c < m.cols; c++ { + img.SetRGBA(c, m.rows-1-r, encode(m.values[r*m.cols+c])) + } + } + } else { + // Corner-averaged grid: vertex row i sits at grid Y rows-i, which is + // also its texture row. + vRows, vCols := m.rows+1, m.cols+1 + img = image.NewRGBA(image.Rect(0, 0, vCols, vRows)) + for i := 0; i < vRows; i++ { + row := m.vertices[i] + for j := 0; j < vCols; j++ { + img.SetRGBA(j, m.rows-i, encode(row[j].V)) } - q := uint16(norm*65535 + 0.5) - // vertex row i sits at grid Y rows-i (data row 0 is the far - // edge), which is also its texture row - img.SetRGBA(j, m.rows-i, color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff}) } } m.shader.Textures["mesh_tex"] = img - // Heights in grid units: z_off + z_gain*norm reproduces the CPU - // Oz = (V - zmin)/zrange * depth, in units of one cell. + // Heights in grid units: z_off + z_gain*norm reproduces + // (V - zmin)/zrange * depth, in units of one cell. zr := m.zrange if zr == 0 { zr = 1 @@ -515,16 +573,32 @@ func (m *Meshgrid) updateShaderUniforms() { r := m.cameraRotation cw := float64(m.cellWidth) + // Grid extent and centre depend on the surface model. In dataVertexMode the + // vertices are the cols x rows data points, so there are (cols-1)x(rows-1) + // cells and the data point for column c sits half a cell in from the corner + // grid (centre_gx = (cols-1)/2), which keeps the mesh aligned with the axis + // ticks (drawn at cell centres) and with projectOriginal. The corner-grid + // fallback keeps the old cols x rows cells and centre derived from the + // averaged vertices. + gridCols, gridRows := float32(m.cols), float32(m.rows) + centerGx := float32(m.centerX / cw) + centerGy := float32(m.centerY/cw - 1) + if m.dataVertexMode() { + gridCols, gridRows = float32(m.cols-1), float32(m.rows-1) + centerGx = float32(float64(m.cols-1) / 2) + centerGy = float32(float64(m.rows-1) / 2) + } + u := m.shader.Uniforms u["r0"], u["r1"], u["r2"] = float32(r[0][0]), float32(r[0][1]), float32(r[0][2]) u["r3"], u["r4"], u["r5"] = float32(r[1][0]), float32(r[1][1]), float32(r[1][2]) u["r6"], u["r7"], u["r8"] = float32(r[2][0]), float32(r[2][1]), float32(r[2][2]) - u["grid_cols"] = float32(m.cols) - u["grid_rows"] = float32(m.rows) + u["grid_cols"] = gridCols + u["grid_rows"] = gridRows u["scale_px"] = float32(cw * m.scale) u["height_units"] = float32(m.depth / cw) - u["center_gx"] = float32(m.centerX / cw) - u["center_gy"] = float32(m.centerY/cw - 1) + u["center_gx"] = centerGx + u["center_gy"] = centerGy u["center_gz"] = float32(m.centerZ / cw) u["cam_x"] = float32(m.cameraPosition[0]) u["cam_y"] = float32(m.cameraPosition[1]) diff --git a/pkg/widgets/meshgrid/meshgrid_shader_test.go b/pkg/widgets/meshgrid/meshgrid_shader_test.go index 493472fd..96a242ff 100644 --- a/pkg/widgets/meshgrid/meshgrid_shader_test.go +++ b/pkg/widgets/meshgrid/meshgrid_shader_test.go @@ -11,32 +11,35 @@ import ( "github.com/roffe/txlogger/pkg/colors" ) -// The mesh data texture plus z_off/z_gain must reproduce every corner height -// (Oz in grid units) and the colormap index the CPU renderer would use. +// In dataVertexMode the data texture plus z_off/z_gain must reproduce every +// cell's flat-top height (V - zmin)/zrange * depth in grid units. Data cell +// (r, c) is stored at texel (c, rows-1-r) so grid Y runs with the data rows. func TestShaderDataEncoding(t *testing.T) { m := testGrid(t) + if !m.dataVertexMode() { + t.Fatalf("testGrid is %dx%d, expected dataVertexMode", m.cols, m.rows) + } tex, ok := m.shader.Textures["mesh_tex"].(*image.RGBA) if !ok { t.Fatal("mesh_tex missing or not RGBA") } - if tex.Bounds().Dx() != m.cols+1 || tex.Bounds().Dy() != m.rows+1 { - t.Fatalf("mesh_tex is %v, want %dx%d", tex.Bounds(), m.cols+1, m.rows+1) + if tex.Bounds().Dx() != m.cols || tex.Bounds().Dy() != m.rows { + t.Fatalf("mesh_tex is %v, want %dx%d", tex.Bounds(), m.cols, m.rows) } zOff := float64(m.shader.Uniforms["z_off"]) zGain := float64(m.shader.Uniforms["z_gain"]) - cw := float64(m.cellWidth) + hmax := m.depth / float64(m.cellWidth) - for i := 0; i <= m.rows; i++ { - for j := 0; j <= m.cols; j++ { - // vertex row i lives at texture row rows-i (grid Y up) - c := tex.RGBAAt(j, m.rows-i) - norm := (float64(c.R)*256 + float64(c.G)) / 65535 + for r := 0; r < m.rows; r++ { + for c := 0; c < m.cols; c++ { + px := tex.RGBAAt(c, m.rows-1-r) + norm := (float64(px.R)*256 + float64(px.G)) / 65535 gotH := zOff + zGain*norm - wantH := m.vertices[i][j].Oz / cw - // 16-bit quantization of a ~12.5 unit range + wantH := (m.values[r*m.cols+c] - m.zmin) / m.zrange * hmax + // 16-bit quantization of the encoded value range if math.Abs(gotH-wantH) > zGain/65535+1e-6 { - t.Fatalf("corner (%d,%d): height %v, want %v", i, j, gotH, wantH) + t.Fatalf("cell (%d,%d): height %v, want %v", r, c, gotH, wantH) } } } @@ -86,7 +89,10 @@ func TestShaderProjectionMatchesVertices(t *testing.T) { for i := 0; i <= m.rows; i += 4 { for j := 0; j <= m.cols; j += 4 { v := m.vertices[i][j] - g := [3]float64{float64(j), float64(m.rows - i), v.Oz / cw} + // dataVertexMode shifts the grid half a cell: the averaged corner + // (i, j) maps to shader grid (j-0.5, rows-i-0.5), which projects to + // the same screen point as the CPU corner transform. + g := [3]float64{float64(j) - 0.5, float64(m.rows-i) - 0.5, v.Oz / cw} var view [3]float64 for a := 0; a < 3; a++ { diff --git a/pkg/widgets/meshgrid/meshgrid_surface.go b/pkg/widgets/meshgrid/meshgrid_surface.go index ddbb5406..afbea12f 100644 --- a/pkg/widgets/meshgrid/meshgrid_surface.go +++ b/pkg/widgets/meshgrid/meshgrid_surface.go @@ -32,10 +32,14 @@ type quadRef struct { const surfaceEdgeFade = 0.45 // drawSurface fills each grid cell with two Gouraud-shaded triangles, -// back-to-front. A fixed directional light flat-shades each quad so the -// surface relief stays visible even where the value color barely changes. -// When edges is true the cell outline is drawn right after its fill, which -// keeps lines on hidden faces correctly occluded by nearer quads. +// back-to-front. Like the shader backend, the split diagonal is chosen per cell +// to fold between the two closest corners (by value, so it stays put as the +// camera rotates): a lone peak or dip then lands on a single triangle and the +// other stays a flat plateau, instead of the whole quad sagging toward it. Each +// triangle is flat-shaded from its own normal so the plateau reads flat while +// the slope catches the light. When edges is true the cell outline is drawn +// right after its fill, which keeps lines on hidden faces correctly occluded by +// nearer quads. func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []color.RGBA, edges bool) { // One quad per data cell; the corner-vertex grid is (rows+1) x (cols+1). quads := m.scratchQuads[:0] @@ -67,19 +71,34 @@ func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []co vCols := m.cols + 1 for _, q := range quads { - ai := q.i*vCols + q.j // top-left - bi := ai + 1 // top-right - di := ai + vCols // bottom-left - ci := di + 1 // bottom-right - - shade := m.quadShade(q.i, q.j, lx, ly, lz) - ca := fadeColor(vertCol[ai], shade) - cb := fadeColor(vertCol[bi], shade) - cc := fadeColor(vertCol[ci], shade) - cd := fadeColor(vertCol[di], shade) - - fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[ci], projY[ci], ca, cb, cc) - fillTriangle(img, projX[ai], projY[ai], projX[ci], projY[ci], projX[di], projY[di], ca, cc, cd) + i, j := q.i, q.j + ai := i*vCols + j // top-left + bi := ai + 1 // top-right + di := ai + vCols // bottom-left + ci := di + 1 // bottom-right + + a := &m.vertices[i][j] + b := &m.vertices[i][j+1] + c := &m.vertices[i+1][j+1] + d := &m.vertices[i+1][j] + + // Fold along whichever diagonal has the smaller corner-value gap, so an + // outlier is isolated in one sloping triangle (see the doc comment). + if math.Abs(a.V-c.V) <= math.Abs(b.V-d.V) { + s1 := triShade(a, b, c, lx, ly, lz) + s2 := triShade(a, c, d, lx, ly, lz) + fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[ci], projY[ci], + fadeColor(vertCol[ai], s1), fadeColor(vertCol[bi], s1), fadeColor(vertCol[ci], s1)) + fillTriangle(img, projX[ai], projY[ai], projX[ci], projY[ci], projX[di], projY[di], + fadeColor(vertCol[ai], s2), fadeColor(vertCol[ci], s2), fadeColor(vertCol[di], s2)) + } else { + s1 := triShade(a, b, d, lx, ly, lz) + s2 := triShade(b, c, d, lx, ly, lz) + fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[di], projY[di], + fadeColor(vertCol[ai], s1), fadeColor(vertCol[bi], s1), fadeColor(vertCol[di], s1)) + fillTriangle(img, projX[bi], projY[bi], projX[ci], projY[ci], projX[di], projY[di], + fadeColor(vertCol[bi], s2), fadeColor(vertCol[ci], s2), fadeColor(vertCol[di], s2)) + } if edges { ea := fadeColor(vertCol[ai], surfaceEdgeFade) @@ -122,6 +141,30 @@ func (m *Meshgrid) quadShade(i, j int, lx, ly, lz float64) float64 { return 0.6 + 0.4*dot } +// triShade computes a flat Lambert term for one triangle from its view-space +// normal (cross of two edges). The absolute dot product is used since the +// surface is single-sided and may be viewed from below. This is the per-cell +// quadShade applied per triangle so each facet of the chosen split shades on +// its own slope. +func triShade(a, b, c *Vertex, lx, ly, lz float64) float64 { + ux, uy, uz := b.X-a.X, b.Y-a.Y, b.Z-a.Z + vx, vy, vz := c.X-a.X, c.Y-a.Y, c.Z-a.Z + + nx := uy*vz - uz*vy + ny := uz*vx - ux*vz + nz := ux*vy - uy*vx + + nl := math.Sqrt(nx*nx + ny*ny + nz*nz) + if nl == 0 { + return 1 + } + dot := (nx*lx + ny*ly + nz*lz) / nl + if dot < 0 { + dot = -dot + } + return 0.6 + 0.4*dot +} + // fillTriangle rasterizes a triangle with per-vertex (Gouraud) color // interpolation using incremental integer edge functions, clipped to the // image bounds via the bounding box. diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index 24cefa6c..47494c34 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -17,7 +17,7 @@ func (mw *MainWindow) openMatrixBuilder() { mw.wm.Raise(w) return } - inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New()) + inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New(mw.settings.GetMeshRenderer())) inner.Icon = theme.GridIcon() mw.wm.Add(inner) inner.Resize(fyne.NewSize(1000, 720)) From 709f7b85765f21c7bd36dd64de2fde48099e6a71 Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 17 Jun 2026 20:57:10 +0200 Subject: [PATCH 51/93] experiment --- pkg/widgets/boosttuner/boosttuner.go | 444 +++++++++++++++++++++ pkg/widgets/boosttuner/integration_test.go | 53 +++ pkg/widgets/boosttuner/pid.go | 179 +++++++++ pkg/widgets/boosttuner/pidtune.go | 299 ++++++++++++++ pkg/widgets/boosttuner/pidtune_test.go | 79 ++++ pkg/widgets/boosttuner/regmap.go | 375 +++++++++++++++++ pkg/widgets/boosttuner/regmap_test.go | 83 ++++ pkg/widgets/boosttuner/sim.go | 278 +++++++++++++ pkg/widgets/boosttuner/sim_test.go | 74 ++++ pkg/windows/mainWindow_toolbar.go | 51 +++ 10 files changed, 1915 insertions(+) create mode 100644 pkg/widgets/boosttuner/boosttuner.go create mode 100644 pkg/widgets/boosttuner/integration_test.go create mode 100644 pkg/widgets/boosttuner/pid.go create mode 100644 pkg/widgets/boosttuner/pidtune.go create mode 100644 pkg/widgets/boosttuner/pidtune_test.go create mode 100644 pkg/widgets/boosttuner/regmap.go create mode 100644 pkg/widgets/boosttuner/regmap_test.go create mode 100644 pkg/widgets/boosttuner/sim.go create mode 100644 pkg/widgets/boosttuner/sim_test.go diff --git a/pkg/widgets/boosttuner/boosttuner.go b/pkg/widgets/boosttuner/boosttuner.go new file mode 100644 index 00000000..dde8a718 --- /dev/null +++ b/pkg/widgets/boosttuner/boosttuner.go @@ -0,0 +1,444 @@ +// Package boosttuner provides a widget that helps auto-tune the Trionic 7 boost +// (APC) controller from logged data and writes the result back into the loaded +// binary. +// +// The T7 boost controller is a feedforward + PID loop (see Boost.c in the EU03 +// source): +// +// PWMCalc = RegConValue // feedforward: BoostCal.RegMap[SetValue, rpm] +// + Adaption // learned offset (BoostAdap.Adaption) +// + PFac + IFac + DFac // PID on LoadDiff = SetValue - m_AirInlet +// + env compensation // temp / altitude / E85 / noise reduction +// +// PWMCalc is the wastegate solenoid duty cycle in 0.1% units, clamped 2..98%. +// +// RegMap is the feedforward map and can be genuinely learned: at samples where +// the loop is settled and on target, the duty the feedforward *should* have +// supplied equals RegConValue plus everything the loop was adding to correct it +// (PFac + IFac + DFac + Adaption). Folding that sum back into RegMap leaves the +// loop with less to correct. The PID maps cannot be cleanly learned this way, so +// they are handled separately (heuristic suggestions + a replay simulator). +// +// Units note: the controller internals (RegConValue, P/I/D, Adaption, PWMCalc) +// are logged in raw 0.1% units (correction factor 1, e.g. 450 == 45.0%), while +// the BoostCal.RegMap symbol stores % (correction factor 0.1, e.g. 45.0). We +// learn in raw units and divide by dutyRawPerPct to land in the % the map uses. +package boosttuner + +import ( + "fmt" + "io" + "log" + "math" + "path/filepath" + "sort" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + symbol "github.com/roffe/ecusymbol" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/logfile" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/progressmodal" +) + +// dutyRawPerPct converts the controller's raw 0.1% duty units into the % units +// the BoostCal.RegMap symbol stores (450 raw -> 45.0%). +const dutyRawPerPct = 10.0 + +var _ fyne.Widget = (*BoostTuner)(nil) + +// Config wires the tuner to the loaded binary. Symbols is read for the current +// maps and their axes; Save persists an edited map back into the binary (and to +// disk). Both are supplied by the main window, which owns mw.fw and the filename. +type Config struct { + // Symbols is the currently loaded binary (mw.fw). Used read-only here. + Symbols symbol.SymbolCollection + // Save writes data (in engineering units) into the named symbol and persists + // the binary to disk, taking a one-time backup on first write. nil disables + // the "Apply to binary" buttons. + Save func(symbolName string, data []float64) error + + MeshRenderer meshgrid.RenderBackend + Colorblind colors.ColorBlindMode +} + +// channel is a logical signal the tuner needs, with the candidate series names to +// look for in a log (first present wins) and a human description for the checklist. +type channel struct { + key string + candidates []string + desc string +} + +// requiredChannels lists the log signals the RegMap learner and simulator rely +// on. Boost.SetValue is the exact load value the ECU feeds into the RegMap X +// lookup; m_Request is the same quantity under its airmass-master name and is a +// fallback. m_AirInletBoost is the airmass the loop regulates against. +var requiredChannels = []channel{ + {"rpm", []string{"ActualIn.n_Engine"}, "Engine speed (RegMap Y axis)"}, + {"setValue", []string{"Boost.SetValue", "m_Request", "AirMassMast.m_Request"}, "Load set value (RegMap X axis)"}, + {"regCon", []string{"BoostProt.RegConValue"}, "Feedforward duty from RegMap"}, + {"pFac", []string{"BoostProt.PFac"}, "P part"}, + {"iFac", []string{"BoostProt.IFac"}, "I part"}, + {"dFac", []string{"BoostProt.DFac"}, "D part"}, + {"adaption", []string{"BoostAdap.Adaption"}, "Adaption offset"}, + {"loadDiff", []string{"BoostProt.LoadDiff"}, "Load error (SetValue - airmass)"}, + {"pwmCalc", []string{"BoostProt.PWMCalc"}, "Total calculated duty"}, + {"pwm2pct", []string{"BoostProt.ST_PWM2Perc"}, "Open-loop/2% flag"}, + {"airInlet", []string{"MAF.m_AirInletBoost", "MAF.m_AirInlet"}, "Actual airmass"}, +} + +type BoostTuner struct { + widget.BaseWidget + cfg Config + + // values holds every series merged across all loaded log files, row-aligned + // with NaN padding (same scheme as the matrix builder). + values map[string][]float64 + order []string + loadedFiles []string + nrecords int + + // resolved maps each logical channel key to the actual series name found in + // the loaded logs (empty when missing). + resolved map[string]string + + // RegMap state (see regmap.go). + rmAxisX, rmAxisY []float64 // breakpoints read from the binary + rmCols, rmRows int + rmCurrent, rmLearned, rmDelta []float64 // engineering units (%) + rmCounts []int + rmBuilt bool + + // RegMap tuning parameters. + onTarget float64 // accept samples with |LoadDiff| <= this (mg/c) + rpmStab float64 // reject when |rpm step| exceeds this (rpm) + loadStab float64 // reject when |SetValue step| exceeds this (mg/c) + minSamples int // cells with fewer hits keep their current value + blend float64 // fraction (0..1) of the learned change to apply + + // PID editors keyed by "P"/"I"/"D" (see pid.go). + pidEditors map[string]*pidEditor + + // widgets + logsLabel *widget.Label + channelList *fyne.Container + rmStatus *widget.Label + rmView *widget.Select + rmDisplay *fyne.Container + + tabs *container.AppTabs + content fyne.CanvasObject +} + +// New builds an empty tuner bound to the loaded binary in cfg. +func New(cfg Config) *BoostTuner { + bt := &BoostTuner{ + cfg: cfg, + values: make(map[string][]float64), + resolved: make(map[string]string), + onTarget: 30, + rpmStab: 150, + loadStab: 50, + minSamples: 5, + blend: 1.0, + } + bt.ExtendBaseWidget(bt) + bt.buildUI() + return bt +} + +func (bt *BoostTuner) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(bt.content) +} + +func (bt *BoostTuner) buildUI() { + bt.tabs = container.NewAppTabs( + container.NewTabItemWithIcon("Logs", theme.FolderOpenIcon(), bt.buildLogsTab()), + container.NewTabItemWithIcon("RegMap", theme.GridIcon(), bt.buildRegMapTab()), + container.NewTabItemWithIcon("PID maps", theme.GridIcon(), bt.buildPIDTab()), + container.NewTabItemWithIcon("Simulator", theme.MediaPlayIcon(), bt.buildSimTab()), + ) + bt.content = bt.tabs +} + +// --- Logs tab --- + +func (bt *BoostTuner) buildLogsTab() fyne.CanvasObject { + bt.logsLabel = widget.NewLabel("No log files loaded") + bt.logsLabel.Wrapping = fyne.TextWrapWord + + addBtn := widget.NewButtonWithIcon("Add log files", theme.FolderOpenIcon(), bt.openLogDialog) + clearBtn := widget.NewButtonWithIcon("Clear", theme.ContentClearIcon(), bt.clearLogs) + + bt.channelList = container.NewVBox() + bt.refreshChannelList() + + intro := widget.NewLabel( + "Load logs from boost pulls (ideally full-throttle runs across the rev range), " + + "then use the RegMap tab to learn the feedforward map. The channels below " + + "must be present in the logs.") + intro.Wrapping = fyne.TextWrapWord + + return container.NewBorder( + container.NewVBox( + intro, + container.NewGridWithColumns(2, addBtn, clearBtn), + bt.logsLabel, + widget.NewSeparator(), + widget.NewLabelWithStyle("Required channels", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + ), + nil, nil, nil, + container.NewVScroll(bt.channelList), + ) +} + +// refreshChannelList redraws the per-channel present/missing checklist against the +// currently loaded logs. +func (bt *BoostTuner) refreshChannelList() { + bt.channelList.Objects = bt.channelList.Objects[:0] + for _, ch := range requiredChannels { + name := bt.resolved[ch.key] + var icon fyne.Resource + var detail string + if name != "" { + icon = theme.ConfirmIcon() + detail = name + } else { + icon = theme.CancelIcon() + detail = "missing: " + strings.Join(ch.candidates, " / ") + } + row := container.NewBorder(nil, nil, + widget.NewIcon(icon), + widget.NewLabel(detail), + widget.NewLabel(ch.desc), + ) + bt.channelList.Add(row) + } + bt.channelList.Refresh() +} + +// resolveChannels picks, for each logical channel, the first candidate series +// present in the loaded logs. +func (bt *BoostTuner) resolveChannels() { + bt.resolved = make(map[string]string) + for _, ch := range requiredChannels { + for _, cand := range ch.candidates { + if _, ok := bt.values[cand]; ok { + bt.resolved[ch.key] = cand + break + } + } + } +} + +// series returns the merged log data for a resolved channel key. +func (bt *BoostTuner) series(key string) ([]float64, bool) { + name := bt.resolved[key] + if name == "" { + return nil, false + } + v, ok := bt.values[name] + return v, ok +} + +// missingChannels lists the descriptions of any required channels not found. +func (bt *BoostTuner) missingChannels() []string { + var out []string + for _, ch := range requiredChannels { + if bt.resolved[ch.key] == "" { + out = append(out, ch.desc) + } + } + return out +} + +// --- log loading (mirrors the matrix builder's pipeline) --- + +func (bt *BoostTuner) openLogDialog() { + widgets.SelectFiles(func(readers []fyne.URIReadCloser) { + c := fyne.CurrentApp().Driver().CanvasForObject(bt) + if c == nil { + if wins := fyne.CurrentApp().Driver().AllWindows(); len(wins) > 0 { + c = wins[0].Canvas() + } + } + var pm *progressmodal.ProgressModal + if c != nil { + pm = progressmodal.New(c, fmt.Sprintf("Parsing %d log file(s)...", len(readers))) + pm.Show() + } + + go func() { + type parsed struct { + name string + local map[string][]float64 + n int + } + var ok []parsed + var failed int + for _, r := range readers { + name := r.URI().Name() + local, n, err := parseLog(name, r) + r.Close() + if err != nil { + log.Println("boosttuner:", err) + failed++ + continue + } + ok = append(ok, parsed{name, local, n}) + } + + fyne.Do(func() { + if pm != nil { + pm.Hide() + } + for _, p := range ok { + bt.mergeLog(p.name, p.local, p.n) + } + bt.rebuildOrder() + bt.resolveChannels() + bt.refreshChannelList() + bt.refreshLogList() + if failed > 0 { + bt.logStatus(fmt.Sprintf("Loaded %d file(s), %d failed", len(ok), failed)) + } + }) + }() + }, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") +} + +// parseLog reads a single log into a row-aligned series map, padding gaps with +// NaN. It touches no shared state, so it is safe off the UI goroutine. +func parseLog(name string, r io.Reader) (map[string][]float64, int, error) { + lf, err := logfile.Open(name, r) + if err != nil { + return nil, 0, err + } + defer lf.Close() + + local := make(map[string][]float64) + n := 0 + for { + rec := lf.Next() + if rec.EOF { + break + } + for k, v := range rec.Values { + if k == "Pgm_status" { + continue + } + arr := local[k] + for len(arr) < n { // back-fill records before this key first appeared + arr = append(arr, math.NaN()) + } + local[k] = append(arr, v) + } + n++ + for k, arr := range local { // forward-fill keys missing from this record + for len(arr) < n { + arr = append(arr, math.NaN()) + } + local[k] = arr + } + } + if n == 0 { + return nil, 0, fmt.Errorf("%s contains no records", name) + } + return local, n, nil +} + +// mergeLog appends a parsed log to the merged series set, keeping every series +// row-aligned. Must run on the UI goroutine. +func (bt *BoostTuner) mergeLog(name string, local map[string][]float64, n int) { + base := bt.nrecords + for k, arr := range local { + cur, ok := bt.values[k] + if !ok { + cur = nanSlice(base) + } + bt.values[k] = append(cur, arr...) + } + for k, cur := range bt.values { + if _, ok := local[k]; !ok { + bt.values[k] = append(cur, nanSlice(n)...) + } + } + bt.nrecords = base + n + bt.loadedFiles = append(bt.loadedFiles, name) +} + +func (bt *BoostTuner) clearLogs() { + bt.values = make(map[string][]float64) + bt.order = nil + bt.loadedFiles = nil + bt.nrecords = 0 + bt.resolveChannels() + bt.refreshChannelList() + bt.refreshLogList() +} + +func (bt *BoostTuner) rebuildOrder() { + bt.order = make([]string, 0, len(bt.values)) + for k := range bt.values { + bt.order = append(bt.order, k) + } + sort.Slice(bt.order, func(i, j int) bool { + return strings.ToLower(bt.order[i]) < strings.ToLower(bt.order[j]) + }) +} + +func (bt *BoostTuner) refreshLogList() { + if len(bt.loadedFiles) == 0 { + bt.logsLabel.SetText("No log files loaded") + return + } + var b strings.Builder + for i, f := range bt.loadedFiles { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(filepath.Base(f)) + } + bt.logsLabel.SetText(fmt.Sprintf("%d file(s), %d records:\n%s", + len(bt.loadedFiles), bt.nrecords, b.String())) +} + +// logStatus appends a one-off message to the log list label. +func (bt *BoostTuner) logStatus(msg string) { + bt.refreshLogList() + bt.logsLabel.SetText(bt.logsLabel.Text + "\n" + msg) +} + +func nanSlice(n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = math.NaN() + } + return s +} + +// --- shared helpers --- + +// nearestIndex returns the index of the axis breakpoint closest to v. +func nearestIndex(axis []float64, v float64) int { + best := 0 + bestDist := math.Abs(axis[0] - v) + for i := 1; i < len(axis); i++ { + if d := math.Abs(axis[i] - v); d < bestDist { + bestDist = d + best = i + } + } + return best +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/pkg/widgets/boosttuner/integration_test.go b/pkg/widgets/boosttuner/integration_test.go new file mode 100644 index 00000000..7118e1a6 --- /dev/null +++ b/pkg/widgets/boosttuner/integration_test.go @@ -0,0 +1,53 @@ +package boosttuner + +import ( + "math" + "os" + "testing" + + symbol "github.com/roffe/ecusymbol" +) + +const testBinary = "/home/roffe/temp/bosse.bin" + +// TestRegMapBilerp_RealBinary checks that, against a real T7 binary, the RegMap is +// read row-major [rpm][load], the %->raw conversion is right, and bilerp returns +// the stored cell values at the axis breakpoints. Skips when the binary is absent. +func TestRegMapBilerp_RealBinary(t *testing.T) { + data, err := os.ReadFile(testBinary) + if err != nil { + t.Skipf("test binary not available: %v", err) + } + _, syms, err := symbol.Load(testBinary, data, func(string) {}) + if err != nil { + t.Fatalf("load: %v", err) + } + + x := syms.GetByName(symSetLoadXSP).Float64s() + y := syms.GetByName(symNEngSP).Float64s() + regPct := syms.GetByName(symRegMap).Float64s() + if len(x)*len(y) != len(regPct) { + t.Fatalf("RegMap %d != %d x %d", len(regPct), len(x), len(y)) + } + raw := make([]float64, len(regPct)) + for i, v := range regPct { + raw[i] = v * dutyRawPerPct + } + + // First cell is [rpm[0]][load[0]]; last is [rpm[last]][load[last]]. + first := regPct[0] * dutyRawPerPct + last := regPct[len(regPct)-1] * dutyRawPerPct + if got := bilerp(x, y, raw, x[0], y[0]); math.Abs(got-first) > 1e-6 { + t.Errorf("bilerp at first breakpoint = %v, want %v", got, first) + } + if got := bilerp(x, y, raw, x[len(x)-1], y[len(y)-1]); math.Abs(got-last) > 1e-6 { + t.Errorf("bilerp at last breakpoint = %v, want %v", got, last) + } + + // An interior breakpoint must equal its exact stored cell (row-major index). + r, c := len(y)/2, len(x)/2 + want := regPct[r*len(x)+c] * dutyRawPerPct + if got := bilerp(x, y, raw, x[c], y[r]); math.Abs(got-want) > 1e-6 { + t.Errorf("bilerp at interior breakpoint = %v, want %v", got, want) + } +} diff --git a/pkg/widgets/boosttuner/pid.go b/pkg/widgets/boosttuner/pid.go new file mode 100644 index 00000000..67302e1b --- /dev/null +++ b/pkg/widgets/boosttuner/pid.go @@ -0,0 +1,179 @@ +package boosttuner + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// PID maps and their shared axes. All three are row-major [rpm][loadDiff]: +// rows follow PIDYSP (rpm), columns follow PIDXSP (load error mg/c). +const ( + symPMap = "BoostCal.PMap" + symIMap = "BoostCal.IMap" + symDMap = "BoostCal.DMap" + symPIDXSP = "BoostCal.PIDXSP" // X axis: load error (mg/c) + symPIDYSP = "BoostCal.PIDYSP" // Y axis: engine speed (rpm) +) + +// pidEditor is one editable PID gain map. data is the same slice the mapviewer +// edits in place, so hand-edits flow back without a copy; Apply writes data into +// the binary. +type pidEditor struct { + bt *BoostTuner + name string // "P" / "I" / "D" + symbolName string + axisX, axisY []float64 + cols, rows int + data []float64 + mv *mapviewer.MapViewer + status *widget.Label + suggestBox *fyne.Container // populated by the heuristics in pidtune.go +} + +func (bt *BoostTuner) buildPIDTab() fyne.CanvasObject { + x, errX := bt.readSymbol(symPIDXSP) + y, errY := bt.readSymbol(symPIDYSP) + if errX != nil || errY != nil { + return container.NewCenter(widget.NewLabel("BoostCal PID axes not found in this binary.")) + } + + bt.pidEditors = map[string]*pidEditor{} + tabs := container.NewAppTabs() + for _, m := range []struct{ name, sym string }{ + {"P", symPMap}, {"I", symIMap}, {"D", symDMap}, + } { + ed := bt.newPIDEditor(m.name, m.sym, x, y) + bt.pidEditors[m.name] = ed + tabs.Append(container.NewTabItem(m.name+" map", ed.object())) + } + + intro := widget.NewLabel( + "Edit the PID gain maps directly, or use the per-rpm-band suggestions " + + "(from logged boost transients) to scale a map. Validate changes in the " + + "Simulator tab before flashing.") + intro.Wrapping = fyne.TextWrapWord + + suggestStatus := widget.NewLabel("") + computeBtn := widget.NewButtonWithIcon("Compute suggestions from logs", theme.SearchIcon(), func() { + suggestStatus.SetText(bt.computePIDSuggestions()) + }) + + header := container.NewVBox( + intro, + container.NewBorder(nil, nil, nil, suggestStatus, computeBtn), + ) + return container.NewBorder(header, nil, nil, nil, tabs) +} + +func (bt *BoostTuner) newPIDEditor(name, symName string, x, y []float64) *pidEditor { + ed := &pidEditor{ + bt: bt, name: name, symbolName: symName, + axisX: x, axisY: y, cols: len(x), rows: len(y), + } + ed.status = widget.NewLabel("") + ed.suggestBox = container.NewVBox() + ed.reload() + return ed +} + +func (ed *pidEditor) object() fyne.CanvasObject { + display := container.NewStack() + ed.rebuildViewer(display) + + applyBtn := widget.NewButtonWithIcon("Apply to binary", theme.DocumentSaveIcon(), func() { + if ed.bt.cfg.Save == nil { + ed.status.SetText("No binary to write to.") + return + } + if err := ed.bt.cfg.Save(ed.symbolName, ed.data); err != nil { + ed.status.SetText("Save failed: " + err.Error()) + return + } + ed.status.SetText("Wrote " + ed.symbolName) + }) + if ed.bt.cfg.Save == nil { + applyBtn.Disable() + } + reloadBtn := widget.NewButtonWithIcon("Reload from binary", theme.ViewRefreshIcon(), func() { + ed.reload() + ed.rebuildViewer(display) + ed.status.SetText("Reloaded " + ed.symbolName) + }) + + controls := container.NewVBox( + container.NewGridWithColumns(2, applyBtn, reloadBtn), + ed.status, + widget.NewSeparator(), + widget.NewLabelWithStyle("Suggestions", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewVScroll(ed.suggestBox), + ) + side := container.NewBorder(nil, nil, nil, nil, controls) + + split := container.NewHSplit(display, side) + split.Offset = 0.72 + return split +} + +// reload reads the map from the binary into a fresh editable slice. +func (ed *pidEditor) reload() { + z, err := ed.bt.readSymbol(ed.symbolName) + if err != nil { + ed.data = make([]float64, ed.cols*ed.rows) + ed.status.SetText(err.Error()) + return + } + ed.data = z +} + +// rebuildViewer swaps a fresh editable map viewer (bound to ed.data) into host. +func (ed *pidEditor) rebuildViewer(host *fyne.Container) { + mv, err := mapviewer.New(&mapviewer.Config{ + Name: ed.symbolName, + XData: ed.axisX, + YData: ed.axisY, + ZData: ed.data, + XPrecision: 0, + YPrecision: 0, + ZPrecision: 0, + XLabel: "Load error (mg/c)", + YLabel: "Engine speed (rpm)", + ZLabel: ed.name + " constant", + MeshView: true, + MeshRenderer: ed.bt.cfg.MeshRenderer, + Editable: true, + ColorblindMode: ed.bt.cfg.Colorblind, + SaveECUFunc: func([]float64) {}, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + host.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + host.Refresh() + return + } + ed.mv = mv + host.Objects = []fyne.CanvasObject{mv} + host.Refresh() +} + +// scaleRows multiplies whole rpm rows (indexed by PIDYSP) by per-row factors and +// refreshes the viewer. Used by the heuristic suggestions in pidtune.go. +func (ed *pidEditor) scaleRows(factors map[int]float64) { + for row, f := range factors { + if row < 0 || row >= ed.rows { + continue + } + for c := 0; c < ed.cols; c++ { + ed.data[row*ed.cols+c] *= f + } + } + if ed.mv != nil { + _ = ed.mv.SetZData(ed.data) + ed.mv.Refresh() + } + ed.status.SetText(fmt.Sprintf("Scaled %d row(s); review then Apply.", len(factors))) +} diff --git a/pkg/widgets/boosttuner/pidtune.go b/pkg/widgets/boosttuner/pidtune.go new file mode 100644 index 00000000..0828864b --- /dev/null +++ b/pkg/widgets/boosttuner/pidtune.go @@ -0,0 +1,299 @@ +package boosttuner + +import ( + "fmt" + "math" + "sort" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// PID heuristics. The PID gain maps cannot be cleanly auto-learned from arbitrary +// logs, so instead we measure the *quality* of logged boost transients and +// suggest bounded per-rpm-band scalings of the maps. These are starting points to +// review and validate in the Simulator, never an automatic flash. +// +// Error convention: loadDiff = SetValue - airmass, so a positive error means we +// are under the request (still spooling) and a negative error means airmass has +// exceeded the request (overshoot). + +// transientCfg tunes the boost-onset detector. +type transientCfg struct { + startErr float64 // onset: error above this (mg/c) with the loop engaged + settleBand float64 // |error| within this counts as "on target" (mg/c) + minLen int // ignore events shorter than this many samples +} + +func defaultTransientCfg() transientCfg { + return transientCfg{startErr: 100, settleBand: 20, minLen: 5} +} + +// transient summarises one boost-onset event. +type transient struct { + rpm float64 // mean rpm over the event + overshoot float64 // max airmass-over-request after onset (mg/c, >=0) + crossings int // sign changes of the error (oscillation) + riseSamples int // samples from onset until first settle (or event length) + settled bool + ssError float64 // mean error in the settled tail (mg/c) +} + +// pidSuggestInputs holds the row-aligned channels the detector consumes. +type pidSuggestInputs struct { + n int + rpm, loadDiff, pwm2pct []float64 +} + +// detectTransients walks the log and extracts boost-onset events: a stretch where +// the loop is engaged and the error starts large-positive, tracked until the loop +// disengages or the log ends. +func detectTransients(in pidSuggestInputs, cfg transientCfg) []transient { + var out []transient + active := false + var onset, settleAt int + var sumRPM, maxNeg, prevSign float64 + var crossings, length int + + finalize := func(end int) { + if !active { + return + } + active = false + if length < cfg.minLen { + return + } + t := transient{ + rpm: sumRPM / float64(length), + overshoot: maxNeg, + crossings: crossings, + settled: settleAt >= 0, + } + if settleAt >= 0 { + t.riseSamples = settleAt - onset + // Steady-state error: mean over the settled tail. + var s float64 + var c int + for i := settleAt; i < end; i++ { + if !math.IsNaN(in.loadDiff[i]) { + s += in.loadDiff[i] + c++ + } + } + if c > 0 { + t.ssError = s / float64(c) + } + } else { + t.riseSamples = length + } + out = append(out, t) + } + + for i := 0; i < in.n; i++ { + e, r, p := in.loadDiff[i], in.rpm[i], in.pwm2pct[i] + if math.IsNaN(e) || math.IsNaN(r) || math.IsNaN(p) { + finalize(i) + continue + } + engaged := p == 0 + if !active { + if engaged && e > cfg.startErr { + active = true + onset, settleAt = i, -1 + sumRPM, maxNeg, crossings, length = 0, 0, 0, 0 + prevSign = 1 // onset error is positive + } else { + continue + } + } + if !engaged { + finalize(i) + continue + } + sumRPM += r + length++ + if sign := signOf(e); sign != 0 && sign != prevSign { + crossings++ + prevSign = sign + } + if e < 0 && -e > maxNeg { + maxNeg = -e + } + if settleAt < 0 && math.Abs(e) <= cfg.settleBand { + settleAt = i + } + } + finalize(in.n) + return out +} + +// suggestCfg tunes how metrics map to scaling factors. +type suggestCfg struct { + overshootHi float64 // mg/c above which we trim P & D + riseHi int // rise samples above which we add P + ssHi float64 // |steady error| above which we add I + trim float64 // factor applied when trimming (e.g. 0.85) + boost float64 // factor applied when adding (e.g. 1.15) +} + +func defaultSuggestCfg() suggestCfg { + return suggestCfg{overshootHi: 50, riseHi: 25, ssHi: 20, trim: 0.85, boost: 1.15} +} + +// bandSuggestion is a per-rpm-band scaling recommendation for the PID maps. +type bandSuggestion struct { + row int // PIDYSP index + rpm float64 // band breakpoint + factorP float64 + factorI float64 + factorD float64 + reason string + events int +} + +// factorFor returns the suggested factor for the named map ("P"/"I"/"D"). +func (b bandSuggestion) factorFor(name string) float64 { + switch name { + case "P": + return b.factorP + case "I": + return b.factorI + case "D": + return b.factorD + } + return 1 +} + +// suggestPID aggregates transients into per-rpm-band scaling factors. +func suggestPID(ts []transient, pidYSP []float64, cfg suggestCfg) []bandSuggestion { + type acc struct { + overshoot, rise, ss float64 + crossings, n int + } + bands := make(map[int]*acc) + for _, t := range ts { + row := nearestIndex(pidYSP, t.rpm) + a := bands[row] + if a == nil { + a = &acc{} + bands[row] = a + } + a.overshoot += t.overshoot + a.rise += float64(t.riseSamples) + a.ss += t.ssError + a.crossings += t.crossings + a.n++ + } + + var out []bandSuggestion + for row, a := range bands { + n := float64(a.n) + avgOver := a.overshoot / n + avgRise := a.rise / n + avgSS := a.ss / n + avgCross := float64(a.crossings) / n + + b := bandSuggestion{row: row, rpm: pidYSP[row], factorP: 1, factorI: 1, factorD: 1, events: a.n} + switch { + case avgOver > cfg.overshootHi || avgCross >= 2: + b.factorP = cfg.trim + b.factorD = cfg.trim + b.reason = fmt.Sprintf("overshoot %.0f mg/c, %.1f crossings", avgOver, avgCross) + case avgRise > float64(cfg.riseHi): + b.factorP = cfg.boost + b.reason = fmt.Sprintf("slow rise (%.0f samples)", avgRise) + } + if math.Abs(avgSS) > cfg.ssHi { + b.factorI = cfg.boost + if b.reason != "" { + b.reason += "; " + } + b.reason += fmt.Sprintf("steady error %.0f mg/c", avgSS) + } + if b.factorP != 1 || b.factorI != 1 || b.factorD != 1 { + out = append(out, b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].rpm < out[j].rpm }) + return out +} + +func signOf(v float64) float64 { + switch { + case v > 0: + return 1 + case v < 0: + return -1 + default: + return 0 + } +} + +// --- UI wiring --- + +// computePIDSuggestions runs the transient analysis over the loaded logs and +// fills each editor's suggestion panel. Returns a user-facing status string. +func (bt *BoostTuner) computePIDSuggestions() string { + if len(bt.values) == 0 { + return "Load logs first (Logs tab)." + } + rpm, ok1 := bt.series("rpm") + loadDiff, ok2 := bt.series("loadDiff") + pwm2pct, ok3 := bt.series("pwm2pct") + if !ok1 || !ok2 || !ok3 { + return "Need rpm, LoadDiff and the 2% flag channels." + } + ts := detectTransients(pidSuggestInputs{ + n: bt.nrecords, rpm: rpm, loadDiff: loadDiff, pwm2pct: pwm2pct, + }, defaultTransientCfg()) + if len(ts) == 0 { + bt.renderSuggestions(nil) + return "No boost transients detected." + } + var ysp []float64 + if ed := bt.pidEditors["P"]; ed != nil { + ysp = ed.axisY + } + if len(ysp) == 0 { + return "PID maps/axes not found in this binary." + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + bt.renderSuggestions(sugg) + return fmt.Sprintf("%d transients, %d band suggestion(s).", len(ts), len(sugg)) +} + +// renderSuggestions populates every editor's suggestion box with the rows +// relevant to its map. +func (bt *BoostTuner) renderSuggestions(sugg []bandSuggestion) { + for name, ed := range bt.pidEditors { + ed.suggestBox.Objects = ed.suggestBox.Objects[:0] + var rows []bandSuggestion + for _, b := range sugg { + if b.factorFor(name) != 1 { + rows = append(rows, b) + } + } + if len(rows) == 0 { + ed.suggestBox.Add(widget.NewLabel("No suggestions for this map.")) + ed.suggestBox.Refresh() + continue + } + factors := map[int]float64{} + for _, b := range rows { + b := b + factors[b.row] = b.factorFor(name) + lbl := widget.NewLabel(fmt.Sprintf("%.0f rpm: ×%.2f (%s)", b.rpm, b.factorFor(name), b.reason)) + lbl.Wrapping = fyne.TextWrapWord + apply := widget.NewButton("Apply", func() { + ed.scaleRows(map[int]float64{b.row: b.factorFor(name)}) + }) + apply.Importance = widget.LowImportance + ed.suggestBox.Add(container.NewBorder(nil, nil, nil, apply, lbl)) + } + allBtn := widget.NewButton("Apply all", func() { ed.scaleRows(factors) }) + allBtn.Importance = widget.LowImportance + ed.suggestBox.Add(allBtn) + ed.suggestBox.Refresh() + } +} diff --git a/pkg/widgets/boosttuner/pidtune_test.go b/pkg/widgets/boosttuner/pidtune_test.go new file mode 100644 index 00000000..9bbfb1f5 --- /dev/null +++ b/pkg/widgets/boosttuner/pidtune_test.go @@ -0,0 +1,79 @@ +package boosttuner + +import "testing" + +// buildTrace turns a sequence of (rpm, loadDiff, pwm2pct) samples into inputs. +func buildTrace(samples [][3]float64) pidSuggestInputs { + in := pidSuggestInputs{n: len(samples)} + for _, s := range samples { + in.rpm = append(in.rpm, s[0]) + in.loadDiff = append(in.loadDiff, s[1]) + in.pwm2pct = append(in.pwm2pct, s[2]) + } + return in +} + +// TestDetectTransients_OvershootEvent feeds a spool-up that overshoots and +// oscillates, and checks the detector measures it. +func TestDetectTransients_OvershootEvent(t *testing.T) { + // error: large positive -> crosses 0 -> negative (overshoot) -> back up. + trace := [][3]float64{ + {3000, 150, 0}, // onset (err>startErr, engaged) + {3000, 90, 0}, + {3000, 30, 0}, + {3000, -40, 0}, // crossing 1, overshoot 40 + {3000, -60, 0}, // overshoot 60 + {3000, 10, 0}, // crossing 2, settled (|err|<=20) + {3000, 5, 0}, + {3000, 4, 0}, + } + ts := detectTransients(buildTrace(trace), defaultTransientCfg()) + if len(ts) != 1 { + t.Fatalf("got %d transients, want 1", len(ts)) + } + got := ts[0] + if got.overshoot < 59 || got.overshoot > 61 { + t.Errorf("overshoot = %v, want ~60", got.overshoot) + } + if got.crossings < 2 { + t.Errorf("crossings = %d, want >=2", got.crossings) + } + if !got.settled { + t.Errorf("expected event to settle") + } +} + +// TestSuggestPID_TrimsOnOvershoot checks an overshooting band yields a P/D trim. +func TestSuggestPID_TrimsOnOvershoot(t *testing.T) { + ysp := []float64{1000, 2000, 3000, 4000, 5000, 6000} + ts := []transient{ + {rpm: 3000, overshoot: 80, crossings: 3, riseSamples: 6, settled: true, ssError: 2}, + {rpm: 3100, overshoot: 70, crossings: 2, riseSamples: 5, settled: true, ssError: 1}, + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + if len(sugg) != 1 { + t.Fatalf("got %d suggestions, want 1", len(sugg)) + } + b := sugg[0] + if b.factorP >= 1 || b.factorD >= 1 { + t.Errorf("expected P/D trim (<1), got P=%.2f D=%.2f", b.factorP, b.factorD) + } + if b.factorI != 1 { + t.Errorf("expected no I change for small steady error, got %.2f", b.factorI) + } +} + +// TestSuggestPID_AddsIOnSteadyError checks a persistent steady error raises I. +func TestSuggestPID_AddsIOnSteadyError(t *testing.T) { + ysp := []float64{1000, 2000, 3000, 4000} + ts := []transient{ + {rpm: 2000, overshoot: 5, crossings: 0, riseSamples: 8, settled: true, ssError: 40}, + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + if len(sugg) != 1 { + t.Fatalf("got %d suggestions, want 1", len(sugg)) + } + if sugg[0].factorI <= 1 { + t.Errorf("expected I boost (>1), got %.2f", sugg[0].factorI) + } +} diff --git a/pkg/widgets/boosttuner/regmap.go b/pkg/widgets/boosttuner/regmap.go new file mode 100644 index 00000000..e17afbaf --- /dev/null +++ b/pkg/widgets/boosttuner/regmap.go @@ -0,0 +1,375 @@ +package boosttuner + +import ( + "fmt" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// RegMap symbol and its axes in the binary. RegMap is row-major [rpm][load]: +// rows follow n_EngSP, columns follow SetLoadXSP. +const ( + symRegMap = "BoostCal.RegMap" + symSetLoadXSP = "BoostCal.SetLoadXSP" // X axis: load set value (mg/c) + symNEngSP = "BoostCal.n_EngSP" // Y axis: engine speed (rpm) +) + +// regMapView lists the selectable views for the learned map. +const ( + viewCurrent = "Current" + viewLearned = "Learned (what gets written)" + viewDelta = "Delta" + viewCoverage = "Coverage (samples)" +) + +func (bt *BoostTuner) buildRegMapTab() fyne.CanvasObject { + bt.rmStatus = widget.NewLabel("Load logs, then click Analyze.") + bt.rmStatus.Wrapping = fyne.TextWrapWord + + analyzeBtn := widget.NewButtonWithIcon("Analyze", theme.SearchIcon(), func() { + if err := bt.analyzeRegMap(); err != nil { + bt.rmStatus.SetText(err.Error()) + return + } + bt.refreshRegMapDisplay() + }) + analyzeBtn.Importance = widget.HighImportance + + bt.rmView = widget.NewSelect( + []string{viewCurrent, viewLearned, viewDelta, viewCoverage}, + func(string) { bt.refreshRegMapDisplay() }, + ) + bt.rmView.SetSelected(viewLearned) + + applyBtn := widget.NewButtonWithIcon("Apply to binary", theme.DocumentSaveIcon(), bt.applyRegMap) + if bt.cfg.Save == nil { + applyBtn.Disable() + } + + controls := container.NewVBox( + analyzeBtn, + labeledRow("View", bt.rmView), + widget.NewSeparator(), + widget.NewLabelWithStyle("Sample filter", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bt.slider("On-target |error| (mg/c)", 5, 200, 5, bt.onTarget, func(v float64) { bt.onTarget = v }), + bt.slider("Max rpm step (rpm)", 20, 500, 10, bt.rpmStab, func(v float64) { bt.rpmStab = v }), + bt.slider("Max load step (mg/c)", 10, 300, 10, bt.loadStab, func(v float64) { bt.loadStab = v }), + widget.NewSeparator(), + widget.NewLabelWithStyle("Apply", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bt.slider("Min samples / cell", 1, 50, 1, float64(bt.minSamples), func(v float64) { bt.minSamples = int(v); bt.refreshRegMapDisplay() }), + bt.slider("Blend %", 0, 100, 5, bt.blend*100, func(v float64) { bt.blend = v / 100; bt.refreshRegMapDisplay() }), + applyBtn, + widget.NewSeparator(), + bt.rmStatus, + ) + + bt.rmDisplay = container.NewStack(container.NewCenter( + widget.NewLabel("Learn BoostCal.RegMap from the loaded logs."), + )) + + controlScroll := container.NewVScroll(controls) + controlScroll.SetMinSize(fyne.NewSize(250, 0)) + split := container.NewHSplit(controlScroll, bt.rmDisplay) + split.Offset = 0.28 + return split +} + +// slider builds a labelled slider with a live value readout that calls onChange. +func (bt *BoostTuner) slider(label string, min, max, step, init float64, onChange func(float64)) fyne.CanvasObject { + val := widget.NewLabel(formatFloat(init)) + s := widget.NewSlider(min, max) + s.Step = step + s.SetValue(init) + s.OnChanged = func(v float64) { + val.SetText(formatFloat(v)) + onChange(v) + } + return container.NewVBox( + widget.NewLabel(label), + container.NewBorder(nil, nil, nil, layout.NewFixedWidth(48, val), s), + ) +} + +// analyzeRegMap reads the current RegMap and its axes from the binary, then learns +// a new map from the logs by folding the loop's corrections back into the +// feedforward. See the package doc for the control-law rationale. +func (bt *BoostTuner) analyzeRegMap() error { + if len(bt.values) == 0 { + return fmt.Errorf("load a log file first (Logs tab)") + } + if missing := bt.missingChannels(); len(missing) > 0 { + return fmt.Errorf("missing channels: %v", missing) + } + if err := bt.loadRegMapFromBinary(); err != nil { + return err + } + + in := regMapInputs{ + n: bt.nrecords, + rpm: mustSeries(bt.series("rpm")), + setv: mustSeries(bt.series("setValue")), + regCon: mustSeries(bt.series("regCon")), + pFac: mustSeries(bt.series("pFac")), + iFac: mustSeries(bt.series("iFac")), + dFac: mustSeries(bt.series("dFac")), + adap: mustSeries(bt.series("adaption")), + loadDiff: mustSeries(bt.series("loadDiff")), + pwmCalc: mustSeries(bt.series("pwmCalc")), + pwm2pct: mustSeries(bt.series("pwm2pct")), + } + p := regMapParams{ + axisX: bt.rmAxisX, axisY: bt.rmAxisY, + cols: bt.rmCols, rows: bt.rmRows, + current: bt.rmCurrent, + onTarget: bt.onTarget, rpmStab: bt.rpmStab, loadStab: bt.loadStab, + } + + learned, cnt, used, filtered := learnRegMap(in, p) + bt.rmLearned, bt.rmCounts = learned, cnt + filled := 0 + for _, c := range cnt { + if c > 0 { + filled++ + } + } + bt.rmBuilt = true + bt.rmStatus.SetText(fmt.Sprintf("Used %d samples (%d filtered). %d/%d cells have data.", + used, filtered, filled, bt.rmCols*bt.rmRows)) + return nil +} + +// regMapInputs holds the row-aligned log channels the learner consumes. All +// slices have length n (NaN where a sample is missing). +type regMapInputs struct { + n int + rpm, setv, regCon, pFac, iFac, dFac, adap, loadDiff, pwmCalc, pwm2pct []float64 +} + +// regMapParams holds the map geometry (from the binary) and the sample filter. +type regMapParams struct { + axisX, axisY []float64 + cols, rows int + current []float64 + onTarget, rpmStab, loadStab float64 +} + +// learnRegMap folds the loop's corrections back into the feedforward map. For +// each accepted sample it adds RegConValue+PFac+IFac+DFac+Adaption (converted to +// %) to the cell its (load, rpm) lands on, then averages per cell. Empty cells +// return the current value so they contribute a zero delta. Pure: no UI/state. +func learnRegMap(in regMapInputs, p regMapParams) (learned []float64, counts []int, used, filtered int) { + size := p.cols * p.rows + sum := make([]float64, size) + counts = make([]int, size) + + prevRPM, prevLoad := math.NaN(), math.NaN() + for i := 0; i < in.n; i++ { + r, s := in.rpm[i], in.setv[i] + if anyNaN(r, s, in.regCon[i], in.pFac[i], in.iFac[i], in.dFac[i], in.adap[i], in.loadDiff[i], in.pwmCalc[i], in.pwm2pct[i]) { + prevRPM, prevLoad = r, s + continue + } + ok := acceptSample(p, r, s, in.loadDiff[i], in.pwmCalc[i], in.pwm2pct[i], prevRPM, prevLoad) + prevRPM, prevLoad = r, s + if !ok { + filtered++ + continue + } + target := (in.regCon[i] + in.pFac[i] + in.iFac[i] + in.dFac[i] + in.adap[i]) / dutyRawPerPct + c := nearestIndex(p.axisX, s) + row := nearestIndex(p.axisY, r) + idx := row*p.cols + c + sum[idx] += target + counts[idx]++ + used++ + } + + learned = make([]float64, size) + for i := range sum { + if counts[i] > 0 { + learned[i] = sum[i] / float64(counts[i]) + } else { + learned[i] = p.current[i] + } + } + return learned, counts, used, filtered +} + +// acceptSample reports whether a sample is trustworthy for learning the +// feedforward: on target, steady (not mid-transient), in closed loop, and not at +// the duty rails. +func acceptSample(p regMapParams, rpm, load, loadDiff, pwmCalc, pwm2pct, prevRPM, prevLoad float64) bool { + if math.Abs(loadDiff) > p.onTarget { + return false + } + if pwm2pct != 0 { // open-loop / forced 2% + return false + } + if pwmCalc <= 20 || pwmCalc >= 980 { // clamped at a rail + return false + } + if !math.IsNaN(prevRPM) && math.Abs(rpm-prevRPM) > p.rpmStab { + return false + } + if !math.IsNaN(prevLoad) && math.Abs(load-prevLoad) > p.loadStab { + return false + } + return true +} + +// mustSeries unwraps a resolved channel; analyzeRegMap guarantees presence via +// missingChannels before calling, so the bool is discarded here. +func mustSeries(s []float64, _ bool) []float64 { return s } + +// loadRegMapFromBinary reads BoostCal.RegMap and its axis breakpoints from the +// loaded binary into the tuner's state. +func (bt *BoostTuner) loadRegMapFromBinary() error { + if bt.cfg.Symbols == nil { + return fmt.Errorf("no binary loaded") + } + x, err := bt.readSymbol(symSetLoadXSP) + if err != nil { + return err + } + y, err := bt.readSymbol(symNEngSP) + if err != nil { + return err + } + z, err := bt.readSymbol(symRegMap) + if err != nil { + return err + } + if len(x)*len(y) != len(z) { + return fmt.Errorf("RegMap size %d != %d x %d axes", len(z), len(x), len(y)) + } + bt.rmAxisX, bt.rmAxisY = x, y + bt.rmCols, bt.rmRows = len(x), len(y) + bt.rmCurrent = z + return nil +} + +// readSymbol returns a symbol's values in engineering units. +func (bt *BoostTuner) readSymbol(name string) ([]float64, error) { + if bt.cfg.Symbols == nil { + return nil, fmt.Errorf("no binary loaded") + } + s := bt.cfg.Symbols.GetByName(name) + if s == nil { + return nil, fmt.Errorf("symbol %s not found in binary", name) + } + return s.Float64s(), nil +} + +// writeTarget returns the map that "Apply" would write: filled cells blended +// toward the learned value, everything else left at its current value. +func (bt *BoostTuner) writeTarget() []float64 { + out := make([]float64, len(bt.rmCurrent)) + copy(out, bt.rmCurrent) + for i := range out { + if bt.rmCounts[i] >= bt.minSamples { + out[i] = bt.rmCurrent[i] + bt.blend*(bt.rmLearned[i]-bt.rmCurrent[i]) + } + } + return out +} + +func (bt *BoostTuner) applyRegMap() { + if !bt.rmBuilt { + bt.rmStatus.SetText("Analyze first.") + return + } + if bt.cfg.Save == nil { + bt.rmStatus.SetText("No binary to write to.") + return + } + data := bt.writeTarget() + changed := 0 + for i := range data { + if data[i] != bt.rmCurrent[i] { + changed++ + } + } + if err := bt.cfg.Save(symRegMap, data); err != nil { + bt.rmStatus.SetText("Save failed: " + err.Error()) + return + } + bt.rmCurrent = data // the binary now holds these values + bt.rmStatus.SetText(fmt.Sprintf("Wrote %s: %d cells changed.", symRegMap, changed)) + bt.refreshRegMapDisplay() +} + +// refreshRegMapDisplay rebuilds the map viewer for the selected view. +func (bt *BoostTuner) refreshRegMapDisplay() { + if !bt.rmBuilt { + return + } + var z []float64 + var label string + zPrec := 1 + switch bt.rmView.Selected { + case viewCurrent: + z, label = bt.rmCurrent, "Current duty %" + case viewDelta: + target := bt.writeTarget() + z = make([]float64, len(target)) + for i := range z { + z[i] = target[i] - bt.rmCurrent[i] + } + label = "Delta duty %" + case viewCoverage: + z = make([]float64, len(bt.rmCounts)) + for i := range z { + z[i] = float64(bt.rmCounts[i]) + } + label, zPrec = "Samples", 0 + default: // viewLearned + z, label = bt.writeTarget(), "Learned duty %" + } + + mv, err := mapviewer.New(&mapviewer.Config{ + Name: symRegMap, + XData: bt.rmAxisX, + YData: bt.rmAxisY, + ZData: z, + XPrecision: 0, + YPrecision: 0, + ZPrecision: zPrec, + XLabel: "Load set value (mg/c)", + YLabel: "Engine speed (rpm)", + ZLabel: label, + MeshView: true, + MeshRenderer: bt.cfg.MeshRenderer, + Editable: false, + ColorblindMode: bt.cfg.Colorblind, + SaveECUFunc: func([]float64) {}, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + bt.rmDisplay.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + bt.rmDisplay.Refresh() + return + } + bt.rmDisplay.Objects = []fyne.CanvasObject{mv} + bt.rmDisplay.Refresh() +} + +// --- small helpers --- + +func labeledRow(label string, obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +} + +func anyNaN(vals ...float64) bool { + for _, v := range vals { + if math.IsNaN(v) { + return true + } + } + return false +} diff --git a/pkg/widgets/boosttuner/regmap_test.go b/pkg/widgets/boosttuner/regmap_test.go new file mode 100644 index 00000000..c0065edf --- /dev/null +++ b/pkg/widgets/boosttuner/regmap_test.go @@ -0,0 +1,83 @@ +package boosttuner + +import ( + "math" + "testing" +) + +// TestLearnRegMap_FoldsCorrectionsToPercent checks the core learning behaviour: +// the learned cell value is (RegConValue + P + I + D + Adaption) averaged and +// converted from raw 0.1% units to %, and that the sample filter rejects +// off-target, clamped, open-loop and transient samples. +func TestLearnRegMap_FoldsCorrectionsToPercent(t *testing.T) { + axisX := []float64{800, 900, 1000} // load + axisY := []float64{1000, 2000} // rpm + cols, rows := len(axisX), len(axisY) + current := make([]float64, cols*rows) // all zero + + // Helper to append one sample to every channel. + var in regMapInputs + add := func(rpm, load, regCon, p, i, d, adap, loadDiff, pwm, twopct float64) { + in.rpm = append(in.rpm, rpm) + in.setv = append(in.setv, load) + in.regCon = append(in.regCon, regCon) + in.pFac = append(in.pFac, p) + in.iFac = append(in.iFac, i) + in.dFac = append(in.dFac, d) + in.adap = append(in.adap, adap) + in.loadDiff = append(in.loadDiff, loadDiff) + in.pwmCalc = append(in.pwmCalc, pwm) + in.pwm2pct = append(in.pwm2pct, twopct) + } + + // Two good samples at cell (load=900 -> col1, rpm=2000 -> row1). + // raw sum = 400 + 30 + 20 + 0 + 50 = 500 -> 50.0% ; pwmCalc 500 (in band). + add(2000, 900, 400, 30, 20, 0, 50, 5, 500, 0) + add(2000, 905, 400, 30, 20, 0, 50, -5, 500, 0) // steady (small steps) + + // Rejected: off target (loadDiff beyond onTarget). + add(2000, 900, 400, 30, 20, 0, 50, 500, 500, 0) + // Rejected: open-loop flag set. + add(2000, 900, 400, 30, 20, 0, 50, 5, 500, 1) + // Rejected: clamped at the upper rail. + add(2000, 900, 400, 30, 20, 0, 50, 5, 980, 0) + in.n = len(in.rpm) + + p := regMapParams{ + axisX: axisX, axisY: axisY, cols: cols, rows: rows, current: current, + onTarget: 30, rpmStab: 150, loadStab: 50, + } + learned, counts, used, filtered := learnRegMap(in, p) + + if used != 2 { + t.Fatalf("used = %d, want 2", used) + } + if filtered != 3 { + t.Fatalf("filtered = %d, want 3", filtered) + } + cell := 1*cols + 1 // row1 (rpm 2000), col1 (load 900) + if counts[cell] != 2 { + t.Fatalf("counts[cell] = %d, want 2", counts[cell]) + } + if math.Abs(learned[cell]-50.0) > 1e-9 { + t.Fatalf("learned[cell] = %v, want 50.0", learned[cell]) + } + // Untouched cells fall back to current (0 here). + if learned[0] != current[0] { + t.Fatalf("empty cell learned = %v, want current %v", learned[0], current[0]) + } +} + +// TestAcceptSample_Steadiness verifies the transient gate uses the previous +// sample's rpm/load steps. +func TestAcceptSample_Steadiness(t *testing.T) { + p := regMapParams{onTarget: 30, rpmStab: 150, loadStab: 50} + // Big rpm jump from the previous sample -> rejected. + if acceptSample(p, 3000, 900, 0, 500, 0, 2000, 900) { + t.Fatal("expected rejection on large rpm step") + } + // First sample (prev = NaN) with everything in band -> accepted. + if !acceptSample(p, 3000, 900, 0, 500, 0, math.NaN(), math.NaN()) { + t.Fatal("expected acceptance for first in-band sample") + } +} diff --git a/pkg/widgets/boosttuner/sim.go b/pkg/widgets/boosttuner/sim.go new file mode 100644 index 00000000..cc00ab91 --- /dev/null +++ b/pkg/widgets/boosttuner/sim.go @@ -0,0 +1,278 @@ +package boosttuner + +import ( + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +// The replay simulator re-runs the ECU boost control law (from Boost.c) over a +// logged session using the *current* maps, so the effect of map edits on the +// controller's duty output can be inspected before flashing. +// +// IMPORTANT limitation: it drives the law with the *logged* error (LoadDiff), +// because predicting the resulting airmass would need a turbo/engine plant model +// we do not have. So it shows how the control effort (PWM and the P/I/D split) +// would differ for the same measured error — useful for spotting instability or +// I-windup — but it does NOT predict the resulting boost. + +// bracket returns the two indices of an ascending table that surround v and the +// interpolation fraction between them, clamping to the ends (so values outside +// the table hold the edge value, matching the ECU's TAB/MAT routines). +func bracket(tab []float64, v float64) (i0, i1 int, frac float64) { + n := len(tab) + if n <= 1 { + return 0, 0, 0 + } + if v <= tab[0] { + return 0, 0, 0 + } + if v >= tab[n-1] { + return n - 1, n - 1, 0 + } + for i := 0; i < n-1; i++ { + if v >= tab[i] && v <= tab[i+1] { + span := tab[i+1] - tab[i] + if span == 0 { + return i, i, 0 + } + return i, i + 1, (v - tab[i]) / span + } + } + return n - 1, n - 1, 0 +} + +// bilerp bilinearly interpolates a row-major map z (rows follow ytab, cols follow +// xtab) at (x, y), clamping outside the axis ranges. This is the Go stand-in for +// the ECU's MATs16 routines (faithful, not bit-exact). +func bilerp(xtab, ytab, z []float64, x, y float64) float64 { + cols := len(xtab) + x0, x1, xf := bracket(xtab, x) + y0, y1, yf := bracket(ytab, y) + z00 := z[y0*cols+x0] + z10 := z[y0*cols+x1] + z01 := z[y1*cols+x0] + z11 := z[y1*cols+x1] + top := z00 + (z10-z00)*xf + bot := z01 + (z11-z01)*xf + return top + (bot-top)*yf +} + +// simInputs holds the row-aligned log channels the replay consumes (all length n, +// raw units: duty terms in 0.1%, errors in mg/c). +type simInputs struct { + n int + rpm, setv, loadDiff, regCon, pFac, iFac, dFac, adap, pwmCalc, pwm2pct []float64 +} + +// simMaps holds the maps and constants the law uses. regMapRaw is in raw 0.1% +// units (the % map symbol scaled by dutyRawPerPct). +type simMaps struct { + setLoadXSP, nEngSP, regMapRaw []float64 + pidXSP, pidYSP []float64 + pMap, iMap, dMap []float64 + iFacMax, filterFactor float64 +} + +// simOutput is the replay result, all in raw 0.1% duty units. +type simOutput struct { + predicted, regCon, pFac, iFac, dFac []float64 +} + +// simulate replays the control law. It recomputes RegConValue and the P/I/D terms +// from the maps while carrying the logged environment residual (E85/altitude/temp/ +// noise + any rounding) unchanged, so only map-driven differences show. +func simulate(in simInputs, m simMaps) simOutput { + out := simOutput{ + predicted: make([]float64, in.n), + regCon: make([]float64, in.n), + pFac: make([]float64, in.n), + iFac: make([]float64, in.n), + dFac: make([]float64, in.n), + } + var iBuff, iFacAcc, dFacState, loadDiffOld float64 + haveOld := false + + for i := 0; i < in.n; i++ { + if anyNaN(in.rpm[i], in.setv[i], in.loadDiff[i], in.regCon[i], in.pFac[i], in.iFac[i], in.dFac[i], in.adap[i], in.pwmCalc[i], in.pwm2pct[i]) { + out.predicted[i], out.regCon[i] = math.NaN(), math.NaN() + out.pFac[i], out.iFac[i], out.dFac[i] = math.NaN(), math.NaN(), math.NaN() + haveOld = false + continue + } + // Residual = everything in the logged PWMCalc the maps don't drive. + env := in.pwmCalc[i] - (in.regCon[i] + in.pFac[i] + in.iFac[i] + in.dFac[i] + in.adap[i]) + regConSim := bilerp(m.setLoadXSP, m.nEngSP, m.regMapRaw, in.setv[i], in.rpm[i]) + out.regCon[i] = regConSim + + if in.pwm2pct[i] != 0 { // open loop: PID ramped off + iBuff, iFacAcc, dFacState = 0, 0, 0 + haveOld = false + out.predicted[i] = regConSim + in.adap[i] + env + continue + } + + ld := in.loadDiff[i] + pConst := bilerp(m.pidXSP, m.pidYSP, m.pMap, ld, in.rpm[i]) + iConst := bilerp(m.pidXSP, m.pidYSP, m.iMap, ld, in.rpm[i]) + dConst := bilerp(m.pidXSP, m.pidYSP, m.dMap, ld, in.rpm[i]) + + pFac := ld * pConst / 100 + iBuff += iConst * ld + if iBuff > 1000 { + iFacAcc += iBuff / 1000 + iBuff = 0 + if iFacAcc > m.iFacMax { + iFacAcc = m.iFacMax + } + } else if iBuff < -1000 { + iFacAcc += iBuff / 1000 + iBuff = 0 + if iFacAcc < -m.iFacMax { + iFacAcc = -m.iFacMax + } + } + if !haveOld { + loadDiffOld = ld + haveOld = true + } + dFacState = ((ld-loadDiffOld)*dConst + dFacState*m.filterFactor) / (20 + m.filterFactor) + loadDiffOld = ld + + out.pFac[i], out.iFac[i], out.dFac[i] = pFac, iFacAcc, dFacState + out.predicted[i] = regConSim + pFac + iFacAcc + dFacState + in.adap[i] + env + } + return out +} + +// --- UI --- + +func (bt *BoostTuner) buildSimTab() fyne.CanvasObject { + status := widget.NewLabel("") + status.Wrapping = fyne.TextWrapWord + + intro := widget.NewLabel( + "Replays the ECU boost control law over the loaded logs using the current " + + "maps (RegMap from the binary; P/I/D from the editors). It predicts the " + + "controller's duty output for the logged error — it does NOT predict the " + + "resulting boost (no engine/turbo model). Use it to check edits don't make " + + "the duty oscillate or wind up.") + intro.Wrapping = fyne.TextWrapWord + + display := container.NewStack(container.NewCenter( + widget.NewLabel("Load logs, then click Simulate."), + )) + + simBtn := widget.NewButtonWithIcon("Simulate", theme.MediaPlayIcon(), func() { + values, err := bt.runSimulation() + if err != nil { + status.SetText(err.Error()) + return + } + order := []string{"PWMCalc logged", "PWMCalc predicted", "P (sim)", "I (sim)", "D (sim)"} + p := plotter.NewPlotter(values, plotter.WithOrder(order)) + display.Objects = []fyne.CanvasObject{p} + display.Refresh() + status.SetText("Simulated. Toggle series in the legend.") + }) + + header := container.NewVBox(intro, container.NewBorder(nil, nil, nil, status, simBtn)) + return container.NewBorder(header, nil, nil, nil, display) +} + +// runSimulation gathers inputs and current maps, runs the replay and returns the +// named series for the plotter. +func (bt *BoostTuner) runSimulation() (map[string][]float64, error) { + if len(bt.values) == 0 { + return nil, errString("load logs first (Logs tab)") + } + if missing := bt.missingChannels(); len(missing) > 0 { + return nil, errString("missing channels for simulation") + } + in := simInputs{ + n: bt.nrecords, + rpm: mustSeries(bt.series("rpm")), + setv: mustSeries(bt.series("setValue")), + loadDiff: mustSeries(bt.series("loadDiff")), + regCon: mustSeries(bt.series("regCon")), + pFac: mustSeries(bt.series("pFac")), + iFac: mustSeries(bt.series("iFac")), + dFac: mustSeries(bt.series("dFac")), + adap: mustSeries(bt.series("adaption")), + pwmCalc: mustSeries(bt.series("pwmCalc")), + pwm2pct: mustSeries(bt.series("pwm2pct")), + } + + m, err := bt.loadSimMaps() + if err != nil { + return nil, err + } + out := simulate(in, m) + + return map[string][]float64{ + "PWMCalc logged": in.pwmCalc, + "PWMCalc predicted": out.predicted, + "P (sim)": out.pFac, + "I (sim)": out.iFac, + "D (sim)": out.dFac, + }, nil +} + +// loadSimMaps reads the maps and constants for the replay: RegMap and its axes +// from the binary, P/I/D from the editors (so edits/suggestions are reflected), +// IFacMax and FilterFactor from the binary. +func (bt *BoostTuner) loadSimMaps() (simMaps, error) { + var m simMaps + var err error + if m.setLoadXSP, err = bt.readSymbol(symSetLoadXSP); err != nil { + return m, err + } + if m.nEngSP, err = bt.readSymbol(symNEngSP); err != nil { + return m, err + } + regPct, err := bt.readSymbol(symRegMap) + if err != nil { + return m, err + } + m.regMapRaw = make([]float64, len(regPct)) + for i, v := range regPct { + m.regMapRaw[i] = v * dutyRawPerPct // % -> raw 0.1% + } + if m.pidXSP, err = bt.readSymbol(symPIDXSP); err != nil { + return m, err + } + if m.pidYSP, err = bt.readSymbol(symPIDYSP); err != nil { + return m, err + } + m.pMap = bt.pidMapData("P", symPMap) + m.iMap = bt.pidMapData("I", symIMap) + m.dMap = bt.pidMapData("D", symDMap) + + if v, err := bt.readSymbol("BoostCal.IFacMax"); err == nil && len(v) > 0 { + m.iFacMax = v[0] + } else { + m.iFacMax = 350 + } + if v, err := bt.readSymbol("BoostCal.FilterFactor"); err == nil && len(v) > 0 { + m.filterFactor = v[0] + } + return m, nil +} + +// pidMapData returns the editor's live (possibly edited) map data, falling back +// to the binary if the editor is absent. +func (bt *BoostTuner) pidMapData(name, symName string) []float64 { + if ed := bt.pidEditors[name]; ed != nil && len(ed.data) > 0 { + return ed.data + } + v, _ := bt.readSymbol(symName) + return v +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/pkg/widgets/boosttuner/sim_test.go b/pkg/widgets/boosttuner/sim_test.go new file mode 100644 index 00000000..91339dfd --- /dev/null +++ b/pkg/widgets/boosttuner/sim_test.go @@ -0,0 +1,74 @@ +package boosttuner + +import ( + "math" + "testing" +) + +func TestBilerp_CornersAndCenter(t *testing.T) { + xtab := []float64{0, 1} + ytab := []float64{0, 1} + z := []float64{0, 10, 20, 30} // [y][x]: (0,0)=0 (1,0)=10 (0,1)=20 (1,1)=30 + + cases := []struct { + x, y, want float64 + }{ + {0, 0, 0}, {1, 0, 10}, {0, 1, 20}, {1, 1, 30}, + {0.5, 0, 5}, {0, 0.5, 10}, {0.5, 0.5, 15}, + {-5, -5, 0}, // clamp low + {99, 99, 30}, // clamp high + } + for _, c := range cases { + if got := bilerp(xtab, ytab, z, c.x, c.y); math.Abs(got-c.want) > 1e-9 { + t.Errorf("bilerp(%v,%v) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +// TestSimulate_OpenLoopIdentity: with the loop disengaged and unchanged RegMap, +// the predicted PWM equals the logged PWMCalc (the residual carries everything). +func TestSimulate_OpenLoopIdentity(t *testing.T) { + m := simMaps{ + setLoadXSP: []float64{1000}, nEngSP: []float64{3000}, + regMapRaw: []float64{450}, // bilerp -> 450, matching logged regCon + pidXSP: []float64{0}, pidYSP: []float64{3000}, + pMap: []float64{0}, iMap: []float64{0}, dMap: []float64{0}, + iFacMax: 350, + } + in := simInputs{ + n: 1, rpm: []float64{3000}, setv: []float64{1000}, loadDiff: []float64{0}, + regCon: []float64{450}, pFac: []float64{0}, iFac: []float64{0}, dFac: []float64{0}, + adap: []float64{30}, pwmCalc: []float64{520}, pwm2pct: []float64{1}, // open loop + } + out := simulate(in, m) + if math.Abs(out.predicted[0]-520) > 1e-9 { + t.Fatalf("predicted = %v, want 520 (logged)", out.predicted[0]) + } +} + +// TestSimulate_ClosedLoopRecomputesP checks the P term is recomputed from the map +// and the environment residual is preserved. +func TestSimulate_ClosedLoopRecomputesP(t *testing.T) { + m := simMaps{ + setLoadXSP: []float64{1000}, nEngSP: []float64{3000}, + regMapRaw: []float64{450}, + pidXSP: []float64{0}, pidYSP: []float64{3000}, + pMap: []float64{100}, iMap: []float64{0}, dMap: []float64{0}, // P const 100 + iFacMax: 350, + } + // Logged decomposition: 450+5+20+0+30 = 505 == pwmCalc, so env residual = 0. + in := simInputs{ + n: 1, rpm: []float64{3000}, setv: []float64{1000}, loadDiff: []float64{10}, + regCon: []float64{450}, pFac: []float64{5}, iFac: []float64{20}, dFac: []float64{0}, + adap: []float64{30}, pwmCalc: []float64{505}, pwm2pct: []float64{0}, + } + out := simulate(in, m) + // P_sim = loadDiff*Pconst/100 = 10*100/100 = 10; I=0; D=0. + // predicted = 450 + 10 + 0 + 0 + 30 + env(0) = 490. + if math.Abs(out.pFac[0]-10) > 1e-9 { + t.Errorf("P_sim = %v, want 10", out.pFac[0]) + } + if math.Abs(out.predicted[0]-490) > 1e-9 { + t.Errorf("predicted = %v, want 490", out.predicted[0]) + } +} diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index 47494c34..b8980c5a 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -1,15 +1,65 @@ package windows import ( + "fmt" + "os" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/widgets/boosttuner" "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) +// openBoostTuner opens (or raises) the T7 boost auto-tuner. It reads the current +// BoostCal maps from the loaded binary and writes tuned maps back through a save +// closure that takes a one-time .bak of the file before the first write. +func (mw *MainWindow) openBoostTuner() { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if w := mw.wm.HasWindow("Boost Auto-Tuner"); w != nil { + mw.wm.Raise(w) + return + } + save := func(symbolName string, data []float64) error { + sym := mw.fw.GetByName(symbolName) + if sym == nil { + return fmt.Errorf("symbol %s not found", symbolName) + } + if err := sym.SetData(sym.EncodeFloat64s(data)); err != nil { + return err + } + if mw.filename != "" { + if bak := mw.filename + ".bak"; !fileExists(bak) { + if orig, err := os.ReadFile(mw.filename); err == nil { + _ = os.WriteFile(bak, orig, 0o644) + } + } + } + return mw.fw.Save(mw.filename) + } + bt := boosttuner.New(boosttuner.Config{ + Symbols: mw.fw, + Save: save, + MeshRenderer: mw.settings.GetMeshRenderer(), + Colorblind: mw.settings.GetColorBlindMode(), + }) + inner := multiwindow.NewInnerWindow("Boost Auto-Tuner", bt) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1100, 760)) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + // openMatrixBuilder opens (or raises) the matrix builder window. The builder // loads its own log files, so it is independent of any open log player. func (mw *MainWindow) openMatrixBuilder() { @@ -60,6 +110,7 @@ func (mw *MainWindow) newToolbar() *fyne.Container { }), ) + toolbar.Add(widget.NewButtonWithIcon("Boost", theme.MediaFastForwardIcon(), mw.openBoostTuner)) /* toolbar.Add(widget.NewButtonWithIcon("", theme.DocumentIcon(), func() { if w := mw.wm.HasWindow("txweb"); w != nil { From 9c0590fbf027efa5b1d4a89e3c9d50e677a4875c Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 17 Jun 2026 20:59:00 +0200 Subject: [PATCH 52/93] add initial AS2 support --- pkg/windows/mainWindow.go | 34 ++++++++---- pkg/windows/mainWindow_internal.go | 4 ++ pkg/windows/mainWindow_menu.go | 88 +++++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 90e0cb32..bd37032b 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -20,6 +20,7 @@ import ( "fyne.io/fyne/v2/widget" xwidget "fyne.io/x/fyne/widget" symbol "github.com/roffe/ecusymbol" + "github.com/roffe/ecusymbol/as2" "github.com/roffe/gocan" "github.com/roffe/gocan/proto" "github.com/roffe/txlogger/pkg/colors" @@ -61,16 +62,19 @@ func (s *SecretText) MouseUp(e *desktop.MouseEvent) { type MainWindow struct { fyne.Window - app fyne.App - menu *MainMenu - outputData binding.StringList - selects *mainWindowSelects - buttons *mainWindowButtons - counters *mainWindowCounters - loggingRunning bool - filename string - symbolList *symbollist.Widget - fw symbol.SymbolCollection + app fyne.App + menu *MainMenu + outputData binding.StringList + selects *mainWindowSelects + buttons *mainWindowButtons + counters *mainWindowCounters + loggingRunning bool + filename string + symbolList *symbollist.Widget + + as2 *as2.File + fw symbol.SymbolCollection + dlc datalogger.IClient gwclient proto.GocanClient buttonsDisabled bool @@ -398,6 +402,16 @@ func (mw *MainWindow) LoadLogfileCombined(filename string, reader io.ReadCloser, mw.Log("loaded log file " + filename + " in combined logplayer") } +func (mw *MainWindow) LoadAS2File(filename string) error { + f, err := as2.Load(filename) + if err != nil { + return fmt.Errorf("failed to load AS2 file: %w", err) + } + mw.as2 = f + mw.Log("Loaded AS2 file " + filename) + return nil +} + func (mw *MainWindow) LoadLogfile(filename string, r io.Reader, pos fyne.Position) { // Just filename, used for Window title fp := filepath.Base(filename) diff --git a/pkg/windows/mainWindow_internal.go b/pkg/windows/mainWindow_internal.go index 894addf4..b6cb9010 100644 --- a/pkg/windows/mainWindow_internal.go +++ b/pkg/windows/mainWindow_internal.go @@ -71,6 +71,10 @@ func (mw *MainWindow) onDropped(p fyne.Position, uris []fyne.URI) { for _, u := range uris { filename := u.Path() switch strings.ToLower(path.Ext(filename)) { + case ".as2": + if err := mw.LoadAS2File(filename); err != nil { + mw.Error(err) + } case ".bin": if err := mw.LoadSymbolsFromFile(filename); err != nil { mw.Error(err) diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index f4c91367..b84ce5c4 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -210,6 +210,17 @@ func (mw *MainWindow) setupMenu() { mw.Error(err) } }), + fyne.NewMenuItemWithIcon("Open AS2 file", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Path() + mw.Log("Opening AS2 file " + filename) + if err := mw.LoadAS2File(filename); err != nil { + mw.Error(err) + } + } + widgets.SelectFile(cb, "AS2 file", "as2") + }), ) leading := []*fyne.Menu{ @@ -280,7 +291,35 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) return } - axis := symbol.GetInfo(typ, mapName) + var axis symbol.Axis + if mw.as2 != nil { + axis.Z = mapName + axes := mw.as2.Axes(mapName) + if len(axes) == 0 { + mw.Error(fmt.Errorf("map %q not found in as2 file", mapName)) + return + } + if len(axes) == 1 { + axis.Y = axes[0].SupportPoints + axis.YFrom = axes[0].Signal + } else { + for i, a := range axes { + log.Printf("Axis %d: %+v", i, a) + if i == 0 { + axis.X = a.SupportPoints + axis.XFrom = a.Signal + continue + } + if i == 1 { + axis.Y = a.SupportPoints + axis.YFrom = a.Signal + continue + } + } + } + } else { + axis = symbol.GetInfo(typ, mapName) + } windowName := axis.Z if title != "" { @@ -308,6 +347,12 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) symY := mw.fw.GetByName(axis.Y) symZ := mw.fw.GetByName(axis.Z) + if mw.as2 != nil { + if symZ != nil { + symZ.Correctionfactor = mw.as2.GetCorrectionfactor(mapName) + } + } + if symZ == nil { mw.Error(fmt.Errorf("failed to find symbol %s", axis.Z)) return @@ -473,16 +518,40 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) } var xPrecision, yPrecision, zPrecision int - if symX != nil { - xPrecision = symbol.GetPrecision(symX.Correctionfactor) - } - if symY != nil { - yPrecision = symbol.GetPrecision(symY.Correctionfactor) + if mw.as2 != nil { + if symX != nil { + xPrecision = mw.as2.Precision(axis.X) + log.Printf("Precision for %s: %d", axis.X, xPrecision) + //if xPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.X) + // xPrecision = 2 + //} + } + if symY != nil { + yPrecision = mw.as2.Precision(axis.Y) + log.Printf("Precision for %s: %d", axis.Y, yPrecision) + //if yPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.Y) + // yPrecision = 2 + //} + } + zPrecision = mw.as2.Precision(axis.Z) + log.Printf("Precision for %s: %d", axis.Z, zPrecision) + //if zPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.Z) + // zPrecision = 2 + //} + } else { + if symX != nil { + xPrecision = symbol.GetPrecision(symX.Correctionfactor) + } + if symY != nil { + yPrecision = symbol.GetPrecision(symY.Correctionfactor) + } + zPrecision = symbol.GetPrecision(symZ.Correctionfactor) } - zPrecision = symbol.GetPrecision(symZ.Correctionfactor) - cfg := &mapviewer.Config{ Name: symZ.Name, @@ -556,7 +625,8 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) }, } - mv, err := mapviewer.New(cfg) + var err error + mv, err = mapviewer.New(cfg) if err != nil { mw.Error(err) return From 87055afe1adf752e320b6c3c73c59d5bef78e3ce Mon Sep 17 00:00:00 2001 From: roffe Date: Wed, 17 Jun 2026 23:19:08 +0200 Subject: [PATCH 53/93] add coverage view --- pkg/widgets/matrixbuilder/matrixbuilder.go | 61 +++++++++++++++++++--- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go index 07c79982..92827c72 100644 --- a/pkg/widgets/matrixbuilder/matrixbuilder.go +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -50,6 +50,13 @@ const ( defaultTolerance = 100 ) +// Display views for the built matrix. viewMatrix shows the learned Z values; +// viewCoverage shows how many samples landed in each cell. +const ( + viewMatrix = "Matrix" + viewCoverage = "Coverage (hits)" +) + var _ fyne.Widget = (*MatrixBuilder)(nil) type MatrixBuilder struct { @@ -71,6 +78,10 @@ type MatrixBuilder struct { yAxis []float64 zData []float64 + // counts holds the number of samples that landed in each cell during the + // last analyze, parallel to zData. It feeds the Coverage view. + counts []int + // xTolerance/yTolerance gate how close (as a percentage of the cell's // half-spacing) a sample must be to its nearest breakpoint to count as a // Z-hit on that axis. A sample is mapped only if it passes on both axes. @@ -91,6 +102,7 @@ type MatrixBuilder struct { xTolLabel, yTolLabel *widget.Label presetSelect *widget.Select nameEntry *widget.Entry + viewSelect *widget.Select display *fyne.Container // Filter tree: a nested group/condition builder. rootGroup is the live editor @@ -359,6 +371,19 @@ func (mb *MatrixBuilder) buildUI() { }) buildBtn.Importance = widget.HighImportance + // View toggle: switch the display between the learned matrix and a coverage + // heatmap of how many samples landed in each cell. + mb.viewSelect = widget.NewSelect([]string{viewMatrix, viewCoverage}, func(string) { + mb.rebuildDisplay() + }) + // Set the field directly rather than SetSelected: the latter fires OnChanged, + // which calls rebuildDisplay before mb.display exists (panic during buildUI). + mb.viewSelect.Selected = viewMatrix + bottomBar := container.NewBorder(nil, nil, nil, + container.NewHBox(widget.NewLabel("View"), mb.viewSelect), + buildBtn, + ) + mb.xBox = container.NewVBox() mb.yBox = container.NewHBox() mb.rebuildAxisEntries() @@ -407,7 +432,7 @@ func (mb *MatrixBuilder) buildUI() { mainSplit := container.NewHSplit( container.NewBorder( yPanel, - buildBtn, + bottomBar, container.NewVBox( widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), mb.xBox, @@ -1076,6 +1101,7 @@ func (mb *MatrixBuilder) analyze() error { } mb.zData = make([]float64, size) + mb.counts = cnt filled := 0 for i := range sum { if cnt[i] > 0 { @@ -1105,21 +1131,34 @@ func (mb *MatrixBuilder) rebuildDisplay() { return } + // Default to the learned matrix; the Coverage view swaps in the per-cell hit + // counts as a read-only heatmap. + zData := mb.zData + zLabel := mb.zSeries + zPrec := precisionFor(mb.zData) + editable := true + if mb.viewSelect != nil && mb.viewSelect.Selected == viewCoverage { + zData = countsToFloat(mb.counts) + zLabel = "Hits" + zPrec = 0 + editable = false + } + noop := func([]float64) {} mv, err := mapviewer.New(&mapviewer.Config{ - Name: mb.zSeries, + Name: zLabel, XData: mb.xAxis, YData: mb.yAxis, - ZData: mb.zData, + ZData: zData, XPrecision: precisionFor(mb.xAxis), YPrecision: precisionFor(mb.yAxis), - ZPrecision: precisionFor(mb.zData), + ZPrecision: zPrec, XLabel: mb.xSeries, YLabel: mb.ySeries, - ZLabel: mb.zSeries, + ZLabel: zLabel, MeshView: true, MeshRenderer: mb.renderMode, - Editable: true, + Editable: editable, ColorblindMode: colors.ModeNormal, // The matrix is in-memory only; editing cells just mutates zData. SaveECUFunc: noop, @@ -1591,6 +1630,16 @@ func nearestIndex(axis []float64, v float64) int { return best } +// countsToFloat converts per-cell hit counts to float64 Z data for the +// mapviewer's Coverage view. +func countsToFloat(counts []int) []float64 { + out := make([]float64, len(counts)) + for i, c := range counts { + out[i] = float64(c) + } + return out +} + func minMax(data []float64) (float64, float64) { lo, hi := data[0], data[0] for _, v := range data[1:] { From 988cc34290ab235b94c7d615d384643663bfd02c Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 09:54:47 +0200 Subject: [PATCH 54/93] gocan stuff --- go.mod | 2 +- go.sum | 4 +- pkg/datalogger/t5logger.go | 6 +- pkg/datalogger/t7logger.go | 7 +- pkg/datalogger/t8logger.go | 6 +- pkg/datalogger/txbridgelogger.go | 7 +- pkg/widgets/canflasher/candump.go | 30 +- pkg/widgets/canflasher/canflash.go | 19 +- pkg/widgets/canflasher/caninfo.go | 1 - pkg/widgets/canflasher/canrecovery.go | 19 +- pkg/widgets/dtcreader/dtcreader.go | 34 +- pkg/widgets/editparameters/editparameters.go | 387 +++++++++---------- 12 files changed, 234 insertions(+), 288 deletions(-) diff --git a/go.mod b/go.mod index 77979766..064176d3 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.1.9 + github.com/roffe/ecusymbol v1.2.0 github.com/roffe/gocan v1.4.0 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index 4299a795..cb93fdce 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.1.9 h1:muLJ5ihTK2LTWHrbfB/Dlk3pDAh5d0/UHKX2PE60fcE= -github.com/roffe/ecusymbol v1.1.9/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/ecusymbol v1.2.0 h1:mI5M0HG17gCgbPTbMpIR8nPJGBu4HNZp0133HcPOzYw= +github.com/roffe/ecusymbol v1.2.0/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.0 h1:OSs//lr4vy/ozyMPUbgZaNFVZWMeXzOsXhCujpA4WRs= github.com/roffe/gocan v1.4.0/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= diff --git a/pkg/datalogger/t5logger.go b/pkg/datalogger/t5logger.go index f8bf8712..4a251882 100644 --- a/pkg/datalogger/t5logger.go +++ b/pkg/datalogger/t5logger.go @@ -34,12 +34,16 @@ func (c *T5Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly. + ctx = cl.Context() + t := time.NewTicker(time.Second / time.Duration(c.Rate)) defer t.Stop() t5 := t5can.NewClient(cl) diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index d238c6ca..21b93f0d 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -85,12 +85,17 @@ func (c *T7Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return fmt.Errorf("failed to create t7 client: %w", err) } defer cl.Close() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly, + // instead of relying on cl.Wait returning and the deferred cancel bouncing back. + ctx = cl.Context() + checkBroadcast := true if strings.Contains(c.Device.Name(), "OBDLink") || strings.Contains(c.Device.Name(), "STN") || strings.Contains(c.Device.Name(), "ELM") { checkBroadcast = false diff --git a/pkg/datalogger/t8logger.go b/pkg/datalogger/t8logger.go index 8b08fd8c..4b6ce00f 100644 --- a/pkg/datalogger/t8logger.go +++ b/pkg/datalogger/t8logger.go @@ -52,12 +52,16 @@ func (c *T8Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly. + ctx = cl.Context() + if err := c.setupWBL(ctx, cl); err != nil { return err } diff --git a/pkg/datalogger/txbridgelogger.go b/pkg/datalogger/txbridgelogger.go index 8a31bda0..b3eca75f 100644 --- a/pkg/datalogger/txbridgelogger.go +++ b/pkg/datalogger/txbridgelogger.go @@ -35,12 +35,17 @@ func (c *TxBridge) Start() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() + // Drive everything below (incl. the per-ECU loops, which derive their ctx + // from this one) off the client's context so a fatal adapter error or Close + // cancels logging and aborts in-flight requests directly. + ctx = cl.Context() + if err := c.setupWBL(ctx, cl); err != nil { return err } diff --git a/pkg/widgets/canflasher/candump.go b/pkg/widgets/canflasher/candump.go index e0cdc92e..6032a5eb 100644 --- a/pkg/widgets/canflasher/candump.go +++ b/pkg/widgets/canflasher/candump.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -32,33 +31,18 @@ func (t *CanFlasherWidget) ecuDump(filename string) { filename = addSuffix(filename, ".bin") t.progressBar.SetValue(0) - done := make(chan struct{}) - go func() { - for { - select { - case err := <-dev.Err(): - if err == nil { - return - } - log.Println("Error:", err) - case <-done: - return - } - } - }() - - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Second) defer cancel() - //defer dev.Close() + // defer dev.Close() fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return @@ -86,7 +70,7 @@ func (t *CanFlasherWidget) ecuDump(filename string) { return } - if err := os.WriteFile(filename, bin, 0644); err == nil { + if err := os.WriteFile(filename, bin, 0o644); err == nil { t.log("Saved as " + filename) } else { t.log(err.Error()) @@ -97,8 +81,6 @@ func (t *CanFlasherWidget) ecuDump(filename string) { time.Sleep(200 * time.Millisecond) - if err := tr.ResetECU(ctx); err != nil { - t.log(err.Error()) - } + _ = tr.ResetECU(ctx) }() } diff --git a/pkg/widgets/canflasher/canflash.go b/pkg/widgets/canflasher/canflash.go index 13368f17..f070d7f5 100644 --- a/pkg/widgets/canflasher/canflash.go +++ b/pkg/widgets/canflasher/canflash.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -37,21 +36,7 @@ func (t *CanFlasherWidget) ecuFlash(filename string) { t.progressBar.SetValue(0) - done := make(chan struct{}) - - go func() { - for { - select { - case err := <-dev.Err(): - log.Println("Error:", err) - case <-done: - return - } - } - }() - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1800*time.Second) defer cancel() @@ -60,7 +45,9 @@ func (t *CanFlasherWidget) ecuFlash(filename string) { fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return diff --git a/pkg/widgets/canflasher/caninfo.go b/pkg/widgets/canflasher/caninfo.go index b5dcbdb7..05562e3a 100644 --- a/pkg/widgets/canflasher/caninfo.go +++ b/pkg/widgets/canflasher/caninfo.go @@ -21,7 +21,6 @@ func (t *CanFlasherWidget) ecuInfo() { } go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/pkg/widgets/canflasher/canrecovery.go b/pkg/widgets/canflasher/canrecovery.go index 6b0f4a0d..1c9d23ec 100644 --- a/pkg/widgets/canflasher/canrecovery.go +++ b/pkg/widgets/canflasher/canrecovery.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -28,21 +27,7 @@ func (t *CanFlasherWidget) ecuRecover(filename string) { t.progressBar.SetValue(0) - done := make(chan struct{}) - - go func() { - for { - select { - case err := <-dev.Err(): - log.Println("Error:", err) - case <-done: - return - } - } - }() - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1800*time.Second) defer cancel() @@ -51,7 +36,9 @@ func (t *CanFlasherWidget) ecuRecover(filename string) { fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return diff --git a/pkg/widgets/dtcreader/dtcreader.go b/pkg/widgets/dtcreader/dtcreader.go index a1a182c3..c04e8f5a 100644 --- a/pkg/widgets/dtcreader/dtcreader.go +++ b/pkg/widgets/dtcreader/dtcreader.go @@ -148,26 +148,19 @@ func (d *DTCReader) ReadDTCS() error { return } - eventHandler := func(e gocan.Event) { - d.log(e.String()) - } - d.log("Connecting to device " + dev.Name()) - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + // Events (incl. the final fatal) stream to the log; a fatal adapter + // failure also aborts any in-flight call below with that error. + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + d.log(e.String()) + })) if err != nil { d.err(err) return } defer cl.Close() - go func() { - if err := cl.Wait(ctx); err != nil { - d.err(err) - return - } - }() - readDTCSFunc(ctx, cl) }() return nil @@ -198,26 +191,19 @@ func (d *DTCReader) ClearDTCS() error { d.err(err) return } - eventHandler := func(e gocan.Event) { - d.log(e.String()) - } - d.log("Connecting to device " + dev.Name()) - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + // Events (incl. the final fatal) stream to the log; a fatal adapter + // failure also aborts any in-flight call below with that error. + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + d.log(e.String()) + })) if err != nil { d.err(err) return } defer cl.Close() - go func() { - if err := cl.Wait(ctx); err != nil { - d.err(err) - return - } - }() - clearDTCSFunc(ctx, cl) }() return nil diff --git a/pkg/widgets/editparameters/editparameters.go b/pkg/widgets/editparameters/editparameters.go index 47c3e1d5..bfaad065 100644 --- a/pkg/widgets/editparameters/editparameters.go +++ b/pkg/widgets/editparameters/editparameters.go @@ -193,81 +193,74 @@ func (t *EditParameters) readParameters() { t.err(err) return } - eventHandler := func(e gocan.Event) { + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { log.Printf("EVENT: %v", e) - } - - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + })) if err != nil { t.err(err) return } + defer cl.Close() + gm := gmlan.New(cl, 0x7e0, 0x5e8, 0x7e8) t8c := &T8{gm: gm} - go func() { - defer cl.Close() - - defer func() { - _ = gm.ReturnToNormalMode(ctx) - time.Sleep(75 * time.Millisecond) - }() - - data, err := gm.ReadDataByIdentifier(ctx, 0x01) - if err != nil { - t.err(err) - return - } + defer func() { + _ = gm.ReturnToNormalMode(ctx) + time.Sleep(75 * time.Millisecond) + }() - status, err := t8.DecodePI01(data) - if err != nil { - t.err(err) - return - } + // A fatal adapter failure aborts any of these calls with that error. + data, err := gm.ReadDataByIdentifier(ctx, 0x01) + if err != nil { + t.err(err) + return + } - t.SetDiagnosticType(status.DiagnosticType.String()) - t.SetTankType(status.TankType.String()) - t.SetConvertible(status.Convertible) - t.SetSAI(status.SAI) - t.SetHighOutput(status.HighOutput) - t.SetBioPower(status.BioPower) - t.SetClutchStart(status.ClutchStart) + status, err := t8.DecodePI01(data) + if err != nil { + t.err(err) + return + } - oilQuality, err := t8c.GetOilQuality(ctx) - if err != nil { - t.err(err) - return - } - t.SetOilQuality(strconv.FormatFloat(oilQuality, 'f', 2, 64)) + t.SetDiagnosticType(status.DiagnosticType.String()) + t.SetTankType(status.TankType.String()) + t.SetConvertible(status.Convertible) + t.SetSAI(status.SAI) + t.SetHighOutput(status.HighOutput) + t.SetBioPower(status.BioPower) + t.SetClutchStart(status.ClutchStart) - vin, err := t8c.GetVehicleVIN(ctx) - if err != nil { - t.err(err) - return - } - t.SetVIN(vin) + oilQuality, err := t8c.GetOilQuality(ctx) + if err != nil { + t.err(err) + return + } + t.SetOilQuality(strconv.FormatFloat(oilQuality, 'f', 2, 64)) - topSpeed, err := t8c.GetTopSpeed(ctx) - if err != nil { - t.err(err) - return - } - t.SetTopSpeed(strconv.Itoa(int(topSpeed))) + vin, err := t8c.GetVehicleVIN(ctx) + if err != nil { + t.err(err) + return + } + t.SetVIN(vin) - time.Sleep(5 * time.Millisecond) + topSpeed, err := t8c.GetTopSpeed(ctx) + if err != nil { + t.err(err) + return + } + t.SetTopSpeed(strconv.Itoa(int(topSpeed))) - e85percentage, err := t8c.GetE85Percent(ctx) - if err != nil { - t.err(err) - return - } - t.SetE85Percent(strconv.FormatFloat(e85percentage, 'f', 0, 64)) - }() + time.Sleep(5 * time.Millisecond) - if err := cl.Wait(ctx); err != nil { + e85percentage, err := t8c.GetE85Percent(ctx) + if err != nil { t.err(err) return } + t.SetE85Percent(strconv.FormatFloat(e85percentage, 'f', 0, 64)) + t.hasBeenRead = true } @@ -287,168 +280,162 @@ func (t *EditParameters) writeParameters() { t.err(err) return } - eventHandler := func(e gocan.Event) { + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { log.Printf("EVENT: %v", e) - } - - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + })) if err != nil { t.err(err) return } + defer cl.Close() + gm := gmlan.New(cl, 0x7e0, 0x5e8, 0x7e8) t8c := &T8{gm: gm} - go func() { - defer cl.Close() - - //if err := gm.InitiateDiagnosticOperation(ctx, gmlan.LEV_EDDDC); err != nil { - // log.Println(err) - // return - //} - - defer func() { - _ = gm.ReturnToNormalMode(ctx) - time.Sleep(75 * time.Millisecond) - }() - - if err := gm.RequestSecurityAccess(ctx, 0xFD, 1, t8sec.CalculateAccessKey); err != nil { - t.err(err) - return - } - - vin, err := t.GetVIN() - if err != nil { - t.err(fmt.Errorf("Error getting VIN: %w", err)) - return - } - if err := t8c.SetVehicleVIN(ctx, vin); err != nil { - t.err(fmt.Errorf("Error setting VIN: %w", err)) - } - - e85content, err := t.GetE85Percent() - if err != nil { - t.err(fmt.Errorf("Error getting E85 content: %w", err)) - return - } - e85percent, err := strconv.ParseFloat(e85content, 64) - if err != nil { - t.err(fmt.Errorf("Error parsing E85 content: %w", err)) - return - } - if err := t8c.SetE85Percent(ctx, e85percent); err != nil { - t.err(fmt.Errorf("Error setting E85 percent: %w", err)) - return - } - - topSpeed, err := t.GetTopSpeed() - if err != nil { - t.err(fmt.Errorf("Error getting Top Speed: %w", err)) - return - } - topSpeedVal, err := strconv.Atoi(topSpeed) - if err != nil { - t.err(fmt.Errorf("Error parsing Top Speed: %w", err)) - return - } - if err := t8c.SetTopSpeed(ctx, uint16(topSpeedVal)); err != nil { - t.err(fmt.Errorf("Error setting Top Speed: %w", err)) - return - } + //if err := gm.InitiateDiagnosticOperation(ctx, gmlan.LEV_EDDDC); err != nil { + // log.Println(err) + // return + //} - oilQuality, err := t.GetOilQuality() - if err != nil { - t.err(fmt.Errorf("Error getting oil quality: %w", err)) - return - } - oilQualityVal, err := strconv.ParseFloat(oilQuality, 64) - if err != nil { - t.err(fmt.Errorf("Error parsing oil quality: %w", err)) - return - } - if err := t8c.SetOilQuality(ctx, oilQualityVal); err != nil { - t.err(fmt.Errorf("Error setting oil quality: %w", err)) - return - } + defer func() { + _ = gm.ReturnToNormalMode(ctx) + time.Sleep(75 * time.Millisecond) + }() - data, err := gm.ReadDataByIdentifier(ctx, 0x01) - if err != nil { - t.err(fmt.Errorf("Error reading PI01: %w", err)) - return - } + // A fatal adapter failure aborts any of these calls with that error. + if err := gm.RequestSecurityAccess(ctx, 0xFD, 1, t8sec.CalculateAccessKey); err != nil { + t.err(err) + return + } - pi01, err := t.GetPI01Data() - if err != nil { - t.err(fmt.Errorf("Error getting PI 0x01 data: %w", err)) - return - } + vin, err := t.GetVIN() + if err != nil { + t.err(fmt.Errorf("Error getting VIN: %w", err)) + return + } + if err := t8c.SetVehicleVIN(ctx, vin); err != nil { + t.err(fmt.Errorf("Error setting VIN: %w", err)) + } - // -------C - data[0] = setBit(data[0], 0, pi01.BioPower) - - // -----C-- - data[0] = setBit(data[0], 2, pi01.Convertible) - - // ---01--- US - // ---10--- EU - // ---11--- AWD - switch pi01.TankType { - case t8.TankTypeUS: - data[0] = setBit(data[0], 3, true) - data[0] = setBit(data[0], 4, false) - case t8.TankTypeEU: - data[0] = setBit(data[0], 3, false) - data[0] = setBit(data[0], 4, true) - case t8.TankTypeAWD: - data[0] = setBit(data[0], 3, true) - data[0] = setBit(data[0], 4, true) - } + e85content, err := t.GetE85Percent() + if err != nil { + t.err(fmt.Errorf("Error getting E85 content: %w", err)) + return + } + e85percent, err := strconv.ParseFloat(e85content, 64) + if err != nil { + t.err(fmt.Errorf("Error parsing E85 content: %w", err)) + return + } + if err := t8c.SetE85Percent(ctx, e85percent); err != nil { + t.err(fmt.Errorf("Error setting E85 percent: %w", err)) + return + } - // -01----- OBD2 - // -10----- EOBD - // -11----- LOBD - switch pi01.DiagnosticType { - case t8.DiagnosticTypeOBD2: - data[0] = setBit(data[0], 5, true) - data[0] = setBit(data[0], 6, false) - case t8.DiagnosticTypeEOBD: - data[0] = setBit(data[0], 5, false) - data[0] = setBit(data[0], 6, true) - case t8.DiagnosticTypeLOBD: - data[0] = setBit(data[0], 5, true) - data[0] = setBit(data[0], 6, true) - case t8.DiagnosticTypeNone: - data[0] = setBit(data[0], 5, false) - data[0] = setBit(data[0], 6, false) - } + topSpeed, err := t.GetTopSpeed() + if err != nil { + t.err(fmt.Errorf("Error getting Top Speed: %w", err)) + return + } + topSpeedVal, err := strconv.Atoi(topSpeed) + if err != nil { + t.err(fmt.Errorf("Error parsing Top Speed: %w", err)) + return + } + if err := t8c.SetTopSpeed(ctx, uint16(topSpeedVal)); err != nil { + t.err(fmt.Errorf("Error setting Top Speed: %w", err)) + return + } - // on = -----10- - // off= -----01- - data[1] = setBit(data[1], 1, !pi01.ClutchStart) - data[1] = setBit(data[1], 2, pi01.ClutchStart) + oilQuality, err := t.GetOilQuality() + if err != nil { + t.err(fmt.Errorf("Error getting oil quality: %w", err)) + return + } + oilQualityVal, err := strconv.ParseFloat(oilQuality, 64) + if err != nil { + t.err(fmt.Errorf("Error parsing oil quality: %w", err)) + return + } + if err := t8c.SetOilQuality(ctx, oilQualityVal); err != nil { + t.err(fmt.Errorf("Error setting oil quality: %w", err)) + return + } - // on = ---10--- - // off= ---01--- - data[1] = setBit(data[1], 3, !pi01.SAI) - data[1] = setBit(data[1], 4, pi01.SAI) + data, err := gm.ReadDataByIdentifier(ctx, 0x01) + if err != nil { + t.err(fmt.Errorf("Error reading PI01: %w", err)) + return + } - // high= -01----- - // low = -10----- - data[1] = setBit(data[1], 5, pi01.HighOutput) - data[1] = setBit(data[1], 6, !pi01.HighOutput) + pi01, err := t.GetPI01Data() + if err != nil { + t.err(fmt.Errorf("Error getting PI 0x01 data: %w", err)) + return + } - if err := gm.WriteDataByIdentifier(ctx, 0x01, data); err != nil { - t.err(fmt.Errorf("Error writing PI 0x01: %w", err)) - return - } + // -------C + data[0] = setBit(data[0], 0, pi01.BioPower) + + // -----C-- + data[0] = setBit(data[0], 2, pi01.Convertible) + + // ---01--- US + // ---10--- EU + // ---11--- AWD + switch pi01.TankType { + case t8.TankTypeUS: + data[0] = setBit(data[0], 3, true) + data[0] = setBit(data[0], 4, false) + case t8.TankTypeEU: + data[0] = setBit(data[0], 3, false) + data[0] = setBit(data[0], 4, true) + case t8.TankTypeAWD: + data[0] = setBit(data[0], 3, true) + data[0] = setBit(data[0], 4, true) + } + + // -01----- OBD2 + // -10----- EOBD + // -11----- LOBD + switch pi01.DiagnosticType { + case t8.DiagnosticTypeOBD2: + data[0] = setBit(data[0], 5, true) + data[0] = setBit(data[0], 6, false) + case t8.DiagnosticTypeEOBD: + data[0] = setBit(data[0], 5, false) + data[0] = setBit(data[0], 6, true) + case t8.DiagnosticTypeLOBD: + data[0] = setBit(data[0], 5, true) + data[0] = setBit(data[0], 6, true) + case t8.DiagnosticTypeNone: + data[0] = setBit(data[0], 5, false) + data[0] = setBit(data[0], 6, false) + } + + // on = -----10- + // off= -----01- + data[1] = setBit(data[1], 1, !pi01.ClutchStart) + data[1] = setBit(data[1], 2, pi01.ClutchStart) + + // on = ---10--- + // off= ---01--- + data[1] = setBit(data[1], 3, !pi01.SAI) + data[1] = setBit(data[1], 4, pi01.SAI) + + // high= -01----- + // low = -10----- + data[1] = setBit(data[1], 5, pi01.HighOutput) + data[1] = setBit(data[1], 6, !pi01.HighOutput) + + if err := gm.WriteDataByIdentifier(ctx, 0x01, data); err != nil { + t.err(fmt.Errorf("Error writing PI 0x01: %w", err)) + return + } - if err := gm.DeviceControl(ctx, 0x16); err != nil { - t.err(fmt.Errorf("Error performing device control 0x16: %w", err)) - return - } - }() - if err := cl.Wait(ctx); err != nil { - t.err(err) + if err := gm.DeviceControl(ctx, 0x16); err != nil { + t.err(fmt.Errorf("Error performing device control 0x16: %w", err)) + return } } From ae0b33857c9f57da8c77eea88faf3d63ea12eba7 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 09:54:58 +0200 Subject: [PATCH 55/93] new symbol browser --- pkg/widgets/symbolbrowser/symbolbrowser.go | 232 +++++++++++++++++++++ pkg/windows/mainWindow_menu.go | 15 ++ 2 files changed, 247 insertions(+) create mode 100644 pkg/widgets/symbolbrowser/symbolbrowser.go diff --git a/pkg/widgets/symbolbrowser/symbolbrowser.go b/pkg/widgets/symbolbrowser/symbolbrowser.go new file mode 100644 index 00000000..33c4d97b --- /dev/null +++ b/pkg/widgets/symbolbrowser/symbolbrowser.go @@ -0,0 +1,232 @@ +package symbolbrowser + +import ( + "fmt" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + xlayout "fyne.io/x/fyne/layout" + symbol "github.com/roffe/ecusymbol" +) + +var _ fyne.Widget = (*Widget)(nil) + +// Column proportions, shared by the header and every row so they line up. +// Name, Address, SRAM offset, Length, Type, Action(Open). +var columnSizes = []float64{0.34, 0.15, 0.15, 0.09, 0.15, 0.12} + +// Widget lists every symbol in the loaded binary together with its address, +// sram offset, length and type, and offers a button to open each one as a map. +type Widget struct { + widget.BaseWidget + + getFW func() symbol.SymbolCollection + getECU func() symbol.ECUType + openMap func(symbol.ECUType, string, string) + err func(error) + + search *widget.Entry + countLbl *widget.Label + list *widget.List + + all []*symbol.Symbol + filtered []*symbol.Symbol +} + +func New(getFW func() symbol.SymbolCollection, getECU func() symbol.ECUType, openMap func(symbol.ECUType, string, string), err func(error)) *Widget { + w := &Widget{ + getFW: getFW, + getECU: getECU, + openMap: openMap, + err: err, + } + w.loadSymbols() + w.ExtendBaseWidget(w) + return w +} + +// loadSymbols pulls the current symbol collection from the main window. +func (w *Widget) loadSymbols() { + fw := w.getFW() + if fw == nil { + w.all = nil + w.filtered = nil + return + } + w.all = fw.Symbols() + w.filtered = w.all +} + +func (w *Widget) applyFilter(q string) { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + w.filtered = w.all + } else { + out := make([]*symbol.Symbol, 0, len(w.all)) + for _, s := range w.all { + if strings.Contains(strings.ToLower(s.Name), q) { + out = append(out, s) + } + } + w.filtered = out + } + if w.countLbl != nil { + w.countLbl.SetText(w.countText()) + } + if w.list != nil { + w.list.Refresh() + w.list.ScrollToTop() + } +} + +func (w *Widget) countText() string { + if len(w.filtered) == len(w.all) { + return fmt.Sprintf("%d symbols", len(w.all)) + } + return fmt.Sprintf("%d / %d symbols", len(w.filtered), len(w.all)) +} + +func (w *Widget) openSymbol(sym *symbol.Symbol) { + if w.openMap == nil || sym == nil { + return + } + w.openMap(w.getECU(), "", sym.Name) +} + +func (w *Widget) header() fyne.CanvasObject { + mk := func(s string) *widget.Label { + return widget.NewLabelWithStyle(s, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + } + return container.New( + xlayout.NewHPortion(columnSizes), + mk("Name"), + mk("Address"), + mk("SRAM offset"), + mk("Length"), + mk("Type"), + mk("Action"), + ) +} + +func (w *Widget) CreateRenderer() fyne.WidgetRenderer { + w.search = widget.NewEntry() + w.search.SetPlaceHolder("Filter by name...") + w.search.OnChanged = w.applyFilter + + w.countLbl = widget.NewLabel(w.countText()) + + refresh := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + w.loadSymbols() + w.applyFilter(w.search.Text) + }) + + w.list = widget.NewList( + func() int { return len(w.filtered) }, + func() fyne.CanvasObject { return newSymbolRow(w.openSymbol) }, + func(i widget.ListItemID, o fyne.CanvasObject) { + if i < 0 || i >= len(w.filtered) { + return + } + o.(*symbolRow).set(w.filtered[i]) + }, + ) + + top := container.NewBorder(nil, nil, nil, container.NewHBox(w.countLbl, refresh), w.search) + + return widget.NewSimpleRenderer(container.NewBorder( + container.NewVBox(top, w.header()), + nil, + nil, + nil, + w.list, + )) +} + +// typeString renders the Trionic type bitfield as readable flags. +func typeString(t uint8) string { + var parts []string + if t&symbol.SIGNED != 0 { + parts = append(parts, "signed") + } + if t&symbol.KONST != 0 { + parts = append(parts, "const") + } + if t&symbol.CHAR != 0 { + parts = append(parts, "char") + } + if t&symbol.LONG != 0 { + parts = append(parts, "long") + } + if t&symbol.BITFIELD != 0 { + parts = append(parts, "bitfield") + } + if t&symbol.STRUCT != 0 { + parts = append(parts, "struct") + } + if len(parts) == 0 { + return fmt.Sprintf("0x%02X", t) + } + return strings.Join(parts, "|") +} + +var _ fyne.Widget = (*symbolRow)(nil) + +type symbolRow struct { + widget.BaseWidget + + name *widget.Label + addr *widget.Label + sram *widget.Label + length *widget.Label + typ *widget.Label + open *widget.Button + + sym *symbol.Symbol + onOpen func(*symbol.Symbol) +} + +func newSymbolRow(onOpen func(*symbol.Symbol)) *symbolRow { + r := &symbolRow{ + name: widget.NewLabel(""), + addr: widget.NewLabel(""), + sram: widget.NewLabel(""), + length: widget.NewLabel(""), + typ: widget.NewLabel(""), + onOpen: onOpen, + } + r.name.Truncation = fyne.TextTruncateEllipsis + r.name.Selectable = true + r.typ.Truncation = fyne.TextTruncateEllipsis + r.open = widget.NewButtonWithIcon("Open", theme.GridIcon(), func() { + if r.sym != nil && r.onOpen != nil { + r.onOpen(r.sym) + } + }) + r.ExtendBaseWidget(r) + return r +} + +func (r *symbolRow) set(sym *symbol.Symbol) { + r.sym = sym + r.name.SetText(sym.Name) + r.addr.SetText(fmt.Sprintf("$%06X", sym.Address)) + r.sram.SetText(fmt.Sprintf("$%06X", sym.SramOffset)) + r.length.SetText(strconv.Itoa(int(sym.Length))) + r.typ.SetText(typeString(sym.Type)) +} + +func (r *symbolRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.New( + xlayout.NewHPortion(columnSizes), + r.name, + r.addr, + r.sram, + r.length, + r.typ, + r.open, + )) +} diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index b84ce5c4..c4a4d21e 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -26,6 +26,7 @@ import ( "github.com/roffe/txlogger/pkg/widgets/mapviewer" "github.com/roffe/txlogger/pkg/widgets/multiwindow" "github.com/roffe/txlogger/pkg/widgets/progressmodal" + "github.com/roffe/txlogger/pkg/widgets/symbolbrowser" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmmod" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmstatus" "github.com/roffe/txlogger/pkg/widgets/trionic7/t7esp" @@ -235,6 +236,20 @@ func (mw *MainWindow) setupMenu() { mw.wm.Add(inner) }), openItem, + fyne.NewMenuItemWithIcon("Symbol Browser", theme.ListIcon(), func() { + if w := mw.wm.HasWindow("Symbol Browser"); w != nil { + mw.wm.Raise(w) + return + } + getECU := func() symbol.ECUType { + return symbol.ECUTypeFromString(mw.selects.ecuSelect.Selected) + } + browser := symbolbrowser.New(getFW, getECU, mw.openMap, mw.Error) + inner := multiwindow.NewInnerWindow("Symbol Browser", browser) + inner.Icon = theme.ListIcon() + mw.wm.Add(inner) + inner.Resize(fyne.Size{Width: 760, Height: 520}) + }), fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), func() { mw.openSettings() }), From 4c23dd0a98b7813ba972d7f310be9af5b3837413 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:56:24 +0200 Subject: [PATCH 56/93] add liveplotter --- pkg/widgets/liveplotter/liveplotter.go | 580 +++++++++++++++++++ pkg/widgets/liveplotter/liveplotter_mouse.go | 73 +++ pkg/widgets/liveplotter/liveplotter_test.go | 103 ++++ 3 files changed, 756 insertions(+) create mode 100644 pkg/widgets/liveplotter/liveplotter.go create mode 100644 pkg/widgets/liveplotter/liveplotter_mouse.go create mode 100644 pkg/widgets/liveplotter/liveplotter_test.go diff --git a/pkg/widgets/liveplotter/liveplotter.go b/pkg/widgets/liveplotter/liveplotter.go new file mode 100644 index 00000000..602b3e63 --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter.go @@ -0,0 +1,580 @@ +// Package liveplotter provides a scope-style widget that plots live EBUS values +// over a sliding time window (default 120s). It auto-follows the newest data and +// lets you scrub back into the retained history of the current pull. +// +// Unlike the logplayer's plotter, which renders an immutable log, the live +// plotter appends one sample per ECU frame: it subscribes to each selected +// symbol (storing the latest value) and to ebus.TOPIC_FRAME, which fires once per +// completed frame carrying that frame's real timestamp. On each frame it snapshots +// every symbol's latest value with the shared timestamp, so all series stay time +// aligned. It reuses the plotter package's primitives (TimeSeries + PlotImage +// rasterizer and the TappableText legend) but owns its own data model and layout. +package liveplotter + +import ( + "fmt" + "image" + "image/color" + "sort" + "sync" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/ebus" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +var ( + _ fyne.Widget = (*Widget)(nil) + _ fyne.Draggable = (*Widget)(nil) + _ fyne.Scrollable = (*Widget)(nil) +) + +const defaultWindow = 120 * time.Second + +// Config configures a live plotter. +type Config struct { + // Order is the set of symbol names to plot, in legend order. + Order []string + // Window is the sliding time window length. Defaults to 120s when zero. + Window time.Duration +} + +type Widget struct { + widget.BaseWidget + + order []string + ts []*plotter.TimeSeries + + // mu guards the raw sample store and latest values, which are written from + // the logging goroutine (subscriptions) and read on the UI goroutine (refresh). + mu sync.Mutex + latest map[string]float64 + times []int64 // frame timestamps, Unix millis, ascending + values map[string][]float64 // per-series samples, parallel to times + view map[string][]float64 // reusable per-render windowed copy (UI goroutine) + + windowMillis int64 + + // UI-goroutine-only view state. + follow bool + anchorMillis int64 // right edge timestamp when paused + lastNewest int64 + viewCount int + hilightLine int + + // mouse cursor state (UI goroutine only). When mouseInPlot the vertical + // cursor and the legend track cursorX (plot-relative pixels); otherwise the + // cursor sits at the right edge and the legend shows the newest/frozen value. + mouseInPlot bool + cursorX float32 + + // canvas objects + canvasImage *canvas.Image + cursor *canvas.Line + overlayText *canvas.Text + plotObj fyne.CanvasObject + zoom *widget.Slider + legend *fyne.Container + legendTexts []*plotter.TappableText + legendVals []float64 + windowSel *widget.Select + followBtn *widget.Button + split *container.Split + root *fyne.Container + + plotResolution fyne.Size + size fyne.Size + + imgBuffers [2]*image.RGBA + imgIndex int + + refreshPending atomic.Bool + // paused mirrors !follow for the logging goroutine (which can't touch the + // UI-only follow field): while set, onFrame keeps appending but skips + // compaction so the frozen history is never trimmed until playback resumes. + paused atomic.Bool + + cancels []func() + closeOnce sync.Once + closed atomic.Bool +} + +func New(cfg *Config) *Widget { + window := cfg.Window + if window <= 0 { + window = defaultWindow + } + + p := &Widget{ + order: append([]string(nil), cfg.Order...), + latest: make(map[string]float64, len(cfg.Order)), + values: make(map[string][]float64, len(cfg.Order)), + view: make(map[string][]float64, len(cfg.Order)), + windowMillis: window.Milliseconds(), + follow: true, + hilightLine: -1, + legend: container.NewVBox(), + zoom: widget.NewSlider(1, 100), + canvasImage: canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 400, 200))), + cursor: canvas.NewLine(color.White), + } + p.ExtendBaseWidget(p) + + p.canvasImage.FillMode = canvas.ImageFillContain + p.canvasImage.ScaleMode = canvas.ImageScaleFastest + + p.zoom.Orientation = widget.Vertical + p.zoom.Value = 100 // show the whole window by default + p.zoom.OnChanged = func(float64) { p.scheduleRefresh() } + + p.overlayText = canvas.NewText("", color.White) + p.overlayText.TextSize = 25 + + p.ts = make([]*plotter.TimeSeries, len(p.order)) + p.legendVals = make([]float64, len(p.order)) + for n, name := range p.order { + p.values[name] = make([]float64, 0, 4096) + p.ts[n] = plotter.NewSeries(name) + p.legendTexts = append(p.legendTexts, p.newLegendText(n, name)) + p.legend.Add(p.legendTexts[n]) + } + + p.plotObj = p.canvasImage + p.subscribe() + + return p +} + +func (p *Widget) newLegendText(n int, name string) *plotter.TappableText { + onTapped := func(enabled bool) { + p.ts[n].Enabled = enabled + p.scheduleRefresh() + } + onColorUpdate := func(col color.Color) { + r, g, b, a := col.RGBA() + p.ts[n].Color = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + p.scheduleRefresh() + } + onHover := func(hover bool) { + if hover { + p.overlayText.Text = name + p.overlayText.Color = p.ts[n].Color + p.hilightLine = n + } else { + p.overlayText.Text = "" + p.hilightLine = -1 + } + p.scheduleRefresh() + } + return plotter.NewTappableText(name, p.ts[n].Color, onTapped, onColorUpdate, onHover) +} + +// subscribe wires the per-symbol latest-value updates and the frame tick. The +// callbacks run on the logging goroutine and must stay fast, so they only touch +// the guarded store; the redraw is coalesced onto the UI goroutine. +func (p *Widget) subscribe() { + for _, name := range p.order { + name := name + p.cancels = append(p.cancels, ebus.CONTROLLER.SubscribeFunc(name, func(v float64) { + p.mu.Lock() + p.latest[name] = v + p.mu.Unlock() + })) + } + p.cancels = append(p.cancels, ebus.CONTROLLER.SubscribeFunc(ebus.TOPIC_FRAME, p.onFrame)) +} + +func (p *Widget) onFrame(tMillis float64) { + if p.closed.Load() { + return + } + p.ingest(int64(tMillis), p.paused.Load()) + p.scheduleRefresh() +} + +// ingest appends one frame (the latest value of every series at timestamp t) and +// compacts unless paused. Split out from onFrame so the windowing/retention can +// be tested without a running UI. +func (p *Widget) ingest(t int64, paused bool) { + p.mu.Lock() + defer p.mu.Unlock() + // Guard against out-of-order/duplicate timestamps so times stays ascending. + if n := len(p.times); n > 0 && t <= p.times[n-1] { + t = p.times[n-1] + 1 + } + p.times = append(p.times, t) + for _, name := range p.order { + p.values[name] = append(p.values[name], p.latest[name]) + } + if !paused { + p.compactLocked() + } +} + +// viewRange returns the [start,end) sample indices to display for the visible +// span. In follow mode the right edge tracks the newest sample; when paused it +// is frozen at anchor, clamped only to the data we still hold (never pushed +// forward). The returned rightT is the clamped right-edge timestamp. +func viewRange(times []int64, follow bool, anchor, span int64) (start, end int, rightT int64) { + n := len(times) + newest, oldest := times[n-1], times[0] + if follow { + rightT = newest + } else { + rightT = anchor + if rightT > newest { + rightT = newest + } + if rightT < oldest { + rightT = oldest + } + } + leftT := rightT - span + if leftT < oldest { + leftT = oldest + } + start = sort.Search(n, func(i int) bool { return times[i] >= leftT }) + end = sort.Search(n, func(i int) bool { return times[i] > rightT }) + if end <= start { + end = start + 1 + } + return start, end, rightT +} + +// compactLocked drops samples older than the window once the retained span grows +// past 2x the window, so memory stays bounded and the cost is amortized O(1) per +// frame. Caller holds p.mu. +func (p *Widget) compactLocked() { + n := len(p.times) + if n < 2 || p.times[n-1]-p.times[0] <= 2*p.windowMillis { + return + } + cutoff := p.times[n-1] - p.windowMillis + cut := sort.Search(n, func(i int) bool { return p.times[i] >= cutoff }) + if cut <= 0 { + return + } + p.times = append(p.times[:0], p.times[cut:]...) + for _, name := range p.order { + s := p.values[name] + p.values[name] = append(s[:0], s[cut:]...) + } +} + +// scheduleRefresh coalesces redraws onto the UI goroutine: while one is pending, +// further calls only return, so the draw rate is bounded by what the UI can keep +// up with rather than the frame rate (mirrors plotter.Plotter.Seek). +func (p *Widget) scheduleRefresh() { + if p.closed.Load() { + return + } + if !p.refreshPending.CompareAndSwap(false, true) { + return + } + fyne.Do(func() { + p.refreshPending.Store(false) + p.refresh() + }) +} + +// spanMillis is the visible time span derived from the zoom slider, clamped to +// the window. Zoom at max shows the whole window; scrolling in shrinks it. +func (p *Widget) spanMillis() int64 { + span := int64(float64(p.windowMillis) * p.zoom.Value / p.zoom.Max) + if span > p.windowMillis { + span = p.windowMillis + } + if span < 1000 { + span = 1000 + } + return span +} + +// refresh recomputes the visible window, copies it out under the lock, and +// redraws. Runs on the UI goroutine. +func (p *Widget) refresh() { + span := p.spanMillis() + + p.mu.Lock() + n := len(p.times) + if n < 2 { + p.mu.Unlock() + p.viewCount = 0 + p.drawImage() + p.canvasImage.Refresh() + return + } + p.lastNewest = p.times[n-1] + + startIdx, endIdx, rightT := viewRange(p.times, p.follow, p.anchorMillis, span) + if !p.follow { + p.anchorMillis = rightT + } + for _, name := range p.order { + src := p.values[name][startIdx:endIdx] + p.view[name] = append(p.view[name][:0], src...) + } + p.mu.Unlock() + + p.viewCount = endIdx - startIdx + + // Auto-range unknown signals from the visible window so they stay on screen. + for _, ts := range p.ts { + if !ts.Auto || !ts.Enabled { + continue + } + data := p.view[ts.Name] + if len(data) == 0 { + continue + } + mn, mx := data[0], data[0] + for _, v := range data { + if v < mn { + mn = v + } + if v > mx { + mx = v + } + } + if mn == mx { + mn -= 1 + mx += 1 + } else { + pad := (mx - mn) * 0.05 + mn -= pad + mx += pad + } + ts.SetRange(mn, mx) + } + + p.computeLegendVals() + p.drawImage() + p.updateLegendValues() + p.layoutCursor() + p.cursor.Refresh() + p.canvasImage.Refresh() +} + +// computeLegendVals fills legendVals from the visible window: the sample under +// the mouse cursor when hovering, otherwise the right edge (newest when live, +// frozen value when paused). +func (p *Widget) computeLegendVals() { + if p.viewCount <= 0 { + return + } + idx := p.viewCount - 1 + if p.mouseInPlot { + if plotW := p.plotObj.Size().Width; plotW > 0 { + frac := float64(p.cursorX) / float64(plotW) + idx = int(frac*float64(p.viewCount-1) + 0.5) + if idx < 0 { + idx = 0 + } + if idx > p.viewCount-1 { + idx = p.viewCount - 1 + } + } + } + for i, name := range p.order { + if v := p.view[name]; idx < len(v) { + p.legendVals[i] = v[idx] + } + } +} + +func (p *Widget) updateLegendValues() { + for i := range p.order { + newValue := fmt.Sprintf("%.4g", p.legendVals[i]) + obj := p.legendTexts[i] + if obj.Value() == newValue { + continue + } + obj.SetValue(newValue) + } +} + +// drawImage rasterizes the visible window into a reused buffer via the plotter's +// TimeSeries.PlotImage, exactly like the plotter image backend. +func (p *Widget) drawImage() { + w, h := int(p.plotResolution.Width), int(p.plotResolution.Height) + if w <= 0 || h <= 0 { + return + } + p.imgIndex ^= 1 + img := p.imgBuffers[p.imgIndex] + if img == nil || img.Rect.Dx() != w || img.Rect.Dy() != h { + img = image.NewRGBA(image.Rect(0, 0, w, h)) + p.imgBuffers[p.imgIndex] = img + } else { + clear(img.Pix) + } + + if p.viewCount >= 2 { + for n := range p.ts { + if !p.ts[n].Enabled || p.hilightLine == n { + continue + } + p.ts[n].PlotImage(img, p.view, 0, p.viewCount, 1) + } + if p.hilightLine >= 0 && p.ts[p.hilightLine].Enabled { + p.ts[p.hilightLine].PlotImage(img, p.view, 0, p.viewCount, 4) + } + } + + p.canvasImage.Image = img +} + +// layoutCursor positions the vertical cursor: under the mouse while hovering the +// plot, otherwise at the right edge as a "now" line. +func (p *Widget) layoutCursor() { + plotSize := p.plotObj.Size() + zw := p.zoom.Size().Width + var x float32 + if p.mouseInPlot { + x = zw + p.cursorX + } else { + x = zw + plotSize.Width - 1 + } + p.cursor.Position1 = fyne.NewPos(x, 0) + p.cursor.Position2 = fyne.NewPos(x+1, plotSize.Height) +} + +func (p *Widget) setFollow(follow bool) { + p.follow = follow + p.paused.Store(!follow) + // The button is labelled with the action it performs, not the current state: + // while live it offers Pause; while paused it offers Go Live. + if follow { + p.followBtn.SetIcon(theme.MediaPauseIcon()) + p.followBtn.SetText("Pause") + } else { + p.anchorMillis = p.lastNewest + p.followBtn.SetIcon(theme.MediaPlayIcon()) + p.followBtn.SetText("Go Live") + } + p.scheduleRefresh() +} + +func (p *Widget) Close() { + p.closeOnce.Do(func() { + p.closed.Store(true) + for _, cancel := range p.cancels { + cancel() + } + p.cancels = nil + }) +} + +func (p *Widget) CreateRenderer() fyne.WidgetRenderer { + // Initial state is live (following), so the button offers the Pause action. + p.followBtn = widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { + p.setFollow(!p.follow) + }) + + p.windowSel = widget.NewSelect([]string{"30s", "60s", "120s", "300s"}, func(s string) { + var d time.Duration + switch s { + case "30s": + d = 30 * time.Second + case "60s": + d = 60 * time.Second + case "120s": + d = 120 * time.Second + case "300s": + d = 300 * time.Second + } + if d > 0 { + p.mu.Lock() + p.windowMillis = d.Milliseconds() + p.mu.Unlock() + p.scheduleRefresh() + } + }) + p.windowSel.Selected = fmt.Sprintf("%ds", p.windowMillis/1000) + + toggleVisibleBtn := widget.NewButton("Toggle visible", func() { + for _, ts := range p.legendTexts { + ts.Tapped(&fyne.PointEvent{}) + } + }) + + leading := container.NewBorder( + nil, nil, p.zoom, nil, + container.New(&livePlotLayout{p: p}, p.plotObj), + ) + p.split = container.NewHSplit( + leading, + container.NewBorder( + nil, toggleVisibleBtn, nil, nil, + container.NewVScroll(p.legend), + ), + ) + p.split.Offset = 0.90 + + controls := container.NewHBox( + p.followBtn, + widget.NewLabel("Window"), + p.windowSel, + ) + + p.root = container.NewBorder(nil, controls, nil, nil, p.split) + + return &liveRenderer{p: p} +} + +type liveRenderer struct { + p *Widget + objects []fyne.CanvasObject +} + +func (r *liveRenderer) MinSize() fyne.Size { return r.p.root.MinSize() } + +func (r *liveRenderer) Layout(size fyne.Size) { + if r.p.size == size { + return + } + r.p.size = size + r.p.root.Resize(size) +} + +func (r *liveRenderer) Refresh() {} + +func (r *liveRenderer) Destroy() { r.p.Close() } + +func (r *liveRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + r.objects = []fyne.CanvasObject{r.p.root, r.p.overlayText, r.p.cursor} + } + return r.objects +} + +// livePlotLayout sizes the plot canvas and recomputes the render resolution and +// cursor whenever the plot area changes (mirrors plotter.plotLayout). +type livePlotLayout struct { + p *Widget + oldSize fyne.Size +} + +func (t *livePlotLayout) Layout(_ []fyne.CanvasObject, plotSize fyne.Size) { + if t.oldSize == plotSize { + return + } + t.oldSize = plotSize + + t.p.overlayText.Move(fyne.NewPos(t.p.zoom.Size().Width, 20)) + t.p.plotObj.Resize(plotSize) + t.p.plotResolution = fyne.NewSize(plotSize.Width, plotSize.Height) + t.p.refresh() + t.p.layoutCursor() + t.p.cursor.Refresh() +} + +func (t *livePlotLayout) MinSize([]fyne.CanvasObject) fyne.Size { + return fyne.NewSize(400, 100) +} diff --git a/pkg/widgets/liveplotter/liveplotter_mouse.go b/pkg/widgets/liveplotter/liveplotter_mouse.go new file mode 100644 index 00000000..4b49824f --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter_mouse.go @@ -0,0 +1,73 @@ +package liveplotter + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" +) + +var _ desktop.Hoverable = (*Widget)(nil) + +// Scrolled zooms the visible time span: scrolling up zooms in (shorter span), +// down zooms out toward the full window. +func (p *Widget) Scrolled(event *fyne.ScrollEvent) { + if event.Scrolled.DY > 0 { + p.zoom.SetValue(p.zoom.Value - 5) + } else { + p.zoom.SetValue(p.zoom.Value + 5) + } +} + +// Dragged pans the view back in time, pausing the live follow. Dragging right +// reveals older data; the anchored right edge is clamped to the retained window +// in refresh. +func (p *Widget) Dragged(event *fyne.DragEvent) { + plotW := p.plotObj.Size().Width + if plotW <= 0 { + return + } + if p.follow { + p.setFollow(false) + } + deltaMillis := int64(float64(event.Dragged.DX) / float64(plotW) * float64(p.spanMillis())) + p.anchorMillis -= deltaMillis + p.scheduleRefresh() +} + +func (p *Widget) DragEnd() {} + +// MouseIn / MouseMoved track the pointer over the plot so the cursor line and +// legend follow it. MouseOut returns the cursor to the right edge and the legend +// to the newest/frozen value. +func (p *Widget) MouseIn(event *desktop.MouseEvent) { p.onMouse(event.Position) } + +func (p *Widget) MouseMoved(event *desktop.MouseEvent) { p.onMouse(event.Position) } + +func (p *Widget) MouseOut() { + p.mouseInPlot = false + p.refreshCursorAndLegend() +} + +// onMouse maps a pointer position (widget-local) onto the plot and updates the +// cursor + legend. Runs on the UI goroutine. +func (p *Widget) onMouse(pos fyne.Position) { + plotW := p.plotObj.Size().Width + relX := pos.X - p.zoom.Size().Width + if relX < 0 { + relX = 0 + } + if relX > plotW { + relX = plotW + } + p.cursorX = relX + p.mouseInPlot = true + p.refreshCursorAndLegend() +} + +// refreshCursorAndLegend repositions the cursor and updates the legend readout +// without redrawing the trace (the samples are unchanged on a mouse move). +func (p *Widget) refreshCursorAndLegend() { + p.computeLegendVals() + p.updateLegendValues() + p.layoutCursor() + p.cursor.Refresh() +} diff --git a/pkg/widgets/liveplotter/liveplotter_test.go b/pkg/widgets/liveplotter/liveplotter_test.go new file mode 100644 index 00000000..422d61af --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter_test.go @@ -0,0 +1,103 @@ +package liveplotter + +import "testing" + +// newBare builds a Widget with only the fields the data path needs, so the +// windowing/retention logic can be exercised without a Fyne app. +func newBare(order []string, windowMillis int64) *Widget { + p := &Widget{ + order: order, + windowMillis: windowMillis, + follow: true, + latest: map[string]float64{}, + values: map[string][]float64{}, + } + for _, n := range order { + p.values[n] = nil + } + return p +} + +// span at full zoom equals the window. +func fullSpan(p *Widget) int64 { return p.windowMillis } + +func TestFollowWindowSlides(t *testing.T) { + const window = 120_000 // 120s + p := newBare([]string{"a"}, window) + + // 300s of frames at 10Hz. + const hz = 10 + const dur = 300_000 + for ms := int64(0); ms <= dur; ms += 1000 / hz { + p.latest["a"] = float64(ms) + p.ingest(ms, false /*not paused*/) + } + + newest := p.times[len(p.times)-1] + oldest := p.times[0] + + // Retention: compaction keeps at most ~2x the window, and never less than + // the window once full. + span := newest - oldest + if span > 2*window+1000 { + t.Fatalf("retained span %dms exceeds 2x window", span) + } + if span < window-1000 { + t.Fatalf("retained span %dms dropped below window", span) + } + + // Visible window in follow mode: right edge at newest, ~window wide, and the + // oldest visible sample is well after the very first frame (old data phased + // out). + start, end, rightT := viewRange(p.times, true, 0, fullSpan(p)) + if rightT != newest { + t.Fatalf("follow right edge = %d, want newest %d", rightT, newest) + } + if start == 0 { + t.Fatalf("visible window still starts at index 0; old entries never phased out") + } + visibleSpan := p.times[end-1] - p.times[start] + if visibleSpan > window+1000 || visibleSpan < window-2000 { + t.Fatalf("visible span %dms, want ~%dms", visibleSpan, window) + } +} + +func TestPausedFreezesAndRetains(t *testing.T) { + const window = 120_000 + p := newBare([]string{"a"}, window) + + for ms := int64(0); ms <= 200_000; ms += 100 { + p.latest["a"] = float64(ms) + p.ingest(ms, false) + } + + // Pause: anchor at the current newest, then keep ingesting without compaction. + p.follow = false + anchor := p.times[len(p.times)-1] + preLen := len(p.times) + for ms := int64(200_100); ms <= 600_000; ms += 100 { + p.latest["a"] = float64(ms) + p.ingest(ms, true /*paused*/) + } + + // Frozen view: right edge stays at the anchor regardless of new data. + start, end, rightT := viewRange(p.times, false, anchor, fullSpan(p)) + if rightT != anchor { + t.Fatalf("paused right edge = %d, want anchor %d", rightT, anchor) + } + if got := p.times[end-1]; got > anchor { + t.Fatalf("frozen view includes sample at %d past anchor %d", got, anchor) + } + visibleSpan := p.times[end-1] - p.times[start] + if visibleSpan < window-2000 { + t.Fatalf("frozen visible span %dms collapsed below window", visibleSpan) + } + + // Retention halted: nothing was trimmed while paused. + if len(p.times) <= preLen { + t.Fatalf("paused buffer did not grow: pre=%d post=%d", preLen, len(p.times)) + } + if p.times[0] != 0 { + t.Fatalf("paused buffer trimmed old data: oldest=%d, want 0", p.times[0]) + } +} From 02c72054ecd96c79de65d789aa6b5e059cda45d5 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:56:32 +0200 Subject: [PATCH 57/93] fix bug in matrixbuilder --- pkg/widgets/matrixbuilder/matrixbuilder.go | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go index 92827c72..f674c338 100644 --- a/pkg/widgets/matrixbuilder/matrixbuilder.go +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -384,8 +384,8 @@ func (mb *MatrixBuilder) buildUI() { buildBtn, ) - mb.xBox = container.NewVBox() - mb.yBox = container.NewHBox() + mb.xBox = container.NewHBox() + mb.yBox = container.NewVBox() mb.rebuildAxisEntries() mb.controls = container.NewVBox( @@ -421,21 +421,21 @@ func (mb *MatrixBuilder) buildUI() { mb.display = container.NewStack(mb.placeholder()) - // The Y scale runs along the vertical axis of the map; its editor sits as a - // horizontal strip beneath the display. - yPanel := container.NewBorder(nil, nil, - widget.NewLabelWithStyle("Y axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + // The X scale runs along the horizontal axis of the map; its editor sits as a + // horizontal strip above the display. + xPanel := container.NewBorder(nil, nil, + widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), nil, - container.NewHScroll(mb.yBox), + container.NewHScroll(mb.xBox), ) mainSplit := container.NewHSplit( container.NewBorder( - yPanel, + xPanel, bottomBar, container.NewVBox( - widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - mb.xBox, + widget.NewLabelWithStyle("Y axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.yBox, ), nil, mb.display, @@ -917,9 +917,10 @@ func (mb *MatrixBuilder) rebuildAxisEntries() { mb.yEntries = make([]*widget.Entry, mb.rows) mb.yBox.Objects = mb.yBox.Objects[:0] - for i := 0; i < mb.rows; i++ { + // Y runs highest-at-top to match the mapviewer, so add breakpoints in + // descending index order (the axis itself stays sorted ascending). + for i := mb.rows - 1; i >= 0; i-- { mb.yBox.Add(mb.makeAxisEntry(false, i)) - // mb.yBox.Add(xlayout.NewSpacer()) } mb.yBox.Refresh() } @@ -952,14 +953,14 @@ func (mb *MatrixBuilder) makeAxisEntry(isX bool, idx int) fyne.CanvasObject { } if isX { mb.xEntries[idx] = e - // X breakpoints stack vertically in the side panel: label beside entry. - return container.NewBorder(nil, nil, widget.NewLabel(prefix+strconv.Itoa(idx)), nil, e) + // X breakpoints run horizontally along the top: label above a + // fixed-width entry so the strip stays compact. + label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignLeading, fyne.TextStyle{}) + return container.NewVBox(label, layout.NewFixedWidth(64, e)) } mb.yEntries[idx] = e - // Y breakpoints run horizontally along the bottom: label above a - // fixed-width entry so the strip stays compact. - label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignLeading, fyne.TextStyle{}) - return container.NewVBox(label, layout.NewFixedWidth(64, e)) + // Y breakpoints stack vertically in the side panel: label beside entry. + return container.NewBorder(nil, nil, widget.NewLabel(prefix+strconv.Itoa(idx)), nil, e) } func (mb *MatrixBuilder) setCols(n int) { From 9a97b3c9c164183b8cfb973f6babce6eff7a500b Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:56:54 +0200 Subject: [PATCH 58/93] split out to separate minmax function in plotter --- pkg/widgets/plotter/plotter.go | 92 ++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index bc15e00c..2e830255 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -398,6 +398,37 @@ type TimeSeries struct { valueRange float64 Color color.RGBA Enabled bool + // Auto reports that the series has no known display range and should be + // auto-ranged from its data by the caller (used by the live plotter). + Auto bool +} + +// defaultRange returns the fixed display range for the well-known symbols. ok is +// false for symbols without a preset range; the caller derives one from the data. +func defaultRange(name string) (min, max float64, ok bool) { + switch name { + case "Out.X_AccPedal", "Out.X_AccPos": + return 0, 100, true + case "ActualIn.T_Engine", "ActualIn.T_AirInlet": + return -20, 120, true + case "m_Request", "MAF.m_AirInlet", "AirMassMast.m_Request", "MAF.m_AirFromp_AirInlet": + return 0, 2200, true + case "ActualIn.p_AirInlet", "In.p_AirInlet", "ActualIn.p_AirBefThrottle", "In.p_AirBefThrottle": + return -1.0, 3.0, true + case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2": + return 0.5, 1.5, true + case "IgnProt.fi_Offset": + return -30, 10, true + case "Lambda.LambdaInt": + return -25, 25, true + case "ECMStat.p_Diff": + return -1, 2, true + case "Lambda.External": + return 0.5, 1.5, true + case "P_medel", "Max_tryck", "Regl_tryck": + return -1, 3, true + } + return 0, 0, false } func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { @@ -413,38 +444,9 @@ func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { return ts } - switch name { - case "Out.X_AccPedal", "Out.X_AccPos": - ts.Min = 0 - ts.Max = 100 - case "ActualIn.T_Engine", "ActualIn.T_AirInlet": - ts.Min = -20 - ts.Max = 120 - case "m_Request", "MAF.m_AirInlet", "AirMassMast.m_Request", "MAF.m_AirFromp_AirInlet": - ts.Min = 0 - ts.Max = 2200 - case "ActualIn.p_AirInlet", "In.p_AirInlet", "ActualIn.p_AirBefThrottle", "In.p_AirBefThrottle": - ts.Min = -1.0 - ts.Max = 3.0 - case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2": - ts.Min = 0.5 - ts.Max = 1.5 - case "IgnProt.fi_Offset": - ts.Min = -30 - ts.Max = 10 - case "Lambda.LambdaInt": - ts.Min = -25 - ts.Max = 25 - case "ECMStat.p_Diff": - ts.Min = -1 - ts.Max = 2 - case "Lambda.External": - ts.Min = 0.5 - ts.Max = 1.5 - case "P_medel", "Max_tryck", "Regl_tryck": - ts.Min = -1 - ts.Max = 3 - default: + if min, max, known := defaultRange(name); known { + ts.Min, ts.Max = min, max + } else { ts.Min, ts.Max = findMinMaxFloat64(data) } @@ -453,6 +455,32 @@ func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { return ts } +// NewSeries builds a series with no data yet, for live plotting. Well-known +// symbols get their fixed display range; the rest are flagged Auto so the +// caller can range them from the live data via SetRange. +func NewSeries(name string) *TimeSeries { + ts := &TimeSeries{ + Name: name, + Color: colors.GetColor(name), + Enabled: true, + } + if min, max, known := defaultRange(name); known { + ts.SetRange(min, max) + } else { + ts.Auto = true + ts.SetRange(0, 1) + } + return ts +} + +// SetRange updates the display range used by PlotImage. valueRange is kept in +// sync so callers outside this package can re-range a series (e.g. auto-ranging +// a live signal each refresh). +func (ts *TimeSeries) SetRange(min, max float64) { + ts.Min, ts.Max = min, max + ts.valueRange = max - min +} + func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, start, numPoints, thickness int) { dl := len(values[ts.Name]) - 1 startN, endN := min(max(start, 0), dl), min(start+numPoints, dl) From 2c6b0b108cd54e4c2e60c73f4db6013762db49c8 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:57:06 +0200 Subject: [PATCH 59/93] get and set value of plotter tappable text --- pkg/widgets/plotter/text.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/widgets/plotter/text.go b/pkg/widgets/plotter/text.go index eec1207e..3029587b 100644 --- a/pkg/widgets/plotter/text.go +++ b/pkg/widgets/plotter/text.go @@ -74,6 +74,17 @@ func (tt *TappableText) Refresh() { tt.text.Refresh() } +// Value returns the currently displayed value text. +func (tt *TappableText) Value() string { + return tt.value.Text +} + +// SetValue updates the displayed value text and refreshes it. +func (tt *TappableText) SetValue(s string) { + tt.value.Text = s + tt.value.Refresh() +} + func (tt *TappableText) MouseIn(e *desktop.MouseEvent) { tt.onHover(true) } From 5b26a12c28a4b35a72ef53dfef7433d51ee2437b Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:58:02 +0200 Subject: [PATCH 60/93] use new end of frame marker --- pkg/datalogger/baselogger.go | 7 +++++-- pkg/datalogger/t5logger.go | 2 +- pkg/datalogger/t7logger.go | 2 +- pkg/datalogger/t8logger.go | 2 +- pkg/datalogger/txbridgelogger_t5.go | 2 +- pkg/datalogger/txbridgelogger_t7.go | 2 +- pkg/datalogger/txbridgelogger_t8.go | 2 +- 7 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/datalogger/baselogger.go b/pkg/datalogger/baselogger.go index bce18dc1..cabc70e7 100644 --- a/pkg/datalogger/baselogger.go +++ b/pkg/datalogger/baselogger.go @@ -9,6 +9,7 @@ import ( "time" "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/wbl" "github.com/roffe/txlogger/relayserver" ) @@ -90,13 +91,15 @@ func (bl *BaseLogger) GetRAM(address uint32, length uint32) ([]byte, error) { return req.Data, req.Wait() } -// update capture counters -func (bl *BaseLogger) onCapture() { +// update capture counters and emit the per-frame tick so live consumers can +// sample every symbol with this frame's real timestamp. +func (bl *BaseLogger) onCapture(t time.Time) { bl.captureCount++ bl.capturePerSecond++ if bl.captureCount%15 == 0 { bl.CaptureCounter(bl.captureCount) } + ebus.PublishFrame(t) } func (bl *BaseLogger) onError() { diff --git a/pkg/datalogger/t5logger.go b/pkg/datalogger/t5logger.go index 4a251882..d0481642 100644 --- a/pkg/datalogger/t5logger.go +++ b/pkg/datalogger/t5logger.go @@ -138,7 +138,7 @@ func (c *T5Client) Start() error { c.OnMessage("failed to write log: " + err.Error()) return } - c.onCapture() + c.onCapture(ts) } } }() diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index 21b93f0d..ab536d2b 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -345,7 +345,7 @@ func (c *T7Client) Start() error { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) } } }() diff --git a/pkg/datalogger/t8logger.go b/pkg/datalogger/t8logger.go index 4b6ce00f..266d01c6 100644 --- a/pkg/datalogger/t8logger.go +++ b/pkg/datalogger/t8logger.go @@ -215,7 +215,7 @@ func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, c.OnMessage("failed to write log: " + err.Error()) } testerPresent() - c.onCapture() + c.onCapture(timeStamp) } } } diff --git a/pkg/datalogger/txbridgelogger_t5.go b/pkg/datalogger/txbridgelogger_t5.go index 36fe53d6..eb936084 100644 --- a/pkg/datalogger/txbridgelogger_t5.go +++ b/pkg/datalogger/txbridgelogger_t5.go @@ -194,7 +194,7 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { c.OnMessage("failed to write log: " + err.Error()) return } - c.onCapture() + c.onCapture(timeStamp) } } }() diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index c8f88f63..cbf52f84 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -258,7 +258,7 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) } } }() diff --git a/pkg/datalogger/txbridgelogger_t8.go b/pkg/datalogger/txbridgelogger_t8.go index b4872bd2..f705be11 100644 --- a/pkg/datalogger/txbridgelogger_t8.go +++ b/pkg/datalogger/txbridgelogger_t8.go @@ -142,7 +142,7 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) testerPresent() } } From 3b7cf9d571bbc8f7f4f735e9329f4d2418d55b2e Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:58:25 +0200 Subject: [PATCH 61/93] add logexport to be able to cut out sections of a log --- pkg/datalogger/log_export.go | 83 +++++++++++++++ pkg/logfile/baselog.go | 16 +++ pkg/logfile/logfile.go | 1 + pkg/widgets/logplayer/logplayer.go | 165 +++++++++++++++++++++++++---- 4 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 pkg/datalogger/log_export.go diff --git a/pkg/datalogger/log_export.go b/pkg/datalogger/log_export.go new file mode 100644 index 00000000..4d16bc8f --- /dev/null +++ b/pkg/datalogger/log_export.go @@ -0,0 +1,83 @@ +package datalogger + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/roffe/txlogger/pkg/logfile" +) + +// ExportRecords writes the given records to a new logfile in dir, picking the +// writer from ext (one of "csv", "bpl", "t5l", "t7l", "t8l") so the exported +// clip keeps the same format as the log it was cut from. The filename is built +// from prefix plus a timestamp by createLog. It returns the full path of the +// created file. +func ExportRecords(dir, prefix, ext string, records []logfile.Record) (string, error) { + if len(records) == 0 { + return "", errors.New("no records to export") + } + + ext = strings.ToLower(strings.TrimPrefix(ext, ".")) + + file, filename, err := createLog(dir, prefix, ext) + if err != nil { + return "", err + } + + var w LogWriter + switch ext { + case "csv": + w = NewCSVWriter(file) + case "bpl": + w = NewBPLWriter(file) + case "t5l", "t7l", "t8l": + w = NewTXLWriter(file) + default: + file.Close() + return "", fmt.Errorf("unsupported export format: %s", ext) + } + + cols := recordColumns(records[0]) + + // One channel set is built up front; the backing values are rewritten for + // each record so we don't allocate a closure per cell. + values := make([]float64, len(cols)) + channels := make([]Channel, len(cols)) + for i, name := range cols { + i := i + channels[i] = Channel{ + Name: name, + read: func() float64 { return values[i] }, + format: sysvarFormat(name), + } + } + + for i := range records { + rec := records[i] + for j, name := range cols { + values[j] = rec.Values[name] + } + if err := w.Write(rec.Time, channels); err != nil { + w.Close() + return "", fmt.Errorf("failed to write record: %w", err) + } + } + + if err := w.Close(); err != nil { + return "", fmt.Errorf("failed to finalize log: %w", err) + } + return filename, nil +} + +// recordColumns returns the value column names of a record in a stable +// (alphabetical) order so the exported log has a deterministic layout. +func recordColumns(rec logfile.Record) []string { + cols := make([]string, 0, len(rec.Values)) + for k := range rec.Values { + cols = append(cols, k) + } + sort.Strings(cols) + return cols +} diff --git a/pkg/logfile/baselog.go b/pkg/logfile/baselog.go index 4406ce3d..d3f015f1 100644 --- a/pkg/logfile/baselog.go +++ b/pkg/logfile/baselog.go @@ -53,6 +53,22 @@ func (l *BaseLogfile) Pos() int { return max(l.pos, 0) } +// RecordAt returns the record at the given index without changing the playback +// position. The index is clamped to the valid range. It is safe to call +// concurrently with playback as it only reads the immutable records slice. +func (l *BaseLogfile) RecordAt(i int) Record { + if l.length == 0 { + return Record{EOF: true} + } + if i < 0 { + i = 0 + } + if i >= l.length { + i = l.length - 1 + } + return l.records[i] +} + func (l *BaseLogfile) Len() int { return l.length } diff --git a/pkg/logfile/logfile.go b/pkg/logfile/logfile.go index 937bed7d..e7d3e4af 100644 --- a/pkg/logfile/logfile.go +++ b/pkg/logfile/logfile.go @@ -20,6 +20,7 @@ type Logfile interface { Seek(int) Pos() int Len() int + RecordAt(int) Record Start() time.Time End() time.Time Close() diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index b2be8c96..7b88e19f 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -1,6 +1,7 @@ package logplayer import ( + "fmt" "sync" "time" @@ -57,6 +58,11 @@ type Logplayer struct { OnMouseDown func() + // selStart and selEnd mark the in/out points of the export selection. + // A value of -1 means the point has not been set yet. + selStart int + selEnd int + focused bool closed bool } @@ -70,6 +76,10 @@ type logplayerObjects struct { positionSlider *slider timeLabel *widget.Label speedSelect *widget.Select + setInBtn *widget.Button + setOutBtn *widget.Button + exportBtn *widget.Button + selectionLabel *widget.Label } type Config struct { @@ -77,6 +87,9 @@ type Config struct { Logfile logfile.Logfile TimeSetter func(time.Time) PlotterRenderer plotter.PlotBackend + // OnExport, when set, is called with the records of the selected range when + // the user exports a selection. When nil the selection controls are hidden. + OnExport func(records []logfile.Record) } func New(cfg *Config) *Logplayer { @@ -89,7 +102,9 @@ func New(cfg *Config) *Logplayer { objs: &logplayerObjects{ positionSlider: NewSlider(), }, - logFile: cfg.Logfile, + logFile: cfg.Logfile, + selStart: -1, + selEnd: -1, } lp.ExtendBaseWidget(lp) @@ -169,7 +184,16 @@ func (l *Logplayer) TypedKey(ev *fyne.KeyEvent) { } } -func (l *Logplayer) TypedRune(_ rune) { +func (l *Logplayer) TypedRune(r rune) { + if l.closed { + return + } + switch r { + case 'i', 'I': + l.setSelectionStart() + case 'o', 'O': + l.setSelectionEnd() + } } func (l *Logplayer) control(op *controlMsg) { @@ -230,6 +254,13 @@ func (l *Logplayer) render() { l.control(&controlMsg{Op: OpNext}) }) + l.objs.setInBtn = widget.NewButton("In", l.setSelectionStart) + l.objs.setOutBtn = widget.NewButton("Out", l.setSelectionEnd) + l.objs.exportBtn = widget.NewButtonWithIcon("Save selection", theme.DocumentSaveIcon(), l.exportSelection) + l.objs.exportBtn.Disable() + l.objs.selectionLabel = widget.NewLabel("") + l.updateSelectionLabel() + values := make(map[string][]float64) for { if rec := l.logFile.Next(); !rec.EOF { @@ -264,29 +295,47 @@ func (l *Logplayer) render() { } func (l *Logplayer) CreateRenderer() fyne.WidgetRenderer { - l.container = container.NewBorder( + controls := container.NewBorder( + nil, + nil, + container.NewGridWithColumns(4, + l.objs.rewindBtn, + l.objs.playbackToggleBtn, + l.objs.forwardBtn, + l.objs.restartBtn, + ), nil, container.NewBorder( nil, nil, - container.NewGridWithColumns(4, - l.objs.rewindBtn, - l.objs.playbackToggleBtn, - l.objs.forwardBtn, - l.objs.restartBtn, - ), nil, - container.NewBorder( - nil, - nil, - nil, - container.NewHBox( - layout.NewFixedWidth(85, l.objs.timeLabel), - layout.NewFixedWidth(75, l.objs.speedSelect), - ), - l.objs.positionSlider, + container.NewHBox( + layout.NewFixedWidth(85, l.objs.timeLabel), + layout.NewFixedWidth(75, l.objs.speedSelect), ), + l.objs.positionSlider, ), + ) + + var bottom fyne.CanvasObject = controls + if l.cfg.OnExport != nil { + selection := container.NewBorder( + nil, + nil, + container.NewHBox( + l.objs.setInBtn, + l.objs.setOutBtn, + l.objs.exportBtn, + ), + nil, + l.objs.selectionLabel, + ) + bottom = container.NewVBox(selection) + } + + l.container = container.NewBorder( + bottom, + controls, nil, nil, l.objs.plotter, @@ -367,6 +416,82 @@ func (l *Logplayer) togglePlayback() { } } +// setSelectionStart marks the in point of the export selection at the current +// playback position. +func (l *Logplayer) setSelectionStart() { + l.selStart = int(l.objs.positionSlider.Value) + l.updateSelectionLabel() +} + +// setSelectionEnd marks the out point of the export selection at the current +// playback position. +func (l *Logplayer) setSelectionEnd() { + l.selEnd = int(l.objs.positionSlider.Value) + l.updateSelectionLabel() +} + +// selectionRange returns the normalized [lo, hi] record indices of the current +// selection. An unset in point defaults to the start of the log and an unset +// out point to the end. The bounds are clamped to the log and swapped if the +// out point was set before the in point. +func (l *Logplayer) selectionRange() (int, int) { + last := l.logFile.Len() - 1 + lo, hi := l.selStart, l.selEnd + if lo == -1 { + lo = 0 + } + if hi == -1 { + hi = last + } + if lo > hi { + lo, hi = hi, lo + } + if lo < 0 { + lo = 0 + } + if hi > last { + hi = last + } + return lo, hi +} + +func (l *Logplayer) updateSelectionLabel() { + if l.objs.selectionLabel == nil { + return + } + inTxt, outTxt := "—", "—" + if l.selStart != -1 { + inTxt = l.logFile.RecordAt(l.selStart).Time.Format("15:04:05.00") + } + if l.selEnd != -1 { + outTxt = l.logFile.RecordAt(l.selEnd).Time.Format("15:04:05.00") + } + + if l.selStart == -1 && l.selEnd == -1 { + l.objs.selectionLabel.SetText("In — Out —") + l.objs.exportBtn.Disable() + return + } + + lo, hi := l.selectionRange() + l.objs.selectionLabel.SetText(fmt.Sprintf("In %s Out %s (%d samples)", inTxt, outTxt, hi-lo+1)) + l.objs.exportBtn.Enable() +} + +// exportSelection collects the records of the current selection and hands them +// to the OnExport callback so they can be written to a new logfile. +func (l *Logplayer) exportSelection() { + if l.cfg.OnExport == nil || l.logFile.Len() == 0 { + return + } + lo, hi := l.selectionRange() + records := make([]logfile.Record, 0, hi-lo+1) + for i := lo; i <= hi; i++ { + records = append(records, l.logFile.RecordAt(i)) + } + l.cfg.OnExport(records) +} + func (l *Logplayer) playLog() { speedMultiplier := 1.0 timer := time.NewTimer(0) @@ -405,6 +530,7 @@ func (l *Logplayer) playLog() { l.cfg.EBus.Publish(k, v) } timeSetter(rec.Time) + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) timer.Stop() } l.objs.plotter.Seek(op.Pos) @@ -424,6 +550,7 @@ func (l *Logplayer) playLog() { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) } l.objs.plotter.Seek(pos) @@ -443,6 +570,7 @@ func (l *Logplayer) playLog() { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) } if f := l.cfg.TimeSetter; f != nil { f(rec.Time) @@ -475,6 +603,7 @@ func (l *Logplayer) playLog() { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) if f := l.cfg.TimeSetter; f != nil { f(rec.Time) } From e8c860d18ee65d63d79bb780fbefda805f2cf2b9 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:58:34 +0200 Subject: [PATCH 62/93] use end of frame onCapture --- pkg/datalogger/remotelogger.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/datalogger/remotelogger.go b/pkg/datalogger/remotelogger.go index 68661083..866ccd59 100644 --- a/pkg/datalogger/remotelogger.go +++ b/pkg/datalogger/remotelogger.go @@ -3,6 +3,7 @@ package datalogger import ( "fmt" "log" + "time" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/relayserver" @@ -91,7 +92,7 @@ func (c *RemoteClient) Start() error { for _, va := range values { ebus.Publish(va.Name, va.Value) } - c.onCapture() + c.onCapture(time.Now()) default: log.Println("Unknown message kind:", msg.Kind.String()) } From 8e52e1adfe2690fa341a1fc18b99d102eb455813 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 18 Jun 2026 23:59:29 +0200 Subject: [PATCH 63/93] add live plot to toolbar --- pkg/windows/mainWindow.go | 18 ++++++++++++++++++ pkg/windows/mainWindow_buttons.go | 29 +++++++++++++++++++++++++++++ pkg/windows/mainWindow_toolbar.go | 1 + 3 files changed, 48 insertions(+) diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index bd37032b..27b32ace 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "fyne.io/fyne/v2" @@ -109,6 +110,7 @@ type mainWindowButtons struct { layoutRefreshBtn *widget.Button symbolListBtn *widget.Button addGaugeBtn *widget.Button + livePlotBtn *widget.Button } type mainWindowCounters struct { @@ -433,6 +435,22 @@ func (mw *MainWindow) LoadLogfile(filename string, r io.Reader, pos fyne.Positio EBus: ebus.CONTROLLER, Logfile: logz, PlotterRenderer: mw.settings.GetPlotterRenderer(), + OnExport: func(records []logfile.Record) { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fp)), ".") + prefix := strings.TrimSuffix(fp, filepath.Ext(fp)) + "-clip" + logPath := mw.settings.GetLogPath() + go func() { + path, err := datalogger.ExportRecords(logPath, prefix, ext, records) + fyne.Do(func() { + if err != nil { + mw.Error(fmt.Errorf("failed to export selection: %w", err)) + return + } + mw.Log(fmt.Sprintf("exported %d samples to %s", len(records), path)) + dialog.ShowInformation("Selection exported", fmt.Sprintf("Saved %d samples to\n%s", len(records), path), mw) + }) + }() + }, }) /* content := container.NewBorder( diff --git a/pkg/windows/mainWindow_buttons.go b/pkg/windows/mainWindow_buttons.go index 5397c1f5..e3131fbd 100644 --- a/pkg/windows/mainWindow_buttons.go +++ b/pkg/windows/mainWindow_buttons.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -15,6 +16,7 @@ import ( "github.com/roffe/txlogger/pkg/datalogger" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/widgets/dashboard" + "github.com/roffe/txlogger/pkg/widgets/liveplotter" "github.com/roffe/txlogger/pkg/widgets/msglist" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -31,6 +33,33 @@ func (mw *MainWindow) createButtons() { mw.buttons.debugBtn = mw.newDebugBtn() mw.buttons.symbolListBtn = mw.newSymbolListBtn() mw.buttons.addGaugeBtn = mw.newaddGaugeBtn() + mw.buttons.livePlotBtn = mw.newLivePlotBtn() +} + +func (mw *MainWindow) newLivePlotBtn() *widget.Button { + return widget.NewButtonWithIcon("Live plot", theme.MediaSkipNextIcon(), func() { + if w := mw.wm.HasWindow("Live plot"); w != nil { + mw.wm.Raise(w) + return + } + + names := mw.symbolList.Names() + if len(names) == 0 { + mw.Error(fmt.Errorf("no symbols selected to plot")) + return + } + + lp := liveplotter.New(&liveplotter.Config{ + Order: names, + Window: 120 * time.Second, + }) + + lpw := multiwindow.NewInnerWindow("Live plot", lp) + lpw.Icon = theme.MediaSkipNextIcon() + lpw.OnClose = lp.Close + mw.wm.Add(lpw) + lpw.Resize(fyne.NewSize(900, 500)) + }) } func (mw *MainWindow) newaddGaugeBtn() *widget.Button { diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index b8980c5a..19c8df96 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -86,6 +86,7 @@ func (mw *MainWindow) newToolbar() *fyne.Container { mw.buttons.symbolListBtn, mw.buttons.logBtn, mw.buttons.dashboardBtn, + mw.buttons.livePlotBtn, widget.NewButtonWithIcon("Matrix", theme.GridIcon(), mw.openMatrixBuilder), widget.NewButtonWithIcon("", theme.GridIcon(), func() { mw.wm.Arrange(&multiwindow.GridArranger{}) From f0f7efff1b927dcb6e45f8a1905b1a9e3de36273 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 00:00:32 +0200 Subject: [PATCH 64/93] add PublishFrame --- pkg/ebus/ebus.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/ebus/ebus.go b/pkg/ebus/ebus.go index 764a97da..b6db15ad 100644 --- a/pkg/ebus/ebus.go +++ b/pkg/ebus/ebus.go @@ -2,6 +2,7 @@ package ebus import ( "sync" + "time" "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/bus" @@ -15,6 +16,11 @@ var ( const ( TOPIC_COLORBLINDMODE = "color_blind_mode" TOPIC_ECU = "selected_ecu" + // TOPIC_FRAME fires once per completed log frame, carrying the frame's + // timestamp as Unix milliseconds (float64). Subscribers use it as the frame + // boundary to sample the latest value of every symbol with a shared, real + // timestamp (see the live plotter). + TOPIC_FRAME = "__frame__" ) func init() { @@ -32,6 +38,12 @@ func Publish(topic string, data float64) { CONTROLLER.Publish(topic, data) } +// PublishFrame signals that a log frame completed at time t. The timestamp is +// carried as Unix milliseconds, which fits exactly in a float64. +func PublishFrame(t time.Time) { + CONTROLLER.Publish(TOPIC_FRAME, float64(t.UnixMilli())) +} + func SubscribeFunc(topic string, f func(float64)) func() { wrapFN := func(v float64) { fyne.Do(func() { From 6b00801e95327660c3d6f8db041f44296e9bf576 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 00:00:52 +0200 Subject: [PATCH 65/93] update whatsnew --- pkg/assets/WHATSNEW.md | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 23f33c9f..82248cba 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -1,4 +1,5 @@ # 2.1.10 +- Cut a new logfile from a selection in the logplayer: scrub to a spot and press "In" (or the `i` key) to mark the start, scrub again and press "Out" (or `o`) to mark the end, then press the save button to write just that range to a new log next to your other logs. The clip keeps the same format as the source log (csv/bpl/t5l/t7l/t8l). Leaving the In or Out point unset selects from the start or to the end of the log - Live tracking marker in the 3d mesh viewer showing where the ECU is reading from, mirroring the crosshair in the map above - Fixed the 3d mesh showing one cell less than the table in each direction; values are now cell-centered so an 18x16 map renders 18x16 cells - Performance optimization for the meshgrid From 0287f13ab728c1908a5d0b82d114b49ef0571aee Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 00:01:14 +0200 Subject: [PATCH 66/93] update go deps --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 064176d3..3c162f0f 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 github.com/roffe/ecusymbol v1.2.0 - github.com/roffe/gocan v1.4.0 + github.com/roffe/gocan v1.4.1 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 diff --git a/go.sum b/go.sum index cb93fdce..71a90574 100644 --- a/go.sum +++ b/go.sum @@ -138,8 +138,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/roffe/ecusymbol v1.2.0 h1:mI5M0HG17gCgbPTbMpIR8nPJGBu4HNZp0133HcPOzYw= github.com/roffe/ecusymbol v1.2.0/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.4.0 h1:OSs//lr4vy/ozyMPUbgZaNFVZWMeXzOsXhCujpA4WRs= -github.com/roffe/gocan v1.4.0/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= +github.com/roffe/gocan v1.4.1 h1:T9aAHzTxS7oXwiOlMIM2TAf75aMHcEaWAi27nKwEFQk= +github.com/roffe/gocan v1.4.1/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= From 614d6e49da2ad157e439ce87fa43eb5c2d507151 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 14:26:03 +0200 Subject: [PATCH 67/93] better single cell viewing --- pkg/widgets/mapviewer/mapviewer.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 8eb82724..c7d203cd 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -30,6 +30,10 @@ const ( maxTextSize = 28 ) +// singleCellColor is used for 1x1 maps where value-based color interpolation +// is meaningless (min == max yields a flat gray). ponytail: fixed accent. +var singleCellColor = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} // white + var ( // _ fyne.Tappable = (*MapViewer)(nil) _ fyne.Focusable = (*MapViewer)(nil) @@ -353,6 +357,9 @@ func (mv *MapViewer) Refresh() { } for idx, value := range mv.cfg.ZData { mv.setCellText(idx, value) + if mv.numData == 1 { + continue // single cell keeps singleCellColor + } col := colors.GetColorInterpolation( mv.zMin, mv.zMax, @@ -423,10 +430,16 @@ func (mv *MapViewer) createTextValues() { func (mv *MapViewer) createZdata() { mv.zDataRects = make([]*canvas.Rectangle, 0, mv.numData) objs := make([]fyne.CanvasObject, 0, mv.numData) + singleCell := mv.numData == 1 for _, value := range mv.cfg.ZData { - color := colors.GetColorInterpolation(mv.zMin, mv.zMax, value, mv.colorMode) - rect := &canvas.Rectangle{FillColor: color, StrokeColor: color, StrokeWidth: 0} - rect.SetMinSize(fyne.NewSize(34, 14)) + col := colors.GetColorInterpolation(mv.zMin, mv.zMax, value, mv.colorMode) + minHeight := float32(14) + if singleCell { + col = singleCellColor + minHeight = 28 + } + rect := &canvas.Rectangle{FillColor: col, StrokeColor: col, StrokeWidth: 0} + rect.SetMinSize(fyne.NewSize(34, minHeight)) mv.zDataRects = append(mv.zDataRects, rect) objs = append(objs, rect) } From e778e608893999d1b7c723742f671560aee84729 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 14:36:05 +0200 Subject: [PATCH 68/93] refactor mainmenu again --- pkg/assets/WHATSNEW.md | 2 +- pkg/windows/mainWindow.go | 18 +- pkg/windows/mainWindow_buttons.go | 2 +- pkg/windows/mainWindow_menu.go | 269 +++++++++++++++--------------- pkg/windows/mainmenu.go | 106 +++++------- pkg/windows/mainmenu_t5.go | 118 ++++++------- pkg/windows/mainmenu_t7.go | 214 +++++++++++++----------- pkg/windows/mainmenu_t8.go | 157 ++++++++--------- pkg/windows/mainwindow_selects.go | 2 +- 9 files changed, 424 insertions(+), 464 deletions(-) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 82248cba..c71a2f1e 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -18,7 +18,6 @@ - Added 2d graph for viewing flat maps - Rewrote logplayer plotter to use about 50% less CPU on zoomed out views - Big refactor of the log writing logic to be simpler to maintain and be more performant -- Dial and Dual Dial now uses shaders to draw the faces, dials and pips - Improved cell selection in mapviewer - Improved copy paste in mapviewer, added paste here function - Added a Matrix builder from logfiles. It learns a 2D map from one or more logs: pick which series drives the X axis, the Y axis and supplies the Z value, and every sample that lands on a cell is averaged into it. The result is shown live in a mapviewer (colored grid + 3D mesh) and the cells can be edited by hand @@ -29,6 +28,7 @@ - Visual filter / query builder: add rules like "if " and a sample only counts as a hit when it satisfies every rule. Operators: >, >=, <, <=, ==, != and ~ (approximately equal) - Filter query language: instead of the visual rules you can type a full query with and/or, () grouping and the same operators, e.g. "if (ActualIn.n_Engine > 3000 and Out.X_AccPedal > 50) or boost ~ 1.2". Series can be compared to numbers, to each other or to arithmetic of them; a non-empty query overrides the rules - Save and load configurations as presets (series, dimensions, axis breakpoints, tolerances and filter rules) +- Added a bunch of TransCal maps under Fueling on T7 # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 27b32ace..7d9a01e9 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -63,15 +63,15 @@ func (s *SecretText) MouseUp(e *desktop.MouseEvent) { type MainWindow struct { fyne.Window - app fyne.App - menu *MainMenu - outputData binding.StringList - selects *mainWindowSelects - buttons *mainWindowButtons - counters *mainWindowCounters - loggingRunning bool - filename string - symbolList *symbollist.Widget + app fyne.App + leadingMenus, trailingMenus []*fyne.Menu + outputData binding.StringList + selects *mainWindowSelects + buttons *mainWindowButtons + counters *mainWindowCounters + loggingRunning bool + filename string + symbolList *symbollist.Widget as2 *as2.File fw symbol.SymbolCollection diff --git a/pkg/windows/mainWindow_buttons.go b/pkg/windows/mainWindow_buttons.go index e3131fbd..510b9c75 100644 --- a/pkg/windows/mainWindow_buttons.go +++ b/pkg/windows/mainWindow_buttons.go @@ -341,7 +341,7 @@ func (mw *MainWindow) newDashboardBtn() *widget.Button { mw.Window.SetContent(db) mw.SetFullScreen(true) } else { - mw.SetMainMenu(mw.menu.GetMenu(mw.selects.ecuSelect.Selected)) + mw.SetMainMenu(mw.GetMenu(mw.selects.ecuSelect.Selected)) mw.Window.SetContent(mw.content) dbw.Close() mw.buttons.dashboardBtn.OnTapped() diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index c4a4d21e..00f029af 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -30,151 +30,13 @@ import ( "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmmod" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmstatus" "github.com/roffe/txlogger/pkg/widgets/trionic7/t7esp" - "github.com/roffe/txlogger/pkg/widgets/trionic7/t7fwinfo" ) func (mw *MainWindow) setupMenu() { - getAdapter := func() (gocan.Adapter, error) { - device, err := mw.settings.GetAdapter(mw.selects.ecuSelect.Selected) - if err != nil { - mw.Error(err) - return nil, err - } - return device, nil - } - getFW := func() symbol.SymbolCollection { return mw.fw } - getECU := func() string { - return mw.selects.ecuSelect.Selected - } - - funcMap := map[string]func(string){ - "DTC Reader": func(str string) { - if w := mw.wm.HasWindow("DTC Reader"); w != nil { - mw.wm.Raise(w) - return - } - inner := multiwindow.NewInnerWindow("DTC Reader", dtcreader.New(getFW, getECU, getAdapter, mw.Log, mw.Error)) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - inner.Resize(fyne.Size{Width: 600, Height: 400}) - }, - "Edit Parameters": func(str string) { - if w := mw.wm.HasWindow("Edit Parameters"); w != nil { - mw.wm.Raise(w) - return - } - param := editparameters.NewEditParameters(getAdapter, mw.Error, mw.Log) - inner := multiwindow.NewInnerWindow("Edit Parameters", param) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - }, - "Register EU0D": func(str string) { - if w := mw.wm.HasWindow("Register EU0D"); w != nil { - mw.wm.Raise(w) - return - } - inner := multiwindow.NewInnerWindow("Register EU0D", NewMyrtilosRegistration(mw)) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - }, - "ESP Calibration": func(str string) { - if w := mw.wm.HasWindow("ESP Calibration selection"); w != nil { - mw.wm.Raise(w) - return - } - if t, ok := mw.fw.(*symbol.T7File); ok { - esp := t7esp.New(mw.filename, t) - inner := multiwindow.NewInnerWindow("ESP Calibration selection", esp) - inner.Icon = theme.InfoIcon() - inner.DisableResize = true - mw.wm.Add(inner) - } else { - mw.Error(errors.New("not a T7 file")) - } - }, - "Firmware information": func(str string) { - if w := mw.wm.HasWindow("Firmware info"); w != nil { - mw.wm.Raise(w) - return - } - if t, ok := mw.fw.(*symbol.T7File); ok { - fwinfo := t7fwinfo.New(t) - inner := multiwindow.NewInnerWindow("Firmware info", fwinfo) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - } - }, - "Firmware info edit": func(str string) { - if w := mw.wm.HasWindow("Firmware info edit"); w != nil { - mw.wm.Raise(w) - return - } - tf := new(t8file.T8File) - filename := fyne.CurrentApp().Preferences().String("lastBinFile") - tf.GetInfo(filename) - tf.ShowEditT8Dialog(mw) - }, - "Pgm_mod!": func(str string) { - if w := mw.wm.HasWindow("Pgm_mod!"); w != nil { - mw.wm.Raise(w) - return - } - symZ := mw.fw.GetByName("Pgm_mod!") - pgm := pgmmod.New() - pgm.LoadFunc = func() ([]byte, error) { - if mw.dlc != nil { - log.Printf("Loading Pgm_mod! from ECU $%X", symZ.SramOffset) - data, err := mw.dlc.GetRAM(symZ.SramOffset, uint32(symZ.Length)) - if err != nil { - return nil, err - } - return data, nil - } - log.Printf("Loading Pgm_mod! from Binary $%X", symZ.Address) - return symZ.Bytes(), nil - } - - pgm.SaveFunc = func(data []byte) error { - if len(data) != int(symZ.Length) { - return fmt.Errorf("data length mismatch: got %d, want %d", len(data), symZ.Length) - } - if mw.dlc != nil { - log.Printf("Saving Pgm_mod! to ECU $%X", symZ.SramOffset) - if err := mw.dlc.SetRAM(symZ.SramOffset, data); err != nil { - return err - } - return nil - } - log.Printf("Saving Pgm_mod! to Binary $%X", symZ.Address) - return symZ.SetData(data) - } - - pgm.Set(symZ.Bytes()) - mapWindow := multiwindow.NewInnerWindow("Pgm_mod!", pgm) - mapWindow.Icon = theme.GridIcon() - mw.wm.Add(mapWindow) - }, - "Pgm_status": func(str string) { - if w := mw.wm.HasWindow("Pgm_status"); w != nil { - return - } - pgs := pgmstatus.New() - cancel := ebus.SubscribeFunc("Pgm_status", pgs.Set) - iw := multiwindow.NewInnerWindow("Pgm_status", pgs) - iw.Icon = theme.InfoIcon() - iw.OnClose = func() { - if cancel != nil { - cancel() - } - } - mw.wm.Add(iw) - }, - } - openItem := fyne.NewMenuItemWithIcon("Open", theme.FolderIcon(), nil) openItem.ChildMenu = fyne.NewMenu("File", fyne.NewMenuItemWithIcon("Open binary", theme.DocumentIcon(), mw.loadBinary), @@ -279,7 +141,136 @@ func (mw *MainWindow) setupMenu() { ), } - mw.menu = NewMenu(mw, leading, trailing, mw.openMap, funcMap) + mw.leadingMenus = leading + mw.trailingMenus = trailing +} + +func (mw *MainWindow) getAdapter() (gocan.Adapter, error) { + device, err := mw.settings.GetAdapter(mw.selects.ecuSelect.Selected) + if err != nil { + mw.Error(err) + return nil, err + } + return device, nil +} + +func (mw *MainWindow) openDTCReader() { + if w := mw.wm.HasWindow("DTC Reader"); w != nil { + mw.wm.Raise(w) + return + } + getFW := func() symbol.SymbolCollection { return mw.fw } + getECU := func() string { return mw.selects.ecuSelect.Selected } + inner := multiwindow.NewInnerWindow("DTC Reader", dtcreader.New(getFW, getECU, mw.getAdapter, mw.Log, mw.Error)) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) + inner.Resize(fyne.Size{Width: 600, Height: 400}) +} + +func (mw *MainWindow) openEditParameters() { + if w := mw.wm.HasWindow("Edit Parameters"); w != nil { + mw.wm.Raise(w) + return + } + param := editparameters.NewEditParameters(mw.getAdapter, mw.Error, mw.Log) + inner := multiwindow.NewInnerWindow("Edit Parameters", param) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) +} + +func (mw *MainWindow) openRegisterEU0D() { + if w := mw.wm.HasWindow("Register EU0D"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Register EU0D", NewMyrtilosRegistration(mw)) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) +} + +func (mw *MainWindow) openESPCalibration() { + if w := mw.wm.HasWindow("ESP Calibration selection"); w != nil { + mw.wm.Raise(w) + return + } + t, ok := mw.fw.(*symbol.T7File) + if !ok { + mw.Error(errors.New("not a T7 file")) + return + } + esp := t7esp.New(mw.filename, t) + inner := multiwindow.NewInnerWindow("ESP Calibration selection", esp) + inner.Icon = theme.InfoIcon() + inner.DisableResize = true + mw.wm.Add(inner) +} + +func (mw *MainWindow) openFirmwareInfoEdit() { + if w := mw.wm.HasWindow("Firmware info edit"); w != nil { + mw.wm.Raise(w) + return + } + tf := new(t8file.T8File) + filename := fyne.CurrentApp().Preferences().String("lastBinFile") + tf.GetInfo(filename) + tf.ShowEditT8Dialog(mw) +} + +func (mw *MainWindow) openPgmMod() { + if w := mw.wm.HasWindow("Pgm_mod!"); w != nil { + mw.wm.Raise(w) + return + } + symZ := mw.fw.GetByName("Pgm_mod!") + pgm := pgmmod.New() + pgm.LoadFunc = func() ([]byte, error) { + if mw.dlc != nil { + log.Printf("Loading Pgm_mod! from ECU $%X", symZ.SramOffset) + data, err := mw.dlc.GetRAM(symZ.SramOffset, uint32(symZ.Length)) + if err != nil { + return nil, err + } + return data, nil + } + log.Printf("Loading Pgm_mod! from Binary $%X", symZ.Address) + return symZ.Bytes(), nil + } + + pgm.SaveFunc = func(data []byte) error { + if len(data) != int(symZ.Length) { + return fmt.Errorf("data length mismatch: got %d, want %d", len(data), symZ.Length) + } + if mw.dlc != nil { + log.Printf("Saving Pgm_mod! to ECU $%X", symZ.SramOffset) + if err := mw.dlc.SetRAM(symZ.SramOffset, data); err != nil { + return err + } + return nil + } + log.Printf("Saving Pgm_mod! to Binary $%X", symZ.Address) + return symZ.SetData(data) + } + + pgm.Set(symZ.Bytes()) + mapWindow := multiwindow.NewInnerWindow("Pgm_mod!", pgm) + mapWindow.Icon = theme.GridIcon() + mw.wm.Add(mapWindow) +} + +func (mw *MainWindow) openPgmStatus() { + if w := mw.wm.HasWindow("Pgm_status"); w != nil { + return + } + pgs := pgmstatus.New() + cancel := ebus.SubscribeFunc("Pgm_status", pgs.Set) + iw := multiwindow.NewInnerWindow("Pgm_status", pgs) + iw.Icon = theme.InfoIcon() + iw.OnClose = func() { + if cancel != nil { + cancel() + } + } + mw.wm.Add(iw) } func (mw *MainWindow) loadBinary() { diff --git a/pkg/windows/mainmenu.go b/pkg/windows/mainmenu.go index 165cf718..472b0315 100644 --- a/pkg/windows/mainmenu.go +++ b/pkg/windows/mainmenu.go @@ -1,90 +1,68 @@ package windows import ( - "strings" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" symbol "github.com/roffe/ecusymbol" ) -type MainMenu struct { - w fyne.Window - leading, trailing []*fyne.Menu - openFunc func(symbol.ECUType, string, string) - //multiFunc func(symbol.ECUType, ...string) - funcMap map[string]func(string) -} - -func NewMenu(w fyne.Window, leading, trailing []*fyne.Menu, openFunc func(symbol.ECUType, string, string), funcMap map[string]func(string)) *MainMenu { - return &MainMenu{ - w: w, - openFunc: openFunc, - leading: leading, - trailing: trailing, - funcMap: funcMap, - } +type MenuItem struct { + Name string + Children []MenuItem + Func func() + Data string } -func (mw *MainMenu) GetMenu(name string) *fyne.MainMenu { - var order []string - var ecuM map[string][]string +func (mw *MainWindow) GetMenu(name string) *fyne.MainMenu { + var tree []MenuItem var typ symbol.ECUType switch name { case "T5": - order = T5SymbolsTuningOrder - ecuM = T5SymbolsTuning + tree = mw.t5Menu() typ = symbol.ECU_T5 case "T7": - order = T7SymbolsTuningOrder - ecuM = T7SymbolsTuning + tree = mw.t7Menu() typ = symbol.ECU_T7 case "T8": - order = T8SymbolsTuningOrder - ecuM = T8SymbolsTuning + tree = mw.t8Menu() typ = symbol.ECU_T8 } - menus := append([]*fyne.Menu{}, mw.leading...) - - for _, category := range order { - var items []*fyne.MenuItem - for _, mapName := range ecuM[category] { - if f, ok := mw.funcMap[mapName]; ok { - itm := fyne.NewMenuItemWithIcon(mapName, theme.ComputerIcon(), func() { - f(mapName) - }) - items = append(items, itm) - continue - } + menus := append([]*fyne.Menu{}, mw.leadingMenus...) + for _, category := range tree { + menus = append(menus, fyne.NewMenu(category.Name, mw.buildItems(typ, category.Children)...)) + } + menus = append(menus, mw.trailingMenus...) - if strings.Contains(mapName, "|") { - parts := strings.Split(mapName, "|") - names := parts[1:] - if len(parts) == 2 { - itm := fyne.NewMenuItemWithIcon(parts[0], theme.GridIcon(), func() { - mw.openFunc(typ, parts[0], names[0]) - }) - items = append(items, itm) - continue - } - //itm := fyne.NewMenuItem(parts[0], func() { - // mw.multiFunc(typ, names...) - //}) - //items = append(items, itm) - continue - } + return fyne.NewMainMenu(menus...) +} - itm := fyne.NewMenuItemWithIcon(mapName, theme.GridIcon(), func() { - mw.openFunc(typ, "", mapName) - }) - items = append(items, itm) - } - menus = append(menus, fyne.NewMenu(category, items...)) +func (mw *MainWindow) buildItems(typ symbol.ECUType, items []MenuItem) []*fyne.MenuItem { + var out []*fyne.MenuItem + for _, item := range items { + out = append(out, mw.buildItem(typ, item)) } + return out +} - menus = append(menus, mw.trailing...) - - return fyne.NewMainMenu(menus...) +func (mw *MainWindow) buildItem(typ symbol.ECUType, item MenuItem) *fyne.MenuItem { + switch { + case len(item.Children) > 0: + itm := fyne.NewMenuItemWithIcon(item.Name, theme.FolderIcon(), nil) + itm.ChildMenu = fyne.NewMenu(item.Name, mw.buildItems(typ, item.Children)...) + return itm + case item.Func != nil: + return fyne.NewMenuItemWithIcon(item.Name, theme.ComputerIcon(), item.Func) + case item.Data != "": + // title + symbol: open as a map + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMap(typ, item.Name, item.Data) + }) + default: + // Name is itself a symbol + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMap(typ, "", item.Name) + }) + } } diff --git a/pkg/windows/mainmenu_t5.go b/pkg/windows/mainmenu_t5.go index 75eca147..d31e37a9 100644 --- a/pkg/windows/mainmenu_t5.go +++ b/pkg/windows/mainmenu_t5.go @@ -1,67 +1,57 @@ package windows -var T5SymbolsTuningOrder = []string{ - "Diagnostics", - "Options", - "Injection [Fuel]", - "Ignition", - "Turbo control [M]", - "Turbo control [A]", - "Knock detection", - "Warmup", - "Idle", -} - -var T5SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - "Pgm_status", - }, - "Options": { - "Pgm_mod!", - }, - "Injection [Fuel]": { - "VE map - normal|Insp_mat!", - "VE map - knock|Fuel_knock_mat!", - "Injector scaling|Inj_konst!", - "Battery correction map|Batt_korr_tab!", - "Fuel cut in overboost|Tryck_vakt_tab!", - }, - "Ignition": { - "Ignition normal|Ign_map_0!", - "Ignition knock|Ign_map_2!", - "Ignition warmup|Ign_map_4!", - }, - "Turbo control [M]": { - "Boost request map|Tryck_mat!", - "Boost control bias|Reg_kon_mat!", - "P factors|P_fors!", - "I factors|I_fors!", - "D factors|D_fors!", - "Boost limit in 1st gear|Regl_tryck_fgm!", - "Boost limit in 2nd gear|Regl_tryck_sgm!", - }, - "Turbo control [A]": { - "Boost request map|Tryck_mat_a!", - "Boost control bias|Reg_kon_mat_a!", - "P factors|P_fors_a!", - "I factors|I_fors_a!", - "D factors|D_fors_a!", - "Boost limit in 1st gear|Regl_tryck_fgaut!", - }, - "Knock detection": { - "Knock sensitivity map|Knock_ref_matrix!", - "Ignition retard limit|Knock_lim_tab!", - "Boost reduction map|Apc_knock_tab!", - }, - "Warmup": { - "Afterstart enrichment (1)|Eftersta_fak!", - "Afterstart enrichment (2)|Eftersta_fak2!", - }, - "Idle": { - "Idle target RPM|Idle_rpm_tab!", - "Idle ignition|Ign_idle_angle!", - "Idle ignition correction|Ign_map_1!", - "Idle fuel map|Idle_fuel_korr!", - }, +func (mw *MainWindow) t5Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "Pgm_status", Func: mw.openPgmStatus}, + }}, + {Name: "Options", Children: []MenuItem{ + {Name: "Pgm_mod!", Func: mw.openPgmMod}, + }}, + {Name: "Injection [Fuel]", Children: []MenuItem{ + {Name: "VE map - normal", Data: "Insp_mat!"}, + {Name: "VE map - knock", Data: "Fuel_knock_mat!"}, + {Name: "Injector scaling", Data: "Inj_konst!"}, + {Name: "Battery correction map", Data: "Batt_korr_tab!"}, + {Name: "Fuel cut in overboost", Data: "Tryck_vakt_tab!"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Ignition normal", Data: "Ign_map_0!"}, + {Name: "Ignition knock", Data: "Ign_map_2!"}, + {Name: "Ignition warmup", Data: "Ign_map_4!"}, + }}, + {Name: "Turbo control [M]", Children: []MenuItem{ + {Name: "Boost request map", Data: "Tryck_mat!"}, + {Name: "Boost control bias", Data: "Reg_kon_mat!"}, + {Name: "P factors", Data: "P_fors!"}, + {Name: "I factors", Data: "I_fors!"}, + {Name: "D factors", Data: "D_fors!"}, + {Name: "Boost limit in 1st gear", Data: "Regl_tryck_fgm!"}, + {Name: "Boost limit in 2nd gear", Data: "Regl_tryck_sgm!"}, + }}, + {Name: "Turbo control [A]", Children: []MenuItem{ + {Name: "Boost request map", Data: "Tryck_mat_a!"}, + {Name: "Boost control bias", Data: "Reg_kon_mat_a!"}, + {Name: "P factors", Data: "P_fors_a!"}, + {Name: "I factors", Data: "I_fors_a!"}, + {Name: "D factors", Data: "D_fors_a!"}, + {Name: "Boost limit in 1st gear", Data: "Regl_tryck_fgaut!"}, + }}, + {Name: "Knock detection", Children: []MenuItem{ + {Name: "Knock sensitivity map", Data: "Knock_ref_matrix!"}, + {Name: "Ignition retard limit", Data: "Knock_lim_tab!"}, + {Name: "Boost reduction map", Data: "Apc_knock_tab!"}, + }}, + {Name: "Warmup", Children: []MenuItem{ + {Name: "Afterstart enrichment (1)", Data: "Eftersta_fak!"}, + {Name: "Afterstart enrichment (2)", Data: "Eftersta_fak2!"}, + }}, + {Name: "Idle", Children: []MenuItem{ + {Name: "Idle target RPM", Data: "Idle_rpm_tab!"}, + {Name: "Idle ignition", Data: "Ign_idle_angle!"}, + {Name: "Idle ignition correction", Data: "Ign_map_1!"}, + {Name: "Idle fuel map", Data: "Idle_fuel_korr!"}, + }}, + } } diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index d34e2f65..2dcc4c26 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -1,106 +1,116 @@ package windows -var T7SymbolsTuningOrder = []string{ - "Diagnostics", - "Calibration", - "Injectors", - "Fuel", - "Ignition", - "Airmass", - "Boost", - "Knock", - "Limiters", - "Adaption", - "Myrtilos", -} +func (mw *MainWindow) t7Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "F_KnkDetAdap.FKnkCntMap"}, + {Name: "F_KnkDetAdap.RKnkCntMap"}, + {Name: "KnkDetAdap.KnkCntMap"}, + {Name: "MissfAdap.MissfCntMap"}, + }}, + {Name: "Calibration", Children: []MenuItem{ + {Name: "ESP Calibration", Func: mw.openESPCalibration}, + {Name: "AirCompCal.PressMap"}, + {Name: "Ethanol adaption value", Data: "E85.X_EthAct_Tech2"}, + {Name: "MAFCal.m_RedundantAirMap"}, + {Name: "TCompCal.EnrFacE85Tab"}, + {Name: "TCompCal.EnrFacTab"}, + {Name: "VIOSMAFCal.FreqSP"}, + {Name: "VIOSMAFCal.Q_AirInletTab2"}, + }}, + {Name: "Injectors", Children: []MenuItem{ + {Name: "Injector dead time", Data: "InjCorrCal.BattCorrTab"}, + {Name: "Injector dead time (Y)", Data: "InjCorrCal.BattCorrSP"}, + {Name: "Injector constant", Data: "InjCorrCal.InjectorConst"}, + }}, + {Name: "Airmass", Children: []MenuItem{ + {Name: "Pedal request map", Data: "PedalMapCal.m_RequestMap"}, + {Name: "Pedal request airmass (Y)", Data: "TorqueCal.m_PedYSP"}, + {Name: "Air/Torque calibration", Data: "TorqueCal.m_AirTorqMap"}, + {Name: "Air/Torque (X)", Data: "TorqueCal.M_EngXSP"}, + {Name: "Nom. torque map", Data: "TorqueCal.M_NominalMap"}, + {Name: "Nom. torque map (X)", Data: "TorqueCal.m_AirXSP"}, + }}, + {Name: "Fuel", Children: []MenuItem{ + {Name: "TransCal", Children: []MenuItem{ + {Name: "TransCal.ST_Enable"}, + {Name: "TransCal.ST_DecNoLim"}, + {Name: "TransCal.AccRampFac"}, + {Name: "TransCal.DecRampFac"}, + {Name: "TransCal.AccFacMap"}, + {Name: "TransCal.DecFacMap"}, + {Name: "TransCal.RpmSP (Y)", Data: "TransCal.RpmSP"}, + {Name: "TransCal.AccSP (X)", Data: "TransCal.AccSP"}, + {Name: "TransCal.DecSP (X)", Data: "TransCal.DecSP"}, + {Name: "TransCal.RetMul"}, + {Name: "TransCal.AccMul"}, + {Name: "TransCal.RetMulConst"}, + {Name: "TransCal.AccMulConst"}, + {Name: "TransCal.Delay1"}, + {Name: "TransCal.Delay2"}, -var T7SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - // "Firmware information", - "F_KnkDetAdap.FKnkCntMap", - "F_KnkDetAdap.RKnkCntMap", - "KnkDetAdap.KnkCntMap", - "MissfAdap.MissfCntMap", - }, - "Calibration": { - "ESP Calibration", - "AirCompCal.PressMap", - "Ethanol adaption value|E85.X_EthAct_Tech2", - "MAFCal.m_RedundantAirMap", - "TCompCal.EnrFacE85Tab", - "TCompCal.EnrFacTab", - "VIOSMAFCal.FreqSP", - "VIOSMAFCal.Q_AirInletTab2", - }, - "Injectors": { - "Injector dead time|InjCorrCal.BattCorrTab", - "Injector dead time (Y)|InjCorrCal.BattCorrSP", - "Injector constant|InjCorrCal.InjectorConst", - }, - "Airmass": { - "Pedal request map|PedalMapCal.m_RequestMap", - "Pedal request airmass (Y)|TorqueCal.m_PedYSP", - "Air/Torque calibration|TorqueCal.m_AirTorqMap", - "Air/Torque (X)|TorqueCal.M_EngXSP", - "Nom. torque map|TorqueCal.M_NominalMap", - "Nom. torque map (X)|TorqueCal.m_AirXSP", - }, - "Fuel": { - "VE map|BFuelCal.Map", - "Startup VE map / E85 VE map|BFuelCal.StartMap", - "Gas VE map|BFuelCal.GasMap", - "Enrichment factor during starting|StartCal.EnrFacTab", - "Enrichment factor during starting E85|StartCal.EnrFacE85Tab", - }, - "Ignition": { - "Ignition map|IgnNormCal.Map", - "Ignition for E85|IgnE85Cal.fi_AbsMap", - "Ignition for Gas|IgnNormCal.GasMap", - "Ignition Idle|IgnIdleCal.fi_IdleMap", - "Ignition Start|IgnStartCal.fi_StartMap", - "Knock pull map|IgnKnkCal.IndexMap", - "Max knock pull|KnkFuelCal.fi_MapMaxOff", - }, - "Boost": { - "Boost calibration|BoostCal.RegMap", - "P factor|BoostCal.PMap", - "I factor|BoostCal.IMap", - "D factor|BoostCal.DMap", - }, - "Knock": { - "Knock enrichment|KnkFuelCal.EnrichmentMap", - "Knock sensitivity|KnkDetCal.RefFactorMap", - }, - "Limiters": { - "Airmass (M)|BstKnkCal.MaxAirmass", - "Engine torque (M)|TorqueCal.M_EngMaxTab", - "Engine torque for E85 (M)|TorqueCal.M_EngMaxE85Tab", - "Gear Torque (M)|TorqueCal.M_ManGearLim", - "Gear Torque (5th)|TorqueCal.M_5GearLimTab", - "Airmass (A)|BstKnkCal.MaxAirmassAu", - "Engine torque (A)|TorqueCal.M_EngMaxAutTab", - "Engine torque for E85 (A)|TorqueCal.M_EngMaxE85TabAut", - "RPM limiter|MaxSpdCal.n_EngLimAir", - "Fuel cut|FCutCal.m_AirInletLimit", - "Speed limiter|MaxVehicCal.v_MaxSpeed", - "Overboost|TorqueCal.M_OverBoostTab", - }, - "Adaption": { - "Temp limit for adaption|AdpFuelCal.T_AdaptLim", - "Fuelcut enabled|FCutCal.ST_Enable", - "Closed loop regulation|LambdaCal.ST_Enable", - "Purge enabled|PurgeCal.ST_PurgeEnable", - "Biopower enabled|E85Cal.ST_Enable", - }, - "Myrtilos": { - "Register EU0D", - "MyrtilosCal.Launch_DisableSpeed", - "MyrtilosCal.Launch_Ign_fi_Min", - "MyrtilosCal.Launch_RPM", - "MyrtilosCal.Launch_InjFac_at_rpm", - "MyrtilosCal.Launch_PWM_max_at_stand", - "MyrtilosAdap.WBLambda_FeedbackMap", - "MyrtilosAdap.WBLambda_FFMap", - }, + // {Name: "TransCal."}, + }}, + {Name: "VE map", Data: "BFuelCal.Map"}, + {Name: "Startup VE map / E85 VE map", Data: "BFuelCal.StartMap"}, + {Name: "Gas VE map", Data: "BFuelCal.GasMap"}, + {Name: "Enrichment factor during starting", Data: "StartCal.EnrFacTab"}, + {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Ignition map", Data: "IgnNormCal.Map"}, + {Name: "Ignition for E85", Data: "IgnE85Cal.fi_AbsMap"}, + {Name: "Ignition for Gas", Data: "IgnNormCal.GasMap"}, + {Name: "Ignition Idle", Data: "IgnIdleCal.fi_IdleMap"}, + {Name: "Ignition Start", Data: "IgnStartCal.fi_StartMap"}, + {Name: "Knock pull map", Data: "IgnKnkCal.IndexMap"}, + {Name: "Max knock pull", Data: "KnkFuelCal.fi_MapMaxOff"}, + }}, + {Name: "Boost", Children: []MenuItem{ + {Name: "Boost calibration", Data: "BoostCal.RegMap"}, + {Name: "P factor", Data: "BoostCal.PMap"}, + {Name: "I factor", Data: "BoostCal.IMap"}, + {Name: "D factor", Data: "BoostCal.DMap"}, + }}, + {Name: "Knock", Children: []MenuItem{ + {Name: "Knock enrichment", Data: "KnkFuelCal.EnrichmentMap"}, + {Name: "Knock sensitivity", Data: "KnkDetCal.RefFactorMap"}, + }}, + {Name: "Limiters", Children: []MenuItem{ + {Name: "Manual", Children: []MenuItem{ + {Name: "Airmass (M)", Data: "BstKnkCal.MaxAirmass"}, + {Name: "Engine torque (M)", Data: "TorqueCal.M_EngMaxTab"}, + {Name: "Engine torque for E85 (M)", Data: "TorqueCal.M_EngMaxE85Tab"}, + {Name: "Gear Torque (M)", Data: "TorqueCal.M_ManGearLim"}, + {Name: "Gear Torque (5th)", Data: "TorqueCal.M_5GearLimTab"}, + }}, + {Name: "Automatic", Children: []MenuItem{ + {Name: "Airmass (A)", Data: "BstKnkCal.MaxAirmassAu"}, + {Name: "Engine torque (A)", Data: "TorqueCal.M_EngMaxAutTab"}, + {Name: "Engine torque for E85 (A)", Data: "TorqueCal.M_EngMaxE85TabAut"}, + }}, + {Name: "RPM limiter", Data: "MaxSpdCal.n_EngLimAir"}, + {Name: "Fuel cut", Data: "FCutCal.m_AirInletLimit"}, + {Name: "Speed limiter", Data: "MaxVehicCal.v_MaxSpeed"}, + {Name: "Overboost", Data: "TorqueCal.M_OverBoostTab"}, + }}, + {Name: "Adaption", Children: []MenuItem{ + {Name: "Temp limit for adaption", Data: "AdpFuelCal.T_AdaptLim"}, + {Name: "Fuelcut enabled", Data: "FCutCal.ST_Enable"}, + {Name: "Closed loop regulation", Data: "LambdaCal.ST_Enable"}, + {Name: "Purge enabled", Data: "PurgeCal.ST_PurgeEnable"}, + {Name: "Biopower enabled", Data: "E85Cal.ST_Enable"}, + }}, + {Name: "Myrtilos", Children: []MenuItem{ + {Name: "Register EU0D", Func: mw.openRegisterEU0D}, + {Name: "MyrtilosCal.Launch_DisableSpeed"}, + {Name: "MyrtilosCal.Launch_Ign_fi_Min"}, + {Name: "MyrtilosCal.Launch_RPM"}, + {Name: "MyrtilosCal.Launch_InjFac_at_rpm"}, + {Name: "MyrtilosCal.Launch_PWM_max_at_stand"}, + {Name: "MyrtilosAdap.WBLambda_FeedbackMap"}, + {Name: "MyrtilosAdap.WBLambda_FFMap"}, + }}, + } } diff --git a/pkg/windows/mainmenu_t8.go b/pkg/windows/mainmenu_t8.go index 7c62f361..c57e5abe 100644 --- a/pkg/windows/mainmenu_t8.go +++ b/pkg/windows/mainmenu_t8.go @@ -1,86 +1,77 @@ package windows -var T8SymbolsTuningOrder = []string{ - "Diagnostics", - "Airmass", - "Torque", - "Injectors", - "Fuel", - "Boost", - "Ignition", - "Pedal", -} - -var T8SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - "Edit Parameters", - "Firmware info edit", - }, - "Airmass": { - "Max airmass map (manual)|BstKnkCal.MaxAirmass", - "Max airmass map (auto)|BstKnkCal.MaxAirmassAu", - "Airmass Fuelcut|FCutCal.m_AirInletLimit", - "AirCtrlCal.AirmassLimiter", - "AirCtrlCal.PRatioMaxTab", - }, - "Torque": { - "Nominal torque map|TrqMastCal.Trq_NominalMap", - "Airmass torque map|TrqMastCal.m_AirTorqMap", - "Ambient pressure trq limiter|TrqLimCal.Trq_CompressorNoiseRedLimMAP", - "Trq limit in overboost|TrqLimCal.Trq_OverBoostTab", - "Trq limit manual 150hp|TrqLimCal.Trq_MaxEngineManTab2", - "Trq limit manual 175+hp|TrqLimCal.Trq_MaxEngineManTab1", - "Trq limit auto 150hp|TrqLimCal.Trq_MaxEngineAutTab2", - "Trq limit auto 175+hp|TrqLimCal.Trq_MaxEngineAutTab1", - "Manual gear trq limit|TrqLimCal.Trq_ManGear", - "RPM limiter|MaxEngSpdCal.n_EngLimTab", - "TrqLimCal.Trq_MaxEngineTab1", - "TrqLimCal.Trq_MaxEngineTab2", - "FFTrqCal.FFTrq_MaxEngineTab1", - "FFTrqCal.FFTrq_MaxEngineTab2", - }, - "Injectors": { - "Inj. Constant|InjCorrCal.InjectorConst", - "Inj. dead time|InjCorrCal.BattCorrTab", - "Inj. dead time (Y)|InjCorrCal.BattCorrSP", - }, - "Fuel": { - "Fuel correction map|BFuelCal.LambdaOneFacMap", - "Enrichment Petrol|BFuelCal.TempEnrichFacMap", - "Enrichment E85|FFFuelCal.TempEnrichFacMAP", - "Knock fuel map|KnkFuelCal.EnrichmentMap", - "Injection end angle map|InjAnglCal.Map", - "Jerk enrichment petrol|BFuelCal.m_AirJerkTab", - "Jerk enrichment Fuelmaster|BFuelCal.JerkEnrichFacTab", - "PurgeCal.ST_PurgeEnable", - "LambdaCal.ST_Enable", - "FCutCal.ST_Enable", - "FFFuelCal.ST_enable", - "FuelDynCal.ST_Enable", - "TCompCal.ST_Enable", - }, - "Boost": { - "Boost regulation map|AirCtrlCal.RegMap", - "P factor|AirCtrlCal.Ppart_BoostMap", - "I factor|AirCtrlCal.Ipart_BoostMap", - "D factor|AirCtrlCal.Dpart_BoostMap", - "AirCtrlCal.ST_BoostEnable", - "BoostAdapCal.ST_enable", - "FrompAdapCal.ST_enable", - "AreaAdapCal.ST_enable", - }, - "Ignition": { - "Normal ignition map|IgnAbsCal.fi_NormalMAP", - "High octane map|IgnAbsCal.fi_highOctanMAP", - "Low octane map|IgnAbsCal.fi_lowOctanMAP", - "MBT ignition map|IgnAbsCal.fi_IgnMBTMAP", - "Fuel cut ignition map|IgnAbsCal.fi_FuelCutMAP", - "Startup map|IgnAbsCal.fi_StartMAP", - "IgnAbsCal.ST_EnableOctanMaps", - }, - "Pedal": { - "Pedal position map|TrqMastCal.X_AccPedalMAP", - "Torque request map|PedalMapCal.Trq_RequestMap", - }, +func (mw *MainWindow) t8Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "Edit Parameters", Func: mw.openEditParameters}, + {Name: "Firmware info edit", Func: mw.openFirmwareInfoEdit}, + }}, + {Name: "Airmass", Children: []MenuItem{ + {Name: "Max airmass map (manual)", Data: "BstKnkCal.MaxAirmass"}, + {Name: "Max airmass map (auto)", Data: "BstKnkCal.MaxAirmassAu"}, + {Name: "Airmass Fuelcut", Data: "FCutCal.m_AirInletLimit"}, + {Name: "AirCtrlCal.AirmassLimiter"}, + {Name: "AirCtrlCal.PRatioMaxTab"}, + }}, + {Name: "Torque", Children: []MenuItem{ + {Name: "Nominal torque map", Data: "TrqMastCal.Trq_NominalMap"}, + {Name: "Airmass torque map", Data: "TrqMastCal.m_AirTorqMap"}, + {Name: "Ambient pressure trq limiter", Data: "TrqLimCal.Trq_CompressorNoiseRedLimMAP"}, + {Name: "Trq limit in overboost", Data: "TrqLimCal.Trq_OverBoostTab"}, + {Name: "Trq limit manual 150hp", Data: "TrqLimCal.Trq_MaxEngineManTab2"}, + {Name: "Trq limit manual 175+hp", Data: "TrqLimCal.Trq_MaxEngineManTab1"}, + {Name: "Trq limit auto 150hp", Data: "TrqLimCal.Trq_MaxEngineAutTab2"}, + {Name: "Trq limit auto 175+hp", Data: "TrqLimCal.Trq_MaxEngineAutTab1"}, + {Name: "Manual gear trq limit", Data: "TrqLimCal.Trq_ManGear"}, + {Name: "RPM limiter", Data: "MaxEngSpdCal.n_EngLimTab"}, + {Name: "TrqLimCal.Trq_MaxEngineTab1"}, + {Name: "TrqLimCal.Trq_MaxEngineTab2"}, + {Name: "FFTrqCal.FFTrq_MaxEngineTab1"}, + {Name: "FFTrqCal.FFTrq_MaxEngineTab2"}, + }}, + {Name: "Injectors", Children: []MenuItem{ + {Name: "Inj. Constant", Data: "InjCorrCal.InjectorConst"}, + {Name: "Inj. dead time", Data: "InjCorrCal.BattCorrTab"}, + {Name: "Inj. dead time (Y)", Data: "InjCorrCal.BattCorrSP"}, + }}, + {Name: "Fuel", Children: []MenuItem{ + {Name: "Fuel correction map", Data: "BFuelCal.LambdaOneFacMap"}, + {Name: "Enrichment Petrol", Data: "BFuelCal.TempEnrichFacMap"}, + {Name: "Enrichment E85", Data: "FFFuelCal.TempEnrichFacMAP"}, + {Name: "Knock fuel map", Data: "KnkFuelCal.EnrichmentMap"}, + {Name: "Injection end angle map", Data: "InjAnglCal.Map"}, + {Name: "Jerk enrichment petrol", Data: "BFuelCal.m_AirJerkTab"}, + {Name: "Jerk enrichment Fuelmaster", Data: "BFuelCal.JerkEnrichFacTab"}, + {Name: "PurgeCal.ST_PurgeEnable"}, + {Name: "LambdaCal.ST_Enable"}, + {Name: "FCutCal.ST_Enable"}, + {Name: "FFFuelCal.ST_enable"}, + {Name: "FuelDynCal.ST_Enable"}, + {Name: "TCompCal.ST_Enable"}, + }}, + {Name: "Boost", Children: []MenuItem{ + {Name: "Boost regulation map", Data: "AirCtrlCal.RegMap"}, + {Name: "P factor", Data: "AirCtrlCal.Ppart_BoostMap"}, + {Name: "I factor", Data: "AirCtrlCal.Ipart_BoostMap"}, + {Name: "D factor", Data: "AirCtrlCal.Dpart_BoostMap"}, + {Name: "AirCtrlCal.ST_BoostEnable"}, + {Name: "BoostAdapCal.ST_enable"}, + {Name: "FrompAdapCal.ST_enable"}, + {Name: "AreaAdapCal.ST_enable"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Normal ignition map", Data: "IgnAbsCal.fi_NormalMAP"}, + {Name: "High octane map", Data: "IgnAbsCal.fi_highOctanMAP"}, + {Name: "Low octane map", Data: "IgnAbsCal.fi_lowOctanMAP"}, + {Name: "MBT ignition map", Data: "IgnAbsCal.fi_IgnMBTMAP"}, + {Name: "Fuel cut ignition map", Data: "IgnAbsCal.fi_FuelCutMAP"}, + {Name: "Startup map", Data: "IgnAbsCal.fi_StartMAP"}, + {Name: "IgnAbsCal.ST_EnableOctanMaps"}, + }}, + {Name: "Pedal", Children: []MenuItem{ + {Name: "Pedal position map", Data: "TrqMastCal.X_AccPedalMAP"}, + {Name: "Torque request map", Data: "PedalMapCal.Trq_RequestMap"}, + }}, + } } diff --git a/pkg/windows/mainwindow_selects.go b/pkg/windows/mainwindow_selects.go index 46fe0e45..f2316fc3 100644 --- a/pkg/windows/mainwindow_selects.go +++ b/pkg/windows/mainwindow_selects.go @@ -32,7 +32,7 @@ func (mw *MainWindow) createSelects() { mw.app.Preferences().SetString(prefsSelectedECU, s) idx := symbol.ECUTypeFromString(s) ebus.Publish(ebus.TOPIC_ECU, float64(idx)) - mw.SetMainMenu(mw.menu.GetMenu(s)) + mw.SetMainMenu(mw.GetMenu(s)) pres := mw.app.Preferences().StringWithFallback(s+prefsSelectedPreset, s+" Dash") mw.selects.presetSelect.SetSelected(pres) }) From 9c5a1cf91aaaeb4722acdb0003c49e3b55a4080d Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 19 Jun 2026 16:05:00 +0200 Subject: [PATCH 69/93] update ecusymbol --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3c162f0f..57aa941c 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.2.0 + github.com/roffe/ecusymbol v1.2.1 github.com/roffe/gocan v1.4.1 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index 71a90574..de1065dd 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.2.0 h1:mI5M0HG17gCgbPTbMpIR8nPJGBu4HNZp0133HcPOzYw= -github.com/roffe/ecusymbol v1.2.0/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/ecusymbol v1.2.1 h1:qqeXLT9NX3ckTQneCg/JImQ/3QeBSX+1GtmZ3Y9e7Fw= +github.com/roffe/ecusymbol v1.2.1/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.1 h1:T9aAHzTxS7oXwiOlMIM2TAf75aMHcEaWAi27nKwEFQk= github.com/roffe/gocan v1.4.1/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= From 92f3186c76625cee865974bf94b90930f5f845a0 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:29:05 +0200 Subject: [PATCH 70/93] add button to decr or incr --- pkg/widgets/mapviewer/mapviewer.go | 26 +++++++++++++++++++++++- pkg/widgets/mapviewer/mapviewer_focus.go | 14 ++----------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index c7d203cd..820a2df1 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -203,9 +203,14 @@ func (mv *MapViewer) render() fyne.CanvasObject { buttons := mv.createButtons() + var stepButtons fyne.CanvasObject + if mv.cfg.Editable { + stepButtons = mv.createStepButtons() + } + mapview := container.NewBorder( mv.xAxisLabelContainer, - nil, + stepButtons, mv.yAxisLabelContainer, nil, mv.innerView, @@ -524,6 +529,25 @@ func (mv *MapViewer) setXY() error { return nil } +// stepSelected adjusts every selected cell by one ZPrecision step. sign is +1 +// (incr) or -1 (decr). Same behaviour as the +/- keyboard shortcut. +func (mv *MapViewer) stepSelected(sign float64) { + increment := sign * math.Pow(10, -float64(mv.cfg.ZPrecision)) + for _, cell := range mv.selectedCells { + mv.cfg.ZData[cell] += increment + } + mv.updateCells() + mv.Refresh() +} + +func (mv *MapViewer) createStepButtons() *fyne.Container { + decr := widget.NewButtonWithIcon("Decr", theme.ContentRemoveIcon(), func() { mv.stepSelected(-1) }) + incr := widget.NewButtonWithIcon("Incr", theme.ContentAddIcon(), func() { mv.stepSelected(1) }) + decr.Importance = widget.LowImportance + incr.Importance = widget.LowImportance + return container.NewGridWithColumns(2, decr, incr) +} + func (mv *MapViewer) createButtons() *fyne.Container { noButtons := len(mv.cfg.Buttons) if noButtons > 0 { diff --git a/pkg/widgets/mapviewer/mapviewer_focus.go b/pkg/widgets/mapviewer/mapviewer_focus.go index 690ba9c2..e9c7591d 100644 --- a/pkg/widgets/mapviewer/mapviewer_focus.go +++ b/pkg/widgets/mapviewer/mapviewer_focus.go @@ -123,19 +123,9 @@ func (mv *MapViewer) TypedKey(key *fyne.KeyEvent) { mv.updateCells() refresh = true case "+", "A": - increment := math.Pow(10, -float64(mv.cfg.ZPrecision)) - for _, cell := range mv.selectedCells { - mv.cfg.ZData[cell] += increment - } - mv.updateCells() - refresh = true + mv.stepSelected(1) case "-", "Z": - increment := math.Pow(10, -float64(mv.cfg.ZPrecision)) - for _, cell := range mv.selectedCells { - mv.cfg.ZData[cell] -= increment - } - mv.updateCells() - refresh = true + mv.stepSelected(-1) case "Up": mv.SelectedY++ if mv.SelectedY >= mv.numRows { From f00f7b639bd534e76f18db7893159ab667d60948 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:29:21 +0200 Subject: [PATCH 71/93] multimap --- pkg/windows/mainWindow_menu.go | 158 ++++++++++++++++++++++++++------- pkg/windows/mainmenu.go | 7 ++ pkg/windows/mainmenu_t7.go | 4 +- 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 00f029af..0992aa75 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -13,7 +13,9 @@ import ( "time" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/txlogger/pkg/colors" @@ -222,6 +224,10 @@ func (mw *MainWindow) openPgmMod() { return } symZ := mw.fw.GetByName("Pgm_mod!") + if symZ == nil { + mw.Error(errors.New("Pgm_mod! symbol not found in loaded binary")) + return + } pgm := pgmmod.New() pgm.LoadFunc = func() ([]byte, error) { if mw.dlc != nil { @@ -291,19 +297,17 @@ func (mw *MainWindow) loadBinary() { var openMapLock sync.Mutex -func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) { - if mw.fw == nil { - mw.Error(fmt.Errorf("no binary loaded")) - return - } - +// newMapViewer builds a fully wired MapViewer for a single symbol (file/ECU +// load+save funcs, live X/Y crosshair subscriptions) but does not create a +// window. openMap wraps one; openMultiMap arranges several in a grid. The +// returned cancelFuncs must be called when the containing window closes. +func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string) (*mapviewer.MapViewer, *mapviewer.Config, symbol.Axis, []func(), error) { var axis symbol.Axis if mw.as2 != nil { axis.Z = mapName axes := mw.as2.Axes(mapName) if len(axes) == 0 { - mw.Error(fmt.Errorf("map %q not found in as2 file", mapName)) - return + return nil, nil, axis, nil, fmt.Errorf("map %q not found in as2 file", mapName) } if len(axes) == 1 { axis.Y = axes[0].SupportPoints @@ -327,16 +331,6 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) axis = symbol.GetInfo(typ, mapName) } - windowName := axis.Z - if title != "" { - windowName += " - " + title - } - - if w := mw.wm.HasWindow(windowName); w != nil { - mw.wm.Raise(w) - return - } - symX := mw.fw.GetByName(axis.X) if symX == nil { switch axis.X { @@ -360,8 +354,7 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) } if symZ == nil { - mw.Error(fmt.Errorf("failed to find symbol %s", axis.Z)) - return + return nil, nil, axis, nil, fmt.Errorf("failed to find symbol %s", axis.Z) } var xData, yData, zData []float64 @@ -381,8 +374,7 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) kyltempSteg := mw.fw.GetByName("Kyltemp_steg!") kyltempTab := mw.fw.GetByName("Kyltemp_tab!") if kyltempSteg == nil || kyltempTab == nil { - mw.Error(fmt.Errorf("missing coolant temperature symbols")) - return + return nil, nil, axis, nil, fmt.Errorf("missing coolant temperature symbols") } realTemp := LookupCoolantTemperature(val, kyltempSteg.Ints(), kyltempTab.Ints()) yData[idx] = float64(realTemp) @@ -633,40 +625,124 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) var err error mv, err = mapviewer.New(cfg) + if err != nil { + return nil, nil, axis, nil, err + } + + var cancelFuncs []func() + if axis.XFrom != "" { + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.XFrom, mv.SetX)) + } + if axis.YFrom != "" { + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.YFrom, mv.SetY)) + } + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(value float64) { + mv.SetColorBlindMode(colors.ColorBlindMode(int(value))) + })) + + return mv, cfg, axis, cancelFuncs, nil +} + +func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + + mv, cfg, axis, cancelFuncs, err := mw.newMapViewer(typ, mapName) if err != nil { mw.Error(err) return } + windowName := axis.Z + if title != "" { + windowName += " - " + title + } + if w := mw.wm.HasWindow(windowName); w != nil { + mw.wm.Raise(w) + for _, fn := range cancelFuncs { + fn() + } + return + } + + mapWindow := multiwindow.NewInnerWindow(axis.Z+" - "+axis.ZDescription, mv) + mapWindow.Icon = theme.GridIcon() + + cfg.OnMouseDown = func() { + mw.wm.Raise(mapWindow) + } + + mapWindow.OnClose = func() { + for _, fn := range cancelFuncs { + fn() + } + } + if mw.settings.GetAutoLoad() && mw.dlc != nil { go func() { openMapLock.Lock() defer openMapLock.Unlock() p := progressmodal.New(mw.Window.Canvas(), "Loading "+axis.Z) fyne.DoAndWait(p.Show) - loadRamFunc() + cfg.LoadECUFunc() fyne.Do(p.Hide) }() } - mapWindow := multiwindow.NewInnerWindow(axis.Z+" - "+axis.ZDescription, mv) - mapWindow.Icon = theme.GridIcon() + mw.wm.Add(mapWindow) +} - cfg.OnMouseDown = func() { - mw.wm.Raise(mapWindow) +// openMultiMap opens several maps (data = symbol names joined by "|") tightly +// arranged in one window for an at-a-glance overview, e.g. boost RegMap plus +// P/I/D factors. ponytail: plain 2-column grid of MapViewers, no dedicated +// widget — add one only if these views ever need shared crosshair/selection. +func (mw *MainWindow) openMultiMap(typ symbol.ECUType, title string, data string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if w := mw.wm.HasWindow(title); w != nil { + mw.wm.Raise(w) + return } + grid := container.NewGridWithColumns(2) + var cfgs []*mapviewer.Config + var loadFuncs []func() var cancelFuncs []func() - if axis.XFrom != "" { - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.XFrom, mv.SetX)) + + for _, name := range strings.Split(data, "|") { + mv, cfg, axis, cancels, err := mw.newMapViewer(typ, strings.TrimSpace(name)) + if err != nil { + mw.Error(err) + continue + } + cfgs = append(cfgs, cfg) + cancelFuncs = append(cancelFuncs, cancels...) + if cfg.LoadECUFunc != nil { + loadFuncs = append(loadFuncs, cfg.LoadECUFunc) + } + label := axis.Z + if axis.ZDescription != "" { + label += " - " + axis.ZDescription + } + grid.Add(container.NewBorder(widget.NewLabel(label), nil, nil, nil, mv)) } - if axis.YFrom != "" { - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.YFrom, mv.SetY)) + + if len(grid.Objects) == 0 { + return } - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(value float64) { - mv.SetColorBlindMode(colors.ColorBlindMode(int(value))) - })) + mapWindow := multiwindow.NewInnerWindow(title, grid) + mapWindow.Icon = theme.GridIcon() + + for _, cfg := range cfgs { + cfg.OnMouseDown = func() { + mw.wm.Raise(mapWindow) + } + } mapWindow.OnClose = func() { for _, fn := range cancelFuncs { @@ -674,7 +750,21 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) } } + if mw.settings.GetAutoLoad() && mw.dlc != nil { + go func() { + openMapLock.Lock() + defer openMapLock.Unlock() + p := progressmodal.New(mw.Window.Canvas(), "Loading "+title) + fyne.DoAndWait(p.Show) + for _, fn := range loadFuncs { + fn() + } + fyne.Do(p.Hide) + }() + } + mw.wm.Add(mapWindow) + mapWindow.Resize(fyne.NewSize(1000, 750)) } func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { diff --git a/pkg/windows/mainmenu.go b/pkg/windows/mainmenu.go index 472b0315..c669b2a9 100644 --- a/pkg/windows/mainmenu.go +++ b/pkg/windows/mainmenu.go @@ -1,6 +1,8 @@ package windows import ( + "strings" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" symbol "github.com/roffe/ecusymbol" @@ -54,6 +56,11 @@ func (mw *MainWindow) buildItem(typ symbol.ECUType, item MenuItem) *fyne.MenuIte return itm case item.Func != nil: return fyne.NewMenuItemWithIcon(item.Name, theme.ComputerIcon(), item.Func) + case strings.Contains(item.Data, "|"): + // title + multiple symbols joined by "|": open tightly arranged together + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMultiMap(typ, item.Name, item.Data) + }) case item.Data != "": // title + symbol: open as a map return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index 2dcc4c26..476154a5 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -49,14 +49,13 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "TransCal.AccMulConst"}, {Name: "TransCal.Delay1"}, {Name: "TransCal.Delay2"}, - - // {Name: "TransCal."}, }}, {Name: "VE map", Data: "BFuelCal.Map"}, {Name: "Startup VE map / E85 VE map", Data: "BFuelCal.StartMap"}, {Name: "Gas VE map", Data: "BFuelCal.GasMap"}, {Name: "Enrichment factor during starting", Data: "StartCal.EnrFacTab"}, {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, + {Name: "Enable cloosed loop regulation", Data: "LambdaCal.ST_Enable"}, }}, {Name: "Ignition", Children: []MenuItem{ {Name: "Ignition map", Data: "IgnNormCal.Map"}, @@ -68,6 +67,7 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "Max knock pull", Data: "KnkFuelCal.fi_MapMaxOff"}, }}, {Name: "Boost", Children: []MenuItem{ + {Name: "Boost regulation overview", Data: "BoostCal.RegMap|BoostCal.PMap|BoostCal.IMap|BoostCal.DMap"}, {Name: "Boost calibration", Data: "BoostCal.RegMap"}, {Name: "P factor", Data: "BoostCal.PMap"}, {Name: "I factor", Data: "BoostCal.IMap"}, From e4ee2378787033899000a5b60f32d6b7db88e8cb Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:41:56 +0200 Subject: [PATCH 72/93] toggle 3d mesh from context menu --- pkg/widgets/mapviewer/mapviewer.go | 45 +++++++++++++++--------- pkg/widgets/mapviewer/mapviewer_mouse.go | 6 +++- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 820a2df1..635aaefc 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -71,8 +71,10 @@ type MapViewer struct { selectedX, SelectedY int - mesh *meshgrid.Meshgrid - graph *graph2d.Graph + mesh *meshgrid.Meshgrid + meshSplit *container.Split + meshModeBtn *widget.Button + graph *graph2d.Graph // Mouse mousePos fyne.Position @@ -119,6 +121,21 @@ func New(config *Config) (*MapViewer, error) { return mv, nil } +func (mv *MapViewer) toggleMesh() { + if mv.mesh == nil || mv.meshSplit == nil { + return + } + if mv.mesh.Visible() { + mv.mesh.Hide() + mv.meshModeBtn.Hide() + mv.meshSplit.SetOffset(1) + } else { + mv.mesh.Show() + mv.meshModeBtn.Show() + mv.meshSplit.SetOffset(0.2) + } +} + func (mv *MapViewer) SetColorBlindMode(mode colors.ColorBlindMode) { if mv.colorMode != mode { mv.colorMode = mode @@ -138,9 +155,8 @@ func (mv *MapViewer) CreateRenderer() fyne.WidgetRenderer { mv.createZdata() mv.createSelectionOverlay() mv.createTextValues() - // Start with cell 0,0 selected so keyboard editing works before any click. - mv.selectedCells = []int{0} - mv.drawSelectionVisual() + // Start with nothing selected; a cell is selected on first click/keypress. + mv.selectedCells = nil mv.content = mv.render() return widget.NewSimpleRenderer(mv.content) // return &mapViewerRenderer{mv: mv} @@ -284,23 +300,20 @@ func (mv *MapViewer) render() fyne.CanvasObject { if err == nil { meshModeBtn := widget.NewButtonWithIcon("", theme.GridIcon(), mv.mesh.CycleRenderMode) meshModeBtn.Importance = widget.LowImportance + mv.meshModeBtn = meshModeBtn split := container.NewVSplit( mapview, - container.NewBorder( - nil, - buttons, - nil, - nil, - container.NewStack( - mv.mesh, - container.NewVBox( - container.NewHBox(fynelayout.NewSpacer(), meshModeBtn), - ), + container.NewStack( + mv.mesh, + container.NewVBox( + container.NewHBox(fynelayout.NewSpacer(), meshModeBtn), ), ), ) split.Offset = 0.2 - return split + mv.meshSplit = split + // buttons live outside the split so they stay visible when the mesh is toggled off. + return container.NewBorder(nil, buttons, nil, nil, split) } else { log.Println("MapViewer meshview failed:", err) } diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index 3982ac46..44b21d1c 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -214,7 +214,6 @@ func (mv *MapViewer) showPopupMenu(pos fyne.Position) { }), ) if mv.cfg.Editable { - pasteMenu := fyne.NewMenuItem("Paste", nil) pasteMenu.ChildMenu = fyne.NewMenu("Paste Options", fyne.NewMenuItem("At original position", func() { @@ -232,6 +231,11 @@ func (mv *MapViewer) showPopupMenu(pos fyne.Position) { }), ) } + + if mv.mesh != nil { + menu.Items = append(menu.Items, fyne.NewMenuItem("Toggle 3D Mesh", mv.toggleMesh)) + } + popupMenu := widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(mv), ) From b991ed54e46b72e73fb34ae83104d9e0f2d2d7f3 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:53:26 +0200 Subject: [PATCH 73/93] minimize support --- pkg/widgets/multiwindow/innerwindow.go | 39 +++++++++++++++++- pkg/widgets/multiwindow/layout.go | 4 ++ pkg/widgets/multiwindow/multiplewindows.go | 48 +++++++++++++++++++++- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/pkg/widgets/multiwindow/innerwindow.go b/pkg/widgets/multiwindow/innerwindow.go index d01185dc..3656a74b 100644 --- a/pkg/widgets/multiwindow/innerwindow.go +++ b/pkg/widgets/multiwindow/innerwindow.go @@ -30,6 +30,9 @@ const ( modeIcon ) +// minimizedWidth is the fixed width of a window collapsed into the bottom tray. +const minimizedWidth float32 = 200 + type resizeDirection int const ( @@ -72,11 +75,15 @@ type InnerWindow struct { content *fyne.Container maximized bool + minimized bool active bool preMaximizedSize fyne.Size preMaximizedPos fyne.Position + preMinimizedSize fyne.Size + preMinimizedPos fyne.Position + onClose func() `json:"-"` } @@ -336,6 +343,16 @@ func (i *innerWindowRenderer) Layout(size fyne.Size) { i.bar.Move(fyne.NewPos(padding, 0)) i.bar.Resize(fyne.NewSize(size.Width-doublePadd, barHeight)) + // When minimized only the title bar is shown (tray-style). + if i.win.minimized { + i.contentBG.Hide() + i.win.content.Hide() + i.setBordersVisible(false) + return + } + i.contentBG.Show() + i.win.content.Show() + // Layout main content area contentPos := fyne.NewPos(padding, barHeight) contentDimensions := fyne.NewSize(adjustedWidth, contentSize.Height-padding-barHeight) @@ -347,10 +364,27 @@ func (i *innerWindowRenderer) Layout(size fyne.Size) { // Layout corners if !i.win.DisableResize { + i.setBordersVisible(true) i.layoutCorners(size) } } +func (i *innerWindowRenderer) setBordersVisible(visible bool) { + if i.win.DisableResize { + return + } + for _, b := range []fyne.CanvasObject{ + i.topBorder, i.bottomBorder, i.leftBorder, i.rightBorder, + i.leftTopCorner, i.rightTopCorner, i.leftBottomCorner, i.rightBottomCorner, + } { + if visible { + b.Show() + } else { + b.Hide() + } + } +} + // Helper method to handle corner layout func (i *innerWindowRenderer) layoutCorners(size fyne.Size) { cornerSize := fyne.NewSize(10, 10) @@ -383,8 +417,11 @@ func (i *innerWindowRenderer) layoutCorners(size fyne.Size) { func (i *innerWindowRenderer) MinSize() fyne.Size { th := i.win.Theme() pad := th.Size(theme.SizeNamePadding) - contentMin := i.win.content.MinSize() barHeight := th.Size(theme.SizeNameWindowTitleBarHeight) + if i.win.minimized { + return fyne.NewSize(minimizedWidth, barHeight+pad) + } + contentMin := i.win.content.MinSize() innerWidth := fyne.Max(i.bar.MinSize().Width, contentMin.Width) return fyne.NewSize(innerWidth+pad*2, contentMin.Height+pad+barHeight) } diff --git a/pkg/widgets/multiwindow/layout.go b/pkg/widgets/multiwindow/layout.go index ae63c655..de7e71b3 100644 --- a/pkg/widgets/multiwindow/layout.go +++ b/pkg/widgets/multiwindow/layout.go @@ -3,12 +3,16 @@ package multiwindow import "fyne.io/fyne/v2" type multiWinLayout struct { + mw *MultipleWindows } func (m *multiWinLayout) Layout(objects []fyne.CanvasObject, _ fyne.Size) { for _, w := range objects { // update the windows so they have real size w.Resize(w.MinSize().Max(w.Size())) } + if m.mw != nil { + m.mw.layoutTray() // keep minimized windows docked to the bottom on resize + } } func (m *multiWinLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { diff --git a/pkg/widgets/multiwindow/multiplewindows.go b/pkg/widgets/multiwindow/multiplewindows.go index 201d3e33..ac88d8b8 100644 --- a/pkg/widgets/multiwindow/multiplewindows.go +++ b/pkg/widgets/multiwindow/multiplewindows.go @@ -52,7 +52,7 @@ func NewMultipleWindows(wins ...*InnerWindow) *MultipleWindows { } func (m *MultipleWindows) CreateRenderer() fyne.WidgetRenderer { - m.content = container.New(&multiWinLayout{}) + m.content = container.New(&multiWinLayout{mw: m}) m.refreshChildren() return widget.NewSimpleRenderer(m.content) } @@ -191,6 +191,26 @@ func (m *MultipleWindows) raise(w *InnerWindow) { m.refreshChildren() } +// layoutTray docks every minimized window into a row along the bottom edge, +// mimicking a taskbar/system tray. +func (m *MultipleWindows) layoutTray() { + if m.content == nil { + return + } + const margin float32 = 5 + x := margin + bottom := m.content.Size().Height + for _, w := range m.windows { + if !w.minimized { + continue + } + size := w.MinSize() + w.Resize(size) + w.Move(fyne.NewPos(x, bottom-size.Height-margin)) + x += size.Width + margin + } +} + func (m *MultipleWindows) refreshChildren() { if m.content == nil { return @@ -316,7 +336,23 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { } w.OnTappedBar = func() { - //m.Raise(w) + if w.minimized { + w.OnMinimized() // single click on a tray bar restores it + } + } + + w.OnMinimized = func() { + w.minimized = !w.minimized + if w.minimized { + w.preMinimizedSize = w.Size() + w.preMinimizedPos = w.Position() + } else { + w.Resize(w.preMinimizedSize) + w.Move(w.preMinimizedPos) + m.Raise(w) + } + w.Refresh() + m.layoutTray() } w.OnMouseDown = func() { @@ -330,6 +366,10 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { } w.OnMaximized = func() { + if w.minimized { + w.OnMinimized() // restore from tray instead of maximizing + return + } if !w.maximized { w.preMaximizedSize = w.Size() w.preMaximizedPos = w.Position() @@ -414,6 +454,10 @@ func (wm *MultipleWindows) JsonLayout() ([]byte, error) { } pos := w.Position() size := w.Size() + if w.minimized { // save real geometry, not the tiny tray bar + pos = w.preMinimizedPos + size = w.preMinimizedSize + } preMaxPos := w.PreMaximizedPos() preMaxSize := w.PreMaximizedSize() From 02cb2fe6fcc0f370dce834a2af735a697cddb89e Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:53:34 +0200 Subject: [PATCH 74/93] cache borders --- pkg/widgets/multiwindow/innerwindow.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/pkg/widgets/multiwindow/innerwindow.go b/pkg/widgets/multiwindow/innerwindow.go index 3656a74b..559c53c4 100644 --- a/pkg/widgets/multiwindow/innerwindow.go +++ b/pkg/widgets/multiwindow/innerwindow.go @@ -241,6 +241,7 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { var leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner *draggableCorner var topBorder, bottomBorder, leftBorder, rightBorder *draggableBorder + var borders []fyne.CanvasObject objects := []fyne.CanvasObject{w.bg, contentBG, bar, w.content} @@ -254,8 +255,8 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { leftBottomCorner = newDraggableCorner(w, resizeDownLeft) rightBottomCorner = newDraggableCorner(w, resizeDownRight) - // objects = append(objects, leftCorner, rightCorner) - objects = append(objects, topBorder, bottomBorder, leftBorder, rightBorder, leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner) + borders = []fyne.CanvasObject{topBorder, bottomBorder, leftBorder, rightBorder, leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner} + objects = append(objects, borders...) } r := &innerWindowRenderer{ @@ -273,6 +274,7 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { rightTopCorner: rightTopCorner, leftBottomCorner: leftBottomCorner, rightBottomCorner: rightBottomCorner, + borders: borders, contentBG: contentBG} r.Layout(w.Size()) return r @@ -322,6 +324,8 @@ type innerWindowRenderer struct { leftBottomCorner fyne.CanvasObject rightBottomCorner fyne.CanvasObject + borders []fyne.CanvasObject // all border/corner handles, for show/hide + *ShadowingRenderer } @@ -370,13 +374,7 @@ func (i *innerWindowRenderer) Layout(size fyne.Size) { } func (i *innerWindowRenderer) setBordersVisible(visible bool) { - if i.win.DisableResize { - return - } - for _, b := range []fyne.CanvasObject{ - i.topBorder, i.bottomBorder, i.leftBorder, i.rightBorder, - i.leftTopCorner, i.rightTopCorner, i.leftBottomCorner, i.rightBottomCorner, - } { + for _, b := range i.borders { // empty when DisableResize if visible { b.Show() } else { From 1029260c4addbb71ba14e1754504f34ea517751e Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 01:53:43 +0200 Subject: [PATCH 75/93] fix possible crash --- pkg/widgets/multiwindow/arrange.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/widgets/multiwindow/arrange.go b/pkg/widgets/multiwindow/arrange.go index 60c74f51..9e6d517f 100644 --- a/pkg/widgets/multiwindow/arrange.go +++ b/pkg/widgets/multiwindow/arrange.go @@ -75,6 +75,9 @@ func (f *FloatingArranger) Layout(maxSize fyne.Size, confined bool, windows []*I (maxSize.Width-initOffset)/20, (maxSize.Height-initOffset)/20, )) + if maxSteps < 1 { + maxSteps = 1 // ponytail: guard i%maxSteps panic when viewport is tiny/unsized + } for i, window := range windows { step := i % maxSteps From a7dd4a466a662126189e749eba55f0e5714d0a11 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 02:25:33 +0200 Subject: [PATCH 76/93] restructure toolbar and menu some --- pkg/windows/mainWindow.go | 8 +- pkg/windows/mainWindow_menu.go | 23 ++++++ pkg/windows/mainWindow_toolbar.go | 120 ++++++++++++++---------------- 3 files changed, 79 insertions(+), 72 deletions(-) diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 7d9a01e9..91ca4c2f 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -277,13 +277,7 @@ func (mw *MainWindow) render() { footer := container.NewBorder( nil, nil, - container.NewBorder( - nil, - nil, - nil, - mw.buttons.layoutRefreshBtn, - mw.selects.layoutSelect, - ), + nil, container.NewHBox( container.NewHBox( mw.gocanGatewayLED, diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 0992aa75..b44b6136 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -23,6 +23,7 @@ import ( "github.com/roffe/txlogger/pkg/ecu/t8/t8file" "github.com/roffe/txlogger/pkg/update" "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/dtcreader" "github.com/roffe/txlogger/pkg/widgets/editparameters" "github.com/roffe/txlogger/pkg/widgets/mapviewer" @@ -124,6 +125,9 @@ func (mw *MainWindow) setupMenu() { update.UpdateCheck(mw.app, mw.Window) }), ), + fyne.NewMenu("Tools", + fyne.NewMenuItemWithIcon("Matrix Builder", theme.InfoIcon(), mw.openMatrixBuilder), + ), } trailing := []*fyne.Menu{ @@ -143,6 +147,25 @@ func (mw *MainWindow) setupMenu() { ), } + if mw.previewFeatures { + leading[len(leading)-1].Items = append( + leading[len(leading)-1].Items, + fyne.NewMenuItemWithIcon("Canflasher", theme.UploadIcon(), func() { + if w := mw.wm.HasWindow("Canflasher"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Canflasher", canflasher.New(&canflasher.Config{ + CSW: mw.settings, + })) + inner.Icon = theme.UploadIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(450, 250)) + }), + fyne.NewMenuItemWithIcon("Boost Auto-Tuner", theme.MediaFastForwardIcon(), mw.openBoostTuner), + ) + } + mw.leadingMenus = leading mw.trailingMenus = trailing } diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index 19c8df96..cf1c9513 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -6,10 +6,10 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/widgets/boosttuner" - "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -85,83 +85,73 @@ func (mw *MainWindow) newToolbar() *fyne.Container { widget.NewSeparator(), mw.buttons.symbolListBtn, mw.buttons.logBtn, - mw.buttons.dashboardBtn, mw.buttons.livePlotBtn, - widget.NewButtonWithIcon("Matrix", theme.GridIcon(), mw.openMatrixBuilder), + mw.buttons.dashboardBtn, + mw.buttons.addGaugeBtn, widget.NewButtonWithIcon("", theme.GridIcon(), func() { mw.wm.Arrange(&multiwindow.GridArranger{}) }), - mw.buttons.addGaugeBtn, - widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { - mw.wm.CloseAll() - }), + widget.NewButtonWithIcon("", theme.ContentClearIcon(), mw.wm.CloseAll), + layout.NewSpacer(), + container.NewBorder( + nil, + nil, + nil, + mw.buttons.layoutRefreshBtn, + mw.selects.layoutSelect, + ), ) - if mw.previewFeatures { - toolbar.Add(widget.NewButtonWithIcon("", theme.UploadIcon(), func() { - if w := mw.wm.HasWindow("Canflasher"); w != nil { + //if mw.previewFeatures { + /* + toolbar.Add(widget.NewButtonWithIcon("", theme.DocumentIcon(), func() { + if w := mw.wm.HasWindow("txweb"); w != nil { mw.wm.Raise(w) return } - inner := multiwindow.NewInnerWindow("Canflasher", canflasher.New(&canflasher.Config{ - CSW: mw.settings, - })) - inner.Icon = theme.UploadIcon() + txb := txweb.New() + txb.LoadFileFunc = func(name string, data []byte) error { + switch filepath.Ext(name) { + case ".bin": + if err := mw.LoadSymbolsFromBytes(name, data); err != nil { + return err + } + return nil + case ".csv": // ".t5l", ".t7l", ".t8l", + mw.LoadLogfile(name, bytes.NewReader(data), fyne.NewPos(100, 100)) + return nil + } + return nil + } + inner := multiwindow.NewInnerWindow("txweb", txb) + inner.Icon = theme.FileApplicationIcon() mw.wm.Add(inner) - inner.Resize(fyne.NewSize(450, 250)) + inner.Resize(fyne.NewSize(700, 500)) }), ) + */ - toolbar.Add(widget.NewButtonWithIcon("Boost", theme.MediaFastForwardIcon(), mw.openBoostTuner)) - /* - toolbar.Add(widget.NewButtonWithIcon("", theme.DocumentIcon(), func() { - if w := mw.wm.HasWindow("txweb"); w != nil { - mw.wm.Raise(w) - return - } - txb := txweb.New() - txb.LoadFileFunc = func(name string, data []byte) error { - switch filepath.Ext(name) { - case ".bin": - if err := mw.LoadSymbolsFromBytes(name, data); err != nil { - return err - } - return nil - case ".csv": // ".t5l", ".t7l", ".t8l", - mw.LoadLogfile(name, bytes.NewReader(data), fyne.NewPos(100, 100)) - return nil - } - return nil - } - inner := multiwindow.NewInnerWindow("txweb", txb) - inner.Icon = theme.FileApplicationIcon() - mw.wm.Add(inner) - inner.Resize(fyne.NewSize(700, 500)) - }), + /* + widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { + if w := mw.wm.HasWindow("Map"); w != nil { + mw.wm.Raise(w) + return + } + mapp := maps.NewMap() + cnt := container.NewBorder( + nil, + widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { + mapp.SetCenter(59.644810, 17.058252) + }), + nil, + nil, + mapp, ) - */ - - /* - widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { - if w := mw.wm.HasWindow("Map"); w != nil { - mw.wm.Raise(w) - return - } - mapp := maps.NewMap() - cnt := container.NewBorder( - nil, - widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { - mapp.SetCenter(59.644810, 17.058252) - }), - nil, - nil, - mapp, - ) - inner := multiwindow.NewInnerWindow("Map", cnt) - inner.Icon = theme.NavigateNextIcon() - mw.wm.Add(inner) - }), - */ - } + inner := multiwindow.NewInnerWindow("Map", cnt) + inner.Icon = theme.NavigateNextIcon() + mw.wm.Add(inner) + }), + */ + //} return toolbar } From bb8619b3af3710abe3c3aead6e57cd37d4cf461c Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 02:25:43 +0200 Subject: [PATCH 77/93] fix bug when toggling 3d mesh --- pkg/widgets/meshgrid/meshgrid_render_test.go | 27 ++++++++++++++++++++ pkg/widgets/meshgrid/meshgrid_widget.go | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go index 5dad007e..b4602425 100644 --- a/pkg/widgets/meshgrid/meshgrid_render_test.go +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -2,6 +2,7 @@ package meshgrid import ( "image/png" + "math" "os" "testing" @@ -43,6 +44,32 @@ func axisValues(cols, rows int) (xData, yData []float64) { return xData, yData } +// TestToggleZoomStable guards the regression where toggling the mesh off (the +// split pane collapses to zero height) and back on made it creep more zoomed-in +// each cycle: a degenerate size must not become the layout baseline. +func TestToggleZoomStable(t *testing.T) { + m := testGrid(t) + m.size = fyne.Size{} // force the first Layout to fit + m.refreshPending = true // suppress the async throttledRefresh in tests + r := &meshgridRenderer{MG: m} + + full := fyne.NewSize(800, 500) + r.Layout(full) // initial auto-fit + want := m.scale + + // One toggle cycle as Fyne actually lays it out: off collapses the pane to + // zero height, on restores it through a non-zero intermediate. The zero frame + // must not become the baseline, or the restore over-grows the scale. + for i := 0; i < 5; i++ { + r.Layout(fyne.NewSize(800, 0)) // off: pane collapses to zero + r.Layout(fyne.NewSize(800, 100)) // on: pane reappears small + r.Layout(full) // on: pane expands to full + } + if math.Abs(m.scale-want)/want > 1e-9 { + t.Fatalf("scale drifted after toggles: got %v want %v", m.scale, want) + } +} + // TestRenderRotated renders an asymmetric surface (tall corner spike) from // four yaw angles so painter's-order mistakes show up as the spike being // overdrawn by cells that are behind it. diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 9c330c0a..c56d3129 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -674,6 +674,12 @@ func (m *meshgridRenderer) Layout(size fyne.Size) { if size == m.MG.size { return } + // A collapsed split pane (toggling the mesh off) lays us out at zero height. + // Letting that become the baseline breaks adaptZoom's reversible scaling and + // the mesh creeps more zoomed-in on every toggle, so keep the last good size. + if size.Width <= 0 || size.Height <= 0 { + return + } oldSize := m.MG.size m.MG.size = size // Auto-fit on the first real size and scale with the window thereafter, From 0ff8cbc92d26ac06925b34a760b57f580a877e38 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 02:44:50 +0200 Subject: [PATCH 78/93] symbolcompare --- pkg/widgets/symbolcompare/symbolcompare.go | 78 ++++++++ pkg/windows/mainWindow_compare.go | 217 +++++++++++++++++++++ pkg/windows/mainWindow_menu.go | 1 + 3 files changed, 296 insertions(+) create mode 100644 pkg/widgets/symbolcompare/symbolcompare.go create mode 100644 pkg/windows/mainWindow_compare.go diff --git a/pkg/widgets/symbolcompare/symbolcompare.go b/pkg/widgets/symbolcompare/symbolcompare.go new file mode 100644 index 00000000..9a5d310a --- /dev/null +++ b/pkg/widgets/symbolcompare/symbolcompare.go @@ -0,0 +1,78 @@ +package symbolcompare + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// Config drives the compare list. Diffs is the (already sorted) list of symbol +// names that differ between the two binaries. The callbacks open the actual +// map views, which live in the windows package so they can reuse MapViewer. +type Config struct { + Diffs []string + OnShowDiff func(name string) + OnShowSideBySide func(name string) +} + +// New returns a list of differing symbols. Double-click a row to compare it side +// by side; right-click for a context menu to show the differences map. +func New(cfg *Config) fyne.CanvasObject { + list := widget.NewList( + func() int { return len(cfg.Diffs) }, + func() fyne.CanvasObject { return newCompareRow(cfg) }, + func(i widget.ListItemID, o fyne.CanvasObject) { + o.(*compareRow).setName(cfg.Diffs[i]) + }, + ) + + header := widget.NewLabel(fmt.Sprintf("%d symbols differ", len(cfg.Diffs))) + return container.NewBorder(header, nil, nil, nil, list) +} + +// compareRow is a list row that reacts to double-tap and right-click. It does +// not implement Tapped, so single taps still propagate to the List for +// selection highlighting. +type compareRow struct { + widget.BaseWidget + label *widget.Label + name string + cfg *Config +} + +func newCompareRow(cfg *Config) *compareRow { + r := &compareRow{label: widget.NewLabel(""), cfg: cfg} + r.ExtendBaseWidget(r) + return r +} + +func (r *compareRow) setName(name string) { + r.name = name + r.label.SetText(name) +} + +func (r *compareRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(r.label) +} + +func (r *compareRow) DoubleTapped(_ *fyne.PointEvent) { + if r.name != "" && r.cfg.OnShowSideBySide != nil { + r.cfg.OnShowSideBySide(r.name) + } +} + +func (r *compareRow) TappedSecondary(e *fyne.PointEvent) { + if r.name == "" || r.cfg.OnShowDiff == nil { + return + } + c := fyne.CurrentApp().Driver().CanvasForObject(r) + if c == nil { + return + } + menu := fyne.NewMenu("", fyne.NewMenuItem("Show differences map", func() { + r.cfg.OnShowDiff(r.name) + })) + widget.NewPopUpMenu(menu, c).ShowAtPosition(e.AbsolutePosition) +} diff --git a/pkg/windows/mainWindow_compare.go b/pkg/windows/mainWindow_compare.go new file mode 100644 index 00000000..331ee443 --- /dev/null +++ b/pkg/windows/mainWindow_compare.go @@ -0,0 +1,217 @@ +package windows + +import ( + "bytes" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + symbol "github.com/roffe/ecusymbol" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/multiwindow" + "github.com/roffe/txlogger/pkg/widgets/symbolcompare" +) + +// openSymbolCompare lets the user pick a second binary and lists every symbol +// whose data differs from the currently loaded one. +func (mw *MainWindow) openSymbolCompare() { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if mw.dlc != nil { + mw.Error(errors.New("stop logging before comparing binaries")) + return + } + widgets.SelectFile(func(r fyne.URIReadCloser) { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil { + mw.Error(err) + return + } + filename := filepath.Base(r.URI().Path()) + otherEcu, other, err := symbol.Load(filename, data, mw.Log) + if err != nil { + mw.Error(fmt.Errorf("failed to load %s: %w", filename, err)) + return + } + typ := symbol.ECUTypeFromString(mw.selects.ecuSelect.Selected) + if otherEcu != typ { + mw.Error(fmt.Errorf("ECU type mismatch: current is %s, %s is %s", typ, filename, otherEcu)) + return + } + mw.showSymbolCompare(typ, filename, other) + }, "Binary file", "bin") +} + +func (mw *MainWindow) showSymbolCompare(typ symbol.ECUType, otherName string, other symbol.SymbolCollection) { + var diffs []string + for _, s := range mw.fw.Symbols() { + o := other.GetByName(s.Name) + if o == nil { + continue // ponytail: only symbols present in both; added/removed skipped + } + if !bytes.Equal(s.Bytes(), o.Bytes()) { + diffs = append(diffs, s.Name) + } + } + sort.Strings(diffs) + + if len(diffs) == 0 { + dialog.ShowInformation("No differences", "The two binaries have identical symbol data", mw) + return + } + + cmp := symbolcompare.New(&symbolcompare.Config{ + Diffs: diffs, + OnShowDiff: func(name string) { mw.openCompareDiff(typ, otherName, other, name) }, + OnShowSideBySide: func(name string) { mw.openCompareTabs(typ, otherName, other, name) }, + }) + inner := multiwindow.NewInnerWindow("Compare with "+otherName, cmp) + inner.Icon = theme.SearchReplaceIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(400, 600)) +} + +// openCompareTabs shows the current and other binary's version of a map in two +// tabs. ponytail: native AppTabs, no custom multi-map wrapper widget. +func (mw *MainWindow) openCompareTabs(typ symbol.ECUType, otherName string, other symbol.SymbolCollection, mapName string) { + winName := mapName + " - compare" + if w := mw.wm.HasWindow(winName); w != nil { + mw.wm.Raise(w) + return + } + cur, err := mw.compareMapViewer(mw.fw, typ, mapName) + if err != nil { + mw.Error(err) + return + } + oth, err := mw.compareMapViewer(other, typ, mapName) + if err != nil { + mw.Error(err) + return + } + tabs := container.NewAppTabs( + container.NewTabItem("Current", cur), + container.NewTabItem(otherName, oth), + ) + inner := multiwindow.NewInnerWindow(winName, tabs) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(900, 700)) +} + +// openCompareDiff shows a single read-only map of (current - other) per cell. +func (mw *MainWindow) openCompareDiff(typ symbol.ECUType, otherName string, other symbol.SymbolCollection, mapName string) { + winName := mapName + " - diff" + if w := mw.wm.HasWindow(winName); w != nil { + mw.wm.Raise(w) + return + } + axis, xData, yData, curZ, xPrec, yPrec, zPrec, err := compareMapData(mw.fw, typ, mapName) + if err != nil { + mw.Error(err) + return + } + _, _, _, othZ, _, _, _, err := compareMapData(other, typ, mapName) + if err != nil { + mw.Error(err) + return + } + if len(curZ) != len(othZ) { + mw.Error(fmt.Errorf("%s has different dimensions in the two binaries (%d vs %d)", mapName, len(curZ), len(othZ))) + return + } + diff := make([]float64, len(curZ)) + for i := range curZ { + diff[i] = curZ[i] - othZ[i] + } + mv, err := mw.readonlyMapViewer(mapName, "Δ "+axis.ZDescription, axis, xData, yData, diff, xPrec, yPrec, zPrec) + if err != nil { + mw.Error(err) + return + } + inner := multiwindow.NewInnerWindow(winName+" (current - "+otherName+")", mv) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(800, 600)) +} + +// compareMapViewer builds a read-only MapViewer for mapName from an arbitrary +// collection. Unlike newMapViewer it has no file/ECU load+save wiring. +func (mw *MainWindow) compareMapViewer(coll symbol.SymbolCollection, typ symbol.ECUType, mapName string) (*mapviewer.MapViewer, error) { + axis, x, y, z, xp, yp, zp, err := compareMapData(coll, typ, mapName) + if err != nil { + return nil, err + } + return mw.readonlyMapViewer(mapName, axis.ZDescription, axis, x, y, z, xp, yp, zp) +} + +func (mw *MainWindow) readonlyMapViewer(name, zLabel string, axis symbol.Axis, xData, yData, zData []float64, xPrec, yPrec, zPrec int) (*mapviewer.MapViewer, error) { + return mapviewer.New(&mapviewer.Config{ + Name: name, + XData: xData, + YData: yData, + ZData: zData, + XPrecision: xPrec, + YPrecision: yPrec, + ZPrecision: zPrec, + XLabel: axis.XDescription, + YLabel: axis.YDescription, + ZLabel: zLabel, + MeshView: mw.settings.GetMeshView(), + MeshRenderer: mw.settings.GetMeshRenderer(), + Editable: false, + ColorblindMode: mw.settings.GetColorBlindMode(), + }) +} + +// compareMapData resolves a map's axes + data from one collection. A trimmed +// version of newMapViewer's resolution: no as2, no T5 coolant special-case. +func compareMapData(coll symbol.SymbolCollection, typ symbol.ECUType, mapName string) (axis symbol.Axis, xData, yData, zData []float64, xPrec, yPrec, zPrec int, err error) { + axis = symbol.GetInfo(typ, mapName) + + symX := coll.GetByName(axis.X) + if symX == nil && axis.X == "BstKnkCal.fi_offsetXSP" { + symX = coll.GetByName("BstKnkCal.OffsetXSP") + } + symY := coll.GetByName(axis.Y) + symZ := coll.GetByName(axis.Z) + if symZ == nil { + err = fmt.Errorf("symbol %s not found", axis.Z) + return + } + + zData = symZ.Float64s() + zPrec = symbol.GetPrecision(symZ.Correctionfactor) + + if symX != nil { + xData = symX.Float64s() + xPrec = symbol.GetPrecision(symX.Correctionfactor) + } else { + xData = []float64{0} + } + + switch { + case symY != nil: + yData = symY.Float64s() + yPrec = symbol.GetPrecision(symY.Correctionfactor) + case len(xData) <= 1 && len(zData) > 1: + // 1xN column with no Y axis: index it so the viewer can lay it out + yData = make([]float64, len(zData)) + for i := range yData { + yData[i] = float64(i) + } + default: + yData = []float64{0} + } + return +} diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index b44b6136..4d741af1 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -126,6 +126,7 @@ func (mw *MainWindow) setupMenu() { }), ), fyne.NewMenu("Tools", + fyne.NewMenuItemWithIcon("Compare symbols with other binary", theme.SearchReplaceIcon(), mw.openSymbolCompare), fyne.NewMenuItemWithIcon("Matrix Builder", theme.InfoIcon(), mw.openMatrixBuilder), ), } From e44b0d309a9b707c68e85e882749c855e6820eee Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 13:16:47 +0200 Subject: [PATCH 79/93] symbolcompare tweaking --- pkg/assets/WHATSNEW.md | 3 +- pkg/widgets/symbolcompare/symbolcompare.go | 27 +++-------- pkg/windows/mainWindow_compare.go | 55 +++++++--------------- 3 files changed, 24 insertions(+), 61 deletions(-) diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index c71a2f1e..f31f4aaf 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -1,4 +1,5 @@ # 2.1.10 +- Added "Compare symbols with other binary" under the Tools menu. Pick a second binary of the same ECU type and get a list of every symbol whose data differs from the currently loaded one. Double-click a row to open that map in three tabs: Current, the other binary, and a Diff tab showing the per-cell difference (current - other). Logging must be stopped while comparing - Cut a new logfile from a selection in the logplayer: scrub to a spot and press "In" (or the `i` key) to mark the start, scrub again and press "Out" (or `o`) to mark the end, then press the save button to write just that range to a new log next to your other logs. The clip keeps the same format as the source log (csv/bpl/t5l/t7l/t8l). Leaving the In or Out point unset selects from the start or to the end of the log - Live tracking marker in the 3d mesh viewer showing where the ECU is reading from, mirroring the crosshair in the map above - Fixed the 3d mesh showing one cell less than the table in each direction; values are now cell-centered so an 18x16 map renders 18x16 cells @@ -15,7 +16,7 @@ - WBL reconnect COM port while logging. If the COM port dies for a reason during logging it will try to re-connect - Performance improvements in many widget to allow slower computers to run txlogger better - Improved camera handling in the 3d mesh viewer - now behaves like the t5/7/8 suites -- Added 2d graph for viewing flat maps +- Added 2D graph for viewing flat maps - Rewrote logplayer plotter to use about 50% less CPU on zoomed out views - Big refactor of the log writing logic to be simpler to maintain and be more performant - Improved cell selection in mapviewer diff --git a/pkg/widgets/symbolcompare/symbolcompare.go b/pkg/widgets/symbolcompare/symbolcompare.go index 9a5d310a..a71680cc 100644 --- a/pkg/widgets/symbolcompare/symbolcompare.go +++ b/pkg/widgets/symbolcompare/symbolcompare.go @@ -9,16 +9,15 @@ import ( ) // Config drives the compare list. Diffs is the (already sorted) list of symbol -// names that differ between the two binaries. The callbacks open the actual -// map views, which live in the windows package so they can reuse MapViewer. +// names that differ between the two binaries. OnShowSideBySide opens the actual +// map view, which lives in the windows package so it can reuse MapViewer. type Config struct { Diffs []string - OnShowDiff func(name string) OnShowSideBySide func(name string) } // New returns a list of differing symbols. Double-click a row to compare it side -// by side; right-click for a context menu to show the differences map. +// by side. func New(cfg *Config) fyne.CanvasObject { list := widget.NewList( func() int { return len(cfg.Diffs) }, @@ -32,9 +31,9 @@ func New(cfg *Config) fyne.CanvasObject { return container.NewBorder(header, nil, nil, nil, list) } -// compareRow is a list row that reacts to double-tap and right-click. It does -// not implement Tapped, so single taps still propagate to the List for -// selection highlighting. +// compareRow is a list row that reacts to double-tap. It does not implement +// Tapped, so single taps still propagate to the List for selection +// highlighting. type compareRow struct { widget.BaseWidget label *widget.Label @@ -62,17 +61,3 @@ func (r *compareRow) DoubleTapped(_ *fyne.PointEvent) { r.cfg.OnShowSideBySide(r.name) } } - -func (r *compareRow) TappedSecondary(e *fyne.PointEvent) { - if r.name == "" || r.cfg.OnShowDiff == nil { - return - } - c := fyne.CurrentApp().Driver().CanvasForObject(r) - if c == nil { - return - } - menu := fyne.NewMenu("", fyne.NewMenuItem("Show differences map", func() { - r.cfg.OnShowDiff(r.name) - })) - widget.NewPopUpMenu(menu, c).ShowAtPosition(e.AbsolutePosition) -} diff --git a/pkg/windows/mainWindow_compare.go b/pkg/windows/mainWindow_compare.go index 331ee443..259dbcb7 100644 --- a/pkg/windows/mainWindow_compare.go +++ b/pkg/windows/mainWindow_compare.go @@ -72,7 +72,6 @@ func (mw *MainWindow) showSymbolCompare(typ symbol.ECUType, otherName string, ot cmp := symbolcompare.New(&symbolcompare.Config{ Diffs: diffs, - OnShowDiff: func(name string) { mw.openCompareDiff(typ, otherName, other, name) }, OnShowSideBySide: func(name string) { mw.openCompareTabs(typ, otherName, other, name) }, }) inner := multiwindow.NewInnerWindow("Compare with "+otherName, cmp) @@ -81,78 +80,56 @@ func (mw *MainWindow) showSymbolCompare(typ symbol.ECUType, otherName string, ot inner.Resize(fyne.NewSize(400, 600)) } -// openCompareTabs shows the current and other binary's version of a map in two -// tabs. ponytail: native AppTabs, no custom multi-map wrapper widget. +// openCompareTabs shows the current and other binary's version of a map plus a +// per-cell diff in three tabs. ponytail: native AppTabs, no custom wrapper. func (mw *MainWindow) openCompareTabs(typ symbol.ECUType, otherName string, other symbol.SymbolCollection, mapName string) { winName := mapName + " - compare" if w := mw.wm.HasWindow(winName); w != nil { mw.wm.Raise(w) return } - cur, err := mw.compareMapViewer(mw.fw, typ, mapName) + axis, xData, yData, curZ, xPrec, yPrec, zPrec, err := compareMapData(mw.fw, typ, mapName) if err != nil { mw.Error(err) return } - oth, err := mw.compareMapViewer(other, typ, mapName) + _, _, _, othZ, _, _, _, err := compareMapData(other, typ, mapName) if err != nil { mw.Error(err) return } - tabs := container.NewAppTabs( - container.NewTabItem("Current", cur), - container.NewTabItem(otherName, oth), - ) - inner := multiwindow.NewInnerWindow(winName, tabs) - inner.Icon = theme.GridIcon() - mw.wm.Add(inner) - inner.Resize(fyne.NewSize(900, 700)) -} - -// openCompareDiff shows a single read-only map of (current - other) per cell. -func (mw *MainWindow) openCompareDiff(typ symbol.ECUType, otherName string, other symbol.SymbolCollection, mapName string) { - winName := mapName + " - diff" - if w := mw.wm.HasWindow(winName); w != nil { - mw.wm.Raise(w) + if len(curZ) != len(othZ) { + mw.Error(fmt.Errorf("%s has different dimensions in the two binaries (%d vs %d)", mapName, len(curZ), len(othZ))) return } - axis, xData, yData, curZ, xPrec, yPrec, zPrec, err := compareMapData(mw.fw, typ, mapName) + cur, err := mw.readonlyMapViewer(mapName, axis.ZDescription, axis, xData, yData, curZ, xPrec, yPrec, zPrec) if err != nil { mw.Error(err) return } - _, _, _, othZ, _, _, _, err := compareMapData(other, typ, mapName) + oth, err := mw.readonlyMapViewer(mapName, axis.ZDescription, axis, xData, yData, othZ, xPrec, yPrec, zPrec) if err != nil { mw.Error(err) return } - if len(curZ) != len(othZ) { - mw.Error(fmt.Errorf("%s has different dimensions in the two binaries (%d vs %d)", mapName, len(curZ), len(othZ))) - return - } diff := make([]float64, len(curZ)) for i := range curZ { diff[i] = curZ[i] - othZ[i] } - mv, err := mw.readonlyMapViewer(mapName, "Δ "+axis.ZDescription, axis, xData, yData, diff, xPrec, yPrec, zPrec) + diffMv, err := mw.readonlyMapViewer(mapName, "Δ "+axis.ZDescription, axis, xData, yData, diff, xPrec, yPrec, zPrec) if err != nil { mw.Error(err) return } - inner := multiwindow.NewInnerWindow(winName+" (current - "+otherName+")", mv) + tabs := container.NewAppTabs( + container.NewTabItem("Diff", diffMv), + container.NewTabItem("Current", cur), + container.NewTabItem(otherName, oth), + ) + inner := multiwindow.NewInnerWindow(winName, tabs) inner.Icon = theme.GridIcon() mw.wm.Add(inner) - inner.Resize(fyne.NewSize(800, 600)) -} - -// compareMapViewer builds a read-only MapViewer for mapName from an arbitrary -// collection. Unlike newMapViewer it has no file/ECU load+save wiring. -func (mw *MainWindow) compareMapViewer(coll symbol.SymbolCollection, typ symbol.ECUType, mapName string) (*mapviewer.MapViewer, error) { - axis, x, y, z, xp, yp, zp, err := compareMapData(coll, typ, mapName) - if err != nil { - return nil, err - } - return mw.readonlyMapViewer(mapName, axis.ZDescription, axis, x, y, z, xp, yp, zp) + inner.Resize(fyne.NewSize(900, 700)) } func (mw *MainWindow) readonlyMapViewer(name, zLabel string, axis symbol.Axis, xData, yData, zData []float64, xPrec, yPrec, zPrec int) (*mapviewer.MapViewer, error) { From 308bc44e22ab07b6b63d479e9922eed8db7c5e42 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 19:55:50 +0200 Subject: [PATCH 80/93] add region axis to display open and closed region border --- pkg/widgets/mapviewer/mapviewer.go | 107 ++++++++++++++++++++++-- pkg/widgets/mapviewer/mapviewer_opts.go | 9 ++ pkg/windows/closedloopregion_test.go | 33 ++++++++ pkg/windows/mainWindow_menu.go | 91 ++++++++++++++++---- pkg/windows/mainmenu.go | 8 +- pkg/windows/mainmenu_t7.go | 14 +++- pkg/windows/mainwindow_layout.go | 2 +- 7 files changed, 236 insertions(+), 28 deletions(-) create mode 100644 pkg/windows/closedloopregion_test.go diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 635aaefc..7298b2ec 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -63,6 +63,7 @@ type MapViewer struct { valueRects *fyne.Container valueTexts *fyne.Container selectionOverlay *fyne.Container + regionOverlay *fyne.Container crosshair *canvas.Rectangle @@ -154,6 +155,7 @@ func (mv *MapViewer) CreateRenderer() fyne.WidgetRenderer { mv.createXAxis() mv.createZdata() mv.createSelectionOverlay() + mv.createRegionOverlay() mv.createTextValues() // Start with nothing selected; a cell is selected on first click/keypress. mv.selectedCells = nil @@ -208,14 +210,17 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.crosshair.Resize(fyne.NewSize(34, 14)) mv.crosshair.Hide() - mv.innerView = container.NewStack( - mv.valueRects, - mv.selectionOverlay, + layers := []fyne.CanvasObject{mv.valueRects, mv.selectionOverlay} + if mv.regionOverlay != nil { + layers = append(layers, mv.regionOverlay) + } + layers = append(layers, container.New(&movingRectsLayout{mv: mv}, mv.crosshair, ), mv.valueTexts, ) + mv.innerView = container.NewStack(layers...) buttons := mv.createButtons() @@ -402,7 +407,8 @@ func (mv *MapViewer) Refresh() { func (mv *MapViewer) createYAxis() { mv.yAxisTexts = make([]*canvas.Text, 0, mv.numRows) objs := make([]fyne.CanvasObject, 0, mv.numRows) - for i := mv.numRows - 1; i >= 0; i-- { + // ponytail: single value view has only a "0" axis label, skip it + for i := mv.numRows - 1; i >= 0 && mv.numData > 1; i-- { text := &canvas.Text{ Alignment: fyne.TextAlignCenter, Text: strconv.FormatFloat(mv.cfg.YData[i], 'f', mv.cfg.YPrecision, 64), @@ -417,7 +423,8 @@ func (mv *MapViewer) createYAxis() { func (mv *MapViewer) createXAxis() { mv.xAxisTexts = make([]*canvas.Text, 0, mv.numColumns) objs := make([]fyne.CanvasObject, 0, mv.numColumns) - for i := 0; i < mv.numColumns; i++ { + // ponytail: single value view has only a "0" axis label, skip it + for i := 0; i < mv.numColumns && mv.numData > 1; i++ { text := &canvas.Text{ Alignment: fyne.TextAlignCenter, Text: strconv.FormatFloat(mv.cfg.XData[i], 'f', mv.cfg.XPrecision, 64), @@ -481,6 +488,96 @@ func (mv *MapViewer) createSelectionOverlay() { mv.selectionOverlay = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) } +// createRegionOverlay builds a thin line layer that traces the boundary between +// the cells flagged in cfg.RegionBorder and the rest — e.g. the closed-loop / +// open-loop fuel transition — as a staircase that cuts through the map instead +// of boxing each cell. Leaves regionOverlay nil when there is no region or the +// region has no internal boundary (all cells in or all out). +func (mv *MapViewer) createRegionOverlay() { + if len(mv.cfg.RegionBorder) != mv.numData || mv.numColumns <= 1 || mv.numRows <= 1 { + return + } + edges := mv.regionEdges() + if len(edges) == 0 { + return + } + borderCol := mv.cfg.RegionBorderColor + if borderCol.A == 0 { + borderCol = color.RGBA{0x70, 0x80, 0x90, 0xFF} // ponytail: default slate boundary + } + lines := make([]*canvas.Line, len(edges)) + objs := make([]fyne.CanvasObject, len(edges)) + for i := range edges { + ln := canvas.NewLine(borderCol) + ln.StrokeWidth = 4 + lines[i] = ln + objs[i] = ln + } + mv.regionOverlay = container.New(®ionBorderLayout{mv: mv, edges: edges, lines: lines}, objs...) +} + +// regionEdge marks one shared cell edge on the region boundary. vertical = the +// edge to the right of cell (r,c); otherwise the edge on top of cell (r,c), +// shared with row r+1. Indices use the same row-major order as ZData. +type regionEdge struct { + r, c int + vertical bool +} + +// regionEdges collects every edge where a flagged cell touches an unflagged one. +// Only internal transitions are returned, so the map's outer border is never +// drawn — just the closed/open interface. +func (mv *MapViewer) regionEdges() []regionEdge { + rb := mv.cfg.RegionBorder + var edges []regionEdge + for r := 0; r < mv.numRows; r++ { + for c := 0; c < mv.numColumns; c++ { + in := rb[r*mv.numColumns+c] + if c+1 < mv.numColumns && in != rb[r*mv.numColumns+c+1] { + edges = append(edges, regionEdge{r: r, c: c, vertical: true}) + } + if r+1 < mv.numRows && in != rb[(r+1)*mv.numColumns+c] { + edges = append(edges, regionEdge{r: r, c: c, vertical: false}) + } + } + } + return edges +} + +// regionBorderLayout positions the boundary lines onto cell edges, recomputing +// on resize. Cell slots are size/count (matching the value grid's slot pitch and +// the crosshair layout), and row 0 sits at the bottom, so Y is flipped. +type regionBorderLayout struct { + mv *MapViewer + edges []regionEdge + lines []*canvas.Line + oldSize fyne.Size +} + +func (l *regionBorderLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.Size{} } + +func (l *regionBorderLayout) Layout(_ []fyne.CanvasObject, size fyne.Size) { + if size == l.oldSize { + return + } + l.oldSize = size + cw := size.Width / float32(l.mv.numColumns) + ch := size.Height / float32(l.mv.numRows) + for i, e := range l.edges { + ln := l.lines[i] + if e.vertical { + x := float32(e.c+1) * cw + ln.Position1 = fyne.NewPos(x, size.Height-float32(e.r+1)*ch) + ln.Position2 = fyne.NewPos(x, size.Height-float32(e.r)*ch) + } else { + y := size.Height - float32(e.r+1)*ch + ln.Position1 = fyne.NewPos(float32(e.c)*cw, y) + ln.Position2 = fyne.NewPos(float32(e.c+1)*cw, y) + } + ln.Refresh() + } +} + // clearSelectionVisual hides the highlight for every currently selected cell. // Call it before mutating mv.selectedCells, then call drawSelectionVisual after. func (mv *MapViewer) clearSelectionVisual() { diff --git a/pkg/widgets/mapviewer/mapviewer_opts.go b/pkg/widgets/mapviewer/mapviewer_opts.go index d1ea6361..7b72db6b 100644 --- a/pkg/widgets/mapviewer/mapviewer_opts.go +++ b/pkg/widgets/mapviewer/mapviewer_opts.go @@ -1,6 +1,8 @@ package mapviewer import ( + "image/color" + "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/colors" "github.com/roffe/txlogger/pkg/widgets/meshgrid" @@ -36,6 +38,13 @@ type Config struct { ColorblindMode colors.ColorBlindMode + // RegionBorder marks cells (same flat row-major order as ZData) that should + // be drawn with a contrasting border, e.g. to outline the closed-loop fuel + // area. nil or wrong length = no border drawn. + RegionBorder []bool + // RegionBorderColor is the border colour; zero value falls back to a default. + RegionBorderColor color.RGBA + Buttons []*MapViewerButton } diff --git a/pkg/windows/closedloopregion_test.go b/pkg/windows/closedloopregion_test.go new file mode 100644 index 00000000..977d130a --- /dev/null +++ b/pkg/windows/closedloopregion_test.go @@ -0,0 +1,33 @@ +package windows + +import "testing" + +func TestLookup1D(t *testing.T) { + // MaxLoadNormTab rpm axis + values from the reference T7 binary. + rpm := []float64{700, 880, 1260, 1640, 2020, 2400, 2780, 3160, 3540, 3920, 4300, 4680, 5060, 5440, 5820, 6200} + max := []float64{650, 650, 650, 650, 650, 650, 660, 660, 660, 650, 570, 510, 450, 330, 330, 330} + + cases := []struct { + x, want float64 + }{ + {700, 650}, // first breakpoint + {6200, 330}, // last breakpoint + {300, 650}, // below range -> clamp low + {9000, 330}, // above range -> clamp high + {5060, 450}, // exact breakpoint mid-table + {4490, 540}, // halfway between 4300(570) and 4680(510) + } + + for _, c := range cases { + if got := lookup1D(rpm, max, c.x); got != c.want { + t.Errorf("lookup1D(%g) = %g, want %g", c.x, got, c.want) + } + } + + // Boundary mapping: at rpm 5060 the closed-loop limit is 450 mg/c, so airmass + // 420 is closed loop and 480 is open loop. + limit := lookup1D(rpm, max, 5060) + if !(420 <= limit) || 480 <= limit { + t.Errorf("closed-loop boundary wrong: limit=%g, want 420<=limit<480", limit) + } +} diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 4d741af1..e9f5383c 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -101,6 +101,13 @@ func (mw *MainWindow) setupMenu() { mw.wm.Add(inner) }), openItem, + fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), mw.openSettings), + fyne.NewMenuItemWithIcon("What's new", theme.InfoIcon(), mw.showWhatsNew), + fyne.NewMenuItemWithIcon("Check for updates", theme.ViewRefreshIcon(), func() { + update.UpdateCheck(mw.app, mw.Window) + }), + ), + fyne.NewMenu("Tools", fyne.NewMenuItemWithIcon("Symbol Browser", theme.ListIcon(), func() { if w := mw.wm.HasWindow("Symbol Browser"); w != nil { mw.wm.Raise(w) @@ -109,23 +116,15 @@ func (mw *MainWindow) setupMenu() { getECU := func() symbol.ECUType { return symbol.ECUTypeFromString(mw.selects.ecuSelect.Selected) } - browser := symbolbrowser.New(getFW, getECU, mw.openMap, mw.Error) + openMap := func(typ symbol.ECUType, title, mapName string) { + mw.openMap(typ, title, mapName, "") + } + browser := symbolbrowser.New(getFW, getECU, openMap, mw.Error) inner := multiwindow.NewInnerWindow("Symbol Browser", browser) inner.Icon = theme.ListIcon() mw.wm.Add(inner) inner.Resize(fyne.Size{Width: 760, Height: 520}) }), - fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), func() { - mw.openSettings() - }), - fyne.NewMenuItemWithIcon("What's new", theme.InfoIcon(), func() { - mw.showWhatsNew() - }), - fyne.NewMenuItemWithIcon("Check for updates", theme.ViewRefreshIcon(), func() { - update.UpdateCheck(mw.app, mw.Window) - }), - ), - fyne.NewMenu("Tools", fyne.NewMenuItemWithIcon("Compare symbols with other binary", theme.SearchReplaceIcon(), mw.openSymbolCompare), fyne.NewMenuItemWithIcon("Matrix Builder", theme.InfoIcon(), mw.openMatrixBuilder), ), @@ -325,7 +324,7 @@ var openMapLock sync.Mutex // load+save funcs, live X/Y crosshair subscriptions) but does not create a // window. openMap wraps one; openMultiMap arranges several in a grid. The // returned cancelFuncs must be called when the containing window closes. -func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string) (*mapviewer.MapViewer, *mapviewer.Config, symbol.Axis, []func(), error) { +func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string, regionMap string) (*mapviewer.MapViewer, *mapviewer.Config, symbol.Axis, []func(), error) { var axis symbol.Axis if mw.as2 != nil { axis.Z = mapName @@ -574,6 +573,15 @@ func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string) (*mapview zPrecision = symbol.GetPrecision(symZ.Correctionfactor) } + if mapName == "TransCal.m_TriggMaxTab" { + yData = []float64{0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500} + } + + var regionBorder []bool + if regionMap != "" { + regionBorder = mw.closedLoopRegion(typ, regionMap, xData, yData) + } + cfg := &mapviewer.Config{ Name: symZ.Name, @@ -581,6 +589,8 @@ func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string) (*mapview YData: yData, ZData: zData, + RegionBorder: regionBorder, + XPrecision: xPrecision, YPrecision: yPrecision, ZPrecision: zPrecision, @@ -667,13 +677,13 @@ func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string) (*mapview return mv, cfg, axis, cancelFuncs, nil } -func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) { +func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string, regionMap string) { if mw.fw == nil { mw.Error(fmt.Errorf("no binary loaded")) return } - mv, cfg, axis, cancelFuncs, err := mw.newMapViewer(typ, mapName) + mv, cfg, axis, cancelFuncs, err := mw.newMapViewer(typ, mapName, regionMap) if err != nil { mw.Error(err) return @@ -738,7 +748,7 @@ func (mw *MainWindow) openMultiMap(typ symbol.ECUType, title string, data string var cancelFuncs []func() for _, name := range strings.Split(data, "|") { - mv, cfg, axis, cancels, err := mw.newMapViewer(typ, strings.TrimSpace(name)) + mv, cfg, axis, cancels, err := mw.newMapViewer(typ, strings.TrimSpace(name), "") if err != nil { mw.Error(err) continue @@ -791,6 +801,55 @@ func (mw *MainWindow) openMultiMap(typ symbol.ECUType, title string, data string mapWindow.Resize(fyne.NewSize(1000, 750)) } +// closedLoopRegion flags the cells of a fuel map (X = airmass, Y = rpm) that lie +// in the closed-loop area: airmass <= the per-rpm max load read from a LambdaCal +// MaxLoad table (regionMap). The table is indexed by its own rpm axis, so the +// limit is linearly interpolated onto each map rpm row. Result is row-major +// (rpmIdx*len(xData)+airIdx), matching ZData order. Returns nil if anything is +// missing so the caller simply skips the outline. +func (mw *MainWindow) closedLoopRegion(typ symbol.ECUType, regionMap string, xData, yData []float64) []bool { + sym := mw.fw.GetByName(regionMap) + if sym == nil { + return nil + } + rpmSym := mw.fw.GetByName(symbol.GetInfo(typ, regionMap).Y) + if rpmSym == nil { + return nil + } + limitRpm := rpmSym.Float64s() + limit := sym.Float64s() + if len(limitRpm) == 0 || len(limit) != len(limitRpm) { + return nil + } + region := make([]bool, len(xData)*len(yData)) + for r, rpm := range yData { + maxAir := lookup1D(limitRpm, limit, rpm) + for c, air := range xData { + region[r*len(xData)+c] = air <= maxAir + } + } + return region +} + +// lookup1D does a clamped linear interpolation of ys over the (ascending) xs +// breakpoints. xs and ys must be the same non-zero length. +func lookup1D(xs, ys []float64, x float64) float64 { + n := len(xs) + if x <= xs[0] { + return ys[0] + } + if x >= xs[n-1] { + return ys[n-1] + } + for i := 1; i < n; i++ { + if x <= xs[i] { + t := (x - xs[i-1]) / (xs[i] - xs[i-1]) + return ys[i-1] + t*(ys[i]-ys[i-1]) + } + } + return ys[n-1] +} + func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { index := -1 retval := -1 diff --git a/pkg/windows/mainmenu.go b/pkg/windows/mainmenu.go index c669b2a9..394edab8 100644 --- a/pkg/windows/mainmenu.go +++ b/pkg/windows/mainmenu.go @@ -13,6 +13,10 @@ type MenuItem struct { Children []MenuItem Func func() Data string + // Region is an optional LambdaCal MaxLoad table whose per-rpm airmass limit + // outlines the closed-loop region on the opened map. Only used for single-map + // items (Data without "|"). + Region string } func (mw *MainWindow) GetMenu(name string) *fyne.MainMenu { @@ -64,12 +68,12 @@ func (mw *MainWindow) buildItem(typ symbol.ECUType, item MenuItem) *fyne.MenuIte case item.Data != "": // title + symbol: open as a map return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { - mw.openMap(typ, item.Name, item.Data) + mw.openMap(typ, item.Name, item.Data, item.Region) }) default: // Name is itself a symbol return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { - mw.openMap(typ, "", item.Name) + mw.openMap(typ, "", item.Name, "") }) } } diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index 476154a5..953c40e6 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -33,6 +33,10 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "Nom. torque map (X)", Data: "TorqueCal.m_AirXSP"}, }}, {Name: "Fuel", Children: []MenuItem{ + {Name: "Closed Loop", Children: []MenuItem{ + {Name: "Max load norm", Data: "LambdaCal.MaxLoadNormTab"}, + {Name: "Max load E85", Data: "LambdaCal.MaxLoadE85Tab"}, + }}, {Name: "TransCal", Children: []MenuItem{ {Name: "TransCal.ST_Enable"}, {Name: "TransCal.ST_DecNoLim"}, @@ -49,17 +53,19 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "TransCal.AccMulConst"}, {Name: "TransCal.Delay1"}, {Name: "TransCal.Delay2"}, + {Name: "TransCal.m_TriggMaxTab"}, }}, - {Name: "VE map", Data: "BFuelCal.Map"}, - {Name: "Startup VE map / E85 VE map", Data: "BFuelCal.StartMap"}, + {Name: "VE map", Data: "BFuelCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, + {Name: "Startup VE map / E85 VE map", Data: "BFuelCal.StartMap", Region: "LambdaCal.MaxLoadE85Tab"}, {Name: "Gas VE map", Data: "BFuelCal.GasMap"}, {Name: "Enrichment factor during starting", Data: "StartCal.EnrFacTab"}, {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, {Name: "Enable cloosed loop regulation", Data: "LambdaCal.ST_Enable"}, + {Name: "Common for tuning", Data: "AdpFuelCal.T_AdaptLim|E85Cal.ST_Enable|FCutCal.ST_Enable|LambdaCal.ST_Enable|PurgeCal.ST_PurgeEnable|TorqueCal.M_BrakeLimit"}, }}, {Name: "Ignition", Children: []MenuItem{ - {Name: "Ignition map", Data: "IgnNormCal.Map"}, - {Name: "Ignition for E85", Data: "IgnE85Cal.fi_AbsMap"}, + {Name: "Ignition map", Data: "IgnNormCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, + {Name: "Ignition for E85", Data: "IgnE85Cal.fi_AbsMap", Region: "LambdaCal.MaxLoadE85Tab"}, {Name: "Ignition for Gas", Data: "IgnNormCal.GasMap"}, {Name: "Ignition Idle", Data: "IgnIdleCal.fi_IdleMap"}, {Name: "Ignition Start", Data: "IgnStartCal.fi_StartMap"}, diff --git a/pkg/windows/mainwindow_layout.go b/pkg/windows/mainwindow_layout.go index 655ba1c0..ff43213d 100644 --- a/pkg/windows/mainwindow_layout.go +++ b/pkg/windows/mainwindow_layout.go @@ -184,7 +184,7 @@ func (mw *MainWindow) LoadLayout(name string) error { } if openMap { - mw.openMap(symbol.ECUTypeFromString(layout.ECU), h.Title, parts[0]) + mw.openMap(symbol.ECUTypeFromString(layout.ECU), h.Title, parts[0], "") } } From 8517a26bbe8ec2c797160ffc39d616b61af945f7 Mon Sep 17 00:00:00 2001 From: roffe Date: Sat, 20 Jun 2026 20:06:16 +0200 Subject: [PATCH 81/93] update deps --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 57aa941c..35095b57 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7 + fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.2.1 + github.com/roffe/ecusymbol v1.2.3 github.com/roffe/gocan v1.4.1 go.bug.st/serial v1.6.4 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index de1065dd..9d329135 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7 h1:2auph/jcheGuecJjdA5JahXIFFeLjglROzorkhmLxiU= -fyne.io/fyne/v2 v2.7.5-0.20260614063241-4c0c29f7d5a7/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5 h1:E/XnR1+hWNRnHoS1A9DK/JYFmCZ7GgE8L8NoRz3StQs= +fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= @@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.2.1 h1:qqeXLT9NX3ckTQneCg/JImQ/3QeBSX+1GtmZ3Y9e7Fw= -github.com/roffe/ecusymbol v1.2.1/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/ecusymbol v1.2.3 h1:/Ng+yRyIaDRp72KpBOjwS+wa4mcKp83b2fBn2rY/DuE= +github.com/roffe/ecusymbol v1.2.3/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.1 h1:T9aAHzTxS7oXwiOlMIM2TAf75aMHcEaWAi27nKwEFQk= github.com/roffe/gocan v1.4.1/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= From df1d6763b842252e5d3597f7ebc7347951578bef Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 21 Jun 2026 00:38:20 +0200 Subject: [PATCH 82/93] liveplot --- pkg/widgets/liveplotter/liveplotter.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/widgets/liveplotter/liveplotter.go b/pkg/widgets/liveplotter/liveplotter.go index 602b3e63..9bce410b 100644 --- a/pkg/widgets/liveplotter/liveplotter.go +++ b/pkg/widgets/liveplotter/liveplotter.go @@ -477,9 +477,11 @@ func (p *Widget) CreateRenderer() fyne.WidgetRenderer { p.setFollow(!p.follow) }) - p.windowSel = widget.NewSelect([]string{"30s", "60s", "120s", "300s"}, func(s string) { + p.windowSel = widget.NewSelect([]string{"15s", "30s", "60s", "120s", "300s"}, func(s string) { var d time.Duration switch s { + case "15s": + d = 15 * time.Second case "30s": d = 30 * time.Second case "60s": From b6cc041d41724a08b2c088595ceda798ba4d6528 Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 21 Jun 2026 21:40:46 +0200 Subject: [PATCH 83/93] add ctrl-shift click --- pkg/widgets/mapviewer/mapviewer.go | 5 +++++ pkg/widgets/mapviewer/mapviewer_mouse.go | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 7298b2ec..58b5c730 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -595,6 +595,11 @@ func (mv *MapViewer) drawSelectionVisual() { mv.selectionRects[cell].Show() } } + // Show() only flips the Hidden flag. On a freshly opened window nothing has + // dirtied the canvas yet, so the newly shown rects aren't painted until some + // unrelated event (resize, button hover) forces a repaint. Refresh the + // overlay container to repaint immediately. See handlePrimaryCtrlClick. + canvas.Refresh(mv.selectionOverlay) } func (mv *MapViewer) setXY() error { diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index 44b21d1c..ba346601 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -67,6 +67,9 @@ func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierShift: mv.handlePrimaryClickWithShift(event) + case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierControl|fyne.KeyModifierShift: + mv.handlePrimaryCtrlShiftClick(event) + case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierControl: mv.handlePrimaryCtrlClick(event) @@ -113,6 +116,25 @@ func (mv *MapViewer) handlePrimaryCtrlClick(event *desktop.MouseEvent) { canvas.Refresh(mv.zDataRects[newCell]) } +// handlePrimaryCtrlShiftClick adds the rectangular block between the anchor cell +// (selectedX/SelectedY, the last clicked cell) and the clicked corner to the +// current selection without clearing it. +func (mv *MapViewer) handlePrimaryCtrlShiftClick(event *desktop.MouseEvent) { + nx, ny := mv.calculateSelectionBounds(event.Position) + minX, maxX := min(mv.selectedX, nx), max(mv.selectedX, nx) + minY, maxY := min(mv.SelectedY, ny), max(mv.SelectedY, ny) + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + cell := y*mv.numColumns + x + if !slices.Contains(mv.selectedCells, cell) { + mv.selectedCells = append(mv.selectedCells, cell) + } + } + } + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.drawSelectionVisual() +} + // handlePrimaryClickWithShift extends the selection from the current anchor to // the clicked cell and begins a drag. func (mv *MapViewer) handlePrimaryClickWithShift(event *desktop.MouseEvent) { From acaacc4699203ce1271b1518a942a6d1cd184a1d Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 21 Jun 2026 21:41:02 +0200 Subject: [PATCH 84/93] update t7 menus --- pkg/windows/mainmenu_t7.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index 953c40e6..2d06e69a 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -33,6 +33,11 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "Nom. torque map (X)", Data: "TorqueCal.m_AirXSP"}, }}, {Name: "Fuel", Children: []MenuItem{ + {Name: "Fuel cut", Children: []MenuItem{ + {Name: "FCutCal.ST_Enable"}, + {Name: "FCutCal.FuelFactor"}, + {Name: "FCutCal.n_CombSP (Y)", Data: "FCutCal.n_CombSP"}, + }}, {Name: "Closed Loop", Children: []MenuItem{ {Name: "Max load norm", Data: "LambdaCal.MaxLoadNormTab"}, {Name: "Max load E85", Data: "LambdaCal.MaxLoadE85Tab"}, From c12c612fa8c38b4f86de991d0ec52274f5e90036 Mon Sep 17 00:00:00 2001 From: roffe Date: Mon, 22 Jun 2026 22:14:08 +0200 Subject: [PATCH 85/93] some menu stuff and experimental scaler --- go.mod | 2 +- go.sum | 4 +- pkg/widgets/rescaler/rescaler.go | 186 ++++++++++++++++++++++++++ pkg/widgets/rescaler/rescaler_test.go | 39 ++++++ pkg/windows/mainWindow_menu.go | 152 ++++++++++++++++++++- pkg/windows/mainWindow_toolbar.go | 64 --------- pkg/windows/mainmenu_t7.go | 3 +- pkg/windows/mainmenu_t8.go | 5 + 8 files changed, 383 insertions(+), 72 deletions(-) create mode 100644 pkg/widgets/rescaler/rescaler.go create mode 100644 pkg/widgets/rescaler/rescaler_test.go diff --git a/go.mod b/go.mod index 35095b57..246e7606 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5 + fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 diff --git a/go.sum b/go.sum index 9d329135..a2e0f595 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5 h1:E/XnR1+hWNRnHoS1A9DK/JYFmCZ7GgE8L8NoRz3StQs= -fyne.io/fyne/v2 v2.7.5-0.20260620165746-4a6045473bc5/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01 h1:G0GqajRHUPTm6LTMvLdklI1EEqY7c6Vh6YLNWiMEUCQ= +fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= diff --git a/pkg/widgets/rescaler/rescaler.go b/pkg/widgets/rescaler/rescaler.go new file mode 100644 index 00000000..525c1281 --- /dev/null +++ b/pkg/widgets/rescaler/rescaler.go @@ -0,0 +1,186 @@ +// Package rescaler resamples a 2D map onto new axis support points while +// preserving the underlying surface — the local reimplementation of the +// Trionic Map Scaler (gray-plant-037f86003.3.azurestaticapps.net), which did +// the same thing through a server-side API and required pasting values by hand. +// Here the values are read straight from the loaded binary instead. +package rescaler + +import ( + "fmt" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/roffe/txlogger/pkg/interpolate" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// Config describes one map to rescale. Axes and data are in engineering units +// (correction factor already applied). ZData is row-major: index = y*len(X)+x, +// matching MapViewer and interpolate.Interpolate64. +type Config struct { + Name string + XLabel, YLabel, ZLabel string + + XData, YData, ZData []float64 + + XPrecision, YPrecision, ZPrecision int + + // Apply writes the rescaled result back (e.g. to the binary symbols + disk). + // newX/newY have the same length as the originals; newZ keeps the same dims. + Apply func(newX, newY, newZ []float64) error +} + +// Rescale resamples z (defined on oldX × oldY, row-major row=Y col=X) onto the +// newX × newY grid with clamped bilinear interpolation. Points outside the old +// axis range clamp to the nearest edge — no extrapolation. newX/newY may hold +// different values than the originals but interpolate.Interpolate64 wants the +// same Z layout, so callers keep the breakpoint counts unchanged. +func Rescale(oldX, oldY, z, newX, newY []float64) []float64 { + out := make([]float64, len(newX)*len(newY)) + for yi, yv := range newY { + for xi, xv := range newX { + _, _, v, _ := interpolate.Interpolate64(oldX, oldY, z, xv, yv) + out[yi*len(newX)+xi] = v + } + } + return out +} + +// Rescaler is the editable axis + live preview widget. +type Rescaler struct { + widget.BaseWidget + cfg *Config + + xEntry, yEntry *widget.Entry + preview *fyne.Container + status *widget.Label + + newX, newY, newZ []float64 +} + +func New(cfg *Config) *Rescaler { + r := &Rescaler{ + cfg: cfg, + preview: container.NewStack(), + status: widget.NewLabel(""), + } + r.ExtendBaseWidget(r) + + r.xEntry = widget.NewEntry() + r.xEntry.SetText(floatsToText(cfg.XData, cfg.XPrecision)) + r.yEntry = widget.NewEntry() + r.yEntry.SetText(floatsToText(cfg.YData, cfg.YPrecision)) + + r.rescale() // identity preview on open + return r +} + +func (r *Rescaler) rescale() { + newX, err := parseAxis(r.xEntry.Text, len(r.cfg.XData)) + if err != nil { + r.status.SetText("X axis: " + err.Error()) + return + } + newY, err := parseAxis(r.yEntry.Text, len(r.cfg.YData)) + if err != nil { + r.status.SetText("Y axis: " + err.Error()) + return + } + + newZ := Rescale(r.cfg.XData, r.cfg.YData, r.cfg.ZData, newX, newY) + r.newX, r.newY, r.newZ = newX, newY, newZ + + mv, err := mapviewer.New(&mapviewer.Config{ + Name: r.cfg.Name, + XData: newX, + YData: newY, + ZData: newZ, + XPrecision: r.cfg.XPrecision, + YPrecision: r.cfg.YPrecision, + ZPrecision: r.cfg.ZPrecision, + XLabel: r.cfg.XLabel, + YLabel: r.cfg.YLabel, + ZLabel: r.cfg.ZLabel, + }) + if err != nil { + r.status.SetText(err.Error()) + return + } + r.preview.Objects = []fyne.CanvasObject{mv} + r.preview.Refresh() + r.status.SetText("Rescaled — review the preview, then Apply & Save") +} + +func (r *Rescaler) apply() { + if r.cfg.Apply == nil || r.newZ == nil { + return + } + dialog.ShowConfirm("Apply & Save", + fmt.Sprintf("Overwrite %s and its axes in the binary and save to disk?", r.cfg.Name), + func(ok bool) { + if !ok { + return + } + if err := r.cfg.Apply(r.newX, r.newY, r.newZ); err != nil { + r.status.SetText("Apply failed: " + err.Error()) + return + } + r.status.SetText("Applied and saved") + }, fyne.CurrentApp().Driver().AllWindows()[0]) +} + +func (r *Rescaler) CreateRenderer() fyne.WidgetRenderer { + rescaleBtn := widget.NewButtonWithIcon("Rescale", theme.ViewRefreshIcon(), r.rescale) + rescaleBtn.Importance = widget.HighImportance + applyBtn := widget.NewButtonWithIcon("Apply & Save", theme.DocumentSaveIcon(), r.apply) + + form := container.NewVBox( + widget.NewLabel(fmt.Sprintf("New X axis (%s) — %d points:", r.cfg.XLabel, len(r.cfg.XData))), + r.xEntry, + widget.NewLabel(fmt.Sprintf("New Y axis (%s) — %d points:", r.cfg.YLabel, len(r.cfg.YData))), + r.yEntry, + container.NewHBox(rescaleBtn, applyBtn), + r.status, + ) + + content := container.NewBorder(form, nil, nil, nil, r.preview) + return widget.NewSimpleRenderer(content) +} + +func floatsToText(vals []float64, prec int) string { + parts := make([]string, len(vals)) + for i, v := range vals { + parts[i] = strconv.FormatFloat(v, 'f', prec, 64) + } + return strings.Join(parts, ", ") +} + +// parseAxis parses comma/space/newline separated numbers, requiring exactly want +// strictly-ascending values (interpolate.Interpolate64 binary-searches the axis, +// and SetData rejects a changed table length). +func parseAxis(text string, want int) ([]float64, error) { + fields := strings.FieldsFunc(text, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + if len(fields) != want { + return nil, fmt.Errorf("need %d values, got %d", want, len(fields)) + } + out := make([]float64, len(fields)) + for i, f := range fields { + v, err := strconv.ParseFloat(f, 64) + if err != nil { + return nil, fmt.Errorf("%q is not a number", f) + } + if i > 0 && v <= out[i-1] { + return nil, fmt.Errorf("values must strictly ascend (%g after %g)", v, out[i-1]) + } + out[i] = v + } + return out, nil +} diff --git a/pkg/widgets/rescaler/rescaler_test.go b/pkg/widgets/rescaler/rescaler_test.go new file mode 100644 index 00000000..7e9cdc59 --- /dev/null +++ b/pkg/widgets/rescaler/rescaler_test.go @@ -0,0 +1,39 @@ +package rescaler + +import "testing" + +func eq(a, b []float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestRescale(t *testing.T) { + // Surface z(x,y) = x + y on a 2x2 grid, row-major row=Y col=X. + oldX := []float64{0, 10} + oldY := []float64{0, 10} + z := []float64{0, 10, 10, 20} + + // Identity: same axes -> same data. + if got := Rescale(oldX, oldY, z, oldX, oldY); !eq(got, z) { + t.Fatalf("identity rescale changed data: %v", got) + } + + // Add a midpoint X breakpoint: linear surface => exact midpoints. + newX := []float64{0, 5, 10} + want := []float64{0, 5, 10, 10, 15, 20} + if got := Rescale(oldX, oldY, z, newX, oldY); !eq(got, want) { + t.Fatalf("midpoint rescale = %v, want %v", got, want) + } + + // Outside the old range clamps to the edge (no extrapolation). + if got := Rescale(oldX, oldY, z, []float64{-5, 0}, []float64{0, 0})[0]; got != 0 { + t.Fatalf("clamp below min = %v, want 0", got) + } +} diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index e9f5383c..4a9bb9b3 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "math" + "os" "os/exec" "runtime" "strings" @@ -23,12 +24,15 @@ import ( "github.com/roffe/txlogger/pkg/ecu/t8/t8file" "github.com/roffe/txlogger/pkg/update" "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/boosttuner" "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/dtcreader" "github.com/roffe/txlogger/pkg/widgets/editparameters" "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" "github.com/roffe/txlogger/pkg/widgets/progressmodal" + "github.com/roffe/txlogger/pkg/widgets/rescaler" "github.com/roffe/txlogger/pkg/widgets/symbolbrowser" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmmod" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmstatus" @@ -127,6 +131,9 @@ func (mw *MainWindow) setupMenu() { }), fyne.NewMenuItemWithIcon("Compare symbols with other binary", theme.SearchReplaceIcon(), mw.openSymbolCompare), fyne.NewMenuItemWithIcon("Matrix Builder", theme.InfoIcon(), mw.openMatrixBuilder), + //fyne.NewMenuItemWithIcon("Rescale AccPedalMap", theme.GridIcon(), func() { + // mw.openRescaler(symbol.ECU_T8, "TrqMastCal.X_AccPedalMAP") + //}), ), } @@ -162,7 +169,7 @@ func (mw *MainWindow) setupMenu() { mw.wm.Add(inner) inner.Resize(fyne.NewSize(450, 250)) }), - fyne.NewMenuItemWithIcon("Boost Auto-Tuner", theme.MediaFastForwardIcon(), mw.openBoostTuner), + fyne.NewMenuItemWithIcon("T7 Boost Auto-Tuner", theme.MediaFastForwardIcon(), mw.openBoostTuner), ) } @@ -399,7 +406,7 @@ func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string, regionMap if kyltempSteg == nil || kyltempTab == nil { return nil, nil, axis, nil, fmt.Errorf("missing coolant temperature symbols") } - realTemp := LookupCoolantTemperature(val, kyltempSteg.Ints(), kyltempTab.Ints()) + realTemp := lookupCoolantTemperature(val, kyltempSteg.Ints(), kyltempTab.Ints()) yData[idx] = float64(realTemp) } } else { @@ -573,8 +580,11 @@ func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string, regionMap zPrecision = symbol.GetPrecision(symZ.Correctionfactor) } - if mapName == "TransCal.m_TriggMaxTab" { + switch mapName { + case "TransCal.m_TriggMaxTab": yData = []float64{0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500} + case "TransCal.FilterConstAir": + yData = []float64{899, 2499, 3499, 3500} } var regionBorder []bool @@ -850,7 +860,7 @@ func lookup1D(xs, ys []float64, x float64) float64 { return ys[n-1] } -func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { +func lookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { index := -1 retval := -1 smallestDiff := 256 @@ -914,3 +924,137 @@ func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int return retval } + +// openBoostTuner opens (or raises) the T7 boost auto-tuner. It reads the current +// BoostCal maps from the loaded binary and writes tuned maps back through a save +// closure that takes a one-time .bak of the file before the first write. +func (mw *MainWindow) openBoostTuner() { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if w := mw.wm.HasWindow("Boost Auto-Tuner"); w != nil { + mw.wm.Raise(w) + return + } + save := func(symbolName string, data []float64) error { + sym := mw.fw.GetByName(symbolName) + if sym == nil { + return fmt.Errorf("symbol %s not found", symbolName) + } + if err := sym.SetData(sym.EncodeFloat64s(data)); err != nil { + return err + } + if mw.filename != "" { + if bak := mw.filename + ".bak"; !fileExists(bak) { + if orig, err := os.ReadFile(mw.filename); err == nil { + _ = os.WriteFile(bak, orig, 0o644) + } + } + } + return mw.fw.Save(mw.filename) + } + bt := boosttuner.New(boosttuner.Config{ + Symbols: mw.fw, + Save: save, + MeshRenderer: mw.settings.GetMeshRenderer(), + Colorblind: mw.settings.GetColorBlindMode(), + }) + inner := multiwindow.NewInnerWindow("Boost Auto-Tuner", bt) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1100, 760)) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// openMatrixBuilder opens (or raises) the matrix builder window. The builder +// loads its own log files, so it is independent of any open log player. +func (mw *MainWindow) openMatrixBuilder() { + if w := mw.wm.HasWindow("Matrix builder"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New(mw.settings.GetMeshRenderer())) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1000, 720)) +} + +// openRescaler opens (or raises) the map rescaler for a single map. It reads the +// map and its X/Y axes from the loaded binary, lets the user edit the axis +// support points, resamples the surface onto them, and writes the result back +// through a save closure that takes a one-time .bak before the first write. +func (mw *MainWindow) openRescaler(typ symbol.ECUType, mapName string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + winName := "Rescale " + mapName + if w := mw.wm.HasWindow(winName); w != nil { + mw.wm.Raise(w) + return + } + + axis := symbol.GetInfo(typ, mapName) + symX := mw.fw.GetByName(axis.X) + symY := mw.fw.GetByName(axis.Y) + symZ := mw.fw.GetByName(axis.Z) + if symZ == nil || symY == nil { + mw.Error(fmt.Errorf("rescaler: missing symbol(s) for %s", mapName)) + return + } + + xData := []float64{0} + xPrecision := 0 + if symX != nil { + xData = symX.Float64s() + xPrecision = symbol.GetPrecision(symX.Correctionfactor) + } + + cfg := &rescaler.Config{ + Name: axis.Z, + XLabel: axis.X, + YLabel: axis.Y, + ZLabel: axis.Z, + XData: xData, + YData: symY.Float64s(), + ZData: symZ.Float64s(), + XPrecision: xPrecision, + YPrecision: symbol.GetPrecision(symY.Correctionfactor), + ZPrecision: symbol.GetPrecision(symZ.Correctionfactor), + Apply: func(newX, newY, newZ []float64) error { + if symX != nil { + if err := symX.SetData(symX.EncodeFloat64s(newX)); err != nil { + return err + } + } + if err := symY.SetData(symY.EncodeFloat64s(newY)); err != nil { + return err + } + if err := symZ.SetData(symZ.EncodeFloat64s(newZ)); err != nil { + return err + } + if mw.filename != "" { + if bak := mw.filename + ".bak"; !fileExists(bak) { + if orig, err := os.ReadFile(mw.filename); err == nil { + _ = os.WriteFile(bak, orig, 0o644) + } + } + } + if err := mw.fw.Save(mw.filename); err != nil { + return err + } + mw.Log("Rescaled and saved " + axis.Z) + return nil + }, + } + + inner := multiwindow.NewInnerWindow(winName, rescaler.New(cfg)) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(900, 720)) +} diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index cf1c9513..63347bcc 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -1,78 +1,14 @@ package windows import ( - "fmt" - "os" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/widgets/boosttuner" - "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) -// openBoostTuner opens (or raises) the T7 boost auto-tuner. It reads the current -// BoostCal maps from the loaded binary and writes tuned maps back through a save -// closure that takes a one-time .bak of the file before the first write. -func (mw *MainWindow) openBoostTuner() { - if mw.fw == nil { - mw.Error(fmt.Errorf("no binary loaded")) - return - } - if w := mw.wm.HasWindow("Boost Auto-Tuner"); w != nil { - mw.wm.Raise(w) - return - } - save := func(symbolName string, data []float64) error { - sym := mw.fw.GetByName(symbolName) - if sym == nil { - return fmt.Errorf("symbol %s not found", symbolName) - } - if err := sym.SetData(sym.EncodeFloat64s(data)); err != nil { - return err - } - if mw.filename != "" { - if bak := mw.filename + ".bak"; !fileExists(bak) { - if orig, err := os.ReadFile(mw.filename); err == nil { - _ = os.WriteFile(bak, orig, 0o644) - } - } - } - return mw.fw.Save(mw.filename) - } - bt := boosttuner.New(boosttuner.Config{ - Symbols: mw.fw, - Save: save, - MeshRenderer: mw.settings.GetMeshRenderer(), - Colorblind: mw.settings.GetColorBlindMode(), - }) - inner := multiwindow.NewInnerWindow("Boost Auto-Tuner", bt) - inner.Icon = theme.GridIcon() - mw.wm.Add(inner) - inner.Resize(fyne.NewSize(1100, 760)) -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// openMatrixBuilder opens (or raises) the matrix builder window. The builder -// loads its own log files, so it is independent of any open log player. -func (mw *MainWindow) openMatrixBuilder() { - if w := mw.wm.HasWindow("Matrix builder"); w != nil { - mw.wm.Raise(w) - return - } - inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New(mw.settings.GetMeshRenderer())) - inner.Icon = theme.GridIcon() - mw.wm.Add(inner) - inner.Resize(fyne.NewSize(1000, 720)) -} - func (mw *MainWindow) newToolbar() *fyne.Container { toolbar := container.NewHBox( container.NewBorder( diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index 2d06e69a..573e7b37 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -59,9 +59,10 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "TransCal.Delay1"}, {Name: "TransCal.Delay2"}, {Name: "TransCal.m_TriggMaxTab"}, + {Name: "TransCal.FilterConstAir"}, }}, {Name: "VE map", Data: "BFuelCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, - {Name: "Startup VE map / E85 VE map", Data: "BFuelCal.StartMap", Region: "LambdaCal.MaxLoadE85Tab"}, + {Name: "Startup / E85 VE map", Data: "BFuelCal.StartMap", Region: "LambdaCal.MaxLoadE85Tab"}, {Name: "Gas VE map", Data: "BFuelCal.GasMap"}, {Name: "Enrichment factor during starting", Data: "StartCal.EnrFacTab"}, {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, diff --git a/pkg/windows/mainmenu_t8.go b/pkg/windows/mainmenu_t8.go index c57e5abe..b69c3818 100644 --- a/pkg/windows/mainmenu_t8.go +++ b/pkg/windows/mainmenu_t8.go @@ -1,5 +1,7 @@ package windows +import symbol "github.com/roffe/ecusymbol" + func (mw *MainWindow) t8Menu() []MenuItem { return []MenuItem{ {Name: "Diagnostics", Children: []MenuItem{ @@ -72,6 +74,9 @@ func (mw *MainWindow) t8Menu() []MenuItem { {Name: "Pedal", Children: []MenuItem{ {Name: "Pedal position map", Data: "TrqMastCal.X_AccPedalMAP"}, {Name: "Torque request map", Data: "PedalMapCal.Trq_RequestMap"}, + {Name: "Rescale AccPedalMap", Func: func() { + mw.openRescaler(symbol.ECU_T8, "TrqMastCal.X_AccPedalMAP") + }}, }}, } } From 518e98c201ccb5e7c36d020d688e2ad8d06c5352 Mon Sep 17 00:00:00 2001 From: roffe Date: Tue, 23 Jun 2026 21:03:42 +0200 Subject: [PATCH 86/93] more maps --- pkg/windows/mainmenu_t7.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index 573e7b37..f17e18e8 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -12,6 +12,9 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "Calibration", Children: []MenuItem{ {Name: "ESP Calibration", Func: mw.openESPCalibration}, {Name: "AirCompCal.PressMap"}, + {Name: "AirCtrlCal.map"}, + {Name: "AreaCal.Area"}, + {Name: "AreaCal.Table"}, {Name: "Ethanol adaption value", Data: "E85.X_EthAct_Tech2"}, {Name: "MAFCal.m_RedundantAirMap"}, {Name: "TCompCal.EnrFacE85Tab"}, From 02c5166a7148c30a8aacd1c81da4207f5401a3d0 Mon Sep 17 00:00:00 2001 From: roffe Date: Tue, 23 Jun 2026 23:11:57 +0200 Subject: [PATCH 87/93] some housekeeping --- Makefile | 7 ++- pkg/analyzer/analyzer.go | 36 ++++++------- pkg/common/math.go | 57 ++++++++++++++++++++ pkg/{widgets/plotter => common}/math_simd.go | 4 +- pkg/datalogger/datalogger.go | 4 -- pkg/widgets/dial/dial.go | 10 ---- pkg/widgets/dial/shader/dial.go | 10 ---- pkg/widgets/dualdial/dual_dial.go | 9 ---- pkg/widgets/dualdial/dual_dial.old | 6 --- pkg/widgets/dualdial/shader/dual_dial.go | 9 ---- pkg/widgets/graph2d/graph2d.go | 19 +------ pkg/widgets/mapviewer/mapviewer.go | 6 +-- pkg/widgets/math.go | 23 -------- pkg/widgets/matrixbuilder/matrixbuilder.go | 16 +----- pkg/widgets/meshgrid/meshgrid_draw.go | 12 ++--- pkg/widgets/multiwindow/layout.go | 10 ---- pkg/widgets/multiwindow/multiplewindows.go | 15 +++--- pkg/widgets/plotter/bresenham.go | 17 +++--- pkg/widgets/plotter/math.go | 16 ------ pkg/widgets/plotter/plotter.go | 26 ++------- pkg/widgets/settings/wbleditor.go | 6 +-- pkg/widgets/settings/wblgraph.go | 29 ++-------- pprof.go | 13 +++++ 23 files changed, 132 insertions(+), 228 deletions(-) create mode 100644 pkg/common/math.go rename pkg/{widgets/plotter => common}/math_simd.go (93%) delete mode 100644 pkg/widgets/math.go delete mode 100644 pkg/widgets/plotter/math.go diff --git a/Makefile b/Makefile index ba2b5839..c00e5dd2 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,14 @@ txlogger: release: fyne package -tags=$(BUILDTAGS) --release +debug: clean cangateway + @echo Using compiler "$(CC)" + -go run -tags=$(BUILDTAGS),debug . 2>&1 | tee run.log + + run: clean cangateway @echo Using compiler "$(CC)" - -go run -tags=$(BUILDTAGS) . 2>&1 | tee run.log + -GOEXPERIMENT=simd go run -tags=$(BUILDTAGS) . 2>&1 | tee run.log clean: rm -f cangateway diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 2dfb2723..000f1a4a 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -10,41 +10,41 @@ import ( // 75 125 150 180 240 300 360 420 480 540 600 660 720 800 900 1100 1300 1500 var tolerancces []int = []int{ - 5, //75 + 5, // 75 5, - 10, //125 + 10, // 125 10, - 10, //150 + 10, // 150 10, - 10, //180 + 10, // 180 10, - 10, //240 + 10, // 240 10, - 15, //300 + 15, // 300 15, - 15, //360 + 15, // 360 15, - 15, //420 + 15, // 420 15, - 15, //480 + 15, // 480 15, - 15, //540 + 15, // 540 15, - 15, //600 + 15, // 600 15, - 20, //660 + 20, // 660 20, - 20, //720 + 20, // 720 20, - 20, //800 + 20, // 800 30, - 30, //900 + 30, // 900 40, - 40, //1100 + 40, // 1100 40, - 50, //1300 + 50, // 1300 50, - 50, //1500 + 50, // 1500 } // AnalyzeLambda analyzes lambda values based on stable pedal conditions diff --git a/pkg/common/math.go b/pkg/common/math.go new file mode 100644 index 00000000..84b57c7e --- /dev/null +++ b/pkg/common/math.go @@ -0,0 +1,57 @@ +//go:build !goexperiment.simd || !amd64 + +package common + +import "cmp" + +func Abs(n int) int { + if n < 0 { + return -n + } + return n +} + +func FindMinMaxFloat64(data []float64) (float64, float64) { + min, max := data[0], data[0] + for _, v := range data { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} + +type Number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +// Generic function that works with any numeric type +func FindMinMax[T Number](data []T) (T, T) { + if len(data) == 0 { + panic("empty slice") + } + + min, max := data[0], data[0] + for _, v := range data { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} + +func Clamp[T cmp.Ordered](value, min, max T) T { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/pkg/widgets/plotter/math_simd.go b/pkg/common/math_simd.go similarity index 93% rename from pkg/widgets/plotter/math_simd.go rename to pkg/common/math_simd.go index c0ba88ee..8009b1f6 100644 --- a/pkg/widgets/plotter/math_simd.go +++ b/pkg/common/math_simd.go @@ -1,12 +1,12 @@ //go:build goexperiment.simd && amd64 -package plotter +package common import ( "simd/archsimd" ) -func findMinMaxFloat64(data []float64) (float64, float64) { +func FindMinMaxFloat64(data []float64) (float64, float64) { n := len(data) if n == 0 { return 0, 0 diff --git a/pkg/datalogger/datalogger.go b/pkg/datalogger/datalogger.go index c65a89bf..402aaf37 100644 --- a/pkg/datalogger/datalogger.go +++ b/pkg/datalogger/datalogger.go @@ -28,10 +28,6 @@ type IClient interface { Close() } -type Consumer interface { - SetValue(string, float64) -} - type Config struct { FilenamePrefix string ECU string diff --git a/pkg/widgets/dial/dial.go b/pkg/widgets/dial/dial.go index 28057828..ac03dabc 100644 --- a/pkg/widgets/dial/dial.go +++ b/pkg/widgets/dial/dial.go @@ -355,13 +355,3 @@ func (c *DialRenderer) Objects() []fyne.CanvasObject { } return c.objects } - -// --- helpers --- - -// max helper that matches your float32 usage -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dial/shader/dial.go b/pkg/widgets/dial/shader/dial.go index acf10f0a..e9697cb3 100644 --- a/pkg/widgets/dial/shader/dial.go +++ b/pkg/widgets/dial/shader/dial.go @@ -273,13 +273,3 @@ func (c *DialRenderer) Objects() []fyne.CanvasObject { } return c.objects } - -// --- helpers --- - -// max helper that matches your float32 usage -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dualdial/dual_dial.go b/pkg/widgets/dualdial/dual_dial.go index 8dc44673..5bd1f64c 100644 --- a/pkg/widgets/dualdial/dual_dial.go +++ b/pkg/widgets/dualdial/dual_dial.go @@ -350,12 +350,3 @@ func (c *DualDialRenderer) Objects() []fyne.CanvasObject { } return c.objects } - -// --- helpers --- - -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dualdial/dual_dial.old b/pkg/widgets/dualdial/dual_dial.old index 063a6d5c..8a25f9b3 100644 --- a/pkg/widgets/dualdial/dual_dial.old +++ b/pkg/widgets/dualdial/dual_dial.old @@ -361,9 +361,3 @@ func appendFormatFloat(dst []byte, format string, v float64) []byte { return strconv.AppendFloat(dst, v, 'f', 0, 64) } -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dualdial/shader/dual_dial.go b/pkg/widgets/dualdial/shader/dual_dial.go index 85f9920d..42afe371 100644 --- a/pkg/widgets/dualdial/shader/dual_dial.go +++ b/pkg/widgets/dualdial/shader/dual_dial.go @@ -289,12 +289,3 @@ func (c *DualDialRenderer) Objects() []fyne.CanvasObject { } return c.objects } - -// --- helpers --- - -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/graph2d/graph2d.go b/pkg/widgets/graph2d/graph2d.go index 008a8975..0459393e 100644 --- a/pkg/widgets/graph2d/graph2d.go +++ b/pkg/widgets/graph2d/graph2d.go @@ -14,6 +14,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" ) const ( @@ -95,7 +96,7 @@ func New(cfg *Config) *Graph { g.axis[i] = float64(i) } } - g.zMin, g.zMax = findMinMax(g.values) + g.zMin, g.zMax = common.FindMinMaxFloat64(g.values) g.ExtendBaseWidget(g) return g } @@ -560,19 +561,3 @@ func niceStep(rng float64, maxTicks int) float64 { return 10 * mag } } - -func findMinMax(values []float64) (float64, float64) { - if len(values) == 0 { - return 0, 0 - } - min, max := values[0], values[0] - for _, v := range values { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 58b5c730..ad736c9e 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -18,9 +18,9 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" "github.com/roffe/txlogger/pkg/interpolate" "github.com/roffe/txlogger/pkg/layout" - "github.com/roffe/txlogger/pkg/widgets" "github.com/roffe/txlogger/pkg/widgets/graph2d" "github.com/roffe/txlogger/pkg/widgets/meshgrid" ) @@ -115,7 +115,7 @@ func New(config *Config) (*MapViewer, error) { if len(mv.cfg.ZData) == 0 { return nil, fmt.Errorf("mapViewer zData is empty") } - mv.zMin, mv.zMax = widgets.FindMinMax(mv.cfg.ZData) + mv.zMin, mv.zMax = common.FindMinMaxFloat64(mv.cfg.ZData) if mv.numColumns*mv.numRows != mv.numData && mv.numColumns > 1 && mv.numRows > 1 { return nil, fmt.Errorf("mapViewer columns * rows != data length") } @@ -372,7 +372,7 @@ func (mv *MapViewer) SetZData(zData []float64) error { } func (mv *MapViewer) Refresh() { - mv.zMin, mv.zMax = widgets.FindMinMax(mv.cfg.ZData) + mv.zMin, mv.zMax = common.FindMinMaxFloat64(mv.cfg.ZData) if len(mv.textValues) == 0 { // renderer not created yet; createTextValues/createZdata pick up // the current ZData and color mode when it is diff --git a/pkg/widgets/math.go b/pkg/widgets/math.go deleted file mode 100644 index 21ccf48b..00000000 --- a/pkg/widgets/math.go +++ /dev/null @@ -1,23 +0,0 @@ -package widgets - -type Number interface { - ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 -} - -// Generic function that works with any numeric type -func FindMinMax[T Number](data []T) (T, T) { - if len(data) == 0 { - panic("empty slice") - } - - min, max := data[0], data[0] - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go index f674c338..3bbef794 100644 --- a/pkg/widgets/matrixbuilder/matrixbuilder.go +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -1005,7 +1005,8 @@ func (mb *MatrixBuilder) autoFill(isX bool) { if !ok || len(data) == 0 { return } - lo, hi := minMax(data) + + lo, hi := common.FindMinMaxFloat64(data) n := len(axis) for i := 0; i < n; i++ { if n == 1 { @@ -1641,19 +1642,6 @@ func countsToFloat(counts []int) []float64 { return out } -func minMax(data []float64) (float64, float64) { - lo, hi := data[0], data[0] - for _, v := range data[1:] { - if v < lo { - lo = v - } - if v > hi { - hi = v - } - } - return lo, hi -} - // precisionFor picks a sensible decimal precision: 0 for all-integer data, // otherwise more decimals for small-magnitude values. func precisionFor(data []float64) int { diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index df6a660b..86b77227 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -7,6 +7,7 @@ import ( "slices" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" ) // lineSegment indexes into the precomputed projected/color slices so we don't @@ -290,8 +291,8 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { pix := img.Pix // Bresenham setup - dx := abs(x1 - x0) - dy := -abs(y1 - y0) + dx := common.Abs(x1 - x0) + dy := -common.Abs(y1 - y0) sx := 1 if x0 > x1 { sx = -1 @@ -433,10 +434,3 @@ func clipCohenSutherland(x0, y0, x1, y1 *int, xmin, ymin, xmax, ymax int) bool { *x0, *y0, *x1, *y1 = x0i, y0i, x1i, y1i return true } - -func abs(v int) int { - if v < 0 { - return -v - } - return v -} diff --git a/pkg/widgets/multiwindow/layout.go b/pkg/widgets/multiwindow/layout.go index de7e71b3..f3fa8af7 100644 --- a/pkg/widgets/multiwindow/layout.go +++ b/pkg/widgets/multiwindow/layout.go @@ -18,13 +18,3 @@ func (m *multiWinLayout) Layout(objects []fyne.CanvasObject, _ fyne.Size) { func (m *multiWinLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.Size{Width: 700, Height: 400} } - -func clamp32(value, min, max float32) float32 { - if value < min { - return min - } - if value > max { - return max - } - return value -} diff --git a/pkg/widgets/multiwindow/multiplewindows.go b/pkg/widgets/multiwindow/multiplewindows.go index ac88d8b8..fcbcd803 100644 --- a/pkg/widgets/multiwindow/multiplewindows.go +++ b/pkg/widgets/multiwindow/multiplewindows.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" ) type WindowRatio struct { @@ -77,11 +78,11 @@ func (m *MultipleWindows) Add(w *InnerWindow, startPosition ...fyne.Position) bo if m.LockViewport { size := w.MinSize() bounds := m.content.Size() - startPosition[0].X = clamp32(startPosition[0].X, 0, bounds.Width-size.Width) - startPosition[0].Y = clamp32(startPosition[0].Y, 0, bounds.Height-size.Height) - //bounds.Subtract(size).Max(startPosition[0]) + startPosition[0].X = common.Clamp(startPosition[0].X, 0, bounds.Width-size.Width) + startPosition[0].Y = common.Clamp(startPosition[0].Y, 0, bounds.Height-size.Height) + // bounds.Subtract(size).Max(startPosition[0]) } - //w.Move(startPosition[0].SubtractXY(w.MinSize().Width*0.5, 80)) + // w.Move(startPosition[0].SubtractXY(w.MinSize().Width*0.5, 80)) w.Move(startPosition[0]) } @@ -245,8 +246,8 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { if m.LockViewport { size := w.Size() bounds := m.content.Size() - newPos.X = clamp32(newPos.X, 0, bounds.Width-size.Width) - newPos.Y = clamp32(newPos.Y, 0, bounds.Height-size.Height) + newPos.X = common.Clamp(newPos.X, 0, bounds.Width-size.Width) + newPos.Y = common.Clamp(newPos.Y, 0, bounds.Height-size.Height) } w.Move(newPos) } @@ -360,7 +361,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { if c := fyne.CurrentApp().Driver().CanvasForObject(w); c != nil { c.Focus(f) } - //c.SetOnTypedKey(f.TypedKey) + // c.SetOnTypedKey(f.TypedKey) } m.Raise(w) } diff --git a/pkg/widgets/plotter/bresenham.go b/pkg/widgets/plotter/bresenham.go index a237bdbc..5b17e51f 100644 --- a/pkg/widgets/plotter/bresenham.go +++ b/pkg/widgets/plotter/bresenham.go @@ -4,6 +4,8 @@ import ( "image" "image/color" "math" + + "github.com/roffe/txlogger/pkg/common" ) // BresenhamThick draws a line of given thickness directly into img.Pix, @@ -44,8 +46,8 @@ func BresenhamThick(img *image.RGBA, x1, y1, x2, y2 int, thickness int, col colo } func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA) { - dx := abs(x2 - x1) - dy := abs(y2 - y1) + dx := common.Abs(x2 - x1) + dy := common.Abs(y2 - y1) steep := dy > dx if steep { @@ -57,8 +59,8 @@ func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA y1, y2 = y2, y1 } - dx = abs(x2 - x1) - dy = abs(y2 - y1) + dx = common.Abs(x2 - x1) + dy = common.Abs(y2 - y1) err := dx / 2 y := y1 ystep := 1 @@ -135,10 +137,3 @@ func fillCircle(pix []uint8, stride, w, h, centerX, centerY, radius int, col col } } } - -func abs(n int) int { - if n < 0 { - return -n - } - return n -} diff --git a/pkg/widgets/plotter/math.go b/pkg/widgets/plotter/math.go deleted file mode 100644 index a555522e..00000000 --- a/pkg/widgets/plotter/math.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !goexperiment.simd || !amd64 - -package plotter - -func findMinMaxFloat64(data []float64) (float64, float64) { - min, max := data[0], data[0] - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index 2e830255..a4d7dff6 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -16,6 +16,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" ) // var _ fyne.Focusable = (*Plotter)(nil) @@ -25,10 +26,6 @@ var ( _ fyne.Widget = (*Plotter)(nil) ) -type PlotterControl interface { - Seek(int) -} - // plotBackend selects how the plot is drawn. The GPU shader is the default; // TXLOGGER_PLOT_RENDERER=image selects the CPU rasterizer, which is also the // automatic fallback when the log does not fit the shader's texture layout. @@ -447,7 +444,7 @@ func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { if min, max, known := defaultRange(name); known { ts.Min, ts.Max = min, max } else { - ts.Min, ts.Max = findMinMaxFloat64(data) + ts.Min, ts.Max = common.FindMinMaxFloat64(data) } ts.valueRange = ts.Max - ts.Min @@ -579,8 +576,8 @@ func (p *Plotter) layoutCursor() { // Account for zoom slider width and ensure cursor stays within plot bounds xOffset := p.zoom.Size().Width + x - xOffset = min32(xOffset, plotSize.Width+p.zoom.Size().Width) - xOffset = max32(xOffset, p.zoom.Size().Width) + xOffset = min(xOffset, plotSize.Width+p.zoom.Size().Width) + xOffset = max(xOffset, p.zoom.Size().Width) p.cursor.Position1 = fyne.NewPos(xOffset, 0) p.cursor.Position2 = fyne.NewPos(xOffset+1, plotSize.Height) @@ -595,18 +592,3 @@ func (p *Plotter) updateCursor(goroutine bool) { p.cursor.Refresh() } } - -// Helper functions -func min32(a, b float32) float32 { - if a < b { - return a - } - return b -} - -func max32(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/settings/wbleditor.go b/pkg/widgets/settings/wbleditor.go index b30774fe..72daadc8 100644 --- a/pkg/widgets/settings/wbleditor.go +++ b/pkg/widgets/settings/wbleditor.go @@ -106,7 +106,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.y = clampInt(m.yFromVolt(v), yMin, yMax) + r.y = common.Clamp(m.yFromVolt(v), yMin, yMax) r.ye.Text = strconv.Itoa(r.y) r.ye.Refresh() m.refreshGraph() @@ -120,7 +120,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.y = clampInt(v, yMin, yMax) + r.y = common.Clamp(v, yMin, yMax) r.vo.Text = fmt.Sprintf("%.2f", m.voltFromY(r.y)) r.vo.Refresh() m.refreshGraph() @@ -135,7 +135,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.z = clampFloat(v, zMin, zMax) + r.z = common.Clamp(v, zMin, zMax) m.refreshGraph() m.save() } diff --git a/pkg/widgets/settings/wblgraph.go b/pkg/widgets/settings/wblgraph.go index b6f72f2b..ba2f4640 100644 --- a/pkg/widgets/settings/wblgraph.go +++ b/pkg/widgets/settings/wblgraph.go @@ -7,6 +7,7 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" ) // --- graph view (native fyne primitives) ----------------------------------- @@ -178,12 +179,12 @@ func (r *graphRenderer) layoutGraph() { const minYSpan, minZSpan = 50, 0.2 if maxY-minY < minYSpan { mid := (maxY + minY) / 2 - minY = clampInt(mid-minYSpan/2, yMin, yMax-minYSpan) + minY = common.Clamp(mid-minYSpan/2, yMin, yMax-minYSpan) maxY = minY + minYSpan } if maxZ-minZ < minZSpan { mid := (maxZ + minZ) / 2 - minZ = clampFloat(mid-minZSpan/2, zMin, zMax-minZSpan) + minZ = common.Clamp(mid-minZSpan/2, zMin, zMax-minZSpan) maxZ = minZ + minZSpan } r.minYv, r.maxYv = minY, maxY @@ -293,8 +294,8 @@ func (p *draggablePoint) Dragged(e *fyne.DragEvent) { step := int(p.accY) p.accY -= float64(step) - p.row.y = clampInt(p.row.y+step, yMin, yMax) - p.row.z = clampFloat(p.row.z+dZ, zMin, zMax) + p.row.y = common.Clamp(p.row.y+step, yMin, yMax) + p.row.z = common.Clamp(p.row.z+dZ, zMin, zMax) p.g.editor.updateRowEntries(p.row) p.g.Refresh() @@ -304,23 +305,3 @@ func (p *draggablePoint) DragEnd() { p.accY = 0 p.g.editor.save() } - -func clampInt(v, lo, hi int) int { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -func clampFloat(v, lo, hi float64) float64 { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} diff --git a/pprof.go b/pprof.go index f86b4a01..055522d2 100644 --- a/pprof.go +++ b/pprof.go @@ -6,9 +6,22 @@ import ( "log" "net/http" _ "net/http/pprof" + "os" + "os/signal" + "runtime/pprof" + "syscall" ) func init() { + // kill -USR1 to dump leaks + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGUSR1) + go func() { + for range sig { + pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1) + } + }() + go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }() From d8195464cc868ab374256c9fe0976a3dbeceb5b7 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 25 Jun 2026 22:58:35 +0200 Subject: [PATCH 88/93] another round of optimizations --- go.mod | 10 ++- go.sum | 20 +++--- pkg/common/common.go | 40 ++++++++++++ pkg/common/math.go | 41 ------------ pkg/datalogger/channel.go | 45 +++++++------ pkg/datalogger/log.go | 4 -- pkg/datalogger/log_export.go | 6 +- pkg/datalogger/log_txl.go | 24 ++++--- pkg/datalogger/log_txl_test.go | 41 ++++++++++++ pkg/datalogger/t7logger.go | 14 +++-- pkg/datalogger/txbridgelogger_t7.go | 9 +-- pkg/ecu/t7/erase.go | 5 +- pkg/widgets/cbar/cbar.go | 4 -- pkg/widgets/dashboard/dashboard.go | 6 +- pkg/widgets/dial/dial.go | 63 +++++-------------- pkg/widgets/dial/dial.old | 2 +- pkg/widgets/dial/shader/dial.go | 2 +- pkg/widgets/dualdial/dual_dial.go | 2 +- pkg/widgets/dualdial/dual_dial.old | 2 +- pkg/widgets/dualdial/shader/dual_dial.go | 2 +- pkg/widgets/gauge/gauge.go | 17 +++-- pkg/widgets/hbar/hbar.go | 4 -- pkg/widgets/icon/icon.go | 8 +-- pkg/widgets/igauge.go | 12 ---- pkg/widgets/ledicon/ledicon.go | 10 +-- pkg/widgets/logplayer/logplayer.go | 56 ++++++++++++++--- pkg/widgets/logplayer/playback_timing_test.go | 61 ++++++++++++++++++ pkg/widgets/mapviewer/mapviewer.go | 2 +- pkg/widgets/msglist/msglist.go | 60 ++++++------------ pkg/widgets/multiwindow/arrange.go | 22 +++---- pkg/widgets/multiwindow/innerwindow.go | 21 ++++--- pkg/widgets/multiwindow/multiplewindows.go | 16 ++--- pkg/widgets/symbollist/symbollist.go | 9 ++- .../tappabletext.go} | 40 ++++-------- pkg/widgets/vbar/vbar.go | 4 -- pkg/windows/help.go | 11 +++- pkg/windows/mainWindow.go | 11 +--- pkg/windows/mainWindow_menu.go | 2 +- pkg/windows/mainwindow_layout.go | 12 ++-- 39 files changed, 392 insertions(+), 328 deletions(-) create mode 100644 pkg/datalogger/log_txl_test.go delete mode 100644 pkg/widgets/igauge.go create mode 100644 pkg/widgets/logplayer/playback_timing_test.go rename pkg/widgets/{secrettext/secrettext.go => tappabletext/tappabletext.go} (65%) diff --git a/go.mod b/go.mod index 246e7606..8291d2a1 100644 --- a/go.mod +++ b/go.mod @@ -16,9 +16,9 @@ require ( github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.2.3 - github.com/roffe/gocan v1.4.1 - go.bug.st/serial v1.6.4 + github.com/roffe/ecusymbol v1.2.4 + github.com/roffe/gocan v1.4.2 + go.bug.st/serial v1.7.1 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 golang.org/x/net v0.54.0 @@ -32,7 +32,7 @@ require ( github.com/ebitengine/oto/v3 v3.4.0 github.com/godbus/dbus/v5 v5.2.2 github.com/hajimehoshi/go-mp3 v0.3.4 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.46.0 kernel.org/pub/linux/libs/security/libcap/cap v1.2.78 ) @@ -40,10 +40,8 @@ require ( fyne.io/systray v1.12.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/FyshOS/fancyfs v0.0.1 // indirect - github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7 // indirect github.com/anthonynsimon/bild v0.13.0 // indirect github.com/bendikro/dl v0.0.0-20190410215913-e41fdb9069d4 // indirect - github.com/creack/goselect v0.1.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/fatih/color v1.19.0 // indirect diff --git a/go.sum b/go.sum index a2e0f595..c7da6d40 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,6 @@ github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7 h1:m3Ayfs5OcAlIMEdLIQKubBsVLGee4YMUr14+d1256WE= -github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7/go.mod h1:QIAMbrwsnQZ2ES3G26RubSrDB5SPyzsp9Hts5NJdTrI= github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -26,8 +24,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= -github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -136,10 +132,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.2.3 h1:/Ng+yRyIaDRp72KpBOjwS+wa4mcKp83b2fBn2rY/DuE= -github.com/roffe/ecusymbol v1.2.3/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.4.1 h1:T9aAHzTxS7oXwiOlMIM2TAf75aMHcEaWAi27nKwEFQk= -github.com/roffe/gocan v1.4.1/go.mod h1:qGgFX3osetru/58avh4tQMwThQet+ckqdg0kGM3cG9o= +github.com/roffe/ecusymbol v1.2.4 h1:5MGAs7e6djTAaa4y8bpByATMXjgq7/khLJqff3t3Zx0= +github.com/roffe/ecusymbol v1.2.4/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= +github.com/roffe/gocan v1.4.2 h1:8kx7UjE+akgs3OwRo/t3jaCIl6Bvr3GMqDPuzosc6oE= +github.com/roffe/gocan v1.4.2/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -168,8 +164,8 @@ github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhf github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= -go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= +go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -213,8 +209,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/pkg/common/common.go b/pkg/common/common.go index 27e35eef..bdb8c7d4 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -1,6 +1,7 @@ package common import ( + "cmp" "fmt" "math" "os" @@ -185,3 +186,42 @@ func SameTextBytes(s string, b []byte) bool { } return true } + +func Abs(n int) int { + if n < 0 { + return -n + } + return n +} + +type Number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +// Generic function that works with any numeric type +func FindMinMax[T Number](data []T) (T, T) { + if len(data) == 0 { + panic("empty slice") + } + + min, max := data[0], data[0] + for _, v := range data { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} + +func Clamp[T cmp.Ordered](value, min, max T) T { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/pkg/common/math.go b/pkg/common/math.go index 84b57c7e..57a2482f 100644 --- a/pkg/common/math.go +++ b/pkg/common/math.go @@ -2,15 +2,6 @@ package common -import "cmp" - -func Abs(n int) int { - if n < 0 { - return -n - } - return n -} - func FindMinMaxFloat64(data []float64) (float64, float64) { min, max := data[0], data[0] for _, v := range data { @@ -23,35 +14,3 @@ func FindMinMaxFloat64(data []float64) (float64, float64) { } return min, max } - -type Number interface { - ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 -} - -// Generic function that works with any numeric type -func FindMinMax[T Number](data []T) (T, T) { - if len(data) == 0 { - panic("empty slice") - } - - min, max := data[0], data[0] - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} - -func Clamp[T cmp.Ordered](value, min, max T) T { - if value < min { - return min - } - if value > max { - return max - } - return value -} diff --git a/pkg/datalogger/channel.go b/pkg/datalogger/channel.go index e347be14..90c91f6b 100644 --- a/pkg/datalogger/channel.go +++ b/pkg/datalogger/channel.go @@ -16,25 +16,32 @@ import ( // see a flat, ordered list of named channels and never need to know about // sysvars vs symbols or sync vs async values. type Channel struct { - Name string - read func() float64 - format func(float64) string + Name string + read func() float64 + // appendFmt formats the value into dst and returns the extended slice, + // letting writers format straight into a reused buffer with no per-sample + // string garbage. + appendFmt func(dst []byte, v float64) []byte } // Value returns the current value of the channel. func (c *Channel) Value() float64 { return c.read() } +// Append formats the current value as text into dst and returns the extended +// slice. Allocation-free when dst has spare capacity. +func (c *Channel) Append(dst []byte) []byte { return c.appendFmt(dst, c.read()) } + // String returns the current value formatted as text. -func (c *Channel) String() string { return c.format(c.read()) } +func (c *Channel) String() string { return string(c.appendFmt(nil, c.read())) } // newSysvarChannel reads the latest value of a named entry in the shared sysvars // map. Used for asynchronously updated values such as T7 broadcast frames, the // wideband and AD scanner lambda and other derived values. func newSysvarChannel(sysvars *ThreadSafeMap, name string) Channel { return Channel{ - Name: name, - read: func() float64 { return sysvars.Get(name) }, - format: sysvarFormat(name), + Name: name, + read: func() float64 { return sysvars.Get(name) }, + appendFmt: sysvarFormat(name), } } @@ -42,25 +49,25 @@ func newSysvarChannel(sysvars *ThreadSafeMap, name string) Channel { // payload Read. func newSymbolChannel(sym *symbol.Symbol) Channel { return Channel{ - Name: sym.Name, - read: sym.Float64, - format: symbolFormat(sym.Correctionfactor), + Name: sym.Name, + read: sym.Float64, + appendFmt: symbolFormat(sym.Correctionfactor), } } func newFunctionChannel(name string, read func() float64) Channel { return Channel{ - Name: name, - read: read, - format: func(float64) string { return strconv.FormatFloat(read(), 'f', 2, 64) }, + Name: name, + read: read, + appendFmt: func(dst []byte, v float64) []byte { return strconv.AppendFloat(dst, v, 'f', 2, 64) }, } } // sysvarFormat mirrors the precision rules the text writers used for sysvars: // whole numbers print without decimals, the external wideband lambda prints // with three decimals and everything else with two. -func sysvarFormat(name string) func(float64) string { - return func(v float64) string { +func sysvarFormat(name string) func(dst []byte, v float64) []byte { + return func(dst []byte, v float64) []byte { prec := 2 switch { case v == math.Trunc(v): @@ -68,13 +75,13 @@ func sysvarFormat(name string) func(float64) string { case name == EXTERNALWBLSYM: prec = 3 } - return strconv.FormatFloat(v, 'f', prec, 64) + return strconv.AppendFloat(dst, v, 'f', prec, 64) } } // symbolFormat mirrors symbol.StringValue: the number of decimals is derived // from the symbol correction factor. -func symbolFormat(correctionfactor float64) func(float64) string { +func symbolFormat(correctionfactor float64) func(dst []byte, v float64) []byte { prec := 0 switch correctionfactor { case 0.1: @@ -84,7 +91,7 @@ func symbolFormat(correctionfactor float64) func(float64) string { case 0.001: prec = 3 } - return func(v float64) string { - return strconv.FormatFloat(v, 'f', prec, 64) + return func(dst []byte, v float64) []byte { + return strconv.AppendFloat(dst, v, 'f', prec, 64) } } diff --git a/pkg/datalogger/log.go b/pkg/datalogger/log.go index 5efd88cd..1bb87b76 100644 --- a/pkg/datalogger/log.go +++ b/pkg/datalogger/log.go @@ -54,7 +54,3 @@ func createLog(path, prefix, extension string) (*os.File, string, error) { } return file, fullFilename, nil } - -func replaceDot(s string) string { - return strings.Replace(s, ".", ",", 1) -} diff --git a/pkg/datalogger/log_export.go b/pkg/datalogger/log_export.go index 4d16bc8f..dac69d32 100644 --- a/pkg/datalogger/log_export.go +++ b/pkg/datalogger/log_export.go @@ -48,9 +48,9 @@ func ExportRecords(dir, prefix, ext string, records []logfile.Record) (string, e for i, name := range cols { i := i channels[i] = Channel{ - Name: name, - read: func() float64 { return values[i] }, - format: sysvarFormat(name), + Name: name, + read: func() float64 { return values[i] }, + appendFmt: sysvarFormat(name), } } diff --git a/pkg/datalogger/log_txl.go b/pkg/datalogger/log_txl.go index e323cf3c..b10019ad 100644 --- a/pkg/datalogger/log_txl.go +++ b/pkg/datalogger/log_txl.go @@ -13,19 +13,29 @@ func NewTXLWriter(f *os.File) *TXWriter { type TXWriter struct { file *os.File + buf []byte // reusable per-line buffer } func (t *TXWriter) Write(ts time.Time, channels []Channel) error { - _, err := t.file.Write([]byte(ts.Format("02-01-2006 15:04:05.999") + "|")) - if err != nil { - return err - } + t.buf = ts.AppendFormat(t.buf[:0], "02-01-2006 15:04:05.999") + t.buf = append(t.buf, '|') for i := range channels { - if _, err := t.file.Write([]byte(channels[i].Name + "=" + replaceDot(channels[i].String()) + "|")); err != nil { - return err + t.buf = append(t.buf, channels[i].Name...) + t.buf = append(t.buf, '=') + // Format the value straight into buf, then swap the first decimal '.' + // for ',' in place (the TXL/European separator). No per-sample string. + start := len(t.buf) + t.buf = channels[i].Append(t.buf) + for j := start; j < len(t.buf); j++ { + if t.buf[j] == '.' { + t.buf[j] = ',' + break + } } + t.buf = append(t.buf, '|') } - _, err = t.file.Write([]byte("IMPORTANTLINE=0|\n")) + t.buf = append(t.buf, "IMPORTANTLINE=0|\n"...) + _, err := t.file.Write(t.buf) return err } diff --git a/pkg/datalogger/log_txl_test.go b/pkg/datalogger/log_txl_test.go new file mode 100644 index 00000000..f53becdf --- /dev/null +++ b/pkg/datalogger/log_txl_test.go @@ -0,0 +1,41 @@ +package datalogger + +import ( + "os" + "strings" + "testing" + "time" +) + +// Verifies the append-style formatting still produces the TXL line format: +// "ts|Name=value|...|IMPORTANTLINE=0|" with the first decimal '.' swapped for ','. +func TestTXWriterLineFormat(t *testing.T) { + sv := NewThreadSafeMap() + sv.Set("Rpm", 3000) // whole number -> no decimals + sv.Set("Lambda", 0.987) // decimal -> comma separator + sv.Set("Lambda.External", 0.987) + chans := []Channel{newSysvarChannel(sv, "Rpm"), newSysvarChannel(sv, "Lambda"), newSysvarChannel(sv, "Lambda.External")} + + f, err := os.CreateTemp(t.TempDir(), "*.t7l") + if err != nil { + t.Fatal(err) + } + w := NewTXLWriter(f) + ts := time.Date(2026, 6, 25, 12, 30, 0, 0, time.UTC) + if err := w.Write(ts, chans); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + line := strings.TrimRight(string(got), "\n") + want := "25-06-2026 12:30:00|Rpm=3000|Lambda=0,99|Lambda.External=0,987|IMPORTANTLINE=0|" + if line != want { + t.Fatalf("got %q\nwant %q", line, want) + } +} diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index ab536d2b..f5ae34b5 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -25,7 +25,7 @@ func NewT7(cfg Config, lw LogWriter) (IClient, error) { } func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadSafeMap) { - broadcast := cl.Subscribe(ctx, 0x1A0, 0x280, 0x3A0) + broadcast := cl.Subscribe(ctx, 0x1A0, 0x280, 0x3A0 /*, 0x5C0*/) defer broadcast.Close() var speed uint16 var rpm uint16 @@ -50,7 +50,6 @@ func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadS limp = msg.Data[3] & 0x01 cel = msg.Data[4] & 0x80 >> 7 cruise = msg.Data[4] & 0x20 >> 5 - gear = msg.Data[1] sysvars.Set("Out.X_ActualGear", float64(gear)) brakeLight = msg.Data[2] & 0x02 >> 1 @@ -61,11 +60,16 @@ func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadS ebus.Publish("LIMP", float64(limp)) ebus.Publish("CRUISE", float64(cruise)) ebus.Publish("CEL", float64(cel)) - case 0x3A0: speed = uint16(msg.Data[4]) | uint16(msg.Data[3])<<8 realSpeed = float64(speed) * 0.1 sysvars.Set("In.v_Vehicle", realSpeed) + case 0x5C0: + // 0x5C0 COTE_ECS: Data[1]=coolant. byte=(u8)(V+40), + coolant := float64(msg.Data[1]) - 40 + sysvars.Set("ActualIn.T_Engine", coolant) + // log.Printf("0x5C0: % X valid=%t coolant=%v", msg.Data, msg.Data[0]&0x10 != 0, coolant) + } } } @@ -109,7 +113,7 @@ func (c *T7Client) Start() error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) if found := c.sysvars.Keys(); len(found) > 0 { - c.OnMessage(fmt.Sprintf("Found %s", found)) + c.OnMessage(fmt.Sprintf("Found: %s", strings.Join(found, ", "))) } else { c.OnMessage("No broadcast messages found, stopping broadcast listener") bcancel() @@ -354,7 +358,7 @@ func (c *T7Client) Start() error { func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.Symbol, onMessage func(string)) error { if err := kwp.StartSession(ctx, kwp2000.INIT_MSG_ID, kwp2000.INIT_RESP_ID); err != nil { - return errors.New("failed to start session") + return fmt.Errorf("failed to start session: %w", err) } onMessage("Connected to ECU") diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index cbf52f84..f3220461 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "fmt" "log" + "strings" "time" symbol "github.com/roffe/ecusymbol" @@ -25,10 +26,10 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) - found := c.sysvars.Keys() - c.OnMessage(fmt.Sprintf("Found %s", found)) - - if len(found) == 0 { + if found := c.sysvars.Keys(); len(found) > 0 { + c.OnMessage(fmt.Sprintf("Found: %s", strings.Join(found, ", "))) + } else { + c.OnMessage("No broadcast messages found, stopping broadcast listener") bcancel() } diff --git a/pkg/ecu/t7/erase.go b/pkg/ecu/t7/erase.go index 34013380..d32a06f2 100644 --- a/pkg/ecu/t7/erase.go +++ b/pkg/ecu/t7/erase.go @@ -7,11 +7,12 @@ import ( "time" "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/kwp2000" ) func (t *Client) EraseECU(ctx context.Context) error { data := make([]byte, 8) - eraseMsg := []byte{0x40, 0xA1, 0x02, 0x31, 0x52, 0x00, 0x00, 0x00} + eraseMsg := []byte{0x40, 0xA1, 0x02, kwp2000.START_ROUTINE_BY_IDENTIFIER, kwp2000.RLI_EOL_START, 0x00, 0x00, 0x00} confirmMsg := []byte{0x40, 0xA1, 0x01, 0x3E, 0x00, 0x00, 0x00, 0x00} t.cfg.OnProgress(-float64(17)) @@ -41,7 +42,7 @@ func (t *Client) EraseECU(ctx context.Context) error { // Start erase routine data[3] = 0 i = 0 - eraseMsg[4] = 0x53 + eraseMsg[4] = kwp2000.RLI_ERASE for data[3] != 0x71 && i < 200 { f, err := t.c.SendAndWait(ctx, gocan.NewFrame(0x240, eraseMsg, gocan.ResponseRequired), t.defaultTimeout, 0x258) if err != nil { diff --git a/pkg/widgets/cbar/cbar.go b/pkg/widgets/cbar/cbar.go index 774530f1..31958ba2 100644 --- a/pkg/widgets/cbar/cbar.go +++ b/pkg/widgets/cbar/cbar.go @@ -193,10 +193,6 @@ func (s *CBar) updateDisplayTextPosition() { s.displayText.Move(fyne.Position{X: x, Y: s.displayY}) } -func (s *CBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *CBar) CreateRenderer() fyne.WidgetRenderer { // Initialize visual elements s.initializeVisualElements() diff --git a/pkg/widgets/dashboard/dashboard.go b/pkg/widgets/dashboard/dashboard.go index cc82947e..e11c4023 100644 --- a/pkg/widgets/dashboard/dashboard.go +++ b/pkg/widgets/dashboard/dashboard.go @@ -525,7 +525,7 @@ func (db *Dashboard) layoutIcons(dims *dims) { }) // Taz icon - tazMin := fyne.Min(dims.sixthWidth, dims.thirdHeight) + tazMin := min(dims.sixthWidth, dims.thirdHeight) tazSize := fyne.Size{Width: tazMin, Height: tazMin + 16} db.image.taz.Resize(tazSize) @@ -672,10 +672,6 @@ func (dr *DashboardRenderer) Destroy() { } func (dr *DashboardRenderer) Objects() []fyne.CanvasObject { - // The object set is fixed for the lifetime of the renderer, so build it - // once and reuse it. Fyne calls Objects() on every render/refresh pass, - // and during live logging this would otherwise allocate a new slice each - // time, creating needless GC pressure. if dr.objects == nil { dr.objects = []fyne.CanvasObject{ dr.db.image.wheelLeft, diff --git a/pkg/widgets/dial/dial.go b/pkg/widgets/dial/dial.go index ac03dabc..d06bdfc4 100644 --- a/pkg/widgets/dial/dial.go +++ b/pkg/widgets/dial/dial.go @@ -4,7 +4,6 @@ import ( "image/color" "math" "strconv" - "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -19,13 +18,9 @@ type Dial struct { cfg *widgets.GaugeConfig - factor float64 - value float64 - highestObserved float64 + value float64 - needle *canvas.Line - highestObservedMarker *canvas.Line - lastHighestObserved time.Time + needle *canvas.Line pips []*canvas.Line pipLabels []*canvas.Text @@ -93,12 +88,9 @@ func New(cfg *widgets.GaugeConfig) *Dial { totalRange = 1 } - c.factor = c.cfg.Max / steps - c.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) c.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} c.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 3} - c.highestObservedMarker = &canvas.Line{StrokeColor: color.RGBA{R: 216, G: 250, B: 8, A: 0xFF}, StrokeWidth: 6} c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} c.titleText.TextStyle.Monospace = true @@ -137,7 +129,6 @@ func New(cfg *widgets.GaugeConfig) *Dial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } - // lbl.TextStyle.Monospace = true if n := len(txt); n > c.maxLabelChars { c.maxLabelChars = n } @@ -200,20 +191,6 @@ func (c *Dial) SetValue(value float64) { // Update needle position (no immediate refresh) c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) - // Highest observed marker with lazy reset; only refresh when it actually moves - markerMoved := false - if value > c.highestObserved { - c.highestObserved = value - c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true - } else if time.Since(c.lastHighestObserved) > 10*time.Second { - c.highestObserved = value - c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true - } - // Update text with minimal allocs; skip refresh if formatted output is unchanged c.buf = c.buf[:0] if c.fmtPrec >= 0 { @@ -227,30 +204,26 @@ func (c *Dial) SetValue(value float64) { } canvas.Refresh(c.needle) - if markerMoved { - canvas.Refresh(c.highestObservedMarker) - } if textChanged { canvas.Refresh(c.displayText) } } -func (c *Dial) SetValue2(value float64) { c.SetValue(value) } - -func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{Dial: c} } +func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{d: c} } type DialRenderer struct { - *Dial + d *Dial objects []fyne.CanvasObject } -func (c *DialRenderer) Layout(space fyne.Size) { +func (r *DialRenderer) Layout(space fyne.Size) { + c := r.d if c.size == space { return } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) c.needleOffset = -c.radius * .15 @@ -329,18 +302,16 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) } } - - c.highestObservedMarker.StrokeWidth = max(2.0, midStroke) - c.rotateNeedleNoRefresh(c.highestObservedMarker, c.highestObserved, c.radius-2, 6) } -func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } -func (c *DialRenderer) Refresh() {} -func (c *DialRenderer) Destroy() {} +func (r *DialRenderer) MinSize() fyne.Size { return r.d.minsize } +func (r *DialRenderer) Refresh() {} +func (r *DialRenderer) Destroy() {} -func (c *DialRenderer) Objects() []fyne.CanvasObject { - if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) +func (r *DialRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + c := r.d + objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+6) for _, v := range c.pips { objs = append(objs, v) } @@ -350,8 +321,8 @@ func (c *DialRenderer) Objects() []fyne.CanvasObject { } } objs = append(objs, c.face, c.titleText, c.center, - c.highestObservedMarker, c.needle, c.displayText) - c.objects = objs + c.needle, c.displayText) + r.objects = objs } - return c.objects + return r.objects } diff --git a/pkg/widgets/dial/dial.old b/pkg/widgets/dial/dial.old index 4eaf3858..f3a76afe 100644 --- a/pkg/widgets/dial/dial.old +++ b/pkg/widgets/dial/dial.old @@ -233,7 +233,7 @@ func (c *DialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = fmin(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) c.needleOffset = -c.radius * .15 diff --git a/pkg/widgets/dial/shader/dial.go b/pkg/widgets/dial/shader/dial.go index e9697cb3..d7a36011 100644 --- a/pkg/widgets/dial/shader/dial.go +++ b/pkg/widgets/dial/shader/dial.go @@ -205,7 +205,7 @@ func (c *DialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) diff --git a/pkg/widgets/dualdial/dual_dial.go b/pkg/widgets/dualdial/dual_dial.go index 5bd1f64c..6e7db05e 100644 --- a/pkg/widgets/dualdial/dual_dial.go +++ b/pkg/widgets/dualdial/dual_dial.go @@ -251,7 +251,7 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) diff --git a/pkg/widgets/dualdial/dual_dial.old b/pkg/widgets/dualdial/dual_dial.old index 8a25f9b3..ea3a3719 100644 --- a/pkg/widgets/dualdial/dual_dial.old +++ b/pkg/widgets/dualdial/dual_dial.old @@ -231,7 +231,7 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) diff --git a/pkg/widgets/dualdial/shader/dual_dial.go b/pkg/widgets/dualdial/shader/dual_dial.go index 42afe371..9bec975e 100644 --- a/pkg/widgets/dualdial/shader/dual_dial.go +++ b/pkg/widgets/dualdial/shader/dual_dial.go @@ -219,7 +219,7 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) diff --git a/pkg/widgets/gauge/gauge.go b/pkg/widgets/gauge/gauge.go index d93ab59d..03f8dc23 100644 --- a/pkg/widgets/gauge/gauge.go +++ b/pkg/widgets/gauge/gauge.go @@ -3,6 +3,7 @@ package gauge import ( "errors" + "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/widgets" "github.com/roffe/txlogger/pkg/widgets/cbar" @@ -12,29 +13,33 @@ import ( "github.com/roffe/txlogger/pkg/widgets/vbar" ) -func New(cfg *widgets.GaugeConfig) (widgets.IGauge, []func(), error) { +func New(cfg *widgets.GaugeConfig) (fyne.CanvasObject, func(), error) { switch cfg.Type { case "Dial": dial := dial.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, dial.SetValue) - return dial, []func(){cancel}, nil + return dial, cancel, nil case "DualDial": ddial := dualdial.New(cfg) cancel1 := ebus.SubscribeFunc(cfg.SymbolName, ddial.SetValue) cancel2 := ebus.SubscribeFunc(cfg.SymbolNameSecondary, ddial.SetValue2) - return ddial, []func(){cancel1, cancel2}, nil + cancelFn := func() { + cancel1() + cancel2() + } + return ddial, cancelFn, nil case "VBar": vb := vbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, vb.SetValue) - return vb, []func(){cancel}, nil + return vb, cancel, nil case "HBar": hb := hbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, hb.SetValue) - return hb, []func(){cancel}, nil + return hb, cancel, nil case "CBar": cb := cbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, cb.SetValue) - return cb, []func(){cancel}, nil + return cb, cancel, nil } return nil, nil, errors.New("unknown gauge type") } diff --git a/pkg/widgets/hbar/hbar.go b/pkg/widgets/hbar/hbar.go index 633f733e..de0fa3ab 100644 --- a/pkg/widgets/hbar/hbar.go +++ b/pkg/widgets/hbar/hbar.go @@ -130,10 +130,6 @@ func (s *HBar) SetValue(value float64) { } } -func (s *HBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *HBar) Value() float64 { return s.value } diff --git a/pkg/widgets/icon/icon.go b/pkg/widgets/icon/icon.go index 81704f88..8e6ee8de 100644 --- a/pkg/widgets/icon/icon.go +++ b/pkg/widgets/icon/icon.go @@ -46,7 +46,10 @@ func (ic *Icon) SetText(text string) { } func (ic *Icon) CreateRenderer() fyne.WidgetRenderer { - return &IconRenderer{IC: ic} + return &IconRenderer{ + IC: ic, + objects: []fyne.CanvasObject{ic.cfg.Image, ic.text}, + } } type IconRenderer struct { @@ -72,8 +75,5 @@ func (ic *IconRenderer) Destroy() { } func (ic *IconRenderer) Objects() []fyne.CanvasObject { - if ic.objects == nil { - ic.objects = []fyne.CanvasObject{ic.IC.cfg.Image, ic.IC.text} - } return ic.objects } diff --git a/pkg/widgets/igauge.go b/pkg/widgets/igauge.go deleted file mode 100644 index b7368da4..00000000 --- a/pkg/widgets/igauge.go +++ /dev/null @@ -1,12 +0,0 @@ -package widgets - -import ( - "fyne.io/fyne/v2" -) - -type IGauge interface { - fyne.Widget - SetValue(float64) - SetValue2(float64) - GetConfig() *GaugeConfig -} diff --git a/pkg/widgets/ledicon/ledicon.go b/pkg/widgets/ledicon/ledicon.go index f3de64a9..6d74049f 100644 --- a/pkg/widgets/ledicon/ledicon.go +++ b/pkg/widgets/ledicon/ledicon.go @@ -25,7 +25,7 @@ func New(text string) *Widget { ColorOff: color.RGBA{0x80, 0x80, 0x80, 0xFF}, } w.ExtendBaseWidget(w) - w.ledicon = &canvas.Circle{FillColor: color.RGBA{0x80, 0x80, 0x80, 0xFF}} + w.ledicon = &canvas.Circle{FillColor: w.ColorOff} w.label = widget.NewLabel(text) return w } @@ -52,7 +52,10 @@ func (w *Widget) SetState(state bool) { } func (w *Widget) CreateRenderer() fyne.WidgetRenderer { - return &iconRenderer{w: w} + return &iconRenderer{ + w: w, + objects: []fyne.CanvasObject{w.ledicon, w.label}, + } } var _ fyne.WidgetRenderer = (*iconRenderer)(nil) @@ -82,8 +85,5 @@ func (r *iconRenderer) Destroy() { } func (r *iconRenderer) Objects() []fyne.CanvasObject { - if r.objects == nil { - r.objects = []fyne.CanvasObject{r.w.ledicon, r.w.label} - } return r.objects } diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index 7b88e19f..4e83b618 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -261,6 +261,7 @@ func (l *Logplayer) render() { l.objs.selectionLabel = widget.NewLabel("") l.updateSelectionLabel() + n := l.logFile.Len() values := make(map[string][]float64) for { if rec := l.logFile.Next(); !rec.EOF { @@ -268,7 +269,11 @@ func (l *Logplayer) render() { if k == "Pgm_status" { continue } - values[k] = append(values[k], v) + s, ok := values[k] + if !ok { + s = make([]float64, 0, n) // ponytail: cap once, kills append regrowth churn + } + values[k] = append(s, v) } } else { break @@ -492,12 +497,40 @@ func (l *Logplayer) exportSelection() { l.cfg.OnExport(records) } +// frameTarget returns the wall-clock instant a record should play at, given an +// anchor (anchorWall pinned to anchorRec) and a speed multiplier where larger +// means slower (1 = real time). Because the target is computed from the record's +// own timestamp relative to the fixed anchor, scheduling jitter on earlier +// frames does not shift it — error stays bounded instead of accumulating. +func frameTarget(anchorWall, anchorRec, recTime time.Time, speed float64) time.Time { + return anchorWall.Add(time.Duration(float64(recTime.Sub(anchorRec)) * speed)) +} + func (l *Logplayer) playLog() { speedMultiplier := 1.0 timer := time.NewTimer(0) defer timer.Stop() - var nextDelay time.Duration + // Absolute scheduling: anchorWall maps to anchorRec (the wall-clock instant + // the current record's timeline position was pinned). Every frame is + // scheduled against its record's true timestamp relative to this anchor, so + // per-frame scheduling jitter cannot accumulate into drift — a late frame + // fires immediately and re-syncs instead of pushing the whole timeline back. + var anchorWall, anchorRec time.Time + + // reanchor pins the timeline to the current record at the current instant. + // Called whenever playback (re)starts or jumps: play, seek, step, speed change. + reanchor := func() { + anchorWall = time.Now() + anchorRec = l.logFile.Get().Time + } + + // schedule arms the timer for the next record using its real timestamp. + // time.Until goes negative when we're behind, firing immediately to catch up. + schedule := func() { + next := l.logFile.RecordAt(l.logFile.Pos() + 1) + timer.Reset(time.Until(frameTarget(anchorWall, anchorRec, next.Time, speedMultiplier))) + } timeSetter := func(t time.Time) { timeText := t.Format("15:04:05.00") @@ -515,6 +548,10 @@ func (l *Logplayer) playLog() { switch op.Op { case OpPlaybackSpeed: speedMultiplier = op.Rate + if l.state == statePlaying { + reanchor() + schedule() + } case OpSeek: l.logFile.Seek(op.Pos) if rec := l.logFile.Get(); !rec.EOF { @@ -524,7 +561,8 @@ func (l *Logplayer) playLog() { f(rec.Time) } if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) @@ -545,7 +583,8 @@ func (l *Logplayer) playLog() { }) if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) @@ -565,7 +604,8 @@ func (l *Logplayer) playLog() { l.objs.positionSlider.Value = float64(pos) timeSetter(rec.Time) if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) @@ -579,7 +619,8 @@ func (l *Logplayer) playLog() { } case OpPlay: l.state = statePlaying - timer.Reset(0) // Start playback immediately + reanchor() + schedule() case OpPause: l.state = statePaused timer.Stop() @@ -591,8 +632,7 @@ func (l *Logplayer) playLog() { } if rec := l.logFile.Next(); !rec.EOF { currentPos := l.logFile.Pos() - nextDelay = time.Duration(float64(rec.DelayTillNext)*speedMultiplier) * time.Millisecond - timer.Reset(nextDelay) + schedule() l.objs.positionSlider.Value = float64(currentPos) timeText := rec.Time.Format("15:04:05.00") diff --git a/pkg/widgets/logplayer/playback_timing_test.go b/pkg/widgets/logplayer/playback_timing_test.go new file mode 100644 index 00000000..d69f2c0d --- /dev/null +++ b/pkg/widgets/logplayer/playback_timing_test.go @@ -0,0 +1,61 @@ +package logplayer + +import ( + "testing" + "time" +) + +// TestFrameTargetNoDrift simulates a jittery scheduler (every wake-up is a few ms +// late, as on a loaded Windows box) and checks that absolute scheduling keeps the +// per-frame timing error bounded, while the old relative-reset scheme accumulates +// it into unbounded drift. Same jitter, two schemes. +func TestFrameTargetNoDrift(t *testing.T) { + const ( + frames = 5000 + gap = 10 * time.Millisecond // recording cadence + jitter = 2 * time.Millisecond // how late each wake-up fires + speed = 1.0 // real time + ) + + start := time.Unix(0, 0) + recTime := func(i int) time.Time { return start.Add(time.Duration(i) * gap) } + + // Absolute: target is recomputed from the fixed anchor every frame. + anchorWall, anchorRec := start, recTime(0) + now := start + var absMax time.Duration + + // Relative: the old scheme reset the next delay from the late firing instant. + relNow := start + var relMax time.Duration + + for i := 1; i <= frames; i++ { + // absolute + now = frameTarget(anchorWall, anchorRec, recTime(i), speed).Add(jitter) + if e := absErr(now.Sub(start), recTime(i).Sub(start)); e > absMax { + absMax = e + } + // relative: fire at previous-fire + gap, then add the same jitter + relNow = relNow.Add(gap).Add(jitter) + if e := absErr(relNow.Sub(start), recTime(i).Sub(start)); e > relMax { + relMax = e + } + } + + // Absolute error never exceeds a single wake-up's jitter, no matter how long. + if absMax > jitter { + t.Fatalf("absolute scheme drifted: max error %v > jitter %v", absMax, jitter) + } + // Relative error grows ~jitter per frame: proves the bug the rewrite fixes. + if relMax < time.Duration(frames/2)*jitter { + t.Fatalf("relative scheme should accumulate drift, got only %v", relMax) + } + t.Logf("after %d frames: absolute max error %v, relative drift %v", frames, absMax, relMax) +} + +func absErr(a, b time.Duration) time.Duration { + if a < b { + return b - a + } + return a - b +} diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index ad736c9e..0720f521 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -710,7 +710,7 @@ func (r *mapViewerRenderer) Destroy() { */ func calculateTextSize(widthFactor, heightFactor float32) float32 { - cellSize := fyne.Min(widthFactor, heightFactor) + cellSize := min(widthFactor, heightFactor) // Scale text size relative to cell size, but with a more conservative ratio // Reduced from 0.6 to 0.4 to prevent overflow diff --git a/pkg/widgets/msglist/msglist.go b/pkg/widgets/msglist/msglist.go index 658cd7df..befdeccb 100644 --- a/pkg/widgets/msglist/msglist.go +++ b/pkg/widgets/msglist/msglist.go @@ -2,7 +2,6 @@ package msglist import ( "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/widget" ) @@ -12,73 +11,50 @@ var _ fyne.Widget = (*MsgList)(nil) type MsgList struct { widget.BaseWidget msgs binding.StringList - output *widget.List + list *widget.List listener binding.DataListener } func New(data binding.StringList) *MsgList { - m := &MsgList{ - msgs: data, - } + m := &MsgList{msgs: data} m.ExtendBaseWidget(m) - m.output = widget.NewListWithData( - m.msgs, + m.list = widget.NewListWithData( + data, func() fyne.CanvasObject { - w := widget.NewLabel("") - w.Alignment = fyne.TextAlignLeading - w.Selectable = true - return w + l := widget.NewLabel("") + l.Selectable = true + return l }, func(item binding.DataItem, obj fyne.CanvasObject) { - i := item.(binding.String) - txt, err := i.Get() + txt, err := item.(binding.String).Get() if err != nil { - fyne.LogError("Failed to get string", err) + fyne.LogError("msglist: get string", err) return } obj.(*widget.Label).SetText(txt) }, ) - m.listener = binding.NewDataListener(func() { - m.output.ScrollToBottom() - }) + m.listener = binding.NewDataListener(m.list.ScrollToBottom) return m } func (m *MsgList) CreateRenderer() fyne.WidgetRenderer { m.msgs.AddListener(m.listener) - return &msgListRenderer{ - m: m, - container: container.NewScroll(m.output), - } + return &msgListRenderer{m: m, objects: []fyne.CanvasObject{m.list}} } var _ fyne.WidgetRenderer = (*msgListRenderer)(nil) type msgListRenderer struct { - m *MsgList - container *container.Scroll + m *MsgList + objects []fyne.CanvasObject } -func (r *msgListRenderer) MinSize() fyne.Size { - return fyne.NewSize(300, 100) -} - -func (r *msgListRenderer) Layout(size fyne.Size) { - r.container.Resize(size) -} - -func (r *msgListRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.container} -} - -func (r *msgListRenderer) Refresh() { - -} - -func (r *msgListRenderer) Destroy() { - r.m.msgs.RemoveListener(r.m.listener) -} +func (r *msgListRenderer) MinSize() fyne.Size { return fyne.NewSize(300, 200) } +func (r *msgListRenderer) Layout(size fyne.Size) { r.m.list.Resize(size) } +func (r *msgListRenderer) Objects() []fyne.CanvasObject { return r.objects } +func (r *msgListRenderer) Refresh() { r.m.list.Refresh() } +func (r *msgListRenderer) Destroy() { r.m.msgs.RemoveListener(r.m.listener) } diff --git a/pkg/widgets/multiwindow/arrange.go b/pkg/widgets/multiwindow/arrange.go index 9e6d517f..f43565f7 100644 --- a/pkg/widgets/multiwindow/arrange.go +++ b/pkg/widgets/multiwindow/arrange.go @@ -71,7 +71,7 @@ func (f *FloatingArranger) Layout(maxSize fyne.Size, confined bool, windows []*I return } - maxSteps := int(fyne.Min( + maxSteps := int(min( (maxSize.Width-initOffset)/20, (maxSize.Height-initOffset)/20, )) @@ -90,14 +90,14 @@ func (f *FloatingArranger) Layout(maxSize fyne.Size, confined bool, windows []*I // Clamp positions to keep windows within bounds maxX := maxSize.Width - window.MinSize().Width maxY := maxSize.Height - window.MinSize().Height - posX = fyne.Min(posX, maxX) - posY = fyne.Min(posY, maxY) + posX = min(posX, maxX) + posY = min(posY, maxY) } pos := fyne.NewPos(posX, posY) size := fyne.NewSize( - fyne.Max(maxSize.Width/2, window.MinSize().Width), - fyne.Max(maxSize.Height/2, window.MinSize().Height), + max(maxSize.Width/2, window.MinSize().Width), + max(maxSize.Height/2, window.MinSize().Height), ) f.setWindowState(window, pos, size, false) @@ -177,7 +177,7 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { if node.pos.Y < otherNode.pos.Y+otherNode.size.Height && node.pos.Y+node.size.Height > otherNode.pos.Y { if otherNode.pos.X > node.pos.X { - maxWidth = fyne.Min(maxWidth, otherNode.pos.X-node.pos.X-padding) + maxWidth = min(maxWidth, otherNode.pos.X-node.pos.X-padding) } } @@ -185,14 +185,14 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { if node.pos.X < otherNode.pos.X+otherNode.size.Width && node.pos.X+node.size.Width > otherNode.pos.X { if otherNode.pos.Y > node.pos.Y { - maxHeight = fyne.Min(maxHeight, otherNode.pos.Y-node.pos.Y-padding) + maxHeight = min(maxHeight, otherNode.pos.Y-node.pos.Y-padding) } } } // Ensure we don't exceed the container bounds - maxWidth = fyne.Min(maxWidth, maxSize.Width-node.pos.X-padding) - maxHeight = fyne.Min(maxHeight, maxSize.Height-node.pos.Y-padding) + maxWidth = min(maxWidth, maxSize.Width-node.pos.X-padding) + maxHeight = min(maxHeight, maxSize.Height-node.pos.Y-padding) // Calculate expanded size while maintaining aspect ratio minSize := window.MinSize() @@ -302,8 +302,8 @@ func (p *PreservingArranger) Layout(maxSize fyne.Size, confined bool, windows [] // Ensure window stays within bounds maxWidth := maxSize.Width - r.pos.X maxHeight := maxSize.Height - r.pos.Y - newSize.Width = fyne.Min(newSize.Width, maxWidth) - newSize.Height = fyne.Min(newSize.Height, maxHeight) + newSize.Width = min(newSize.Width, maxWidth) + newSize.Height = min(newSize.Height, maxHeight) } p.setWindowState(r.window, r.pos, newSize, false) } diff --git a/pkg/widgets/multiwindow/innerwindow.go b/pkg/widgets/multiwindow/innerwindow.go index 559c53c4..93c68414 100644 --- a/pkg/widgets/multiwindow/innerwindow.go +++ b/pkg/widgets/multiwindow/innerwindow.go @@ -67,7 +67,7 @@ type InnerWindow struct { Persist bool // Persist through layout changes IgnoreSave bool // Ignore saving to layout - //minBtn, maxBtn, closeBtn *borderButton + // minBtn, maxBtn, closeBtn *borderButton title string bg *canvas.Rectangle @@ -225,12 +225,12 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { if isLeading { // Left side (darwin default or explicit left alignment) buttons = container.NewHBox(close, min, max) - //bar = container.NewBorder(nil, nil, buttons, borderIcon, title) + // bar = container.NewBorder(nil, nil, buttons, borderIcon, title) bar = container.NewBorder(nil, nil, buttons, borderIcon, container.New(layout.NewCustomPaddedLayout(topPad, 0, 0, 0), title)) } else { // Right side (Windows/Linux default and explicit right alignment) buttons = container.NewHBox(min, max, close) - //bar = container.NewBorder(nil, nil, borderIcon, buttons, title) + // bar = container.NewBorder(nil, nil, borderIcon, buttons, title) bar = container.NewBorder(nil, nil, borderIcon, buttons, container.New(layout.NewCustomPaddedLayout(topPad, 0, 0, 0), title)) } @@ -275,7 +275,8 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { leftBottomCorner: leftBottomCorner, rightBottomCorner: rightBottomCorner, borders: borders, - contentBG: contentBG} + contentBG: contentBG, + } r.Layout(w.Size()) return r } @@ -420,7 +421,7 @@ func (i *innerWindowRenderer) MinSize() fyne.Size { return fyne.NewSize(minimizedWidth, barHeight+pad) } contentMin := i.win.content.MinSize() - innerWidth := fyne.Max(i.bar.MinSize().Width, contentMin.Width) + innerWidth := max(i.bar.MinSize().Width, contentMin.Width) return fyne.NewSize(innerWidth+pad*2, contentMin.Height+pad+barHeight) } @@ -442,9 +443,11 @@ func (i *innerWindowRenderer) Refresh() { i.ShadowingRenderer.RefreshShadow() } -var _ desktop.Mouseable = (*draggableLabel)(nil) -var _ fyne.Draggable = (*draggableLabel)(nil) -var _ fyne.Focusable = (*draggableLabel)(nil) +var ( + _ desktop.Mouseable = (*draggableLabel)(nil) + _ fyne.Draggable = (*draggableLabel)(nil) + _ fyne.Focusable = (*draggableLabel)(nil) +) type draggableLabel struct { widget.Label @@ -633,7 +636,7 @@ func (b *buttonTheme) Size(n fyne.ThemeSizeName) float32 { //n = theme.SizeNameWindowButtonRadius return 4 case theme.SizeNameInlineIcon: - //n = theme.SizeNameWindowButtonIcon + // n = theme.SizeNameWindowButtonIcon return 20 } diff --git a/pkg/widgets/multiwindow/multiplewindows.go b/pkg/widgets/multiwindow/multiplewindows.go index fcbcd803..109d97fd 100644 --- a/pkg/widgets/multiwindow/multiplewindows.go +++ b/pkg/widgets/multiwindow/multiplewindows.go @@ -260,7 +260,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUp: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } @@ -272,7 +272,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -284,14 +284,14 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUpLeft: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -301,7 +301,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUpRight: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } @@ -311,7 +311,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -328,8 +328,8 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { pos := w.Position() maxWidth := contentSize.Width - pos.X maxHeight := contentSize.Height - pos.Y - newSize.Width = fyne.Min(newSize.Width, maxWidth) - newSize.Height = fyne.Min(newSize.Height, maxHeight) + newSize.Width = min(newSize.Width, maxWidth) + newSize.Height = min(newSize.Height, maxHeight) } w.Resize(newSize.Max(minSize)) diff --git a/pkg/widgets/symbollist/symbollist.go b/pkg/widgets/symbollist/symbollist.go index a29b849d..071358ec 100644 --- a/pkg/widgets/symbollist/symbollist.go +++ b/pkg/widgets/symbollist/symbollist.go @@ -386,7 +386,10 @@ func (sw *SymbolWidgetEntry) SetCorrectionFactor(f float64) { */ func (sw *SymbolWidgetEntry) CreateRenderer() fyne.WidgetRenderer { - return &symbolWidgetEntryRenderer{e: sw} + return &symbolWidgetEntryRenderer{ + e: sw, + objects: []fyne.CanvasObject{sw.container}, + } // return widget.NewSimpleRenderer(sw.container) } @@ -416,13 +419,9 @@ func (s *symbolWidgetEntryRenderer) Refresh() { col.A = barAlpha s.e.valueBar.FillColor = col s.e.valueBar.StrokeColor = col - // cascades to the labels, delete button and value bar s.e.container.Refresh() } func (s *symbolWidgetEntryRenderer) Objects() []fyne.CanvasObject { - if s.objects == nil { - s.objects = []fyne.CanvasObject{s.e.container} - } return s.objects } diff --git a/pkg/widgets/secrettext/secrettext.go b/pkg/widgets/tappabletext/tappabletext.go similarity index 65% rename from pkg/widgets/secrettext/secrettext.go rename to pkg/widgets/tappabletext/tappabletext.go index 40ef47b9..b6a4c923 100644 --- a/pkg/widgets/secrettext/secrettext.go +++ b/pkg/widgets/tappabletext/tappabletext.go @@ -1,12 +1,10 @@ -package secrettext +package tappabletext import ( "bytes" - "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" "github.com/hajimehoshi/go-mp3" "github.com/roffe/txlogger/pkg/assets" @@ -19,32 +17,26 @@ var _ fyne.Tappable = (*SecretText)(nil) type SecretText struct { *widget.Label tappedTimes int - SecretFunc func() - initOnce sync.Once + Taps int + Func func() } -func New(text string) *SecretText { +func New(text string, taps int, fn func()) *SecretText { label := widget.NewLabel(text) return &SecretText{ Label: label, + Taps: taps, + Func: fn, } } func (s *SecretText) Tapped(*fyne.PointEvent) { s.tappedTimes++ // log.Println("tapped", s.tappedTimes) - if s.tappedTimes >= 10 { - - /* - t := fyne.NewStaticResource("taz.png", assets.Taz) - cv := canvas.NewImageFromResource(t) - cv.ScaleMode = canvas.ImageScaleFastest - cv.SetMinSize(fyne.NewSize(0, 0)) - */ + if s.tappedTimes >= s.Taps { + s.tappedTimes = 0 fileBytesReader := bytes.NewReader(assets.Korvring) - - // Decode file decodedMp3, err := mp3.NewDecoder(fileBytesReader) if err != nil { panic("mp3.NewDecoder failed: " + err.Error()) @@ -54,8 +46,7 @@ func (s *SecretText) Tapped(*fyne.PointEvent) { player.Play() - s.tappedTimes = 0 - if f := s.SecretFunc; f != nil { + if f := s.Func; f != nil { f() } @@ -82,16 +73,9 @@ func (s *SecretText) Tapped(*fyne.PointEvent) { player.Pause() }) d.Show() - /* - an := canvas.NewSizeAnimation(fyne.NewSize(0, 0), fyne.NewSize(370, 386), time.Second, func(size fyne.Size) { - cv.Resize(size) - }) - - an.Start() - */ } } -func (s *SecretText) Cursor() desktop.Cursor { - return desktop.CrosshairCursor -} +//func (s *SecretText) Cursor() desktop.Cursor { +// return desktop.CrosshairCursor +//} diff --git a/pkg/widgets/vbar/vbar.go b/pkg/widgets/vbar/vbar.go index 93bb2057..d2fd3827 100644 --- a/pkg/widgets/vbar/vbar.go +++ b/pkg/widgets/vbar/vbar.go @@ -134,10 +134,6 @@ func (s *VBar) SetValue(value float64) { } } -func (s *VBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *VBar) Value() float64 { return s.value } diff --git a/pkg/windows/help.go b/pkg/windows/help.go index 30ec9712..d555109b 100644 --- a/pkg/windows/help.go +++ b/pkg/windows/help.go @@ -13,6 +13,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/assets" + "github.com/roffe/txlogger/pkg/widgets/tappabletext" ) func Help() *container.AppTabs { @@ -117,7 +118,7 @@ func Help() *container.AppTabs { return tabs } -func About() *fyne.Container { +func (mw *MainWindow) about() *fyne.Container { kvaserLogo := canvas.NewImageFromResource(fyne.NewStaticResource("kvaser_logo.png", assets.KvaserLogoBytes)) kvaserLogo.SetMinSize(fyne.NewSize(190, 117)) kvaserLogo.FillMode = canvas.ImageFillContain @@ -149,6 +150,12 @@ func About() *fyne.Container { thnx := widget.NewLabel("Special thanks to") thnx.TextStyle.Bold = true + versionString := tappabletext.New("Version: "+fyne.CurrentApp().Metadata().Version+" Build: "+strconv.Itoa(fyne.CurrentApp().Metadata().Build), 10, func() { + mw.app.Preferences().SetBool("enable_preview_features1337", true) + mw.previewFeatures = true + mw.SetMainMenu(mw.GetMenu(mw.selects.ecuSelect.Selected)) + }) + return container.NewBorder( nil, nil, @@ -160,7 +167,7 @@ func About() *fyne.Container { nil, container.NewVBox( widget.NewHyperlink("txlogger.com", tx), - widget.NewLabel("Version: "+fyne.CurrentApp().Metadata().Version+" Build: "+strconv.Itoa(fyne.CurrentApp().Metadata().Build)), + versionString, widget.NewLabel("Author: Joakim \"Roffe\" Karlsson"), ), ), diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 91ca4c2f..aa58a35d 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -35,7 +35,6 @@ import ( "github.com/roffe/txlogger/pkg/widgets/ledicon" "github.com/roffe/txlogger/pkg/widgets/logplayer" "github.com/roffe/txlogger/pkg/widgets/multiwindow" - "github.com/roffe/txlogger/pkg/widgets/secrettext" "github.com/roffe/txlogger/pkg/widgets/settings" "github.com/roffe/txlogger/pkg/widgets/symbollist" "google.golang.org/protobuf/types/known/emptypb" @@ -80,7 +79,7 @@ type MainWindow struct { gwclient proto.GocanClient buttonsDisabled bool settings *settings.Widget - statusText *secrettext.SecretText + statusText *widget.Label wm *multiwindow.MultipleWindows content *fyne.Container startup bool @@ -136,12 +135,8 @@ func NewMainWindow(app fyne.App) *MainWindow { gocanGatewayLED: ledicon.New("Gateway"), canLED: ledicon.New("CAN"), - statusText: secrettext.New("Harder, Better, Faster, Stronger"), - previewFeatures: app.Preferences().BoolWithFallback("enable_preview_features", false), - } - - mw.statusText.SecretFunc = func() { - mw.app.Preferences().SetBool("enable_preview_features", true) + statusText: widget.NewLabel("Harder, Better, Faster, Stronger"), + previewFeatures: app.Preferences().BoolWithFallback("enable_preview_features1337", false), } ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(v float64) { diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 4a9bb9b3..d0761522 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -100,7 +100,7 @@ func (mw *MainWindow) setupMenu() { mw.wm.Raise(w) return } - inner := multiwindow.NewInnerWindow("About", About()) + inner := multiwindow.NewInnerWindow("About", mw.about()) inner.Icon = theme.HelpIcon() mw.wm.Add(inner) }), diff --git a/pkg/windows/mainwindow_layout.go b/pkg/windows/mainwindow_layout.go index ff43213d..8841057e 100644 --- a/pkg/windows/mainwindow_layout.go +++ b/pkg/windows/mainwindow_layout.go @@ -58,12 +58,13 @@ func (mw *MainWindow) SaveLayout() error { return nil } + func writeLayout(name string, data []byte) error { layoutPath, err := common.GetLayoutPath() if err != nil { return err } - if err := os.WriteFile(filepath.Join(layoutPath, name+".json"), data, 0644); err != nil { + if err := os.WriteFile(filepath.Join(layoutPath, name+".json"), data, 0o644); err != nil { return err } return nil @@ -117,7 +118,6 @@ func (mw *MainWindow) jsonLayout() ([]byte, error) { Preset: mw.selects.presetSelect.Selected, Windows: history, }) - if err != nil { return nil, err } @@ -144,7 +144,7 @@ func (mw *MainWindow) LoadLayout(name string) error { mw.wm.CloseAll() if mw.dlc == nil { - //mw.selects.ecuSelect.SetSelected(layout.ECU) + // mw.selects.ecuSelect.SetSelected(layout.ECU) mw.selects.ecuSelect.Selected = layout.ECU mw.selects.presetSelect.SetSelected(layout.Preset) } @@ -163,16 +163,14 @@ func (mw *MainWindow) LoadLayout(name string) error { } if h.GaugeConfig != nil { - gauge, cancelFuncs, err := gauge.New(h.GaugeConfig) + gauge, cancelFn, err := gauge.New(h.GaugeConfig) if err != nil { mw.Error(fmt.Errorf("failed to create gauge: %w", err)) continue } iw := multiwindow.NewInnerWindow(h.Title, gauge) iw.OnClose = func() { - for _, cancel := range cancelFuncs { - cancel() - } + cancelFn() } mw.wm.Add(iw) continue From 790b36f3cf0a4a5164a5e6908cbb37819a56e579 Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 25 Jun 2026 23:17:28 +0200 Subject: [PATCH 89/93] update gocan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8291d2a1..bc5e146c 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 github.com/roffe/ecusymbol v1.2.4 - github.com/roffe/gocan v1.4.2 + github.com/roffe/gocan v1.4.3 go.bug.st/serial v1.7.1 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 diff --git a/go.sum b/go.sum index c7da6d40..823e7e8e 100644 --- a/go.sum +++ b/go.sum @@ -134,8 +134,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/roffe/ecusymbol v1.2.4 h1:5MGAs7e6djTAaa4y8bpByATMXjgq7/khLJqff3t3Zx0= github.com/roffe/ecusymbol v1.2.4/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.4.2 h1:8kx7UjE+akgs3OwRo/t3jaCIl6Bvr3GMqDPuzosc6oE= -github.com/roffe/gocan v1.4.2/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= +github.com/roffe/gocan v1.4.3 h1:G7DBUK2tAHDtqRR9KJ3OCJiWoi28gb/jPYtsAFUUa9k= +github.com/roffe/gocan v1.4.3/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= From 6e6c045f0df44ca9b5ccb3a54bfa545463cd877e Mon Sep 17 00:00:00 2001 From: roffe Date: Thu, 25 Jun 2026 23:36:06 +0200 Subject: [PATCH 90/93] work now --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bc5e146c..c28caf99 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pion/mdns/v2 v2.1.0 github.com/roffe/ecusymbol v1.2.4 - github.com/roffe/gocan v1.4.3 + github.com/roffe/gocan v1.4.4 go.bug.st/serial v1.7.1 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 diff --git a/go.sum b/go.sum index 823e7e8e..e2d2ba6b 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/roffe/ecusymbol v1.2.4 h1:5MGAs7e6djTAaa4y8bpByATMXjgq7/khLJqff3t3Zx0 github.com/roffe/ecusymbol v1.2.4/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= github.com/roffe/gocan v1.4.3 h1:G7DBUK2tAHDtqRR9KJ3OCJiWoi28gb/jPYtsAFUUa9k= github.com/roffe/gocan v1.4.3/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= +github.com/roffe/gocan v1.4.4 h1:8CxnuAzqUyOWqTOk/FZLf20J+DNFyHo9Iar9DdUm7uY= +github.com/roffe/gocan v1.4.4/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= From 0a2c30e476be4d3496fc162de294ab774428f8c5 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 26 Jun 2026 15:53:52 +0200 Subject: [PATCH 91/93] =?UTF-8?q?varf=C3=B6r=20e=20du=20arg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/windows-release.yml | 6 +++--- .github/workflows/windows.yml | 6 +++--- Makefile | 3 +++ build.ps1 | 21 +++++++++++++++++---- setup_build_env.ps1 | 27 +++++++++++++++++++-------- 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 48245c7f..af898af0 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -38,12 +38,12 @@ jobs: run: | go get . go install fyne.io/tools/cmd/fyne@latest - .\setup_build_env.ps1 + .\setup_build_env.ps1 -gcc - name: Build run: | - $env:PATH += ";D:\a\txlogger\txlogger\llvm-mingw\bin" - .\build.ps1 -cangateway -usegitsrc -txlogger -setup + $env:PATH = "C:\msys64\mingw64\bin;C:\msys64\mingw32\bin;" + $env:PATH + .\build.ps1 -gcc -cangateway -usegitsrc -txlogger -setup - name: Creating Zip run: 7z a txlogger.zip txlogger.exe cangateway.exe .\debug.bat C:\Progra~2\Kvaser\Canlib\Bin\canlib32.dll .\vcpkg\packages\libusb_x64-windows\bin\libusb-1.0.dll .\canusb\dll64\canusbdrv64.dll diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 0ccd16c9..d5d98ed6 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -41,12 +41,12 @@ jobs: run: | go get . go install fyne.io/tools/cmd/fyne@latest - .\setup_build_env.ps1 + .\setup_build_env.ps1 -gcc - name: Build run: | - $env:PATH += ";D:\a\txlogger\txlogger\llvm-mingw\bin" - .\build.ps1 -cangateway -usegitsrc -txlogger -setup + $env:PATH = "C:\msys64\mingw64\bin;C:\msys64\mingw32\bin;" + $env:PATH + .\build.ps1 -gcc -cangateway -usegitsrc -txlogger -setup - name: Creating Zip run: 7z a txlogger.zip txlogger.exe cangateway.exe .\debug.bat C:\Progra~2\Kvaser\Canlib\Bin\canlib32.dll .\vcpkg\packages\libusb_x64-windows\bin\libusb-1.0.dll .\canusb\dll64\canusbdrv64.dll diff --git a/Makefile b/Makefile index c00e5dd2..fbee460e 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,9 @@ debug: clean cangateway @echo Using compiler "$(CC)" -go run -tags=$(BUILDTAGS),debug . 2>&1 | tee run.log +windows: + CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOARCH=386 GOOS=windows go build -tags="j2534" -ldflags '-s -w' -o cangateway.exe ../gocangateway + PKG_CONFIG_PATH="./vcpkg/packages/libusb_x64-windows/lib/pkgconfig" CGO_CFLAGS="-I/vcpkg/packages/libusb_x64-windows/include/libusb-1.0" CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOARCH=amd64 GOOS=windows go build -tags=$(BUILDTAGS) -ldflags '-s -w' -o txlogger.exe . run: clean cangateway @echo Using compiler "$(CC)" diff --git a/build.ps1 b/build.ps1 index 1479b5e0..db017d41 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,7 +3,8 @@ param( [switch]$txlogger, [switch]$setup, [switch]$release, - [switch]$usegitsrc + [switch]$usegitsrc, + [switch]$gcc ) if (-not ($cangateway -or $txlogger -or $setup -or $release)) { @@ -19,10 +20,14 @@ if ($release) { $setup = $true } -$env:CGO_ENABLED = "1" +$env:CGO_ENABLED = "1" $env:GOGC = "100" -$env:CC = "x86_64-w64-mingw32-clang.exe" -$env:CXX = "x86_64-w64-mingw32-clang++.exe" +# Default: llvm-mingw clang (much faster, multi-target so one compiler handles 386 + amd64). +# -gcc: GCC-based mingw-w64 (slower, but avoids AV false positives; needs separate per-arch compilers, set below). +if (-not $gcc) { + $env:CC = "x86_64-w64-mingw32-clang.exe" + $env:CXX = "x86_64-w64-mingw32-clang++.exe" +} $current_path = Get-Location @@ -39,6 +44,10 @@ if ($cangateway) { # $env:CGO_CFLAGS = ($includes | ForEach-Object { '-I' + $_ }) -join ' ' # $env:CGO_LDFLAGS = ($libs | ForEach-Object { '-L' + $_ }) -join ' ' $env:GOARCH = "386" + if ($gcc) { + $env:CC = "i686-w64-mingw32-gcc.exe" + $env:CXX = "i686-w64-mingw32-g++.exe" + } if ($usegitsrc) { # git clone https://github.com/roffe/gocangateway.git # Set-Location -Path ".\gocangateway" @@ -81,6 +90,10 @@ if ($txlogger) { $env:CGO_CFLAGS = ($includes | ForEach-Object { '-I' + $_ }) -join ' ' # $env:CGO_LDFLAGS = ($libs | ForEach-Object { '-L' + $_ }) -join ' ' $env:GOARCH = "amd64" + if ($gcc) { + $env:CC = "x86_64-w64-mingw32-gcc.exe" + $env:CXX = "x86_64-w64-mingw32-g++.exe" + } fyne package -tags="canlib,canusb,combi,ftdi,j2534,pcan,rcan" --release } diff --git a/setup_build_env.ps1 b/setup_build_env.ps1 index e7bf741f..195ee1b3 100644 --- a/setup_build_env.ps1 +++ b/setup_build_env.ps1 @@ -1,3 +1,7 @@ +param( + [switch]$gcc +) + $temp_dir = ".\setup_temp" $canusb = "https://www.canusb.com/files/canusb_dll_driver.zip" $canlib = "https://pim.kvaser.com/var/assets/Product_Resources/7330130980150/5.51.461/canlib_5_51_461.exe" @@ -27,16 +31,23 @@ Invoke-WebRequest -Uri $canlib -OutFile "$temp_dir\canlib.exe" Write-Output "Extracting CANUSB" Expand-Archive -Path "$temp_dir\canusb_dll_driver.zip" -DestinationPath ".\canusb" -Force -# download llvm-mingw -Write-Output "Downloading llvm-MinGW" -Invoke-WebRequest -Uri $llvm -OutFile "$temp_dir\llvm-mingw.zip" +if ($gcc) { + # GCC-based mingw-w64 (32-bit for cangateway, 64-bit for txlogger). + # Slower than llvm but avoids AV false positives; used by the CI workflows. + Write-Output "Installing mingw-w64 GCC toolchains" + C:\msys64\usr\bin\pacman.exe -S --noconfirm --needed mingw-w64-i686-gcc mingw-w64-x86_64-gcc +} +else { + # llvm-mingw clang: default for local dev, builds an order of magnitude faster + Write-Output "Downloading llvm-MinGW" + Invoke-WebRequest -Uri $llvm -OutFile "$temp_dir\llvm-mingw.zip" -# Write-Output "Extracting llvm-MinGW" -Expand-Archive -Path "$temp_dir\llvm-mingw.zip" -DestinationPath ".\" -Force + Write-Output "Extracting llvm-MinGW" + Expand-Archive -Path "$temp_dir\llvm-mingw.zip" -DestinationPath ".\" -Force -# rename folder llvm-mingw-20251007-ucrt-x86_64 to llvm-mingw -Write-Output "Renaming llvm-MinGW folder" -Rename-Item -Path ".\llvm-mingw-20251216-ucrt-x86_64" -NewName "llvm-mingw" + Write-Output "Renaming llvm-MinGW folder" + Rename-Item -Path ".\llvm-mingw-20251216-ucrt-x86_64" -NewName "llvm-mingw" +} Write-Output "Installing CANLIB" Start-Process -FilePath "$temp_dir\canlib.exe" -ArgumentList "/S" -Wait From 8ab02b19ccb86477f3fae032fb4be8ed7112f203 Mon Sep 17 00:00:00 2001 From: roffe Date: Fri, 26 Jun 2026 17:11:16 +0200 Subject: [PATCH 92/93] kan du inte cykla eller? --- .github/workflows/windows-release.yml | 6 +++--- .github/workflows/windows.yml | 6 +++--- FyneApp.toml | 2 +- Makefile | 9 ++++++++- build.ps1 | 25 +++++++------------------ pkg/cangw/cangw.go | 5 +++-- pkg/cangw/hidewindow_other.go | 7 +++++++ pkg/cangw/hidewindow_windows.go | 16 ++++++++++++++++ setup_build_env.ps1 | 26 +++++++------------------- 9 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 pkg/cangw/hidewindow_other.go create mode 100644 pkg/cangw/hidewindow_windows.go diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index af898af0..48245c7f 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -38,12 +38,12 @@ jobs: run: | go get . go install fyne.io/tools/cmd/fyne@latest - .\setup_build_env.ps1 -gcc + .\setup_build_env.ps1 - name: Build run: | - $env:PATH = "C:\msys64\mingw64\bin;C:\msys64\mingw32\bin;" + $env:PATH - .\build.ps1 -gcc -cangateway -usegitsrc -txlogger -setup + $env:PATH += ";D:\a\txlogger\txlogger\llvm-mingw\bin" + .\build.ps1 -cangateway -usegitsrc -txlogger -setup - name: Creating Zip run: 7z a txlogger.zip txlogger.exe cangateway.exe .\debug.bat C:\Progra~2\Kvaser\Canlib\Bin\canlib32.dll .\vcpkg\packages\libusb_x64-windows\bin\libusb-1.0.dll .\canusb\dll64\canusbdrv64.dll diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d5d98ed6..0ccd16c9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -41,12 +41,12 @@ jobs: run: | go get . go install fyne.io/tools/cmd/fyne@latest - .\setup_build_env.ps1 -gcc + .\setup_build_env.ps1 - name: Build run: | - $env:PATH = "C:\msys64\mingw64\bin;C:\msys64\mingw32\bin;" + $env:PATH - .\build.ps1 -gcc -cangateway -usegitsrc -txlogger -setup + $env:PATH += ";D:\a\txlogger\txlogger\llvm-mingw\bin" + .\build.ps1 -cangateway -usegitsrc -txlogger -setup - name: Creating Zip run: 7z a txlogger.zip txlogger.exe cangateway.exe .\debug.bat C:\Progra~2\Kvaser\Canlib\Bin\canlib32.dll .\vcpkg\packages\libusb_x64-windows\bin\libusb-1.0.dll .\canusb\dll64\canusbdrv64.dll diff --git a/FyneApp.toml b/FyneApp.toml index 88569b1a..6eac2e8e 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -5,7 +5,7 @@ Icon = "Icon.png" Name = "txlogger" ID = "com.roffe.txlogger" Version = "2.1.10" -Build = 689 +Build = 690 [Migrations] fyneDo = true diff --git a/Makefile b/Makefile index fbee460e..bd4038db 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,14 @@ debug: clean cangateway windows: CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOARCH=386 GOOS=windows go build -tags="j2534" -ldflags '-s -w' -o cangateway.exe ../gocangateway - PKG_CONFIG_PATH="./vcpkg/packages/libusb_x64-windows/lib/pkgconfig" CGO_CFLAGS="-I/vcpkg/packages/libusb_x64-windows/include/libusb-1.0" CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOARCH=amd64 GOOS=windows go build -tags=$(BUILDTAGS) -ldflags '-s -w' -o txlogger.exe . + CGO_CFLAGS="-Ivcpkg/packages/libusb_x64-windows/include/libusb-1.0" \ + CGO_LDFLAGS="-Lvcpkg/packages/libusb_x64-windows/lib" \ + CGO_ENABLED=1 \ + CC=x86_64-w64-mingw32-gcc \ + GOARCH=amd64 \ + GOOS=windows \ + fyne package -os windows -tags=$(BUILDTAGS) --release +# go build -tags=$(BUILDTAGS) -ldflags '-s -w' -o txlogger.exe . run: clean cangateway @echo Using compiler "$(CC)" diff --git a/build.ps1 b/build.ps1 index db017d41..1b5794ee 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,8 +3,7 @@ param( [switch]$txlogger, [switch]$setup, [switch]$release, - [switch]$usegitsrc, - [switch]$gcc + [switch]$usegitsrc ) if (-not ($cangateway -or $txlogger -or $setup -or $release)) { @@ -22,12 +21,8 @@ if ($release) { $env:CGO_ENABLED = "1" $env:GOGC = "100" -# Default: llvm-mingw clang (much faster, multi-target so one compiler handles 386 + amd64). -# -gcc: GCC-based mingw-w64 (slower, but avoids AV false positives; needs separate per-arch compilers, set below). -if (-not $gcc) { - $env:CC = "x86_64-w64-mingw32-clang.exe" - $env:CXX = "x86_64-w64-mingw32-clang++.exe" -} +$env:CC = "x86_64-w64-mingw32-clang.exe" +$env:CXX = "x86_64-w64-mingw32-clang++.exe" $current_path = Get-Location @@ -44,22 +39,20 @@ if ($cangateway) { # $env:CGO_CFLAGS = ($includes | ForEach-Object { '-I' + $_ }) -join ' ' # $env:CGO_LDFLAGS = ($libs | ForEach-Object { '-L' + $_ }) -join ' ' $env:GOARCH = "386" - if ($gcc) { - $env:CC = "i686-w64-mingw32-gcc.exe" - $env:CXX = "i686-w64-mingw32-g++.exe" - } if ($usegitsrc) { # git clone https://github.com/roffe/gocangateway.git # Set-Location -Path ".\gocangateway" # go build -tags="canlib,j2534" -ldflags '-s -w -H=windowsgui' -o cangateway.exe . # Move-Item -Path ".\cangateway.exe" -Destination "$current_path\cangateway.exe" -Force # Set-Location -Path $current_path - go install -tags="j2534" -ldflags '-s -w -H=windowsgui' github.com/roffe/gocangateway@latest + # console subsystem (no -H=windowsgui): GUI-subsystem console-less exes trip AV heuristics. + # txlogger spawns it with CREATE_NO_WINDOW so no console window shows. See pkg/cangw. + go install -tags="j2534" -ldflags '-s -w' github.com/roffe/gocangateway@latest Move-Item -Path "$Env:USERPROFILE\go\bin\windows_386\gocangateway.exe" -Destination "$current_path\cangateway.exe" -Force } else { #Set-Location -Path "..\gocangateway" - go build -tags="j2534" -ldflags '-s -w -H=windowsgui' -o cangateway.exe ..\gocangateway + go build -tags="j2534" -ldflags '-s -w' -o cangateway.exe ..\gocangateway #Move-Item -Path ".\cangateway.exe" -Destination "$current_path\cangateway.exe" -Force #Set-Location -Path $current_path } @@ -90,10 +83,6 @@ if ($txlogger) { $env:CGO_CFLAGS = ($includes | ForEach-Object { '-I' + $_ }) -join ' ' # $env:CGO_LDFLAGS = ($libs | ForEach-Object { '-L' + $_ }) -join ' ' $env:GOARCH = "amd64" - if ($gcc) { - $env:CC = "x86_64-w64-mingw32-gcc.exe" - $env:CXX = "x86_64-w64-mingw32-g++.exe" - } fyne package -tags="canlib,canusb,combi,ftdi,j2534,pcan,rcan" --release } diff --git a/pkg/cangw/cangw.go b/pkg/cangw/cangw.go index b0e86577..a6107ba5 100644 --- a/pkg/cangw/cangw.go +++ b/pkg/cangw/cangw.go @@ -32,8 +32,9 @@ func Start() (*os.Process, error) { } cmd := exec.Command(filepath.Join(wd, exeName)) - // Uncomment on Windows if you want to hide the console window: - // cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + // cangateway is built as a console exe (GUI-subsystem console-less binaries + // trip AV heuristics); hide the console window at launch instead. + hideWindow(cmd) stderr, err := cmd.StderrPipe() if err != nil { diff --git a/pkg/cangw/hidewindow_other.go b/pkg/cangw/hidewindow_other.go new file mode 100644 index 00000000..4cec078a --- /dev/null +++ b/pkg/cangw/hidewindow_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cangw + +import "os/exec" + +func hideWindow(*exec.Cmd) {} diff --git a/pkg/cangw/hidewindow_windows.go b/pkg/cangw/hidewindow_windows.go new file mode 100644 index 00000000..e4f44dc1 --- /dev/null +++ b/pkg/cangw/hidewindow_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package cangw + +import ( + "os/exec" + "syscall" +) + +// hideWindow runs the console child without spawning a console window, so +// cangateway can be a console-subsystem exe (no -H=windowsgui, which trips AV +// heuristics) while staying invisible to the user. +func hideWindow(cmd *exec.Cmd) { + const createNoWindow = 0x08000000 // CREATE_NO_WINDOW + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow} +} diff --git a/setup_build_env.ps1 b/setup_build_env.ps1 index 195ee1b3..add4034f 100644 --- a/setup_build_env.ps1 +++ b/setup_build_env.ps1 @@ -1,7 +1,3 @@ -param( - [switch]$gcc -) - $temp_dir = ".\setup_temp" $canusb = "https://www.canusb.com/files/canusb_dll_driver.zip" $canlib = "https://pim.kvaser.com/var/assets/Product_Resources/7330130980150/5.51.461/canlib_5_51_461.exe" @@ -31,23 +27,15 @@ Invoke-WebRequest -Uri $canlib -OutFile "$temp_dir\canlib.exe" Write-Output "Extracting CANUSB" Expand-Archive -Path "$temp_dir\canusb_dll_driver.zip" -DestinationPath ".\canusb" -Force -if ($gcc) { - # GCC-based mingw-w64 (32-bit for cangateway, 64-bit for txlogger). - # Slower than llvm but avoids AV false positives; used by the CI workflows. - Write-Output "Installing mingw-w64 GCC toolchains" - C:\msys64\usr\bin\pacman.exe -S --noconfirm --needed mingw-w64-i686-gcc mingw-w64-x86_64-gcc -} -else { - # llvm-mingw clang: default for local dev, builds an order of magnitude faster - Write-Output "Downloading llvm-MinGW" - Invoke-WebRequest -Uri $llvm -OutFile "$temp_dir\llvm-mingw.zip" +# download llvm-mingw +Write-Output "Downloading llvm-MinGW" +Invoke-WebRequest -Uri $llvm -OutFile "$temp_dir\llvm-mingw.zip" - Write-Output "Extracting llvm-MinGW" - Expand-Archive -Path "$temp_dir\llvm-mingw.zip" -DestinationPath ".\" -Force +Write-Output "Extracting llvm-MinGW" +Expand-Archive -Path "$temp_dir\llvm-mingw.zip" -DestinationPath ".\" -Force - Write-Output "Renaming llvm-MinGW folder" - Rename-Item -Path ".\llvm-mingw-20251216-ucrt-x86_64" -NewName "llvm-mingw" -} +Write-Output "Renaming llvm-MinGW folder" +Rename-Item -Path ".\llvm-mingw-20251216-ucrt-x86_64" -NewName "llvm-mingw" Write-Output "Installing CANLIB" Start-Process -FilePath "$temp_dir\canlib.exe" -ArgumentList "/S" -Wait From 59009afc649483019d2f4fd28547a3ad5a27a9e2 Mon Sep 17 00:00:00 2001 From: roffe Date: Sun, 28 Jun 2026 20:58:00 +0200 Subject: [PATCH 93/93] tons of stuff again --- FyneApp.toml | 2 +- Makefile | 5 +- pkg/mdns/mdns.go => experiments/mdns/mdns.old | 0 go.mod | 15 ++-- go.sum | 30 +++---- pkg/datalogger/t7logger.go | 3 + pkg/datalogger/txbridgelogger.go | 27 +++++++ pkg/datalogger/txbridgelogger_t5.go | 10 +++ pkg/datalogger/txbridgelogger_t7.go | 7 ++ pkg/datalogger/txbridgelogger_t8.go | 7 ++ pkg/ota/firmware.bin | Bin 845376 -> 1009984 bytes pkg/ota/ota.go | 16 +--- pkg/txbridge/client.go | 76 +++++++++++------- pkg/txbridge/client_test.go | 19 +++++ pkg/widgets/plotter/plotter.go | 4 +- pkg/widgets/settings/adapter.go | 4 +- pkg/widgets/settings/controls.go | 12 +-- pkg/widgets/settings/settings.go | 5 +- pkg/widgets/txconfigurator/txconfigurator.go | 30 ++----- pkg/windows/mainmenu_t7.go | 1 + txconfigurator/main.go | 6 +- 21 files changed, 170 insertions(+), 109 deletions(-) rename pkg/mdns/mdns.go => experiments/mdns/mdns.old (100%) create mode 100644 pkg/txbridge/client_test.go diff --git a/FyneApp.toml b/FyneApp.toml index 6eac2e8e..2c0705fe 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -5,7 +5,7 @@ Icon = "Icon.png" Name = "txlogger" ID = "com.roffe.txlogger" Version = "2.1.10" -Build = 690 +Build = 691 [Migrations] fyneDo = true diff --git a/Makefile b/Makefile index bd4038db..f6eb5f34 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ endif default: txlogger +pkg/ota/firmware.bin: /home/roffe/Documents/PlatformIO/Projects/txbridge/.pio/build/esp32dev/firmware.bin + @cp $< $@ + cangateway: go build -tags="j2534" -ldflags '-s -w' -o cangateway ../gocangateway @@ -35,7 +38,7 @@ windows: fyne package -os windows -tags=$(BUILDTAGS) --release # go build -tags=$(BUILDTAGS) -ldflags '-s -w' -o txlogger.exe . -run: clean cangateway +run: clean cangateway pkg/ota/firmware.bin @echo Using compiler "$(CC)" -GOEXPERIMENT=simd go run -tags=$(BUILDTAGS) . 2>&1 | tee run.log diff --git a/pkg/mdns/mdns.go b/experiments/mdns/mdns.old similarity index 100% rename from pkg/mdns/mdns.go rename to experiments/mdns/mdns.old diff --git a/go.mod b/go.mod index c28caf99..2f211f9b 100644 --- a/go.mod +++ b/go.mod @@ -10,18 +10,17 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01 + fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 - github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.2.4 - github.com/roffe/gocan v1.4.4 + github.com/roffe/ecusymbol v1.2.5 + github.com/roffe/gocan v1.4.5 go.bug.st/serial v1.7.1 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 - golang.org/x/net v0.54.0 + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 @@ -48,16 +47,17 @@ require ( github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect - github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/glfw-js v0.4.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.2.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect + github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47 // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gousb v1.1.3 // indirect + github.com/gotmc/libusb/v2 v2.6.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect @@ -69,7 +69,6 @@ require ( github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect - github.com/pion/logging v0.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/go.sum b/go.sum index e2d2ba6b..ce31fc14 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01 h1:G0GqajRHUPTm6LTMvLdklI1EEqY7c6Vh6YLNWiMEUCQ= -fyne.io/fyne/v2 v2.7.5-0.20260622115008-9d9a9461ca01/go.mod h1:+QHmxyt889RWLBt6HjSY04BmnO+IUQClMPkRVKltTyY= +fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41 h1:cLKNpbITUjaxNU8RFIVWzlUCRwO3s6PD8i1o4sUuM1Y= +fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41/go.mod h1:J1MHvPeMxAUF5zRWfGNP3rNRHAK6ZBJ/OiQl4BjzUtY= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= @@ -43,16 +43,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo= +github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47 h1:8gV6hg2D33yhLkJQ7E4eHNLMLw/+SmJItBBjkHVikfo= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -77,6 +77,8 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8 github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotmc/libusb/v2 v2.6.0 h1:5HRTO1EqchxnWvUIc7l3YtZN8NewWIiQtpSYvUvKu6w= +github.com/gotmc/libusb/v2 v2.6.0/go.mod h1:rU0mvps+snf/CHvEkISbxrwUlWpt6VluOkjqpKo6TFw= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= @@ -117,12 +119,6 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= @@ -132,12 +128,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.2.4 h1:5MGAs7e6djTAaa4y8bpByATMXjgq7/khLJqff3t3Zx0= -github.com/roffe/ecusymbol v1.2.4/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.4.3 h1:G7DBUK2tAHDtqRR9KJ3OCJiWoi28gb/jPYtsAFUUa9k= -github.com/roffe/gocan v1.4.3/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= -github.com/roffe/gocan v1.4.4 h1:8CxnuAzqUyOWqTOk/FZLf20J+DNFyHo9Iar9DdUm7uY= -github.com/roffe/gocan v1.4.4/go.mod h1:8TjfD7TGUNpAZSPwdtnYRI58ICd2NPAo/gTWZUsne+k= +github.com/roffe/ecusymbol v1.2.5 h1:h1ghjJZcm85+n5P+UjJWCiJDXMgy5BUhaFeKgwRJSss= +github.com/roffe/ecusymbol v1.2.5/go.mod h1:Y6vMPbT3P6nVXfUetMZBJKc6N4jPuzpNJfk8bHAfx5Q= +github.com/roffe/gocan v1.4.5 h1:4dLsm9ulGWmpteyEcKWmebnilhaXn3r740DIKAkN/Wg= +github.com/roffe/gocan v1.4.5/go.mod h1:vayI3roc38RKMq5B/yeG+hQt7HUog5Izx8EvpQRrmtg= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index f5ae34b5..c5e3fec4 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -386,6 +386,9 @@ func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.S continue } onMessage("Defining " + sym.Name) + + // log.Println("Define:", sym.String()) + if err := kwp.DynamicallyDefineLocalIdBySymbolNumber(ctx, index, sym.Number); err != nil { return errors.New("failed to define dynamic register") } diff --git a/pkg/datalogger/txbridgelogger.go b/pkg/datalogger/txbridgelogger.go index b3eca75f..cf26dbc0 100644 --- a/pkg/datalogger/txbridgelogger.go +++ b/pkg/datalogger/txbridgelogger.go @@ -8,6 +8,11 @@ import ( "github.com/roffe/gocan" ) +// dataTimeout aborts a txbridge logging session if no log frame arrives for +// this long. The txbridge loggers wait passively on the autonomous stream, so +// without this a dead stream would hang the session forever instead of erroring. +const dataTimeout = 5 * time.Second + var _ IClient = (*TxBridge)(nil) type TxBridge struct { @@ -76,9 +81,31 @@ func (c *TxBridge) setECU(cl *gocan.Client, ecuType string) error { return err } time.Sleep(75 * time.Millisecond) + // Setting the ECU above applies a per-ECU default delay. Override it with the + // configured rate now: delayTime is the firmware's ms between reads = 1000/Hz. + // Framed command: 'D' . + if c.Config.Rate > 0 { + delay := 1000 / c.Config.Rate + if delay < 1 { + delay = 1 + } else if delay > 255 { + delay = 255 + } + if err := cl.Send(gocan.SystemMsg, []byte{'D', 0x01, byte(delay), byte(delay)}, gocan.Outgoing); err != nil { + return err + } + time.Sleep(10 * time.Millisecond) + } return nil } func (c *TxBridge) startLogging(cl *gocan.Client) error { return cl.Send(gocan.SystemMsg, []byte("r"), gocan.Outgoing) } + +// stopLogging tells the dongle to stop its autonomous read loop. Send this before +// ending the ECU session (StopSession / ReturnToNormalMode): otherwise the dongle +// keeps issuing reads against an ended session and work() logs a spurious timeout. +func (c *TxBridge) stopLogging(cl *gocan.Client) error { + return cl.Send(gocan.SystemMsg, []byte("s"), gocan.Outgoing) +} diff --git a/pkg/datalogger/txbridgelogger_t5.go b/pkg/datalogger/txbridgelogger_t5.go index eb936084..4b518588 100644 --- a/pkg/datalogger/txbridgelogger_t5.go +++ b/pkg/datalogger/txbridgelogger_t5.go @@ -49,6 +49,11 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { go func() { defer cl.Close() + defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before closing the connection + time.Sleep(50 * time.Millisecond) + }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -62,6 +67,10 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: toRead := min(234, read.Length) @@ -148,6 +157,7 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge sub closed") return } + lastData = time.Now() if msg.DLC() != (expectedPayloadSize + 4) { c.onError() diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index f3220461..4f8a28f4 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -101,9 +101,11 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { go func() { defer cl.Close() defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before ending the session _ = kwp.StopSession(ctx) time.Sleep(75 * time.Millisecond) }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -117,6 +119,10 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: toRead := min(245, read.Length) @@ -203,6 +209,7 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge recv channel closed") return } + lastData = time.Now() if msg.DLC() != int(expectedPayloadSize+4) { c.onError() c.OnMessage(fmt.Sprintf("expected %d bytes, got %d", expectedPayloadSize+4, msg.DLC())) diff --git a/pkg/datalogger/txbridgelogger_t8.go b/pkg/datalogger/txbridgelogger_t8.go index f705be11..7df62416 100644 --- a/pkg/datalogger/txbridgelogger_t8.go +++ b/pkg/datalogger/txbridgelogger_t8.go @@ -62,10 +62,12 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { adConverter := NewWBLInterpolator(c.WidebandConfig) defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before ending the session _ = gm.ReturnToNormalMode(ctx) time.Sleep(100 * time.Millisecond) }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -79,6 +81,10 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: if err := c.handleReadTxbridge(ctx, cl, read); err != nil { @@ -94,6 +100,7 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge recv channel closed") return } + lastData = time.Now() if msg.DLC() != int(expectedPayloadSize+4) { c.OnMessage(fmt.Sprintf("expected %d bytes, got %d", expectedPayloadSize+4, msg.DLC())) return diff --git a/pkg/ota/firmware.bin b/pkg/ota/firmware.bin index a23cca3a1fc0de00e45d133080a509385d1dc411..8710acd47b340c84d4b477bea95fc65d725ccf50 100644 GIT binary patch literal 1009984 zcmd44&yQnSmf!d0hsLealrEV~+=<$#L{@?lKXt(vx{u3pH|L))Ge0Kh@8(x=wJCp9bKOAii`lp+x^ZuaQo8wfsU{QE!r^Z&W@zx}trUi!a3`@{eGPk-x| zPXEb2{Tpxo^ml&uU;mrxzxhx9&%gB_|85kWMZd9J>^rCYDkUH9{Y6*)FaGMU=s{_J z@HhYc)oFhr%@_UoFI>LA@T@4PqZ4bOOAJX2}w;s;F`MUbe*SDsRZq2`mZ%sFD zKZ=KMrtR}VXL`}@5BszBxHFmcXZ_JIKJWAgz3wBjefYtevZHZtA@8JjIeODu*IsYZ znfBVRd+)3fHQ`!WSLSSVdD)*W78uWGC*_NdA?WJyO?$KU{Ii0x&Do?se6<+2f0>N6{S9qu z`qK7}TTiPE${iiI+O?xvt5!Ow{Z6%F@B7CmM-{%czN%N-hqdNmsa1Z;d!t&aw2zMt zzVhX&N5>~mp0=CyQn^Y}R6BZ3EtU3B=`i>Hv~*NCAf-_~Jbqqn@0V%^v=l|ps$aER z$H(o1&d}ErmkkI)KX=k?t|*llNO!%d99VB-b@S1)`@>E5Bj~~%vK7N zXEYr4&St$XeQxPOtZ7bX?v$%FO$4Qwxac?pij$DU6zBT<= zz47B&v3{qcv)8>@=y@?t`Wn_Zv#S2y07zX^bOoHJuI!{Yn@@(lZrq*s;@K#kdC=YJ z#+OqATmrD-{<9u5%+dIU{aA#tgOOm%B!&4YgmrQI&9wdRH;>!Rqe`oGSUvv!_mj$u zwDh`LMe}!~IveHZ?^EY_e=yja!*!sV#q}Shbm$UdK6xj|cLsyeS!dRZPpz!O^>MHF z8uW~yl1)RVih2XSYRN1k>)T0xCS2G0QJvx=vtF&P=N0{QtJ$sKM~^~*Sp6^U zL+Gllm|63W4}a|X{0pQd)i;omsi!64r=G^R^y|hETekSgWHcYgWxxQi_l*mGq@J&Z zfiL@$d5?AS-O3)$XT7T%mqiw7_cYx0U^E)H&#b%cZtuJ^A2>GM>sq%XFTA+i5XGB} zqt%(dj+--tkt_tFXgr$E3`#3uS#r{=hsU8|ZN84;_VeYMyJ4;kGeb{E8 zS_$pW7|BP7TJWtuoc1O&zWoAmtu~zXCg(^A9^ImD4>@J@4wUPk)AR~BJA~4Lo)0HT zhEr;ZZ6f1~&agY^O@wP6cdepgcqPqWgSD&r;c&+{iR?L@b&z0zuJ4j~Vy)h;KCd3N zvNI7yy`h+5t^S7XMseTLF}l1|^yXoF&>y~z51?VjGZBL0uWxlF1~Xu|+<6~Cu(&=0 zX$&QgD@V<^Has7N9>qIXw|4Gd!9rEbs`8bm<$4oz9*y6xT-t7}UR<)czk8i-JnYRb zSwvfLZ5Ch7r?dF97oSf?m+|)Qea-&%?q~7F?t=$g;V<6#eDllQ`#ZNakm7n<7rnSt z2byuG+tp4a-nccr9fLOUj87V7%sjGabKgVrNVCc|WmgkV=cn{A1G7aL$tpYOb*8;o zqSEL*o^*zWks-@dx~K0P##=0r`r{rZ{9na{Rd%nXbj(3^~40+t%XQ zXg&ZPVaZ_Nlr=DhF~n8LQ;!yESq0l_>#2|~SCd<@^U3PU96So~Dp!+X&gyES%g$NJ zq5G=-rSt}AQ!B539#bj|&l87RJG(C*t@;Pp|A4>v^AnSI;?FC`FOH(=y7 zQ9FJVN3s0}J!Mf&V$pm$ukZYdblCIhWYp=Nb)X;7D~H`mj^gfcst1*M$U{7PH}3I! za`fy-9e;9gyjNmlcThWe#)hw~=Vq-{4bLY>wV#|MZ|wK#&+oSnOZ9rS(&pI|Nxwke zdDt1EVVOA2S-fr}-`Gr=Uc}4jCeZ9K?hE9Qy>~}YvQ;=CPT}!FtnB-@rr(c6U;Iwu z`?tE^hx*aVz1__yx-}JIgg@qyHtv+4KH?|3b6l^23elYxOrZVNOYM5|S$N*{FLz#$ z&R_aoEg4Rbb}y4>SDAdmofoBgEqptEQ4Mw8Yr}Ti%@a-8Ui4 zZCNrYt`C0cJ9wUo@+;a-$K<$KsV;n`JX@x=({7c0sV8pEN(|q()x0D8uS??D!q&%DT4O+J3u*g$D!-bAGg5S zAS18nbE||dX6h%3&FLu;hb{iNKaG!2QT_PGZu7Xhyck@pZ+@ZjJo})2gbT~(p*JdEN)XUxo+OlSzBGl8A9k}-BR&z zU6e)C{@Hwj?tV7N&Ujo&hRmwo+REb0(!6T@Yef%6NWpNL_ooFa>0y0x)T~#_kT*28 zh!#)V)tB{Jqw1ays-@>}h*Gn8Tn-;tyY3y$?qTUD=!#WVN?+-l?Yru;a;_5hY+rp& zWSUyjCD&gx%B6aHuT)9XnkV%#ie*a_&1g2;tw!kxnWA=l)DA_XS{1F2simXnuvRwZ z^9lXG|DyC&TZ-xC)6%nQ+tg(zFtjHPq^Gtt(=P(}P|QZ@x0 zbJey_$ECyC(UW%RNuydlL=2!v#9V}-9XHyHinK6$#|<4Nv|CSWO%v}lBP!FZHlEkY zfCRXs>91EC<%5$-m5FO2Cnk8-{>gFE>hk9cd(6^{T18ra%^|!(ohd()xEobpmaEl@ zT6=jc*@hL=7DkaLsvcE9k3(eZIoIy&PD{9qv}EXxLiJ| zYt8L3ca#j>)|}8)cS4gO0`0qnBCLp_0*NKpVmc^!`ECEa-$oVO9;2E!X-5pHIDbm-SIcjJZb5)^buH4- z20DPz5GBkU^?>dFOLI0g!&x7NwazkKV}e`Vl*9X@xvB1c{<4My+9$nXZ_+;tGDo}P zRFYm@dUi@bieXZpjNbI8bXW=v*JloyY;bX+Vu~D?9n{)`$%?vC_%b2!K_jN}+30M< zpUU?B>D`A$L355Mq zM#rpOaN)*j1-!1Ss}T6#v97g%FRn8KH}F>a0OaW20_d)XIb|=I)b}mtX{O11PD=@u zgIunb;nV&WmUUarYy0adg%dUx8(v8}(zc7syT%h-Ma_7Fb?vsu8R`I?YYuw7@lDER zdDKS>G)6Wwm!1AF&~KJRO?8U)=v*qTf6+Do%bIUucN&@Q-x9*@@Mv8bpp$I--1=?3 zly%QE!b-=;omS$}^QNDt!n%~N-x>6ODkef+t7vw0I_Y;`^)_z}25h?{)CFCQUg z`wt#|`q__omWBZ12us&k&sfC6-oVB!998}%CQGqY4m>jfRt>*NQIQYjV=m^Jh&5wI zuE5lYKB|mV2BsZT-5z3V%91-hga`m$lRw`U>tw01q_2znE1T1l`T636CA!=&qi0DzL6ht|Ela87~BFqn_5Sj#pZ)4s<{l1;#6=PGW!jK`z5 zJyi1HJdCqMH}{&)LuP!x)@Vw2bO_FRz>r4Y9NY`x77SgcvoA2%pns+U`2soZvftIu z@5hs?Z{7QJ+Os5V$0n2c(0!dZQhG(n|mkCc;m_8LFwqW zI~-brgI34=Pq!Z4{gJQb*lPJC*6)M3MrE0^x7Gc4u(RVUXnnff`Yf*dg4ebE@a}`H zd!OA8WgjF36POjw;mPpzaP)SFMOx3y0nN&3&71>P^k-9S*SxtNM=M5ypc9kX!RVFF z?ozxd9H*^Un2hDM`9?MgMc0hej#x9rqBC%&=O=hgxiZ;AihSR#yU}s06rJf%dZ4F( z=Y{(-Z<%w%g>&HruPyZl;G ziDhRLjabFy!%75c;|!c7l8m(wA8JEP68l{k+~uWCey9DPaC#~)S`tk1wC+%}` zl>@6aW1t~Nlds8>z($yKle``#hImF#-(8-L23r?eo1EXTtK+Xi&42sW&O^>K-6TM; z%MKqx+wX@v-QJ+{Zao|{mzFJXxHV%`Uae(J-7s%afwBu~}_=)!zH6 zU8+>jmSErc+qe8EU3-P&O67y842P^~8E+eG*Y=a#@H_4KE$`i}yJkB7V(&mQlz!ID zgT`5^9>(Oe;@9P^4TqyQqEey2+P=-={^5Ql9(%=sTDT_v@AUeJ4a5Fbw$K{5g|$X^ z!U}tN9n4-dbSR0kwzM!!JD(-!DA{x}b7XsMpMwmV67Vb>3+MW16f1YeP+rO;imic7 zzJMsEN8%fo@dh<{+%N2(+OiWTuY#k9vrRgOBTp~*oY1Ypb+B*Q#0?!syQ9@}DtqQw z>|@pwrmmUODkz=fFl)xBywmv^o(t3S`Cxs$!u8V;3^N1y93xxPj$^2qrtZX;FZsz2 z=PQy0Mf-Hj&I!5xe#qahrSZGz&nIe*sgc|^hR*dI1@63>+1w9aqmeP<(P;}VB!B3}XLHL#hIBb>zA zVs?28*NWwJ$ZA=ytrzPs$l)v5zGdnzTu0Cej*zuUlAXFE5$ozV{%svXvKkM1I3c(` z9ZlFKX~GI$yt8NKUEOwoioLbZtZqSxwUz3ckABnRFC&9!6i?j>H(gG zFbXu9UP;~)L&Q{tuANb|Dld>yAr9q|z-BdW;y z{Hg1+t5d3$(APp7n95rV&76@MEe+IFJNF!m^SDq5^H45xIBi1-aQgI`u{3+%3fuJT!S#Oixw{vJI+Js)$88;>E6vE_{3?3zZ9%BF17Tt*-4#^AT-2v(VnLZK~N1rv;eWUVMe*4=_xJ;OKQB_mZ}eXdt?nH zLirulT}Rk&WInq|F0Mzgh`WldaFP+2o7~hd;pJPrDho9fN zwAHzJ(HU&M8uv$AXRC0icC6#+TC<^Hz3JoJ={Jq7k#lr0npF(%pmK3_I9;cc4vO>axE*}FC(Oxr5~liscAn^1LP?Jo7pP+`RyPTExG+N#7Fw#Ao1rMTU?jamTFa67YE zhgFL2#wZrFL_~m+8YtN(r>!^TZE=0_#NdQTh#l$-KCAHtrwaW`#J*ee+fM&mT!+n* zn+}XO&P|#ao?#6aoHBmen~ZD{Zc-kKFdEFw=7S!os7&HeMDr#ixzqluT+amQ;(Ak9 z1&ho@c*Pq*t9WZ}`^nHUBmS=Sc9G}x#|!}GXynbw$geRWvl#Lgg%R=&&PM- zSI}yFixcRo?hLHnYI#5+`>%#D(41a%k4w@$urvCMSR;lLtH4L7sX?(uZ}#243< znV)1D*bEPHsACDM`_B0B#_ic^4WQ0|?xPg7L3b9VpAGN0RE+0qVXB^fvCv0O;I?9z zmV=8#m)_gZ(jAj#z+mU(l?>EyIfHN5TC+(Hn3F&_U=LU|C8z`VIAhrgK~~ET2|W}I zP6^=3J*4G#WV)R%QVbtFMG#=z?lOxu@rFhhe_`5_%+N@f-v&VND6-r6YPT+sTyD0(WLxU1P|$CG;PSbInrkm^^`aJ%u+{kHsX86(%^n|p57o*b4c)dMMDOO-OA zHLA_0$1QutUeoHTl+tuPXVc5an8ISzE2^Zr8;32-VBIgxN44E%yV^VY>w((z@5;Ut+Rda{ zOremku~3fpO6_RJ`lmAP{qDm1?!x;$-pja_VeH&!H!HwC`(EaJMeof6OxxY(?7Kck zO+2JR`7)5rykqHmoO@@AvhPhHL9=pr$V}R#_-9UALwd#F;L|7;NrLBE3L&fuxEtkykn)%ee#~4UNdSFIb<<*e6E`L8$AO?f?8-&=_^Oxc8xv~Ddd zlyM5G_bXFwAsT2&xQ2@HVxb+#*Y#y?q`zAMp>;4vJogKMmG3rpXy2~yj~~NQ**wtJ zj(@3a9(eCpHV?$&vZkf3*amiQK5bLvk#@KoFa$Cx?6^@`y84~Vwq6z$y;-}B_bFRD zmVt~&@+pxxYR88sFH!qqiNaoj2PfE~m_)@1&p0FI#4FK%36}9;hnRF%rfyEC@;V*V z5hUJ`)A}@a0Xc>X=n2$B1r&Q?C@VPBpOt=ayinDhe91 zTG#Nirr&t_`lL}OdJ4y*Do7wfVWuMj=@=Jz@6*yjOyGuR?nrNSKHrA6?iQ5AwDkl$ zd_|~(>)Kc>i)BYY*~A;tubr=?P4iqim9ijh@f!S0%6gp66ueoR9-nVoF4<7J4KpUr zBU5UulxIC`kVrf$MW=AVTNi~shXC{l=n>R9iZ*Zj-v?2Q#%KodYsy&E|A%Du8OXSY z1paXA?s@!d@3z$&$NN$YnsLhYc}qSlowsJ8!0LJrnFXn&)B0EEJ?Kn`!Akfs>k9$D zUa!jgRc{&{jiTs_^uKto2a(59!t{(y85+gu*DtV*z37iEkQAVdW4Uz=!cgs(@GMca zS--anMC^C7smJhqFMTd&(#zTOhiRUtnLPFTX?poV=K0}WUR+yGcl8_$KW=r^M{kXs z>81Ku*?CdfZ0v46XNPLfKY8eXA4HX1zWwAubaJ0xym{-j^6_TnzWaTCZ*zwoZj5`Q zb9qcF2F_c%=W4SI45#S_p7=IbpZQil(cExKuhRnk$2ZXF@aa#<5XUcD5U^YZzIXj+ zzVCBLO!S~>yw{(d_j`mW;`?9Sxw|vCvhr2VYr9=k+WP~7Z}lc#!ufBz zTlb$l{i(B5))(uSFIT3jDX|vzTJB5s8)-N;Vz|?ryh_I7%hjsYYK%Mh78d;Xb9L5^ zP-`41x{m23=#9pXkYzX(T2VVu^b_<2f}I;sx{iURura$d1q;@umgd&Zd3WhXoN9&b#sUO3ZM-*Dqj z-0i=S)q@foz4=M)SYV0F>P?&AX;_(p<*mD)P63DPFP-_$XM>k_%wmBWK6P}9*d^Y4 z6+foB>LSPdRJ-0s@jeda*c9i|P@uMN$yX(luZ5?0_FbkzyHdD>x@GdZP+`W3$3 zjcVoO(Aq^=sb~gLJ4My~69N)c<*JXreWkjO_D~8S*7(y_t8TiW{ZdQ%s{MUR9MoE^ zgQ}jQC^X(2ozG--5+Jg>^XT5rqn-Q8qqyG$k+ZnpTC101+zMoY)!P<#5e~si426Ls zt!~yT`|)#U?aWN-y8w?{&6fPn;@JC{7v$L+Vg3?2LEg$SsuCksai#kF886RGcb3-S zO3+NwPIuIwSrJ(1>;B9Nr1_sCdvp+x)BKM9#FgIZ{1t)M@Hn<_p8kZsHs;`q2$kAd z_-4i;Q_0-vw=&1l$HRC}maOi-@GZk2zQ{8WnaO6qevsoerE`UFT)}GKHMXtr%RP?m z?NN-+P+I|?zdoH$x94cPzxi4s(Kkl>ZpPT-STy?9{W+s!N|0&0-{?2n(>U9?#bMC5 zUxcpk)U+Y17R!d@GtVO@46feGlF!G}oe71H%)Y3$EUKLWB)0E${VMwflYVr?7?IrH$3~|)@Nf}^j_&#h3-Dw zxdX?Fh$nd`4lm#a#^fA$8tIF~i-j-6Fcp8tdkcPZmYn7&fS})0IqJ^_+SzQt8qU2i zKuG))o}So$`317Z&xC8b4I!A~VZ}C>cYE6y{dnWNY>J&%wlRB?BaH^2F>Y@|`?h7+ zI~uYLx0>1)Z`)JX{q0b}l>Mj5sIY!Fzhv|v;Fn1EpM1g*U7GYc#-tVu&?Vxc2Nn3@ z3&1t$we79#e0;No_-^M73uRydN80SQ_lfxu|Nig$hLSqXC;`=GuI6mLp)P@C0 z!C|q?-JP9ih?p%62xC+h`|ltA=)=c<<3_=aZ}T3${kon>kNNuz{{BklTgda52OnDg|L8X#|M+k7_jmdG z2mJjJe}Bwh$k+Y$!^dO(KIbo_wce1%pX&HSena{n|A!ww{ulfy{d<3d@%+-?`=vkl z<-hkUfAZ(Lzt;P;DEh|_AOCoR%zymx_a6V)_CJ02_+S68XEPX6s2pL9K!%*ZK?+-~1tt_S=%`ZcF+H!fTSF=f{g;EdSDQmZ|=GFviK0@0mcilUd?bn=gWHHv=s&x3cr8AX3^ivl`T{yY5rFTDRY znf`PB{^qYl(Vy`5y?-Z)e&gRoi2EyCw(^Uu zSJI_PmFD|J*~XREQIKT$_WV62rcK7af;_d+<@|~fIXmc#hFNm+g4Wp%GZ)gzI>1~= zvqMcwa|lB9(`=sf{ki;L8J(rLJ~{wTqthAcl7?gQ>VIGgW-U1AoXy(C+uEpUarv+- z9-?V)RvyojUCiOdPa~O>MpbrU1I{QFv6?$uZgsh}MUt~ga{lDO^hwdj8=NZ2v`;q0 zW#VEr6m_dc*rz8t!G2~M%1Zqh>)jA%dun+tjU(!%61P7V+u8VNga6cC8%EJaovkXr zR`1$$;G5gG<1b+>SNG1(cl6(FE404A=f1$*d;9z4+nkIoa>!!tYHb>GT^fG8(I0Z` zwK)R8FcWbK_~8isnb*A6Pj2TyB)AX|OR-I_0Zk_7zdewAhY?n8JG8JdPv&DZ&^k|c z=wbD-!U0ko=FWf0VU7lCUY0iiisj3KWW&(l{(aCO{vkNA^WYx%@WXf`Y4Hc~{o5-r z=Ej8=u_wDm0-h3r8CxbBIy;rc95OIvX9lVuRhsQ08dXCevmyK-k14{EWGR6z&1*oj zls^li$?UAn${}9=+1EV#MYP3|TmhD~1r|YZ!-eGQw58N&m9YfLLMYG~`x2ZJ43z5t za|0Ta1B~+j+XGAv#|voPHB(wp;@@81@iJ(mKyQ!6)7(6X*qd`<%vT+~a*= zXLxygR40=!Q15((nsa>dZrbMlv1$C#7cqN)_WnVMK!s)EM^<-HEj3;6z$J|0>I-k_ zuKiEw7@JJa52#52b923v-!Q(dbWg_%{yZb!Q<)TE;e$R%)~ZlPxmq9WluBv6_ZRC;K`om`Bt!1X$hUli zA*>sN+t%l7BtM8We}bIb(S6RgSZwlRyB)!9M03WHoE5kW*iPLUf`I1_i2JjTBQx|z zT;;&02-J;WdkLV`0^@1O>Vvui!-m1!3#2O;QLUUSg zeYNq)G|ku^a9v7)sG>!X^o;xa{77s;FtgHyhq+2VCMj8XH%(Wa871%@4ddRGmtf9y z7O@o*N{CwJ*(5l79Rh1RKgcSnac*dsD$#eWN)|H3v$q&2)bg_MR~}!w3aPfY?j<{7^hs19c#T zTm_z@Hg`VLnSOTUVKS$^S7sIGE4O*f=WO~MV1O1uaB`4Ud<^r)$cd^MiT#zkH1Q30hE>p6E3~oEJqxq#OJKp&?;HB%}9r>>Mz6Lx0 z`H=j~>&H1Dp!tM6FjImj8p7ANV}gGSxXjOiQB2uD&63jdsdJ)sb9lkh>`kl`-usgg z`Vha0^RpIo0{3=d=*dDjcXl+6EG4KJ?v&CE67784*0HdK$yLyQ5cj2hR{g3(Ue3|GNd!kt=8nQ=CP++0*ISq>s&3G14NyxR~#%E(O$8F0J&KYb&H<^v5tP*Ov z7>!=HF_F|7btNt3?DUQeN3`>HZs?(pH7|z2Bj#e=3kr*n&4v=@`dVb-goitg`)9fc z+*@d1+Oi=N1Qxe@xEmgJws~yj@Kzy>5QSgCwxnTeL1E9)!;IeA-kGeJ*noc+??NJk zFf23KF_168eNkqtrXDSfI*)m5Cb=n&F@L~QE~ol>ivUyl2uvY!2nphFJ9-rnC8&B9 zmx5{sC4#gBD9Utpk3Fz6gJO5rv8{o-3`=?)R@>>EfSS&=?2>DK`t0iiw0` zKVabMCyS@`FyMTzzmVLgQE;1Pj&Z&4Vor zBiB+T1@;JZ4rapDe~~w6?#?eUU6G)Ob&5MJ(1w^OP}-a%X=0z2P!_FcChf`$A|rB` zV#J#Z$(@C?P#&l%Zk6KUMb3as@{k2&WAfc?B~jDh-kkNuarE@<9_g>gBdzcV9wfsT zPOotzHxzLn5+vhJ$lG!BLWI+O!T38MU>xnCDtHD+xewUC9ECUQObw|%j_8QLYgZt6 z6xq@bu}GK?#j0UGxD&YX$`D`Re7}{Oi5^s~M_V=6YFQ;yB_GT=!7g z2@h5!pGsDl9;_NYI7?R^9)!#f!^5ZH;j{4YqwsLoRUMV?x~h3_RrBDg=D}6XgR44f zmKs)G_Y0Lvm`&>8xkA832Yb~g`i9%}kwxH(4(w7OQmV<6%H3l=bd<5X74Xu}G_U>( zsyNvaCa4fqMsumC(}XnZYrKW9I7~m4M?@YJN+A@csZ1p}tAjr1uu%}ZEq5p`fvfbI zuXhovg~^8UQ4L}7RfjuOz?r$Zf=11EI+`9(Ns_kT1L3OjU5F0Y4Po>)>G@aLN8U}^ zi%MpeZykUtR?Oyh@7?bZkR_^@IXIv9B3RQKjwD{OM3Tx%1MjF5u>f7m+{?vsTxb-N z)e6T6F4a0nN^!n4Rry*6dxwOnw5s(iD)y8pe)?vkOM2y6_i3YxK0!G#_ zYD*H++LVNF9qKXAW!}r<9n2D=^7yX%*>ykn+|PaY^T7Q)bU&Z!=Mf~z?{Q!9tiA5b z=)(^_{9rH011#*{|9lgA+c>G~?ko3p)T$kE1DIvSdm*-~OtbtU;iTep(_v@Kjgd(G zW*k_w0S)M1N2QQ+u_CwqT4XmzCx?5;@5lRuQy`}E+N#ecL^T$YnZjCHN{rT7$7m+Y|%}(Vw3pT+9!D>{h{E~u2FJ;3wrTnKEV#X*X8WR6!M0_ zkDLK<1HoP({*zOE|ML$#ruRGUEC$!oY=t<>NWH=iY>vEkxcwWU;UoJ$h5FLO7fg>1G{ zMO0)Q^uu^B3AKH_A(C;mW?nyoaSUuXPA#{-&-#3>lTkc91)E zGig}s@0Btw%Y0uM-Q9Etx>ql)z<*m9Dyg#LZWD^)u+$ju$$tn-#ekl6wJ<(z#VL^%7SC?%P3e zK#EM``B~3)3oiVGs{ymEgH1A9wJMvrFI)u4CDSX~CuoaJ6gT`W0J-`i+|0O$S9I}Z zs|oPRjiS-&Tcx`CTX`QLco|44rccG)vBvaCH%I0%T7Z$a$6B26l;1A7tfRfiK}hjs zbklRV;4%x>7Y!!O#`;y=?sNaF}>%4Hud8k)s%(?z4K|^ zZdMPtFOkmfABP_2>a#mV)y;+Zv$BUQY-%VAZE~d=#|H;?v0_1~-LWnu({F(xT(Zr#kSBlm^N-m7%CqPyhM`6wUj&^@UnLkLceka{bIBzv+1PZv_9 zbjVt*7%j;bt|>VWXP4xRNHpT4A@{)#G)cAF;BpgXDe9HQRtjTHQoZ&qUx6)+*0HXR z&wr@x=ie36CZCdrxHSWE9OW~y$nptUQg3Axn>(MS&i$O+c{wx;7CyXy z@n%W5)!rf%D=)6qEgWy=^ULL7r(fOWw)qC>M!B>I)dsR7MybMcUD%N?!GSoY9F0=C zEFE;~j%(&KzaX`liY37E==en*!+qKkiu&}d1e0XV$vQqY2eu^Vq8#HtBu<{ZoBZU{ zt1lT_GK7#h#d@EjOJ=RHrm1xhOf%rc$ewf>8PGGTOxn)`w6+CN#Mg zM0S@@;*bz%c?esIO_E#KS&n7jmSCTxs)Y>mNz#pMBz;Mf9Y0-8IGS!`4YY_aeZo3SqTd&>0G5U$~q+s<&$BU$#t$S+ZZh z?S$#)p|iNWt<3XdcB(1MaxEVcc`pZW2PuiW`Sf~953FRPT7C{E!_Yk=zNjYRsnz!L zn3yI9oCdh1!?eashJ6#k)P9N|>F3JRa-A4+Iy6FUT}Bb+lOgISp^&-c=7 z3tO!8gZg5fZ7%T4OqK?BQ*+CAtHn{)>JA%w5pos}=g5hrZc##R(wRwHIy|U6T+=}P zp$Uwub7(LMdbk#_p0s5;;3SlsGetZ^!+NE z$M#b&I3vSaiGclfx4Cv8`GUTc>>{;d)zXs^Q6O`@4|$;+<%8oa$gGIqiIi5`*H!4b zf+pZQwB_dX=6;Pk)l=ePg>A39I8S~6TyS1LsQPQ8lA6^*>tJCE9um33OWi@FI6h!+ zs|n0)i5K#fX`cE~WIf}C_0ac_DLdDi*yHjsx~|e;EweKZdEJT%G9Fv1Jo1EWt|duY z5bvTDqLF3by$G44o~12K_|k$Zz}9+AmC6dut(*90 zv$8V#%Lh9@YM0$e2&-^irEtr3Sp#2_(Mic`GPt$6B14^f!b=UhA|1<$JOW!fL2EJ@ zAnHvVfb;Y`3;$M4xb0>8HTi5IE_{D+e6VkGc+EU!^W9)_LUw~R*oY~4asNU_!RjXY z)&ee6R)(__&SA*lv6ijS8gLX&vglURjOBA&Dorlh@!*^HB+no$CD88$>BYV+hzY8n zU1wG}a!F3<*H}P3-tyfC~ZxzcPuWovI!RZxo0VJslg`V5uY z69scgdY^40R8mJ3mgSjrUz{P|yO8L19!}BgD@bY&d73EF*uQgT89KTyOQGVdpsEvs z3)P+|a7-9Y$iyN3;S)kon~q{-lW0NEW31{kn-$zaj!e0>)Bq=#m{5hUg?2M|!QgJe znWVcWsm*&^euf;}IO{r`NOM*XEprG+Vnw{eW`Zab@kRd?24l`v4og3miakSL#!x#7 zh%jvv1>RYoeZA4g`r@vP_IJP8dd2yPcgFZUm#d%swtVJ9b!M@wY6m(z#ps8pF~l{mc`bBeP~L4TaK`yvTcP$x!f=YyEoIs#8k$Xk-cplPvjlnYFJRhGfSfqaWva1#meW z@g2IXKg8wtc7G@3ITO`6uuWw;aDDmEp(IcI-twU{kZ00Y(q zxD^fPRRVntAvB>Oxk4NS&k2_%Gf}=@I|wi?ozJw!_7J-gU%Bg&5}fHQw?AZKzPM&w zuQU;^RK5aRr|M(27V?Iw=&f#p`Is9rdvDC|P7h|4#+j+YzsVn1(?m{9(Z?9|(w|V} z+MI?nx{*^iWv;+77YQ_ASvnn>J3q~vUz*XbM`S~}?aa3U3%J|6a-KAiOvom0dfl&~ zGq&WkoU@7e9<9JHvxRcTN-QugU7ICVw`m2q%-jt%f0?V;*^=eT=V+`B^I_H69(a`* zuTN^nN7Q1LQRZmlj25n-q-M2n6_2p{96+X=d6`cPUq6T{ifvcfuIUG}SpDFftRInq zKz(e!49RX|y({N0JRtizv6rzKBh`&}v6aC5zB65gy~+W#hiIM>rU?|5wJZycDQI{5 zTq7iVe9ugm zcSw=9q^vqPW#U(uBz$i6EQl`%FKlixKe-lCA^wxbg1siZ zi=Ic$JR7Isey+#2Fh1#=xyTM+HD$ef(1z%j87wb2YwRi37{YC0wI6O`!MtTOxf8A+ zx7%#;#+OSa&GQ(y$$N4Dw6n$-I|*k+hW(`6Cb?MgjH}gH4RtJS>^ktS!pQ9`5`sJAgHgbY`crB2@Sj-W zW}7D%u4wygHW_%DD}%8aVn%MtgRPmrZJRi8n5a$8@||4F1iwG)>o(rvY$ld@LRg#w zw(_kPC*+}qoIq?lkh_2C<^Nz7Hy!kPc|Xa9x3opt?DEYrsHojKff2O#9F!`z(!}!N z>GAQiCbuMecb6%#@*(E!Gev^ah6JfZ{1DA|W8-7nDTqU35WnD#<_pF+hkSfJ<+N_K zFNvHbzm4n)sL*NR1L$9XVAuVEk>R#oz@?4(LA6>Zwtu;;u%iuaq;aB84&B{ncE;;I ztE8?*YO%|wDCgJ5jm0WZ8Z=566unsdpr)-&)ne9!D;h8QCUcV&#h&k#&|hM>g`Scj zx`DW=kEDjKL>yeX2iS#7@bS%HrsTKYg%B?}a+ePpHw-!Cb7tMS*T#Y=io&$E05YS+ z>j_bqhyn3dOYZC)*9c5m!}oSSefa6M>?>H;V~nZh#k%WBcj+LgvydjIVy83$HOb zEwkyQWUpWYpOoFF61j40J~K1*ZD_(=Q(jQZ=9lv;f2a2JMaRXH_&i>b@xU$)2-QSa z4GYX65pKi&C!VJ`ckNkx8#;J9z8zgPwasHfBvTMUA+~WS1Jw|c#a^TF_^K(B+cWo? z-YOeiJ+)9S2&l9$epACCt~Ih;sx%SX31a0$)X;T}OPQ^C>)=VgcPG|8tM&@x{daYI z+|A}!)$+ew?pNOa-dWok;V2ajAlb|Xc_{LXu zQ+5D(ZdAf@50}uV@!3;^h-)FUl>&InHSm;O1iLT+D(r*|q_?AF#PMy{n5MzsA8w*y zzJS3fQzqBrYvv)T_vbOQb8{WV!hdA9R(#IX?LJ@(t5@9Os?p*Vs~ump%6p9C%%Lcx z=UvJCC*;!Wu|jO$+DqZD9SSE z1=@9v#QL5awu8|7Le!(eVolNihs}cl0#SKScs9N&&k0m9obf*WI$lD>Z(LSkoDrSg z^eLJ*U!J9VeR)W(bYPx{5krd^@hL~RS)9K^WV!3`t~GKgyfJ}g3!`iR)N9H^40xYt zW!M@_QBi9_M0fMc@tcGtlAt$P{)9qPS?<1On%Z>izK5hDd~XrI#P+ zl2SLnyb>X=4!c{D^w8ED`$znRujz}HFr)hc9d;{xT^#_N=`rc%hw;^Ze=?nU@$0r! z>yjVQpIo|$P}5FSy*anXi<0u~Q(Vot(E3cRIjXez9?emxCC~2n=_0CnbV-o7`I+ne zop?i=5x{a~0kO>=^G&1h2t=2+WH)Qp{z6+! zsc_7rSZmaj`h0=dkKZ(0<@s(d7H#y-dVRtyE|d;SG`H6biLn}wKfX2nI2>6?yU0cn zA3u)oyfYmt$G0D$KlQY;?$0)~Z6MU{4tMnsA<+7nsu7Oj!#>(&Ta zmrlj&MDjcn?sv~Kd>5+OK&wW?gCZtpl249@Eo^Q)(Q=Eb;9GgmeQFW*P@m2EdNn!G zv{-OC?v2!zA;Ua#HHbnWyH4t;VMpEC(8O1}Kw=#n0vd)5y;L|8F%FZA^dcl*Rd4H( z3uxXLyo9gOHdpL$+r@A?BDA^UA%^iuO8NK%r&XjLG^amhe`B9HH6WzX(%^}!gnHH6 zNg##OpNmCEx}7O=Zz84JW{U4$K)#lwHRr@@^V#eXN$CO+%5h>sF2=en^AKdoB|>C1>)B|;;No^2AtLiP-$Mf$3EoJ z!Bns`&?@|>0CGO>w*LiiS$(cT$=l&YQ1nAnikh9>T z0!P7Q1e^9dDEKa&gSO`x!ZGN|B?09)Pf0>xW|P0(`KADCP4OjMOagQox)PUeJm5rI zfsB$YmY5s7<+bP*=&-Mi5mWYN0c^?{5Y$9rF&tt{%-4BGK~ppL%-k&Alk2d>d&*8s zXw8L1v9{z-ZEb9fuNv%KPfdPcHQHu9Xw*2Gb?T#qT=36^6gJS8d~rS)?>Aw7q0l_j z(7r#g)so8ZvhTxdR!okZ$?4~||7AXw8n<4dI9>W+5*XOO^ubCumWUW9nRa_~c6ZV7+_`Enl_tCzO(1#GLr z4ArJhu9$K{eY!7#AuXe#m{fN7RBhP8l2`&Uqk2F1g%uJ?-2{MiKV-LgxPFqx^jN%e zJqCtMtMMb`Sc}LZ(|aQBB8&t67l9ek$xxGicycdfbwE*gMt{*r> z|1SC(|A^KoAY75Ohr$l2+v{L|a67EiRZuR^EqI(UDg!fMHoU|Yh zgsK*9Cv3p)hZbZa2@fV9&ia|W3z=3dq-<}B<~6&}*n-)4$x_YPOw#tH9Zu-Z@*h}v4w+Kt1EJe+kHcH6VM4oh;pKSXM7~#9{F73^VzR`(1(OyN{3P-k7S_=DYtJb5! zY1F(ePh3uVNoB%}{OInXRk+9ygzS`Xr2XCmxh^YS1R>O}ev<8yH`yr4W1(zMp}%YL z=-8EDqHFVnn5GzLDEv=xhtey`=R=}W^?PrYZE3Qd=a`r)l_kRZ0CIP*eNhn!*0TBw zw#x2;>lIM~Lu9$NU7K7H(U~o;WO{_P}_Nop)WYwt>iY>}CD{zo)6zc2U6?ewWjn)$^HdG9oDmGalIn^tYo%Xk?_PWxb(0TtAmw2-_dN(ew_1r%v8n^85 z(?#VBQ3?hqHI>6=k8pj|%Syu_kD5>u=leO6J+&tmm;e#lALGC)yLaBTp+s)Jy&G-s zM&}m!-we8V#N1|iU#*>N5vsS}oJ${q%3^l)^?COj&Z&AmEO#zCb`2;dv(@NKoY|gC zBcrLeoiRmq9|S6LOj^#T8y_d1KfWU^h`uZHomiG#&-d~BIw;|xNi30C`HpF-h`h|@ zLA^IK?pS=6V=@j^G>NEhVvBf+&2=!2%d#4m6ZN!BxpJS%P<4m}T48>7rZE^1RhlJ) zr8r?1mlsc(Yej~%gO(Ky6kM#Rf6k;o&gA-5b}l!}Yg&_ac};^VIORH!cE1=G?7M|B zwI@&jQ?1T$K8`S*nr(?~Z(*r!Gp`5{u0F4^$W2th0HjH=OH!37De*ajJ@dZ_qX?2xGYJ}<_)^- z8KYe{HZ#WN&A`y@op;b>Z%_e@_-rw}@*|nNC8n$<{Fyzn4rDzNKf+KIe2`I!EW01A z`X{+PWL+lLZMCxm8@CjLB&X3Sn~N)$$2>t&8b*Q_FLvHla`Qs6bbjA5!HWKU%YhyE z^;}*eIIk}1zSzPHToOvj&09t+8~aN`&Vy!saqtp`N&AAVyVOI5$!%u>9F}EU=&Qio z8=vTjD@!r%pPaLs;SD=s!sQbV91bKMzM1oHaBCd08;+rvOJp7*&RV=+a- zlixZnoh^`eo4?SGM7oHWqOW_isrgiKcEmNUZ@tALl!Z<#T;e@=+aGV+KXeLv%Ko;m zKz!MiKRcz1ayg>{JK$MN4+lSH%Fr2^$x?HS zV(egUF;fzX*mVp;a2>rxa^g_w(73+lUU1auwsk0D8VP+)Qz*pQ|L`?Okw~U8Gayu` zJ8BiS3KwExN7U-zI4e{40H{$G=z@NuY=~|!o3pcVU$(LKYcgGj z5bI$cIJ}s`ev-N`Pe)f#y6n90kS!Y*XR+1e@(E&G7Tikakv$(*#!q|K^j77zyF+0! z|KgN_Yv4rwZt{V#n@N%x<2yD+7IvC#B{wJN$n;S}Cpv=*B#3Cs9^M4ZO=Yl!Co7yIok5ntdca#yAc>siEB)HmEtX z%FayyPY<5Dm! zHkp!}PpL;=)`D6DuO-mpG0+Z&dg*e`JHnce3LZh2GuXyMOIC}**Tj+AXv`2qJ{O1J z21n%Uxo)D9_5{|MA7yy+XY3vx<_wo+)yRs!I8eEEY(!umHw~vnugzd}vpkcriD2qJ zAk29T2{{<$svvD^kPWnMw&WE{BLq3FKBp zcOrJKw$hs(<)N=Ty*R+!eel4IDuGIvM@Qw{i0Q2xErvkFV=d01AwKj_a|ErejD!C0 zwNVMH6gs$A=Xy?Q)Jn6>f(gCVYg^E&#*%-ws6hwd^6;d^dG;1PDi$?vwR?)_Dz`%uWR-+D=6yX#?^Ljg+Wvxh=^t zx@f{RyyLS|x_tIV5~*7Yoq>yyZoM0K-VJnhTCm;G#!{RKcX{n&u+Bo}x(SV?Ef9ty za=h>0izfG9*imTwwio}N7TW{~8K?M3}Bz;F25NpI&CHA@M@d*PuAb~dgZ$T_Zes=of+t!cy z80bQn&)&3&714e>%!nD}Vgcewoqp8h${_RnJnD`F4OJa$K$-^M#z5Mg;U>kW)&@7U zVcjx*m(DXO$@25^q3g7|n48em*`|np$-J-5#!|XLF7aOLtJoyBv}DMNpr8Tv&d=Ml z*==7=r$m5&f2QNX1s>{T*~ktogbV5CP{oRGf)_%P4anbSH-&|FUW25h3ro3Q&0PnW zei*SO!Ygz!d0|>QGT+-)|2wC0h#wjjEY6U_C6vcm2_*E|ZN8=#^O-dE`B9N4^{j6Z z4*MnhE=AW}ai)g#8E;&p;=^seC=t3d zX}z!J@xq$|d+t#<6Re%tUw^oH?v5mk?%be+J1P-wcwKnfj9R(ucd@*VsYq*A^|@iU z8Ma>_0Un-6u*zAzYjY+;$^wrL4=*~o@>Xi7drzGi_|6Ibc=q>xEK{WEn){1e9uyDFI?J8cefQOOO@3_bm=Y7c=ZXrxFQoj@2 zJ)@+bX_MlZh#~%y%&?&eqAzv}y(0@>d?|Zv_#y+;4aP3jth9AjZO!?T^liEDDR*fRV&A{Z@6h^Duezt-!&PIk++j_T>I>9=-n_B z@qC^JCB@xFz=8sysFD=TlL?3xAPR6N$feovLt8;8r!rV;`?e{Xf|gdj5jYVvy_&4Zb>@c!e}%-RvPhv-eYY*5+47x+7MPglLV~2Ux~<1I5WJ zqK#l88^7s~bPuxy5OK?Uq0&U`4O`MHEZGW_@*Y zr^7J^CCg_LAU4r?_-cM8y(`q}Z5-UC6y_kPKmc@kR+d$?rsa>;q#-PV!|Ubmi`ouN z_<|JZvMh;yjb_V0mK?6=y5+zylhL^Ig5`G80WW`hjY=PmUL*u77+RbADG??C6nFmS zOY3Y`CdzdAQF5!CXLyRyfX+;DAVEMbQb4XFoTOH-ThKQlrLJ!2D_ic-TCwFnU~gm! z57lUzofG0H-P=_JneF>5jo#Tbns+6?pkMNK=7n0E8+3$v=tELl1`66E7Ni@sjZElr z8w^jl{YlOZxqvDb7wBGqvrsxof#B}BU7x*i+jN#X>e2q=CgNOw{O10)Dq}EKr(*qG#fLH_lj~czcZLlFZV^}3`-B5I zRv%P_9KCT#Yo@z!m041|`tP+6H^!hI(#u3RbTbgS?1sqJv&}Cr-fA?49YNP7+nlLj zKyRxpLd?Ig7`33AQ9@V8M-j4Ix|g66?u`Bl8-&Y#FCTQ!!## zyy8;$ffS6Pd?@gWM#TPZx^ns|pp|pu(NI96Q8|tdUUPf6IW9l)g0#IPdUF8l0^?1K zN=Ostu18%i?2Ij>%?uUXxQFBWbP(7`H483rqr4eLTqo`Ky|CX`AuiFJnL1|KuqS_# zY&&!}&*S@T6{GR@=&EVuDPr_VGNb5(Se7J23GL%PJN}Q}tIBiyZmCjKehzR>UT}uR(?=TC2C?A1V$XBBa;|hbbaaZZHW2xQ4hsH0tW0 z=(Y_vgx|w$EjbP48l16g3Qo#C(3bX_hk>S;u_X4j(vV2^ed9A>u!#0=(z*wkj&~%P zK=NT*16PD|YPuOY;cTzhYuR{*R?)Xvj^d0A z*xC^gLD681JB5hMJ#3%R(7LG1k*TpbL)> z%FIXvZf?f~buQ7WKP?fAuA=LV){ogWJ|2&^`J?T1Rmo3c)%T~z2bJUf{pbXl#Nf^G z%KJ1uGCP{J)01}f&F|4u6YR~wJZJx9QkUII+ub-D^cBMb=g^{+evc)M%5#y?6Smmm zH7v@u>{_I>kezV$(0DQ12T`s5{9*f$D1%%}NdUvA$J`;6T6O5847sld0%5*h3?iMb zC-GdiwUz2j&Mp{pT8*?dnQFZ8ALlc3j#Gks$mrZwe+QXz!nn-jcFM6L=Rw-3m=kqu zb$MQ`#*|^o?z@8>gWhAB!@fnPDe<}P_s*rT^_IDT=#SgniBs8l za?_@Eo!LP;M!nnFyNoYpB3uTuyqTH|r8|Tdrq8ZC%TqQh9FVgA`M{xxqUf>vYdmY4 zT~S*XJ78X+o|p|SywwilWJiWc7jj%MZSE=n@vGj(CnP(&qSTiKD)QW2}ULOp`FdE}D{gleA z5{dLkk~(}dTOlO|C8y1hGYHX{U++o>R$X@S($o1#lkVKTOAgWoY#OPuHXF_y&7*hN z1-&9wQ}hngayl5L;kaU30ni&p3E*G^v%!aVdHDeb2s~WbuSNumHvV{gIwCGba zecAj{5O}-gPB<{uU+QhhA=zT($2atCG;y(9QE?jx4B6yu0fRKhUHSq{PROH~Bi) zH`)MfjTRM24p|1+&R}k@h|Wn#cZl)b%K8p7OA{SX`-ybAL`L&gQ{(BQRK)@UuMYYW zRSZ+IO9don34%X`3o!~9BPyejP^a4DF|t}dimU>+R3vXR*E>;|t{ixijMMIx4z{hI zo+LD%ap6?@W_2opG!olr|MN+vbn1-C=7_a041g-`FBS;+$T_Hc<&R zxQ;}Qr`yxvY;6Bf#Q`cKozq80c0TiVG7ezQowodf7Ni9jO$2XL1P!%lwvId?wS$P?fX4SFja# z!jt`6liSs~A|x^A=1zrKjtms6Z%9MsdQ_(mMgrW1=*E zZ}e&omg9tZG3$OQ82jhBLTZ_%D-m1l0oO*?gM8KbT=+?OD<<;}5-E+5VDd%!V~~&$ zScsFQaoH!bI8USHvB;$(1!TZUBv7G*vkh^HUvPDh1O{+cL~JuP$%6~U?0(|-ejS{bV$Z>w9Ib}{H;UrhGa%}D~b7_@?~Xl zr^t?NUc3U?anSrE)cock^vo@41nXpl1cbAhlt*F~qcE})7CF2~ig39NMp>fz*rsBa zqZ5i;1CKsP@@{_VofcnigA1xMG zs8-@27(v8t0D#Miv~(Luqs#Jr^2zyeB(Fil&jLj-^?2wPtHCb5b#dWVnWs?JcW89B z-E2K*>0?$ve|SDxS{|=}`kK|DX+J9>7uS?SBT`f<6c@7;<0c`;ye#{=-Jpv*E zvi-6>Ub2_W;}G$#XPccFf#WU`+dpmX9A4@l~}CrFZ7&R9lnh1sh*e|){1M-f{-5BHAjh zo_tqfL_i>_J!w5#NcuDTFRFL?lec|Fk}uFKRpY1BC(bASUx%qk2DbDLK|vru)j`j6 zDFtcq{BB&oTa-JV@5c3Aec7yAm!_8MS^1f3=kQrnKYSJ+aeNwa0ZIIfaXd*bob=Zy z5x8&zB7uB8nGW0io~b|Gqn&ar3CRl}7EG00VBXfZzHV~poSv~Kes$6Rz1M@w;b{CV zmSXcaZ?E3{bobu<2iysQJr!}Cjoap^S5p?anU8Ua(U=wnHV(3J>6a72vpCf78oOLU z@3HJfMJFd9drJ-RLG%{4bYnlc#$WU|_xrY0QD~a{f=G!-7p)yQ;xapSWTE^dSF2v! zGo}~1)aK~;MXq`mCtplV-q&z7C_~(O=?#wYyG!pal zX@dGmkFs!DF^1sg{DP~~J#-WJv^^Me1F@AU2+GANb$xd7^`bDCWKQ&|2-}k@6bs@9 z{c}m;nick!$#0b53=qOQio2O6R{26j~iUpH^*0yt1D5MyY+^XqS4PHcZPubymzx?i22S}c zs^Wpr0kDN&$bvbr|De`I`)|m^v{);ertS!>f+_P2J!v78qC%!Teg+n{&;iWEHSr6~ zBTNDd6XMGWgjDf)RKWpe0aXlXk&JX>dkzw1ST9$gp}F?nDXwI`e@vOaaxiR8zl~$= zkK)|Mx3Vs8qsom>vPCJ*OQnb!T)rE6oUg0g8w>~^i^e6@wS?VgKBwqkB9bAOO66!x zaRhv4CB%cf7>$kOV1Apc(4@`h43WbG zmXSRU&vfgy$v53O!OgT!7|cy%Ct0BJ`YV_#ao}_bm)z7j|DT^}9jKP}#ZRU4QF#zF zl;6TY;*Em0bS}T3`E@uWxuqCmvhy5(;qXlH@&awW8EPuRi@Pci8Ni83M5|&zVw)f& z0)uI@y*@ZOqL%EU48~1`e`brz6=WUlhRwO4cyPx;suYvhQL&L31i#@Nwq83D4`)Gg z5zwol8(O@!_5fPl)^6d_Zd~HR3@&dz69&Kcn{QXd1MkKigXa-TNP2@C7fEkiym^@* zgb}3)QZffo9C;J7$MB#BOBNfp-bk;wfuD(#8~y3TOCPtR){9atM?p&PftfUtT;JKA z5%k2BnO*4+)~VcG$c`ns%aO}%m9c*ym1!h#&+4Hw$eElfViANPsJS5{HJe%g$C2(E zWDg>3i~40mt_i^`)%TVd7b*hJt}x>m%)7m9rC5iTVrd5y!=$zQ7DoWj1VnJ?jHEWm z?2~%!xLs?upA(3p(#DLv&bgBk1K0pTf8<0&1F19rbS#eUj zbL>EKXZg1LZRccyma$*)gY5M~OY=boWIm|cjD6{)tv8*M654)|l_gE%Il@lXQ7PdR z1r?1C3szn5`vptXgo6C(DuN8p;J4E2*dCEBTXx9%P6z}u+>mW6 z#qFqw3}Q>J4oL@Zj>5w=fF)DR&KTB~l~F*Z zk2Wi-O4X9oB4*BR9`{`57w07RYU7`DAI>z(Ss zzQ~gu0%84WwJNZs`k!AoE6Ul~_&JMeeW|cUZ2%!4gLd)W8LPOhruV_L9$HJ#hv3h^ z>=J|S219f6b8QzF0Fo_r2imO|>410{Fu#LGlZ6^5lQaKvBvqrcy^<)_!XgX6%z-5X zj%x=NeAqH3_Uk7g9bG7z9bmz*7Q1L?Vn&q~I@jXBBi`Ubv`3r|OGS?A-OlO)R=BvM zMH6L3ZxKI@(m56s0B-$S*B&=YHKpQF+jqoU5lvQfVWFBZzN8PXu%lk;y5wbV!Kg`J zQT*t*RSih&O{g;?VNOCN9(Pt$lTOc#XH-kqXI7`RMKPXzvj&r4*BkzHDAVnm4LvPs z>oefhGWgRTtpTJuB$I2xtFrxf9Q@|fPzynP0rN}<`Q2tyFl{Ot^LK!#*BAsPnux*j z>6W9tUeD&Ts~4 zVp>0s)d8k11_?V9a77k)*S^t4$-mywIU{!|Hj*R{t@D+|SeoqlZYV3c-lUm5#ar8e z4uaAWK5LMTewK~QL<+fE(=Vd$-`e?XaP|EU^YTa4g611v1v+4mfRwb00h7sTPz69U zoxMh_;vE4q8!MeA>f4)wZ`aGMW-#ur(-Q>T2281R+~{I{8>iNwLY8e2FjZ~9V2vzBdF z>Wb+WOpGI59AnvEZyZ1OM_1WW?aNn_9HGddy^PG985siy6#4T)#UT73Gn5vd8~LHg zk$p0g>3^kqQ2Hu6vRtnYpETO_=Plnmjb&4JJVf?1MPuon+&cu&hus2h8B|n$y3w9Z4{=~Mxvp31Z zrMg*!uWKT$86*SgyDJ-Tgh%=F^>GFiSNc=nDC-L;va-0Jexd%(6vcwuSZ4F6h7#7U zv7vmvynU<3M<@_%Z=U~}4&Ur;99reDpt?9G*B7Z&rK#b8QWYWfz2vT z6NJDtEZE?h5*^_KguYE5YJn5$m~qd8uX1&z#d@xxOq{;_7qT`&%(C z9s9K>?Zd;9cD?kZnuKTPf?!oLAY!>FD-jC0u#blgyIhGN53}2!pu2R=h!6~^_hs1H zwIe5N9KK^|!#Q`2P@mX`GK^5$zV@4RYBCZ>2_@d6KZ^x3UFjBdrSk^HE-NiRL-IYB zZq&nwYxr-H%H2S7LnaQ$5iv}iBJO($&z8yZfR=99v!Luz@rAq2C70n>^i% zFb|^V^%|yP^%_=T_1e9tw%gp%Ka8f-XfYc8b~`isLI{c|^h zRa6_#2?43YgGrt4obn=(GOnl0r`JLAJ6Y<%LX zvb$6W23a4}3F&%!#6tx;r*B(}yY8)W7RU zVNj?SyGy2aAQOQ?6n*KVE5^p>I;^Ug8k9j#%cNmdWf~Oti}^@e5bHv!H{xKGSy9cv zRt2u2pUA$>S%NY(ZWNjB2G1gn=_%8O1wPz0$x3>iCxVSNwmLYA8Vnz?d3?7zRbVs9Dh`~dtNggKFBG}ijp-fWlCt;bN z$p?qXgbf~&lsqIB77}a}GkPFKo)Em>A|i^ZHA6trnllDaPO^G71hB%PY77i92^oV& zH8Lm))tN~YQwE}#Fjy|AKa(g_p+*MfB4@BDycUf@)G&(jC}I&Fi;H?SjcCH4SaJqM zF{xv!2U7;3uv-!q^>woauV~!|5s$KwRXg!m;ixeh<8U{ZWp0vT%~F&xxFI9CNv2D5 zlp(IcUnS#P|My- zS(ZHd@ytXXj{>7LJEr3a7qC7G9IWsR@hW zTGgBz2Smdx2YI&NODXkQ68JaY2lTv`KF@QH{DWHTgfIkELQWZ#D zr;iJR85!(D!4w70OUY_dtBK5xhuS6DslYaB;N2Y-Lfc7%T0>O|JE}#kCNx~&Or}5| z76QMNwo1VLqZW_hxGW3!cr$Ms8eot$7jS#Meg8q4wN)u;dlJXJ|7Uq`W0O@VY+00m z`u`Ti(1;bdMC5{s{Ff-kX01?^peU%~e~BVBJ$GK=DutlcB zuKZ*(m03|ixIQqdvT>k5T)H>k$S7dzPgG1hk04m%h$H((EmE}jvIxSbpGP~sX5r!0BB)&rA;!HF zWv0=3V{>SVCIlv_v9JnFK)n!54AOu%aAMpYL}5JeG3;=#8273o3o|(A6*xDH)`nFs7IJ#Dff?g&Z(1r8xK#o*rbL`BE=Xmg%aE~S z+^dMO;<)LFtp29)sAa(-1+>45##FK96_W}8+Iplz$_(iCB_|#Q2CXZF)H>lL}{Qf znX1KuPJd%mb;N`c(v1mV>4)@cvQzZfh|Ai6NkTzlluW%g8z#5=rwNe;m-UotW2U~U zoxq=#?34s<;;vPKU{}8WekkZWD(zb+CDHr#ORcq9$8_=X#NXu63qq@dvllrFh8!~*% zxamL5UA%0~rY(E+9yoOF=ACdkPKM29rqg9SOf;D#?MM4c z1U;xWF74Y%z~C1;>2F5o!Cz%EAQp?yuziX}BUo8uZm(`^cdQqws$!|BnAI+vtComQ5b^>rZ`y1qb>9N zdrGtwD2;7x5DSA9ajXb6XjHF3eP4eeQIp^wuhj_k8`byMHb@kF8r9R*OYrmNMxZ3G zR3=PB>S*J)SVT-j$Foy-Q?y!bLmxk1U(sGEM2sxHu^%jdpBPZ6;QGa1z}r1T@b!%+ zN@9P~g;X6Jad|kGgPj`-e>_nV`;#t95$FFRx!hEkZXwH0RW(eBiFT67gjl;rC^rnP zNy(%GG4$C`lM^zfE&Ot>2V40rK1>K|)0W2-r3`+kbZ653`&OZ12zQfdJb~L4Xq%J< z-3od3UNGGb3vXI%kCfFCsr`r+wxO9NKtCb{CYWJ)F(Z?9`_a^G9cauXFb9p94BSk8 zF6U;Z^<|;7!%BciMjiAt=|Vh3tAhOke69Wie;CHn}~}@!KQPTCC=#}9k19p@=5iR3jID*6H86k zU!X(f*xp%Q->ACSfFu~O>qBu8aU=LGN#v~rX6c!nlqYL_aajKK6I&wOnSG7vp+L|@ zz(AYDLTq-j67wX(jrSm>z@4$!tC(l%*BSh28hs;NqbRMk$`jF>)->8^`5fsfWMFNs8P3I z>?Kk%aQYcI;sFylfm2UvAPMNG{TpZ-#tZGEzWtgiF6>)xD5xQQ`wdlGLdCtD{xK0e zfMk|UdWW)w{kCCI;i3v%eZPQu_yiLC8zd(9#y1e+8Z?Ycz+sjetxx?%3H5}8IIS;a zgn;_-0g0%9hCbSWM*fY2`r5d-fVc$qc1Xu#F3=Jh?D$a^K^WZG&qruahK+)#XX+eD zvnOpBlGQ)>v7o>m^~gc9Ml`=81<3q79JA6qFeIc+TkaAQ^rMtz^4#BoWm3-@b9X}L zpvYD{dvb(?bri#3$Awa2Lf%+{=bq5WNWmjKrd@PwP&+6AhK00&*AJ3mkwo79J`8+) z@LGq`5O$8op^~{9gCd>KA}9hjU5%}Y8a^sW590^|wz3J>V=QR&aYFqb{GZ_Ci}z4J zkUmrJYbgY@lttzyc1&6)U#&D@;UQx^L1tVQS+|zujkXO&P^18*J0X+av5 z%4uz#DKJ5a$0In{0|z;E#9*BDYtVX(%Es}zIO>cPF<>nf%@|)%Oic@hHlb98E&lAt z0~i`Kgk*BPWlTQc-OhKYdhp{suw@fBhs>4nnw8QLjh7)cY2GDBm&Ej0q0WZW*KjD0 z5SFKfQH~596amE8QS^m?4+F_UvSFD%7<(gG@z4%&2safBmI_j#j+HKiLMaaK1Geu~ z%nZr`;_(F#B*V_ojbMAT@D(jnK;ehlMUoX)bY}Rjz<4GFeIMl}6+4t$VZ(*ssb$91 zV9r6`UuZ#<#lB-Oji<}d>&0nKTw{7)L&b?xpOj~utX{)OjT&u+nX3)qS4sIx7cMik zE3~46M2xpY(h8IIy9?6XDI0e&(|kyn+5mqcU>g=}Ov|#MV{tK~6VJ{$CrxpIG~A)z z+#e@Ep&O@aCL18)4dxe$g(nTx05OoJrj<=wfN>K^U60jg9PS1_BcVWy*25}Hh^7y6 zcFT;_WsD?MKz2GNArKeuTvlgndvU5ZkJcKy*_;}WQsG2S^njA5J90}p16hELOA z+LX=Ku<;7h8bYC2jd8xzHuXX$QCCruqSN5xj9OU|HVFtc_fPiw#3xNMF@)!Wv4yr| zZ#Nk>FhoNo;$t8klGFkRHL@p+T2=}f|0c_85DJw#3)bfNc?Im*KvSWY6r0O5KJ$jQ zrtgmr6D+T==Y^h1(V&;O#oVBgOCh8LECliCTx`=#OM%4=Du$I z^Ym=#kJ~j8#I6QS9;14C_V6EbTHa!?p}Hs^V_{?u5>6!|J2LWT3wHs%7nOw!NJ|Bl zJ=BO`V4Ef!m5~VJ2I#0XWN(D_@GuP})+x{vE0x#yY>Ns^;&p&8DYRyFELtfNk*Jprp-><3{s-T6)+ zS^;T`mksTUVh=xc`?5NuWhZ9ZU5IQS)v60LC^14rU&PEc;hm&`Z5|Y*F6q-J=fNqo-8w9wF5mS-8)>|?sfkP{4+8?cVe5)OFWb5pK@ckqYH~Cz3ND%p z_UQ@ukP+GiwWDtt4A!{Sy@rjrT|OBjC^&?)zc7DC^YVC4#G zL|9SaH3>#x(3$E|SU!>!H!&ugO*M_pStcp4(wIo$J*G3wg>mG?z*9yOE!^@8&n^=O zxYOIzSS2W%fr(K9D}S*JoP1`eM==su@+jO3%Zsp^BTrVk5`@H>rcVieEiYcf*W$>` zM4A_g)w_wXSk$7j#ieT~GcOr8zi7-wdc}#ByqMBt2DVy|Z9>u@jm^%)BaAr-$+(7z zglIhJ8=+7yHHL_@FQgP{NQ>;Ir6l%eNgA2Rx(J5~QiPQT)%qeV^kI5z+@LdOiiEHY zlnBW!!pEzW4yh^1X5z(&G8XIYV_`E3rwx+v_ zB0=FGHK-xT2jm6v04;bM=n85GiUxH7Wr6ZQ(?IbV8Cj_rbU+y$Nvu!CREk!ElaNre zs0~a)5#qa4CWkqEd}GH_?*yZoUk9)ksve(cJVCx;!eo63qw7pe05ju-v~NOu)naH9 z$2JG^1v!tDA{)lI3NHG3W_*YxLZ3r60`2|5un6mY*k%RK*9#Md;;^M3UNG3XM=vPf zEJN*xhfzyqpISW6+M<;i_8EfLXM$)0rD(?Vi6~EylA&@k>wAWgpJ5M*+?tq7HQaPH zEh-!4*|32OH-o+saKI1VVMT{1%q9jqf`nPaq1onC59~w1PEg}T8JnPZt9jdI`{`w3 zR1&GXWu+GkwMP>Vm=;7gmFml$X5Kj@U{9LfC?)o|)Qj>DIz6r*@o;Pw8WfHTM`Odj z6IleYulpawV1;BOS-QS7Dj`*NN`_!zX=Q6|lah&fOV}%5*F_$RfKPLh<|BE=fw_4R zsot0{#frt=ceE%r>kE^K7>we3m7*E~@dYQ)_c}gY;%TxHpBRQPn2$0QW_cTeX;OMuFN zDuTQ~zM#gS5Ksgt2Gk3b1j+J3x1PTLn0rdvufJTC*gXVzN zgSLTw16>s93h*&V@x3~*6sQI$02BrynJywq@@eo722BQLfYfRs#y>O1xR@DHc&|@{1wQ;X=KNCZlhH)`<(0+u>qffEhbPj z{`ow6wQI1InjdZ_2xMA%;aGJbrJgz%*NO-$;fKbcupI~uF5v_a3T?t2pgHh8&;n>P zP910o^Z;4`eSpv@5n2IlfIWe>z-*u$a15{na2`-_5`>LF1@JeZ5_l7627C*IzKGy3 z9{B;Q0xf{SKuchEpcOC&XbqeMv;nRF+5-0g?SPkoC4g^$_CWgy>Ocoz4WJ`16zBwu z2bKg529^TO0y+b?080bU0m}ei0bPKlCaMEnfj&StU<6PoDF`V*1#kdR37iTv18xAS zfG2?Fz(+u#v>;gj0C`|ppc2>!Xa?*GQ~|Sr=D;6-7Qj_Np^PA$1S){nfJ)#~pc&9| z65;`?0L_7ZKnq}Jpx_}089)Va7*GkEO?)4ekNCjfh!4CBGy}c@s(`kWksh!d&;sZU zv;?Ywcx?+Ef!06`&<2RT(&;^(ZbOjCrx&apg%L4ZR%K@(g%L88l>j536ssk}KEmQ*r0Gk36fRR8gFaekd z90*JT{s2q{E(N9lw*&hC&jR}b{{*H23xR1sr)lcIpMX_?Gk}eNGl7x7S-=$FY~V29 z9N=uApcaJ9Kn3t1Pzn44Xa;-_Q~@2QqrQRFffhhD&=S}cXa&p!S_4M`ZGiKDw!lq5 zJKzam3E(}TJ@7No0a*4&w0mHEpcAk?uq3cAuoQ3<&>1)vSQ@w!SO$0o=mNY4bOpWx zx&a-2Lj40P0)VOTXaO{vf$IX62U-CGf!4r8pbc;Y z&=$A?Xa_t5ECGB3vRGW2ebz60@?t72igMv2HF9gXCt3{LGT4C zfGvPZU^>tYI2EV@?g5$u?*lD>wsVjVuny1)7!I@sCIKCRlYmaZb-T5}9;42Fe zV~EB`z6y{r*!Y3A^~0zpY_p>lua|9U~FEzmDN5WN+?<7B^E zf3tUwj05LK_FS0o@{8dKpT3UkQ4CLA46k=Fy!>K#3yR?t6vMk!3{Uu6O!!qD=3C{>ucWq<>;pzF%HMn2>l+zPiW_gcPsEI{AOYp@n2E6 zGr0u80`!6W`GP=ckla^YO)OSWwL${yBJ;&$GD?!dd{|1)ZIhFu=|eyn!QF{#F=H<0q?BY9InCkpJ=xF8F2f=yQg;M%fQfW4^(w_Aodx zo`hS>PiaSd`GZ7RtBA>d9%|7+Pz@x;!;Ugpso++FJV4@)h7pEeb-42R^?8(K+%YONigM5`iVmwEDp5SLJ7|-2MPijg;`cWN^VEHKp2V!zD`s-MvpdOSWwdD#R zH_+(+7>^Q@;?XsYXYo}3njc-R6t9vYo>H)e+XKXFGN085{z^Yoi-N_YGJ1ffvHYy) zGgpepuMcvv`<_o;%Fi7{*E-|B#zTcl@eoaz^Izj36Dc0ulLh}Z9!f98qkFdGzs94! z1@d%#B|yvnYdjBQJbDIJ|JQhGV?2ArTmN6<3Ej5IdhV=FGwk_Yop2N{cb%~Ooz9Ixon8Y~bGGJzd21{*5rY!vS{DKhGWjGtlpN?7 zOgckJAUheE$)q=#&8#=ke4r^$TpVG=l8o*^^@zViT+ohUgY)wmjl_O4LSQVmUVVTel$Es~pRf*CKuNK+q&6D6R{X_!bf ztr)sSx^8-W*pJ#Wk>uyx|L&)_^eZmDl(v+<6OB=P{qS=&tV^$E+g@oZC^W z(L!gl1-|THQb%M)VO0i)D(SRjtkG0Z^wFL)(>UTbYdUsL3dq#uw9CL2#r`b+DB2&A zPWEE(i1W;4aYPO~Yf2kJ+S$;sGnC_>L!+2+7Ey+Z+!jk=dAKqh;7$6dVrFm_AE3=* z{BS(IMT^GEBc(u_>QXcfbNqhJzw3yVW6$;s@Ux~67$I_34 zwIkY_8VZZ0=_pr9R!|~3P1=b}icYM6U(1MBOP2ppp(NZM!Yn~FK+(@Gg)=)cy)0_6 zG3nT)riJcS7`9txMbPdNmWNTo7{Cjdii0oOryIDqztDSNXRyn|i%C}=Y={mcr8!aT z46X2#BrLmSNf$?T6xBA8m&W9N37HyRfqdhuSX^3c41xwGzi8?cqOgodFGC&W(MsQy zc3F26)gdWmULLW&QB9nI?jx>0#o+x2jM5wU)U8~L{uKBEg44zO-$?w_`^smsa&^MTtO6a zBB9n5mY0$hf=j@Tai};;B5@j~y((npk&H1rG-V8>&~U^an^!lPa0tQFn#qg6$PD7= zx{{6?dLnu#bkvU8;#t6oX^v^I)DMZFxL7E{p-S{(l_y}gF6kUlpFt_{i!&t3Ml+;r z#gwgSHxwRxW(AsSUqiu;ERzegDr{IvP2xQ*XNs4O(HZNF1Rf73qv4A^Gb@?tU8GTu zAInv>4NR`#X7r-(O2=zbjDq!f4Q;+>y7O!fl1_3GO}=56tEZVt3@b5aqoQF#5Zkh_ zU`(UNEV|HG(s^WJaZr0rqrERV9r4zRXRn1KHzLm%Qsn(1kAxs7kYxy_(s)(0z_2!(o z6qB}`B_BA*Qsob;vGRE9q7FO-BYj;?2=(;ry3nPBY7^t2vr2r5N9<52y0*)R!a2r_ z9mF3a3XyTC+DX=yfOOB|@={wPNg}O-!jWlSCfjk5g;G`f#8!C7Z+9Oi}pT=XpffsqT{kL7f54g=~l#>aH!T$CergXNXY#ZPNxUPV(+e45tLaG7D>@| zcxju8A*rvJIf;6F1m>_s2wJ7!xw5+DP8~ZY1A0Fgov~!7SL80!yNLBbiVU3t`cWUPrC}Ux z$U$?{ z54hfev;nXVFCw`|J36R0gARlDM&#SUFH8#xJU%rj@FB<_{s>TSP!@&Be!XRBN$hFG z@T9PBp+}K)**0K0xC}iD6cUB=RUFjepiEB(^=P0~RR{IX$`0yl@Xr9P2QLEniNaBK zDIXR5lKX#yv=NWqKBUo)!v-5fr?Y@#HSq$a zf=7$y@$e3zkrb5o#Vkk$_6NBry~kteRX|_V{MkhdO=V*;Ca=?Ka#(Z3Nz_bJ*H9*O z>3uP!NGml4$=EDSCin6Fk6t&JHl)2|*sX~NysP!G*v66u{OUjmrkC0KpE>yIxsZ@S zXB1%Gmuie{N-W})1f=zO+PBC=@jv z>H|s!C4v$_@68;F@0waVMLZlE5izk?JqE|Y$c+nPfV+HdN zOm|?DOK~A+LB+9Y>RC*c-q`W|k$8eOPh?^Ggq~S!RE?#kk7h(&t!QV8=SZanZ@%P-vVq@Z*8v=%+76 zV!7!>dD)pVP7wjqFmcSx)8ut4i^?8KvFLg|8H?C=0F8ls3UDl@y4 zG)Wl=BNY}}|)D(zs?8#{3 zGh-ns*0n(%#bH7^)~AUZtBHdnxG?_C?9U_M`zI@nN0v%XLKGj}J2}O@B05bD#vSRg zxp;(F!mJ;~Nz-C!$cPKJ0k8oWb@ik`gt-Jm(PVW&(aFoxAyXUJ;6O}$a&{JLqU2${ zy@5sB9heO3z<6fNZz?8Z(e@ISlT;X1VDX})65=Z>uD{4*t!lLPQ{Z^e2v9!A%)-vq zr+1!$hB6kG{70#>VjyL_3dJ+c_{~Da(JVa6S=uZ@^2S?Eb0nxHTy!oS;}p+j$s!5n zdc~Oh@fH;r#Kujju%^PcVX}2MVu_--d0Dy)n#4$=Ibc~^Dkd#yQwhb_VN@;dF=1Y9 zUK&2@65}vwYV^yTu{w;wjUnV^@$qCA;fjh^j4c*IES-$SoZ58$p@(e|NxT}_g-W+v zPv86)pK!d^#!>wS zi$u5F9Mv=oA{z%%rz83WHay}FK6m1oCI>!(rDv7Aa<}X$qagN_k%Q{f*kKWeHAosX zp|9m{G6R=c`9~MPH$%`vOrN_F7bWFRybNfEIen^PcNT_(G+x9jh3xfHH%|Muw3(?G z=@5hUNX$iJNZzLio@`YNF}C~?J7z=ia1XYe$n!-O38(A&%+5gLNT#%1AU!S{gtm+`P9Lpdbk z2uF5H>r=+mP&kl139QnHL1M>3x0`N%{n+$``uIx0fj%ioI3$kpmXFgFY>AkS)2TA+ zn^rJ?A5y{j<|{UUVzpjG5Z8xN@jbNSu!1;JrJw|?Ql!fv&y*?9b<=>C4Mt!*Kx}KoW=)cphl zb^KbmvPvd)m$LUTIr#S;xiGqvdN~%r_sWPcIF+n2+t0Jbn$GpLd$PjE@15Ii-MQVVNAF!7{OH~Lp^jFH zsrAZMUfQX4%dOeX6OPPLPq@0L$?(Amp;Q-j9UPTUAR zb@OwlCy!?I9`kXBZl#6h2c5HeZimWMSNZ!`Y_=IB?7~VdU(sC4XLX*7LVLFYDjh>l$u-u1U9=_j;wae>Z4ws?E~j zn_Wju{;AHLWv`kRY&jEL<5exT{{;%@;r(dYKaPj(v!!}H9 z(f8DV=KCo&-8o>aj_y%W?Mt)of-=9}OiAM2=&=;f&P2i686i2ZW<(8!0D zlQ5jnK-Zjx6?_#7-W1N@GYIs-Lcwu54fL64QUOx{d|ttnU4*B{r#wxiu*l>QRrDzm zdFf?LZj)6rBZFpX*i;@(I~!(M=tv`JB9uPR=+P2~N~Eoofxe2qRPpAP0_o+=zDNxm z8n3XiK8CRL*25sd@ZhnGq^Njw(TUItIKDr71s4tFPkB);;!v;2=WJwY@c|tF^f4Hb z`btXtJ}8=~*oR2-R^d>>Gd;j*!ofbS3~BI~In49(onB}NWodN$`JWD@1sy&xDsx#+62Jw)EFkVD*)32vZP++A3 zq`lTr{bG%yx&WkFhi7(&qdI>x{GdGe3y`)?B^UL2FSaLrhhw{1}ttzQuj!xjJ==i!u5aqum1 zBfe=&|H1fi`Pa&ysiACV zH8$yZmeO|j&rM3ruH2!uRsV_ps_W0EyKOqrIHa6UEt|AgvCa+J-wsGi>##5E?#L}U z@x2Bgd=nA;=;sDc``Q-v_~nho)~C-J_Wjsx%>1Qm=X*L0Y*X=mE3d>3PwsYawoe$i zZJ}n5`HU;!^~=Yco0U=~aQSD?Q?bLfJ*!+>yynO9@vl31jO@5$-h~>LnLqxRmSh*u zuJz6ts+U=*R!6Jot4jq}=4O&j5cK`P6nw4e(*;YVqJpDyIopaBRwdlcZAzCha}`|N z?5xY%s~nt4mMTm7!!#3OWo=_?SHj-G5lrXOWn5g{%9bndUZG-^cikrj;8up6sXW3UaYRAX>is&iSeE!DX!xLlE7yCT8mO=H`Y zFpXUv=WUqYL6gLhnPbHY?&Zssb#rwoQ`)%{cAz`hm$0+7v9_|r+MtDx6lm)ze;X#S{{s~8TSUjY>ntk z$cFj{^U>Gj*FHYdO=d?Ib2)Ja#wbef;dB`7qG1GdT?~mq>(A)n^MeT~Ip1W764-3Z zQs7>Bnw*1A4ycC8WirXKoScDqUG8O5f>QBVbk3(s<*8w?AD4z1OnhkIvm`EEPcy(d zew-mq1JY(;&JnedU?hm^BN;^5Y?BxYlc!vo7aoQx*>+>j6Ls=162?lgxC4acE-u8% z)tKn86c%Qx1ZBwdYc@3lW>O>}8#7uoJVVu>EkN&xjnRZRsn%*DYCA-j!HHxfyvIDj zdv33U6f0N);^eq#swuGGjHw1%>Shhwm>)CkBfwG}EfO+2HkmjA2fB&)ph>~WE)*h8 zOB=G_L&stQSiwm#gGCe96SxtaEG)@WT6#>_i5m#SJf$gdLn!;!;D%l4Nu(k})Fd@S z9=vE`65=|Vi8w}>SaRp93@m`hU~F-v!gz_qWkOUJ29G>v_9ZH>l`JTAK}AZsW}Z43 z05ufG(3VkS}IA}@52#A$w zQXpCsrLQD5HI6IIiA_twRw<^!#G5zPB_#!=ujH6s8GTNrU|sU7P|?ntWFkz|t%#>C zdAFZYZ-o5e|3K7>Q*%y(ZFY9BIek#_gE6zF=LSIzAjg2@`5) zj;teKC(d)T$8?j~HzG6{p@l?q517O)YRih*urf%d$RwpwA#c;$&JpjD~{RR32GH((T=m2sCxq&Kzs)GDLfuOda9-tIZ z4rl~uDrg31A!rq73uq7M2a{@pb?-+pt+z`psk?aK&L@BK=(jTKrP$z z`GmJ{d!vrN1N8z80u2Pww+Ddj8>R0<`c9;`Jd7$2YLZ_0&4&_ z18V|T18V`70BO&2Ca@$h8Ax07dIL!pwF{8;ibn!zt}Gnr0#pNOk4!@#?VAnhf-i@Kve=Nhmg@I0_KZ~>5>l^H27Cf+4%`OBKuA~* zR0Ed-gMeAUU|=dR1egE}1@-`j0i%H}fDu3d`!!ZW4{TZsr}l(X!<-Tbr-VB7Cma#t zG>UL|u+vn+VL?vRZoc)KlwDFy)Eg85T0n6U)b48tHG%HG62>=o|EDy{BOcL{tdi! z(b-?sk+4)fRV-miKb)+H_K=Hy0rVuNq?)enxQIJ}t3lI1`Jhz9$(-g}dL-dgpVIRP zr_?RIm2gCz(uWC$dzZdUIIMPQde*-6%fmGiNpJ>YCNH58LPR3RZ3f}C9Ldh zwFl_vT*~S+VX2ZBa6q7LB*uM&+3hgUDrf^b;164YOP>$e=~5lMB`k>Vscy7eN|IJos8jJJ3Dr!@Q# zpGc~!9K@Nlyp88D!X>RezZG{g+Taq}V@&Bn)jO z>?RBe7mgAJw-hcC`r8Qi21mRLJh#{&j73EH-{Os6}4zns6n&lT$lkLUPp_&h4!yi9DX zZEUXCO|VwBL0RZsfqqjN12?@_Trr;0!cFgza&TwBP46A_!HOYp(>ntFvSJL}^t_=D zR!oJP>b$gYOIQpyy-%FsUIRD1yGscL!dAHHJya6zy>QdJ3H`L<7~F6gen;?fpg2=0 zVc;2LPUw8&!;MAn=3MMjQd73a+jCD-gcWm!{rLTs>sNYO4L!HPy`|&oA#QP_g50XD zhzRP@JSNksUgps`nv3>lk8IjB!RMIerz7>&-aj8WZvUgJ+vZ+hImPNyV}}YS@+_iX zEU#PZ=$W;e>SHgpIoiEfcx>xdH?D1T8d`C5_XEO$vfnNB6y90C`dm5h)QUZS7CiCO zbn4mg#-PgYx9_yB9561qS08oD$h3j6gSJooF8NI4g!8BW$g9|-aH93;H_zgmY~EYu z@|Mi`Pe)Ge6EUZbU!`sdm+M^Gb$9L1D(}kVYj#AUWi#;2B5Rjp1<-r|75F{pyy!8FW8#a7IrDue64XhgH}< zzI0U6YC&ehf}Ms=&aKpF zY=w2_TmG@}`{yfLT&(YZtJ=edj=Su>9367?PWzXGA1rP;x^Lj#twS6Cyr?kjRPu|2 z^6pL%k$?X=5+taMH7N{?@LaM*9(MLn_f zEhV)4zF%4QNi~+8TV2Iz>kzjq8%l2e#`P}i6#mmN`wElZ{T#Pt@qk9-`%M@=calwF z^PMZ6Exg+KlbMuL&R<89Y6kXy5$_V=#f8%Z+7{-+{(%R)5j$} zwfh%W%e~S#e1^;WGTn#1>U!Dhr1#ZNvz3;%-=CZwzaVDyj%yzVC+_Y~{R{o-ekv?1 znzlTvcN^yC^jq=b=!m!{XCGdpp1}Ac-_p3Wn5%7${?T>U`hs_Ecivt6cyw5>|F1)y z&bJ!eW69QE?6rd(R%B=1eU!8$Wuez6pPMsZw5``YY@zfnPP_N*{3m?Ak=3U~ii69i zSJmWYoLjfo<&_mOdh5>(XO_DAzuB6v9JIdL+DDxW z>|eIOO8qDO8ayjLFYAPclPhe^*s-?doH5&uY-w_Osr9>X#qlA1f2_M_`1hka{4KAL z;@qf+$iy@AbEfSct=^|wG%B-cK%C{8K*jdR1L|sTdd<{5`>A$f^Xe|U*105y&hFeQ z^zDqx96Pp$s-VfNygoG=xgw3kUHA6+4j%krc5t7OdpCvGyRp1*QT4s^qb`^9i^WD_JPdYSm>vY@t<;2NjHg9`fdehAAx+{yGsYm{@pwg@< z6=#*77`MIo^bSa(xJ&#W@o+8r8V8Q|aeMM)z_Rt>8%vb!a?`uPo1twrhrX3gto@&p z2D*m~m^*Z^`^gQxZ3?!%*iy>(%Azj+O?D`3>2#x$iDJ`>mS`W2%HSvaSB>r9HoIT^}0R$uH>bs!b}pkv8U=DPQSl z%!#{*@9TAT^M)gRo$J59F;gDy6yCqW!rTw~3nx7LK6P@`RSa^=O&HgZw%7+{`N_NNE13qoZ$77}7COJ1?|1j>Z1UIEs z3)m){#|aR5fxyAwQFtqn7X{n^9+jh~$V&p=1&_*D+)p9qZw@4)^osk{8mba13G}xz z`&)(mt;qg*u)j7!jJpSmYKL@a97XFGV>ThZ0f!+z{nx%t}OK z*t;T+#!fk67u+=NZ6NY!d>jNf zz0V>=9*w2D!A;jx+%H9x(*tQPkgj(q+?2)`;0zF5?=m2jeGPC6NP3TeW%8qIUe_LF zYl{7@pf1}>s(nC`kSn=CPqyJ3cxy>DrL#qp_lLYBCt*=}D$5R0-t|AEPuD}&bqo1@ zYT}|^0!#%p1gStTP=-sO{h-kxuhmv+I_5A9RtRFrK?hOLkxFEZjB$m0scipC{J+K* zX4D-UJUO&Mzf&Izwk*B6AL5&?X80?Coj9uL37k1sYVUwkv3baH>^~y&Zo56ZA+DC z7!|Fz(eAM6lT+u&zM&&)>!vJxxMxwD#InmX$F-{&`m2cE~<)wD*kIw1-I-^eVvtd8r__C#W&UuGNSSb&U z|GRMaq{pd|OWrO1u=`bBzk~nh3jAN*I4N4?wO+r|JvgC#UWM*M-6n0H(7o7sb<9w@ z9}TyJzovWQcG~C7Id$t9&foeyp^`Lg}wC4V9)D~`Tnm(7> z!wt>4anvT3Z_I5%Gzl2yOzk7V>}3SC5rt3iPHHEUe|mqK+KSKaXbWmD zt{tCWq&DNYLK{Qvrlv=~&eV3co!Q|>?dS06v)iZ*MXd_zK<#Me-gR9d`ccb$1(gnM8+E3_<69>#4!ZJZ z-kJ1gL)s5|`)h`aZe`L7i=Ss4UUXoP#%67a54%+}0-egWEMF?XwqLmsgH%=C=3e!h z>Ul{0-KwL0J9i%`ZL@n`(})s|bM_~>hx`)U{MhdE2O???)_$5(V@TI0e_w38D&ttJ zOP7|d$Ge55@^0}+V>Uh-;kWS?|BUd<2tR=E$q1i-@D&K( z(qZ7^uTSS8waA9%|W>o#%aUI|9upbtl zGV4~$c|BBZ{7d(*w(LfYwNri{@#yTYJzpPKzh_C4exWUDeR`a6;VE1FNwTCul#>yc@tEZesW+vsql{Led|=qG)5s_PF6KX|{|AWUm8|8$)>>0W{P zFPfdJI(Ey0;7y1Ad>7$V&ur1^-W{#`T=1#CwBa80;(0?~|Ne2{okqiE*Dd_se_}}J z{u_^uMSnNV@8^A0nmvn{ZX5jfZ|$t=EKKye?71S{rS_YQzu_+zOVmrbynpEYXV08@u9)P^4T}jTfQvY&%MThF&SIq z?N_?)F88TZ^EL<1_V<`|wctzMiEeKyUSD2swB;e+4J94BR?mMD+q=V%6>eKvm+-zf zVE)u2bDl4_)uip(ns3)1e!QdU@Ti$_F11vHf6cvE?RJW@CZ>|zpg-D0f10+h_3`); zeOfeaJMK)czY~sa{%Louow;?)&bf8n>uwR$H|E96lt1UVuGQRputU%%e*7_FQ)?zq$U8j!L*0`ntOks3*kgE2ua;N09h$nNWK`kz_P+HibqQBY zY>?%5^wEZ`tL%Q?-zm77<&55!f7$M&e&R8H*McJ>RIRGKY#&*%)U`_m^E%)8?K^D~ z{WkekIsZ)a#bYn@AGO@)V23spey;vk zz}a-qGQ(o8+|OS0y!OWO^Lu9G+vq}Pr%tl%UT;vhj<4euzFU7WaA4X~=UYpgRo?V| z%#D6!*ZsO>sAgd9cdz*uM}>Ic3% z?!WZj%YK)9OREl5`OWo5^I4W9^Z$5UePg!u@Y-d|rFUDhWnq z_ddsVH)au^?;AROu-SoYOw!c=n=A_Xro^5l!(aZMgEx*P#tt>oy>^rwQe)9CY{U01|kT&5$ zyA`RO-u$T@u-59=ysCA||GfF&!|PcU+w2-QHnaJb#Bj5}?BfDDZy&YSectW&Gn`T` zPn>6J@3YU^|JlB(S@FuyLCt%H|yj2tXeNd+&t!=leXfF%DM3k zC%-vSufCl6ZGh*)-YF{=ER2t?a4W%4Se!lfzV-F*ho6|F|7-4^j+YJ}9GX!|^Jv-p zoi#qx-JECfpx^mbqbKOLzh7OyX49AIb2~yxugT3H_N4l~kpXr=&!@UovTvu|we5$; zCw*cMl)ZhlWi!RaPHjsh9``Qkv0-M!zQN{OeO-R<5?8LgLmAh~p&PeMwp#uv^7NoF zKXsp8cy@pIyQ^kb0;ly*wyu}-ddMHMt2JoyW1HP|`j1ba`1Y62U1#)Z@oUxKMSoky zJhN$)+;EB4cSrW#d2?u9!=N%bm#6lArQ5e^R<=dw1(V-xIkIT{*-3v{E~`FqW2+k> zJ$^sb{Ou1(6i>LTRNH!=6TAG_*m}BS-lgE< z&#T>wD;wBk*e_Sl&v-aa`Pinm`}&LtqwU?kZ)dy0uD{ce$`88UzS8V)NmW*~&*05o zG0Bm;Pw)3VR&$NR(ab`>6svf_^UTLzpN0+9r;V8Zto3ho_A2gI4WGGv({~?E?z!2o z#);HY39~hG3)*a5Iwp35%bT6&TAXUotL3!6KXw1>7-nU`Bq+P^~i_Z>Z)AMLt+cj2wEFE0+*aYx<$mU)-UN$rLnJ7T-G&jr_> z!&=na{|F*yBrmvd#>wrzl&1)8| zzBJ3FY{2n%Gvm5F^z5X*t+B4RXU0z>lRrJNxsnkTzqpRoI_a?HGM{8(+!!5-&cwRa1=*5AG3q3(~K z{h(-uwZ$i!su$e5JF;`YpLT15syy#IHOy^krKk4o+qr9}>>9Og-46pFS3KF==UQy9 z1GZ&=T~>6v*r8Ku+i@jkC4M}<-aD&gEsqx)M$Eh!acrOe;2iU`tt))b zxTt=2?$S8!MmeYQehzcWM7h4I{3Z0%#^1IKnCxlwaCz@fDUmBrFBr6NO#DyL-78GL zRhV$rala70_+9qZv1a$LSO;F8_I;1x$`h?8)zc^a^?L4*J%4nZeW}{v1_zrA{V}6W zsok17kNPhgKR?_)NfTY zxoN|fOVnQHzT0smWN+y^Yu@A@%AdDjUgD4@$0v>ZWx&HO5i9i@4?b^D_fh*{yQ2%E zmcBffQQq-h^}lZ z;&>pAhZ)O>q$*HaiWW!%^vmer++BUCH87>!Gl?|>+LZwX$C9f&-3KWNGmck`8bM^+8>eUTgQy+`V zYV3cRHY=}m&&*7G#rtZhQ2b5mTM7uuRR%cp3# z8B5bpVQrIJ?uPL`;p)X_LvjgRMwAqLqI}`H zr4ac_DbV=J3>5^2B>|KR%v8MuGXXZ?>I&CgNr(nvmR1Tg558vxx*mvB+;1S1uT;u$ z%A0IwB6QSh!f*DY*##k+iIaT|oLDC`Ry1bgy3?4nR4GO%l!`^HAep4BWoxHuiszmL zZjq9ejjM%^;KTd{J>j?Zv8s&|p(s3m2mV;~Huzthh2JjK;PhF3RNC$HAG}3(duYvmb zJF91cHi7Pdo`LEFIIDv|TF@!@`vIqd=7Cm#j(})R-^P}IrO*)+_|YXQ3c5L~AA?GD zcUCt5<%0w>JC&WCokD46iKmb4?9=G_XF{=?4g(jBHK%B`+J-)UzP{WvL%cQ)nz4eB zD_taZbHhBck40F{v!mko=HGOr%W+cH0HBDT2n$F2#{ev46*f+V(N z9jHi5*k(msY%(xOQY0z7de_zDEffQBZ>r0E+i8nTkfhGglB;YPrM63kia`nSNc zmpthxvWrw~NOWEZjA5{IO61AIhyjj$KsQ6oFgj8M}++Kj(9gDv-=#8g6)8U zjt)!dNMEVU6_l2lkj)OG5OPDc(m4X5DY~rw!LZv%yjUDQfWEI|UNC*S>V;gGy%z6a z$2{D@P;BuELf_3~iw5A%sxY2ocNpJj=0crcan`nAA-G_yc0r(3X$q&8zEW5O?F0%R zB8F!pJO*RN+R$QHiLoMKObqlIpxv-?C4K$b3V)U4{C-$Hq3}CcL80(N{doA8xg7Vm zRdhux}X$q12zf}4uy^=VF5NG5FW-V3*~bcV?&A;426C|9mb9nz7S(l z!tF)kdE0Y7X4di&Zxr& zb+d)M9;*)~jJ%R5zi}%l2Dbon%Fop;vo}+*`tU6Hpwa`Ich+``swv_9%#kjL+EVyq zse;t!dqQINQiYfo67y^bS)%K(vo`sjyXHM1me`nmF8$Pcv23MLx7kxw>#3^qRIT+? zHF&BTJylJfstum1W>3|ojH;lgYI9oE7EjfKp6V)3b+xCu)>B>Qsb1@;UgxQ<_f$7} zs+%&ZH+ZTyda9e#t2cS7gP!UKJk<}T*HmTHto78?dukdyHI1H{4W62fo|@*Yn$5nN z7EjFso|*?ewRN7_^`6?MjM|MEwGVpgYSQcKJay|lb(=hOLC@L-&)TMpwauP&)t+^= z8SCmi>(+YK)qB=8de#Lz>);0$uG^fk?t%3B^;z{BJoU|<`d~)=W=}(HUPGOyq0!T@ zA-$p5)3C|Y5cIsZ$@AKcp4SGl*ViptU+-DJ!LvTFcztuu`YoRI52UYu(9>A$X{_-y zHh3D>dm1-+8iSt3&7Q{lJxx`frW#LEt*5EZ)3nypRG-nb-qWyM&JR@eN{`E>paaHJVTRfYpJe%q~o7VX@ zHF!3y_iWnW+0^XWbiZfQ151K6o?xvfxNd2%-V@y52{wCzK~Hc?dhkKd=Bg!|*LgN? z%GmsXXY+%e`)fS+*QMXzka7P8&;6S{Th@BEZ1Ox%=XoILd9cd!;1=4BT5;cfoxly_wme^r?Z z)ZgE}Tdr^7uD0GCa!a@lzZE|f;I?O%THpWmQY!;0_Sb@)arim%B`&$d;h2k_0xLJI zz-n2`vo~OGW`WfPorV_#r@^Mk%9UKxD)$Yl6@=)29%%hnuF{&>LLwG<2Kl%ICG0A$ zb8GG6o*cRUQPLH8@vQt_$*1f-^gDPIlkKM#Kaz%o5tEToU=@Ms^a4vU__Gyv1O$P@ z+mC%5jJS9wu%83*`79>C^-&

4EQ|wSMXKv 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:N zLt#9=zfJ3Xxkh}shF?ic=x``IKGpFr1lx(07wx#W*75A|7i+w~tPy`%W9;8=>sF{> z@q%ca@`y$sHf9bz{(McXIMVxkjcBg%f3}~0uEv_+va`bQQaFaQWu$o`kDE~<#yh{J z@VkI*pQ3bR zm;c~s?{QcK$C;jJ_tpJppv&LVZY^lXzVz+>3d34#o_=wEhz~u}5wr=yS3V1Ol>iD`b2X(e6(OU+Eye(=tA~ z)0g?iJ?*Rs@F$7SKag4Y6O^?I*X2DIQeLZ88E*v~3GMo?gC_8#Lx*z`tUp%hQdtg~ zQ$%f(cI-zcnlD6i!?;-Fum2if+^zf3&O`g5-+ybr%yup==^k{3IV>Q@bGZ2MJlD_jc|OnQ`P`mwQean>e<#W!%7u=7_V(V&^o%x` zUq%0czs1z)_AYKji^Oj3;`DWw%*|x2IlBaB7yBwHM-JNWSD2PxH1R*2#5sA|PV5;r zo%xR_Z~R)pZbUI7cRjR?lzXk~q*8dji?xuFuyCO=bgNUf5uB^2>oQs)*CO|>K{^Ac zx1Vte@d}KOD40LB8o#E9Jd9Xlg*MB(2oz?}8G?4w265dRFwF-n6;{5kRb89U8hya7 zjY^vATk^5Rre-bvp_jUrB+&ZP)4FU?&UotM=7mzc=WV9tU9qlfX$m+TEsd+dY_Wl7 z0cb8h_$)vjs_G(q$s`Kq!reZL=Yb<7g9<0V9ESnVw%wf{gt)H2a-b?^W<{4)OMRJ0 z4kX4i3gb8hQ_~e#8u_-g3(Oz04t1$!CxW~}7;%3ithf>gK$iDk>^>ArWKhBT*-<2= zCwRXzIG&9o&k!K?1jjq~#h7D6d&N*e*J(qSF%$J)W!SDjex&}HT{yYZ3EJb~mSd8x z)G)?U-H#)36#F5OaNE@~9BRglGoSVtxBet<>$JW%9L3^4!}elh;uW@49@%+xgs^vG z#)dxd7@ZWv&=4g-=J+eBar{);u))#z%v9~ItHR{%=qS>aM_l{DcD@K6-uY&sCUz-9@LS9yLXv z1vyA#>eMw#N!eW6$hX*n&DLDO)qfTdDmW7-mD! zlIYMg7<=HDUuLfV=?^XH(q_ODombFx2q&l#|Bha@Y%rH}1rC!gsAT^Bu;2VdgalYq z^hP^yM32mRF^?)7uUJq&s>q?Sz7e)?`HOQFz$C*Fo1xyE-B_C65cD@U3PFFo9a-Z{MXxBhhFm>YfrzOU zT*G{B-Kfp8He}lxs&^9uRTF=aj^gcGxuKff57pcIsr9kwTKx}Ppnk*F@)uSZ?YQNO zXd8EiPa28}bmoO!0h|B2a#;KEs=w+2VtQj0>ret&eoFUzav*cPdtf9=Gw`hESU4z2L(KRMr6Ji>er^_iPG4sZYYj>_Ek{da`k z4sS>;U(`SNw=f-rPlK1?OAF-Sf)+>rG)H6URBbDvJ)pTg0&a8@r@1Goa&_~k`hi-w zt41ZhNqE{swQ+QmRhQI~VN@%?NO{%9X8i!400PD^RHIG}LHt}@&6vASq=^p`*e~=$ zfrB9gm-)r}Z$mWzE02q<88w1k?IR^ zbM}!IpQ35c*tCR^<_Eh1V~1zu(#T+j-<;l60)Z8HO$4OnXyf51%eGCX_qLvmqQ~My zIyv+;BGeSEHXIF(T8Fd1VO{TJ@c>Dh7R8S7pDN2cbxM6$?bjWj+`G{HHu>Pw?Vs#d z_l^{U|{uHbC zgWPU-9=U?p9>v{m1Um|4b_Krcl%=eIMeurOD5>in$l`)mJK1ZU$iGTMrlg!q4M~8bL`arBswq(~avp%7`f~W^FeikM`d86SI~goe z8w%=tL+|3lvEaL%I6;%e_(}o!eW;7b32~01f**T(cs-rCHkfSFO^5|jtx5^~x(&LF zL^VA>c*G0#Y+2hDC}Y)LZSthjQ3|>hK{^m}CvI=E*u!M z(KU+VJ-lQPmp!zyIdy$GxGyNW#`h9J0Y+b@I?5Fe5$l0Ds)*D5vx!;OJau}!t`7T&gc{#lBY-8RazL$ zygvi-YBEyTljo8S9rCdcc4l&^dWxt3OsVymsgyF0=Nov_le1w#6yarcw5EioX7a>2 zg}EH&51KspgPq24=-Cc3szs*mQWFGJmAIuF-jdhIK^ybDg~(lg%!oOa5eq+?UuoufG1rIWn&xQ8bWf?sO}wz>Wv2Nci-0*@uCHYo27|Is#`97=tHQHH=x(lF z{2QuaDI@@z=8rwZOI{*+{bmobM8nz(TlHm7`WNh19W-5AO-B#?r(@YkFBUAb&T~?i{3rIyjsZ7STivHRf2w13 z*sB-5>{we&g)|+|Q6<7K==q_~JA$8g2%mSDzx5Zs_f+Yq*U?6VcHE*j^d#IHJ3Obr zr;Rc^(~FDW?>MzMc3Cx!1#>kKGv%82UkXP%vfhP?a)HmnOctvyyi?2my@MXJj9XTU zngNqT9UBOAaI>s=jkE8a`KuNYvWeLS*$a8~3gTgqku)@-#Mw#lJq zIyVdA8uC-x8R-kkh29ODcZG3a8nSlu@Gl104uS135BZrp-w-S?pcnU%_~h7U{?Gvy zB^eY}Z^x%F)!&Dw1n<}h(%>F)XAMMaJYVKG+ z+IX3gG|X?n=|CpcIG4oBv#Ka)B*gDvH!F5pL7>@ z_#krRv_#_NL@U9O9fGlg^^@*6c`c*xi08Z{H2x(Y7U@t-N00D_U`&S))4}xk**iO& zb4ktYh~WpglyTb5LSvbNX+>>EzEK@HsSe)o3Azr}PU-~UvRh>gKxd4XZivl4XS?VV zZun;Z5C+X*|3hp_$xfA_6z%@NS)bLpLuQm<6!{}rlEEK*I6!kmEjadEQ6cWo@RD1{ zg2|Xr*at(u52`8SI;}edwAYn9jJE&nJM`($8DEbuHTb1Z_?Isv`a!+xK0O`s4G1|y zpZNlx`$C_$3;*_ko_w; zZ*{5z@A_^Y4V33U@&%8fi{c+5U0k@^XGIjL1f{qcSv7&(J`4v(K5~vwoj>RczE8&U zk{{^Q!^74eIJMF6?P`(p2f8r$=t#xq`!RM!Fo?X&dOv2c!w=RP2QuntKa^=c`zId( zevVSKptpMhTW$?Zb>8g@ZX@kiLL8+^$pyzXO1N6=j}i2W@g(q%X=7WINw3FSU9T|$@%3b#kyl!b=~b-0N( zSl`8^KAf{t-w0!;)wk*Eja-U%Fu~)sALrz89vu^Es#% z>U{819!;2iLeGl#dI19CTm@6=2mTMu_61XYLcWjfiwwNwjQEV7*tdmt`ox3YvxZ=z z@1zcEOPJwf-y(Ue`xI+=SUVj>^GuAyPW9EzWgjH-Q^-(7Asjfa4Q2a+_xpsAzR+5K z-Z!ee$JFH)^U7byH-4|8b86m8=d>*e%>N^BQ29=(1=8BF4&x|9%tUj#BUYXH=%BKG?Icyj0){zX8%UXLH^gg6*XlZRDteLA`}bi_U2 ztf=_jOONGYbeV)@xT^!*INz}!$MS=R2x$%dgS{|I{iTfZi$k{u*aTiNsWGW6M$$SJLg)E_gli|-;h*SRkA3o>ODZ+RgX6W;Q&CQ>n{g@4II zm?fdxdsxRNku+wGKlEt}d>nSlZ>L%q`AtSY=3n^0w7V-rF9y-ms{8tv7($?x9+elF z6J1rPVuu%dqPe?ufsagsS69(O5|}qyZ{`8Sx^8_Hwt3k$(h?AmbX`lfsI^)}dHM5D z9kF)u$ryhlw1zUjL@_Wz+3ek&;MK!eL-e<9G(b*W(Aw~lr{5FHzT&m`ocvpFqXKRz z&p!YKIdn7NS&(2`3j2HcL!inV@z%qeb(Mxw;@U*9R2g~&XSM`78YV=cldiyv-sC#J zBJ{3{m_;feRW!2iOQ2bo&YCj`R+o5%XT5_xVG8*fZ%f50ua#Gn@T3Xr9FPL?KVO~; zMFLo+x*Q~oJ%^u?{DjCz8+Bx)R{3+pPbpHMQ72dO-|1`-fy3-?WPXWxij<7t6}!+& zkHH(b;^8!bLK-r^UvbZ)GIAerE&oQfe2seH9Pg>wu?4TBmQlv7i0#kLCD}@in$l%T zEO{i%k`q>|4;drxdGh;)vEn+a)SzjZO$bA%l3JMbBiF`ffgOjC#cjlWoMF~WdKGX7&{7|*K zXqh%}mlwSmPjW?$2Yosyw&wbkws@F;uT-<~Uh7IFJCx** z2bp2-^iHV6fp#>>BSQw3Yir%5MkOM9RewSV9GZwGUp+hjR2>R4_fs%xlNHhX{EYX_Y?(gp1%3KRqTn59*UY3wM{dJvW zq>~OcLt24wdOT$;2ty5s&*#Z!$b01WVA+^vP==PZhsxT?8ZZAwRlbo#lrt+RqlaR@ zlyI=e7#swv$f8FcCx187+={*o@OSqNB(m?23X`vx>supu!NpbW!}>vN*yeopxMZoZ z)a3qZ?Y?WmF^N4&@}d*R$baDjiTxXSK;7^S9g@@jF0lu0rp2>|Fhr8 zDEFn9nqNW7WYp|)q1Hk2Mbf>~%=VHeF23%P&?m8P%gOQVZaFa>iMJre6Bj}eg+#w!G*@~>2^izMn+>V+iCmB=%MEFcsCbNo*Z?o`Yv-yKLln zF`k zi%=NWmee*C*kG4ppgQL^7OU8DIe(4B{*F9IMs}^7ut{Q9lLu(JXZ}@PGN>+nYhU4e z>SV6wbsw|8)p$i;q|#ecN2MW_5nTTQDxpkbpO%ZRmDpdC2XtngoKPXLi{*!MiG7kh z0IS#~a>^R1yhp|UiacZ1o{-a4N#$><*n;7-$K^D$RQ{HVePlRo9{EqinWZC19}v9- zvj1VUBzTFmd)5oTmTGd14NIhYJ8zHjW;@2UWN>k|6>>iG!<%gzr^T5Lt=b+2>mMms zy5v5n{8V&;Z6q#!dQ7s7LuKCVFSwBn^CLAm#+4?dvU4TxNZZ45U8Q9@WLFJBZdbbQ zfcai91+yUsDqVMq%|q2%@NMANxVS#Z6hU=u3V4CUa#JQuwiYYR)h)O6o2hZHJs2+k ziEv7$LK7%Ctu7rst~3s|PQ=XdS$B^ci%uE34n48TIvD=KI0^ClAYmB=+hJzCyXZ+u z7cv+tiG}PcxAfyQIC`lMC5sAjJ&QOKB4=OQKihHpwZh$C--Qt0~aU+rZYz|V^v&i1_W zJL6ei`*HxG|x)1q!_}-!Op5S+$*`LV%86UJbgQifc+w!#vnJjyp7M~T2 z1uo})lQhhKJ@Pi#I(dpc>p}8H!`gj6V0ZYMyzHE+cOLv<=QQQuWJ<7ksxC5IqAlt}sMbW2*s^f$y_|QXVLWM7Wtf(tsKky)lj_XcU zOdj(1Rbob~X_!Bp{5SHR{_}e|c~5KQYluS~I%rc({w+yjTFZT=ig!Fn@~Q~k9p4J~ zeDbEN__K#3jJw6WRJ`Q@|B`x(e@U)ocah=9<6Y?WRB7pp6<$yEQP6hl8;EZo!+Jfl zKOi_B*@m5bqWKrCer$M5qo*7<7raWw z>CiKe-uy7)q%K(J0lp^J)|KJkP^~AqSzM@qHcunm^VAOJGezfy$lzKo(Ry64w8CRql}9JyGEB?* zyoSbhya~VYL|)PQFkHI7QFZsJg{2<$DFXgvpP6apzftjib#iC&eDZ8&8hcSRGkh@5u$MEXi+pW!&n-h$IT!gLQijWizmQ|IPN97jRBuy!Ex z2u$?|IUaVpj4cm(u#dA3dTMq}3H=7EQ2~1nT3>N5fwDDUhO&Z$7HW~`G3+FgioW0h z6@y#jbofu~EoJ7EFcO6AzR8S|ayOY#QqxUl^uN;CH%TY8A%fElV>}U==$RL5?<6u& zZ8z7Qfs1yNxXq4~SPwC~AvCz`=-O1evOQ6Ju`_UohiK5x5@}vA+EW5^gO#13KXzL8 zeL+vznQ_O4wCGd_CA8(KN5z#+bb?HqT*ub-2N4gD;x-*u8qbg@elSq0xZa+Yp$Waz zS@CBV9X*5gD;rg`Ri&T%=8|_mnT#7QVnZgEUu93>VvkR5oH@celUTj1k6V@ciE>`y zQfG;3)VdX+AHqGIV;UY2dE0=+do<}bVVZU3y?7wT zibV8ylUHS38F7}>emcRFu@#>qu)_ZG(rxL(c;7JD%_*DgDRkl~`7C7T&>fU<5PY?B zLPt9yUEF+^%V6(R3gg@j?d_g~Xkom&L2UQfl?|@;_lGtM$l1FNKfCWBPj_^8Kac+d z83eBm7*3Gjr27s118(xs(B0?Wm8>I=QRq4T-<(rUY0OrGf$z^&IOQM|jTv$mi0;Ea zIZ)e|mrkscXp!Ut4Xxy8D3U(U^ zM+$uAaF6O23G+w;8{`HyH{jp#$w!V5C|ENI6l_Fk!$n!P5E%5}63-rY&E5ye=Lu9K z5tgs@jc(W$+SCmPU4cj2n-ZgkADWecdF^ymlRXy&04@CPhnVEZmQDSKQ6d)axT>?q zz3ZKU9^QgCxuHGsR7RoFK{$<)GSSVhB*#Djc_uFP5#B@mZ@scge55lv6;3r%WE;+?LdJ#e~I z({VPUv>JPEosLM~kP-0y>aW6m*XbsKcROge`4632#qRf`*V@jEzuYMTdW*CzjID> z4HiBuVC)IYae>v=d@jMVDZeK7j;H1iofLy7Md74q&h12md*pFu5HOi3SJ7(4^2%;5 zzRH%BH6GMAE60=@FAqMcvYPn*l6DS?v>|X%+^^pf?PY|wPEc5yyN`U3Zy$pmG(7}u-HM?;G58lz__HWZ?%ce$@%>BosPr-FBp-C7 zmRj_-zJ9zzm3+A=8Gt8p&~CADA~ofJA~*WT?pXOVVjVX^QSdEHLdlS}9y6Z_!U{CDxYy-#H(G zkvpy7rq(phTD(m;`#x&uL9vIIf>Xr71l%ROKjGj%&Mf)3?XENHj%R$_7XO)k{84?( z$8963u1A;dUea53aPKD>J!RS?pLPGJ*&#Tdk5?}Es5|QkNd#D9lbbS9)bA$2DkFZS<^oxgWaum6rubkLK9I43-9M(yz( z*LfckV#M7Kd7q{YJ?a?mnwDtq#s#l*XF+3YSQ78$NDK}C9pafVc8mP0u++kM)Sdbs z-an*<-H^MQ1fQhm_xuCFAK8m;!2U!*(xTLX@he|HGj!eE!~Z$>qg(c}gFUD-RcAip zGk$t~e|n#aR(YqIEzTuV2k)eujE)!sy*JAIuFpB1D%wDKUs@G$5phej_WN)z z*Qzg?9^U=RQjT~|;WHt51dl?ehHvP`Gi2;U!hRvj$}6%UVlRs_-XAq*oO1R!YGI$d zOjC4BBM=G@I&-Dkwzy=amhB}_@K&y>G1jVq zW6Vxp==t{Iz3!^3ey#XoI|!0yy~b99fB`rWRJTWZ)H_>tUw@G8A*EXmVhwyojLd-i zS300aCN{N%M+8rUV|>*GB7{S~`;>=ONP1^8D}w8ApN%@Mr}Z_hTKH&cqbSNbf$~he z`@#gpIauO$7>OM?$8#A#WnsI!)(71SF*`^?#P?Bya!i+oCO7*!*=a4CQX1;r8Bv+g zFvCuV9WlH%8Z#)i)}5oz%H~gH$6noZ`@+CT96yx=V-ABLOjsFU-R2T!V3Rw@y1Nhc zU#+`6t2G#7tHiZU{ZFJY%bj$cRN1E)TJL5pWc0M5>ixNu(eHU)&z@GDvC_8Kny#sV zL~HlQSrDn#hfs&Ovl-SJ!Wkaj0#hnxs3P zdUj}qJGjCvtZ;`u=-51d-=1m5$A3J*@YLp^{h?~h8FwfR4TUxfSa zl+O(j43cpgyemk~k{3C;+Zk)h?3beEJNVQr*?vKMv|Y$_L!rq$-p)=S%vPCxa)!HN zyt{5EwCgPy8H+<#!=AWk2>3x-aHJG}Pkr?4YPCAZ|3S3EK^2xCda%f1Hon$ED;V%8{&%5+JKBDSOTOiYng3#-y6 zv|LKl3hkjXxskMlpRC&`-I??Bfu{W30sLb30%&2DihHzz4tQWSW`fWcRw3*Uvh)N>P49jW7y1N~$u}k{z==+r%z# z;h{X+blUIARrcIVb#FEmaSHw_kpb8JnjW*Dd28n?+ms0-BCwP6e7|PjL;u{b>_3>L zyQblVN1FB=d0Nlq5E?Y$0o#`yhhQ` z?5efWaW%WKDQ;0(OcAc~L4{&z!0Mvb2j>Z^Dhg9fH4jJA30be*zr1==ovp46#7*wT zT7fEmE~aj!y~60dv2E=QyHJ;F4;a6??pZ-D(32|QxnVS1FG{D1#A{jXGly_#!=ZR9 zKf;o2qMu1qj;}gHU#y86TXj5HT@z=l+D|`U6L)7-Z}NvVar!EWeyb))5?YiXg}h3HF3?GZgg%OjlT&^qB+7c;RPKFx@SQl9@vdASDW{=|ZDLFgVQn$2Yo zmC>OEn@rYXJw2a&(zQhY?c1elCHx_9R#%H3ho3fFyV964DvzIa&Zj60E(k1e;p~PJ zW@GWF*&x}Cqp4lCCmUZ^HpP+1baqXBy{4(DB28_;W5Xj&qmEwlrJ|9NLRoHU5pf?J4XwGvmm@P7OiwKEKzC365ojt;vLuZ`9Gfv@*bI^-x zN6$EkXD0gUWs>Kh26% z`StcsqP7iCphP5AA$td@#L6f!&r7w~HA4U1zH0>>=R@Ca7v6J+Ux9b>VkbvTwI27iIEO6 zXZ)#)$O0s&09Bw?tmq&dA!re7>r!agBFhx%uo~B%=uc|SYGLm~uDq7lEb9H*?xM6Hl@ zG%leU-)L=ILdJr)m%OIoWoNyQw=_Ibb%q*~XW>}80Y^$3uDEkDXGIJuRMLUG^%UjE zAW=*D492f6`JTNeksHA`b`$AG{r#0= z5>f{g%mW>PUppgmLAW|Bbyi&nF|zFhO9_jeYlC;evjt03EVyP>+?AGaw{eLgG`S=2 zxO3Rh?1JL1;Nwm)6+{7?AL1K4>g-d_pv5`YN`iL_^T3M18%Uge4VI42vU6p8Q``;v z0i`G5@64o*zzj4lizHKps0)g_gTHXDEvCe=9Ykn?^IGu?^c}~+As*o8E#nUx>W&ZP zID3Sc;8drO<78%Z^eLCOo5Z|p*t}|Wg=jPzjrM8Gzr032i@k56+@=Qcd^`zD8+9s+ zBi5=@S`aEtr=}n!A2XsL!klEND?rh-wG&srZjGXn4HnD&2#jczBf+gUXLVm(fcpja4rmGQ z-UHK9SiezJC=`~XuTVv^6k#8Enlvw$|uaNhdv6&PKIMs@@~j)ZFto)M}qhD)DFfYq648KtfNz82o(!xM7*r&GQ#6zU~3sB70EDNJ1m_FeO+(j08Z8FnojmCUP4rgdSca z{zyXLMAkIHd-G}e+<^66)T0PRcLa8l9t<1)DTD6`;x(U~+ph{8g?U%0K?xr%sOOT& z!a5T+F4NM>%tK+M&>ceP%W%swv#uimv5)M8gxET}1oI^yI$b(WsZBw2=;=i(v;(}h zCukrwvc#{_z+cW^?8bX_*dZuMRDJnWDwvg)K6@_)f z3+4+xGX($zcLC(VO3P9jp(Sz0swDti|&*PCOC+G zVET;Ln8$TY!hP)Wxj|TtK+NHzZ-n~?vW|R^g|Arek*_jO3j2TOo2f_tn3ga$E#q!l zd16V$lR}0Lelb1#GtkJ6bwMsV$>1e&?Xl;O28Bi_$tC8x*a87~<<&=Y+gotKL`-UI zTEbmv875RyR`IZq5npsct|n7VaG4)OHO`fapX*VY<6X=HE;dK5Cm~W#THNTg)VpcJ zi7D)NJo3VUVv4I;LCMPFM-dn=Ql99kDf37ddQg{W&VgYM%yc7kJ%tg{5;f)vVM}H# zfF#rUloT>CFh~CQQk?nGX;#-63um=tT1~0AnG5!!*T`0Yb4A_wB+imKAj`jTb{S1d z$?ghpzQ7|Gdi=_8t+-Rb{)c^?c|ml(OVdeqmE|%Cf>gK41b?v2n_Bd#E66b{v`NHf zAQz*iysMtt{1IeWR?+;SFA(MxS!~_U* z$L@l2VpUMYUsOi!3G(OF0iN`jV1x43B*iyC19pqH`EBxoE- zW7U@Jn;dbG7pwY+to4wl@!s*+mz@0bHOtiHF!znK?#GT1)d=@|#Az_uNCASn1iwQc zG+TvtsKqC~?JJtAXjsDwzmtbNL3Pv8RArteg4tO-OngZF4~s$t>SrDi8=gk(L{eXx zFp8cA)f*DXka95y{I}(bd7@C-#yoM8bdmlwJju&>|10C|?+54d!dyPo<7=49_iu`|*es@O9&sw184v&~Y@@hwUbSufZFQMv!IGN*4DAXt zcv=~na|?dOxjd-aaGJwki%0bY237dw5$5AbvWe5Oj=unHW?#y2T= zFAt$QJDKJW(CnUE>lKuT{@p8WyhJtim*EIcW+*4aK z?Q_hkaqf-KIHY!$=GT9bPtDHbud*CW!}Bbku&1i~KmH5C#vS>4{}&0GBI&BJ>gw+J zz-Zp9lalPw$C}j#ms)Bq``%Vs_baxoQ#3cy3F*1eb@^Pr99lmq%BHi$4{9h|Qs$Ov zZA+umdlgBJ<+=So^(vyX&t>%cm9YC0@1lro%<^gfzTJxGll@=ye=$~5H0i4G#+9$4 z4>r}WNPSP^Vw#Q7;^yt{Rx=ALiTp;Q%|_2GsHK?N*+$u29*=c}Myc))f@6?4g?%5J%RPUsX4fxd7&V7;pT_ zx;O@Q|2N;Qt}oMbpiyC^HVl9yi&2!XWMgzX2K~MwDSg28<-u)Rbb#d+Wp{7C2Qb|` zKuni-Z$IlKfH0qLH*2ZN=^yTD`t!#hgxTPe=fYgIy2`f0ceWQ4^z&#l$gK!IqQBCQq18bI*-6(Hjj+#rDUdpEj5; zhuLR#h_h!nQtdg8DRzk~b2x1!k@n$(Rk))!j*G8Jf?=@v3BD&)iNLBgNkk`W z#$Cm!Dr>rG_;n#q8bJN{hWbNah3g~ju@CPchM*03JD>;5-y!7VKNBU;1?==44ZkFb zzue(~QHrBXT<2pS+Tqn7N;EIWY*NwzL1*ax&sgH0P;*F0I8U`Bapqr3pF&G zP2K}pJG|+5xsSZ`#yeLMlP%FHz`Hh`k^fB<1;U-&gu_N+aKTzpzaQfj&aMmVK$ zX;{$AlkR9At%to-e`@S(H3>YD%1;unx?rHgw6uG+4dGn!rYV%?M(~PMm?~#(8kG59 zbAc}qM;x5wgTYhQ^CNZy@7xiZMz+?QZLUF^b=&H;a;mzVoRK@T5ebM^Ie9&^inxZR z^0e{bjod`KAZ;WxZKBcbT#5Tg$$@#5kM04xO|93$mlQ+0*w#(Kvgw4F}DlwUqGpwou4Tq|%iM7cx?-hyBru{!I-X zY=hN8@L-#8u+2Qzw{{$5+^68uB^`(jb2SWgZem@I6psnU2EEvahWsk?;+p`*80mxN zCw;^bkRIqm3(GoF(LP1zbM!%Rp3k`Yx?Q^^YAGTfUbA2O$&u;R z&Q?)&UDIgwx^yt;bx-8RLsar@xG`N>X{VtyA=5Y4NBC{Op3A7TUvgy5z7pPuXP9qM z2v3vgO()hsaz8@|%7sG@FRvhsPyF4DhP+nLIuN@0mxyj-Edm~`?q}}b&i=lwyAqUN z5nTx)B&=y$rC(d7Fh73n_Vq{Cnp*_od{aCQwu-$xNc+W*t}hH(gV z6uYR6-V9f`_Li(dF* zJEUIpGK3g{e3GP-3X%SB+imZd5YW_$U$(75)PR;V?9(mqg3o~m&e(J25k9zYk2jQg*dqe+2DZHQZRUmIH}7dabd zC$-u2MeC#J+J=b$Ao{>X4fAtRq6;cfA-&Bky3L!K**CXaYJ2!MCR;X5=DGeKGe!)h zw2_2{01zESUsZHOU|f&q}_$zkKsC`l*QH$!i7@EkjqjAQP(&8jo|RY zP^)R}xdoNoz9-HrYWTkNUA$ zt`z+&rc4ViE?9J~VW^eXA6XQ;_PvGQMg((2r6?sl1&mEEHG2~f_JR8Y#d*nGD&%_4& zE|)Vkp)QfR%O$N1O=u=t@&ve)$YU)mR+bd2c1}@hr&EB|TAo_CwYBBnrt1C$Io;B8 z+X@uSW0w(7={W>D$l8t`Z8TI}@jmy?^CZXIZu`sIK2e$);bAO1(#$)+>*w@LP;|fb zHan*onNjd%fSpYxC95v;l_tl_))XZiV@(R@#ainMX>a2swEjrzH$J2JayxNtDbnMRlUVwlhk zYZ?t>Y6MTG_3H<1$0E>{VX$+`UCfX-u&mW8oR3Hi&KI=?m$bs4H|Uk~nF`MpKi(Qa zhUJwOA|BA0pLMeZt;+(u<#(Sa+C${9;OGv;cF9?;ATW+BikmQLZ!8OR}Sh0fI;jCRz&vei;; zvMhhznlid4_+%-k)Se7GQ;o*2e=H1U9px#+I1Rnxg3czIi-g*?l$vIL7^aLLT|_&=F0>6r^ieTmkY3-Nydk)~1RuTCzA0Oz;5j*`YRmwQn6=F0=ABaQc5 zUVxBPImc!H|i@fR!HK!IS zK2s(X{eeg>dWp?#PjL)>kEX=jvbl8z3F_F00Esh1ENo$V46EDe%GvX&+fpS1?{ouf z!n_taB#_e(YYWt^uK7wG%~^}12ZHzXu63rU5SP!Kf8Acp{<5W+laaqCliVBAQ%n&0 zXi99P<(r9dNVKNJS-9tI3$ZwQ*X|EMtN%bu^o)jF!Xh~e==Bc$fdm|Qjy-$_5Z;*@ ze^Na#lk6(}{m+{>IT3_I->Z(^+g-N_SX^{vgoJym24)gUVK%w3CVRSW2uUqG*Dd#k zn+9_re6vlHVwwBgSRtkBenD2CIM&St>g`=~p-whuHd6k0?eo|2N{Zas-ajqUU4EBY6ZywA3u}5k0?HxQ8 zMgS-fXED}od_X})&|BCfJxaR2w;yXD5atRSl_tzZtgVnSxeHT97n%PIkiBD@qv4GHJyNy@-6Zq9NS)fCvb0@>*}62iBb>os!3ZRgS&jrIcL`m_Py27} zm1JPIQ7v(dyHQYZ1nnGijW6t?*EbfAwg>Q> z+|uMw^mTxHG%-zDxN|%46I08*v`-0U9ahosb|D=N(g`4QFAtMB^aP)fh8oZ5HQRB% zx+K?C_J8ypB)ZfjY}_8wx*K_Qr0=N6nn7TRnwHLn?tLGk3PZvinc(l?s{#0guMsz) zbHlrWJqN5kDUs6%>NJLg#<1sr1jtFiaD`i@R&o~`ib=d7$6T3?jQ}G!bAeT+MzdeI zL1DPg)UmkonYoW_47a0q^_m+WuX-@j3-dQFC*o3ldg}k!tISti@`-1-YmT|Ie{+MX ze|5Z3QlT*B_e&T0fA$(&Y;v|+YQRiNv5j*{@5LWo^m_P0{oGf%qqI$1AO7+yr8bjV zc+d6)q=Yp?m6lLyguUEvDiOPyJYVv)R5E9nzmcAyY1tULB>jwF}L6q z!n~Fyj0SxUU3=6J`D`0LfY=HaVUhr_|G4^8oW_}vtnDM~*-oftWARA*_N2(a#iK|{ z%BcGK7m7Zy;n|up(!xDpwEtK=4)}xwM}aowMx8NqotUc0JZC;N`D$~7#m-vPZ7MnE z*O;d@XO(tawFw)QM`j}WU(s>xk5iX1dwH@CgdPfudwHWr%~hA`$CTF)Hqh4uMpNbk zWnQzh324dm^2Ig>1ej^*)6AWYd-TIc87+>V9gfH`0)6mmLp%BMS^ZhFy0N3qBBYUy zLl+(#J^+Z40}!p}h#no^oTc);I35RkxS1TA$@yUClvL(kZ{Tmx4CF(E13}LDqs_rX z%{C&6xyxI1K_2SPhZDt#-oSgvHFG%Ef#%?WW@|Bp<2sHE#k-r2q{glth>38NR{ZiX zK>mt~XA`@D$UKOny@B7NUh}PbUn2FcBJj}sT<>rGr(W@moAs6tcla;=T`v>k4HTi? z-`%QrC8_rb(qHQ5dguL5y^QZ>y-yAIclWJ&1%fxC!5hd!y_Lg|eWE$|iwOL$N>vxM zahNCq;OyojYhw$88Jj(Q8ecp67^zGWW(Mye+Y?(rULj(*B5J&WNvLfj!3C&U&gV4; zQ9C5?KT0wV7Ve8#zQ}?Tghu3X1B7vtnp0O@>E~jzat5+q&gVPxYotgAJC4Y-?7hs$13G~qIHT?#~_ zBXwsHy6F5}&B41dsKEm=-mw_>IOeJpP$EO)@VFD3gOogO@f(a=?kj~@X9&BC@HF+6 z3ItHRM<={)5B=WZ(IqiINrCSjR$=RK>FDMlTEjYmb*Qk}F0OG@8a@Tw@KF9x@N3AZl-65Yx zt1elxv+VE>fl0k13)Wi>j+{&FIrZ6Ohsn(`*SqBNVlixLKvS60QeeBo>iq36P1#M_ zvy=9QBJC}F)v=(E=QJnIi2szV&NYEoWza>!S@|8oE;8P~Nsv3!NqKu?Pq|{v1_!zI zOJV%zxx4;F@|?$UC@#i#49O!Rf26+ZD}kBJ+V*u}>gd=lqo9#axCdro02(|`wt&|y{Z>0+du{U?_r*+e z_@f*OW{GRF$LH#eH9vE$cRWjN#b;?c9IPcmd2G`|rLJYwRaYTKbY_e-`<)A_EjXg% zl|*Vpr$fJYE-00g(uR`~#oswmG=dWXiPM(#gb6qudQ%E~VYj+MGD(VZ1V6XS`?LEd zUKG8l5Cc-+B=USQj1-h3NCA7QiBP0bHL7w|qWFds_z*e19M18RJ$Ts;v>E(1+&E6X zbKV%y4+4M;XNEI;XAho7LxZ2=yMlc0kOF_j_pgS(e`ycyC*M!tyRN=|lyQvG?3Dt0 zk>%{I_5M#gEUtp5kWX|-gCD>aMf`0zX9YbkD|ZvlmeBC2*aP-qVrCYlKqrb{8(zGB zw+H`1x)^$0!cB}=DZm7kIEr7s?HnBF*cSWD12MI;K!((g3g(xtKCQiC^Llohy|&V? zV;;$2U$fgQn(fkr(ub9^vnYCZ(@5p)DFnamwSn^2VUkS~`*%leuf{lCU3CrD2jMW| zi0yU^)W4O#Yep76?q3EX(EnGmiFCii!bTHmyO;Gl6!cwq>RlGRrwLaO)^8{4%drdr zlo{n`#qh)wy6Y&jEWZZ*hQ-}yOQ{LVy_OpDg_qVt?oyLr|u8-;1e#~coey259fNp9{fFW z$vBjsZy%;BFJmX>GGO}JnqRP}>M9xcT)&1%cWtf&b>UP6m2`AXnX*SEhwaJDv5up3 zWh8h(k4noH6Bux+_%cLx>rrXgMdTgyC$F>{^asSpIqqlrLqvX{KT}*d4I?fH)&pU` zHc5eTcB^;sO6U9&2V;2}ZQ!5!) zQFVk4O_+}oV=v~&%f|3k@MK$lmEbpRrDSEDuoMCpf`%?dN}YPfn7P5RM9h;c5#=xD}^@wp?mwfLgrKdBwKzGVS{s+8{8~8L__O zrPy+Uh7*ia2qFn$n?zF*DpR4Y#tSosTBHgy)#*h$I8~>aGLD^5tMyXke7}7HO8w^j zzu*6PeDLI)z1LoQ@3q%nd+l{8-76}2`JA{h^P8Ameg#y}ybiuUwKEmGZicrSE|^?T z^MZMXL~ycL*9zxLDs}=d3$U?h{WPccty_hA(FABM5SJS~%T?y?JkV@-YlS%ML(RVZ z&4g#^myOL5*U^lN2e3o1Dconemzx?#^aZ-z4JE*X(N&@$fGi z!vp6$IZ!}s)XuFaw>f~q@0xwTL&4r_0$0;~t|W9tb~Wf2$smcSMa=&*a?+4vlA-H$iU*tlt7x`WIiJ?`sHv{>yvvb)7<)h;C|PgufObcOTBXI z^1Zergzadyj=1w(g3~H*NvNN5#47bxI#VO`<*)?MZyqVc{w?wW{eQSSp9c0Fql0N| z_7w-l<0oU|Q8~AH)oQx|J~m#7zR2A<55<2Q=!D^S>|vur z#`j5g=R+t>gc~gH|7!NlM{%rIZKFj0gu62l-wupozqHx66d%!)b*SG2Jw8c)36FZn z4VrQXtO^$U!U46w7g>Vvswt6vy~AL~t&gb~IX-0M?%t&9mLu~5O+J=6c%8er%{s+e zIhcdngCXbfo3Zn%+g~;uzXZCf&5fvTIl|T<6vDwK!$&u(KEfelmHP$wZT_se#a49! z+ex+U2x~@x!%E9XH}QLw=>=T(2DKehRec0#_3a_6T3%>zi#Fg6!k6`rx;y{1-Oxrd z9b(J8qG|Shvz@FHf6~}|aDUwnW|qfSBd-DSMQCQaJO7Fr!GZCKYW7Jm1aKF1YN8O~ zCdmmc#K)zf+^5@D_lDH16-D#`CwFo?)Or>Cy?Y1@yeS71IOy!LBXWDXQxoCOa%d0A zF`b~9<4*3w?F;5%mCLM4&8hR3vf;R>0TU10)o9K*TV<{^H;m7ZY!{Ps+}!Spac_Bx z+rPcttr;NpqDw}A5*cJ{c;{iIZ9IXj7m5J*qfYL(+ZSYmggeJbnD(z20X~0nf^vb& z;|^YGQAVn7UB7ps6PY)lb2eT;wGuxW?RYz1rReH5AY$3tt-ymY2D89 zhSN&2Ctmp&h~g~Jgz@!Gc3kiePJT_vL1$H}}p0KI8 zQ}yTIE#WVK_~L&;;9lKc^*xa>{QI4}Y~E?}i-?bN+UB--F>=S8*wG(%=RWI}`yU>G zlq+vdzy=2YD8CT!CWEqr_v|?z3%&v?QJbsJW@J>TMEzh`VzM%2-Fs z)Nl7S02sn=wUsFo-WKBc5bSg3R)%s5wy)r;MRq*3%f1fw^9$?s#a0|KbA9vc*@aGf zwQG^pv-*;l-5_Cv`|);gDy5;6(x?a-{20jksxZWw@7s)@3e;OuXydb#_C2*Prykl} zsq1kE8+-E6dWgOy5r}E_YqG&%xPwn-)H_R}@upIIgH!=0r{hf&-n?}8O$y#j#hbFb zZxZn)7H?i8Z!8HB&XO2)&UcoCDD|1^mV{{adi6yAy|?oAzMrpNc8!g)?`YJX z`Ieisoei$Y7L9ib7Izgyb`{L-a!72Mmc>d-Nry%)8Rq%cFdY7GrdYsdc?mJ7=+T6c0Ho^Co*I5#M-=E}gmPBgP zhL~*v7KzS~qBaFFp9=bawRL{dM8NtcZ!g?N*tlVuv{ad?zCp7w?1IG~hPeLoO(NBG z$Tf9N>ohicQ6N+((WiEZ@|cO8XPT+8ISt7Ejf^D@0huwOhM!ykbDE_*^ldlL(LWjpAv8a8NSXP|uQ zyUYpPt|sw17m>j;%chL`7Qeemv=`i$ne0E`*15CEU@yU9AEoqjO}-X11%f=)Hn!1F zh7SabePiu`SpQ$zIvY^og?knL)a3gF6~Icl+NM#_WSvAa*aqr0HW4NVqZwa+lV+d| z%ZI2!s+#VqLcrF@sq)v(G^2QDz-L3|-ICBPO?!*m-Gn&;y=;akMqE{hoCG8NeVIx6 z&)RSfG1xcV!{YrW-!DY8UA=eFMq9<(vSwPpV0tC`%?bu z_^`oXm(JiNZBfB^=00p_v(cQdfy7I?Yez$5Kfo*v^v_KIii&<)h8U8FgghdpyTp)0 z2ZHbj|IcpHm%bo`XWD-_8;`IZ+DcwoN$8+@{z$?*f?6qWa5Zqr+el2Jz(WgyvTj2u z^eS!*!;(V_U<{Coqp^tKUr)L%f*Ti$2=YnQRqfDid>Xw&;BuQfXb>E)Oo>dQgQ{-X zh$bH&c^-s=fbhfs54t4(#{yI^pfvp=Os0B?%rU(Qw=e$z$`V3ZKQza*db?*C3`f>fI%?Q@W4HK0boT1%PDk6{C z?r1Yja?9FGD!07NglNx1$V7wPf;L(}cLD=qRkCV6(xx&+Tcv!MZMmt!D&xa#8%%Xp zIX`87?y?2878C4PbJ9#xgVl>=A_P65(WS$^zBy4tSF=Nb^0s(#fF6bR&pf3>6^9loJGvZsgFEfM_ zwt~1>t4TGgGuPHouAdT>D6%ziH^@hWU2TL_tw{TUI#=(jL)OB`$Y`C5KdeK}J7Jvv zz8Zkm|1Niqyp1grZO;5)nCL!DPB5iuG?PUkd?%BMd<5Fu38bjz`4R1cTf72kzLK)(!gPwA zZwSL_5b#JHQpB$x(MI2Z1#=2r_#>(`o5b+b@=Annq)qT))U1c`rB-zv%^`R~wJEOX zAfj3Zs`Cm2pX>c^3dXF)Cn%>Q9X|_SCO>hh>a_ood|gG==2p#4BVT@ncMI`g{3Sx4 zP|h~dpol8*~#Lx1OUZ|pjFz_T_p2TCd_=wII9kk;yk~V%MuziIm zfV)$4j6lJ{#{qYR(|&$Ypx$HnnTnqnMtn791D*3L#OK~e$uoh8_Vx?0N(MR;=WczV zWbX{}H7@|npt5Abx~4a0sBu0x3B znO(T&=@ZK*praqdgM%On@2?LC&_b&a+sCvB?9E=Qz^*ekW2_6elO&c+$h(kYY!uj~ zg%*J=V;b<=YkXZ`t%bi4kojmUexEjO5!j)^Iw5u)V}d<;wy{=-jVv?@u_?@E{9bCT z5n{^<4MMCJH%e5j)%cna>n?m%h&|106k>;ZUm<=aFiC!D=aLe_Iqgtd6{3K3!kLE+fr2e7@?W(7G{*j z(1mlT(uc@jXiXLRnIr1kR~YejkBCgJ1Hk7BWF`h_Y_2jNAb#@CXk#u_`g6KBm+G<` z3*qPEMH{`@)c))+)`~}vQRxnv$)UQOOO0?oI*oT=2kcPb|Dr?oL!rj6FeRkyBdC@W zn@v8E$H>^oz+-F*{x5t4O`NYj6xw@Qh+T>oy)(h3NP$^YXI3NDL9CY)n<>_`;t?|t z>kjdwYbUL#qJ)h|uLoFZ?~GvCIC; z!fs*qLnPOH(IMkOm@{oBwBKd#!#XHBv==bnC*^kUEqs^!-=-|=#QS=@e+T~q+rkd= z9@hqk490fUKfeg?+#>ArXw2v$?+9)neZbL^cXskF5AWWb>6gqjD~lb>pB#j-&yp-I|mQ8 z7Q}Jk=4`k)nw^~9O!k@6clQ}?h4>-`>PXno%0=hsT1rm+i}(c(UKp<*4xe!Q$w*Xj0osXCfH9MLgxO zNS~T#QHHQ7^ov;?c1JJr1HeW$hl3WtgZdE4*;lI2EFQ_-+I<-_DiM9Dt z6e?9sJXr_8(|x?9R}>&D_6f~R1=iA}s0^(K#~c4bp&P{>WdBUq!!^O3p;uV@o!r-3 z$h>>+&UsCp%kJ+;OqR6w+d^40lzNxjB{@ zKbDCSMW#4yKOFmo=+}1NGZx1}i(lEsWm`~^&>ZOPMl*QP1gPj;x1mBFov-)>7SX?` zbDrfCaU)hb=34Y&G7_5lNlVY}$4@?K>3q_npJN{_ufKE82a(IOAjI-KYj*Qw86SzO zPTUMbTN*%!U7c_$6uv%~OQ%mk+)*EtZV}}j@;oePzmTnqKXEc}vx<~tWfr z<4{Kbr+;T&aS+q|*Z?%1`8)HqSIPDOCUIxr6=&t6HBXE6AlZuzBC?cNQ4=MF2ip!Z zctqC2`7PRlXX1{eJuds6z}BdxoSMAkCZQW`k?~#jO(!Qi57hKUwca|rw=Ef zcmfJ<^?#(hNpJiY0qT2)YcgD3$b33v7Whk!<>EtoBMoEiEoTmi?M;%B`X}fUx^I}X zGNjlRN9AL{8E%hVK)&613u*T<4!aw!Hh5CpK{4%~poXs-yiuO)_4CZZrkDdtHuylK zH^>y#A8ZQZQz7}Mdf-5gx*FB!xSN;rDn1fz708dK%?Zg@WUI@IQNee#-47fPw zD%AH2G=T+5&&yjw;0+3wmb)_!on#*vkjBOksBFPgWW9*6kx7k=LV%qF=JU27zSIoU z0H1tsKAk=h{;m7qe>M(2`#-?H@$cY&N8nEy4?l?D|Lr~a*9iU}i}2q*$^MAgmj>{E z{x1GIMEtM(5We_QbD6-uQHO2@=rOP+{NK=b*978XU5a*NNw7Z!?1h-czJiwm;K0O;fgO^7YW?+r5?S6P?et_RTVeq&+gMEOkt^jpSxChh!`Ta2cpAndr`(V0084nXS z(f42mC~6!`*Qxtqx=s?98}EY&t}$*L^?L#^eiY@Ivio3aj*o|_ zX&VPKK$+uUYWnVnsX0bqK7Aid|IzU<{f+?4pNe>bA`L0yVETLRhw1kbm_NP`rtA0P zVY*t!!6a1v1DLMv`(e5c6PT&@!PFld4^!V9fcYB{&n@@C)O+uTsozgvhTjKM^Zt04 z8p}AC?ENq`@7@nn(?wtkCeYicZec)rR`!iNJ;wjK$YH^EI}me~yeo=v{buZ)?pO8e z81*r!JDL0MdQ^^O5goRAr+LA;2%<^oMx1_b75RWtD*!28wiA%49#-b)`P)`r04Jg=$#3U^GAKw@}j<9%) z6k0NTMZ5v1+%Mj7`X-UGyU^#p1<2rq=ZYK1V17&ZUSxPq()A;Ou8aFie!b@A=(a-e z_}-y^lXg5(zfRSE~ey%Cspa?hQ z6QKUvLi&UgK99qh9L}q}$^23gZtHDaRjnl3uINVwRasPmrsqwk=y*Y{swv9zwc%J~ z)veXXvTGwgvndzy$jFNFqq3AUAzaj+ZlnP~B*F_kkO$5qy(1>8!g(9^!Ro6Si z+?RneA`Xb*e>E#cT6J~mWn3R#U2T7L!NLm<&Yno_Po##n?henC zrYPoL#jPv!s4;U!tvNZb<8Tra8l5|Yc0%$RizUaPqY zJk36J!iRzMCU>2P%ROWw`%+*$)oH`!S;SA2(WE$rF3vY6eN0&@FO>TW|1Rmj+2pHxQ>dA}+>eSwdEa>1$m4 zqYVcVfAMVEM%wuWF}M*gE=-;L)g)ysKQl==X-oXl8^B@7fkalt(1>9ub^TdTZy4du z@F*@isNk{!!GjrGPC4I<>xt`@JC8+jJn6GJx9T?RJgjt5YOwXjlBmluPd+a*{zEA7 zu`7B}5b^4&#ZkR|yYH_E1^mGr*Dq;p+U;(pL#nwfXzOlS7+6lex@dm&+}w9#o}K&t z?=c&s+Ume$+~{VYUetes*rg-JFVNq+bIxHtUfoE~&7M5>-ATc7@)Gk)jzwne4#qtZ z>~#Y3Ga&Cq^hfSLMOZB(ptHGQa>G#pk(O&Gr<_X&P1_F5{!=7@?C4CT9kNwgRn}!V zbcqAcXJ~W351un!NvvZqUwPGYaAZSV5Zr8Np*a6KN%@TRMpDp!UcJJ4D`uw~nud&k?oWLRF8oDyrVu*;VE9Jjlz?Y0pnNdP;zqc|le-ep7 zQjMD$NS3BhZVj0fUbzgBQJ=GB;@KoO*n~Gpj!05?^m_No=SfCEeuO z(Hhj;LNkjgzY(!8T;0M}m3sLd5_17wG?XiTCu?PI0YY2Oaf{u~4q47j!mqVChY^)? zG8wtED(yifG`bFYZ4T1Xq^pK5oXOyVUr(B7Yk8x~{_5Z3n;&}}v2od9Yp7R}nLoSg zTW9`{oxV8*`HvU)VlDYU0TP+IDVaL8K4m8x)a9FTRUX>EZP%}nWX*wLhl2x$vpPpFI09<2m)I=kAvWuKcg=_ zc5xT+=g^)t9kSKWu$j-0gd(n)bhpfdSkbtL0q}}{u9`7t#@Zv7BGm7G!vVqH}4lfGVm=F$;A*6AA`quG&{T; zmpeDIpbSl-azRlmy>wv;;)E9}D1RYc!q6)ktAh_@y`501Uc$27B+`k$OHj8DbLVPN zUG%6UQQ_CPUVIbbSZP~EmyYIM7jZgxjhB1lp@m6NjcYvk(75JB?E=o#^&iCnQIL19 zz+k;#N&f=SnS?O5EZ!ic7x1-@WOaqXJjNaGkEY=x_;OoRC@4hX{~MX*<=_o8YBjh?@0{_I z@E1`+o1UX@*t+W+e z-jTF?aO7a4`h>hMv|CA@9+Gz*{oUTW#{ROFCOF@zQ{c*r^M3>Lz2^Sn_{l z%gD@6FUX(1IR9ZZ=D*z9&A?_D3(cBg$$tW`TvKW6Xb2+;eeY?3FRn#Wq|XFHucXJ9 zg0HK(=N1GFS_$|_;MxuCC`LH3ruj__62!^8-uD_y<3ob9s?_|wqx$`W4+ej9XQtny~2iE&C+^+?w2`D3Op9p(qjN1?b1lz_RN zfzEEQ0$lffGCr47c}HfgZOwL9cKPP`|Era|ZUhjg=x}FMHb*D>Q{QxA34Dkq+Op1J zVV#P{y;vXPIsrIDQS=DDKv6WE>;VBB<0X!-jA!MGeTe}S-726+pz2~+l}GN*To2Pv zQHU}~>MfFO>N;rvNx2P3!d^w<`Xe^3OVL2mEFyW4F9w>mu_S;u@Lcg$oMvIGIXd3B zmHUX`Yl)A+UywSI46!pr(VzrCS-S zVBD=)ua^eVtM!vCQ{pTiNi5G2W*f8CVt=){`MC9ws*7##Kv|2kNT&T&cRqo_O%MlL zl`@Yhowa7YKhu}!v~RTfn-8wG`XRg3*S&y~%eSUtu#o+EL@=#Xo+AXNL+^^Y*Ql=jSbmMQTN zWS=E_Tkq#W%-XC7+W2Ro@ElAv!}r=k*{AwcUJWGWmP13c2c+jFQb@0dk?BpNEYqki z%|!GO^;Y{*uz|bt=PM5AxIYt}zo%HcA%vj7czB zB||a}0l1xyj>HK9;&I>|VM(I;m$Bhh-$E)bjhcw_t6cLM?!88G9_bANou-pFoR5^( zwvTixoW68_r{pOWI3qsjt02!R=0AeNr5UtX@ZlVw+2u=zF|KWh)r#xh}mapz;w>qooV zqO>xlsf(cuBONk}pj)oshs?iQGN|$ti~aePu;-$+n0XVhEizg?Uygl3=KOGu{~d|; z;6I%*Nv4eEUL%!b_UAVD9}k|HyB1at7YHsfhnf%llMJv;cc7SCO$tGe$9Rjax}>!8 z-3l0Y=*tBoB2?k5(S_75g+CEUqna|IkTghPws7`^?9j2f1N>^X7=INn14kXHx&j%+ z^4<%z_+}VZh8TRYFDa{roh!o+B$I>is`Lp13{j;>(e88=tJJoH@Jy1DE}VrcbgJ_& zn@w5VGX$xZRO$tj3J^@@3mU@xDJMnhd6N&m$7kc~VHF$X$p9C!tdw%|N%@ov&a@e5 zSjo{@RqL1{?}K-Ub9w1q1S?_4%9g?|%x#$vx?%Hq|gxdujAdq8cNOSA#-TO-IY ze;|1RB``URrNJn1M~ZKwvTyNbn1|yha^5UXkdX)7Bvy%tZwbCwlMK5h;&;PT+!R`5 z?y_TvWBd5I@RG`$yL3w7bufq&SCtDwADZ(^ErWPpr;zB~Y%pP6M6{d0nOd6~NK z8(`wrrL$c}XQxR07j~qx&ZAXco#w)hZWYq~IPI*>nDTqhm*dCSBuI(k2H=OPA#tFbLvhG2Ey3T{JJc-p~skHKxht&GL}l|lC7cHZ%9hXh;<9<_ze!mYd<7rv`Y2@Y=9+p}4UAp;mqO^>Tw`{Z zsd3CKi`dA(3g)UJ`hs03GDFN(L=vgaB%Ic158mMwWfA?^m6S=;Y6;AuI~4ph z#L=DRdd|hbSJ4!P3ug>^VDE&~I*9QJnODtqZVF5V{(|0ov(^w%D*>PIpSO?awM*N!JG?S{fNv}np2Lsn$RVW-v6k=DUUkX-j)=xbV~2H!Ug&#S3zKk)4Cq|@h*C{e~sY2 zV4;5ui~bA7zOmsV`X7Vj8OSIQSu!Py=3#`=O~w5$vT4u<^#&9F7$=+)Zp3{n<`HXof@=wIW5_;^cHZPnZtcJ)kGpg5^I#=P(Nv=p7 zbS2*t%wzN+)L87V)$=bPF5O;hXtnJ~waq@?#|~i!Cuh1V<&`TB(7o{pBlQ%+%1s=C zNi`UHLQLng7enjA-6E=MhzailOGRz&T#%M}olKn-wQAD~CI_UsqVx_ae-g5P^_rMx ztqqG7tuaaXD=kAe9jATXPW>f#v8op^4d?2bqv_s!iu+5QkrCO!>uuQw-7~BDYFnl` z<0tA%B=)*Ebp_oJOYnD!GAlVR;(S&_>0f>W$iTXVl({{OlZ<;YcZ#6a7D4xZBH%6w z4A_aRaSyxTYNS3m$q)}KO0&`vx#H(FJZB09gNV_FD|kos+9R5F@@{!8?xp`(uIat4Iq7BzF- zk|e2LALmS>%xN1LTeLaI1N(`W6sAf|s*A7e1)^dXA3+zpbV|)LlC7$)c;(@`jSM?c z{rUIqW{ zZx3+2O|T8^nn$=XFyhR04E7kXTI6CH$5$mHeH5?u&F-M@C@%PBaUay2l@*y$3nr;b z(@E2LQ#|_VEjERkB&pnd-LeLJv*;r9pJ1cY)&=t`S4PIf8nF9t@70h`@ubPXj+#p8 zm%U+7#DaPpIKw@qghx_k_3k4`o{M8cu6*T`e#kqY5PR^q&%A$PIS@skZ5trz=Hp_dqR1z=Lk& zoRBRo)J?5ZUx&Wmq!?{gfsv{t(z1!P==#^l%IK&|%ke@1A91U~Ao&r{d+Lzy&AI?hp7hrMTiI|9;adn~zI!FMh{-FMy@KSBS;kxOaF?UH3{i1ThgSU`G%$L2)vQn7&rjO@8NQ)Vbf0HfE2uFZbPAusu&!KPU7;Jd<}V4xtN)4*7Jk zhP2R-XlicxPj)GW6GFe%ptLT*3hM|W76>T{+_ zu@OPJ?mQOax`MQ>aoU65p_)lTs&z|m>V}G)$#TtFvKu5v+qI{!xjPGd>FSotMSOy9 zwuP3|+I~ug1EvBnz3L>0uqLwa_Q3*~-~tP2yI{~spd$luVBjSLsGD(pHll66&cF$} zBS`-Q*|J-17-6MPA)A%d@Xj4v{#o7`ZnlPZ`Lelx5w{M^)Wu8I^Zd!$sCW$b9r`XA zj7?_`Ezz$MxQo1Y`9-hKErTB~E(ykVC303wFhC%J>Ym)@h;A@ooR|QJk)zzWH1Y{( z6pBmW2J4Hw>Wlpb=Tj_+vqYP(FvSyF!%$nOP2w+ywMdZ(g`AS9xOfrAK#~J3mEM8% z$tlIPBLi%*cE}C0{+~O&N;jja8Y#SSPfp8+Ctegu@d9)Jx-=?{OK@RoZqdX`2VRBykVkh_&_$Ho#t&Z}v_)fSp?jxE?w5od{hR*mz5$!C@lT%^zIC(p)QP7tsyy zm+YlcPwD?enzcdk7mJ2h{4EZ100xI43pA~Nbwq4>x~e%kTKmQKZW$>#iEe54$lOf3 z4azbb(|oW8GlV3S02|k9&Q-evXMXsmp3VRpAS*yy zFf>(JqB`z)2ZatL*%S?QWPnx~*d&7%(>Z5P2<~Uk%0}+mIZ}M!_Yc>Q;yi38O=C zS*H~Rc(PRHFcH48~)a}xQ3K+v+H%LW~_1Ya4nIz!}($l1<6msnN#cm>5 zd{>P(7E$*v%Duf)DZgi@b7d4%Grc#2E?FcSi;IFMh~9*IhYXQcHQX(A>$nc`!^#+B zL>cOJKO&%sAUv3%EuW%w+f`&`Ud$CN%{GA%X^Aq#Tu?x z#2Nwkf%35Ryw`-msS(YUdLkZ1YGUjBO=>9PM)ZOE71-pE7tgXtgj46ss1?hGE%-!(cZ{f}xO;8G( zqD^V0T+;#*+$K#M3>8X4MNlf)Rtbs($Ako#x-)qmY$UI3*Uc1y7z z995u`5^3YzICMe%nUY^Ap8C|;N~rg~7uc&$smO#aP$W$$3I5QVab!5}l<&bKALipC zV3)v#fs@wBk<>1AH$Q#6e4pcn0`>3q-VoJSb6JNbZees<=($#^!aklP^= zJ6(!9!j#;vGDWzvpw`PWF-9rnPD^l17&Vqve{oan3%Ch$2X-zxxVDq?f57;)TZP%U z0>!OX#W?L{`gmk;!Z9DYl*?7+(d7%umzP(RZz!)TZz*ptzgT{|q#~rGBI+eoP8wt( z*x$I`f-u;6Oj{?jE>=fT>!WN6kW+-UVEy9$p!EgT65PL(jLByF>ejN_#wce`EJE$o z7%`N*v_p!7Q+Q$(h){yG72n~uSxnaQ8xn3KdN-eeV<(vh_=baJUTcX}LLhO_ZP_ch zB?QFtFs8t6HuC#;`JI)FlKG{c>TTJdqsm@`*iWz<- zq=S{XY3cyq-d(nKPa)nhV-$C!1@@25W3k-~H%lBj#5^4xIU+|PHOV6@t;j>$EjK(# z=mXdI50P?9H86Cr&1L>3n3muVRhC3FIU-$ywfER%PHaK%)u7~-E~InjI>}>KPb6#R zeoG!j+rxaSo9?kos%~?hK*?B^Ya>sOb+NJB?jMvJkXGF`%iJ>Vjj`8c4V5HF$fj|V zcxppdLrT@{vkVGJQEF|@kBU^axlezW9kM4fK|u#6#9hsc)8sR93U?jD(Ei1qsm)P3 zZf∓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

rhKKg1r>=9?2F5e{1eIL8EV(0y( z<(cAv7Uo8NdO5nSp1F}CmS^EoL!{?jG0rdM%nDaR9N$cl+ZJ(o8nY@#AUnLau>l!V zULwxj#@r|o5B$uOSBVGy%9O7c%U`Dv(?C(=f5*!A(X1Bnzzd?C>GzexF+p?#=YJ;{ z_AOi+D>sVDJ5Cl(=kjD`O-`bon=uL!xh_|fC(Rwjm9s$zQ}?rIDojN@Co(zJcpdl| z4$ah-S4tn?%yLtLHcyjrk(Df#R9SBmjdsTg*BLsV{vxPg-g zi#JL?0dJFC*4^~v9XE?*!UB`7SH7I9cflmDAPy4d{* z1y6yfjG!H|pYnEp{u}G2uT6%D$a-K0c%JwUu1)DrVU@VBT`d2rFDY$O()6#aX%{g% zC2o{D7H4IW0~0}yFCHU#=RzfCHdV?hLe!OE6(K=+dK6i{)yF1c&(3k8s3JNce&2`V zx>(Xt?UX`U**$l>^h{WuyhlSA*M^ZiInkDCYQ8*8u{_5XTX;20&?EQG851&;mzH|_Ar62^M>`k6!y#Zbqa=4-N~czo-n_1FDBo=Dat^Q&jTSG{zH1XoZ<=P50VQ) zn|<^&iGvq~`VW|e14&RHOkO&AHA96@rhGzr&3hKX&~`hO7DAY2O=XC=oRb_W)*X=L z;j#~YzmRl?Jq?DT$`OcvgFK*N*nTIcox)SbyXgrLya%OuY1~KS2V|8}*!tN;zPRMb zo(|ECG>ZQU`JklDLOdz0O@+l_IeNyFs4doHoaXNDgER`au<1UtWCSogiJG$ZVQBv0O-sGY}J(`BtAKpaQXez`6Pr?W80(H9J?^s>Yj zJ&xm9{@yUNA}2vR`!p-fS*kUk=BB@!C-H>LCQgv|b7(?(m7{3?QlVrwao_cOSa@KI z;)rBUnjjCJd)e0(6J}QBX^Il`i;##OG3ySGvtgxN7*3^!o3K@7(t-}`RanqBIU=~_ zQoG_^bM^rljv#EN(G{xw`M`f>Q7+ahDZhjq?F*5Td$+Yq9`=J3gXapCfl~%qpF~)l z{zej&gCpf(FA0KjES3ylu6Kt*C5mZ0b{%LY4uH2bG&wdjr)(V! z9o%;2wHg7FU=__Jj)CqH@sZ54`85K{aDI0CK=~UI$J^&OcG8iT_uH|$FMY30yri_g z=xe9*v$~r;Pvp*~U{A)*mjZicH$3inrXj_1WGK_2lwSiMI;OEApu0kTr2Z_`fLBvx z9BX?j2J6o(Jm0}5|57=x%i8rpD9*v+s7erF+MCZFV-|R0cYX&xNi!>;mXU2A&vi+aKyt<)kFGZ|9b^m^CniQCBf;l3KXhW;W0) z?S{NA_V=npb9%-eP>Pj_#d}JchOO!4g_!Dk_FZIX+TPldP!RGc2J@c$UzBK zBfw|6f!*|BrhJo=ZORbZ5AvCgL)?*(?%CA9f|!3Dl%~29C7wj!-R(QMZJ;rJ{qgQ{K^Io=&2b~)pBzEXZo5NEgV zybx40m_7~M@WBNAOC#r1E`!SJq;zTVe|U6&g*NwKf?w<(=yKx-w-0jdg9+G9Na$bY z-8upV=(|4M&R{~Iw~TNl;IUz^4E!y0tW*WNY9Wt3^at0mFP)Fpa`%rH?_`6ps|2~; zPd}4}jjemfCCG!p)|Bn1G5qV#F?HXK(OOJrcoRmg--y_wc{=`Ss(ouzhAPz>Iy)*) z(k_{uLiPIh7$)&b`smTQH6wMOjp5+qfYf8=&E#qQMsi#xu(-|Mvt#y)E~7~A8nIur zE6yKmOAK|WYL;s~+{TMY$~!oMb#O7ol7BnKY$U7^I?85w#>EGzHwV098b*5 zwpbzej=9>d^HL$a0}PXRTd06M2^z@!!4RYqUqY0;`6m91Vnc|GUr3=LC;2&(DhmZ( zho#W4?E#mwm4Ar7zz(s>Z|=fMuMh`$(%Ll^LT5aX{`jFNwC{Gv9Uh|haflp4^tlx( z+xJo6w$M=F(vy4W1?Kw-=;RgC5KNw_`43;xo2FAMvp4OD0 z?D$afgb9m8#{CreeKUdhW{bzV`;6X-e?jozauf-g8C`wTcpo)Tr!+?T^o9v#ebbzE z)iAnkb%aNQ>#Z+Alo;DX{~Bt^Th%ZhdIe(JG+(r?-1N8XlObR|aoxxU=NW-QWdmba zSD%HyEHrswC{j=UAzl^QBhi)OOb{AZD7pK$x6kJrC4I6`Xknj}h4Lzj3U+yl5_B;M z>`vfsQ3BAaqUK}hbGy`9^)p0FLp#BhrbHeDHN^%->+}!$%1vKCdoBClAyi?H)Lpqi za0KZ6j?#aHfEiw~03A#i&{ayAnDILP%3WtX{|z~b56t(H%fG5@Btot9R0y&=Be#x8 z#^h{l-?@;kb0KZ#Le3Hsh`&Ig;i#0Kzu}D-^+-X#%Auk?Nd9P*kLbCa3JevQ)aOI) z;39Xj$z6qOC`7{D(|AI1j}h)Axj=}j*sJ$}NA&>D6fO>#C)TxzD`ngRFx=zq{tY(Q z7BVEagHoRxxbxR@3G1Mjc`78t9hE?)se#_xSB(n8$xScNH_x$l%@GFi54%J9@-c#e z7^86REHP5xDG=gAMp~1sx*!2|h`!IM65X+#(nQ}4Q7nQ!O-|1$NHrO0l<8n0gWp7c z&>R+Y_?BERE0x2Bcj|p2734zw z(yd(~{`##C>C@buq3%>MvB1^^)U=665uzqOl(UOq5TH9g!2Qk03csFrHl%x*eRY%yC7fgPuo#CHxSFJu7WA6kT)(QY?Qt}Mqk^faz{tz2KsBrmk3_r7}4u* z&xQ333bsJ-Gr;VEz~)Y(S2<+%T}}nBqZceU9sIBTF1Nwj^@T<_Po5ppH>|mPSfLj~ zF>CEe-Ej<*GAHv))N)l!KBcY}Qtzs05lGM!dc%=Ng0<^}MnH|tA$=d*O~tE4D&Ey2 zINIn(I$Ph@ux1*y5Qg2TvMZCLTt*$G8BVDrMQI=+=>0uH^FM3g;pqak#OvvsZs!R| z+-Os(S39xR;HnLY?fUJ8Oc?8X-N#R&5KA`oHtzfYHj7|Ydu7K`zLipd)uLx_)e++j z-B|Yuo)45V{A(JlPhb~74bq_)lNStMhe6tgz0ZhFC6i>B_uL&rXqXT2%^F;x&?atp zM3^FQNtR4EJEf7lGHt;#mBS44*4O zsP#FDnH1FyxxEAA6zn?OwF$eLmGC{}>&fBY6`n=>+rqPq@1|#tF3{6BD^17$P4IQ` zyM$-|WpMe-V!oZ;U_TZ%4ww_u)%hJBRdgD^gM96c`F2kP?<98}MDNU18U3iq02#!f zIv+!wxg8Uq9m#Ijq`s{=PR!(}1;-`ucwgg@+9Cya>JAMK3nLb|ulQ1*)R-mKe+Sw= z7|C|gfqJoY|MM}jWBj62q6lB;Di*u+I>D?V|0?C06h)E|ShIwhz=?Bd){UoGYJEz{ zO0pLqAH4E^rO+ADd5|*o8$o**)Ds&Wo#y=wQ=ay;qbpNv{JZ1X*V+&6jYxtN(J|{(LM*pU%5iHFW!Ah33(F z`pPwJ6&k*h0$Xdve4wVgPf=4UZcRsC-Vd%~abxM$Wejapjj?@tRZ!_8MIg*@OsadCs&(kyHPEB6wmpr6iNMwLubmz~cHv3uKuMF)@W@<`e`vp<7y;bE;%<93N zt3AnkH}zyRDueg7H0B#A>?BQ>K{H5rvrYe-^=VZL_(%;9c;YRlITV>0AT>ik72Z8d z0%eullDsK-y-tXZXpr8eEvfEP|2%-S9V-zG6GPW?u1Y|t`BQ2%o3MU<>vy?7T+kJ$ zfiX%!bXR$I<KEt0NL>l&hKx_m(1<{@|$o-FdiI zcvVj+w>*H$4A^>3Js7#O_3=ir*7}Up6XP&Bo2Pi5-KqRFaDk65VqtEg!VVlk3o&eD zv!>q}+zm9hXNOWy<_l zkaxVDyT=!8Pcn<^PWfO{9EpM-oBf8`EafD!V$JYN>Jp-~D|N$l>MCL#gMXT457}>q z1*4Ue70Y(YjzC-WNXU_d{zv5f(Tyhj>dgL$8_dkMeC zC^H>2E^PAR1{6$p=mcWJm+Hm8@knf$-+3h7obUWt*)_ktA#`PGow`_OmgskndR_C* zXL?M}PWK+Xm{DeOUcZ|pGrUa=27#ryx&ZfbH>>@{t-Yc;rBMy$^q4#mUg>wvDQlSN z2`-()`;UvW%TN}uIk4uEA+b4adunkW8j2|Rl`@4zYP*cf12TPl!qx)y zfeFQqO3%7=9;Mx0BUtd_<0-T~H7N=gPj{TmQ}^Ym+h(izO!9812Y*rw+-Nv}X?UL8 zSV)Ft43NRpZ1op!`Yu3S*dx)uJ-@Yt526@d)6^GKV<3!Rusc)jZ4rS|F{!bT%jqlb zN$Ga(KY72p?^kNr#t_Qb#>A(nT{>t;i+Eo!0zr?tD@Dyl;UclgYVXUyK%X7qZ_pQ% z@nn*^dxxtPkg74heTg`0-7M{9evYw8=t@^mIJQR``P3U81V(*>-AckX?RE<6JUFIu*{ zk`rqRiD^;R`j887%@+Bu4I~Xl?WRDVX$4iuIJsU z;yRnaoBiQL@QDLj7q0$BIy)#GI$+odN`KnPqrrWrf^$23q~8ras3$|YOcDQzP;Mx` zo`dbezT?5z@;vfJ)VCS-8EVvA%_6Gns{!>Hv-?rfAvHU4^e8T*IFefZ*VO9f-cOR5 zPac=RnuD%CannYey7yvzXz6})cS=gl;O@pFqk9};*ah;4H+8s{n~+r9&*1kp<2vBF zZ+wl%rl9UEEtit_zc_qGz3IZO)ZPo~#^bxs1hrh)^h)EADc5k_WwV;`CA75syz1J7 z$#4y>I{YTC$~v2j#N_|-eVrUgG$!_o?#K);RM%V^TIX`EAa9)=>`+c^ zPQCmktI;7^QF4z?QGQvBrnfA>M>8!NfVDJq}8otyPD zA5WjN=g|O_;s^kERo{YO{bM55xK3VEK_o99;jhjWEW_7i2B3I1`{o6E9~BkJ3=5~` zu>YR=j<{Sum!C+%`tpLI;Dh`2*eGv_2-in|NgE334_ye3MsR_kpjUXLo6XeeLX*3Y zI45T&0Q3}Yo2l_)C?Qs#^D z?mR9bt*A_P|Ppy2)ln_0o<0&9tRF!c`!z{Ijcs>*yo)1}x z?RTPekZ%j~WM-VEFq5brc;}=24$hP57kc8u?|ATGdJyj=PyV|g!nwgWmwFT#Z&Pl){=jT^+s%`gg8D{-uwF3@ zb)NX+kmZmK%dz%)QdySVGUk+%iT@%9Ywz(1>~B3{+oSVaGx^<=Lf3^LT4J#BUJ0^I z7vWZ`gXAsTlahTRhz+x9Jcb`5w`_gkBk1| zsJmU0P96&C`!uNS{UH8b@ean@gZq4UEpcGh{S&CPE&d(+?Lk!%)m7r-A zPQnlSzr!D1dJEzgE!D6#v()qBqPMtja9}DdX5AF5Xlalj1~pjnNY&ESFdQr3J8#gZ zXJm60RJ?Sh$T_%rI?Xv%HjV!;pEdn-L9m*h9LQF ze5Q6Qr7s>ZNcb(}qP4fx*X=T%Tou$;8w4`h~39k$z5dUAPC-})f= z|9}pZE`JNf$K9gO24RmCvy)g&-`b$AwLxubgE9mj)E_CHA?ggjo*Z4p!ieY;_pJ)j z>qPw1fyV>0%|Rd-i~W%fbi1dWyg#U~Bxv9VpHb@4MKwg(CX0+x zPk%$E(KIXuacOKR?l7;f+{EWl99D4wTk}Tg0Y!Ea5)#x0`5E+ukduks?xK_NL4CPF z0~cuV%8n1>4oezpk1N>U5>6t4Xb#S-e%mQS(wHJ3Ql1^B8DdMJ`SAdy(-n4ie&~a2 zYEi>ei12u){BGRG|0}M4dOhw=n$M+t&l`l;G9h-KA*k+MlmK{eqlB5u-h&J5z=k9< z$MutwS~`mIMaO0LEXWb$sqYaO5YZG}Ct*?+u;QZWG-8ELWSEdvOID9B*y8i~vQQ_> z@F9i8k`u&K|5|COeo$#Se`6%;5@4=GZIN@Ztk|!xJj*IAQn(W)r+-{SfG{n=R^Y1vuxrztJ-pJOa5;rK{w(iYUSPmjT2Vf~6 z6<}GSu*{gFu$-T*uv|sBUcmldg(Vfw+kvB52BdTO1DRzv+#3L%0MrfXf2mMgJ_2kB zR9lv*)RwDHAl>N-ixK7B3V0OoFv4?yE6C?byw?DXNPC$%JL@fOH0#yS(X0h)FJKptpeil5MbrJc# zgXhzLhKcAmlrdk+FuURZPe2;{)qsO=pFln}fVl{F98iihihr%Je2DZC@&4U}(X4*J z5~T4LJZ*r8l^7F$@BG&;JbS$(Sy$s^y~A-bhSAD;ee`5R44%Dk?DoYm4Cc_A45R4v z`2yfqjb|48{tWl6fB1X|>$~}nZ;@YTMt0U*0OAcJPLLU65pL~b_%#5APzGc8Xx5Kt zr}qIqA2L80-p9AP%4Y=9W=m4GzB9Ka0tWdVK-_y}=MP}u<|0X=}Brxlh% zEtnTiDlEaMKf6|8c@1Hm0DJXyDo5}J;VEYJBfJ8OwR0)LAZ?MFVKzUQo#lbM3&8$5 z8)X~!Mc}Cg{2t$?;xzK)EZ3 z>7A*zWWMZitKEx;&1yB2ltS6hrPq0aeoOR#~` zVybU_z*v~Ke7@@40F=AB+vnSWr=9A7u+{jsD+lW*pt{TFdmHVrA8-ip(LS}sGbcN1 z3m^}$4zL8U8{dleATPLCKu>;l)>XhqfV{WV76+c|01<#H_qgi;zGXmg1-uv<119%(o1fUhL0k8|-3h&CZ&PXE2}_-%j9t z2rvk!1b6^;KpwydPyjqLF>V2afOUX8Km@=(1O9*>KpsE=7@UqLzz)a*4DCWce=1^b zq0M$3#h5}H%$SQZ0JMNmfC69!RR0Y#`c0qj1lnwf+7xhx+AmUR*%gI-_Y`9ZHD1k{ zzg%G{K=>B{&jT3U)vR^!Yd}A3kzUP;Ko}Czl1q73y_G(u1Gc_e`-=2N) z{Ql^_e9~8TRFp8YV&fafzaKxp&++tpQ}ET#PaXU#($vmF{{bq#QdT|xw~F^x|M=vz z%%2{1|G8*lVMC?tl{e1T#zh_<{_UCnk@W?9{Okka7gkQmSTylwYGAKV9adbq^DjRy zd+p)dfBy_=Ie#OpX^i5?{vUt6^XP_qZfzU?;eknyovit)Gwz9p?s@z`_Q*5we+pUr z>U+mhl3U73M2ohbx%{iJilnrP`uw+FoNyoe>7=}OW@(X5%%?I->p7X_S->{bS6i5! z^&MatVAszI%P`t&3;e6_E-6C);Qdc# zoc{-7c>-;!2u9lhrU23bX23K61CRnjffH;rikKMec^(z|X*}5Sd2)8v1H6bI#&JJk z`jy02E-N*Z{^VONXSs_r*U8u78pbeDzjGz=(*zFm?lE9u8C%%^Q~Z>SLQ{NdMyV;@ zl+4c+XRJbW_cKfk+ur8to3ghHv?YA_fBOh`%iaKYUZJ_vxT>f$cy%^umet4@PVwO6 zlARHxK)Ce`>{!GE!Lw#?7{-U{v&8jh1K1V>tP{aNIwTgN7vNs3nj%Jc*U#gE> zO*n72vx?osPG)2AwRPj3W^vu4qV2!_{oD_3HjCxI6y!Z84%Mb#ePo(f80C;Znl z+aZAYFf;cy-g57!H~Fuax%qe-C}*~3E}I**GjiFSnwX9C?NURsXmXzaVuu4o} zDU<^`IsZbz25$SVIoD8z7G$^{j=C&3p19d8@P4yIY~BS`u9(3sWObRM%@Z$7M2$Z5 z&0W7ev*rII>|NlRy0ZQ8DQfg&xVWw6s%v9R6)>kWHKv47lt`pkn zZ|PDyS7h;x$~{Ta zWZNrpIPUHm!Nn=3v*pnGsKy=V?V;=|?B%0JE46j0;E?jaFfy*KBI1vL_~f;#DyB1? z{~cIT99{K$+4GIcc;LzYy?lEeHjNS{>^RRZW8X{EQp%^XwlX7Oc|SZR6d;+hgXI6MgKYMD1r0lY)o4yQ#@JKiPVPiDo;eNhJSQndT>Ixgd|Y zqb*drdEiSSHs5E$?fKj#j_9hmX-oYHFZ^FPk`uMIAP)P{R>F}86swr>xf1^>+=_## zeI{$AByQW$87s;k4!k(wf9LNwmmEP%bw`f?Q#n>0{|Y{u%cJAKA8PpMQG!?f&(dzG zD@7IJzW0DPltpm|gBb*l{>kOdL>hNeU}gyHPyM7JrIcy@M@vErSZWWgORfB;vSP0P zY103t4Wa=e@mflZ!bPEE*cA%q?c)OKs;itdgjEGQJm}%Sx`XP=KNnpxnxL+Bv^cl9 zyqc~!tTAV*1n(_C7_{+trmN`o%ySa{gIj?A*Y)$H*lM$&V=UI|{is*HqeD&=Yk4Pu zy7|9OyNs<5rhYDf3eB2L71|jmiQDjT+%~3s$G>WgC~lvau5-tDb^kB@qU6V3(fM@X zU)FMUu$EEY&TWDJgVKY+{C*qwNj3g2bpguW3)bR4ngMV_4dDMb-Kk(YqU7HK|6JuE zdy*K=negMdgM4oN2v2&95{L8UHdQpcSKyzZ`t7WMp2w>4P8gHR`rr<`fnAmbebR04 z#rO)_&t}0!SbEkdPAg_hrIkoE9cNHNo+~K@H@csyQvkZ|fQ5(p43^9wP6iwbyLgFugY1 zn|sG$jX0+Smiag zYju&u99nFiU^9zb$)AmZe)V3(7l*?Sw`B02jFtY!6pdY4dQ5Zrn1t^hD?Jt_J~b^L z-}YQ{Xv1vUaF-cp%aJzoZ!i5L-Wp!l%=;g$5{k__|f3SZ& zpbZ<;hU;z+@6DvD4`xg2 z*+Qs6)oE_c)l}}fd;xa#uWAn@K403Nx$eVVZ6W<6!DV3y2#@we zPn*hn|Cn(97@tO8N^y&E#?-}hDNU)rI*Ju_rUCj8z}AIp%u zV+ikw8fy_0-l#EQ${0U|^8CaWuAMqo#nm$Qh#F*L%b3$Tu_qE>N^jU0EU!XSLK{A| zH!UfK4;zb5pV%Ty?74f)J7FyAY5;{vfEEeb6HGkHd^kpvFLW;4Suz;+0Ze&HrYwZo z1lJMJWQNs-;Pw$ShMS~wToWd8CH7KI1J}Fnxqe$&Q~AfzGHq3PH`M>wiIw4XG~)0h zqae;6-V!P~+^BIGNpNEAnuPY{+#=12_F8RHU0n#yuwMBZMj7dN1^prh@>IgMo)v%9MFJ?TwtJL~VoB0VU|^^SMspx`pSs}a zxLs3Kc51_c2+R;<9GFqw5%W?POW+-E+;KVh$bUe{`4i&v*eDJ!lQz7l@+kRs3VHtM z1ROLg`SpCfPkfYr6bV3Tq45`Q~WSMu5CcpLDcheq*$4Qc@u(nyXB07)?sU zTaGN%6MS1OOMM-09@2i`8-r+5_l+jSc+eYp_YX+ysW_nthhA|YWL9v(9rxn!Shw4?EESl7DL#A-LIjHYDK*BVFu1VT4ig% zPLmkypy25L=$G;QXvcm@m952l&$aENNr8q?p?oxW&`?D%aZyuSoX_;wM!mLC!8Xbl zP#|=%{s!rQD~udM9_cj9Bq&JB`rjyad<=!UpP!q?Z>J!eAS+B_!K0v3=LI-xo(aWF zal9Uf+wv2*dxCHZbW>DQ5A8cC^9CYiPn?cOew=1>4Dxn}|Z%nL@>*)>5J zQKWEBhNCUFMEaNtCu(yjON?eIqx}jYWt0!x#J4>*@BY!{tHKgy)nvaY3^Yc< zS|j4VM>RsCLF^rL2@|2Q#p^6)rs?x%!$T4@dt9~td|w_ax^9le}pKhhF$T*$uK((24w}rAcA6O8G$oKbTZ6 zx5qqaUlqCB-o9F1@&T%;a&kuRO(Tw|rNF9_NgD|J5ADa*k)X3mNgXvvyYHJNwiroi zO{RJ^uJUPWe%GGSceByH*Y1dwRvd}qKcZYvZ)a}eAW1lLlix`Jlsfqo?9bpll1MM5 zzyz7*RE9?LRy8x@RKnzir@}vMQSg7J812V5Tje->K1Cm76m*RLBfSD|$IX65IB}Ez zEq#-{U*esjTfsiaMy;lS*(daFW|qOoTonKMc$&9Ci$XYhlYfc=h?az0zhMUdhw=EP zo5CM&^6Tk~6Xdv>E|K&)VuM+$0UW(vrZuZOTH|?yx>6j2&{Vgdb zVO0DbMNvETgm#l}MUjO{-19+Mi};(Xm^F`Xn7aM}1z$#hE+$rej!jlLSF|r&QLv;D zTFbIV7hgef(2lEm854%h5DV56c5PbX=nie0uz~S}Y-$|ew1Dq*I6lzUrlOQ#$hcj0A9B`e~)EvXO^FYsWXm(# zpA0R1DNG%z*uboT0ok?*KgrccHuA+>H}Ug-%yr34Vb#s@^MS`yxVGwXQJPAcNUaY^ z?b@mprJ3y;n5x2!keJs0j)C`tZuy!&ikC3?C4|EuQUds_UXn@mPMGhfzG{7M$uit@ zjLp;ER2RsXSd!T`CG3i8lig&Zy87V00o2BGVi3D!aX%A-M#SE#OIOiH~3XsD{YoD%*PVX&v+KFtAmm-#e^{X$y5-23H-;2+`ZgE<&w z>P$&`q<@i`KNW=Wem)|6H^TppzR-*Ls^Tj$kNEy*Q$OLX!IJ$lMw9 zc#t0VrCu1By9Bi8_5_sFr=`-=`jO;Q*uYFCc_4u*kuy(anKp(q(=f$9O?buSx%&^O zTr_&@euQyBRlp4tnf*4DR<`y0b;SGE5#g^R{8JRTquJ$^``&~4aQ;c9UW+uV7N)iP z)Y8q&mnfo)Jy2MtFCGUrr%;8%994K|2-Cbj6&>YxpNA7(Ol=hKze$9aMGtMdTmW>E6 zkMJTmpFJM`;>c}gK#+P%Mue9}_(>F64!y0C4IlI&fgPEMM*C8JUZevjR>nopq>uc zHhDVd;^bZ0A7CT?0sCD~&WJaAM0j`v`osyTuSYP-KL$FzH11$zpgDg(_S>YqBADb1 z<9%>M$Qt3hMRLvs7qL)R>Cnn>e^6LB;`=(#i3Jb!#Ro>(m}J%#6RD4xQW=rV*ke44 zsGM{Grv4=VJ)uCI%^JBa8*uI^8Sv^x1pNsA4u#Y7Hk+3uL(2+Iqj)!k^&RKG98Z&o zdOm=9o;D)HkMNDb6eS-#n7KSNEN|+FZAxrk0yiaL2FI-s<;fEye&GI4;k0Fm+9qjY zDx4#kE2TLZ22l?iTN*P}Uq@;Aj|mf6eNXs^W)RJx)hihh!bbR=6iyn~HeF<{(O``P zx=tGEINcPIf9kC6~GKbo#tB0C^00j_ELyO0U>T z)fW}P;-G=NLxpr!fRXjX!dt`q{gkdd9}?iv;j4)d`T zo;sgOW6;jPGhK#VVP%w*Uq~@18FrRC?#y4LnO8Pb1~(pGM@;9JP*fXKrUzUxJ$r|} zrNhGBVG@vFNtGC&;TeKpWtfy?(9NA|7LTg<+kS*v7YD2l2cRYBONyg-TgsPPiWdbSM@2**-lG~Qnh z3l+os7Zj~rERj&oX9Ij#5OA)P5S8_qhrNr31q-nYm!NzZfdkk=?+I-A5CeEJs5iim z^L3)<{Q$Zb=$XcyR>}DQVQo_dvFz$uKI}~!7V-!Ssgc6g)jhobry#HPWDk3jhJ{=L zDeno)D=cpX9Q+uxc)=w1gYb{ddiV}uRh!E9CrDSIHme336@)|^J=~_cqU>f~RG#65 z2Z#ALD3t$OFoO|(KzO~I>6tw&%pTq-&aeB#_|w7o+wz_?u;k+{@fxpcSV$dyL4?^W z!gPaYWGU1M;N*}$}(EOr`_!xpxTVvslf_Qg$96>m!@|B$S z`uxJ(!~Am;$~TP1-SpGEMUtJfA)Ld_6wK%N^L;Wm^tJ5xNN4g zBd}SHo0MoZz8#XjD*l;Z0&k|`-L7qf4`XK3zXlvzts0e0BgjrqoVk=8HKkO-bdq z?_dYB!<&qG7k&5zD!cCIuw~fV?8$T21zx09R{m0eUqVRQR2O#M4mrbndB5QH^AQwQ z^6j3fjIt!aFA73XTWjoj&F|gj7k=*#Hl$FB>MRKOUSM(@^Mp9RtPgq&skzlRh^!ojLf- z$ITuCA}~f`4&BTDEQryY?}xna@bljgDjKdN=@Iq&Feiy{QT&u39HjK6excG2&EQnK z-<~S2<=p;UHndF^y|f{+GNv-Ea*@YiCvt%ac_i`5AJZ=I3nhNi>b5zhHjzx*^Hn=6{c^3+zr}44h0YzXsCeK(F5PJ%5#G7FQ5DzhLz%|BN!1 zA43r!L+}>=lQ*BNf^Rd&q4ym|spC+S!^k)e{mo&NmE3d~LmjwFGr?h0IgAq>Muo$u zbQnWiMmcEN`KaGs%rTm}P_<3sNSN&SXrW^R?ah&BA>v-HK4=vejIWJgLj{Asv7y=EKk%Tg=J2eF*M4T_gkSg@J=$OR59rku zMz~x+i>_%@n3cOG_b8G7n_h)q zxN`&f@Uw}8Cgx-Bj%EmDHU{1dGXghknY3GoqXb*#bn%7Z7?~-Pnp@g9@$UX$@>&f`Hatq7(O!8q>F370X3Gsc>XI+HQZWQ=#72!bVwU?f6k_}om%(>hL4wKobpUua?H75tIKG# z7;~KKs5x1TxnO~aXepPuR(x5;Wj-dpJZwu;bD6&u-+#$v{-!ixt1WXmmzh_pdA8L4 ztS$2qMA;Ija+xME>QOFp#Sc*{#i(gqX1*A;ipyLbjN&ra*fM{G*p)seDC6-Y7(BsG{YHb!v_Ds@gLS-`S*u# zIhS+(P7Fl4Y6sspM6*cUwEyQUqfK^du;;U(wL83@4GEtO@sc5oHT92pm~J_rPw)1K zTzdmk86hI#XHpQhg}aA@KM(Q#>-d5-Q{ZQ(v!OU@#zfM$Dy6clFSO^wp%!P6w{uAF z4)K==uv(TVV+}zn#W|*pp;LiB0j|aS{*dtQ5Px2TD;C}x;{Q&stuo>05Z_0y!jU2V z9K8g~sC@?(efgtA&d#@n;L>HHo%UTtEAO{9@P~(5L)G_5MKd_=wpo1B5IY@nGcw0N zzL1PRC_X(y;>ir(NMAtNK)>U89H2Y|3fSo$vg3ka<%G){cJ3Pz_74G#eFCm)Nq-@Q?-?Z?-dG3-92Q3s{Eq5y&%}V6TVnHo8A1 z*rP0`4U2>Onps#Y=34DH>Q(30;ZC!;=Ql&m>xTFOqOF^0@n#~LKS^ZJ(s~KKaMB#Q zx|-O`NsWmDC(TaW$w{*kc}}`Gv51pqCYm|vFR=k3eK;|LlP*h4<)llY5+_~KY#6#S zF)^Hz=3dJh@@5Yu(mG1_>Ftui&buJzNSYT784hd##mhgS+lz)Ey;Qb?oA_jr75X1o zF?+~6cN{^_ZO?h#$32}#NKnvg!?Gi<(m@7JsL-x?iNK-wld;R>ex_#`6e(_PPtWT; zu%%m$ESlqobh`-u8h)~fKT5=ZEq2Ho3+!Tp)j!@%gA}0OtC3os982>x4g}+H9L!F7<^{(aQ%_uH5v@lA64MQt@ z-r|(|>$URwx%~0#_E2~Y zsA+6RR)~hZAF;#zNrZjxx=k**d;xMNJIy9`PC)TTD6UBcVbRtlwTW5`6kJWg=wTAL zw-Qs^t{dbM^;@?x{{4EZT>aoJR%I=k1uHI~&nJ_#bGU$9%5hSWXoR_O2t6JaEC|A^ z((y2;f244LrV~YkYxiCk^*wCbyP(PiJub2nU8yCjNcNt}aJ#pXx`_2Ru|&^ov&5!l ze*z~ZIEpWb9Y5DiX&=(GpkgKNzeoQ-kH)#x8b%0+HYHy0tGg=}CZ*NerM4tx{WM3y zl=>W7f;z|p`zBk$6j9F@XkW2PE#reZu1Fx)=Jg7WScmMRSK^E2ldWyC zh3!M^ytsR0xR06ZGl?uGo@>*uqb%1_ubaREJuXEzQs&(x1>QT-q5mK(Z zr^m9>oC#&%_IO>mPsZSGtMyn6_9AdrrR<|)WZkKl>PFqsm^Kyc%F+ns`|lW-{yKWc z+8x6kfewl)yev>xrki|aSXG*71QF4LYGweB4c%bj#;Mi{W<0_m9IU3Tu?`3FkCFm_dkh;SR9;<63}`Nl8pHt&PFP9RNY)>e z8(N&}Fe>nusS~E&8sr3NgG}8Rq8%MfRM8`f9&5BW2NRR%F^?X(^w^{w8BE+uk4AbN zrN>F_@L=LOdTggh6+JF${ey|4xRAdoOnYOH+Z3)H!gGrFoJyq6#HD24Fz7!u4=-QD z1nbH-)Tg`N=kAZeAW21_aXl_lRR|lsnG&=*Mxx7%t*-iqMQGh2VvjRsv&y3VjEpZa{B^h_0)OA4G=mMZ_pbzZ1IE?z5l=tDFwrjA7>7o-m-E29W=5A8p z7BD#N`?8M{5yNM(#QgqXhx1XGLj1RDpdk9eb@2aPAj{u-9rfElr1@^dTu4NvYJVdrAH)iqV1f{_t9vy z?m;~V7BRTjoVsrigs3-R1#SJjZ?z2MQz=gq@llt_{2xBuW%B%sL#z&)Kv25i&k=Q) zf-ocn4;Q1X=VhILqcKlgJ&5JJdOKmT;ed_N?hpeSiFdkBgJHtUgH#riO#5$Q5kz3y&mlQUKCjkY-d3hmE{ z^w|)xOhx_#kbuAPJ;lpALnLcq9e|fn`~p>95|nAarYK0WUx^^t5`?_sD~c&hlChJ; zeG2;C5>q%&#>W2m{d7!Wu8f`alW(J}a558@Tg1l3%~KTSZZtSUbJEzToS2+4q>Iei z2QqZcG0t4M^9}iuw_S^tA;1#>*En4Y!y;O~5}gUuc9w#~4G~5~PA3JOp`c}|2LENa zt)hUVBEd?&m4356gmG+Sv>!3$Uk0p2#1vgo44*Ex!jqad1ytJj2>S9w7=&cQrk?}U z7bOV;}!F!jLe)MzW!EBYv21 zfd_kC(^y5qz`|8!?8eQZ)wUhQ^`F`!suRdYP&X{vLoKOJ9LB`@u1$qyr7c_Ax8#@M z?g8|XGb9I14z7qD*^_?n7Y*Tj!!_8hQZwOp&k_Ib zJ7;%qA4{G%cp}zYaxMD)UT+eBj)+56-dX3*Qy06j(ae9=;lGI&K{J zvvK5+i(f)PUWtLUdD%5MGDND|pPVxgvmpBJ*RHl-4PSD# zIpbO|d6U9@4f@9=gHGE%+7Cv(;yC^>M#trJ6&&sh@9j38`%A{`Yi;rikN9e1z#5Y- zy#D3ryV~R(F$>u3ul2#y&XWFff6?uUDY@>9*++A!AmJ6?7dtH|rg+zGV{?gr<+~e- zPvk|jy>dlcNNImK%vM?C%1;W3FZ?VbGa84OO(A^GcX)q6y6%(MZsxY)io7L8Ep})3 z;{KVc5`W)3_)rQVqgM}6B3A9Rl&xF0E}F&h2~u+unCJU$CdqZiW;{p}M8RJqtigHS z(2>HvZ0G6|Xwt!Qhfk(7)!RSjvK`-${KQ6(rmxu72v)(C13I+&IMj{E? zflA!HM$f=^x^m9sDSR`biJthe;Qr+7>Fu{fCA%PPX6zw-kA*)Jb8Kd5qV@;Eghk`I4OUcfAjkaSu7eMaRLhFFRJ>sQ(7%~D}#FPE;x>m@a079i*h~p zl&#}Q^u&&(SCnU9VYIh}O2-_HqUaEgciiVAaCwsbSCj8H>^hwhTm!abd_Oxop8b~^0%KwsWbkY+2Gg@o4T0M=G=cf0sw z?FwL|>R9S~>nvogycnFd1qf{1KOnCkosUap*Ee{x+SkxH`^lWSsV-OL51|<|w z5*8X_jWq1VEIX3Pdf=TbLt1ArR;VCP9|1!XQWVe`0KtH9Qhr;MN9CuYr0r ze~?nTxG8)iy@&^v#M;#{l;9hxEGCEEb>9Sjur?}=6)nOS$mLzig|>hW&SRf!lkI5a zZxfp4f$!MRXjKklh?R6rG+3n1Rmm&XhR(195vm8f7~iqezc7YFk6ThFsQY=1S4rJX5 zKzAl_r`CBDp$R19k?M>#e&e+lgw^LQ+StBcHr*8SwDyDVZ07j*m&4cu6I}kqJO%v| z&8l4{b387#M7*M6s~y_7_7h<>Hrn!iOzF_ZS9)z9-N9W%REyoKNeD~qVVT;V`04j* zw_er6g`IX_tFq+f5fk;1*=D9GmES~#c~RA6R&Up_ z_xC#Q)b6zNrKLljBd%;nKdx+=m|b1B;7qjiC`@N&IN#8;#B6r06vIEhB%X)?{6L1Asu3PuQ31IU)mmZbQ`0KAJ}g*=Bg0=26qjwbWYff4@i+{`FIKxkxexO&6edcqgfv3o zu~!@45%Y9QOa?%#f9`W(DmPH=>_{UWP3S$dPx3j0$i9<(vZZV7Nf*DEK6j>n*A{Zg z)x|qZ#8)cxFDSeX7Z%f-FCA}hAM(8i>*V~WR}k8Xy{@idyH~w!7`}`z5@18osZC8Z znPo*vT7_;U3@QJd!r?>uP=9UFrj1+{Vdb1BlFGOb3pVtq^W_~Mj7+q)ckVk=bcJa@ zu8p49M3txe#aVT3C}e@7lojx_(y#W5D~QvJ%2u%C4?jis;>s??oQbr2mDMzC$QWkvK;yIuTqXmXHY-XEV&)huehRAFH&JLNo|vn|2DS5etyf=uuAPuHs^fD z$;m?PAMMAtRPHLAHp#(-RlneHKHee>Z8@^5dQaiBkczM5>|{g17VNjAe5v7tuA~^@ z)2q%mT+Y`s4p)Uc?d>@4YqZ1MZ|RQq!>!>kzWBwl@Tz0p6IVYuM$O8-&4^MQr&4sp z;M!kv=T+UdA()cecLCuiD4ga*L6Z6(S1>J#+tzkkXH_<1ip2cLs>bKWZIuRl%M82A zVrl5DZ25ZC^RK9Gs~p_s!o(F0hzBiKODn?;Ov|wy>bGU6qm2XJ=pDVu6Kxq&*%4dD zblahWwu~s&Xv-kIINo`+v@VRDRx`4zi|IJE)|El0?c#h+G#~1V-cWUoope?^(6O|` zX>ViBLS6_uI=D!y1bZ_c39@)d*&B)VG}huQB;&Cw#VCUsk?AyA=y8iS1R@RG;^?!* z2|jYJDZOJxn%=^`{sdI30dNwkuc+d}Gh99m}&i~hRw@v=>gXU#W@kQx6JF>|x(jCrIeJIZoJ*5T#0 zS+{JN-2CL#;tbA^L9+|~eV1|W*2GxXz?YV^gEOvcY#(rXzVMYoOq;8{t?c#2rdv(> z+UJJr_m#FkP}VJL2o~LXF2I2X4k8$T^D^?v&;}cSB(Rbjwds5uF{5>b3))XJ&+a~V zTklPV6$K1=RL9`Ylq=)RfmDM;#2*s*G^`RF5+y!%-&{j_%Z#**y>nZLMZbXA?u|P) z3Sd)j%#`97b)ou!^vJ0NYW*bXeQD3{yKBC}GD)&>w{l*;10gT4iUkS_a9ZUX&6^dg z^HvjUI?0SqVcY;1yJ1DXjQs~|S6OY|*wuNMyUmqF>_nHmK3cx8F)aEk=aRDR``?X} z?a0b)>@`zm@@nGmGS!(3^>!tUL0SA%qwdHL}&57R3O-sqQDq&bPjeAi{- zlU8JLXndrW@222&aI54^4$`HYdbU?9-#w3*orV1xu`S{pRfh4mO{TXjhAY_}8Y47HZ(k4i2g z+*ubk9vW)1WdA?QaG00oHe;)(a5`hNm~BoH_-qyXv6}45FA}aDHWR%8L)(S!gJQ46<%j*z)CD>u&(gbm%s;or6&EkG~J(KW+36-(ea#Au zNz9bhtu|B^16}zl&`HzF|G>$r_E<1p* z5G&K!9$3Y!QkZo5{8zurY7f{Q)wMX>dMJQhCNyRfXHhGlf39(LEDbKoef~4{)asLH z$u5VC>MW}(AZch38K7$nbV3VTB3Wf9TtPnQ{MHu=b+p0if8b|vQ<(BU2T+KA&HHvol~LZZD=Y1!Ox9C@RlVw0zvu*IsD|)*_diwkt0MELp~FT63U%Wa73$Sn zDAeLxu#4WpVV!5QWZ8Gr*P{0+X$*BMj8_q^qUP<-z%119T9Haq+?09sZ@;q`KCuGql;x|&TX>zRt^-N4 zEF#?zr#>}tn^E^!zYdt7r2YU<_;v+4*92+@-fMs)aBFq#;H~K{F8tuhe=t?I+GOG) z>ISN<_J~#K6IZNGr;3eO-2v~TB4I>N$LlNZ|0kY2Ag24LRMb?dEDxb9z1W~f`<#t& zE7Vr7g`L(UtNaUAr@&|IDYDowQy2a&PE(Ilr22G zHUB|HN=sS(=W@qiXsyjwIgMcp7jCV8%O%9lXB+qN%Pyf=Chih9fpqVVpD#h)2ud4J zIVd2B)B1KGE@~jBAhC=ng|CoN(IX({vKM7)gMwAEk*_EnPRo|EvXw`q3zRA8nv|5S zxAtv_V544#BBP%hXPTu+4d=V)qS;Tv1vWZj&q5ZP0x7_1ApK<ge3@CeB|;-7MTG~)cYtlhcw)F#K-k(xKk>NmOKpI%oJ+ibb~E*z7s zkTgF*FNJe|SDUAvFvStSseWUN@O!rU=)14&M>+Wyi8o(EKe#Vo&nTS6blM8s!6<;&ue+@X+hdbgIt1Dw2pGh4P>ep{%l>dp9@xgut^6Zu$eV3Sc z;!eLRf72j)ukee@m^qn&_2Fl=--aVfcRJf4bnV^KG$FVvJeLLwx{YZ4{Q4PfZC>yC zEf>PoH}AE@hpSgzbrF#R>o>jjmh(U<5jvj;we1f*qaDynE6SPjb&`SgmfrP?UGY|m z|L=>4r$eE5!s}XZRjV#SLu1^uRx+htmnb)_FEo87J**G69s2va0y(FsPi9NGuoO!hEZ z;e2gZ{@l-Nr#cd6VM#Z( zG7A$YHGg%<5&!gI2*@*P-q_U=|AhMUv5xpBeWRbNNB@qAHc0P^04hHJ+rMQ!0^fEk z<6!tIFHkOOuJk=3bY6lq+2NGvTC*`vV9VgEJ6U@JKjiGTO7a_TL*FI;5g%?bf+iRM z*hqDyoFIY!rPlxEgxI9`Cg!!bbWL~CGhD)>jR)ql`On;LWB5N29QJe>>l=5nUKY=% z*pc}3H2%LX*+b)3-S-6-&cAjkDZ#_kUt}5EIVrbk3mZ{#Ht`%6T3^G3aiT;6F1nu& zkO+eD!xzfIcaY0T!^=->oQRfHjh4yKuE?^XQ#VK{IG!rUE z;F*wtbCyeYz}Pq?3fsWI10FQI72R@AA>}nsqHeqe%QxZ}2f>AN!bq~h_YKJ|pur3s zg%ryoNwHIEal$FEWs$6S5kobur__qRQ%Q8O-3d*|MRI594aaenZIQxh&#*00x)#v_ z7Y?OcI2e_}tM$$7FRH?8Q>*IKRkxh>t+quIT#IN8YmX=`mFI^>-q)}>)E>dcSA;;> zZbqRkSAsQCu9VhDxlC~`Ex(pDZi{$+{R}o_Mqx3chl_Ki#fJ_S=Q8Zde*jc`!;~+? zHG}^FvmP)@8rcwbT5*n)4Y7#YbQ>J&D#}XN4Ha_})hkg_c1xkAm|05i`z7N0GIfQd zXwfCS!?Q{;R-*?B;~8ZVU`r`%TZSc9a^EJ90s;@Nu$-Kn`^@@xnaRvKZFFWyzKl zro^=yI_?-2Q6~8B6i{cD9ESBj(O}7uSq>evkf;D{*+TQtB zs$*W7aqe5hrpt{H_|co=_1@^IkHe^bhqIKn77>U$QMfwLm}Y>*udkS_B@)A-ev^}$ zfMF5kyjEEf%SN>tOp6#a2g4$H@gjw3QAqJ38dC7D`|9()izTCZ?cZXj4BtURVDM8+ zcfRII_~YNA^kRz%MTQ@;)OQw;uDy&rsvVnUb|3GwO;nr6uvjnT#bvP@B*yH1x0De5DNVpRqUEuSGL8H{pv z`V&Tzk+B$M#YP1}@t^g#Kz`26a&JJgOorR2w3f(_(3;;MSyHjaj>8wZ%^*!dKQS;V zD78VBf=*(P>kWk92~L!U`8O%AHC*-SCZ;BJ^Gy) zjVt*SJ8_@_(f?%NUEZf1ZBEODof1tB2}J;*v|&?+u+Z`8%6js;<4m$))$T z9DVEEx0&);lKG((d(}Ddh`G;hTQ3uTt1_v?R|D(!ey8pjd&iRBa)N=^?v|jHf+N}d zw2Q5&6?>yya%EwZ+9eO^Q{{w+DJ!Q^Nyu@U#C1Zk@@TZp85!TIMq=OF**tp@_OhOM zsWO|;!!X=C#sBF#!C2qQ)&{;CYOv9c1*c~^W@vgeg(_^;63d>nV;CS zX>l@`s@(IksSo8!^`c36psWEZ>US+pTHw3Wvws>h+Z*cIzd>Wugf*7pDyDw_I*;o2 zAyH{9`{#GKBt^fwkWSrwTS!AHyiaJ4(&+Potk^rhEu=Ro99=|O7mgsSz}Tg=;itDt zO9t_2l%d;6^cZ$htkGP~9j znmjQvkL956n-reI8}>gQm6_O_*pr|gNlo0DAb-J}cpm2Ryuj|O`D~_&7TDrAAcQ*h zFL&)v4y!BOQlW7qN*arh6|OW21s6o=tyU$j&FVrNTtshTc%_+8yvo?kr{Q&IaOWB# z*bq;nmOfE3XYwNc*F-mfU9RNDJR)0xWKE%N2ebR!EmsHDd=a$&mP|w9Kti~^=-cp0 z?P{8ca19!Vvf=JZtMp$mq9mUEvXmF_6B`{Yl^i5(}0Wxl`qQ<|*j17sgX z205RJpa0L`wo=Fh@qyktJt-nq}Z= zzSAz%3++*9E^Qe1rUMZ=NA_c?q!@g&N?j9f)-O{vXMBr&(h9-3opK>7`P1GS+k*R-MXy}+EZC6 z8_LwDaZ@HwN5Zya9Lq>!YDI@!9jTj4Q-HM{rTXj62hLX&bkS|RQPbZJ{teVdux zQA2P?qHR;SpJH~exlZGQ`#hWv>Pnfe)Xix5EinIB#O!Xo)~z@rG|5_1-AHs2iY*>N z_xAuA0Pwdlx@VBzcd(D6z=k6Lj$S?EMPrjSg;Iqnv-{$uR&o!Cq(p@35=av~&AP`?o{L1ZN9)6T)wiIahbXX8`U{|-2U_4Q&bRvI z9`DlRsAKN6%nXn9WOq3I03Prb_sgSBMICcGx4M6m zjVZ}n>@zV9*zWx@=;XHtrD{cSJsG}VgZ6Y}o7%**W)KmhbAp_+u3>i1n-k=m4!LZf z-4@cN1$S4W><@rw-lKJ|2K+3L$y-l}jB4HKF}v4+_B5a@LP;{*zX31x04cJb41!$q z6uDOdU+Q?SMb^hqcPZobWdc1vAFr=RP_~~3bG7E7?(Rpf=RH~-VD`=g%JaGp!0#qS zYqM_!vwKrAe9g>2<)BE7_npyF<#>4Ca^#;xm2T|_fZ}!NX5hA&ak?~Ygr+gYx-@&H z3S9H>;~dRG`QnMi=f?vU1792zmxJ7GVt(XrVjjAKJ7=83^O?=6nW`66>b>eVx%C)T znRd^z!dhd#D&qKap2Ilix-iJCr_+#szKi(tU6Kq zRMkxi_k04+GGOcCt%ddiFq?a;^r~R`7}cN^vsK(Lp|@V)@EXkZvn$n zGROnq&wb#-Qu^#nk@Sa%?N{l3NQ_U$&T`EawV~W!$_s!03$P0D`BI-dVKFLJ2;A;rBXR$;f?w=!k z-al~5toI`zF(J<-q(?Od8J>HwUk;7#uv4XB$4cG$>=q#%<7Ct^9aSYe5tW(9uroY6 z-HFgXVrS?Q8F+Y_;ZDFdBnDxRIV#N;PXK~d{PF6d-W$+Dyv!Pn;u0yl&>Cshx<`CLlhqgu4wK!jDY|!oaEVb-w@W!AeA(Z5SNxc1QOb0a9 zmhZpw!+b<)5)e-KlN9&FqwJHfM@@sGm7G5$0wWfp!M>W96CQf=f9bghLrnN5FNH(l%JU^Ft1LQe{+3#_Z!$v;$BMa#x!>dn2;g(R z0rnSFx=9Q>eaqakl+vv@Ju+X&%eQmR%Ch_fPVXv>O6zF)X;V`JXy7`?nX61b4+AqE=g^EBj`*Gtwq_Z-g?cP2e#V~nGZYz?p&{;cx z0zDOu)3J@xGilyU-vG+ja(Z{vR8|I&#F7v`NBv-%!j=$Go4&4GW9tf({;JfakW{TU zDJ09+v^Oc1r=?Vy>oQu*w8(O+vYlyR2(p`NGg|ZV&)iD+MJjeh+mt40iOXBE{E;wI zMhO5Zn_JR`o%x%TB{x_lq)C@{0s^fP2JcNuUqHJ1)4S|NP4cQa`F*mNXc~%uTx}W7wW!V_FSMkoYl#~a;Qs$*tq_bxf&gCs4uYk1kkEm1=WUr=o zO}Yq^B09wSOOul_GG)v`ya{!&qS1{v_jT!JmHMb3z&IUHcIYBKJCX5Ys@+F#Kz}-n zao@^%Ow}a!g)zRdEGMp%riG;}muLNVzyj76mNvf5PGM&Ru@btggzn_EZWR#XSVR|T zIuy{|k?ubCeYeuVMKmc}5+}RwNKc;|DStn(fvr(&ZL%c!zUwBe~Q1B;w!DcO)?Uk=?cD=b}X)-%C z?;QeLrP#<@TnnQL8(+tl$^4(JqD=vp0w!}fzIi*P^zi)W)E63dy0j$a@So9_eP`*) zGHi)82&Wc1SC*;YmXus)70al`v)+}o@)$}U{%rT&I}X=EG@6z-?~kfmntb#KT2H^y zl@MwDN(!km7}@crJgV;GM=joki1588-92y2=33bD<`BBQHmqNXbgkC&fWHa&mP50n z>M&-A!AE?5m+oFO0_Z4v$D7>At~R<0r}GBVdE23dRT;H{-aJL`{RK{zORg82)y0`~ zT)+HT4SJBnnP8I#DvnId_o*Gut_rvwa7r!+O^P#oS99x5z*)b@LWWUwgF@@To0EIr z40XN9MFlQ?d+sdt9%nVn15Bdt zlQX5>Jv3D5{_XVXlM4Nzz<~2GV#4gDVOL8#-fRK&cQq;cmA*4s4q@S5-WIwC2(U%B zWn0RYXTy@Axf<1ocb=nK*)97vrF0#Rb5ICW1Fn3EWdr`su0TpkY<(msfg+G|Fy$1c z#g>F{>*-)|KrgBT5l4?iH9}>NmBGzf5fbucgxFp*CyBuY4*;}bOLwa#v#9ZmJM}b?+`{eUFcV$T&FNR zAhgAK7(DJ)V)AX|ld1R?aBYU?wEJ5rvW+^1PuKrP*xSHGRptNVcOHS^W;{6}P%(oS z5p@GJ1=3w+L`2jL*c_3~{Q`LrO?TDYG@^~c!U1XXp*3*rx5L=2fh2Y8Vba)jMkC~* zRVpHa7OmTwQJI;d0`q@=?l9Q?+u!&1dhr_0Irp5;`JAWEIp_0qVuOTlWO{u_Lw8A6 z*_DE%?NV#82{krg6GF!c`j0VD9actoL&gT;mW%l*zAi&Asf5>M_TA>AUjQ=JM7?1D zq)CYt%pH>@e)I*38gcraH2N9tsaboNJYRo&Q8? z!hHu5X6bTmxM9tG<1SX6gt+0f0N*^YTgz>AM88ubcW-kAjp^7u z+Yv)N8SN6LHB=ZG>uYVUUy?pV`-2*5kjFF(w8c#1YNY>KVJD^~dRY4RxloN%`*u$H zw^yv9*fAdeBJCgWZ&U1`=%jzA1gFdvGg;jJi7h5Vpx;U6Tb~1sCTJaOWEI>KpClE9 z09`_h@YRM8Uhqk3zkqeYJq;i>^y7BfLtv)j%KkkS_VEhj;xNv$8*Na+WoQlBieM~c zss^755aIp>weX;(tAjuu@)aqR$~D26t!;I%ZHmO!*p*>h9qJ8phmbRTEd4p8)1tZS za+8946dyvg)i<44Nkw-ifp@by_^pFpqcOZh-}1S~7OxPuAk#!`^=Q(R5J!v}Q0>2Q ztR{6Zskl~qX?Ui}3o`@U4r;5PvCz+O;d6&xnUXVKZBIhp66NEG_*Yc;FZ>FBz>e=M zvgdMtkg-v$e?iUmhC)eyQenzttTo%UL*PTsmoJmAl26O2I9QHt@6bDq_N9STYk&7x z%5bCIaggEqy5S8y5}@dqF8p3*&tU_(-^-d*Y@oOSz1*bcH^}UTh^?39G=B~dMjc@x zQG}Kp#_Xb=-)7X?n5_=E8h3cV$fjMQU)8G86el5Wy0^U3}Ne3AZy5x7%f}@&QjuUWv~6mN15uml z6IC{TA6Lx`fJLh5iW2*Rs!7c~g7x$bt%W9~z{~dSA4C}ZbpBx+qrOIeISvg+So;0k z2btqe9GUo4GP=9jJ^XUnOijm4tXMhOk>z6!sSjclow#IypaE3;sIw4Zg-|Tpw{D~# zek-XGV7sq+7}hd0r@Kbg`+Ph>dEx5MSIO-h8XiY-7=0me)VS?3u+btcjg}`hZmZpS zUq3b+7TLTE|7T=V<9~w-2`+MH%xCDNVSb4Q<>iPKDTRwSHTe;Uf{gi^PTb6Ft~hfn zeT_Yp&SH+h5H`Aq4uQb;*R;hbc>D_lj>}{@2Jd(i4&L#e&6pQb;l*AreHj-$jJ`f0 zLVw}4%U!Qsyy7akbfLG4ok8R}PBe7yJAT8?aSvrQ=m0@5LW6&U#^x&F7tk&qAuOb! zgHAOR88Clqb*4OJLiKjjkYc94_VsJ%DkUWKIoOeZ#GO?QioOJAISLcEXfe33SHad% zK>TgzuqZGRjWiPCW(-41n+Hak4i1b$JI9x!+-OC8f*Th-OHU$BM(8|YldL=cawW9( zCAv{BD^yW)b7Se|kXtxCSFdy~wBLsapDcS)lV^1D_T{Wfn2V{kF>iZ=js&<1t$EwE ze^IjPW$O9rRcb67y3KK^-sO}cqo)S=6zWhvcmuZU5KETLROei}fNVzHbo@#^n~{cx zlP{oW^Nik+%v*S7_sYaG^~hjOq%McTg!&hF+hVcVPPBkeA0N;eW(SpwJtfcRhn>Wc zi1Z7Gw{#s|f5o-_(igoSdWU0%7}vdT-H&`c24NKjVLdlp8U${pjC`VGSJ4oFN)9O# zYWJGzl(eW$G=DagJs6e!nj=aj)jNuHQFD%8QDw%TgCTcCrJ;~)JWTEmDSK>bEPKooMRJbT=79Ox+6^|D# z-o5f%QQ-uRAT%oeIJdG`$YT!%bE$3*Rf$;3Ju6Pz8^`DGdwM}R0GqqqR1ry)k0N@pK# zM#j?E2AuyXya*HMl{fRqBmOc7`yJLaco>F2EVrJ@3ER7I`U_fKPCN)ftmu}e@kCmU zN^v3mQ)8{byWA|(HmEL9iElh{<|X=R)TL0bVKZ}+8ON7rigDHuDmAp+@C(@ibnSxhsl4fa}y5RepqnYwkuG3*YF`AZg4DH3leF4 z8s};`A1K?G73ehJtqqk>Il4^qtOgTm-DLs@xIUQcJ{KPvrB2xahTCxu^vEN5wamE* zb)W_rYLLoZ8l+k{RyLM36Mv`10JR%2N5FK>N?*%u80lkC@H(?^({~l7c>}$6tFpl z!^l;`jv3KzI$Z!i%B93+Zf~TPyp^|RXKH`~F{@dfrH-R6-`KeQdc(YPC?@0I<5Mb||N32xK^y6*6_UZ);>i6R z|9KDVzZuqpYbNi|a_E{u5^3;T7e1-E+!)OF_q?dF=(NYb-)fh`WE>UmDV|&sW14fd zWPz#p8XMLmPm*V+!GO&;)|ujbXxypE(U>kCh3cW&uz-GMb{t+C* zm@WmCU1zb+i`_jqpEjwuUS_5r%<1WTFRDOk`y%LM4{n0!FDq6auY@#~VT|AbX<9Eh zKg98p)5`WWj`Y*Dp;^&-8Yy_g8>fVX52B?c_mn%4h8H9DW!-9~T$E$F%(U)izXmZk zn%9AS7l4#>xlp45)XlepNR8JmoMmE_a`ib+%V`hFz)9$oQ0E0)P(EgSbvG#}?RBA_ zLEREI-ju7tN6fx;w=gyS&6qCTMCl44JHq04br^lxH6{4CliJqKpy~{28%D(Mb9v=T z25cpClrbhm`;wgdWFfrA7sX7jh=~}~HmL{0J24c{H8;zsdp9VdR_>Zn8HBD8KU@gi zExWcRE;&545yD6ehVV`gJSBob5b@O>OoD**eqYHe?`O1+k;XbV+hf_(>~FG9*r2q> zWQOjkU%KD?czxORs@<_I^2%C|gztsMp036ZX=l9H+rvglk{AB1kM^a0ffjYD&>2s2 zrOPo-8Gvw6Y-B9?Q8rD>VWvbbKTHew4o9(2h1EbcaQZD*v@C7Vee#ftIg4rhH9eN zh!Zw=(<_Ij5RcAYLc7%dCsFP{+`Z6@Q15Dh2$B^49S5z_Rk{F zgU8B-#&pUYA$JoGD`2z&nqS(qlI<1pY)Jb2F8bG)q=Qs6RIjn*!O;;73!8~s;s#TV zg_`hT8gaz&3FJ#Vstfv@7LX!eX&WVn8*^wh#MA_&=?U+t7?-vI*nG5&mF>8uq4uM&V zLZWBH8P=qTJb~PtXXK$zQTONw;ml72na10dXqYgEuz@ET329qgfN+r17|4E7`h05y zjQDQ`(6y0`)+1X`_U6b8oN~n*^x;eTAYB3!!UPX^taScJJ970!#b69xs=87QDh2a=@vN3#pMGh`|}3F78X}c0_(^3HUPn#~qO$EbXGR3xg2OpOZY@$A?K*5w`ei z6Px`C|1hwU$YCCB54nYtzMK-xPMoRASv!FExaa@p{c+@ z9nr!yynSKXKWx!Wf3(KG!M`N2CWBR564&}?%_`DG1Hr*-Y$`j=czU!bE~sGt-VFhJ z;(~V-S1funeBGk71t+7fEqZf8iLOTdxVj`!tHrY;Sz9+QIEj&c>m_;Rr?|DDYO#vJ zE*)_#Q(in#>#e24RuTnv%e)g!|XeL8MomV7%k4a!RU}DXr&d!2Cqv`d-UYXbMGp$dMcp! zQwtvfvYIagw{Cq|Q=yd4n6$MP%B$YIlT2UURiTTa8_nw(rViO?Ax`a z-A*E0XsoI2BP4-rn@-=UM=dZiSQnJDWdLfpmtPEi!7k7EwZ|44x)dIuBlz{`n=W() z!tmk{8w>`p3}DO(n#vYg_ocI){`gk?l6mxBNa?=xPkT$VA72YL6iN|8^Z?V)f}pcS zJN_j>N%5}1Ln(7G>*opj)3488dW0{+VL;1V?Z zQT%g?<3SHtl&z+q?X(Of%qIvdTk#}q>3f|@T*3)c7kvjH)<3H_Yf*({wuyB0^B((N zZN$6IWBeAVOn+AyOjtPd8ttd2a;=P8&$1!>3C5ZW(M18g_EZLVa;;CO{m1TJ) zZfcckTmTi^V7enjz6_3DxFaJKA@UsEz>07`aM~r`a1|h!aY03KI0#L6=e`-p&xQ`Q z=Gku)QfFLD|HiK$Z^aB1HY*#5G*IHZBdpvSv$siZwHDARvnwvZVU2E53^_m9Bo!0C zKruS-4i)xnq9-Y-lyig)vF7e=QsFTRoR4I(oJIeVzQoaK2+s-32w9XAF7e8|{yuO9 z3V#%z%o)^474acj|511R1nr@b3vvG1UytaqHy9N?t=bodhwR!1hV{@!R7|4@z?~Q| zW!;*7Yz7OCulXq|4hbXS935RioXD=YK<)e+SKuh~>{28QTYI zjYBYu!F?Q$%8hk45RYcjj#UR-`_RA|fJ=u*3Q1OUQ2-K5K zf<7qvUKPy1m|7iNdd0sJU)7qp$DfI>iPTs4Gv!qY<<&v>tyn^XkH3ubxLwUT&lK+e zOq#P}dn(;;x%+TmXp-~U=x;F+hjKQ8zXu)IFJC(2)uT;*oSx|}m6u&1)+QH??gohv z?qPCvcsr0lV5W!Vm}{u>UNy(Z1;QO6gVz;{lUl(|lxM?(*Pjcq0WmF(J+1$7U127pT{UG&U52~2I`{^j_*BK#4X)dNN1SL)`ubOvF+(C(s}FkXgbJk{0NHX z(lLs7K<(hM1QWrSsi}z&!x#E!0i_30I=QPj5J3zmu8ygxh792Aek70CTsW+(#Qvw8 zU~vLSwGg(DI#Af*qbryzk6^i(`>EU*{1vaO$8PMPvHTteGOAZ*4;rn^isN$Y!jo7i zSg%k<{D{f`z%C|h7S4}=D?ZPdvWBx>h71e%6+`wBHql1sC;C$;!$4!ad ztrFET>kYs+?bA)Kszs3nRDHrp|t~l#{}gk{Ad4}89cVk|Bw!^OyZSvH@TNV zg7^>SfJT3K6sf{u$^pu?i!Ku~`dG!7B1Wy3qWcv)T_k-7<9?+A&$M@#&ZS|PE^LT4 zb+{S2ePR4=G)KD*S9QvED;-@hg}R^xOdH^)y&&N{i=H>N6qQ+G6AFCa(D(6pe83#}lXZaaMKt+MTV&Z>w05DqpgS4|303<+p_6olE0yE~ z5zckh z&`jygBnC???O1ucP53SN3?tO>?r?r7qiwqh4{j2_%Z8`gK2FD%CG}z!2z6bF+V~PB zO+M5HkQF-2@dBxuHSppvM9q`tcG4;ENy*xj{{x!esaWDb?>#?$D)OG=;IBfWf9GgG zHh|7)_p|Vj)rz?q&e87_@x655^Ikg0p8{Xz&@4>#Z$An`5h&hAv3$Swqf1PkDp&&W zXD|$=EOptZC@IXPNU6##nd!O0#U#pIs&u%*p$Qu(w9w~j1)}bjsHQ-!3h}{e-`CTK z^)({a?gC(L2cjLvJ5@-a73fB^y`Ixz-GmDh@jHc|$kcp@K@KB2yPbMrj6OFhxKcC> zeIl(x@i?tR{y0XJmKB_xf=^_+sr*K>q|5CJYd^NLp7&R+(ri%n3-_ZNlw=f$dFtJFphEl; zIs`YwNvDQWlcpXcOAfmn5pydI!!nIM)Z9X$2Kf^GVmd9r!;v%}pOhyQggpz}U{+Z$ zW%~wsg5D5cY@amH9h~su$ujqv)GYf{I44xzek3}B5NcMoPAcd)InWW@lW3oMwz1rt zM#-HD=hP-ut#1E`3Y`q`nVLoW0~Tr3_Nlmx(@XKF6ZE4?X4A{Hy||0W;1Wf6M`_pd zColu6iXwmTk{$j!W@kCpZoe1b_sYOZz`8*;3CnfKd`ek1c;cQ zA`F}%fCzAJ0VemRl6){OCV1-asJ&R<9}w*lu6t}&FGXn$nR_4{#Ua?&v!Uh*1Vr*O z)j~Z)Gvlg9IwU}TPhPSB1JT4s``=cG!1mzk^h~Ac zK_=;x$MnHy(kWTO{f}btuuJ$?33bgdP=8|JRIoW`9C37u@GA92qmuBJ{F-KbNqh1t z=-keosI!CyX0}e8T03o~|ABdIsGS4m&HhO|ox{xk`V_e1?UI;*r}0h=UTx21jHf-O z4@ct^k?GS^i{8=USWEXLo?ebKrH(jb22KY^Ky?~J`fi?=pSEKe@8zncLvM|c&y6rm7Pj!y&+N` zX+fRQmXoyC$2ZtvbUhMiQA>U`L5*&Co@lhj7%-fGmzEs{>RV)GvP<@^Ql}Le)-5Lz z3SImSngbv99UpiGtxmI?q`jcJ(R`koU$fkdD#|(n8|OYBOGKqsf3`KK?F<~HoP9gEmSqCdcJ0~?>Hpx{ zk(Rl&1C`i3ocfd7gQ`y?l!u+I4YmYE&SC=6%FB1dKo$LiKhl>etAGizcirI3@U6!Z zlG=z?CoD81WT(H9%tj5(O^!AF(Vx|1s2S^#^gUKtg_W@ih9gybtk?2dPLf3 z?ICH$$zhSBeHa$Augun#onbYwwR$1EIayyltAPXzcutV1)dw~nPl2j->oA0obT@!h z+M*wL9@dhP=}v!o8vd{KaF@a;(vDbn*TK4W$85T_wDfysC|FJQtdqoU^p0b~8-^x% z!mjkUl2vcjPS8$NpuVD^~H9GoDuJ&Wrx& z&w(Sm&z^{8qSBbp>fe@{a?L}vo_;n!k_*f!WD?8;6@_?zij12RYL6{BIv<>gI@ltm zB%)rvBwgAU3YkSA%DH>O=EFtQVtHmLP9jq6N+_$qBu6b%7fbwmf;~$B3y@=SFrcIgxK+CutQ!c_ERjSIiE@txw{kG`|qT+h0Jni}DOLdo# z@O8t4B^GMmCC_62$mLSFF#V(B< z9=f{8hGsv!eJ%ARJSgFqj{TT=;2Te0RzX1lsmbFkh>e00|K-u9N{b48*=3b^KVX60 z02TJ_b6tl$zI5^WczPj|_LKRRhWX}NhO@qR#x159hnLd5KrlD|UwPqxdn~Vx7|viO zr39BPn1=Vah*EQ1So=zL4k)LAo!F!-Dx6g0TD`I!27#`X=cb*X_C0i=^@YLu!jRm; zQ0e{7iMfT^qC!##6B5&>HZCvGP2I;@f=#swRxwjSO|$9bxK(inJoHLfDsCom;!}75 zaJE3dX&n77760U6+ikAZ+&m>t(Kek7eJSpu61beS40`BF5+^P~0?W{T`xRr9UDUjJ95t;9bo-ifGCCf}{b68hFy1&uNp4zt zcWD5ciEP3#`nc}76^F2dHWr ze^Bp7t~}Sk)ZqAM^zPyK<+b123mfZN1Iljbbs_8YPXG5#+*NkNt~#)uy#url6`1GH z+O^2E)5*Wgi{KE}$zvy}_OuS!rZIbT;SB+Ks+E)T{8vuPljpuG%Pj)K3d10Q%aaE# zM*^UTr5;o7)x4az?`xJ@HdW`s1-!$xG*>s#Qdq5TzpZ5mL2v#VC=8_b89dKI zw*i9fDb6tdeu_jufsrnDzoAH$s?3r-%K$F##y_6yfrfXJ+^v&~zo2i`u(`wt36CK% zcs#IJ`=-L}igLK7VCSCCJPZHIZdY`(k_M5Y9o2c8Ov0@^vv&C{z?Znje4@t^JSN62 zi^CX8d#R)rB|PCNYey4W{(vE7CpP1Xj^0^muG(Eyzw&66tLog!^HtwhNR}rRh44!q zx-y4GKDRKeqHtnGp%!x3io)=U!pXUX5fz1z6@^i`g?CjH-d$064;s$*d73d?AvCeW z#4Vn(>8Aa`-b$kR59wQD5T4hCvI3t#D9%TzD6uAjM<1l*?lPMApri9IvjB!IG z{|=igUHBCR8zB-Yfx|V*A4plYa8e{bF!>yu2}5^6eK$PwO-@!&Fdakw5NZ>;F;BEG z5^oB0yB-pDQE?4KYsr`kZdZx$oCHtpc5T4}Rk+RnS&D<a%Q+P-%sno} zFXQzvlv0yu(hCnnNcg=bMn-J)D7k8>jK{G$}kAKeBetxmtRf`YW-Xs4p! z4~gQ51BNmN(&FUePuOGP8*2mOG||%%3iLs{^a;}t z*|jI3pe`u7E}@S0P00y1=x$!W<=&LK8{`LuVIiDm}6`nDCMO0fjyr9*P zz5ZlEagen*$f>k0k3LcyZ7ICCX|2*Z!A`xvH!bK%v_2bzeD9n*VhMtCv$d43HiyuY z+#AAftx(gm`EV;z#so;u<<^%kdPCl_p0QaExa+NB3S#C@KgKRyLH1U**ECjpIjiCh zD{>8o;QG>f0KNoHh36WY(((+Q%3Q;1xz>bS!?9fJ>k-S-?kmqdTy8WRMiuxoPwN4k z+yxY76*E+iJ;PSG;!{hKBtbJgwz{(1^-QzBSGn_5a4??)>A1sqdh+5#$UEtk0m#}2 z&|4Bh)&mz4t9L_U_N+Qe^H_CQ_1j%6p6PQ0GG|XDIF?d1ksalKez?mOvFA)s`L`!K z?|{5eQ8(rm&7pWwQ>(ieY`Up7{rOgfYkj`mZ3RJ#Fnc8MvcKiN8wA0$;coy0hb8dg z_?ZU?I$Z0)eL)&u4+z5%MYgo0*Oo>U8{G-3OFpHP0KqF4Obtl9O} z-t3)0NHc94+2f9G3cxE-esx6oxEG;SU7KjSsW6@nnx}c?`PUwMIxPQDo0^GSzS)nl zZcW(i9qMAK8>5;?s}h*6(sZ=ZGZ6DYe9cxu#bHeVqQX-OoOE&uK9*V)ieD1$!uXQ+ zHVb;63iHa3H^|-f<@gA#fmph4>I)y%A8y?`FLC36lPglDXCFAZvF_xe6us1X78%S_ zsk|*VM0$pv)yD=8$e9EC^@lsm3%zwcOm)Sft`c(=<&7&oa9{iGPF3{V<3x6H_`-xaak_F9>v zzT7Gh+T11u1iG~BiEi24(L$+WX*&qk9~9>GJyn~_h9qyOW8as;^A2^>wQkf!pUe*< z55SClZ<5!9fD0w@j=FHRlrE3BBYpz}C=2XWnN94+&nn6E5q*r3nSPQ9#p3S~HV+V7 zqniX+;zorfF{vmxxu~Kjq$6gYBBdxvuk8LD=cRjDMNiSk$;!z7vx$bW!IbWP8xc?>0cHQvL(spMIB&^*G;r4mx-=inUw2DBJL3E+UZRPp$5rrf+aj1(>zRsr1_QVT z;d>ajHNiV;3AwkaxMgeHqN#C;xD|)4K)4SlEs)@H-=le8mFMEg0;SsFib9$IE&KTB z!Q;t`B4?#6LUA=5{K=A~Qns86^&h4dQ73Pp~q8wHJ$ zZ(tGwuQaz13Q_(=R)%b}UntiHkiu1@9zyYGVY^=P!uF!N{4aZ7sp@Y(M?!Ghd(nWw zKcWDJmu^JKqo+I!)nR%2QL{*};%7ytpKLkj<7qJe}!Em`YV>kJ*m;1|9DaIy(&))=?zI9N6Q*a-SvQbv|c04Gs z1QiI|(Q!-axgDdJUBI!ZswOY}9D$Py9sK|!+ZtD}8c&^PI>9mR-J!aqkL zNvs5We3^zg!_xfMyOp?Z;47EBpgSKM5mTvPNSJ#}l8=81XO3RJBI@$GnM7Xk+tCiM zNW}Y!9rYQsxbcPm3#d!qexrVJ-{~c8FCK9oQ?)X2|Ht5F>!&GZ5+fV9XDarsmZ3d= z8#U^+m(zn^c|AO5*TLFHAf*#28~@KyZRsfK_Mo59w~-5$xW9p1nVpWvY!Au0`i?I- zhnpbmr}!Vxza4FkQ{hM9hpzZz>UT3;va}0u$`rKY&~V|pSAu4` zq(u3h-tym~ys1}#*d26BQ-jrn8(3SHE6iIVH;8y&n;SQ>bImNie~oxYTl+D}0R%YW z`Qs`kt|9qw(%~!o8VQkfAObvcPlZcj@uQ=po1l&G0~U7>Vj{a>$?0Ylu6eToz|^Sp zVW8whO4zUPp`hfxK*C^k`>gR4@d8r%)}aV>ic&m}Xh$h_a~1alW+s6C4`D;LHowC} z#C~^e%AJlFhEDU>Jl4|anRpwEZEsR?;g}DbqCVEPjb2_IoK*}q%DU=?oF_F^g_8EC zAYahF)l#^%_A(v)Wk5Wcr{8o=wgBf-m7_gecw`jkl2ktkN0#iZ+Eo2Rr>cFhc|s4o zDR*^-<8+aD!$2$YW861!zDOK0nARzm)OAjH;cwlJma9wL$KN2>G59>#YuOZ-Uer3g zZdTE|S4~%yxYW?CfkzE}5nZli8l!YHLhM3r?M)R5^4VxdaPt-pMrKlp5Sx!VOb#bl7>_W{e4daFl)6kv=&mWv&p z$_tp;=&~+X`jK|13JC3#t!>DOaY=7KfuWZRZIrbCIwd7j5-{V>P+I;qrydV7=2Fk> z|Iw~!Z*=O#9Eu&kYF9vhXln}>(?_)*{s6}GyB^H@0`r4aiS6mE--;K43f?o&tr?xh zJ29|$%}=Mm!Y_Z}B%Tkb-V3$K({P$3SyKq}JTQ^l4+ZGX0NW{6xZh(uJq0?!ky|%z z+#(4ru=ar#_sdb6n-%8cH=Lh8Mw@UOBC@l2j974QOO+Absb>mnyI0AZWN~WH1Uf`; z7mSc8pEp8^%hT@^aljbIo4Uf?r^EYM?CC0jikq^u?dQlKGaRZQZ&E_)L~p_r%+14; z5FZA2|87W&QHGbYXhPX}h{*(=_JsJ5qIcl~qe;enW$exhYG2*2?!*=bfM5dBcbrKH zIuQa>{+(TrQ7fhTk5WR`1PbE`9)zp!ppTd`s2jT*Qecoo5$-ZY_6PRMI>9C+U`q@*(d=LbyvTP;zENIS5t;5QU)s}WU0DNhZFv& zR(OR{;?X;V5FLSr^HDvK<+)h3Sm8(84+?W=Yw1NE+#|&M5P@bnHtC?F(LwdmLKzaY zH@|6;iP57NkZ4T%aGrjri1%Pv!7!6LLzF5n9r1Ro_yBe6{^2Ej!J7ny|G?NCuPS;s zh|~+Yl2L8-SL4GdF`7)hH!y1GIrI&f%qZM8)UO^JH14mIaQr%Q$eLjk%-15W%@^^) z<2at&W(m(B^+#j-7%kdnBjzAazu{c7(YrswxWlcKBn;vSdL?D&0Qa1=@<#MuuQ(nn3bl3pmX zi0>ea&BY4WM+jm1og!YtZvs!h^Emp=6n@0-gZz&pkdx})4M+2dHl+a$4oY-z`D#oi zS;6^hhrYu{^d)GQZqr9v&3Eb>WB~G&r$w@p!u0cVxbQeEx0z6m81l~8DM;8v2^vgL zw`+>Sm4H_Lfl@C0i1=7De@_X$SFy~{$sQb`Oq#3IgTia{A?pXMa+?410Bt>`#9wi{ z=1H_cEfi5g#P>+(awSNeSV$>1zXSL#c$H+S%L*u|?OQ2{2!Fr2Ma`W(E3;zF%^S^HjPQIuQjKYZ`rTkrFTy=#1$^isoGRyG)}$CwB?Q&OT0$=yP@W;lVKo3 zp4G7Oayu*!sXFfO9_hMZfydD)wVPf@`U00FCH7J3h&%HP*~JyBG;8o`0Ccxp^EUXi zP_I9m8yvr!q?8Y?RA0vP;n7TN z;v3 zfUNcnHxrr0yj_1Jddu00;95gG(V1P`h3coZdUbhgG+ejij>cP1jW){ROVPa8Jr>lA094VNc@94}g+UM?(*P zFL2MYhcU1E0N1J`M^~Sn9(=@64%Gv6{qfhOz7ZW#-)P&eb>7GlaHgK!wp!Nytx zD5kX~@=QcyuC>MN=|U4CmOrcNl-C;Gn@mCq7R;-+=E*aXnOxUAbh~(Xq@w<;?X`jk z3m8G%;Sp2C*V5j!$J$~s>@Td2rEB8_In021a>Np}0%AXKw6V#)2ZZ|KdyeAJ<=48K zn}=^5sTPB&gxy0B*GJSfHVBV+Oq;)iZ%YM+NxU0bOx2>dh+7u`wmuR{!TMfu zoHXVWRITl%3IiLOm$_+DL9@@whD0SRYyEz}S%GD=h`+d>I=1?dDe9 zD=DWVJES-D$dJ#bl9gRMyFX!9WkPwm@`8$i3QPI#UArv7n6(8rg3k5^71SjRT#Vkc zPqi@+`tmaAukWTPZ@3*!%tKzC}1UfT;1MHv6w zh*w|7;R?f5nM7h9`i|SRS9HfOFm^}a)=inYo{^U$<|SDJ=1#l^`Q26-@{1!Q%RuvQ zN&37(n$c8xvqi-)M?VIk4HJH4#3P)pu%jP-ApX*5svb6+{h;!^$I$vgEZi}??;$M3 zu91Dmue2{9QpO>C^ZO;aQu_VI?$qz4RD||l#%*v0=tmeg!$?QBok!7nl?Qbn_anY3 z5JG~I5*pQ+MJ6S7yfAhiggDm-!F8$)n#`Pi4`R0tgLZc_OS`Fza<@@Zmpcj@kDMPJ zU4|uvlDHJlGA`2`xMVo^0d`nbLe`{>5QiRa2ta`i#CGiNmq-%j_CE10vo69Pt;pS zr|!ptm-u?OaS-qOkqFOR_CayPkC2Q|-hYhjYsAyXOOa!mabMerx%2~4rkvxFFV4a9 zp1QonM-BQeXAb6Xrt)#@a|jpEM zvi6uC{2-!utr@1rMu2*4I3gX-G`&PPJTZbiy_yQ^>(zxoM9r;|5DAd%y9G0Sz3Fc} z6%O8sQYfdW#viBs)gwzPz~aYyeN@q8%nA60BW)tL&X4;}xQnk-jTHm6QN_FJgmWZAm-UClAhv6m9BI zg|(-=zMT6=My)}|q&L1KM!}DQ)HL*xkUug=0$cGOV{t@yg)R^$7t}sfe{pwfj&}#y zie}FAOFVH72pM%W9Nvwmkum?{9H_R_V9((_25Z3#NouIK4@8;4)Gk!Tcd;QuZ-g1BMuj^E*jFn&Y6NJ*oezYbrn zv~)5t2i_;ZmnL<&GU+t;AZF}8DWy&8%~F6Y?^43?iCwM&%Ca8ot&>vri0inG19XY8wlP$S!Ah5Y3JI?V@CbWyR0z%EMayrcOJ;N_;IO98&T z9lpH0lRo781211*;UAO~?}sF+xSzZk4#VpWz=+NCsZ=$FEVUdTFwl4`hTtp4Fir05 zal3;^hfX|>4}p{}CO?F>!(kY(`zVIh zcQ_gYHrp4O(P$TD`69`PB>5ug5Dg{zBG~{i9>MVQ3G~HwxFo`Y$h{+`HCr@w6JJuZqIlgz)Vt7)6C)^F%>Nd>Rmie@?fd^AzWj0=?Q|UKqY=k&1NIV$P z`%)9WW8b6G)*nau;)&FVdp#YgAobDM?`h+rrN%!x(w9mfxji1!I$Y&I&7wRYmrU6$ zeBCrM2rtq^rFVHurNh$a2x#lvYM<4t0kZrbM(`qr*8+fhend^)zphwelEC2E?4RI- zTZ$U|DabBR)0YPGtghmS9B}IS08hW_f({%J;^NDNtE6Yqh=hK!_(XezACS^l-H+`7 z_LvypNr-n;OMYv)H%6bu6)sAWeh7V|mPDe_|JQ!uhNN}_hvX1e^XB%oYeQmYS4uc@? z>58>MQQ^*zx03Jl)<_uTNtF&bgO9)65GWnYpbLw>VHHt-g{u(*X~s}K&B%Q4ydF5= zvgq6EYyF+Ak|dBnXE@<%ueazOaTA5JwA63iATqg({hJzSHZtF z9u5|oeAQhP5BY*Uye~*I>ecbwlf=gHa1cj%BrNhQB|pO=FwTdC&@hh0B=P0(@{_pR z;SgRJOLq(Y~6+`th1WxxbCW3gZ8duZkhz z#qs1x;(_sS2uE3@Dz;fHKda*YNM9AgU&pHm6KluI2XnW>LHrBj>7fFpOK>fg+MmPK z`rvZ=#^Fxn_l}nj5%!FSgT>wB;V_Q!NN{T{_MgKI_Q4f)jl)Es;$3s|-{Pq+JqVNHZCfw8GHRJ&KD%dp%cSs2ubLd9xwI1BSI& zZm5j(fQ?vgXi39=@hL+_0mYv(w3Jc&Pln396vzKpDE_3O@&v{4|DT@LL^!T$Y&at+ ze(OuEy`90% zQ3k2f|3-pOMEEKdR{ARK;a2zn_3$QN@S?DMJSaZs3l8z)LHq3tPAP*_>3^fRUx)fC z<#TjA8KnCE8{K_`hyl)AbWX|g zAuuE?@&O$ZGsc7bc+es&^rhNwXK-@k8CsE{y`DPT*(0X^lxjTgqZ*cmDv4?&FO^6J zPOXGPx{qZ1%Fyx);x%Bs|I$#ILF@gOhKz@3{m(aKlu~@Yq4F;jpJ!+}Lh*S9^?%X> z@*EJh`=|40t#2M}Yk1r{kJ4!#SqHoqw0p+p5f^D_`HOcR@ezhf8_k!= z2DLO_CL1bGQas!MPj{F<;Rdkme!8E~T?=;yO9)wnLhq__76Vhn6c-zlW_g&^&ge)v zn&g^|3y7l1ytaPrrh(PLDZ4R?&3T=;Lo_LBxpi-EGY`BMJbkM)#mo*$e^yzW__%r7 z#I4vb?_qE$bGzceUqm_KA=ylD8Ps$UQ#GmhJ;R7Lsw@+mHK-SL&gh`3sD@v8Yp|GM zdAE&Q_m*@)Gw-KVOJefT>RBn~tpw|HUU6%*81(!ZP z4c>*21JYV%V0Lio+-WdkMI2f?40w7+>)&@<)pSGsjzn>iJoD&_NR!w=-`q~CLz+MF z1P8~`=q?165_yvJK%JN}yg@S;kHgJi+Fi+dVtj%3mlq-qr!^_sT@YY6_4&VXw8sgz z(sn*-iD4((6RWGMVqQs>y{Kj*EVHVss%E{A4D-Hr7v0*z8xke?s5q3?1mPwnMROE( z>LKcEkK?bWZ9WxbSuh4?0pPF!fb$!``OORPYi|V}QmKYS@VC)m?rK`Qi^@~!AJUqY zaf#C&X5~vxthiq-c{{o5@-*to7WnuvkereS`xxyF8%-)=iXWD@yv|&Ew4`{Jyyf@I zwFTkDq4EqPQye2#{}vsQA~*c{ed=WsQ@lW~evB#p%~v^V-j51fnUquf{>nZ0Ni2Fl zw`gJx|2~A;Z0KVB;tFF?*kZ@x3n?Kfo0Ez{Q*0~UDP4x&5tx<~hJAj~UU`d?nLB%N z@uTtObl+Tm2Qu!ao%HKxxKT8i%X*GmjIfI?-X>`}uRQ&7C#D7c0zxsa^zd*$^PJ41L zcCxEc`rn=CZ2auOPvQj$#Fsu0|4V0oCbhxg$?5N8w*X8(f$0Mn_Ld|R_l5#(1R6+u|pL8^v6ubcu zwE|lTxoS;91n&VX4aCjrZ%oelb^yMue+y@o?bro~`#7f%@Bg;5stqcZ5KdMa51+>W zq{5ga>lX=0dkrot8@LoO(NqEmZs_UH~QYj+hK*+M(Vx3DR? zWr=~i3QJW?WO34|FyvJKYfPtGCM!JBzDmBQUfyLL#0PyWL%)TS0_(JO6ZBlN)`ll) z>JmTEAz2_ruG+*NovGBnmw!_ItmXoj%trnjkE?NTtP_1AA^Jp+>Hgt)S9Uo5z7*zD zQRXd#@y{)X&!Cp!_(PAZ&^C$0MNZk!(=v4D|`rPU_H0k;~ zvS#_v)Z%+&ZI;N!&F_V^bkV%2Qb#T?YN6h!{K}(0e7X`|1@wncwn%Sm8nA8HtY5h6 zcfpRND7n#wZ00nnF*fWt)ETTYDtV7S`Gq)cpbEUtdU+?52&yY=B`G%R#YTIIW`-iE z1nw(R>yk?5?-)&tDA)wqGMhKfLe)0|DJ-gke zrv~`k=u?mJRcSD)tchW{4kbKB`E53klYC4c{ECmj_;0YSeRBc4)ky|}g=8Ff981@Q z+rC&pwt>B|+#DJ}^7;3y=AB;2e|nVHhqt(%$(hs+waJ;p93FS48n((DLHK#C0BEW{ zgreHgTX4{Usw8OKQY66!8vJrUFxT@b7Jrk%{BJ3|?#5^kYqZZWm)FAkYB`*;+v=IJ*SDrlFNNVp`I>n#lI4N- z)OflL_yLjH#LfVtev(ZejwNhr7xUCBI=Gt|Y;019>(knc;Oz_|OU#bGYNR}p z)(NMqaIm?X@RO*TT=ajE3ni$cQv zjHz@4&t&R4<>nLAEXhtGL!`6ePiz!ODRpeE)Pz8bSPJVVcS!%AmFvPgV)MeTl}yx4 z?1)Vcgl`X|?15_Uw7t{`y3mf;Z9nRQI^Nv&T}A5<0+RV2tuJ=W&Sf6eEzL9NIaVkv z@^_N$AqV0u;n%kP;#%I~*yK5}ne%LVnRq{AhrNMC?(h$Qhh`}%q+<1wj)0g-{>#{^ z9Vs6QtqQUbW*AYc%#a*Y2sdTq)JJ?(o_eSPP5ql!ifh+c;v9=iHUe>M`nUJ_%q5N8SHX*Dos;B zh%(3(ki%La+{-kCXxm2cUHD#>F+>mb@V}t6YBCPzZ1RjBkk-S^q_nJCNE1&eGQRaV z6ahA?T!guNksP~_m#y%vhn$K4@A^~i2jmucO{*n5HgEQ|lEB#H$+4L_8X<;>-Iny` zwrTap+lwvVuz{5Uj6FnDD!u(}58=L%WsnI59w`E+S)OslgFM4{eVpQNQ`LY^G_t;N zypC(z*lT(B-mZhKsz2DiBjpE_IIX}f0-bCR;YSp{I)tyO4t%vz^CWon`~DN!ObOaO zgfXB!=Y=Mo9EZkTm!JXcMOh2M5)a5*01NhQAD>BEO|&$V>^Wf8mQ&uYE%MKlYs37v z>9z0q8I=KRUjK!+$G-8bqC*sk2r)ZgQh{iy3hIz@uaD_cC2YYJ{_Tkl>l~w=*`{yu zb1MVd7MbhLU^YI~o+ocox>W(J%KSVftk^dfRGAzFcJwC8;Y=6W42l485D9dS;X)ZW zx;}h{@EW%ynr(iaisrwPe{7G2aR)Ld>k{@zK6vt9id>E)m@yvlC84DHOX^PyB>D|F zLk$Q-UAL&c=6J29=JDG2niP@DD6sC%Glu7>t~?^i@UT%ozg=roTIAXxMV?+OD5#l5 z{*vk4zdfL6a@ctfrds6d-T>PE0sMJSUw->hG*RwW`3nP{j#94&b?Yl1v z`XGKyGFVoEBT$MHrc6A5V3KtJ(sw-DX+2oZS?B~?p`)@zu?%^q%l5WJTK|ybh`syIAQd^H;CTt*{< z+R{#LnJyi4iYYrwLu53<8JVXi=`I{kJ7-MePO4l)1J2mf2Hld!kr8xB5*hDv6?Zj4pZ1zoHr? z`1Q?g{~vX4AJ^28{g3AXLcp|5K%%0y_gaK7I(q6Th`S!+TvPkZM13;(ffVR4e0LY z^ZmYlzkhyz{CF`p_ue^kX6DS9GiT1sJ=6Ri&$ScL>T%y+Z|Q<`M84B5)IZi$_hB!0 zXiVth9lFHrlnt`|7i^RquVhEO8~FV~*UFh@adhN9LUD;QNHmSU;SyArxl<+phBhc25^k%`5@*lK7m& zyft_R`rJhIt6|fmIf-ljB#|YE@M&F0HFjd;$SShf+>q*tIcjnb01XUJ~r{@T6{y*SBw1-Hfus}so~>b|C=iRQI&fkwNjCZr?*u8 zPgN6KYZdONsO;ez@aHI$^$ccf3#gFHqx1mx8#s2DM z;0!C<^qWV*y07v@o3eL;HZzABQVKd}ZjDp#3dMn1etsWzfDo16_I3)sg&KM*g*bJ< zhgwLL?NuqJk(blR!%5}#u=3SgCr;RvT@q%SM;t;~Z%MJe1kIj&er#VChBufe?@>%S zzv_sL#CCP2)exUtMr1LRLSkDdYz+DP-(?ClKqO-zPXdGhEC#^NIQWM>F3rb&vu}y} zgwksa9g@vlM%kMM*GTnON1Ev%SqHqG#gLV~nwHQ#3#Z+fiLcl1`O~9vxLB**D*K8T zO{kgR`g_AIMM(GO{Q5s>^FJh?wG<;%AK;!-@|)^AFO?V?RlO zT8xv;G6hv?FtINKXPgDjC?KOQKZ5P|L`sN6g^|<7+}m8kp(>({VaZZ9&AE(Zj$J2+ zS4WMFK|haRh`}b%AVZQ<_==|c5}%*tuzdPJ=Ih3a!x(vMRO9=OeIpyObGwHgOvEIn zalfi5_}JTRk8Y6VXDO{Wde+Qaa3FN3C1q`xEi$09OJlWYKTHZvBOzQOk=rQj?z@|r zO)UycY{_P+ujU7zhB(0hBdM35OzC|*$(s;a3G%?RC2gAo`B|+x5?UWAst*S$*y14* zGdCGGN;LQoqd2R>735p0O5n^49J5p=H2ro{zy(!lN*DGM!L6z7dkmZx&G=pdL#E7j z9#5z2jfb$EKUM~L(C3ByMFx%=T7oCoO#V6of}(w}Rav=;>gp7Bb?t^XMIfw$C6xn&uxnE`@hV zZk{lHg_12xVqtUAn<_6hCkY;*x`~q-;HaB@j9WzH+srWA#ZGz#$t{|s+jidJCC3}= zlhE;ai92pOd0!ZHnXm&%OH%}TvXIMUhnI{j`y?~TLb&ZpXWisuoOVob0@n63@vKRB z>MC&z6v#XL7#c|u`==D>tjc3Yq~5*o3>W*y6z<+*icoytYe+Lbp9XX_axRRhOUvYn ziQG*hotMcmKnTCMjF-nLrn2P{%q1}gs}miouS@}OM`i(9nq#?seqib(4}dkiG&BXB1S%(*!7()v|T-RA`T zZs9A&T`4aISN4+U+|Z@T=}6MmL;p>9Ch@t1_O@^Mb=0!3(+#(+2`X)HL3-+XU1h&V62oe)?Dqgh(#*fJ zhSSRY?X3Q!_B37@(zugf0mQKtzlLX7eW9a7nOVYK=td;`%l57(Ssv%?eZ!TGb*w(C zzffr?WC^m<+{g!2^u*Te{vsAY9qn9Rn}?3n=XMk-5#Q0rnV)qOvK?z#y}7jT3atiE zXJ(Zwa)$<-Y$Hseg5tN70ZeSS7q zmIdEK`WJgMFkb{rej#VBX}FBI%!jzzwERD@`m!!snOQ^dXJ&D&4aGHV=7+4l+*@O4 zBiIdlxms_28(Ufp2-Li`?F6mDdv`6@T4VTt)o*vVDeb#5+X#BMmn&<#LWd*1VmH_7 za&KYvPq#Exlo0e4HC*1`4JEAp(F!|j{`6!?PAA|TgPcRCI|x^L+Odpv7qY#D`fG>+ z3H#HO_C7CPkw&nCdUy+(6%CA}nQ!)Iu=?jZ(v-C&jx@HKD@{YB{{_xmLLFus&Hx_N zbhoh?O@=mB`7ohg$4WM*PXAO{A$Y|1d$#&=e;QliV(la)sMyeHE_AyHEXtzKIm`iv z|6{Ik1zVh^Jek!2uByxH3;pBhDluaSxcq

    v2ri-2kjQ;n~a%drc8~TQt)P?8z zx3TA&d?hSZLU5qu`ajugYTE!Uv}ezmW;(LtDb|0G1?2`ikqAxNU&x-X^A(a5D82q% z-lYp9MaB1A&*j!j&9u7XQFd?ujM-Azm_?va3-`yY{!8=eiZ=Enf%SdOwVv)FjRG!c zrj?*6>X`<@5_ptef3TvL9l7c2V@LV`-+zni{aZ7wLITiX_;Mr9IzDE>hJZ#x9zNqc z%)*fqqQMl0FSnf0-}JsWeA)Xk(su0UT3f+{9$M-5vXqy_fhtHwDsr3McUkYPt?l61 zS{|T{f96JRG&BJXc%_O~xog-4xDh5qMG6$P;{aP_RqAUleBbD0FL;r}{}boEgoZ#G z6r`b(B&j5qGq?m2HSR|umA>ZWKal@Pk_gqur)(r2v_bVig_kX^Wg9NvN#TEot!dKN z^d9qh*Gc3vCYyR9VBTJN5foz0GvE_s>WV z8q|7Q1{{9|o3WrHxH3?-{*Tz20e#I#O-CDB+cr!(l+g0j$1J+Z@E9q;G5{w%<`#RH z)3;st0cXVlwBj&#fgicqQipALJ-*ordN?*hjJ$bmQ{rA~1AS-oAouAVw1Y@ETBIg4kq5UP+)9SmWX z@lNGG#wOJ18Ly|YkM$B>aWrtfy$zMXh63knS%_M6EH}gY#*-ve>Y3WA#`jnpsH17B z^@98R(E&iFJ)E}&3DMtChT^w5eW_yxJMs%M8(|P%af8$H9xG{VFo>p;f@s#T6%4op zuTUQdDlA}&9T^%p3ebhu-@dn9h`#+a{9>xE+-B2CPFeiKzeMmnnZs37bh zZV=V6jWf|oLo4gUV&FpLDdYcl|rHwXVyxY334?0$nw8g%Ye^(ZKe5 z*~3HtfYnhcw6ESiavew^8tr`M28trX7&USoIRpq&)Rg}k+p(0&Jg9Wlbu3jn&~ui< zXhj6ezRMn3eTQB3hqW`bf`EPy(-|;iMyKH&w)kOXQDwfDb)7-OjAxmbO=wZTVR(C* zLg&NCh+Ols10?fURemSc(9|-}Sj!eSDE&^Q33Mu>4B&8(rk(>FXi;=If1Ks@Pxf|W zQ1`ZQluhaCgmA2BYOH2EYFI+5J5b9?w*Z0L%$b)`&r<#pWqt`&M0gY=GM6~%NOZYB zvJMQ7WfWDUL>)5Aly~}-epwGUa@Dbu8gT_EhOxV>uzv;X7ltkn)75mRHQl?E%Q$_S zHU^;i=^#kWfE!dygE~1zz3Mt`TE#1DaU2Cca}8D8YHF8^^>bD8R6^JA1b1DSm!Dfm}^(fckS&`U3P^S`5tFDa=@ zl=qT{Cj1Q<>^{sIG*rGtc>;9NC?Q{|Sds^F9p>(c1pT9>S$TxbeUEe6EDUOz$_fwb zn2l5_aL^TclK)O)*4m#0VCL&wZK318&_wWpNbRe$lP(9j#?avfbY%W!)@P#}*Od)T zKAX~A!gd74rmr9n@}H*6S4Ikf13l5sn}iV*?NLCh^JCb|WlG3jhHV`0=kOY>XUd!a z_dkf!sWNv{K9}+zl?E53bsvb(lJRVMq-c#>;g~I6%SIG9%aRYN=i$e z5Hdm!)gKUqK9m$Mqdky8x~aPq1y>u+vi=Muy6Pn;1^ssh?Advi(pdji3K|b|er?HF zYh-|c=g%;-lKPr;Xeo4UEW$dpO2cWEilZ!ac;;zR49Ic)#@fQOw8~D%i(aQKtiH_9 z6aR4rINT>W-#)fO7|;MrdZ&3rP)6b~aw)Yj{Y$C-HQ*rSzRQ>iAO$+1HVquq(A3W; z-MK8}5m6)V$|7ZPwldQ)#zoL&^eJUY`Yc9J@1Tz4{W`gm^|1NzM4@pJfd?KzN3A|n zG2X!3vQN<=%e*JOUQ%TAzQHD~_xynG4R*NFUqhI1ynh+u*s=wKTxn7!!|DmJThG-r zX_J7K$e=AOT3#6NP_zE;!A9Z!ODv(;IBm;j+nFkQlC}hC*=VkmaX*V@WJ}nJzY}o^ zedznnv-CvYX{1&8$0=I2(fr3EuC}BjornjjiYu-8#{jqhnjaLQaq+s0W6dlj^i(1{ zxU%bc{7BjfdJ@ur^UQqXN;1-=u{oWY<7ps|sZ<;zFgCJ*CCkovFI`A$AOje*1oI_# zA3J!tzmLtS%!C>`U>NU*6(+WTR+gQDT-Kk4d}tst3(V#oA9YWr`4_Nd&jSw>2lHud zi7$yQO~PaUc1~ZSAx(kqqoc#oJiZ6neg=|Eg_qzUgO|0?>df&5Dzn!X-sL78>8xxO z+drEvW}p}&377;Tuw%TH%5q1tbabT9zoF%7v(OIggDj-=zjd>Oy5dh{15ZM0*;z(dZ|cVbT}ChY(TyFQBE>{;ya}!N3N?ge>5K zyG?(~&i~`g2;mp%3RlV(T_zIhIkw|7mfgY@RYD8MA8(Sf4SCr>nMZ*AzvfDx1unt~ zP5Eot#t&HE4`W3tdz^ZLH9zDpBubT;2&UqtoP8aj(SC!Sehb^boOSPIi?c8d!&^e$ zlVhE`Y^8oq%YZ%$iSp-hd5<(SHR{=lSJ{pQ1OO#3OVTD4KS(I6jRsKWTuvL^(+Mn~ zax%xRVDsm*SW2iS6$KH&dUqJu1MW*3x$0id>iHHj3{X0*F^%p2Vl3~TO(Q>-m6yGY z7=H?P-dehKUv19HFX_mYEd3xD2R>xwhVOr?WRAwtH4V!nQhPn1xevQwusag0ARpZO zZm-4ny+Nj<9p?1Xq>W+bBz%3{sm7Ls?w5Qp;Z33np(@>`aNXD_;@1 zt4gqqp-fdKxE8|iPO|n4*u<$vaq&AP`g;`R&7H(sxNS&yRs?AAQgbXNaY`mQ zQ-!6&@L*>uG4;9_2e79S6Q7H5rV2}*lKY&g#8l`E4{@fFn!9uY5Xsb~bEc979j7G1 znJO26LY=9hB*r8F5u=^U9)08MhHk}oTq;mG$M5@Qj7?iFsGsrLy$Q=O^A6zB}U z$C*k9<0=q1OfdUF5ku~myyS< z(#%3OM^c*Q-^Th%l+a`H3QG&k>7tc}#z#5yvc{*l%xqSfPHytESS*{lpI25CDvJv# zyUpy%2{)(vf5*a@IPWnaG_2!*+;EiZ=q70K z2#MCXj%wUOygw7PeR+L(HAT_Btt?a$UkRJT*t15i0d2*T1Z_p`7+O4*VzGqH`!&#Z zY#?YmMmhg=f)B=ojvQq5YW2q1g$%73@si@ zFA=nl11;=sK#QT#caEUNBeW;~2Fkw;8op54OL>>_+KN)$zhxUslodd0vS+~xClCy1 zK}ABpWrTjEE`l}(Xwd{f>wATB>?CMOi?~?d)0FQ8GItAK=GU-}-%yU=Rsm0n2KQnD-{%qj^KmXbW?uiY3z3U`s=3G1u2^BooTUg+eMc_CgLAvCLeN zq|8sEif-w3C{q4&q)7R39EEXPYb-ioc9~P{SyVimJPY~?#{OqG6pej^D~3Msh%pll zqAF$lzhO~(4A`4vRi%$goz z_zPutUg_>r7WYxTpW18eeMO0+M1|awH11HUY_5n#dlV(+()oGD!2JioBEMZYWsCET$;dXSWkta~5iUov#&^i4-lak?)sfQFUGGWoc^1f_t&&1_UrLTjQpZY?3VTWGz~ z!em-oSfOEAM(JSKWR+!O_~z({(o6PB7dD@#NyE+-8uk+BJIf~1C>b~es(I;blnfdL zXh!<=w}fVRWYMn|3H>@rS)4@aFKI7n+fXjTH%7uY;0n#@%siu33x!6Tv~?|^(i(wE z@f_S#G|O#wv|886&-($uSmfB4c>2 zaoMUhfBX)5f}l5An5iBd^7nd#g^7Cm_1PY{RKyuWc@nbCcPb(aBja&sD|ER7M9--I zgSdS4OU?MU;w7B4i;ah|fD#rc;bepCQybdK!_mh7qi(FZs~i9H@g3b5r#(V*v;SXo z{(bUVajnz3d~Gd@X>63tl9ZM)pXf^NKpydBq#86kBr`HrGg z?CdHg$}#ctiCT{9#=^c#)Wzu*IScSF-u0@9%kA9Fkc*DX>4ZmlTrA{LKrWlPHJ!Vi zxB&O=DtIF;j3QLC;Uqe_vta7j#x*XJpuj~(E++0_f&!NUa@kBSPVQHoNVjurkL`cz z)7Ty*=-JSxZ68CQt|m(B|0nA7Y*L7TI$b(ZQI3sO65CnQM70s)q_;gK{{O8q?UX#= zOr3}-I>VvqN8-k{SD%bsC;5;SrY9>A1RoTdyf z$M(-t;Y`r}7gg+H)=^Fw(vA7u;`~CRVFl%Qh0XtjJ+};=?OE&nSFAG_>jNh1OpBGei>TB}ljw6?%H%VVXRt+PGWNzK;Dig$2QRDm_J%gUUvK6uuu?6St4u*P4pMlse{omFkZ z!BO5HDaH!*&2O7-c#v&Ox!Ws2y@R8qi>~{wNX?W)vx=KnyF$L zs>AXnwO9p_&(2|~Y_@-yv0|ne02Ld>XK7_e0)}Y{I)WjU9lo4%Z_#~rTg&~)53HFd zJ`k#3XfM#~56I(%hdD5w>h&r9mo#G6FPbHPd!Be!sE3X^BDZ@@bi6r9Z|S0=_E|sEi;skMELEMLWAkQdXHZ{> z&l@}5RrwapWe-v?i_M%%^-|OlaWMm(nDUv7{d=k6C1PqhTO3c`6dw}%<{RDnVYpH? zqJDaoiedeqQ6@UV@Db%-s)QW}Y9^S?oO*(ua$Mfuro;{h|GP?e8#R9p3fbChU)S2c zSiW?gq+$V;*T&Gw*7sM+lP~8m4LVJGQI)8L&x^-pTJ&OMXzcYR5{tLpGl?jtPqDeK#3tawmNd8vx8#r_0i zKicXsHg7eIn+N1e=c0`mTHX479@FYwDc8|z#yVlXPExUqYNn@nXJ}(O(p5dHI@(no zpy^TS{3=irbfnIS6VBv=y#J)~1=BVol>|rC}w=dX|njE%)^)&l~+8DI5C;RZoZZcIB<^Jr1hQ6)z6;_Vs>p z1?PEPZg^Oes?{Fbl<~-7dCRZg)*;Tk+??v6Cwg^Yu_V+5jMSQDHurFY*8criTLcAX z$k3&P3dc6=j%b#;ilF!-`~V$lm8il*5F7qcc85X!M9yEr?pL8r;-zT-Co~uHhs#yoq{P?DrU{YuJ^g z$^=y}Qkjg=z)*g0?*VpjQ)axec%>LGpcW6W>R)Os{!-lWSGFJWbRhE_SvfMSU^Sbl zZz$?-$_AtRv@-K8rTbk9-XBj>j`P$~y`?v^Oq0E zi2+`z`Ay44?Tnu78NWNE10l^T%;sV4fPADYevzbNAEl|(g03yr!KVIqu=`;6jDI#+ zX7(zN$ial@#`{men9C?ZX@>m_*&U{|X+53h1^WJR4DijNv{y=p^tK3}_^`4kzjBa3?$@UCTKa2f` zsN(%%w#3-@5v(WF`Ar>f8;d^?qXQLhhSl8t4(tDjVn3zw`;?kh>@_9Tru2_eBO`~6 zSoEBYW4ow!3Qm&WPLWg`qfUO)dgvd<*7jNQUq}%_SaVxz^LF|9%MCAQyka&V<~0A+ z^2Xb9#M?u$0vkVU-7X*5yHt33>!F^e!A@)n$*6>7WV}5rhbL|H^3iCq?zDnz3f%`O>|DYJ^01 zZ%iVbpu-J+W&IEeU??v|g!wmOFcMZndOt&5ap>+MFlz5PFY~=si!*rFFCn}#yh{7D zeO-$&!}?@hfi)g8^|w^{+vc7(nyhh+0vC5IR}~+msON}yfI2X2G%RQH3yjnyvF|Oa zh4ZgB7C$d`A7Ehs8XRaWF&2L;ZY-hl*Rkh0|NB&NDeF5xWwt4YH~GCvjLF|BeLqka z{x!@Uk^5JYW%3e~<1p3xS9AKYRp#Z}<^KX-{uWGT{+DLecDadGwp=dS%UGi{?`Y%B zT4Ngynr2K`4@vf0%4{|-?P=O5?`_8R`Hi82XHM?*zBSybsia7i zz94&BtkhGgjzTK`2#`~~Z2yyj{4Jy^PLIpq{jB$G7=kv+3C})Bd9Sq|ywTKJxKh4` z$Oz4*j^C;5UA;$hRzVhYWT`Tm23MJ^lYAia2Nd-yvCm>0UUlqDmvu7r8PzWc3hK1D z|5>W|S7Pc>)*nwq#~_)ugT_7xi*0Oy(Ty z2#Iqk^HTFeIh}c%*UIDPlV?!Wo@RcirLxD0?Mslug^r4eYhzv&G!Nk#E%;X&w#wX<6`P72;$6k$`Adc3v!X7eyxJw>H4xGi=zGlteM+c zDp#Ox7S+-O8By_1F&W?5+13wXZn`C3zfgQDwDptH)yF#Xe!WsYpK$iz091t->ZCYf zfU!n`*d~=5;^8PY-05bF8Ji4?<{CB|uUIE`jHuYBDQXVH1w~zFi#ECAjm7iCSl7ji z{Q1UWyBJaz2C<%1L@|1hb#J2vH}(GptPewB|7~RkM+_4yl-OiyPW2a3+i}`9I=a1} z^`L$IE%{PXxV%C;-*b7TyZ{5-#hi2cxy@G96>CJ%be`r`rVMN59JZ$pq;g41C-(KZ1IEQ#-&E6mc>tr!N&O@d^DT9 z3c7e>DaFF5t5*(gV&_x-jl@3dKdsE=^tygR*=0tI|Jy@*Kba-ZFxuDI*Q2e8_zmB# zIEfWk9j(-TV#v5`I%+7OqbC$573TaWIiPmUQ z>SO6$^t9IYglt9lXA5C%I6)_j+#0z8{oDJdCh>5=m4XnsN`M-nkU$+T+GL-jXA}`F ziCy&lgR2t$d~)W#mp;=0;T3wug@?^a1@r?Mi3xib9L!92Slr_Eb6_*@&@-Fq7-$5s zJ&7l1aNx*~38#Q`S>IPnB#c{RqG#m{<^if8WI_MxXw6|stW z9%hc)_UZxIP_gKESa}pmW+|8pfWpWbin)q7L?4kZ4tCFb^#ECusxJ|hAH}=oE~PvB z)dRt{XGFvd6FA>Kb~@jElh|IWC7a!jiu`rv-B_4xfdf`WB)r_MfqphFBc$$PVQGjPeG{=1}Pj@HtZiOu`4`22rN<+=9VrWy-3++I1HNHI470jJQS`SXm2ihyY3*_1_M$sBm{&>wi?$WxU7TWAqsBt7yPttFnSD%wlJp zvC0_XsD8G}IMGpYt;!hbsK&zVBw?v`vanP;1xvME#(TTmey1_2(Xmcgu$^jgKkYGw zR~dy$aztc`p?NzX}r+nDn)`Kd5n2To8v2drvxji z#8fA&s0s_XffZF@0oO9VfQz-+zydDTW`hAewl*s);9_kyuz(9A5Lq=9j6lKyZomi> zM&LJ(FW_QrHn4z;wb^?}OkizRSimh9U%-tYU%*Aa!UAs9-38pLzyhvdP72tZU``Tj zP62b0U~_^wDPVJYaNOpEY6>=|=DRkh<}sU-WQJ2PH#x&2or1Z^Ka;C-zKvSHlu_UHT0E!cCovHBxP>M5EBLIP5 z2_)u<0HhUeovAYfpoPxVnF7#sXX-2xb5;PFE!;X&A0%09HHq9vQZ1=-1Tfl?I#-BN3SjdB_m))hC83fe;lT zfawGGmehp;*m6tiA^|MclDb%k3KzhZ1nw=VCua{#0$!kuvHtsvYO8B$ z!yC>+uz)Cngja-*)u-+dFmmeyHAbFw@3(R5{;mGaZX(jKA|N=L3&=Q_pMT=%`Zjzd z3qOwB8UkMFX>*2(CXjP?oy$lG`iiEF;AhDB5kN8th~gf>A-G;Gn)vDX8>I*|Xrc&?nJ!FkyjPt{T4ft^VD zW#_K8y6tV;dXjW#QuNSe{*RkQZ!zj~Ch<(1R(U)a&5!ETPl>i&2CiwrvVhBnNuw!` zT8t^%5Z?Rfz}LO_1uZH^c!j(nbXP6At17f^m>*g*D_trQ^+@7oDit&8uJW<>MLX*x zGBWatx|oSgm-)KO{GmPnN(lXrZ{q`*4#`C0G~D~5+yMMXBq)+p5yg%2DDPcg18n0K ze8w3d5#fU&#aJY{pRyD&$8C5Lkrk%;?+cTJXwMcg&srP&=A789h~uAt3G(}+$@9wj)q+q_nz6L=~R z^KjrjJ$jEq}`#I32IMT}aBd-e*O-;h~K3y8=8gbD?c6Cn_*?!>r%v`X* z=zkC`4zqs8MZlY{q#c-D(O(PU^P^_88f)zyiaSVHYPH2R9KKgW=> zL-4gTk;2@UByZA+^)HL+-@vKSBDEJM?`jxSQz1!^F+rjU3?r0AP6~S_mH}C?~a$vxRu{Wq#gGic@wy11gQ^-OhKYTco{fDUe|Lt>P%$(FTNp2 zJ#sTys*9GO)!@64=o9dRKuRnU?L}LqjowVrB2Wl(5TX#4ASe-fM{cGxBD{srir_{# zittASGvYIEpuZr@L@*%4A(#=SA;=Klck^b-M+heo4kDaKcnhH%=`9Fp2>A$lgx?^{ zL=orbR%p*xQ(<11RcV!5Htvn zAw(g3hjb@N-UxQU<|AYyC=n+^7!AmoN_hDTZJr`+><>CSCCE7?IQ!B)>a{_AMckix z8IdJxih{WsKK)10QruMYvL8f>UU!4C&>YOY&hPw}sL%_qG<^DyD5NNadzF`cOXO1( zFFS+ZNl!i0m&5&m?>-&C^9rB-PXW)%yzC!$@s#m9zZCH7=F>kH@VI!{0Kv11m;DXD zqWB+zx!?2Y0~ndO7kODPUXgM+`Si~Od>k+PRHXPzV~etIc`)}oerKns@SR|8JD>is zfNvY0-bL^|&!=~Y=3kPe&l9Ov2BB;veD{X|T+i|82L)Wu^0MZ;W!uc}d{-#jCO&!ozDq)&3yV6f_E98z8SxPx~B88rvv4Ch~K$EK$OO(+s6?Fb4I?q zP{3p0cdirgr10ql1kVyaJs-cMt}l_gF67f6!!xPt0)A(%fGe3#Uvn4NT)z7e0oNRU z=PChL5}*Ds!KLHVSBhrd_sc7i?zv-ioXw}Fihhv?bF+AvQS_Ny#?9byOyD@S5H5jF zHy|ykV?4iev4Ag*PhUvz#q#M3@H^=#1KN|}yIGNdissX21643b@#%@-gd(o zE_Kwf^Fx;jfeR{lrc&muBt zzFc=V@^0QD5&auP2hW#B+>IW~T_zUUK0xNNO}y;Zc-})zOVoN18G;gjecL7NOWt;6 zr(9%;koFyQ%0=Jdq212 zcC~y~X!-ZE!kqG;=1|e0mY@oadj>v&E#5uQ!(&m!7iZDj+Go30Els2Yr zOih6b_%m-?OH@D+t+|~LG`3597Vs27_hs?>B7W1x@De>+p$N=e=;^mylBVmtQ=_W8 zPP}&^2Zcf-X}Ss}Qw6+&4kOf?4hLx6j{sEyNF%Ac2oU91yeMikWf=H75UwJOBK#M^ zHU!XYlk^taA!OBcz9C|9NJ3;na6&A}EKFp)!tV>R{>;bMPGsW$L(lq=mxYT&u8@Yw z0NW?q)9QJ%B4KXALpdQiUy`)JqE+YkeZhOoc$n_jgamX$5aTT04V6294N0Lb)_u*} zmNT9hQF*OKI6V5e|o zEH_B>HzUHDMK6D@LTg7B_EQeNf?a%%3yeXfL5SQDfnh7h6U65tS|K^h;W z&ImHa5Hk|^Fd;BK0SW7b?c=IyEBv6#?|wbb~Km zPdVd7tA_c}WG5774N=(lgd%#?=+){iN>6k)$t+BCI!qDO^dtY{gw7v% zp$}{gZjfz`kMxCzY$1^sM>N5L)40{@N5mc`dpyAzzVQdD^DIy16o6+KHEKf-5>cVP zLZ;AgeRo7!KM6B6SqwmCniZ26=1~B>97gx?*(1T4;62ScX0`f>J80npz3DFk-sArb zZzu5LgjnFj+;TVRb+O^V>=N%O)dU}qGt5jN{o9l{sD=}M2uvLiI(W#%#0l-(=
    2_OxPMy%e}@L-V`G;#modTjTd-lOhoxs_;-^6Y+q_a>cv{R zk}zBQSyaCLRge@|#;NKPYX1$XJ?R4?XH0mx3+M1Xg#&+)o{5=Qu4_mQ@J%Yx0SFE) zL>mxFvWzLow{NDrj^7|Wqt9=Yz}Q^trC8}dJ-JfDVfEuu2Fu$g zSANS^hgOAV?wS%$KS~gu;?+_aLAW>g4ni+Mc#>B{3H-G={$8hBbk;2{KELC1kS914 z4w_^`I?P=JEKxV5s!P#?vp%XKqcVg_0mO2Aw?O+L$N}x6P^oR(fa_7y12ZT|M%%llVo?2r}IU1-t>aBCX;E{`} z60n*i-r(T~u*xKHng}BN+ajZ!(V|^ZKYN}KxvPzC_L|2O#^o zUfc~`0zD>Yeo7q)s&~nB7NRjGhwGxnx=$vv-VTpYKD-mU_J+-)DW@QJc%J`;zfZ(} zm}^Yo{XDNUh(sF5b_yW|LA2HdbO=1CJw zlful{xPK7PJz-5XA2ha8xIIj$hv#Vr|MU4E-`Gw*S34YLI7k*ddct6NF|1Hxql01t zo|lDXKn*lzA2hTnGoqI7Z8X2?d9UA-CQ`tsl_w2{cX(D4hF-omQ0I-L&LDyKoxPl$ z+&8_!%e z_)iy$x`g|JI8A{}l%GGx@s_IPkaaiTvpjzT`8wpud3?3?;uS@d zfC}`pWy1R*9_>3F*4vBF&*fVZ8*iF^yCa!JLpXlAD&U z+X_i$^bWx1q|@i#r(y%}$VV0|$}uIbKgiCdy2a$S9=FN`rt%xil=V|}y4>{u!artk zi3@lpLS*R@TOJX0Fe)e$%xWSK@L%6L0qVc;{acu$CI>Y@2MCP(tEYw&S<`tNj+{jv z_Y3vkPOfs3`6!^BgW{?pT~$$1RYrdRi=czIgg4+Uo_aTYV+v!ehsZ@+_C%pi=aarE z*pM}FG%XUHACr@}c}B2Lido3dz`I1eix#Q7L^ipcOlBVJ4ngD4Wzg{H>-?T!bdNF3 zO`7e!g#rK{x8s&e-XL{qBEhC(`PT=@v-M)QgYjUZ1c%n80C8&6gr@~@P4s}+8$1Rs z==ac3F`tS{mcdahNsr-2SQwF`T!4dPW!u#-x1Q>=+eUa;t>H8<%GCQ{p?uQJ6^aSV zy`1TNAYK7Cp>WWeLhffOUV0xAi2E~nI1+@@4!S7Q=Th!Z0&c7jt{;WRykx6X)_P77IHa|W$-7EE&n{n}Y@zcc7@r)djb zDkgh^^4&SFT1?yUR25{cXnD2Sv>o@JpaMs&a@&RG@D%qu$_g*F?Vlb?3L>ygaoh7% zK~Ff~R%^0BRD;6>s6b3r(7J60@!D4dKWoa0G|D6-@O&QBn5L`>t40)-tna)xh1Hb5 zHsM9Z0!57DwH+^qItyr1SgpzALMFyP+&0BbFW;h*R_KIk;GOLpFHnE#&tL+!ih#X^+qb%t@(5a@DCl>Fg0(o|tl7LuTMZlO{C3xuMHVH6CtXpC zuhRCg+>`ob0EVbeSjD~W6kd43w>f+FI2>0iyZDNVtGf;>o$htcN66YK{=3D1^$1T+ z$<+@vQ=Af>>A8hr?7B|we1eR(K&P<4*aBWKB7O?mdbPO$oQlu?Feh>)peQRSQaWWJmD^gTc|D+tIuLAXzqr;Q0_JX#bL^_`r%2!VumZ& zIVdfVU!VWh-xLwH9zNjjaT>;El*LAc#lAYl=A!9Q-ouE>d%5aSm)uoNm|VUaA_HY7 zT~4${WFqj1S9mBz>K0Mr?OQJC=&($NIPN8+X>dUc-zUvB-$s&d6?y&pQFPmYIDih7 ztraV#4qW0DGlgTdY(I(9Gfw!1ZYB(8MtOn2|LkitU=f zav~pexKV_Masumq8%O_b-R}l(tDcdLu^sDv>`wc>u zX~+vVlqZK9(iVgp=2*iG>(+%E9@`gg$cYx@tnK}Y+;zT^*g8a2MWV`Ud{vPc*BVXb zRlX*fTr$Wdhg=9B5`vu-8wkDVw9%Op6j7KsF-$I*YJSJ>lbkZc1%N_fANYkw?U11U zYbX`4o81WXKa%bwkNNjWZVo8x)XeDYp^0(SmPP;kQ0PXlXs;-i6NuY%5>2p zUG(Fa5vnf)&3r;y7eq1UP{vFYrChS#Aa*1aZc4*qYk5VIZXJOedPB%# z=u3X6n#bvB@bXzm-rM#F+>1F@`Y<4*push8)M5b+({XmK!BHdHku+$xb1zRPO*RBx%+RcTO}U%WbVD2 z;6$h9ad3sRPQ1sCHR9b-iUy}y1nMF${11H>`f`|X<~aKJOFVbqZLA2QGwjjT@8G#< zWVDrwc9Y&6y9-u50*Sd{fPOX?PfKs_&zn|8zjjk2VVK9kxM0*hdiobWAqtHlR10Zs zUx$*}?L@T0UbMra!{G)A?swq61^RzC+T=ro0@4?(qS$RhyYxn`dg11@vW>wES<)q@ z6*4Us*Z+?n_-o%*~3Um({N z6BPEH30RrkY!>MRBIc4-v-O9nPX;TzQ<-F$Gdf-ut#JxgGbD7<#N?UCHM2So`+H+! zvdWRv;Yf00qsjcL$ixo*M~Px4K?^j)(t#tq%`Mi|U7a5;>HdZvXoVOgk+J_v%)~*5 zn;f8ox>O7&wd3t56d|?;b=Vm!BHa5f6h2AEeyaY@gg)sa)TF+U$4qdX5;i+wr#YjVGMtY#WETf11HyVI^i9YVWq4dAh@=jj&+ z9_KioU1NCe`9I<@-N9pP7w}Yk5-3jrPqA0&4Y$M4s{ArV*!yG^&bkjANc(btI+3Ti|>U3SpUqu?l(K8>g%7q zSM3aSS*qMy%QLF#PgPVu;VNjZD=N=uu0Pe-@`OuQIQfO0l&E62yTVn`_)dk_g#o-` zhqG?kXir|>wjOi)hOU_l+NZ$+0*ke-uS*jJ5;Bs>e_~YKGE6WhGg@)r%^{CsJd$Oc zT36$nD#qP|d9&8V>fJCcf+wixW;P{}iltem1MP#7pk(TiUv{45*_WK%#P6{J{pJhk z*n^QqVr=kb~aYVA|pZss56d`DYJ1y{Ay{C5~0Jy(2q7xhlN)IDE zjO1#s+~f{638{_8c@x-bq6w`xF7Old33*62R2#Z73@1R11ou6mkYhOrf6xTJ#izQq zU+ycpaN%ur#K1>H+%~&m=dIr$-Zs1wGy7y{M(d`gVLQ=gp*Nx*q7qB1jtv~-6^|+2 z@wml9Pl~w%e7cyRgmfg5?ePFDhx9ymd{kX4<@#@uX_EYV?u*+WCTJlR)h0=-jvspY zUXO*;8~^olk$Z5n8-w@Ra1kVvZJltI2AWT#L|biz9mLfp@U?9&&m9_7>qEHKn^l8i zPw4k=$hnVhf2b`Wn7e{K;UcwGq)rNUCQLt-Lj-ur$iEBl_FBwvxr>+zP4k7p9Sy&~ z384{j~b#Sbu-xcA&pU|9jus zGuGc9s|xfmU>f3tzGV0~P#+D??EsyExjiG-nMX(^J`eJ^_Zn3rf`#~Tw6ohxfw=8? zik3hryWHj20p};fyFwBu?^tI|7-tnjfK_6jA1ifW?nd)m(Wtsq!g0upsB7x?#eGg# zZEOs`6WSbIeM=*$E-251B{~#Fc+#H!BLR6bo#%2#)loqtRkM^3_WTW^E{zS0`%4wo zV>JBvf6x%xZGaVHH{U5wMnF#4Ci9s3(56xK1qo9pxt^o}YWl>Dars2H_(ftr*6yfj z$-o~NE=YT944v+;=n~2nJ5lJjiNF!enNhWzZCebf%D62NadRT$CQd|=3wNX`($awb%C=~K|3oR#2O=?*4zd3swuqLjwZ+vEwOad7c z2m}OdlY#J|xP`$gs9i~*fFJEj)Ihs!SE3-=_IVWDYFoRTj242jyBOUSDD4ihC?ZtB zuC=Z1BCY6Je72yi*41sLrB$m|@e9A0|L;uDcHiguzt45OUKyD=bH3(2_qoq~?yqx8 zVN;(oZXkA6-P3clWDfZk2VRQcu`Ee6`hbSX*FG0OHjBtF;``_SoYsmcfJAhaBZ64$R z>toNo-AO@QsL~g*wZl#h|C1?Cef2;lk^~I*r<%U>%T2(x2O>_s>a^OU+Zq|LYH0l} zxhVjI(Qg7s6+*s}MR*|WsqH57)LVT!a0(0B?=52eNxqhzj(Or(=!cuG-9#h*M zCw2fz4~yb%aVHa@8yLrZ6Ox2SUiB%a=XeFm~F=xp@8DE3H?uJ&8v{X*u*GyzQ{s=h5n|px2(Uu z4Rv!Ic)W15ZeDiV$D6A@@IVga-_Af9!A0`D5V(_?5>-v=ABWv916nmaAHN(Tc>3HO z<6*VA==LyeE4jBj5l*bj#g7KD)_J9wn5q>SW6fs=ffas4Mzi?j`o-o;U|W+JAn*gk z>=zV@M^VrCWnY#rhTJ)KjJL65Fh(!szD+iJ*#AB_J!$X9p_!)PF|?V`E}Aob91KqZ zV{x$Mvmx_DVCG2kp+VzSaF_QGrSApm}!1mR1Aw$ z@YCrGci6vodN#0SE!Um-uE~An9pF<_TuWVgSj+5m#{YU-c=zpAB6xAHA+PbV0RN%5 zJz?&C^88ak7^kbI3^VSW3Ytvt@%+E{Vw~zJ|GHu^2axloQ&<{!?`)t6=ed8F^R!%} z6630TiD6aFrf~N{(h6*%i^<<{%6Ti)n~Gck_NYJ36Ru04i3GH<>f+8J_l4V7h0#0r zs==Sm->0WwjU#Y!|95&lyyg{04A0;=GwO5`yVrJ!4sI}kFaN|{8N%t3QZuZTW>kVe zcEj^;{D03Y?hhuJc`K2A`?L2c*;o4eYS}a7?!0Y$TJ1h?YnWH~``z716Jpl7fAMd9 zo-`$IxX)LH*BC-`{CJi`_*4t`faY8ybr<0f@uWg1r!(l+a#1edYB6bv zBrQ)+A`zkqMHcL?zQv6qAC=d`1$gs%y?Mt_7C$m~?@mz0%3T5CN`sr}l&E$|=bJ{V zZ@49QmJ(@JWi>(o`ZIHprdG_{)=?#TRno}VAf&yVBhTlo3${QLwIaGz;K z>->CypP%M$h|q}txRsZcIJ-OB(W}B*10H9A0|MIRw=nhEb@im3m>^@|5E~0TgisDl zfjFCt7k>TO8F;iT3A!++i^EGwbUKJYx0n=$ysKSzo0N|XGETqC8c?2cKSy{2U42Qq zx>a55RMiXCr*9E{<`}GgZkLSLpm_g0RsThqFdt`v`YVS({`vW%`S}VqU6FA}TK`{y zT5)a`J|6w=KO0m$z#&sLs-O9!0=LwRTd%1SXFGfWKHrr512+>j{_XQ)3h@mL05`mN zw(}(IZAi9Gx&_+_rFC0QsqG}=okNs$HVc&S&-oT=e4Cr_sIcU|`IGlf=U$l6+!l8W zY3@q|nyo56c5(*O&Xyv{5*Wm@r6q#8Rn<60)o=>d+Wk}{q^6}%qdMc0zhbG>&|A}H zBP))QOweF(8=r_o-^n=QTg>`f>2~8sYTHq4FiK0tqn_foRMw3*i`2IY)xLNN8aXK9 z>=K1ln}wwQDLd($jw)6AVki3>XvJ|xe)|FCZ0W&>cAm6j$&CbnJORec7jg`lq^-+? zuQ7QxM|L2A(W?IgM$G|!2yu&Si{q_V22rQF{-*#^b}QR*{|!8ojFZ#~d`=A#_-ygR z2cT&j<2sdf+RdW(2MYlmKd3m(eMs4Ugw!NIq<~=xCrxZE8ovp% z7{a0L*7WxSVx+D9Gy*Y-Dn4hO%Hf)XYtLEi2OFsP&`0Z;c>W1r0~0SWIe8SM!1xCm z2+>xjq-xdU{Asvim=A+rNaT%uBe4jwu#3d;PS_xqgc{5r-Bwmd#^}_LcVQ@=`q$g2 zP?6Zt#-kjTagvuGYq=tuUmqlEG!rzy1oSbw%2bizD$NLDR2jlEfjuYWBqG3MIrK~$ zFHFH?1k1S6U&b)NjO=4Zh(#)q2^EV_ONdy6U375ICn&~6b5j&aaR2Xt%jMyiqKgOd zA>h7Dou-}JORua2vAdv*HSrCwcNZIM?OP@C`7>1wTBouM|>s|&3) zdvzhTA)+q0=P$BiK2uytPKJj1)V~Pqg}n4Kqwrv`#c?>vs&?j%JRmJ<<$7+*ZQ<_} zms2pQ^6cW0Jvvu+u-Hql&l#+Lf!946-3hCM-Mm^){#Nr+7mbe;b>m^Q$Mdtg1x0Bh z@`=VgS?Uf!9#0;u1z}z@9lB*?J?C$ioN;=}&c&QLjb{JoZ}w(u|8GdMl-Hma4S{Pb zV{TO>Dd!b=3^kErfLdERMeJp!*ZOqp!~j)6X>|PBXji#)EiX1qTnmo~&_21s`%w7+ zF&HaZtKkEvE)}>#fcepK^i#PT?=}p&c+y!TkCR=|RZk)5-_y60{+&Yq4&iFRwG0>5 zRl7lNl9tB^uc?Fvb%DT?PookePK5GDBA>Q94_vabRFajKW+rTm;#s<+J*=HoxjqcB zu?#sV=##rRk7Re84gyr*24n-mfq^fG3s~_`=(s@cg^c{MllG~a@-vE%=I&`hS57Sb zHY&L1OGx69^^niLNUgfsl}x2Bzu}C~#izJ+;kFcpbZ|qaA^9ivL zK>5c4F9!DfO@?s3s{OzqOkTE=Y>x%jkLmePz8DC6X_rh8zV**1YTG^e+zU-b?sX&& z&U>E@1(@HQ%dcLcN&rVYuCci85x(&Zf$58?m|cJ?516JgSguvAZxL)3~- zG7-1QN&C&V8>Kz#w(xuux?4$$MZq{R1#dvl#$gmibFkMO($j#kJQC1;;Bu?Zolg2P zj*R7-{;>oRz*t&xm9ad7vAp7rBzZ8F$FHYr{L*I3js^bxL5%@2Cm0SjMS4a_o{q{< zn6Gtp?R>mRYstlm62IRKhjA&;@; z5ep+B6lOZa07hV7rb9S8frpt6u~^{w1XH4w&m)M=h2lT-1M5zqp(#A`X_u%`6`WN_ zgylfGsG%k4nyV(n)Vx#Fz_2sKRcZ2SsA>=X*SKnFOYza$LyI@qG96o zkrjVrHbw85RoKDB6r|G}xAXmujt@I-e&1L*rgoAY6vB`gJ|?>+a{9Dsk$ILeaW+td zxDH*@I|`wBOEc<8>;7}rZLT8Vx$6nWAnX3 zG^|*OE_mN|0&FIA)pgkS!e5A3HxgoWRFygPbzEV%CeU=Z0k*uh&_Jj3usu|tIoD;w z)QM-ho~LX)ofl?TM;G)z!mCFPsP%p7z@$`OJtE1>tAmn4cs18^kt{O`MF9|t6RB0# z%Adu&ajK&n4w!0X8y{uN3f~q7yQ=(&X##05g%)-c})qerJ-+L7Bc5UhC zXzsXqp2-&)75Bp$I2t&`0BE5*_fl0iafCzyfQq!(uY4)1K5ZDNS!YjZ^*zq?} zO%uC$WlC~tAyhAXblGoA_Ef?m;$W7;6Cx3&e?Ynh)S5oEI_XU9{>q-G8Wy$TwG3ui70g%_diMbf&h#KgUnLS9(QVkNM7xX&7fIOi@8yy!zPn|xM-NPOdmcLOY>Xk4Ud z4M#=de$pjO!jYY!`o8_aF~8fV_J#)5byw2vN3V;C#DE@feGJENLe_Fho=MglR?%1= zDuQ{Q+HF)&Q)T_^+I@ajfo-s--4EToywt24DdU4P@>YHAY0uMi}H)+%x-lYs^eLZyo(i8E<`PrkA%qjG{Abse7(D zi-OCFLU^m`87FTgdyhnwv6FT3bB!fzQQ}=^Je51)+03Ob*x)+(Ib~X6>-SY4busX? zwpDHZTE0gdm%obhX-pqzTi2Oy@JR0j|ITiY% z#L3&RgYkbIjuB85YWAq@mPy#bTc@M>&PgoD2S09iFX>` zj5Hp;U9QE{iC8=;b@1k@zoYSVRTC_#2sy8sPO626r2qr)$;OkE8A@#kL2a-KvJ~S> zl>O8ZhI@m1rGIbekI$dr`NR9q?j1lyjd2fMFM8T*be_}-(mHP=Kf!2R87M1 zqOT}*B?Atbeps0A5xKO8wqR^^M={rUy8odFVy6K;zwraoyZ+G;BH0Wh$_t-G67W;z z-5J_8bqeY^fxnp)LygD%KXAWJgpbEz*sh*|xQMp=;xWf<3KGapiGg|jrDn@#P$-($ zTwnT!>jFQzP;0!ZPW1qK-A7aZb_*umfmBJpVM)LXp;gNMH1xJTFb!rowCdO-Hzy(4GS_xqsXx%Lry-hrUm1<5IdUiuq3s~$T zY?tL@x$7mW)ZQLHxpY@4OX}2m3^Ve&P@yfBc77a*fJo?5ZxJB`{U3!&=MkAOB=v3C z7rn5fIm8RXZ8@2e;hHqTr&#&<_|fQTuQ|9X%F#+8Q*^WMmkUw0or^4q9^vDnBuR}p zXTF(HNQ5CtFAY`)@iIyEPd>C#TXtO48?kEXUk8_*_TiZ1PfttuWu6ZC^oZ%xFH_x> z#O!$KKr?Dt@?o&FWZurLo4FXNr2|qSUltjJGUlDO-^yiKgcTXkQ=*<8$RgZGyPgrV z*!C>Kgn*lBi@_;^!KXf0vP50AwR6TWYC#uT_TXMctvWBQ2u4*HDX-1dAkvNghCSB4?hocdBOW}qSa%)ewXvtahy<~R4(l6O?o3INbI ze@^@U@f<;&{yf&#kkZ^(JfMdn*#0?^I6KF2Ga7njzL+rP#ixvT{-og_a^pXZ`F%s8 zhB99rN0!Sdz`$+$-9<9Lw7%!G_ZPT z>nHYLjn#xkrU(+kYqzJXJgJGH3CpxFGXE$hhX&)Tn3|_|@NrQlOvRf6<`YAL(jFg2 zrLG!+sEePBYQW#)mZ%h_;Fb{fM0Hyu^e2zuvGJy{f52#7GPEy{(M$>(&AU@mK>NM{-NX7~e&QO>k2dzIjUVX}mubs#^Rmo{w4inkW}6a&A&0K; z2FEa-FxTY;u0Lqj{vqZMu7TC?M@nR>U^<*<*c0~rKl=6rHA>etczOnvkAW!X! z%LWQP5m>IGtQ^U7R&CYe_4Ft0{G3^HI^3PxTyuT199A)QbbVscl)IEj?qMGzDW2wOC zGKvI7n~^UtY=%`}G#RM^qs}l3p@x){V*~sgf>s~O86i)RMW%nT42M+E^VrdCdt^r} zBk-z}6j-+zQ~x3t>|VD_c7cE z67XYv*Hu_GKIPG2to$;hbd1MR$l~6{+%{1Rh5gUGdDp6J*hf2H9S}3{_9tiV4q=|& zklWnbis9XjiD*|hl~`hYammK>26N^B(F_r5Xn51`RPe7Wr`l=JIUy%bX z#-iU}MS{o@mgrP^QPtH_wyFz~-uPj{jIk~9kStAXQN>jcySAA)WB8nNX84fk&LN$E z44APT)B759oW9v-*`_a53k_2y2}B${`70KarBv;VpQ79yVM^6)&9XE8G3AkqvBgpC zTR*kMO#=PR6H*)Hl<}qv0d*_ml!|e8R<`~NbLL*un! zNS{D6_M)F5`gE1O5O>L^evNV3`Y%kD^+T8zZ4uj0Ya{&JWqJMdtKgmbB-&MIYM0(_ zniln;)0{l7xkGvmvE#&|iQb|KeMOHVzvY^ya91Hh{x=sIO2f;#A|cv&daJJ})oY$0 znkV*|AC3Pe-zT|p$2?r}F;jjAr_|?;(HR%~-2Vmf#|od(oE}jSmNNRXAnlyEk?E4C z+*rLJNw{afC0aZ|>LB-D(}MIVZgTD+(RIRPPtwjRP{)VsY#;A*#6fz?^qGIwxgS1@ z!0x}o>oWBNxDy0N^Sq)d5zNVaY<#?(=?Y-R>7LbzMUPC06N^ZwDfqMtS~@yyQ!G=? zFoKqfH$+tJ$~BDgN~bp&V&MHql_?p^&D68n922HkSrG|HRX&i%r9B68#;Uma@Vnnp2n5 zbZkjty8cTx_Aj?-Xzpt6E~e{K+G~Eq_&x=8P7I(cHZwWhv|t`*+*MYlqLWoCb}{TB znp1NI5)$ek17JP8Fo6%KPg{R{0N#LKsJsE!OGdHX1qzn6B&_d6vM=f6)(EESeHxbS zN3^1O+z0Rb4l(Yxw*tooqNP8UtM*Tp~q=rO1Q}NlVn&7Qk@Lb z(Xrs*gh(nTlRDH;-3`2`IyKAIF){9IE{5=|ok}3W{_BC&1p1V93>aTt_=py=d34ie zP8Qr4#m5QFG9s$Z2|;taKK7pWqPo{SZXb)7l6cX$6)PI|Oc&IA+spUq&`sZd z=z~e6?pV@8fEIL>wnWdhTP;Y zr9~jJSSpSaeIG^5+ie0lSaQ>p`HhKFp?$ROi=W*1XPJ+m4AL#wNH6gleR=7LAuMyS zn8ub$(8flm^&B4(a%26k`{f}KfK4QTW#a(YHNYM21)vp1bA&RQ&AIU(kBm_XMdPeFl~VFBC2Ckl zqEJp^cl>XSxrR2?FkSm8>t|Om$&KeU?OhPz6U=bGMcNW+3(z9kg9r-Npc~#eMpRG( zeF33yyrOH>c;gJXt)DJp;T+}7BZF%%BAv~FNe5h=Fb=Bvx(Ss7jq5!S`09b}ySYgy1UE9JME|)V<6+txCm0ver!{_BH`y0Q zXdO@xM$Sb71iWKCYjIF+>a5C+Es0EyN482=KqQRI)!TFSsq_DJ-DghoyiPiNCIOkcr?jg7=Xb<`%s4dZHz2;OQn@}+Vp69{O-k+T1V`Mf(-u(iTlP0?(u84_d zqwdq)n3&_`uE!wIq1q2X?n43f>}8QWdBue2T9(%(Kr*j(_OnVH8N-+-WM{an3e4WXNP zhLvH~+n0;3UdA4y(9sfRy?Gh!4sSTdTd!ZnhT~)FD?i-daIoM(uaGRhILi9NWpIMi zo692TB}D_5<)?eI9%1@kwvU7|#i?~tyXmj)BCL6~r0mqrNp_Qra zL7l}>Yn!%51RjgJJBD8_B3!W zw5t1G4P>X$?V4yV>P>cM-G=Jw=NVW`by?tI}5?u);)P} zWn=dcU={B$xHFZiVch+bs?*(%q3Wtb?#@dd-EhS!%AG;7G(jtgmus7SC3A7Ukj}K0 zus+=<_E^0;X4Sn>y>`R4rN*NtYrJ`TkcL>ofA8Q% zxt`j9&;yQ8szuXO#JvE>;GO%<+X}4OBK)AcNgf@CCyU3yD0ALCvYQjlx`a}uYcgd# zFamlmr1Z-Z)@v_68%P+hFuk#oa&5GFXOF-eN2k^EWWN}P!DRT9no*=od zoer$YxBvaFYi@@)JJ7D^L$_{Y%4?{?_3`0M`JX7ek%+%m5h(}RemABCvR)t%*sr!H zB<1!q<-es)IUo-F`u4!)Ll85ZA8K3J*#ERbeJ z28;LYA<7x5#sFKBrcrCxR*vFyHP(`IBTC@Ss(uK~mxHxYgDu~uK9sKvQ=`Z{7Kl+= z8<7}wC^T_`wr?`e#+8J=Xy<}lb3=HJGtD(|qV=&x7=7f1Gi(86UHOA8?nUGH@b9~O zqx8K;2J${`kDuvFn3Ka%Hk8T^@p2q=;pUV^lj&N*Mjo%~51SYNVB=rpMB}?5C}((3 z4k7^7Ms*)B|Hlsyp7g4C=9-NM@pe}`r}-9re#A*ugA(uI}>U$c#MxT?Fdvqx7r_G^0@hEdpkelw7_{GcM&DMbJR5vLYk8EUUl| zgf1;rlECe@hj1qn(zQ9a@Cm!^YL3qVU_$x(xh&UM{utvssp29;CLHSZgR9$Bj`rzS zQAtcQ{<93Sa5!4a7mv`EDHi8QYrI0zrklBBgk#bwxOG==ckh(V@5r+fH}t6-1F8ae z-L<{r*Lz#yhRRtq%a=rqc+fvZ`3!9v=`Jsgu&W#ie~g1Qel(JJiN?g{?|Y{={%2q- z+pmM^TdUr}0E7j`KH8^pG@e2%cbZG?`J>#nQT8cSDN=jO=pnwrVJdN=TYR=!hSsS%H>c->Wh7H`7P(hPaY%fah2 zpV%k-e6Ts|610&jgDrM$#WzgXG|CQTWtdZQtUM6?MwmWd!enDJ;ch_o;dY8gWf$Ag zc$D1t%Lc`3ZT(}HJOLOaQFkM^=@1uHyj*Lw4RR4>+2m8K?_cn!mu3#OYN5c!GiG(G z)&p(*6x?x9?V3;flfvlg7vhv%xU-Wv#ZW4Mhm30n&Q+p z=WK5Dync0S5XTK@KHW!F7|ZnV7p~{oopDLC9KrSlNu0(RH`$&|^iMeBBW})96AGdd zdH%%$?O67;({NH zjpe`KmZHenQ^gB2_*RB^e@rHyrP%yT`O0te)3h)&GZAsju-`~McM(QkovOT3ocq@o zz;w0Uf;RuL27maZNGddwN^B5Sc`hCpkVkq<Jr z-{_86*Gd9TOT zOre{&S+dfK1SM3}N_y;ymud2vx&NB^$?J4_?6Q~fQdO2kz6Uq|?d7R=%fi+7U#oxe z+Fy0dYQaI4t#so`gLp7_OY!DM|T74 zV-(-3T-0N}{yjoPz5?amu;veT2#^ui`R8L*8nm>hmUA(!+?Ct%8MjPqc;hx(4*Lg} zvWM;T?zmU|$KcL5N5v|QS7KoNW{;cA>2Tm(#UBOxQCbXp$0xq?ggc!cVjjwLH=a z2E#?wLP1qhVPLuvu|q&I49dX7sY-4n29W>LAB6=jB{JINXl^vO=%vcwogHyRB7CF) z=a*o)jsDPI9g4D7WmI<~?`K`-<^Aolhjy(oIaUVDE3wX0XO?Hp%iCa^JuHX>NrvTWLCfQ=w8?P}wiE@`;s9_KZ6eJJ*;<$G8BNk0~ ztJ<P=Y5HXx~m6%*5vFWr^p>gv{my=tvT?B>Bnnv5g8Jj|(2}Lagb+>2Hpu7bN z<&7w%$CeZt><1#9Mbxa44rc)&zxY`K6q-r|nSW4dYEQ?Hu%7ty+rJHhVy8jp}0svczmuqfyTW*5CB<4l+v*%UBMCST+3L5LyHaF7lF?)6dIQTTlgJ z7b55$I#AVU$5V%lh;(=oc!&G1vTL;o#q#&b6PGdg-|j5T-zyiW^me{5b&sUvEQG-s zMuhvPosUdC?@x=2`6vZZ3spAz&jh=G_Bcbs4q31*6l^7U!mn{t7&q1@srEO^{kII9 zo}09Pn_P^$Xl{~ut9*p9oHtjb?)S;KJ8Kwy`J$gznZ8=S z`}d(grB(ei?tzR@=3jk(zK?JCe9R?SQani-#Sf$Y2Y!IGCq)hzkfTiz~y#Fg3Jho>*~? z8VQcPdn?t>9q-Ru80=G<7Lg=NOBik~*X5=KMTgFHA1+r@Vwkof-q&>vo7I(wN=!KP zRyC(C#ioey`+(Vs>sEb3L1;eSZ41rDk%)pl8+GBQ;O^(ZSf`)YfzaOXuXIHrlsWeZ zp*$xQPbT<sSQ6>hB^&#mS7F3gTG9Mj(LdAXMQz*|@24WLdXjq!LJKCkl- zb=U)lNAn4Fr~S77TJ(7Z`kVrKHOf!3ikqXhEj?8Rt%1UXN8AGuYx_XLR~A1)nikY* zR~jtpRlv3s`}i%dGhGGLSH&TViWWP|;_IqblqFCR3z>2|7GcJ#*4orMTTwtBV~cZC zT5H{6KIuonHF7QhsdIDGKFzj#zg^3hh@e$`8DTt20vYyal(pzwvJ%glM2Gkt9PE2O zm-j|BscZ79cP{T7^W0b4Ln<5IZ$m9#ag?WFTj`#*3xUu+AbvBP8cDL6LZQR=>1=WA zy>gs))0Cg`C+p7lv!u-B)iXBKtQSk_Y=wk5b4?4WaYM+JnQeX9vr=a(WNn2+77A5U zu8E7F!ZWFYhG{td(UupFWLqy&7M6{pEbVg$8gK-!SQ?0qCEu1$Fwq8ZEVqXs>=EHJ zQYKh~dw|_^yQC^y+RDtzT%Iy#e#+w;N{b#-qm3MslomqtXfVfB3HkVu8u4q~gY$nz zU)BOIPWf%%6w=jpcR(8OqlH~+3|B~(C{dsfqO(x6zk`x)@w_)t?H-Ufd1Jh~JYxW} zs?U@Z%;%AP*eM?W_5W6pLE&Xp#;RVH#OMO$J{`z<#ckiMp^HP#2k>Nsj&xakeQb#=VgeX-cr02Se&VMV8@Z{}1BqnC; zUFm{19z?x`Eno1RgC`jThY#R9n8!YCst+}LoxFr^I3#^Z0!)?4q zSr4BDviNQ0a-gfUW-7VjD8GH)nWyd!_|^ix-^gvZ|J*3wC3|Rv7Og*Z&HCY4pR|(f z+ev7ZClK()_{}JO0VQM;dmCHvdhT^+n6aQ-M^=M5r1vje=K_m*Wp9{qR&B6{#ff6S zP0jy@Jgcz%>B3oa%}eHXNWTu51&P$M>avIeIQbwdRoR?al$>&KNm{V`0HP8hh{3g$ zf_IM5o=^GmiQTp@pXgjCt_b8JmZTBP!4qz4O+p9*eDkR7*(t~`%pnK}Kg+7Zv$|7H zlF&PT)&r@QfxA%6;a}>_b1Sn}yrmJ;kFW_;`s|5s@q%<=(xn(Jr>YqNv!fbar}~kO zw4LP-Q!_57>rO9VkYJCaWwe%Muc#HW{L!Sugg3NfSM6x$&m;M;v{m+(vDD%L5=R1$C;kSrt_j9sCjuf+UQPq9P4rB#IlzIL+fmb_u zjdinZ+(7NaSz8(XXXQM4Z;22;W3PdWNO8?exesLfp@-*rBwymw(1RhUsh`$j%a)zB zYH(Iz%B-|t$JdBu#_&vTRcP_xkok9VLYS^$s$WiqULE%m`Oihs*V@uvM;zWh|a_Kn}FB#R)?R=mMBxi*lS^bed(E zT(bHCr}7AbLWSY@1+T9M#*m+&pP9tzn$nT-WIuK0=jh(2=pN) z8DM^lHva>%Hp)elv9U?CSE5WC36va#RLYvw#+8I=JQH(gV2cI)xF)yld#iY-(hvCA z!2Bb(CRCJMGt%+|uq~AWMjHw{%6~ClXPXKWqGrPMkc;dLxWTQ%=|57U5C9 zwGv*>2W;m%?;s9s!iRe0K*@@h17xivfsr2p54;;=^cUDFcYE$mt;FBUxF+mJ;I)yd z)n}!}rq@(?v+c3!76cWd5pv7NP{nhUFq}P?iNDJX$fo~zSFjOi5ao1wLCV_IHMy=O z%eyC5zp}dOCYTDV8801cBJe+3L?!i{3N1^@Fy;=4()P6#Wl8asJ;oLHM01cA)>cwb z+23VQt`&QtfS`Z6J+S3{AjWaIZQ@%f-uQbzjxPN*$Qe9ZMD6XeqjcqdQR^7c7C>>^ zB~jj7vJEo+IgT5(r^lzKt8Q<7ILVhgtR%{*0$7ZdH@sTM?x*0!m_t$EQt!X*T0=Pk z%sE&vNj(MJC@5f5-rQ1DSa}BjSw4sLU-5q`M;tbmGM+Ov`{PGV5f2UAEk!^48TTPf zJ+c-b)p_=pq-%&8?jhN04uYkrQ3oyz{!-%o_KFt(`7Wo`ry%tJiagWe_di4(P;<1Dl`(WlL?9SB zJ&(0mDIA6;e_>J5C8TT06r_N*PgN$#JZ(;C-(X(-Es+&1LM_l3{7&0Pn+9mJm!*6x zU9}t$4a?LHQ4LiY943sfV=qke8i!2-M*$T#BlXI6=_>e+3973-Gv$n;BiE^1nS zW$Mf3T7sPYi|-1wiD#*V4LX4~^DLcUaQgO}8ebe~w!-NoN9!EPP2tw{OvO1KmQv+> zSiCO&^~lkez1jlTS(8r!u~OBB0CjpM$ZadG*3t@66c^v+&iS+s&zVh8OxG(iyF}&! zHa*Og|4Qzfs;m*92YC!3){Q%GUJ9hNA!6t|5v6)1N@IadF?Ul;*3eJOA*Bel1M?Ch z2MVVr$nrmM{dP#ETsV;&=^dF!|C=oP5U)@ij#>Dtk(jJTJ7tHD!LNyr!Pe3Z6jZsMN1z9N+~;8GIQ+PNoO1 zpY>*SnyH+|^Oxi~L+}Wxr^%f!^qmZ$hACJ)LQHaOU8zIRRIrM$##^{}q>$uzZ&?6x zw$-D$3_*}ii)w0O&BHUYPrZ$4E!`T}l0_Vmj>v7+H_1om`@1Ko8L^iZ@_vQ10k!J# z<*RK%S7dCk@X~Jpz$qRO; z2Z)F6&HQ-EG}pA%zJ~dol8d-VxNF|P(q-JN!4m0{PQ#04#)fw3w`abUT^Xw(hT(KE zcawu!^)IbWcWp4&Z5Rm9xT@ne{R)1z^WPg4SCPytY>Fv4q#3XjemBxk(aKcB1x4Q) zu+6Xby2NP<@JZ30A9<<<^nbKpR-)|xQg01x{Q_NwSfIDQ@lj>m&=EKT*gjH28sZwi z+6GaOdOkLV742<&hAq^@55_ivCN1&5UpP|^N?s$ zPe~VTWFwAW@h_D!ZD_a3-)>b}p2{%?W9io`eLqOr{e6979BH>xGPZ-!@tNG?$WU40 zM~Zqtiw2TF4}>h@G1{jqD2Xz2YrHy#e?1?c=4EIS z-v9nrkdWvg%44FD1^NDdhphVJgKP}VWmIp3c8S#FMKz;vcAP`+!4u0+B}vhE^0OD* znhJmZJTOcCe)t+b;6_9BN_eY1HXGU2J#RatLZ}L|w3NebBao_gEoeXFuK&LJJ!}0N zuGMDO8W;3Y)zxv-s7&h3hMl!0zsx5(ExK7eg~+|{SHvFVJIQ8LVM$J+PW7)a7rD@04w9q;tPqG;@?L&@4*Q7 z`=h$S24=r;riaOU9%Xdh8)csh$9V6&wIrAlo%MK zBydO&r{kqnoIZ>;Zk#xV17|`#qtlP*l#z85iJ(NZ~T*v)zc#{711A0sOLuGq~_UbCueo(H70V{+~E8P$IZo)%MG*WnL?f{vCS zk}1L)O)&6EU^HU?`e06y54kG?uY5Xk@{-*~x7!$>O@&!0*fcg9iwv*w_)nR~kgxpm z*r%UD$;-{a8hb}RqTZ)^)%R+)`yKKj(|Lk;s2hfl>poNRwM(aA0*;fMK)6)2&qm~M zf{O9lbb^hHOV#zNV~BvD-cAU-yPnjteeTmrJ@6faN&W!*ga|6q#-=8c2vV-!8Q)Dxl+#~g#N_HnH-;bPKKw|`m-|U-4r>cB0~p1)r820Km$_yWv+K( zWFps>`i(p>($vM0p0DQYIS3v7KX=^4fqgjyenn-Qdg&6*3~@v4OgW{?zz+nw(!d~) zV@u8&oGN=hjsfYyk^a9t*<9#Ah%$X56S8x3pfJ+EJ_R(`g((*K(sC8{yMwUCklwI(Fwt zQ`;Bv2m5wrY`~dnI18$|ReEc+@e7<-9sBu9G>Q10oD9cX3_*X_9iWU}k%>A(_1;6b zpD(G6aVm4!r(;D_iLnO>n8pzjFpU_{;Fn{O4&pD+b^l4%3 z+$lL;on~R}6J8Xr4M9Cpt%*b^n0IYqZ93XOOaFT_p0XlG-MmuMqB1l{#4t+#2EKpL zKar+guCVyX(5jX3iLuB8TtXDQHjEfK4rG6DYk>RfTEO;A1l`)+Rt|$8yhY-_?k6J8 z;cf1d?>UrZ= zrxXTv&z(1C!;6}hxg^sSWWtYSDM?xoT`OH$h9$H7tQ~7&;`84B)i>fo%%Oi z@8W{YVt^4!e!<{aU?L|_U9zbz_NjglvwlELxqAX_SwC=bHk*Jao9cq7`e8UN%%3Kr zaAbf@Mf%5r-z-Vg*i=`_DP$h-GJy%_m@X{RsvkDJrSqw-=Hl>y(Qx+T)=f+0qH04b z%_254r~JWLEPuX3-eB*jx-_7=(xSl^$Fyt| zEfSy%Y10Y2*jpLLPXdLYsG z4P-c3&;!K%Qh^Bs+J}WI+$&$DGDPp1mo7WF|dU4Y6=)&j^_05hz zIftdF-!XNdN?fOIIj4u?A~zjOAr}d_iW2xdf@IiPiA<_jU0U&ekoSHMx@gQr(LY*< zU71a^?&Y7$(H{1KOu9$bh*g8eG}b4rhR$(jvoDWv?^BMr!6{BCNjAx1!}hgn@Rx9 ze`8P{{>v&{r>W|hX}HGYp85^J-3Dq=>MXcN@P*8&*D+4M5odl+#tDx9-_(-YUM53D z!GN0k%W(@9ESx?G6>O)prd3@S-gh8!`>&k`;%ePrEIn6*?Cw_;K#UQ?gT*<7zY)Vj z&K8Mt$QQk=KDWGZ*NLSk|7F+cYTVTOvG#0@J)6jR=RMo;)ErLpJI&6u8>$;uFUtPH zTxs2w{}lZckz$nhkt6a^f z8ALuua#NHyKutKJY%;ba?Z^++DB$%)_z40Vdew-TnIf;1K9MZ`$9UqvyK@3$J z0;a_q1Yb-V%TB|j%Yab%H>4{$2dIV&^2ov=gJt7KF^(@%oeP5Tid_yk`6~*d{^yGJ z+#@AFq}V<91l5mCVrlX>$&!F`q)GE-fSLUZwf@&%!;8c?N)P|%d0R~RyT0qRFi@{L zZU?lo?G^((#;evk7lf1c=TKC;CA^hY(q8>j?M5VaJ&82qaGG-g*=)cY4QUY%(g<_R zK2FjiAEXfq!)B7SQ4i8aku)6s-flrK$>Da%`p-_lfLWrD7J!HxoO1z*`wkQuhHu6H z_Nhjg6%#d0L%2-9{3@068;XK!`(P^EKDU4VYS%t(lNEKdF|c!f@`e8eaB8KtB~*h7 zH&fXqWbBm*#gWoK^8)OMsKpWoTdx9k0_|HvXgOyg1y_Nysi)Z^+CBNcF~o<|%hF?O z>q-jE^ZrP@<#VFsm2Y!=ixws z%LoeH!QhnBFvjXOnIQTNddgQJ@$?M-o?gYmdU*<@%H!g$_gSb$~Q{R>U zPHt%L^NtZ5@AsG2%BAl*cAsUp$h-f8VWZ_7Gi15oNjOzrub`}-j^z)uUkJU2b8`hX zYl=nm-m^|gl`8c?tpx*C-^V&=*WlRq{c^tZn)C6y{!UrO%N*-dHW`9C2-X#%nuE9w zFR>ZHKkPlV{i(938A8lb-{V7g!FS$`E}5}nOQ>h!gfGc=g-|vf)MemYDQTlg&@oin z|3}%ofJIg9{o{M~To`7yI^0I&Vlx|N7%mo!mVlZtC?aYJ*@%|!5p)pE&N-4N&8))= zhJ%hL96E`LHH?k|$(xR)r16HRNQfqwrluaHrc$%a6cK^_`>Z{q_5S|P^M9Tn&urG- z>+)UqwZ7}SzL$WC47>c^n$`<3E?$peTy&FVhUAI|O!_dl+0^J#`$eP<7w zsCr5qc{{Dt(IOq@ZW$~Y9{{0Sa7MD|8S1Y3IIB=Ih^2)&_3<`KE7FQR*={hhX4di{@&?6 z;NV9ppcn^xK-l|dXYsuc?iM^Ayj$Vo zEGbPx8~=*$*YW*3+=XyCxVdm;2up)&#+fegM*763;Eut!A{zSe;QkJ|){{i9N)7cE zZM_flk{_`hd8GTm50n4k8VlaubAsG+w80@_CsS<3Ae+(8I{Xb81MFmi`eSSr28Ub& zD-Ceh22tmiYEjZIf3h!cRMlX=J+(I{i625FO^eB+m9c$p~9y0OiyZCBPU!>8voJ{y;{ znU!-XH=o9Ew11j4ob~2%E{-~`Z)W^Ar?bMr!j_?(Z}BzZPr9wgsg6%NSNS|}9Rs-hg-wBLwRNDNR@6q=t{Vnkf@o&c;;w>{0 zdEpihScFARm0VO=lNU{eLqyUcA}!@(8FD!6Bt=%{h(Z#iC0wtISillKKkE7Ag~8QcSIBhsY}T%MXEc z<__xkvld2LCZLqvx*1|<+6x?Z4U(y3G}4`^98u_S2W9&>01ELOIQFy*Dp$$Kz`)W< zj$30m+|!cAk_(wg-0VB$baOkR^*f$Qapkm4(c?ks1CfQY%;-ck9O9W&2}C zxZCeDBj(Y2=2Y5_L0z?yCuB7EOP=C3+IpN*$?VeQC<7(AYKih5wc=N%a~LFU6Xc}H zpW;^h?YVB@AbaW_?v3}e@!Dr~ov;bSlR{v%$(x<$)4q+XdaN7SIfPE!OG-d4j&lCd z18KV#i64O_pceg&&Wzx8{ml>FQ{IB#0q^VEcvU~~JH&u&U3TqXZRN}bk^t)6sZ9%G zz4Om?O>R;9LtZN9FFp1Ux_Sh&*`6`LEVgI(_ArT@+O^Q1Bu`=La5S8Xm?9ALidJU2KNXy@HQ=8RMO*J|0O(c+}8!A)nL>g*zutM%64 zrqNMJrv@>yDgU&@ij83Kcg#sNdZ^BJa7l}p^_rc1neh~4syqC5gWS=ZPI?>H-@?u# zgQgvQIb(YClgYt|K*I&7Ik5*6h%DlxcdEv|=f@P|6&m^VPHOCXBBlT(*P|psHa%m< z9>eZX#E8Vau~-V$_NJ^xY=%dEhhmHxo##=xs+>zHGyDhqnb6EyPM0%4Z=jWy>qwlJ zZw&cvhIjzOHFE=J%^#2|f9ft<%Z(&*Dsq1h zX>!Q@Bc=rFIri7u?v7MUCXg&-f{4*mw(FACU$sPStJYaVZ9m0NIw?rovm6_5^(y2{ z;cQ8?sFi@Q@LnGyDpQayKx|u;HQAQeB&zL3RLdEf*@3%mLl$M=7ba$TRg(Fc_YHiX zGQn#%GcXp0UM|)1rjUK?LMV3=tqZCRG_;3WV6|ioD-$d#VASmO{W~IU9g#W zuZRA)cSGAJFm4A2U2WCxp_ol{0vKG(3Q}Yyl%X9uo&-Q;g9b1}#LH_KbIC~O}+fd>fhInDar~|j*+B?UDZcN?iMCJaTNm#s5elZOHul^4!HYA z53#fzM}tL_uH8qwa)Xis8)&f4O#q8)BzPJFNfHCk(+E{|0BmUM0GhOa%%fy$K(00A z2SFcjihlS{!3}i!hAUure9RgP1_3a21{Wqr)f#N=>ar{3&R=nrCR_>Ghhr5F&~e8-UW8_mK9o zAA4##<8DF5KCs-r|B2}1KU*W1AFlb@8j@r5P-7DwqP ze~34?N5mBy<|70@S0o9Hi+GeGBGoN<%WcT_ zg@%*R>@c_Xi9LJ7kK#BTA#MY?QrWXdg#V@yJpAD#(%#3BBC_3v1&`xshLbo4Lr5I= zA90qr4GSN~@dT4NtAdb?{j7*DXhOYPVs%g5f;gMW7;yz6N|o9e7eD~*dkiRHoqKAP z7Z4v$qwvP7ABx$}YSldkzx*^VPV0s)NXS8=)4?oQil6AZMYE(dK@!wb6 zzPbV^Sw;P>#(;WP0t54#=ASDu^qr19){y#LtNvZ@n$9!|;BQ&1<1;jWPr@xVqAK;e za;Mn7JJsp9UB9c|1>^PwJpVqFj(zHECmWFY0*Tuyz@p;hg9=1+q4J4Ted6fbNafh> zaJWiZ94Zvu*-j`V=2uAqRurOlV0sH6^sG8k322TXE&<9tc8kdKkoH<@q^Os0j+lZ6 zYduyL_3bN4DGfs}#QY0bB|J~|L9CH|mxwhMybIW#VnvxB28{sw4mfifa3w*G{;~I#Ii;1qi=Zm<5g+P)km&?14B>W zU>z{Qw^0A_0F=Hr(1k%0TtCy+uyAY6C>V!-!tS_jk$qTV1-L(M-iJ@z5a_nT^I5*L_5FW%TJ4)6Qtx#XSs-tW5Yz=KoMNN2KhI}eTfYK%HxB_*p zc^O87D5>E}k+4xvEZKHo6J0iuVh*}9w-Lxn%tk`yr2L$}VPmTkH6s-M-*a$Z=41N6h>7dGLd;u&aHC;0iL1?XGwO-7(w zbwrZiO%rbrTv=%!Z)QsK-~Bp)<&2<^P1}9e38pjIY?#nyWne7J1a4FqH3IKGzOr{> zo|fyg2x|?2Apr~{ufm}WV?QucKotf`(XcEZ!#kjy4z)L1>1?-wAcZInih>u{xAfR8 zAbfJIQG}Mk9Q53$m2>@|pZQ>;z@5Q22AQwTQQnqtou`#c#ATrrFlwhm<+)2MS3#c& zk+~`o6intTd&ZloHxD-m(EJK`mL3}x$sgSHXbUE>r8EID`2DUkwDMv0SK^!XGz&X7(PBm7M5G^i6f&t9=;sPx1uZh;7Driv!zeE56TtOFxP zLMS-Goh?Abgt-=RNXeI^qMDPmQ9rzF4n-LQB`B0ZT7BR>LdD*rTFz!vBZg8bFr4ER zOiUuIjQF{xN?0{j2o-8OiNB|kFQ=`rK|+`Sul0kSS?0RPvEmS@!HiHHtz_!m!s;@j z&<=y545F^*F}ez*eFxqrgm~lx-a9sRP%VT(y*A4*`HfT0@^IF&- zLSC+m$P31(&Uo{}5`oN|jwjs0KGzpCxe|MV0@0uE6B)V`eS#{YGi1RaC&X%5o6*Fl5}+8#wthsJb{uHJ%nhWU@V?hMkj zj1blKjv>qd8k`}Ho@StSCXFL$=hwH!ToFkXfow;X; z3SEZHCfoB2nEWJt)mo~TeaOs(kH$^^+@3hvfi*L2w8K&pzizbmt{gE6GudD&{{Xd`Ola0|zTm60LGQ@dv2 zEP7G~9f?>WJr^IS-}xo-w&~MA3oF7ghAec$nwW0sESxug<~-*gF#c8peC=7YkhQt` zGBloz2)FLn#XTspi+MNu>N(mo1lBmICI-emtOhQUMt@X1aOVW#$!9 z!mvBio45eed<7F!Ff&G$JF-?MExrs5+&RA}XGu-9fu?>*Z3Y{sg|2O;3EI9jGJnly zfjp9({>!)1>WOX)qD}&q)-tBS%nZ?!LEfQ{>W={a!yDp^5;@z8yg|@6$A1~3$6pmu zSsCmkavZ}?voDYd25mbry3&X0C!jt-zBo%U<&Tb3rB){L zCjh5|0%~c39DTG1s|u-V2s0g>F-kZfbZ9Ekmp^k2V%HZBn13L4 zQiZA^{QdzOh0hNEAyvZMAs{HvgyF0sU>EzVX~J-jDacYqi81!%ct(f~6NPww#+f4w z<8UWAdk6#*=#Id$bZXhK%Q`;{5%XQH2WorX6`0AJr)VV|F^Epj?fbYclv0p<4_Fa3_ zh)p43jPfm+@o9`$R*YE-JfFK3kCLvYf+7dQkZD^+%3{-!NyPP)e~T)b(^ zSSNkYE-c!#<;h|PWe;4<20b0KneBlJ##sXAj-vVSJ7$V~HBEUpL!wMAaaj4x2b6WAo8|R1`VGoKNQtZs)qF z@eg->G}^tRD1kZn31khu5Ih@?B7!|Xs8ANsxfNItjnh*_oeB^H<{y`=b8U_0omZetJxh7;CU6_0kx9z^HaChf9;4K+UaHN- zeW2tf2BUt5G-ny3Nzbm?-#KbZM@8hYo|M#gVq>lM=^{67ysdvU^E!^hF$= zg>JRt4JC3l>xj2|yPY3ah>DRGMZ7vg?A<$x;0vgC9I7_ujqmGsToscY0ji=PyCx#Y z>nBb}yyry}d@Oj4&QYG{$?F!n^&CeO)~a8}iMCd+tp5)|Go`qf#-Ak!deAp6s`{(R zier3!=A}kS!S+#clX6wdExPmqHJ$DByU6ZmWrB#y!b z1q;z2O+v&jyTTO+i}q{f&dK-iiC?AwpvgPg!Xny9cj2@lCO)Q2(p{^x7U5!FH-FBN zQBxo3Im9`xF7F%|gh@{;2IA&m<`4y z5e1k~+A)^azWqAJ(mL>}47w|!9nzM`WR`(WRS*>p-=1We(0q*rlL`4AzgA>{BRTov zPg>cJ>P~!I&yZ3cRJPC|maj$p4xO;dE&RJr2}^z;;+V$p?Gx+_S)H@+KQsTgk~7ap zr5?qKdIdx5#`g_FyYmTJ;kq{Hc)fJGeEjcDb%6Q!Lv+%v(OJs1qNMjO79IKgT<#9( z+*ug77{6lkD$?)z9lF#p=<4Yh(dK^;pM+s8(HCJDRA^TRc4N6%u(4$^L1eBcEr`My#hp5|0tY)1?8V*qQJEyB{L{uFy3roas$roaXnNZ=sY2z~3? zzep9y;A8w?((5oOlz9@LHB4}=a!ml{gdw#8k`y}z$?YBXdf*!kLRhM=9&L8@L!Wv0 zkE+H}^H`FIaIJW0X?vj+(suSQipZ0)}nU>2_KgznPb88S$Wwjw0wYhzw$g|9ziZJgLRMBNIhhF8e1>G4kWJ`TMK}QB zDES*_;hDSDw>k4|GFYl;Fekz-Cr7Nv+cQDG5@W9%^iP}ky7w%alk%n#4Puj#F@3Hu0sHF> zz(u+7cfTa(g*;)dMvrBWxM^^6&TzS6$`+zw0`r7UjR5IwOCW6%^)v{Bu8*JTC@}Q| zO8m&=5luE`3p_stCRJSE^D-f>GLF*x5Xb}(om^2hdeLO`XQE!EpEMbbqZei35}uuu ziFdxOjD~@-o8+E5ol5`bg`{6D>O{3duW5MKmx@ucEPNWGl|aQJZ(sD%3J3*YfP*1e z5CL2Xq(f^6ZyWx}T(cyuqdX1ZKHB~^9>7hycoFyEZnXTjeB=K6S`}$r2x)#$;nMjU zw*_r~X*g;7_peou;NQ5t?^n3A-e9LgK&rE)W_53cH4#Wa$yP;)O<~WNY{?*1F{X&| z-?V|&bO(6uai;T}rcU7rVqDvvNt(*{*Fs-^jUoN@<{$lK@cF01C2c$!ZEQNAz)dm! z`tw|ypGisq0PtX=W!HJTmWQ!~hmnOdmF7+W>G?lH=rEA8)-}wQ*Dw|@5qsP?d(u+y zeGB$JAF|71UcujG;Dd2l>4DO^#v;Zos08C$-ptF zv39z3GL`<1i#RkENUry2>C9vb^7jiAg-Y34iyyy@Xnq(ZH1(3@JvzT0R>Z_F(N!dsIh?c`;$9^OBM6FXqx`j_r#1+y zOL2S0$RNcAzo1~-YueGlU}wM|7LH@kE|LieZ=nfqG7uv8NF!|-+NdYWq=awXbA)hG znvTFkjOQXpHWlO|Y{h@4gYq2aoF5N3oQlFQQHRoWW$+R4NZJ!6b~jbI|y74+N#-E?~)DvZJpT(oZb4!{w@fe}w=?)Ob`9v1aZ2lL< z!3JT+9mt#C;+hx;X_@Z#l9OFGJBDyFj0}QZ*%pTYXePp3_j*Bxio9I~nf;};dfcjM z!y@lE1l^9Mo!2-p|K9#WRTt1OY%|%8Ja>9qL8xORBd+T^25&JqBaThU(C?t!?8sYk z$ao%zdfLuD(eYsXKsT$BKRdQ*RA(3ALZ_gLtBWffF&un*-K-iR2ZK)fcXn_Pwe-)t z!cowtD$BgYIoT&88uaabW1IYO<$0XTJd1Uti`7;~M*P~%#_LZFj{U+9TmqS2!M~eL z*#D#$c6?h;aC$aArA^POQj+zoIwecbvihPBJsYJjQtH|0lsrAe9E)IeHN~oDbze$1 z>lptQHul0wCQ@G%G{L`lLgiYuh0>HNJv*wd*U2WP{B-^Ng<3b8v=sGXb}Fa^%mwMP5L37st&Aq|GdhLWo)VWpQIy5x1z1D-6w}>G&kBB zCihHNdc9}h^-eIekn`JWzo4|!%XpjVnjyU)E@2Tit)*-FI8BVA<^iXPRhY!Rt}3Pi zxDWDMg6DOd+ag{_pV*8wbQ>Nkp1*q6Q&XR`$eHmNFs`qAK{*;mPOft1ZDzddOmEKjke1ybZjfVf z?q*~2uW?t)N+C@N1s9%9#BPvxvvJJ$tHx3&O}WasPWP(czJR^ft133|a>@V3qBJil zV3I)Snt`prh8_NqPC%D`uV$LU6-9e~?KKx%V4yq`*KYQ#>u6`bsbBWqk!9&GiBQA@ zXbQrH;rh}$ev@9jz=W{SC+UPXz6QvV3d~<@KnLU+f*ECQ#tlHQM91bDCNbh|;*$4E zEjuW?0k+r+AWoD^j#cWfzp>IaZ}&mI8(&D-B!N=G-@a$r6rc#|tP8cGIrQu>vMFdj zxbGSXg3iy`(2;f>{dei0>3ibf+eM_1Iu1gUOUC1ZYwkRfA$9r4A7pP-$6f%Tc^*g% z*KxZT^`d4{(hOKIHy#vew6yxk3rs3ceyDz}L8J`|DvT0=gbHHoXgQ(4tt4xgYXygO z%oN#+tmifeJ(Un~>u6`9^pkyAYu+X|#@D<&%GfK6+r>%p3uqgQ@H#B~L8&iTZvja2 z1nqPPJ?oL)rRX(3^Q~2C)!B$8U)R3^ETH)dZ(8{3Xv68U7>eLt6HX3=!r#*kDzt}7 z$oFAkkit{Kt-hmd_d{X8stewV!l-%bhv)fSz$L2 zBm&CBfNxn0ncytT~zzB8T`-&XttJ(3lGpCRb-0 z(f;JUF|F|SKQ~t7tPt))Ula({+VUV&mr%DrWqZ@(>IKgU2!h1pu8-Wc zqu0~LiwaUzNN@4NP`97Qq7M?69Yf*Jc}thT$&X81F90Ma7cSnXGvi2GIC@tTlcJ!v z*>YXq4z_}lN5KE$1OGv<=^{2hGUtOHq)#mNmC^l28DZ4SKugz_D^Nxvhk6L~h^>6J z9v72z*_K`);IfWjXG^f@@}T`cGdx@;F8HT!way_5H zyel&_t-G`?9SpD?f1Tf5`qoh8b3`2@gl#`UAxV3@>z$u^wa8d005OYs8LJHQk$x7L z)Fi@&UR`?R?VP*ks>IgxtF;04*q8LdRQIEBFt-lKZ1to3YUZ6kocgd8%(bAAbY3PR z3Mi_p9Po)1hLz&tCDS{Gw=9{lZ5ZPRz__by^q;o5OFoLa9OV{|q7FqJ7!?r?0sOlw z?BBmd-(KMe@1Em#RdpAUB?R}Sw$Zmgb99~PKR<-XY~H;f-!zuUsFdC1a^t2OKYfsR z!~upMoYN$C@<|rvoi@yo4dToR)YbC=7yq(uTcfynbHJ9@KRWcvt`@nfJfCOlG53>b zK-Byi#nTR%ACRwFu~QcBoDu^lwPLp{;j>*%*(m3f1bjD&o$@E^jyvNOb>BGS1L}Ts z%3uC02LUEw((3DtId%7)^68E}B4>=EqF%8FBBhHIjpCO0pytD(+n)!lzSP{V*wdkE zS)>5uxS&055fomnKD6g^h2xOQO?;iQ@vstlx;Y)HHWasfo@H;V+RJYw*a?Rf))_kIg;%@JaU?N!fvQ_&O&<`Ffx?94@}14lokczgn5Dk`F)J4v zrgBG6i=&Z|=U@K@x+oWZ|@4huWZBHdx(*{jI= zd5E_>Kw~u@gi;s3zgrc1Y9Y`}I-ifJ*Xn|MlBL?vAoxf^DQ$ABHtTuV@M6Nzd?o83 zOh)3eEHN9!((69&Vk|1F3SOCVoI4SLiQi*YG1KtR8y-jA^5=8tm<|C@J zZ4s>S;ze_0z^)GHkbx&WgEY9^Z)NqD;2=5vhV`(sE#Di-Jc2-AA`#PgV;FAYnMk5h zVmxI(D|Gw6?2L)r6%I?6l-qxvGe%9`pTYZIoH0@4eKg+x3(J%f?NLvB%ATaaGvXYc zM&9EQn(y@6NZxgnGbY*ro(TVf1F*;xO*z2U;J=W(t10(ck>iNCTV8eigEzb7TUz|8 zkAG0?mT$KHwsz|hM~&F-zhKwqeA2&N>Cvc(uT4TpUz0Yf6HKdtxB08}@Tj_`7k`MB z+7#q}l5Uc;jwI_OF6prIdqxV3TL#^Sqa)%>c)c;uIu>E1eMrNh5gfMgLhIoz*7C#i zE0$Y3H(y)(8J)R}>v)w>7VvSZI*YC?+U!R@Nt5awj?@>69X5t1Yf7jB4N(qI&94rWA%uNa{ZiqrxwIKAIsImHOB@eujpqJ}t$0y*it}rozMx0rto5 zgGdFv)SsVY*;kDPFrCET{nAp~5oZm(AC?t$6r;&R{X-12KfL5?_Gi(VQIz@6xhg4C zDi#Jw3P()GT_{BOClj7n_!2XOeW0#VRL24mse03U6Cpk(6~t7Hz4QWZyz5<3f$<=X zd(MQp8mZuCnPeC)!Xx^$K^{pTRG!GD3RwQ)xiIK+)GhM{N34^XejPwx0ms%T2Pepg z$DIM71!0NKQr}&X6LbUGqz5qpc>@;(Zs=r%#@8s#Q_{pJ6>j^`L_vP!CV6)+sXgNG zX)rE5rm)D{MP3@%-j(MpHFVZX%1QDZGd6I&KV>d_fK|Cw2a$~z%_}af{f>zBqNa$K ztWQ+kwfpzP)X}X_YnXV~_??gmY9;*gR9ck7iADO6UVYCMAwIFu!@!h=e>cgXN)GI8 zIx*HhEO29;C(syaEJ^lxsv$ly>07QNI!kCfiX_l@5Y>F7lw70oc#gw!;ZN}RcawN1 zBjtq)mV4`B9^_bFHufL?*Z2Q;@#mlPw&yHQQbu0k6^?RH7Xh5pCFn(GXM$}OjOxFE z)Zk??uN{f*!1N*Q{omg7$$DFcA&N3i0{#gu8XD=z`Fr3#S_W-U8w7&b3>oO(w6rib zqBh93#S%r^vdq?XfP;g58F3+xqu(GJML5OyzMD$UMEOu7201}h;&R4C1hE6heoD8- zQTYMq*1wjj&825u^30EM)Uws9%v--E;)w~H1@3%;odIHFK$Tz)i%W=*1~kVWS81B2 z6bi4_2Fm9za+GU*S*4bFuav7t!1pt);UzM;9u*?#ov)ooX#{{^sE>Nd2U;nsNN|HW5oA-iJ zBY|*88YZ7KR#+XZMtmA0vVaEDH0oC-hxS*~)qB|6>l1NV<8aI;WtwS$lDKiU6NGIQ z`|7)-?zZ-D7JF38&TBX^2q+2&t!a7>>!8>L7&qR=Mc(EQ&#uO)6o!}0moHd!T3anj zW6N#KttA9w?=nSQtqx3ETFlcAY6c(`Lz&oNHT|4RHO#bln0Zf6oo~btcnv)a{m2q> zU?N5R1U}(octro=^=3?Zk*Ccw?2>ckLCF)NdN5`z!5XhAQY&ea-8=a zk1s0bJu!4kYuOH#G}%c(_N>de)DElNI8hOVm#nZDB%N1q zjB<6{=ka*}@%x_6d=hL4_XwSUyuEi%oBE^$-2Y+Q=n~#?SoL*F+r<)RsRMVo$(qgg z)Zd-)fdx$UjB{W(u-}8-MUp7o-Zsd{Dn2SjuXJp&xBajI8ZRo9Qa_xdmY5F{Uew*G zOhUI1<}JWZaftIAxvzQeUV~75>I{oh6K~5|{Rt2%vMk!~4gX&3Js+jFo>~3SDguvz zMK=sk3c~_)qa{MjS&w00lx+CIZ_=AktSl(~OyG&$u7BNAfA;5s#+@gVS%-W7p9ClF z;2A_eZKJ{9)<#2q&tFg&F|P-^Njx=ycKZuU4h3}k(^tP$IsHXceZc91coCP>3xIL% zz0Do0cZ@GhbS#evdu#v7Rd#;~6JJr2K7qEh9oiUtwj`mn%D>|C(mh+6E5?_qq7)c` z$b5fRt`~5ZKYjXy4=`jy{{VaufSK@^ExP?HH3Vn^B~PnxZ1ln%{JJEbz&%mg47lS6 zT<@*U1>Bi@fhU3JNP(`O#N`%8Jow;d&b*6r*w{uKLj@q)-m|HLhD6X0%#YlOQ8?rgZ9!L5RO7;Yon zcDSFx{Tl8;xL4pFg8LKP7P$A}9)(N23|TC=GPtd9gW$Ho9SL{M+Wv`8!hHwsvvAAc zX2V?z*9g}NcRt*5xJ%%!hr1l^I=FAcwZXN*t$1> z67CmpN5VY;R|WSxTo&$CxKVJg!;OY}7p?~G09-9x$-MN5F>nLn#-jWq;O@b<8m3v;bC$FjI zyh?DL3n=3fUJVnLfGTqLX0l;T7G%DNxJgUDZrM={imDTs)Cntk+5+`^6O-O0R_-H# z<$yVzYYujLYEFsqsHnl)T(KsV36Wcr3Pu6rJL z0!-vBSW#_~O$}fsHV7~9^op~ba&CX-GXnLSpqPS(>q928PQb+1ypH53o9hf`Y@aqD zyCqZqgPR`7EM>;k32K&k13jI{e<~mNcP?QFvYm#&rQSfzY0emzMPRCzaLlhT(D4!i z7x@BDamKlse4vSAUWpB00te{JbWOfj2fwLj%A)eGp2ASWtWFBHxc->H_+6C{&|`Y9ODgq zh`>Z|pz9E4JUF`hWAlJa{UnDmFeE}=6OXX#e04tKj2Dymc8ujn-$jHYsm7bsa{%Q{ zU_c(vlIBh5#B)3wIRM9aC630acG_= z)WO^>=JK$JmueKq^Gm)+dpKjgj<3y7zBUoBAf?<_oBu|6wR|i2^8+>FO$3U4<=u_) zYWP4AKh`4FBare2YCb~yM)85%AQ@{BTM_thkRPR{I*b=Jdi7WJgTaW8NKHxK*4!J6 zB}FprLV3~l7)}tO`tULxaR_N|5AwswG~i6 z8SyO!)b<+Bpm)I3aK;-WFxrIb?*`E`H)8`>$HLz}v)25_o3rafq#w?VWS&=F9wZ|w z;vovBuesn2_H0A?5N0F__6^BS=zb)QC4a79Fhw_1k2bnJ}(+SfM*yIf~ zy@&QxJtWJ0Zf%|-c6{xS22fNps=bH#@ z@CJJRh5q&919y7^*CVjj8>rcU{uN_PJ+Iz{t2QPwVQbBH^e#W9OzXM+B_yHRId})F zYh=PlNcn-U^UJwQTlj%dJ=o<~g94G>t?~xCAV!8W3BdzH&gj6M!Jq#;4Z*wX&Ehj) zn?Fxmq#?ViB3JjBhn%rWtCKC~f!AnkjSbR`>pLNc8#@CeSpq|cJ#{tK{2_4ffS>RL zo?Up-+IT!tHGg@68+-dAH!T?jyfK)e=Nl^=hSK>OM^67ho_J=iqg$-r|1VmSMyY?{ zR8Km<&QDXiV|xBVRe3i+{kP6!T6e4kC*+PO_4O`-X^ZqxlQD!_{r-r!aG2VJi0W%y zS;u@)rnEr4*QY8ct3h0x=Or={J(nN@%H>QVl%k2%d7W9b`fD#xL-bT%ToDANTGbTn ztUzcv!Ac57&m%{v=7O~ifmkuwz(xKF`$ z8w&YkN1tl`52{Bc3cF9_s=W9o`qD#-a85D}3eAo#a?^2MXoqdU5wO-e0ONPNoFZm( z%x7V+^}ye97sd{=OMf57ZeocIbJ^Iou@5qt(m12x{FW%GG3&#t>7NWUK&E15vrr{1p27Ige6jKTaJ$|L@c}r1)$QWeK25EX}y`p-bX$(Xt%Ak+<`Yp4Lg6 z!2vA$Uv*@S#Q|iVj0sd86sgB`VTThm5j#p5^TO(hEFip zirWNCkXcGb4WwG4Dw8FIUwFAbDgooftMQqlUM78k`$J8aWqVP=bVgoF!(Oqul)x|t zcVPzC(%sS_T~er5l3TVfU01*Nch1zNVrq4?rGW6H`{sWap#@wFw?a7tjx?UWN4y?~ zDex-ta*KZ@FxB#|=}eQr3N~PSoDOn(SBY;&eUl6fY_!=#zIYnZAL-`v=>|GUx@6jL z5_@!JcJ{m}<}3KtTH2iy-95R~94z(q0!j z`AccN^mo%K;n;&}O{WmU-h8|hBOi^{B#WrW(pS<_sriJ<1m%bViJwd*%plh(;V%6t z#Kw#yu*|KUR;gY3!T!ZWE;;Li|4$wMhlGdD=vd68*z<_gpRXtzu|6)q^Ev55GnF*< zv~vdqs*oXiHjajlY?U#LoQKFX2jjfw+aCTf%!rxcuZmTRDqFy6;ec+r z->H~sb!@Edx5rWyMUFcX)$AvfU@xEP@hvC3J^<4&M( z4u4AXT(AWQo(M$=p zI4ERS;#h8!g{yW|8tcP079(H3Kj)j}%Qss@^7VU^uOBjvO(Hp_#v{A3X_Uqgtn3qP zEFjrE%4kZ9B%w*-g^#Pzd4?}Y zo}3hJO^)DfMKQ*I+l^5xa+Rn72sy`K18PFZ~oHw5B zhE5349iCd@y$f$q20(DT)%@){7&5uX1QtiaT3E0CI|uY$bC&b8ahfkUS2O22$(g!1 z%~sBn5Dbn#*W275kjVU2T5dx1jgq~Y>Ex6xcUqgVZ5dm7h{g?mP6#mO+0Yq0T@rI7 zY2Y-Z7+V}Rw}Y}oK@jXi73)y3x397R_0IZQXS>2|BqbuEifpYgfr+cf*oGLLIfrvD1J~chSNtvW4|xIz-i0$vPa3!ni|dNY@)&N0??gkI{JN_zw^DP&Aeybg6=q z=6|KUAxQuBG(<w~jjKeO@L=U!p?d09IM|wxoz~H9Qp=9_)^)J#w3^>Jtoh);2m=iRyoR@A z{AP{)-1K1&$U%g@=V>NzlyQUv3@$8Of)cWaXLX4rxk|^`JGYq*NSJMQZMPz@Z};X?eXh zpib&+*VP4V6V`%;JYW@6>yk(4YU^stg_bs5`G^g)u?uGsu&#<(R!Pd?y9VOR+CZyQ zPGfND=}Agln%w2o6VBjr=o=XFum+~odtnFiqGIOyK2$-yv(G`Kdr(g>k8F%P4Ks z#z^gV+QKeYHqtIxqH->zYZqfZwbok-7I$PN-G@cHC0MVKt7Qr4J6C_Ao2k={*(K{qF6G(Q*GfuYtSO+j*qr}P$vWP%7_p^47EJ@~F31`P4dHHo zu+fm>I{j&P7U@B|zYrQ5?Yy3i#(lMJc$pj4p#5OP-jYl5hvEo*l)qO2v%Y|6_~Dxe zxM>~~JU0mR8UgMM4S{m=ha{chXdiAwEW#mB?P?bi1<$WkZVt))xZaQ}&&}}5&5$rp z=Vp+xk#p>;Tuo&4vAklxe}qQ7lbhkc+LN0h8$UTW!`G<%O}xK713#HPL(VL1g3%F% zsEW-P0sz*)yionII}O5=P)nfvrqx?U1eo6ovbYzDm{vfPEq@D?JT* z>odqbIo_FR&Oo_=2S$<=euaomUOg>7`LTmLGbl(Fn*TzA66>4wfpSo@QA5Y*W<4L% z5#6lfIa>JmfCte*XS);$&AB8|{dPygf()J95%J^p#6*UiMrVNnXorfboi1J zMgn>{*Z|^hX7(n(Lts4DLwMpT$_WbxtH>I7XGarZJ!Ehnv-hcYJk$B`w8J#ROwz3-6m1g|i~=upk2 z)3$q4khmj!aV3bWe3Y)$AIrl39dp>DP{d@s@7QylM-*l$|0OcKK#HN#R9FIMhi5gz zIA88;ulDR@0Ztiy1;ZIO`9PKsS0%ARb_eBq$NdhHdB<%!l^n<%O%BovsVPE2;a?3K zw~AzUa>m%#=+-Bwq$j?zpP>ySXyq6`LzGClhA2igh-MWoTxd8p-{Lr30*g#*#O|o# z29aS7ZQvPK0Nvvn;*6SQyJ-I4Bsmc3643XC1+$z{Gppm$H=hIn_OU(>7@S=&(;1ah z9hP2slE0-udMx?GKS%#@V&aP>@Nl&<#(U74Na@_Q2FG@>n-20EXGkS5UaWytp@}KY z7;g4J*_JI8{Ia8y)|^`7aFR8Lzkb4ZPU-*tzJ3ZU)_izc9U*1vq#He_D(ni!W>rck zz8?>^mCHD8H*<0e*DG$O zTH2M(rKNhQAo1yRXwM>sG!pNQ-8vHfH&dIFE0cvKl>+k>n9EQ==^m_CB&KM}#8gts zb!HA?CXQ=PEH9lPCA{Q-z88G>HYk31vlM+``&~PF$j;GPCUoZlC4|dx&R}#{Srpa^ zh>19-M`#eK1fdaXkVOs87TV~m1L=^+c5kMRJ`S+D12Xo)L$w>X4|X2H76$3Q*3SQOhrxGQ}9W&m94lt$Hf9TcAVplnDhF*sLUav0q*KJewYz?@*SrL8+`Uw*g$DqX_R1Z$OtI*0; zsj_F227o&5<`k%t6IN7=;XO&i=%wN}t@>=bE1T4MTKyvv(*e?CvZXmHO(x0HM9xpGk_{X>F?mImTMx~ECl{R`06K#poRp&K^R)?cSq*Akx zR0Vz(sdODHpM*=UX`_wr!4fI*GCz6BqEq+eN^Hq&C_wRq8~CT|PHmvw(dz1l)$X1b zMxbFEXwtN|Er4Z+R$GFpb(S)Wq7C$8J&5)$!QQY_4WQAC*|XWv7UYhdX^pt_ZPmWj z-_`mjrYchWVK)rT1Dh~ zt{nJMS`}G>rV^+{bh_4p&_8|u&&jk*(jt7gl}GaAP(_PWRbcnEz6|>@Xhdfv8y;fWK>>?Y z#i3R>@hKGmaD7DR8WmK14it$hAh zB~+2RerKK9nqHub{iZOyNI$$#$y67q=kHW)E!u4dMo0!}mFNw4zR}3>T=Mhh`uhTgdtWs7Ho4 z(A)62ouQ!+myQ*%JRuiBCS!k-AYa;9t6tf2)sI@%(XkS3A2VFhBs+JFLdi*% zn(FYjaNDKuLtSz|Z+-_zQ5-_=$94`E!F;!E~F`FH{IbQ$=GbYyOtp_f&eOBOPtk-tFyt%ti7Hb4YWU$$tf2wbN_q^ZKR6tm|9 zP*I91KOAHjn0u!eukYG#VIqbwjMgG#f=V#L2ckw&V$1tr&gTAJpBS85&UegZ`((?)_^~Yax zeksBJRgS;Wd&HUZ>pClK{oov41UEpX&%EV_Biz=0m=9=koT1_z-BbqSfR?0g}x(5ME z6R9kPhHUV6hNTc=yeJ8Nu2C3_Nv6}a>Z0E<=9&ZrQ5ek#z(>BeqexPxu2we56o*w# zhe9944^8mc0lc<49z*kIerWnizkwf{-icv)g99G|f#i9O%L~*8%JM=|pRQBm{7A+o ze{9_K$tZqoF6OSS>jxYD-VQS#pNt{3Uq`|}8BxUBjr8>5J$Fl+hDqKe0!?3GEGkMs zm#85_F!6L!;?=uszG(HG*d$i_!m{5^7+b_$`I76)07+9LIiLU2GG)EU@D28a{=$Xn z5$l;y<7GcK=G7C2n}+74Pjj)XR~=Vd5{Y{$EMolzSt0WhlNRIjEEN7YxRlVtOT zY#Ra|d-l{I3J)IWpHJvKP=xzc$ef<-YQ z9YZd$V1-=>&^*)TJbD|=@7E5>;IO(K5gTY`1lY7s%BsVrudX?Mf*FR=J7pAM<@>6# z{pk9da`Wb#3;BGlOf#4nSZu3uNO>{R4}ow`#<^+!y-_G^#5(6JvSrk&Z6;p%Ck_O* zHV_=*xB$MXLZM@8Z9Vw`6|TX-ORJ z>PqwuYmS3+YZG$k5@Z!;eBU0BUxGChC-J;s{VcNVB!}paMAW9I5J6MVsk?c>#N(%w zkmOISX5~c@6H+CdIY%`|m^?f06X1Pg-w&KF1Xib?Ot2mN`26ksmp)F1b1D$8GmVNl8Qzr40&gL9(|$!Go9N`ElUOgE?Gl8V z$C84!1k2lxwD1K@i&p7lB9&1h_2LrT(B83gGGdP5z-7{xg;{kapMSONjDu#|36OqG&__Q*i`c;FBg zyDL2y0q(egl9tl=j)?UQ0?$)exT^cC;=D<-tKuN!3lws9FcWM&2(jDAs;OkFB?q_8 zpEz=7%i{@}bS!NVYYaS*DoX(ICdlFaLA&|XSB`}LmvWe5{@=^tO8);X2kdcBA$X>* z;J9KuO1uLf-6%O>xqyrRNw!+G>{P7CcU#q=w@xh+LT8)EI;TuPsRP@I<&%yq^>^RG zUW;9))y@$^6h!hn*HOUgQpyM?JCR6idy-Il?bt+uIvTC|;Vb>r-*IKvac&0<4rUEN zk7I--b*g!=g}e7McktkzywZ)AI1mm>D@#6ALDu_N;@B+`^?5E<^oSD{eDi*WiBI6c zzPC6Xx33b=MzP-%qzszrKxBAsed<5`?-*h;u8pAhlZX~@dpsH%|UVBnNP7vie@YCGl ztT`R+IwS#Q_$n9{AbwUDm{65EWpT{UlQ-^bubShIeGYsQo8pJt;|x1T1hAoz;VhdM ziPZQ(D4a5VIH(L0n05qq9K=*bn`xYb5}X@a)FR(^`C!MzKl0$O0kaN*Tz6V8uuf9D z+M(kYY8PTBsW-Jl)-Y?8t-COUNjmn=#Ap@CLjXNC!V;)vW|t5o$o1YJ8$&ts!N#hr zC9@CZ_R(;0RoLaA(wgsLr&T$YYi{_iQ^>gJc2Hdo`f-@gcprM%8+P@I>T#IshezrD zWu+*Zl9Ky^B6qUn#|1t^IAn|Ec&K+)c&{?#z zs5TSiCD%D_f=rp+UzD12i<|knV)mxb@?^Q17Yakf1`*}={YT8*mF~RZGfT!aNT*Z< zH_>QA&y~T|bXvATrBS4zX@)a_2MPlc>mB;Ijmqu!^+C!N-_Z~2{q_hn2+F&7Rle|D6f1;Hkc|k=`Nvk;p;U(qV!NTC7gx=WTXIA|a>y|Nf0PO|Y z3fDIfi;^=zxf zu8&gIN)ZRdLg9VXEQ4xQQ529698n;0Y+OQB?RrZBb%;o57%um07%UhrpZ{VBcy9}$ z5VmO3(9Ob2Z=?hvD+!jfS7o(3cWvt3yt5;vpjEdxh>*B3$+dK1Oud6uIVZ|*UMN>vG#8z7#6(<$@mjj8E^=>QdQ#dj(n{Rzbp)8iv51FEh4^OBD6`& z7tZzHg5u~|b?V(?8wACgS63cD_*FkhSpl~b06#L1A(>g##R6*8$~V--uoUfAR5&*5 zr*!Q|%t0Q8XUIPU<2q8p6!aqw?PxR8yCF<&7LYS zJVF{YD}XeluRmF<&bkXCH%S&wGZrC_L9b}hJy2)kH~Tofop?zDNnXVRiR@B2Rg=ks~_4|1bG_Ek9NuPrJ69FE52SJ%}iBeV=OQ;B`*Le0+VX?{w!R0u6S#_0#vVg&~V-rp}JSdOTYiq z%sFJ&%q6f5;&Wd6Z9LpMa}I$Ah4(e`(tE8l*AptebvuEN*UKw*7A3j{&a^A%4qPgA z?W{eNmR@hQN~wyS+bpHmT9FwMT-K*wDZScim)1&4UpZ=*dbI_31I3Ua z?!eNYNH!cdKO)1t`qY`smN}olB6964J=a=OG;^+U0eq$wSs6xF7H!=`SL}4ji0ecd zDQrBaCLW0K(KWs#oaKGCpoja*5eYRyBU0)>CS47#K21q`>y!1^5*_Qj9q= z*s>mma9mPC`}#h7;2(t8rV4hOt^#qwy->QR)xB}^7SXrtq=igIW4xPEVVu*Z01>?5QNHi0Ju+6)ZB#9b*=7k?2l``Pf3tm_eyK| z-a8U6dmVN|{ZWBFW?~TZ1K1*Du*Z*|6z^!P^y*G|g_r)Q7Tu|@Ns-Q{NPX`rPL_Lz zCDm_fEVfnjXkP7kL$PIqH{+?YBojM>9pWyc;Jl$oI$LMGfPR22D!>tx!h9;r_=qVXvI`-)VZ>xe(tFA)hqmD&4sE^5uBk& zL(hx!UqNBV&bRh}Ta#CML-)|oI+oqCkOx+u$O|}q<15bPr(v*()xkHw^j9Tr4;)v+ zcQ2Xk$Lbr&S*LNJKzGK}ZFzo?TRXpIpU|4KQIxB^79wbVISwADiuBkmqBg9|Ep?0S zWqEOEg2ZRJwdM6DGMu6ETW=`L8JS<%qWp67%eD}On$cZ}l$DT!WrhgSj>yQD%J9D} z2WLz1!ZugAUtVz?2VBd0U=q6wpSB*?or2q1hb9(@5Qz)n+6GReQR43#ikyd9bH&vs z2+>KeiSv3Yh17tStNsz^(l)9yVIDs3&)7_NZq4RKk5OB6xjK z!l)6z>VL&E4)+Is4vM*Fp&_OfI?{<1bZbF#VRL@fvda90M+eeYT3f5oj_SweE*}-m zChnOh1yhRfq#AdE>fc-w%Wv=R@r*c`T=|B;#7|x-hbz26;Lm#MQk^|Vw+sS*pl0W` zR_DE=WE=>yVBzWW&O1jTJ6z%XbI>LC>I&A2K8eL!Z15UUYbQ7)0H8oQ?1%O_W zP4)t302(l}KA_}qJA*ReTA}+uNX21gj2u`t) zGJ7aCciJz8{Q}|3_J^PSOmH~mN@|kSB&N9hQOD%#*Q{`&Os9(M z4wsXEiVzB1@n*sIG9DY{8~^;8XDeyAz&u5qUi#bkhNHxI!!PgG9rYYgZhY%MeYpe? zoDd&vJfsBYEOPj>LGoZpnaoevLqoCNQ4xI`uP}nZo%`jjho(xu1@BYsi$3R%;c8?>4SItdu>yUF+J@VSOFyo$0XH{ zw15@R=9y7hIjwTXw8~FEPFdwWr1a^dA6FDlK6-jTr%Aj-SI2e4fr`?Lyt8rb{|aly z$)$cd^+Tsr47FKFIca+#h5z1a}Z zr2U%SO!8}{+(NoXgp4FMx(Clf&9+B-E{U6%l$eTy$*eg+VITN(+01#(TMRP+mIyaf zz0c#>mH#x^f-YITl!BQCy#W@9SixqN6kGl~d}4r6}{$B^z(8 z5lFSZv)v#cFKuGZJAtw9!(z{MW~VZpdQr(P_0q6?e5zJJQOCpR;2~;2KJ@N$cV#|> zl0z>7h!ytV6u{1TCBRr1i5`!4#WwlQ6}$EVT_GJ8v{o82`-3^l0tQ zY1+fXEZ@l>v|;q@D2(rC^fVX7#~VGK!nJd&l;EL;_Vh@VxbcYS*x4SKMKt^~2s#eW zR8+eluU}AsMhq4AG6j9bf|VYUIkd7zN{ASQ%L4;-k1J|dvi;8VYf`XF{j_as_!!0+ zea#UJ1bID_&lb&jM5`(4E#MZgBS2^qzb7<#FZ%KH{eCa9Vm2Z%UB zu*Eo!b**1&J)~@s+K$~p=@QMOVR(nHm)9kAi!Vu2VxDE)3DJB)ghz>XH!9XtEM5H$ znU3}3&_Mj|lHbPH23NmBW^lbC!qaS2I9CTFqFGI3@vKnzC3DG=_8sZIL`NnOmL%T| z`|5`cQ}`K`mGVs!riiGzPKRyH3+SJO^lu{tc(vL{`WI2!BlFaKgUr*|r6HwoLKZuM z4vz?IUhsJB<2~;;v#S-#dU7udX^5AP87?ikP~(NioQ*_=0trG##AN=!{ci>JAX z;S-`A35q2Z3l{7`E9;`1qnE-03TZi!_(J@nCBKbN4}vW%yw6`wmU&%N`BT9KOO%z1 zKutn?anuE&zq`B1kdj4<>i-({y)$f1lFZM9Owpje5T@;>3AFKu&Ul|`i?whQJkeCx z73_g5=u*JiEAf02&q}J2!gw?gGfh(Op%kb1Rh$XcM(Z#9n6B`*du~9Lwc*yQn(#Hu zy&*Sf1t+Gih_OoNn$u6+s1gmSa(ni&uWxt@7towtv^jXElnZWf!Q4MENakGGK$(L% z&rV(N=Gv8iVP#(J;K>U;h09jXSdkG)@F2P^I&G8;e*LAF@mf}*UR#DF^L41G*&74T7vb*MOO z9S}Wd6SR1-jdz8=edR{`u5iQNS3{S{5V!`gN%{ebQIBS4lgYKq4IjVS%W5ZpAmT+! zej7jgaWCs#o|s=gSq}a!L6K> z;lqz1b2??=Fvbd7@1@L0mBgsc8~LUZMz>(L=z2IKeh^B8HMQ)+sR2(Sxo|#HfC!2q zyYdT>IP{-uDA<}MN?CpkYUg(NyLAp&LI8j`hgor-TwEbvQ~B!Zb;1rOLxQ+?$#3KD ze7t&{u*1pwI{BKKk6{XG&D*EpYS>ReazSuPBaWW*^Cg9FJE_|7>{5pNVHM`HS#?>p8>!syolrMA&?`wz76&- z@izxy1d%I37=^$D1^qMB?H6lxnj5(iZDK#vsm&dVy{au5iDy8Yu*TGIIs6lk|AJ6N zi6s0KKJ94uDO?3|SYOhGaGSB=LO2)>J1-E$FMJIjqwuNtGS0&>sPB*XoBjvG*eRq9 zkm+eT?Dc$MPKT|l=dcGY5n=D^ka7eOx8c<1z7MeoEN)Q?%tCgCyEf(#TOyEuys{*8 zkn9i0JscOYI(~fpPQ#lFppZHK%S} z%N_118z*OaO^6I-_eRYm6U`-r{F7Eb~&E0RM`a)cg? zZJTIIjRk+smm2HYsP>Z1h*LmDIUfhv7ksVplC4bExccxBRsr9@UyIedj=;K&%REd` zx!wbcYCt!-dR5C1<@f@KN^QpgXY@87o@>uPAp99C8OuCuzPLBI)5g+wI9xvb*$`yx%b-MtrROq#g>vSMaWSl zqNqmizM?JOT4S9(x_*njPLBWO{MRCP<`dED*PllwVV+S&&f+89jdG8-QlBP)?%2VJ zzAqGRZhZ23w{^Vxgh-z@C3$#&GxFTHl?U{5m2J-p7qO-@tY+KwaY@O;_2wiL7by|t z728O8_1>asFt-fa|3$G+JxsMy1c|jaMRE9Z{eVIEW*RwX@xyh^3o=&nh>Vq<8;7^n zaiG)c)1SoS>?b27N9O2>BRaF2dxGa4U!M6O46MOkC-^UXmeI`_mv514u7!glEMI_t zHfUenw~K^ztR5?Lb5o_xY`YugzYszG?(|AsiC>(|WtMQ6N^o}OlA}0u1nPd!imhm~ z=GhU);?Y|bxJNmiJek|_UZXDgK&DG~A(_jo*r&_9FicUF4A6so`5(egC+mVcE;6JY zv0FjF+YvBc)M$_%U)UEJaSa7U*<9=yH=Qa z&6H#pZ<|6Iv{O;RMK4;+IW$j1qv9tjc*OX*)(Wd7qieBaF%O-azRigKJHJ2dDyh+X zbuhdqs(mjdddK?I^?F?^hPw6rxG}m>I|8Zuh=AGP&c;?)EL^XQKCoWDueI%fYgBWq z@)PpP_ap2wk-cpDB+PS0l^uXN(1445W1REZVZ6lMmHU)2?J?i^uuflSO@>j_#>3GE z_8o4!`@%Jt861(VSLTf%#r;4&MB9wVMXnL=Cq5VLTC@Lf&5RWz_{=AxUHsf9`OL?b z#}&GE^78ny#IeqOhskIbIU5h->)z3{JNF#MXx?%@78y;vAp7iYaWUN@7QrCetHU)xMVT?Az5(+^RCNoi-T2P3V~7rt?}&hqeF)z- z^#~<@iO=`ojzgWrf5v@QOT^hAY-1k}xz_skxiVrT7Y!iCDV!E{i=prYm=gATrd(_B z>|lqqO@(KVBg@#wi21QMXjtI_IfNM2@jY>Ek~Zh|AwNA|Tm(DtkAVp9r;S7fl6dD~ zp;B5TZa53$AY%{}BnTlb(hp9*Sbf^4hHt_qELjS){eue)e;|VgKcTRD_|sc~_s-{v z&1qx_=ow;ZrtO5$cRFYK;lQRHFie%bYb_}oH}c@lTq(;UJIWJ0V`ALAprfO&EMxZk zPzvGXq4P!%48^G-J*-~xOu_Uw&k?AG$YbBaUH=lH8Npu^1BI9{3~zBBk!p#USBP&^ zB&u(PQ(YdN+ylUcJ&qmA21W)3nV=L#taO9qz+hT5zq=-d3uAI?l2Zd^sdEmyXl|1W z61PO-KK=AbXX;^BQLP<(_OcnVuuELs=spRt-|bNI@@~u0;Zrg{&Vh%qS$O${J-U8A z7=rg6!p7uvxo9{R!D{H}O`A3iFf1641LiZ?wq>bP*G@5|R*w)Ww~9s9V0ZD&@Dxj6 z=8HKqZOt>MEWDxVD2+MHwgF2L*`?*-mbnIdA(`-bi%{w@<`Tn;Y2HB2VO}ie4FqiD z#msRLo(Z<~1_O6oUJ;?7n&gn36ie6(i}FXkR_^W#WAd9zU3QiV)^cYcL+`ZmyNXGq#qXo%>I}>IK@E`Q$ ziFo@^Fb$n=k-Ah98McClthv2c6dy;kAaBl#A)HS-O-9p5dQC>tNNUGu@)cFGq~_NXcP$FV3pfoHJKhcDkgL{%qDK$Nkdey zGNPY`@8GFaUD?7a#wgf>E?EC&I9)mb{@i85#P7=YjuySESj{Obxokn@2>G3JTZoc& zIr=87f1U3Ab zi1B}r%^!RDo?+}m2#E-H?_y4XzugR*x8G!SFgLr(&ag=xQqz>n%F4^BFkIYWGNB1w zG?k~$jMdX3m?zc?s<|QYwK0sBrhGBd(@UVvbRNL^WPq~g!VOG?hK|7MR1s6eoSUtL zrRIP~4wLy_=zCIt4U4kOUMhdQ(lVk4WrQw;RrTFEvC+5zl@-xn!J&0RBmye3;D62M zzpWDkYNS@`_2L@Kxc9_sBVcWYLf;RnSi^i(gRb+Huwu zyV7V*Pk@a(?#R?jbo+K<$$1r=C|v6*?LNdtU4kh}rOVXCOewy1=#1o;#9b=N6M1!{ z7>%cg*Q&QwT%uv(*&8OGg|2j6qLG>8#D_(uJlGZTrVF{>?NPhKxilx<003&-1{8qb z;7)BCEj=9$ehf4Sd<(Ou@>yohe-CBj$Ql8eA1Xeds^7?GZm6tRwrwyt!$pjdG0A4< z0?aaUB6GUPm^t5?o={|T#6{}@kQAW`(x4L?S`v%SGh6??MRW|t$@j$uK2Ah+XQcp& z$?~RLWDO<6=iiEt&mV`igt`s$!b`82H`-Iq*D+h-^b_CFdn)oiWifhrr)FFY$02Xx5AY5{GLDBm0gE zYf*2VtKYb3lpczf+({8fD?8-xK!Q?Ul&RL8&y3ZzW{Pf|Z2yKJ9idY|XUHDS$!t~| z!|)+O_xZvjip(gTOB;Qbj_fDrAHNrLO{j0M5AuE#&L$iHhE<1xaTHukXMcwiF|v?s z|L)?`=ep|n*>f!=lyk{de)c@xL-Df|%j04qJWXGM0&-MXewWA!2QoA{I@I2Ul`%k} zPoHZ!aR(6DmqH!t6ijP}*kmX_B)g24gDvAEaMVFjUE)H30m8}!#YFSZE>@mP^W#kX zIC8h(r#ULgx%Lt1)qSsiq+65f^=XfRC5EC@-`&ZjbFW!mx)T&pIy$#&X;-x%vqQ$D z2kzbhr$EY3?0K)!`QR%Ub}$Ubc_XC@^Gwbim^_Mtb%vty!J2B*xcH`)^>)#H2TnwQCf0g2R`LCu3>S;qMjNy!QJ z%*LAky`CHT2b>@F)W~^HP1cr`D^s~05bO{%Ygcm~&;YFDk`vnhBhJH*5dML%1mQV^ zF$l>Btw%vO`IgENAJV5Wp*<2@zq)q<~ zvofp411dBms0uy#1)cLA$sXY${YU4WLrFzN;z`$%3-+X|n)05lE07KOIMu=x344C0x)=F+-)1WdU4dlcMN0SL3jn>M+DHLvEpVFzXmlqvi#=5 zZs~yHkv(<^FEz2!IVWj&1x~pIvz}EGsY}wihG7KAkgBPfdv=^X5WD~0anHx6{_BpR zydF>efn>;tEZl$4{Ww|HLs^(Em8~!Tdd-E>+fF1n7m{w&l%HI4zVw#!r+Mf(=F3&= z6|%#G=GS5UlR^H;qsZG$z?{%uy9f^1cG+cE*T@c&3UXvXlPUHA7wF z$Q`9zw}+OV3R^uQ%ip7J?URHZA%JH*?! zg}EZqYW#f5x`f& zu#4>&sn3c|Bj+ZpJ7>mct&iRk%|VzvV$B2xgvH#WlXDzgyOFwtM|fW8ph?UWMZHV2 z7hhQ!pDPz8B9IoJbs(8@Px>Wq2g%D$Ce0`9sSqe%>VWIcmM3#-R+VS zt6v8|r}Gh7-M6rXv|#H?I5uUIynHDkuRGfpV$8Pk5RvczPljwm97%niq+)T^z3F3y z@ankgi#S}3591Xp$A3~8yYCo=qR@=$FxX5l0<4n-=T9=EME z;5jp?&Ml{QtZ!buav3idXV?c-Rwk4slEQ)_tO}|uR|Yz0`;A&vqyV7@XQh-0G*${0Np}&LhV@zT zvE49p8eEv8gvj^bZ=2}c{SmKwx3<-lSnKNh9>+at0#J~rg)fl#gYM;3k7x&^{-9y1 zKQ?7hT1~R{k-<_8s<0-A3xA-47|#J!4-#YIuyf8n0*4pucmkqP&gWiU{gagp)nOXjyrU}JGGG3GqgQIz+5m6n`7C>X17sE0WPEELIFn#0&bg@C|TZwW_FeG3(#nj0HS*^e8pf6FCM0G|c-8Ie4_rb>iyz&W7& zh*f}Sz$ODg2^8H66m@%xdRV!4!eX33{0utc=mPcNgLIr>w{Rx7K-*}AZIE+#ZjYQ1 zhMo`2Ok$aa5%e-0X;BaOR+;*3YSV&2%K%d!lyIVaZAw^=odmr zu;!Z8p4qmT74A21uiYNy>atX%^}@6Io1S^8s*ByA=yu6_++Wa?T~37GNJhO5TCC zc$Dy%nUM+E3hSIqG|NZbWVNQzSW4R1;I6PEt)FVI$QL4?{^e#xL5lR(nX4Y7sKSeM z)o{uhd>CB9zmaisq|e`CsGU=Bt~O)l`>B8>Q0iG3ACzJ?ezUj=hl4mHDX zzqz17shzF{#pyh*td4&^j2n4H*r)39OAUjX;JL5Ys2pWG6Rt3nO_xLJmaV8FM5~j_ z*C|NM#TWLVjuiPZIU8O6YS=I91-#BX&>SKR-)eAdC|cKA z=W0&lL3PH5MZj>3=>u>c88jaT8Oo*LXHOFaTE~#%LW!QHYj7W#3e{sJLts16eTIQM=&ePJ! zQinPWiuYZ=ioNg4kpP?@9M+s?>X@Z;0=72g0 zvb$lxn+ar%hxCej-guNGac2q#SQJ%!uE`B^PV}}Y=MPONreRYtUq>EM*= z_s?ehJ&k}*0;wbP{naM14l?6LBS8Z>PFY7_2+RuAUWeNsK;pB$j;lI?nsG|3^iW4w z{a)Az{&fOfrKdQ+r1Vs+o;GomDVw718wRz_$x_XeS%VCx6`N`?gZLt(JQEo_OHUprog#fwmTV zx9hzHpsoE7ZW#8Gt?eq|gS#{Y9?Z>m(WSbVT=F@t7#Ce5zd1L)aP3e6llj^hSTz}0 za~_~4CA%GOl#d5fuE1C{KGJn?^{EogI@=GVMdQH|98X@jXuW*ASza`KCmlQmT1MDw zAm^yw6NU$H*x$(+*OWW1?OOX0kZ^n9YTIFKkzBAP&VAp{*3gqnSI;Xtk$c!Aq9|ip zzcCD%loFcdL06QRQX@pg@nqL@iIiqhta@j-eP_7K2w#K*R+=HoQ%@iR7HYZ*vHbGr z;ynjk60eRp+O3`EdofzC0fGXPYzvS8%>qxor*l;7x9<@I1kxqYtnnUFwr>pEx}ahT z@ElGMZHK}*5M~hk4|o2cCc=Qqbte95-;_89kbQIB`OX3CkjUN%Vho+JBop{gO1#F# zQGnbZarg?3)mbb$FZ6y-h`DLcn^2k(-A|F3e1Vb`NeZt^u;trNE>OtKdW*t*I# zaO}`Dqd5t ziUr=azGtG{C#cE!@K;LR&?x|JV4=*Xai`~6wV zNZO$=Wy~4uP!w7giG;J^G)`?&f%j^Y%r6V-u_07XiLq@!n*lQ!+lKSpWmn~rW=#g^ zEA_In{qu0gII;%tv;E^L$khsL2>i=cET8qlr21T#9VA4pT-uvPT4=e$O7!W3=eJiFY^ww2yMx%Z2B3rg5KL0c9?~54sw-G)d z{H={tY2`uSvvnrNDJbMjEgKl`)Z78FOKy_cI*Uz$N2#!~r6%K9{V{ z)JK{4-(p<4f;aG$SJ_d%o#Hj(J8Y6G%3DOncVnUhHl*}Lgw8tQmf5b%l%G~Cw-P1_ zNDE&1J6_>DaF<3QCA6G~IrEuRi`?XV@*vqK70xFP;!W@UQsbO-5Q6Kv3Yj;f;1efa zU~X#Gv^s%AmYYaM(B#Ss$Z}ayPnKgv6uZhH8;=dXC5KJ=M9ofKY?u*ij4%-Xe3c!l zlb)xWA5vC3AXxXAU||=ugvcy}Ub{-}aqQU~?LmFoWNb)6zMS-)Q&x_Vs$Q8Zwj35r zKnXlTWn>Q)*Y77}+{A;q5^p+|#Q?0LoR4moNL+CCt*T=lm^Rk)iRV#YVAXY<~;4zY0j z1AzD{9GZDmdRYW^CleaqvuMx~krCc?fU{nG`|PTCSp$3<>+ zoHsL1rC12@(Lhj1=)5ZyVBRek9e|kyQj8QVV(9h9dc{o5q0NKl!gNI2EVRP(g$O5t z%p{wo&XwK#F2y&Cv0Qu!_vqN$a6^xKba%Mnnexzdp{<&QVpWoZ<3s^Yq^>!DV@prF zRo50o(6Y~z^U?T z-n}8SWT{-=f~zm>NE97+v!K|vw$BhQj(a`iQ1vq~eBmp&y5^@_6;UqP=JRybSc)xj z%K!^Wa zxphWTw3f_&MGGTpJQP~r11zp4GLHXS6}Iy|Y@*@XenMb5P}bEdMnsjreyc*}kQJ1Tq=S?QM5EKWjcduesAY#7@E(yn8_8%K%yKaI zX%9iAXH%s!K+lyhWqJBB!;$?J1<)VjWm6rVy99$C zNLQT`u-5)!2d?Sqz{3BiI!8I;#f9hOj`YFp9X+Z=kNrZ#SmemaQ;i&DBvu-`jYqEI z*x8eAt-EIYO5C=DXAG8#T;LNej6wC&$iREX{d=9&LfwjybthV6*=-XoFFaZ$rc~^s zfuqBne-YkIe8iEK+P2xq470pBLSVyDJTpvna=1fA8<~d+2hyfC$`MrhWtvJ!>($2< zWR?5AhpyVe4IBTq|Cqv^H9wH4+V&ZrnNVBjnh0VL2rk+Zr7+wrrG);b$?R!S$79J2 zozLr37>zd+SF88e^amOaw7V>6LPuJv<#&u*ywv^49k2y%WGdLn?VEa{fHGyLN>%#Yt9AC?Y?tai;vi0`mlA+4Mv$Ih> za$kf2nhS6Wvw(g9RV0YN_tRVsa57DqQprJJKSV~SvE3?k4`qimQfI<`dl48TR_W9- zi`uPQU%SuBY=RT+b??Wsljj<+4OwYQmeo!sLUZB@$VJa%&@y|6(f7AZ`>a|iFQxec zLf5xS#nu8s1|~zR3+UonuX3{qd0u0eswMmFuomXd@!UM96K;|w8R&ao{}FBeDXBut zntBNx9Wv8Q0VQfr>FE#-Q50ai+-Xgt^jd23tW-UKyU7s9U{_@e`EZrS^+bd+kkj{L zCo)X=cdC_9WIvjUanKhiklvzTR@FBi!c%BUPek5a{imC`^wNMC@V7?EjUy+gYDACv zU;AJm!{Y5;8ps_1nc>V4_q`{!-|oh`#i9kgq6u{p*z^1f!B1JlCMz$7RbFM;>!nyH z#E7)v;J%^)i%!B0%3ra}QU`x-+Onwy@KiVFd(aF_fZithPSdKaurW(#WNjb@ZrkW;HU(B^1e^#s_D}HDEf?g<%rGr2xvSbK z>tMu{mti4;#@Oh&=25>h4)|04@aFg9OY@&-{vqKR2qUwjiF$V~t@*msdUz-Z3y1R! z>P8qn7uJtoN}fhzR{UZ+G|b2q?iaIyH#$My4wh0nxMqSAd>amZ(WxJQ7vlyUDQj~t z_(kAT`00s;yv+whL?{wxe?;8>tN2~SxAn!Hj(vo%f070a#V67#7d7MyuIvuk0EdfGK6d2z@It75rldKH^L%>QiPUw^2csQ zod-bY&?8;}ImiWQN4r==B_aNwQ--lk2tEYx&Wa26&8Kz75{ZM;noDE9Js!TXTvmKM zJY~A)yyWV?!Z()5ivJm&@|5S6gdNRJ!qRRFM{onphn;3FPjA&$hj_^*-jIvF<42AY z^Y%)!5I1w9%HUX5N#7VQs~wKF!K-xHa9X;WE_;d&*3;6{*lxB z{fKntu1-fwq6?JW-sK~kj{ju!hb>EtPr%*y&wS%t&cOZE*} z6?Ulj3y64tQ0J+M6FWw$Ba&Mn%b*_fX3HGA0@Qj9rG^gg0bE zF~*ie5+5x@gg3p(_W!r!zBIt9{wR0fFxjRkie%pO$S`T1B-bkdKjC!h@U&^(>GX1W z?7e`;qnUqik}vcHqgftRwE$!1X)UaI+_=3p(uv5C#*P)b@n~e=+DC+Wp&P!pJBhq5 zd3V1vygL4pynU!3VDu(l2TM?9OJbRBqBV^{X}D0jJn+QWA)Sf^oEGN$CN8w5leJ?Y z#vuo9BI7Ugyl+hUdTIJMG5q9dUsp5Dy1m^heBIl~7oiYLzDCjQ{RC=s_5W3o>|_js zU*dcAp7g^oCG>^Z#7+?W*t{IYzJ7l`UyO9t;gR-^#C5{auvMX->|cwgdH;F9%lMD04Y#FmUcon4btkPrL4jR*X}Vekm3=2?oZV`+t2hPRl0Q#8Y?ux zj`AI83}Dp`*;MUANy5fB)I&Cgt)i1Hsu)MW*g`TZ`4wuO%Tv{{Wa``Z^$1%>^GfF= zu=s1Fpxj5+1|jB4u^9Z2RTzy3-;(%0ytn@!-vgFX@b~-A{pt*`sTJ0=!BAo-y|NeW zvq~ulf2dxeCi%#CYzFTvW6rwh1xF!$&C5467~r0cL>J}+E1z_|VM$eT;D_C@3k z&`WX-eReMhkNy3~_r@Plu;B=PzR$8sBNezL+h+ z>{K5yOeSj)BBNMZ)w6}A==&2B%8snM5G^$k8!Be3+W`gNp6tG{TweTD_moARjtpEl zFnU6$UROK2+dI1j!7ojIr!j55S2WU?P7qx^syLi8oKzv0P+V9$Jm*|D9xd-la@PG< zf1;mW{GsJLCg7>v5vT-3$a^TqdHH$VwVXN@jEDe`X6MD;xGs(}WTPbfe3Jn^4+UhQ zuKF)`yk8gnVTWjBdwaJgw=>970H>lt-!Z}OC~J!yB(BnTcGpO~Y9;nOz7;mS5F^{2 zh3TTmT~s4&Xz6xmj=`%)F0g8Th{~0MQawg0P}6a%j%D`Dp{tHj&c^P_MV<a@OTy_)4^R7FkT#jI0*C}iLLuCfF?96_yJWu?Lvjjl~MlH zo}UsUSGTJWepOD2I^#A6SIwR1=p|u44d)jyHvUDbuGibN5#g58> zhV>!^U^hen7*@rmMEU}(PuwuJo8!iibpT1TEKM*<2i_-vQB+TZ3?0 zvW}y;!)^cwT@|h>#8x3NDCB(Dq0zyrFkZr>bQMV5&+iegrU3W}lak>UHM)IR_pxYy ze@#p$n;5y#BG;DjNT@)h{!g+Ev5+`p)yqNL<(fDL5Y0@sT@A$25q3 z-E-fbC>;~&dG#o)|NaaozNd9WbK-8UZ?b4Ej3RTH~ZF}w7GHr%B zWe>-_sB$OhIF&Ks$KB8SU^1_ztqaA0X;+;D*=%;Qsfe;lVk*mGc?nxtm0GGlH_z_i z@@lo}htFl{ssr#yGEz2+HW?^sKBXHe>xkjR>CqfIu!o~fk5Q%KJ?wGEm!eBjx2$`e z{y8{qr=s#xSFeF$DjS!v{&9!)kE%Irg-kV&oizN67)|(bHyYvnSbA1Q8MUjm=@RV- z6%a+EcE0M1C}X-(wJ^#r#uFVY%4AN)RyvRNDuF5Ud z(JG_#H~o_i01LhmOe=EKu_rnrbkqx1I9d03QO5A-oD(N9o|146tuEt+a1YJjW`5q6 zn-CeD#`qr(=*(n{wvaKZ1I7ud_jIh?Q0Pjp?v?Xz|H>qU4T9cMXTj?xxapzYsh*#C z-F($aU_G&_3c}hVxC%Lp;G++Wfq*B~_wALXcOpszf51FWK^TN^<7?od2zKC)C~M|f z;_h5U$8qpZ=8e@u(I~Bd1O^6c1rXbrD}y_8e7m&|4Tz+ob(qc2H&+PrG^hm35>vP? z1Mjd0V0M<=>K)_mp~aVE_|Nt=NI5uhfgsqn-i4>Oqqr2nvS?z7G_vihE0srM!Rix- z62iXcbgB(eIfHgh;)tT%|Cj#Vi#H?scU1$?UrK<)#Q0u`1qQ#UqJ}gG@Wd>7J3Z%_-53b8A)O4y&*4i_NL&kpf#Q9w7z$Gk z>dY)WN1OCGqq@tV*zNubGR61YpY(XPC@mACli!@aFWJqA@~1T?Z<(G&n{-5tP`jI! zK1>C((Bmmno8MKi2~*x&_WP(Ng}b$9^{!=m|3aHGsF@SjS{5p#8e}TF@Vq%qtJTmr zX0*B?+9G1b#G5u$r4XqP?Zi|^yzoIlV%AX0qq1XC_Ax)1F1X(y7L`W-thy)xQGLiR zeB-<}7b7JA=!e4W?=Zdxudt&rr#SaCf5n_q&{ETual=~Phd%4Zq}`fZgvSejq!sY| z^ig#1n=pPPHi%`W@1v_uhJ#%K1K0)n#c{HxOCgVoeYOx%w@@8+8wmX1mc#6?*lR!( z8hv7<>@i{uic&ks>c-;qvq-b;#QMxqLxu!(VqQS=N_lzdt-?oWsTvV^{*)0st*Nfy zJ(QbXK7G)F{6YhpXf`h2I^6708jVV$2Rd&Wl!PmXly@^MNH%nW#0CwzDX>cLy4rX| z#7|s-w@8HTF@B;zdKHU=XF{%pzFg(&27Y3-@Jvq{&QE*;k7Wg0*u-9pMDdYu$$0yk{Y6qtytqOB{a#9=Ss~+*Bay3 zBEfFU5H54DichkXBwdnJn*=P^!ikwK=6iU>7KU7$tEC*&^o=+QTNn4o&hRj1l}3FBYv>vwkDK^R2m}mhP;h%fZyn!t#c!YaJcP z8ZA6QJb`Z56h59c2GJO*X5;b9!SBjkYgCt{>bJt*zD?TLB^C>VW0qO~M&sN=7usvW z4ZGViV`qqjI&7=R@pec#bQH&Y&k5o>s5vzEGRx-7-lhHl_HXqk#3eL`_Hlvur8@$A z%|?HeUPGixA{VWmIe#oBdAMIzQswm>i*u@iC6W5B!vFjc57zesdetgG)DeabDPHV-OTH!5-Hsm+5%j?UUVD9a^+zvJc0R*>VNY96kz zKO@oWIYV9*yFb}O-W8K~v*1^T{4J&C%S&EQn&4cKwkE!6K|0$=odGq@I!Dgx{26oa8ToXi{ zDYwRQbnzI}6wLphzAyUM=Jm9+k#faaDq=QH4cD(~!PMo6Bt7>6fk9 zw6`@=un%~arE1#mDg3fPW;{-nns&=XB6c3gOo+r3QGS^_GYOch%K+nID-5LX4`O*@ zX!ktv^8e89bhKuW^lhr+pSNx~p#{A4t5=V1-N{Zwab#{c4ugwi<;KDPBBS#+qKk9h zc}BMNFgwYcHJs#;P}Jfa)CgUN#pDPfL>y}IMz>5fWc`LkuF?fH&F^Q@ReuS)M5@-a zaL2{oTGvRMD#LIFQ4eFjUWI6L9c@|~9@_1vThw$TnQLFwHCF>~CTw!oA%|uNZziN? zOCeL9z7uEjw&mq%JCT4#sD_>1T%NiUVE9N;xv#+iu^{%e0c9kXN9VDjaMxLj^GzpSORJVoR-oj($m8pEF-OAQOZ;r+p>3a;S+o$CJr< z4di%JplDoi^S1OpsxOT5Cy9HYA=pJS`W%>175&6VoLv6Eq320(NF4jN>9P`z0RMDKh9 z1PlYO#dytT>5YV7%yN2W8q6(m1=A42&(?Wqn4oJf?rrCi00(E3aWW$}ZQbnW@hQLr z6igT{{f#FeX$>JzGxJ5L{}ovMQn<$5C&DZ}>87VavYa>e7Ok0ggN>Q;J##Fki}@UV zhzUI>^$qT)iRtFj=2z?M;?TPm4<+zH#G!MSBKJAQiuj!5J)Q6)3%g)mPxI!)2fd!p zHz&&79?!a(Vkv}6q7vB^%lFa1pWGY&wtU7IuZOb1apT)_mM7&nCj`KsbjzjuC)Z%I z_J)pyk16;v-gLPNdz<1tQOnnpBz)t0<1$1ELE(wud8oU?L!JFkD%j$5i$@k(ai_)e zIf-bLw|n);jkuGq5IMP0G(G_q;rt@w0N9j}jDbfa=#t%nO>RFM>b^`6P|aHU-N}uYW7_fYKBR;NXH)l+`S!jQ|%ELsk1?!&Opi6 z5{DH+V_Dn^HymxUUA|0!sNzLC^oNH66@u|KUem`~gra;eh=k$j;PUa&k73P^mw~`C z0c-xD3*mr~@Tl<%We@=AHe^*7pw1e=LN0_cm6A$~ySlx>$T5KRP&d$Zq56ipCf^T2 z-BzyIVp5P1*V9n|`37{+5{Mz@<>+a7o?1`K@?Z->3Y#_X&~%)PxaPad=5eH6^O1bN zBxk}7fn10Jr1L2``|&$E1NxluPWFn2!wQA^lA93LoBo67Xf% z_{K-=y&n-=9u}ef&;M!>gagt*P%g$h=f`141XRB6t^AuI zKR32=cyr}&PvtUC@o?H3Kwxp};`~vn3xIPli1C{f zC7NQ^<6%6#zKJoHK+U~9OOwPM z+%(a;X$)LRas@JUUK^(<<7nvvWNZg_PHxF~TSXm?W#dxY zaD(ostV`9by<<77z_o)l*PG(LqdvG@f7I?57eU)cuz4gr2_hH?Y>Kgv@XGySBS$rS zM`$mI0e}U;^+mak?jyK@n4@V0;SkgE7+_l(#`t_UySeeCRg)I^W$O2~3&1G(7}F~w z5dx3;Wh@JL^!Imv3y;bo@FtNEimGmjU}dG0-0oE(a)LvaHrx3^ zlQB`6T;Nt`8%v~kW!WbQ0E>G@0vlHcw!V)P=VsD2%P3||OV}6%N?^FQpiW+tm_PBU z{P9nDx1ZR&DbA?QMwu_XUI=a31mgs<7}hkB+?B6;k_+bB*uu$iBq@Dqdb zp&UtgD$c)=3fe9jzk*HT8*O$FsS9o({gR}GSS~%T&h~Pn3>$YK$@kzj%gUGd^ucZC zTh!UWf+09Ryt_=1KDd)t53=8S0BaDN8MEY?U6APU zmgbC1dX#1KuJ9jVscm|eVGKq-W1Mbo+w>rsPs&T86-KAQ$I%VUoUGCBhw|a1+ z!Ck7WQFzq^P{zK}W0X&n5#r}lv~)e}^bOt?PsRj|o+LC{VJm)~^w}&Siso(O&FVp_ zk7t?H163g~MzSp~GDTzvonwXkE184eLZNrH>>zddN1UaZLYjt)9RxdnRho3+>U>g?+GTIZZx)VSO` zFd_7qoR#Djn8&?|5ApLYm09tPDnWm^77kj9{S9zeGjcarUbFR<1Y|ISrzFYw=mV?3 zVL`%KDK%f*f(faPW?MT%3DJK76rc0Zjzzgs%SUamkafwX+-5VuF9cVoT0aEnvzS%fNvthvTYfz zFb<84AG~8Hu-)eD10F6e;qwErshp=-ADf!i+{wkJ9(XFqQ0jtRbzXp6JNCzV4m5iX zc#um6`9K^MLBK`0skykpI|9hvoO-~ME5>U`CHf_}L`a*coHq}0;{Gr?$BpZc2K+FW zu6lp~hlFIzsbVhCdR4#dYpF&Re7+O@tZ)A1vIG#wy3{!>!3T&+DHeZTtUwtH{R7wg z=@G}(Ik440ACr)`npF|9r*;Hwl8dmjM$V2ke7PN(oE?m;{hOYQztg6BR3P{O%-f_M zhpu@xaK;@UH~Zef4c9RLpf|do_frwaX}y#Bp?c`*Rox)1lwQYa%|H(3GeHmoJ0o&p z`DR`A%Pyg6oK9!t*nz%_J>~nigRl0#2>TkisH!yXJ9j=9hU*8QBLeQuTo@QoTNo;V zY-L8pQLL3w(R3{s9Yi2ptOs_mSDJD21iB&6ErgwLoC)5wov=n!d9^{ z1S~@(G~vGgb1zu?@xHIWxpF_w_j%4a&w0-C{2!Jbhj$n=T6appE`5INj_Ob8^ve|G zk7`sTA2<=4+a8nKCs%(UYYY5m@ZL|5o770o`NwSPu@Q@HqK9Q08^gU1YiDK5V!GuV zm09&B_c2X$<^~`xNHLlM>`PdcY_;70bq>NR1>C=Y&IvHNw7$W&?i6YPiqvlqeU5DsDoWwdqPU-SQTx zJc5JEhTs>@gxTQsEkpi=-!$`0EkJpuoIn4+mU%aY;g*p_PA!ixPeKfawnO2ZS*xTe zr6LvdiP!Z{zv;{zewVd2CUXs`s3#0cq_SgchpYOF)p>f<6P2u!OVxDz?3=6|Q9rF? zfKyJOy3pQ(FwX%jB>8KXp)C_SBRERc_ zr4B-fD@VD&VD6Nxa^{=d++b9|{W;w*k(5sRd)4Z^Rh|}6#9PyYH?T|WJzTqkQ$-XM zn%$arHkbblha;oD$+{E9g$8YT8LJFJt?q(623EZc$ZByQa8oc`c5*OcK zT;Lu14J7oWaWB2?3fuB>`UF%*lvh-Jx{NQH*S!VfY=E?e>y_vBaq&eKk zgDcU*DoYb@A!1>C6AhFnEZP6^3Ooadq43Vs9*2s)2K0)(%x^Xe8%xk zaHGPL19okcG)6GBBblrnz<@$bX1x0}2L$t~$P5=2mZdaa>-oi@H&eQu? zFq@)&D)Tv~9$(O0>TKV4Sk3YLmZOH7{oZf-5mMa4=+Epcqgnns7x+eji!X3p2e{*w zIJP;01R!aj+{ab@ByIh>`(SQ|jPT8QY#+?!K9jR*_?95ghew_<0?D&)A9lv9TBJep zybI4XtA1fPZD*|*Ucsu%;S&n_uMS)Yr{BG=i)O!sEqhsR$j_Pi8Q)-I)yRNV_lAAr zEEN;6zKABqZ_;%#Li&a^3B6iz`$N(w+Q&>JJ1-iz4HBxhRG+_~KJFjeO1~BLVVi$( zuRS*Z`?K?Tsm$VmHmSq2Quc&f8&M%9?c`jyPgKqT!U8!@D_MUiP6lv_Q+JpUMh*Ao zyiunUIsW|yu^i5veau<34l~iC9;`NvcHR*vOo22%)qO53taZMp*oF&yoT+8sRD_9>{ z#L$~l)cS|_q2F2K0x=#I;@ZoRFUQ^=@0_;J(!8J`nqQ&D@C>OA4KLoIKT%6IR#PzD zFPOzMAt_`Q0F9%CNFI~-Av(+|T~=Uvx<8Wv@UwE7Y3>7C*n9TkV!if5=09eXcovP& z9r-Zt`T<(4pW@&drnmK%SNd;X6NUFg1h*{~7?j!?%JaF*H17e@^dVxV5)DH%@t4@C zn&Nr*%xdl*sY9K-gVqP^Gg?|%QaBHrLd2Mk;#h6>kmqxn)qGD1q9$d?fy~OkCH7%W z$6A!*pMXLPAmxDDhZ8Fu--+eW->QSAJsjB~CVji%By>kraB=2J!ZEQ}ARQzd;?i)~ zPG+rQI8f^5LPI$-;llCrf6xmKeP*=ubgys!RhQ5NeWE6CIvi9mj46i3+a*%H9E+fn zKmw9>{kZO7_!@c&?Voe6{mI$uQ%G+lOsg1X9l|XAw=nB|4C5UXoXvG?-ntRujSClT z0`$Ocn1~b5Anr%{(I2h@+&A$~!x|S2=aG1z4*lzz3D{O`oI)f@nA-e2$R$dd65Y+P z`Ub&o*@WlT4en+Ex7sq9C~9i*Ul*|UV%5d+1lw*XUQHaC8$gS#K7B=<$buy!Glm9O zl774NB1NLDzt#c=jlz|KUfRT1@`${sa4Oe-1X# z^-1qI@OM<(C?Y`gt{%Q+!c_oHeTxJ`VI5B>8)?|IKA}vXM4ja+h(sGl!4}=S3OjyS z72B1n2dMfQ656{$d6tKkKh-v>E(k|M%%rwa!1L6k-xTzDbtV>?05Q1>6A+SPF)5F3 zFeYRibu3ddlRPYfHjXM@OMdKs1}%E zimd^?pDg8w&PYm~ICp$B-0v74eRqe)I2J3oOK?1W6Ev%r{!@q_JKg^J!unGA4U?t9Q zsesiX`R@^^I5_?Ue`shF($P>G-+nrOEL?|dU5_s~tRVUZ?+W~jx>gQ6v_bg@4J-Cs z))-k#Tos)d>pV|#g;MhS@Li#Eel+ql)^ozBBJy+CAt_0?)8q@_FTiWT+x2)x&Ht$s zN}djN}S^$7x>y)s8u9VqDA&vK63ja7*o%<4hzn5U5EQdq_*h@zg-s?gx z8;0X;^BEy?`AT-B(XukewlWrDQH46>npFDN-y+icjR(`%vAH2ieY1c-5+`8csr-dx z*1*rdTOj_nQA~=oixU7iAy;C9p5DJ6j?bVk?DYw`hG++)PF%j%0?`@ov+08qyakr% z*O%H&UND#Iy8aCtCavc66Y)6x(0XpnMpsN?@o;mXna)xa3U+EA%wK z9{UkehFt}4mfD~nYg-kEpw4{Ky1A@UUnr!@&;UXDv2WE=MLU}Z zb70FkMGb*`9gqkcKTf8xwSYf0S~n93_Ema9Y%+IakM3xJnWf9ihxA zBEmm)x8U5SLX3?`=?F13PH$`nUg-&O@8D5~RxVrsx7(Jt$&_e0V!MjR`8 zblJc)yb1AuesP_)280-37ejE|#v zK{T~5PVsSKW=>?5%Fsoa+BtBt^8&efWWNx%jO12x>lMv#dt;sQeF+Rcpm&os%UDbJ zy)cn?nj+27&Zt@j@X1iiQE{ifCyPd&T7O$8;0jnt_n-n;wQC@ULwquygGgh`mbv9J z&prXZwIL=baq9}oqe=>H;s~?AXmfWp|V>zT_b(PC+CORsO7!$7UZR$lLP197X{$WV6<%;E(1Ka0B!PSY44&0dkO%^rT%_wQvUqSMaGIeO&?_nF-ywz*+OkMqmgV=qQ- zasKw-+Ircqt;217OZcyLPE8>#ZTRbOOQUT((2T__wd4BmygU5-nE3n$J8Zz98-#F) zf8U!tvm=VA)?B(HOeL@;^*6E&s~|!dj`B3|@I_Ip5J!;W=#T7WSw)r!k_|bc-4;ls zE7|^RB6l%-IVdtEvk{*CM&j4^{g3dUXZu%>@Q!suV)%H3pFJGj^FPA>Bip}BOg~== zACB-_hQq%mhG+k+Jk8nuB?xaw2VY9sJqY3JhQn|9AL$>;_Ae6CpCLu~9`x8Jg-`s$ zf45Ohwm%cWr8aWR6(Omp%ZM}nTdB^^{Es|#W_uqcc{qmiIE(O$5k7f1&Z@me3%Fds z6Bm0R6Ueg3E`2XxZzHJ&)xVPMop+6NhWCb4#zw?{6>&EV$6xgSj{j1&H(iRqO^W{} z;vbgcI%n@?jfyOb4Ec-QO8WGfZ0}4`Ua%$DT)+iDY?#8aL?$+<8*#so;wC=$-|b`1 z_RsjSeF}dNX=nrD3=PML`|mi5v%S;r#qqnOIN6A!zb>_DYqldrY|{v-PF94^ z8}7HUdy^Ob2W?N!c071555p0$IVcn2d^AiOa^#`R`gdCTMYbUck#Iix>uaR)Jm#QP z&*0n#7n#TI3e4+nKJ>(0G7_FE(n1dK_Kv`RhG9%%fxuc-Bl{q1s|$~lu78K~gNwKj zj~vb2B}a4MC+fA?{wbs$9-0E@^R-Z&tlqz6Kc}WPhyUV25TlNq{#-EwXxi$Dx`;BU zXKlRzgeNGCswt;q4;H@=GBFeAHKeiMwciz%r5-9Az(U#n)kqeDPk3Xlf%{@;=h8ht z2~Oo>kMk>BNw~CRzhkES-*#i$#V^xoi6cLpAi{?jGZnbrPdHdoOf2R--7%>X{oi#r zLtFPc0o2BpsQ#-b9J*`zeE>DinX!l2vQ%X=EQf{{P)g^S#HFH9GdEmZ-<(k&qqI{s z6{+59-A0@NN_)m@a$qCl_}b9hEoS5G=?46_2-huR)^6;pZ+*fMjuN1ZZ2)3oUNqbi z<$32z)oBTT6@6bhZYT|<@hCoZE~=TQ8h9CDIggKVeor8Ky(RI^esW`w1HP6z2>r4H zxXSwlVRde9ZUX+cwYIh*{cSkI>w13W1KUF$M%38k3HW~tyRBa+aX09Gkyx@#uPbW8 zt^odAg)Q8+KB2_rfr{o2E*0!_sO=#E&Tb+0@CO?Q$m)mm5`XaN|F}moaF3P_Op{?w zAlbG{$mP;{@!TGQ(OvE;-{7{+cUy^;gGf6hjo zU3T%yjkIqjZX|Vc3gJ6KoELoC?C5S^AZ+L*!wp#1{XiNG-}TG)0Q}2Ga~j1os1Tfv zPCV-yIii(N)sv;;Qf8iEKycB<4aGJl!{-u(xrpz~*aMzq1z>gE^!(y-j(&tOtxg{A zxC8$YE+uQ7&(Lj9E&1w)=fJOXshTfh!Nx?|xEbeI!q8xAFdIGiy zR$L}rC~xsu3{>c)n@$TLv}p$e;Ap&%Y!$qB1UNl|WjclhvlTaVmg*8n68}Lf@$`zOz$r$kFqZ$jQ-!Z ztD!dLS^NU-JuvL*mNi7to=_Q=1Egzd*Kk_!%p`<-DznV&%2}QZ(8}zaXOcE=KLU$; zGf5=zO;ER8PUUJDyO{*VuLXk%3a)nf4U_yX-Xf&wO#z~nIHhSep?drP;#ntyuOf!Y zWPN$c#B)NU+y{RHK6 zd)UBac!G)+=4N)ZOR_ti~N)30>cZoBoq`VxVhoT z5I?D`J8Au2+nLFbS$OD1zCclN_-#!n8(0lN1{VlLlBfct>GHR;&ZHXFIsRp4b_86gj zLtSsy<(~skDgst){%{4rH1OXtI72jqEAo~1r#;VmVi#!V$(~tT4RuEeYF2u0TD`y% zy`UmIab>j#x{B1${TNcK@IDjoSN!MuOi%P;ykGL4?-z6DX%kPGtN};^r>6Pxo%!j5 zxX+hYm+4T z4d#Z1;2yeYRB}{?2j%-@3&hSuHh*YXHt(vy)A; zQ%ti{kpO<_Ky*+IHGv)94h?^B7GD)z z7t3G37m-%I*M{t~iJX|E7fR(8&U0?M>gGpfSeQA3s`6klJ(3#IT=s&#+=+0G?jdH` zpbDSLoWzSvqr&;z)jfLnfZ;?z5AdJzN76J*yp!1Z5%en?q7sXLZ;j|!-w>hbn9M}B zfO^4_U?DK1ieg+GQbX@gn>18-TOh-L&?>Qd0-nffao~v0%y00VJMs*^=OH{hL>Eoc z_Y6F*8u?COf$+Rie4Y+_56s_*kNKcB<#560FtiywJmq-s27Hck3TpGj3Cw5@zZaA^ zNVkn{91SHa$1yQ(AN(-!RCBYU^Q8I$Qxr_}FKDp=4T%FNY#IE9g?$BmH(XzX-t+p* zGaB$W1%E%oxOorP5Z2Cetmoh3?@Rc0@P*K{)za8VH2U%uW|nIl!b`+LzT!|$tPjtUfqd&H&acRp9UL*%!h-ZtJ1@MVg1=!@g02N2rQOtBqZ=70 zltXjibD>;c6ugFvEXVV`v45Sd1tvAU*AhU@d2PDIki=G|>7sQ|hJAu{B$3m3<4mY= zLWi@xjbvh2-wUPCg?J^YgyNOThOX1vev#1t?uk+mgw)50GA%@<&-60O3_FI*6dW77 zVMvuix6G%Y2WnSb(e4hh!;7(&hGAk`Ad}%*D4qf_+D>&=AH5gAiAD9Im4{(nFZAXa zS3N#7{G)IG?$SmL0$ktM#J80GeJc%jh@KDov`0okAGjKaU3Dt|nqK>?uTiqHN|

    }IqnuU6MoYR9WM;L5jEgwhCQm!c<*C@1R29e{3c%H- zsCA~O2^HroXXT!%DRy{^399kDtyMNCrUGdW^F}&Z05FDR?Q2FH8a&pE$<7wa%rc#o zS!Ujk5<>OsoXkSkT4pZ!@oBGRW)^wcR=f~!w%ewSi(n%AcEw&=3R?%@Y1cox0$;t0 zMD7y$CH&rG=RC_gg*Hj)J}2Yoy{5pd&82fdY*}Fnd*BkLC|9cqN+R6fJ6cr`1;G!w zPl+*%QzQ;}aVCJZoQtAPv@Pe1tHB`D zsT>Etg{^eBgjti5TMgo`xRT?jM$x2k(4oq)SLdmUU&GcRN(6zHQ;AQI?>>3r9PYSB&%i>dFBjPR$sL zAlw?w3~-i4WJ_}XlZcHpy-V81$5;0ey&Xl}m_~+U$te-BSsxI*(|cjRr*TR&FD_EO?UfIWfdw2igz?F{{x)OObH_Q$EGZLMej@#Wuc4ENs}2|D zM(3DnkMS?OPJzR^Vw=(0>cL(X;}+~14n8`ejrI6AbOPz9K0;64`+IS~LUeuD6ui%B z2B`r{8-7XrrrtVQlACty2~8V~^n0$DXSCr$J`N%~)$UL*8t-$r#R+^WW`^J1_UGNk zG0~da4g3atT6??wgAbqpSUKjzw}QVMrnB%TRIwI{>(|gRq)^@|y~Gj=-2ymJWQ`)B z7>3E>9^$=!!FLg^7jS(n$wZu=?#8hoDyKaq>olS^qowlp6Nubc#KI|S>wvf&8BWU) zgSXfdi>|rlbwLh8um6TP-BSZ;<{2K$)q8MJFH_V6jb%pI=(HKHppJ8K-G}S0A3SrQ zWyYhp%($?&Gz(vV~0NiR(;f^AB=y-H#dy_#NM zh{1eYB}3yGX5RLoKu|+DO?HPi@0iUgN<+!*%x!{)4%Ehzxy50*ZI79hwOf_bu`5p{ zfih@MEAvdfEqRuXLrnNimCFVPC^&b}?vc4qxn5BL{12(Yp<;WXhyhMgk@vz~dANbw zSH`Hy?2W1|MFmGn_ZO^R9Tr@&wluqBeQ^;`sR&}Ym+D1d`D_(6c5g@ZA=w(6%d+{9 z0uO5}hiyI!8mUDP!258l3Z1D{%kXLQA=++N`Rvk1Zwl%a=}5h8XXROiRL*n9vlov| zhi#@-opZV#o@cNE>KQ~TqB;damW{wPRN{4>$<{!0U(0mr2Qkp}!7A`Sd* zc_Z!r-EADiKuZ$Jx*_J-u7A` zxQW6ZGf&cJCXXU|Rc(0kOpAPD#V#W>m!GzrMD7+8nu-01coUG4EmOfKd3JLWs;JC>)r+!0yr@=XaTiWCSNX_7;|yAcRfgmDx%vJSdJDJ1%`h8-nDEG`=QfRL zUP?`NH7pJGh>FM&<%vB47fNL%)6Spm5&_?da0A`I^9Mb))t-@qQ=I{Rb)|3v7tq)C-9oE6K4K1u9qBj`0z_o+}%Anw8t z#kqtO75LRrKgF*e--$Yc5ASHh?1CszHpI}f$d56~J{p`SHP>vRm2(pnoA}Y!-logtk#r$&oy@($JkF~2oTojSt#?yB0dBYtdotr7xwIPFUL zoX`gV5P*GaBiuv06O71!Ej}FlcFoVA)Zmg;6e&n~d}4X-Xt3U18LF<9KSIBxiO6-yvibF!Q(Iall3H2-_M%$0FrPjS=TOdr zLO~4gjA@HEA|)gJleAtH(@IsL;tiUup0lL-)enVNzdn($mKTS>bJi)uiW)A86_q_g z+goJWy7kYSsyg|#!SisJKxAU*FXoWCUnD}FH}X-R1(*^CV9Pn494 z^K9jf)(dp==cBJ_V64nB$NQ~M^&2(rD&LMWzMKab3p;8rquF*-NDef~_r`_>Mbz;8 z`i%1GvTJqfMs-BIyQ&o~@?&p(cL_>sBKE73X`4TB>Zj9Ypb+I*%}6@YqSZai&DOX7V|V7^Z- zMV%OR1X~mZp|ZTSM%Nn9t$-W^vR?wz>S z;Mz#$WtmJrZ1x{wD|Flf=$b ztII}nXK6P!H|nkMss+3%%9O z|CZ&SN{iVfLXPRr6Av@Rt1NG}wGjEbzFmC2$=tM<=h!WzsqjmeQS6%^u;w}Jn*~t3 zBRi`Ta05f+nfN(3lD28)b*C#?{kW|bh;BY5Z}33C#L}ctaAf6xXNmdD@=h6$wBR(y z`)m))Fg>z4_aLNn%reKXxM)3~9c4s>e9fj&=O3%r5S6kh|elRPkwI0@?mIU;rSsCwu%iqTr$nM*sSC9_{D& zMBc*>CN{Q?wU`l?(!-z%;@vQ)0`BXZ{3Dp)ufZ1^SXIASzqdjEe~Yt^ajvo{Vm*LWr2aE1Xp$0-euDkO<1SnEL226X{2 zDTDz1u|ylKeoSwp10D;u!KyG%VrKm-dX~<_j-eAfhF=1~Y%rjGC5luR;P`b<@=R1S zf94|O1TiEU6QiBUu*tVb^7zNh$l^bfT`VQ*SlUxZBP0`=@IXT5?m^RRLIL>sKO1WO z{K>v#1%W|U;6>p%P;PGcXv_K0w&)~NWm>`mI@78od?9163F8BEiSv(DoN-d|Dd)>o z_#g?NDh@ll&p-~$sgHw!MO$K_!tnrhEDL%>arYyLJB7rr6kGZ(&T7cI>VLGSwaR~5 z$PItM@etA2@~0{iS64w|qD_JWHXySB&p6z~ukGa5n#{4t4?jmn&y))2iZ1iViU*?4 zVounF^$BZ|iL^6GSZbC3wBEc0>rIltoSwuNb1|B2u7vP-?$eV?t$x9s^k8WLv)n-t6}wM#Ho;$riw_OPnsB3KriK0Kx1 z>}f&5`t3=_CS8AVWm+!pidjbfh#qE(Vq%l7B7;fSgA*uf3ijqrm|vSVgrVf>;ZZMX?m8XFs)_ftMr|B?mcQb zrJg&&(dqiHg@8P|a1CV~PgIs(CY$lo@?<$Dch_1oj))N5HC4{D+ zlN#u13UGv^0hcY#CV@Trr5=tg$qfrd^SP;EBru%=>2FG5iH3 z!SY7P{Q8!=Grq)L#D{Amt|hqsc=7`o=BjnHey#`TPd1uL$*gtg=lrCIoPIWnSP`N> z&68Pw7Rf^cwT?nLrJq5|ZrWAMe3J&(beyLqxe-2Vre6JKpUkbdkMV=ZzPjZpf;X((J~B!Xdm$5vONWa$rZP z=sp!7ld1L(B?KwQ?QZ>(KzBSq3|6zGzbczrJ<{roTO<2w!mreSb)(3v6V{b5Kif zUw2tf;{HMR2<1(>zOBEK1)~%9ly*Rt^&Y|N*o*$bZ1Qb);_g9qB%taP%B3%BdHUlf zW=!Va1UP9`zNFlCt&$6*YEL9E~xjIk%_oR_IS3|c-@43 zhYz2v+mFaEsHZBqDqn=KWpT`}==3(>C7N(^7u3cHSse9rtj;z;pQze~A+|bap+?m$N|2S*c&v z1rSx{5`pN%K1lfDpR9lXvGPgFA%?89{(aib$TNpNNDmPC<(Ug+@xJKlkRU8<{fm({ zL>|%NdS&@|G^aSeIq5Sd)HI8%Z|tgg{EtC{IVyo)@3HL0BS-J5Sg&;{sKZhFUoY8T z&Lp6?pWZ8v)dPk+lLXKMTBg2&BSx4uN3*hMLElKI{8$Qc4uNogYO*K;d#05QW zN_u1B$4DT(QehHLvHW@csF&fO9P&h; z1wP|xUPJMxf!@#A6_)i zcpDe$qo*syz!uHM#6Q20^|FGC0yF~w$A#_|Nk!7#Y6IBW@Z-bvy+os|O(A6xp9F-p zrpdwk8=9^k4NjKd%e8-zo)k>PJz$9L7vzUw^D=?Mvn+Lg@wWD7F`3^Z9KI^~O)Bp{0!s=JMr zpPpyp1datxCJe6Oo&J~(X#dz=Gzs2a{}IkHIjT4ndI=7c4Q-Qnl(#NSY=P}FbaHI+ zekN1}@7!rCc_5NyeWJ{?yn%Vh23d&Vaj5;JE#$F@@i-oWyC@uvG%C;|aL>nmBKv)* z+j>8qAn1S6V|Yk%SD!DA$1pZJg9=Tfk}DHTx>r z`_o*!#{f@^Q#{56K-WTp;$=QhailLQna=j`p6v2Gmf=)LHevU<&x5)d*P*5JS~i+RiBC-O?Y@6oR!DZ4WXgUCl&km zAN25)Yh~H9XG6zz0RXL>$bkHdMdJ>`lah_cJ49}rLt&YoD`T|gQ>Vo3OM-586QfMa z+GJkH!#cnYPVJMNU-Nk?xQXUMm3HmM&DyDXvuX?TV`6fbMA>6fDn4;nb>;ku-=MNr zPI-FGrq#w#Ie*&a2^p97AStejLIc=Cm0ZZU)nV#}Tb*zUfzC;kr34YO>2yW#Q7v;!@*j z01#}DthtpYar;PUh`PYfR$1mVOb|2?oUf3V;BUkTp5`Rixmx3JQW29~oFxG<51+f2 z|0NQf$ec-h=O2b6i=jN{Wsp8FliZpixAkv`=GF|ltvksL{ed9QQ56z708oyq>p$DvW$2OvJg3AGY;&U!|b7ohNu9$O^Y}9 zIggTppjY7K*|7EQvD41@U2X7hz)XN>%59v~prX+7MWnm2Wynt_adNue$Wyt3D^wn1 zG>#J^&ihEA zGv9v$fE2Y4WWM(XB1_m59g8zhys=bS?9WX9+ft>+Fv71=na4;V45rMZZ(tHwR>O=s zb>MK7$6h0uwD`VZG`6VV>hZMqzlPKCs~=79t>xU@4MTAz`Pua7;%W zLc^HwrRm&0G(6w`nart`WM8YT3R)`HE)L2ld~Ro=vOCZh94*N#QGV@B3dU+U{r(Q>2bkCl^$?8> zZ=?*r>^8r>Gf$EEeSe*D7|c?cr;s4n@p^Ihf4^o_nUQbge=Kt;>~p?G8SKsQ_k?vB zw`dATrkc};a9_ls68%1jaP}&@r`4rcyYhd0XkG-hSm`}>9pb$dWh5DUz##MK>S@|% zemJ&z$ymGLsp71>^kOY0M@D6Ll-LbBix1(&bCtKrbb{A)a^jyO|61Pc;b}5gZU30I zYw4_XWj=u6G!2G)Lxs!^7z@Zs6rPv`(qRN%FmRB)0B>JVc?=7l4t1tvD&Zx4@Nkd5d44FgeGKf{=j*o}zpNwXdw%mk` z{=B3GSz{kdVw8#9*KOk7DAW75WCf-S_;N+8i=yVDJq4QDIPxLXNf@I`{jde*u0iF8 z`7mr`3w`gnJs=ZkQ69t0RG?Kz*yHkN>S_a*NQ9QcUX_`7h}H>$~FE| zLSooX(d)rIXj;VxG;N)NVQf)2DIJu=s5UGaj2*656d5cxeo}#pzwQcd(2grO%8@9k z7$$Y}3OQlgL|`kL+(`@`ff%pv7=~XpcYl+!E=**kU6(H>8ZrWj!~+#goJHi^B8S*QUbhZ5PyuZ z0lsP2>0yOB?Sf9!~Qemo`o@&BD9Q@{r!|fjAohkaEX>K}wE@cV{O-MK| zZR4QJ;HIHa901XAE!f|~CK$B5lgVTdkHc{}DQ2SR_m~WX;vcxGf>_{7ar!1-fAY=n z{nbR~Z_ty|{!W1&wJC4cehf`MxS7y7&)Y!MwvbTbOde;lV{GOaOu065EYif!@lhAy zCnzrZA!1&jz7tXIwi2n=Qd58<74fvUR2){CJTMoW`gU&~_CoMiqC*JhagsDsj1-YH z(An&CzIYFP5db>>1=-Uy3BnEN2u&aQVMgb_&^MQ$i)=BQ zEapTcT+CYO;a=(!E|uMC|4yj?^Fn;^enmP8H1v%qWFpW*j>}gwZ1qh$nJ01V$deis zcjvsnQ{*l4RsD<>7~WVVBT=XWw}?vn@ap-mt7P|^R>-5DH-|)!gT3sVtE;ay^TFhp z+`Io47Wxj66Mp7Ak^DaW9DEk0QEMxquH&rSnJ?nM=7)Xqbgs_6^b-^gKf^16{lw(# zLpmf7+>f>^DLL=X3E6gBfMT9ICs;ai$fs2vI-r48H`9T|r-EEK%h~ppQ1H*vvpKN5=j7eHm_>FnZkne)<4E@qN8 zPsto3@NHt>ew~SyK%cwSw{fhf#9OyWGF;ABq~V+)cKtE*`;=uUCZ`|kHpk$DTYZyL z-*TJdI5Uwh;Ag1oaz3*Y_E?|EIrYtcV+yh=wo255vnUB``(qce^q3@(#UR5yo| z#bl9=-`m9)q5cXIDn5n27E6xzswx3oPWvAvF`HC8L(5gt2PjpWnq8H`jf8bJx1zhl|`;M)LKQNZe~ihGz~1Q85#Eloj8 z*M62X{ICY!n^&dNu|Gp^a22lma3w;w?h@fiofOMc{<`nk9EJXJ1&nJ^FH)js2XU@+ zEFO>|qQ0POGNkugpxq`k}s9;-5<%43DIHn(Uouv7Eg*3r%E;qAXx z)tri`anDyrlt;R_MVbZIZ$|zZ)52EUq9DWv&(JKGL@ADU=T&AjvymvZTo~;z+#*o2 zxOeQm;lDV%M+U$Cw+f>L5Ooy?SWjzwRASugVD??GXc3nWnixHZxZ{u>1IYvP;UQc{ zaGk*AdO2;z9oSp00v+jA!UqEWDnL&?UqVk5*Mm8-#KHX4}*Tai_0sG~EB;6Z5J2Ibb+%%1n4v2HUYct5z+NH_C9keF{bKZ%Or=QNAcLUc-B zSm^cZ=RNA$2KC-;UkJsJvI*N>8#+-s#8;Gdo|w-}_oxH+JmXQT_vCog0edn%YSkXI zN3A3)7pGRVpBIYs<-zaB7$QC}Y`yA+5^~_srPD&phNw$GrgTlZ)FQ;>P2d6tnGZyK zl}qmnn^OX|wZIOM?^5@jSSKAC!^0*dhG_C4adIoX3hlSrA@7+%=llO~smsEO=R!#) z#`t)~Uvtjw@TrI+17IWSkSl~CK9yUqBOVO`@!)AQ_YavmrB#e0X&=jVT zK3(bTAejK8EpGxaIkD)Q?dCKort@R+$y2qdk)Fa>P^MjW#a?ES-8={13Gc4jY8U-v zInR)Q8D87R5a+EknlQvg4(=&%aE52DPH83c@jUM%UBh70%o9YaMlZs;)}AXEetXr3 zGi@3YIE!Tw(aDHlXAc*_(I6UfVA!6Q^iTdnCy>Bpw<#8Uw7x8CV_$I!1O(*35;CBK zg7qd|Jh%X&(oCIhZmIrT0hEwZ8p$-uj3JS!b7dwo*#g9gf~1*U7$7&n4_wF9i|Z>~ zm{()+W7rgQusH;2T)^l)Qef6_w$lF%jbI5;LU8iGgB`>g)DFqb@~ z1Bhc|U&pihYj~bYrUvr8KPo_fRZ#VbE+wc9{}`W!JfuooP{us_vn!jFF-c=op;XK! zmgWB#8#+!k7DpqW3y1CxD;!1XKNBjV3xg>Ar$X^AXsGu`N9jKibW?aHunT6+7u}m+ z{oLx~xlOH0FOC-5f@r5RA(#9@)@e~F{Tb?Y9@i;cAK^;FCDwE56;2al3XQQmsv?+g zG2CRat_g)9VqNC|AmZQadN)b`&AqykszvL2g!Fz?t)mo`yjSZf=$dKC0eZin`c|rU z0qVU7^Ub4}<9||r(Vq1V>{(~Q=m7XpiJ6W^MAn#vBW3$2{m*u}LJHn1U|CxCaTCW- zF&hm1E=^2wXpD8Xh*KBiGLl9M3r7QTj7;UR`5&;QIy7i*bEvGd5R$QonV zd4!}*#+Za;vuPe7W;W~*cAkRVs9$I2k=2zo#Ecq;o52@GWj7@mC?<1OVg2rc{-@su`Fa3k9UTkm zc>VXj&?$_*@|r6g4ex3y3ipIUJm``7D%2BL?y*9(c2!f2uIW7;pwdZG#KkmQ<=AA9 z`-zXRRiIc4)mHS`KVqjti;W~LB@8t7K3*Y>sQE$QPXfW8P}EqKBi|SQ`2FJgALA>of+HpATY)L%mFh$MXW96I#k^@T z84UQffVzpzr7|eV@QtA(o>Ylurl1TcpU?$BqDj}ij~cQz4NfHnK$ugQ(%33m#Zo#G zpLtcdm=wHhC8KyrZpk7e$$X$}8@nS};R}Q?sGQIdwicQH5?*{DSzabsPo;xKO7| zg0EiysjSkOL$WdV;kgOV3g-&)Y*?$p=uWI7OyI&tQA+$&#ONfA6Ca7EGhkbEhCbTv z7x2x|+vQ)Qy8EI-j7K6Zl_IssD8|Q$PtBD#nJo>W;XXw)QPla^RqRX30 zGWb`$03i+yVJ0T2kXPhbp@?Wcw^`QWwbX4wW;ZNSpFYzQL!mByO2cf3=M&+ zLMc-`dcWxM=csCzIhLtdc@xrY)aY89m>q^sg{&=d|58=^8yH1WaQ|mrQx%MyS(Nan z0?rnz{(O96qT4{_giGihaMf}v62M@zjU&H%YyNaRqYXzv*ge?`i>)m z!I41HbW)rwt(gyO;3gCaHvReYs^!SlB$T}C8Ws`McCvi=GtC`&V1X%ATo zvHgNaqwQqRgFZE6DMi+n++*N6k_q+??y;>(Yr07&2KaaPk@01wp1!^wE=%#HO@ZNy z5$l*lLW{KJ{S|r?VH)Km0)6j2#F0ZI{VqiwrcT68C!I+0-M=1~k(&M9QbV{B69|kE zCodT%Yg9TmNoV^VdWuS$CaJ^n0Bj{p5BDCpr3G9##@ zc5sx^-glLbN9BG!TyhMz^;o4h4F^B|-Etu2Zh<)Ged2xRj{gs|L8Y21FRsC%$8H4n z{{X}}#3?8dahv>iRQiV3lIi3qv?Yx2CN(lx|6Mp6fnmJ0UIc9-5G0~MThbwDEB)@* zV4O+2@<33;?AAq-u;c+StO!c>>4vjXzivBx%z2f7Dft2mwh$^7?TDo^Z>vA%tUosV zjS%8bzG43P#Yo>u{Z0~;=s|pT{PW&(9bFt^h?m;NpoqRla}tGl?~Ker{&xAjZ=fW6 zX6XKSQOE6wicFNKn@F4D6bB;e5Z{V*CiFW+FO^%&-#dmpNps%;9hnemWr_6cpkz`$ z>`iem1wL_jv2=7f-V%2qVm!m#AIsy7`0?H`|HpfhZ^ZC0B5m);6>CFL7bUtKSv&U^ zusk!7qeF(Ff~m2TNZ0O=ATC6+rAU7lhaN#p$mTe(=pz`ttE4a(7Fi2H@R$*TOwlrz zGC4GMgDfM-HeLxj!|B!z*Fm?MsV)y-uC~ZfM=BS$x7}6-edan?re6I5;Z1Q6NEXvXxQmtFSNprNgEsk8E|4kym>_xrZ_-MRA=80$ z?Bn_M{iy*NE^aPK+{tZUp$4ja?F&xlc03O&YnD0xu-yd=WSLX`8fT=Su8D6rKWo84 z?tFm+gr4RG1ldx9@##8PG5&B=a$Z7B6JKNI_rk}-N^I1v>ecI2R=G-s`dFgq>{-DL zye2u1uWFiCGv|ns3QFVmZ^LQHJXIo;QR%~S{~S=9D3LKXTsMB{@{Z^3e9;+dv<{YIhR&{(lG^24~U?bJUZ-kaedPRbc z+b^ZY1Y#S<(D#xmBFhwYBBb}0@tkEGQ^wgwbCr}y-1sD3Y3FPVSNQ~I3jx4`R66+i zl9FP6&!mD|zXaH)M?lk)p??h{j46#wr}rZBFg9thrWk!;bQ^rbd3c4tPw<3%iXk&W;QE z=)Bn%#`E|4chboR4$s_w@Bv>ac|1siVhCeH6x@sDCSGl_43`(Jsr#Ks|L6|wvjJrX z@+M}#eE*fD>w5AkQ-tO~RfA^yGv5nuPY-=McYnTWRK)Z7>Ezr5KUD}6ZUZ>YxXmfj zM}~YH?xq$E0X8;#s7Ewysw`hMI{)ub;vX$V6&cBV)fn*|aYm9)wj_C6MD7|c?|>zp zlrHZ;o+^9w*qAFc4w;Y^Uiok6?NLDpo2*KhHa7P%<4Z>q7`D<6YqC-S<4^iC?IUay z6%vxOm*}_8kmeREfKb=F4!YD625ebLSgJ;4eHMTbs4b#6%NWi=D&_m@;e+_&n4<8} z)+1KM>Q&+1O^_3~4qC_EYj`e9$t@y-6#hzmgv;=@VS?j$3i~5$T(J9H3px_}j-)lQ zi)6ZbK*L0m!vjA71cWa=g<~BSmv>hI;}UYDrZFOpJK^`}kQlp;^_&N*@ZA6o9#xkH z2+C&tqw^&;dt4Vet44O*JVGA0F=~o5u7)!8k{5p7pjJ*MmV6>m66e5BH5?283r*nbhHXnt?cq482lCV)1 zQP$HaC-$hKZXm?E3F{Bpe$IN9eFqh#NMMg7PgocYF3eENb} zyn7ezQ@D<})FowOTldso6ZRh-d*Y>OgE(P;_m#s6;GIop?-_ zVb?+A4e^Btm8^znH2DBGbC?uCZB#=gRj_qzgCekD7Kz{L<7N%ZC$M33&kAzS5@+Ef zChMs9m<5?YWUqeHMBH{2T+-cQ9vYX$0@i#q?B)LWKD(Ns2NBYov0I*u4J zXo4jNWyMi>bjK&GH|{%(ancZ51)PV~U&;`>%y9c_(q+%&4smplJ7E0w!vPV{4J3tg z(?MLo_|5Nxas<6Wv;A;r1ev2yS^VSZK+Zy{Qg(<7R3XY-vf*LE3SLgwe1IV@mE+?k@AU7;*`AEh&KjY;U8sLoDv0+wDTwb7;gFUqio0EhD$fXIYCsWJ zm;V}@b1o)`I9((Kx}3dR=P1=@)ePcgeMW{$jjqZ%bGL9Dg)dwV*`#@k1x~Lho@WY} zTu4!-fGOFB$nuUU7#|_~PZ3%01B7;%T)WuPttY`&Ml3n_8-z%YCuU9FkMKQfp(^X9 zBGD&Ib44a=^Tb6pm)VrBk|j*jnbOH%!w*V#t2e}L`|Ui&m3(M}JXyOvK)oR}<9Q-f zrZ$9{0%QeAI@25yI&XK@?!hJZTbW2O+TvEgdxUhT38O2NWxaW1bQOuCODD8%73!bQ zkZ9KNrPx;#GgBfBF|I)boVpLRa6jq$9I}=re6&8-tq~VFmJPxxS9xVn$HE67&uT(~ zScEvl>R9ic@A^Oyq$gAYAV=y~4CmwjRLlp(z)solg@D003+Y>tK24=jzm)O;jWL85 zU-DPkQab08L5^nJs&T{gL#WwJGn{JN=6A-9(9c+re$X9163<&)AfdOp0SMoEvJ8(( z_|o}bbo0S?{~Dp2FNOf(Sf4mdz-=PkyhS7j$)Fp?VH10^q6$g21W>NG?5c5`I$-tT zuW)+zv_9+fV2c9!=70`cq$-RjTjT~p1=uUfRmpGV$rJxBOe_3Z^7y5pvCqje7Bht= zr7U};&OUp%r(1}iX!Ub!<;}TF5s8pI-W6J{l4ZE;YFP;sE<_ROQg7Dyp{#=nOsueI zK~WqP_xf1RdcP)pyjSew>LHlD)DO**`q3T%pZoJJ5+yO3gdrEDr0<5a}^QQnxd$L>0?`y9E*k4 zZlrmZCvI2kl#aJiv4tc+wIe6Vt3-+q?3chDt`HEdF)!!aG{s4%oaJnF8)rM=e%*azggf6nd>#KI&_ zbD2ZXn5_2=x2v~CEb}3op54uS_w5KNS%+}eQvbK%Kn|Z6s4{$bw6sn(YC8JZ-4jS7 zkg0R}(Es79-{{lR3~loVlYQm`$Gq@k36x-V3z#ns(XNGYiA zBC^Wvvagu|OInMj0hbhU-!u?xE3Bm=7KGhe6boBiK=)g8ako}0sHlJlh^6yAXC^JM z?|vV2GIRg$J@?*o&pr2?bB}*2?b;p?yxga`K}xN}`nLd{5~=MmcpWomM1SVSGK4}|O5aUVcxod&r6LH)`Bi**C%GDIYHvk;C{17d{t`m=T0b&gjrvnX>M`2|9)McCh;0e zm&vE4az~B=uL>`{(3608(tWaq;P?g6~g&NXEIz&oC}|eyhE|(?-H301pTmXnO z&S9`UsV&=l4U`h&eL(5o30Xj8Q2dM(v|I0;?NM8@EIBl1Vq-J{Wz;)#;i(oNnXS2P z6}alV?MGKzTLu3YAQ^0ZZo)4r!Y|WwH5V29@)L}gD>%0~s+`?GyES&q;H^4sO&;LX zz>em%I@z!`i*p<5t?1&@9@acr%Psp_?cdE!yeIyO9!sis(dnI<&~CqB$(0OIlUh}7 z3C#{{oRU@xUACgcflhydM^m@9Ii>z8h~XNv4^)SSRfVCo2;}vzALNhK7UM% zfazBF?G1yHm{UGUzs2r{Qcrz1pTdY^U&ATm=hZ6N5E}ZT;d{ydLt1nFvX&nd;N0r3 zjeW^;x#4T%VWJ3;rNp|)I0wa$*cY?$MTM`Q9`x&*u0cqQgoZeS&?_}wiGiB%fdg{7 z@2hxCMwK(g>fgY#OxXpjs@`o`oLwx=;Ah0vP3jtoFCT9hqX*f5;s%Wo2^*qaH@WrE zw{CLBMLTbDgQankTNC}$O>SNEz)fy-bpK6mHu~mGZdG*OkXvYmU8xbME!DMirS(2) zQo~khf6zK_1KJ&7ab&yl>^|YYHu&{BJX}hR${R*v_0zZmgwt zgHO4PSTw-)e|<eNjP~m!kL`Q~JTNufeaj z;vg|%iFE}gt?)akK;3c1nRyrZeW>%6=P z6m5X;zV*WOOkw0}>lvK$LGJmkUfJ9;!9iFgt?mozN1H#?*h4+;PzNcu;$j_MA77$k zs`#(@6jP`p5EQMi&YF-D;7^DtBQ5p|r@F9I#M>U!+sgIAsH?ZOh9lO9hm?MlmdR{S zX5&g_oDGrz#Kd7ZI7%ceDO`$MoecHLE#SN+lcsu-lA4$Xa71P#!%gf<;Ty?M3jxMx z&u`o_lKG*SL*vf&2W&FY7F!G6uIFXGsr6Kl#;XX+X4sLaaaA0W3l$AujN(oJ;*0`m z`m%&fi7N!~Aa^`9=*duOAbd-a71&zSAVfQ&{!0H_Szt3)*-g(=BzbL1{qlw@?qhu_0^{sCA zSfvPbp?gNU@@;!J%gH8}K};+)dRnSEi(}uaiLAlS?~tJvH4IJe>Xz ztTT3H)%=r=i{0$VvW>&{Aqpo3gLY4SVSHn-+7r}|FfUcXT~-SasH#$^-=oKAtAX~y z*knro2QsGLGB7SmOLtXfVWP31E9ycS+9y;M+n15i+IHjx`(f6{N8J4+g_3 z2&ST_hSVUALd+ks4P%3F;u}^C%f3)Gn1dn1mZe;(aPRM7byKwSyX3onlwl7xZgNin z&LiGIN1&Eg|A(-I$+mCc#?*+77aSCNyO0JfAsW%k5$10XH;ewvmM$l6 z%uiv(N4X!(y+SXO0U`NmE#h@Rh6~V)6S(-CYoGTq8quE ztl-BinXFoG{7tpnYp(Q;@u(wN#mE{}d)W9=JYtXN3VTt33;Y;ZC7W}GGq1Y~wOl{) z>Lo5CzUS20zDhGU@j~U8y$)z5?|OJvi!h+)>_`;;bd4KPG=TdzIug6=@xm*! zFn$U);&i~CY-v3)b!y>8WG?qV#-{Mq^FyT8`{Vc3a=Z>df-##=sb&9xP_#RKCZ8T7WQXX*-@XUkKu_)Dk;2f>~O#f$j1B@#Dg z#mp2m9e;*v7^ptX4gT!?=f@sTAr6kn3gODJdIFP|?Bt30}Idz%j~op|Y0tJ>BZHX4^%uBAenzZLGV2|tNK9qoATa(GRhe zQbzqYL1?8q#QLpawB%oq=C55Kd@w-E{cKz;J<|Mz8cX3oZ76iAu=z^;GI=Kfeq4Wr zBpe1WrzV@!rlRy znxH!M&&pEP+uZ{SomFMe#%dW*_2AZU meTDtwu4WWhl%_5s3Q(4iUD`oVrQr^~W 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 |uokRg;D?{*XFu<+^xE(5; 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%0Aw7ehR))yvkXxX&62G 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[Y)|PZUNyU;%D%K3F;0-D~rs#S(@o<%)uz+CwHGL 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≷X65MQcIT}LXka}^rD7$Izo@fPeqUy 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 diff --git a/pkg/ota/ota.go b/pkg/ota/ota.go index d666be6b..5c1c7510 100644 --- a/pkg/ota/ota.go +++ b/pkg/ota/ota.go @@ -8,7 +8,6 @@ import ( "io" "log" "net" - "os" "strings" "time" @@ -24,7 +23,7 @@ var firmware []byte const ( COM_SPEED = 1000000 - MinimumtxbridgeVersion = "1.1.3" + MinimumtxbridgeVersion = "1.1.4" ) type Config struct { @@ -57,7 +56,6 @@ func UpdateOTA(cfg Config) error { } else { sp, err = openSerialPort(cfg.Port) } - if err != nil { if sp != nil { sp.Close() @@ -71,7 +69,7 @@ func UpdateOTA(cfg Config) error { // return err //} - //cfg.Logfunc("Firmware size: ", len(firmware)) + // cfg.Logfunc("Firmware size: ", len(firmware)) cmd := serialcommand.NewSerialCommand('v', []byte{0x10}) buf, err := cmd.MarshalBinary() @@ -204,15 +202,6 @@ func openSerialPort(port string) (io.ReadWriteCloser, error) { func openTcpPort(address string) (io.ReadWriteCloser, error) { d := net.Dialer{Timeout: 2 * time.Second} - if address == "" { - address = "192.168.4.1:1337" - } - if value := os.Getenv("TXBRIDGE_ADDRESS"); value != "" { - address = value - } - if !strings.HasSuffix(address, ":1337") { - address += ":1337" // Ensure the port is always set - } p, err := d.Dial("tcp", address) if err != nil { return nil, err @@ -226,7 +215,6 @@ func openTcpPort(address string) (io.ReadWriteCloser, error) { // readSerialCommand reads a single command from the serial port with timeout func readSerialCommand(port io.ReadWriteCloser, timeout time.Duration) (*serialcommand.SerialCommand, error) { - var ( parsingCommand bool command byte diff --git a/pkg/txbridge/client.go b/pkg/txbridge/client.go index e679ff59..9052e74b 100644 --- a/pkg/txbridge/client.go +++ b/pkg/txbridge/client.go @@ -1,28 +1,51 @@ package txbridge import ( - "context" "errors" - "fmt" - "log" "net" - "os" "strings" "time" "github.com/roffe/gocan/pkg/serialcommand" - "github.com/roffe/txlogger/pkg/mdns" ) -var ErrNotConnected = errors.New("not connected") -var ErrNoData = errors.New("no data read") +const DefaultAddress = "192.168.4.1:1337" -func NewClient() *Client { - return &Client{} +var ( + ErrNotConnected = errors.New("not connected") + ErrNoData = errors.New("no data read") +) + +// ResolveAddress returns the host:port to dial for a txbridge connection. +// The host comes from a "tcp://host[:port]" port string; the port is always +// forced to 1337 (appended if missing, overwritten if wrong). Anything else +// falls back to DefaultAddress. +func ResolveAddress(port string) string { + addr, ok := strings.CutPrefix(port, "tcp://") + if !ok || addr == "" { + return DefaultAddress + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr // no port present + } + return net.JoinHostPort(host, "1337") +} + +func NewClient(address string) *Client { + return &Client{ + address: address, + } +} + +// SetAddress updates the address used by the next Connect call. +func (c *Client) SetAddress(address string) { + c.address = address } type Client struct { - conn net.Conn + conn net.Conn + address string } func (c *Client) Connect() error { @@ -30,28 +53,19 @@ func (c *Client) Connect() error { return nil // Already connected } dialer := net.Dialer{Timeout: 2 * time.Second} + //ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + //defer cancel() + //if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { + // log.Printf("failed to query mDNS: %v", err) + //} else { + // if src.IsValid() { + // address = fmt.Sprintf("%s:%d", src.String(), 1337) + // } else { + // log.Printf("No mDNS response, using address: %s", address) + // } + //} - address := "192.168.4.1:1337" - if value := os.Getenv("TXBRIDGE_ADDRESS"); value != "" { - address = value - } - if !strings.HasSuffix(address, ":1337") { - address += ":1337" // Ensure the port is always set - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { - log.Printf("failed to query mDNS: %v", err) - } else { - if src.IsValid() { - address = fmt.Sprintf("%s:%d", src.String(), 1337) - } else { - log.Printf("No mDNS response, using address: %s", address) - } - } - - conn, err := dialer.Dial("tcp", address) + conn, err := dialer.Dial("tcp", c.address) if err != nil { return err } diff --git a/pkg/txbridge/client_test.go b/pkg/txbridge/client_test.go new file mode 100644 index 00000000..f3969f81 --- /dev/null +++ b/pkg/txbridge/client_test.go @@ -0,0 +1,19 @@ +package txbridge + +import "testing" + +func TestResolveAddress(t *testing.T) { + cases := map[string]string{ + "": DefaultAddress, + "COM3": DefaultAddress, // serial port, not tcp + "tcp://": DefaultAddress, + "tcp://10.0.0.5": "10.0.0.5:1337", // port appended + "tcp://10.0.0.5:8080": "10.0.0.5:1337", // wrong port overwritten + "tcp://192.168.4.1:1337": "192.168.4.1:1337", + } + for in, want := range cases { + if got := ResolveAddress(in); got != want { + t.Errorf("ResolveAddress(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index a4d7dff6..bf0ebf4c 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -412,7 +412,7 @@ func defaultRange(name string) (min, max float64, ok bool) { return 0, 2200, true case "ActualIn.p_AirInlet", "In.p_AirInlet", "ActualIn.p_AirBefThrottle", "In.p_AirBefThrottle": return -1.0, 3.0, true - case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2": + case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2", "Lambda.External": return 0.5, 1.5, true case "IgnProt.fi_Offset": return -30, 10, true @@ -420,8 +420,6 @@ func defaultRange(name string) (min, max float64, ok bool) { return -25, 25, true case "ECMStat.p_Diff": return -1, 2, true - case "Lambda.External": - return 0.5, 1.5, true case "P_medel", "Max_tryck", "Regl_tryck": return -1, 3, true } diff --git a/pkg/widgets/settings/adapter.go b/pkg/widgets/settings/adapter.go index dbf4545f..4e738ddf 100644 --- a/pkg/widgets/settings/adapter.go +++ b/pkg/widgets/settings/adapter.go @@ -2,7 +2,6 @@ package settings import ( "errors" - "fmt" "strconv" "strings" @@ -26,7 +25,7 @@ func (sw *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) ( } port := prefPort.get() - if ad, found := sw.adapters[adapterName]; found && ad.RequiresSerialPort && port == "" { + if ad, found := sw.adapters[adapterName]; found && ad.RequiresSerialPort && port == "" && !ad.SerialPortOptional { return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok } @@ -47,7 +46,6 @@ func (sw *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) ( if adapterName == "txbridge wifi" { cfg.AdditionalConfig = map[string]string{ - "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), "minversion": ota.MinimumtxbridgeVersion, } } diff --git a/pkg/widgets/settings/controls.go b/pkg/widgets/settings/controls.go index 3be2f7a9..d467c88a 100644 --- a/pkg/widgets/settings/controls.go +++ b/pkg/widgets/settings/controls.go @@ -150,15 +150,17 @@ func (sw *Widget) newAdapterSelector() *widget.Select { }) } -func (sw *Widget) newPortSelector() *widget.Select { - return widget.NewSelect(sw.ListPorts(), func(s string) { +func (sw *Widget) newPortSelector() *widget.SelectEntry { + sel := widget.NewSelectEntry(sw.ListPorts()) + sel.OnChanged = func(s string) { prefPort.set(s) if itm, ok := portCache[s]; ok { sw.portDescription.SetText(itm.SerialNumber) } else { sw.portDescription.SetText("") } - }) + } + return sel } func (sw *Widget) newSpeedSelector() *widget.Select { @@ -167,7 +169,7 @@ func (sw *Widget) newSpeedSelector() *widget.Select { func (sw *Widget) newPortRefreshButton() *widget.Button { return widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - sw.portSelector.Options = sw.ListPorts() + sw.portSelector.SetOptions(sw.ListPorts()) sw.portSelector.Refresh() }) } @@ -202,7 +204,7 @@ func (sw *Widget) loadPreferences() { sw.colorBlindMode.SetSelected(prefColorBlindMode.get()) sw.adapterSelector.SetSelected(prefAdapter.get()) - sw.portSelector.SetSelected(prefPort.get()) + sw.portSelector.SetText(prefPort.get()) sw.speedSelector.SetSelected(prefSpeed.get()) sw.debugCheckbox.SetChecked(prefDebug.get()) diff --git a/pkg/widgets/settings/settings.go b/pkg/widgets/settings/settings.go index a6536ca8..c3572cd8 100644 --- a/pkg/widgets/settings/settings.go +++ b/pkg/widgets/settings/settings.go @@ -58,7 +58,7 @@ type Widget struct { debugCheckbox *widget.Check adapterSelector *widget.Select refreshBtn *widget.Button - portSelector *widget.Select + portSelector *widget.SelectEntry portDescription *widget.Label speedSelector *widget.Select adapters map[string]*gocan.AdapterInfo @@ -143,7 +143,8 @@ func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { tabs.Append(sw.loggingTab()) tabs.Append(sw.wblTab()) tabs.Append(sw.adScannerTab()) - tabs.Append(container.NewTabItemWithIcon("txbridge", theme.DownloadIcon(), txconfigurator.NewConfigurator())) + + tabs.Append(container.NewTabItemWithIcon("txbridge", theme.DownloadIcon(), txconfigurator.NewConfigurator(prefPort.get))) sw.loadPreferences() return widget.NewSimpleRenderer(tabs) diff --git a/pkg/widgets/txconfigurator/txconfigurator.go b/pkg/widgets/txconfigurator/txconfigurator.go index c5c07c24..09cf5e2c 100644 --- a/pkg/widgets/txconfigurator/txconfigurator.go +++ b/pkg/widgets/txconfigurator/txconfigurator.go @@ -1,10 +1,8 @@ package txconfigurator import ( - "context" "errors" "fmt" - "log" "time" "fyne.io/fyne/v2" @@ -13,7 +11,6 @@ import ( "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/mdns" "github.com/roffe/txlogger/pkg/ota" "github.com/roffe/txlogger/pkg/txbridge" ) @@ -23,7 +20,8 @@ var _ desktop.Mouseable = (*ConfiguratorWidget)(nil) type ConfiguratorWidget struct { widget.BaseWidget - client *txbridge.Client + client *txbridge.Client + getPort func() string apSSIDEntry *widget.Entry apPasswordEntry *widget.Entry @@ -40,9 +38,10 @@ type ConfiguratorWidget struct { container *fyne.Container } -func NewConfigurator() *ConfiguratorWidget { +func NewConfigurator(getPort func() string) *ConfiguratorWidget { t := &ConfiguratorWidget{ - client: txbridge.NewClient(), + client: txbridge.NewClient(""), + getPort: getPort, } t.apChannelSelect = widget.NewSelect([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"}, func(s string) { @@ -66,21 +65,9 @@ func NewConfigurator() *ConfiguratorWidget { t.updateButton.Enable() t.connectButton.Enable() }) - address := "tcp://192.168.4.1:1337" - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { - log.Printf("failed to query mDNS: %v", err) - } else { - if src.IsValid() { - address = fmt.Sprintf("tcp://%s:%d", src.String(), 1337) - } else { - log.Printf("No mDNS response, using address: %s", address) - } - } err := ota.UpdateOTA(ota.Config{ - Port: address, + Port: "tcp://" + txbridge.ResolveAddress(t.getPort()), Logfunc: func(a ...any) { t.statusLabel.SetText(fmt.Sprint(a...)) }, @@ -90,9 +77,8 @@ func NewConfigurator() *ConfiguratorWidget { }) }, }) - if err != nil { - //dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) + // dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) t.statusLabel.SetText("Status: " + err.Error()) return } @@ -167,6 +153,7 @@ func NewConfigurator() *ConfiguratorWidget { } func (t *ConfiguratorWidget) connect() { + t.client.SetAddress(txbridge.ResolveAddress(t.getPort())) err := t.client.Connect() if err != nil { dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) @@ -410,7 +397,6 @@ func (tr *ConfiguratorWidgetRenderer) MinSize() fyne.Size { } func (tr *ConfiguratorWidgetRenderer) Refresh() { - } func (tr *ConfiguratorWidgetRenderer) Objects() []fyne.CanvasObject { diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index f17e18e8..3e967555 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -71,6 +71,7 @@ func (mw *MainWindow) t7Menu() []MenuItem { {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, {Name: "Enable cloosed loop regulation", Data: "LambdaCal.ST_Enable"}, {Name: "Common for tuning", Data: "AdpFuelCal.T_AdaptLim|E85Cal.ST_Enable|FCutCal.ST_Enable|LambdaCal.ST_Enable|PurgeCal.ST_PurgeEnable|TorqueCal.M_BrakeLimit"}, + {Name: "Injection end angles", Data: "InjAnglCal.Map"}, }}, {Name: "Ignition", Children: []MenuItem{ {Name: "Ignition map", Data: "IgnNormCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, diff --git a/txconfigurator/main.go b/txconfigurator/main.go index 7c831cca..679e2f8c 100644 --- a/txconfigurator/main.go +++ b/txconfigurator/main.go @@ -15,7 +15,11 @@ func init() { func main() { myApp := app.New() myWindow := myApp.NewWindow("txbridge configurator") - cfg := txconfigurator.NewConfigurator() + + p := func() string { + return "192.168.4.1:1337" + } + cfg := txconfigurator.NewConfigurator(p) myWindow.SetContent(cfg) myWindow.Resize(fyne.NewSize(480, 200)) myWindow.ShowAndRun()

f0uE&tF*rI((+rzm0(RyF*29NC6-pxhdtJtbsK*ABUn|Ryf|CXUkF@!Ew*&5(?lz56>kVKeUHYC|HVXGCYEND_7f4}RGtuu- zZ%pKdtkKJK&=`X@KJulk=+cBk^`-qZ_4pqswo>QVKrw{zJVx;jyP+>cFlhSbap(ff+=;k^o3{gKp9i=^f z$fUBTQ_o(ZSkUTJl)`b1=v(u6)($x8f<5JYh2##OB|=b{Qns`N4*s*t<;Zql>vCM{ z;>;9@#}-xIS470=nyg6Ve{RFCdRSZ-Gm*=(R!79BF4vGjiRWsTWwk{#Fy}S6GOlr; ziTd>#u4lB3C%Vwa3teeCte6JNAD_i&>~$xB_dXLp@*CtGia&6SYaPwpOPN3E;$ERJ z7vBlwZ4qJA1Ukg$AO`U#PZ(V8B-9H740)eIIri-AlPi1h3W5HWqGY@A?r<)NvI3Ru z-*ayfH21q*-8!Sg&+)r1?rDl>Q#-o4B2R)b2zPXL)%-yLiC5G_OEcZ+j`siB4=?Mu z-J|GLcR6ahxW_02AIbx3k2k}j$vH?P7gcn@*}O)WZ`BAH)?i)-4`+Nj-I{QFD0kgF zPN4rKUY>MxnYK{Wm?%BXD=1Q#Apm`=1M#rlECkgH8yT%NXrLAoqi;oIi>F+-Eoc-; z;JBoS9RTZ~;l861j+eHW*o*eO>U{9U<9&P_On zt)HQ7i!W$f$j$MVxT7A-?HU&U$ujOWU5;nExPMYVax+Kat)`kC_q(&Y9BaF{%alfT ztNF&;=8u$?`~c%ImBkovyrj&?2T#k7F`rk$|AhwLQ_JTU55uCxtRpKRD~1IHq>lUz zj2oW2wJg8q9epC*hQMkB_cmb#2Vm|g4a&%UWc~0gH*U1@`CWH3!w%LxtIILJi#tJ; zz$ znq#{lq&ZV3vu8xh21q7@zvDPU=HZpaM|UuA!(9;>0BPS#UWkKJK&4th9pB{$>*DGu zcFGl7@U(NW#$E#-90O*26}@8g5znWPj;zR^ybfOA*I|fgs|sz=6B1(*6JkxVt8Ilo zHM^lk-gS)on4lDW-`Nda+hMWtgl16PCaBMQu;r^F1rMA6>eu>k-IQhUY9~SMasRC|jW#zqKXSwE8?_mF zgxN|J=c23_o|ig@9VRsI(@w|VJGqS%>s{}=b+JCfj*qWRRSbUC=|0)%_^gv#Ps!eC z(S}ib%8_%zzlJFKPH3T-+~$SgF##4n8t|YH*@!!S@!KGY3@IId=;T&XZ1Kg?$|P<#eQtwI&|$}?C)CEqyHx#0osJ`&+!9KH=$(T5C4Es} zf62J(I~`q}+#-s>X-Bc$STgpu(jagBI~Yz!or?~esobs0pZpTLxJt*aBQJ_o>#284 zRiWs##ML7&lNr=d754%GE80s)V&Ycf`f~6$o#=w!baK-viF?jl|KR@4;;)At0x~!p zo!nH49i7(`W-#+{Mw^iKy=UMi#)$C)!`LvvHiZt6Q}J>>WvdR&8W%+`|Fa@r**m)ejZ}rg1o?PHZ?zF!s!gyd!oE|RMgO(z7M_lb^R-@7Y4}{(`SdROv4=~cw?D!iQ|4s z1O?6x+=^Q#)lBu|?e@^CMqFJ{%wz-)z)mew`vk zR8+JZEKUQoG)ErroYx^iC_0@H$h|q*xXT^G(zfDH+&vwROC8)=isfD#O*`9>blot# ze`3-!gS)N6@mU9#MJZ`70#|Rpwqj}dJT!tU^_I9%G%P6xKkjfJ=y3FO#QJb)6tA}7 zzIW2VLUtl7YP%xIQAX2oL!^Y^{Z6Q=-l)Fm;kJxc`B4XramSbLeI1SuI=H!VdGHF( zsHR&vFjq zFX&;(K%UlaL=C>%;r?ZZ!`{I?Kq)b~>h&H4uz1g-KdWu?YtWp4_l8J2u#<`PdFU(L zW24xK9awRs9ZS{jO&yNkc5wGm{2QNKp8l+m%PKQxEU7KaZ+^2QBR|p2y+mmv4a+kR zWd6FjvI9KfIYXTWclSGFn*E5IPZ_|~4({oI)r4W`wELM3$EzLOP3rs-R-ve06edKO z$i6*Tb}_?%sTNtQ5HA_BbxQ6*FG}vL4#&<8?lP4rKE*|PV+Wt_pxF`X&ggKIbd=0P zf1Qeeuc;c4zIkg$&1Sk(_&hX}n>?C-1Lc=KbN{5nQPfdF@)BHdM^c7G*49PHh1=*H~f^ra|YVfl*G^shO<4fIn-(IN} zyvuqJu6_|5H1@?PxPK5#tPv?P<~Q5{9gd6+b(1GQ=FQ&dh*MH6H8rIp@@Y?YHDpX3 z3p=`*k@bY*4es1%*~AWZ{IGP$?bG4Vc5rXXG#th_0E)K8+r9nDih%&jK+oslbWz8P z3VtO{0@#aq+4tXwj;z%lyThSB}@+kr(N3Cx~(xR_URd5kh652J=s;7~P zMbV4q8`JOb(fVD@^@8Ny3aB+&&>Xf$ULA#ds2#_W>+Re!3ey=FXQ8cmt}JA3}h!Z$7tq zw5ELo;ZN-)WUU4_OuUR|gPIRELyv^-pTif#MV0`$H+0wFI@+6zX8c?FFXi+rgrXXs z`n6wkPmO~AC4v8_T^7g(0la4&vc;J_!Dq0i-6c+Tzu)e7znvRC3{1;6I)2yAO`x)E zN=H{a7eJq*_g&q}eJ?w44a%*dNFgXgsAH^SXA>@Yw8Ex#M^ih;P>3%1aK_;p+F!W& zLK)=qph#wG{2hD-+8P^dCdStIXINr}hcT1ZX7VHbFrCfhFPT&}6Paali0w9$+Gf(= z+rF{RPht$^+ojlv(y=6I-9*-Jmg%*)gv~b7>o(IHvrLu9HQGR`)7DNJ^xg=BBVKKn z?*_ehbYWHUEnP%eyZ555%s56Dd(dt?C^&xEUSHSz%l6ubqbtNYmqUdZ2j5=rY~~RY zT-rVncWUfp1>RhOO4}J+Fw1v>FpzfF+ukkhW&EBk?WJ%${YtxKr^O&xN)vy?2Ei}d zlq`S;klF{irli!~IV?7%_Cc)90k%Q^uDj`f|^=_g$s;i5H281DCi$f>+pH z+CB+&eC6@qKfT^N-m|N3^?e6E)ZAV+d-`lPXkPo91PtvJjLsm@{;U}DGA=sHRk#p+ zqTS_q+P${jv9_HnpuR4?5eGzWRVmA?;@iok^O-#>+pFmEV2l=$ky!M|_wJHDFnF7< zJm)(0ENB<}!kiZ23hp=S{MgBHRX)`M&cFQ|tkL(%;w-H$r(4)$RNCTBZg(tf=N40? z$CFQ}cEK0huisddW0Zf6X}!X4~9<DbO)8w(@VN&Aoyv3OAdkm`(Sa zO%I44c(WN?riig@Q>DfaT_&Ssnr1ddnoUt=Q?&S@H(QL$6g!sfL8$bd9x+BOo?OJ9+n!LU8ebx zX@S|a&}{mN+4P9m?9GntRT4Xl;)ND#Ko6<#>H=D_2T0E9*iPYHQ zGG$1nOtWdJ+4QK{^qAP<&9=;CT0WNTajEg3%akRVR+vpI&8BR#X_a`;n{Bnrv}P>Z z6H;TV%d}Q9J!v*QWi~x+Ha#P@db8!YOu1v(o|PI8xlCrs^qkq0XEx=VO$Fj1Z?;00 zX`MHl%d}oHJ&!Iveuljdz9jure7cE7nx8M9zF7WH^XVD#hlWol6No}AoN`O)OD*DN zDg9B4b2FcwDWyM#lvm29O21FqP%nmk)i!R)`KBA2Tp6Zo8H<~)Y=#EYwTui`1_6-# zkpD^fW2ux*`p%Nyqf$C;Mv~uSQu;IW5v!;1lD^E9K1QDO$L087S9%sckZOw!kct4?+1zW&fRm?FCR4A8THn8>=OR)vhfPa+E^O}U-#l!3wAV!B z>t83QEE_g0_onok9v?PQW9{22`ms~2=rygZ=%JSNnzC)CRY*C##HY!QMlWuv0F77L z(lcCgC!Nl4i--pT6`0H`QM--CA0d!-7oK>r9&&7(!M@3e{m0~EsMu_X!M?#5O|&}T z4=Wz>=(7|Y)5e<>M$s-uA`WkFne2nswUFTIR^z(8bVg)#eO89g3Eu}-V&V6CACrzT zobMqlI53b`wlXV~KzgB~Zif$dyY)q-n23L0H@@2Qzds&`)oMQ z{xz+vnOfG*96co^`hH{d18a8jsn6Nc3s5E8+v=?k%~)GInJKANgslwMAx3^z*i<>+ zm%DJda!AF#+2@0#KJ3>p;fv&BCt=CBvh(VUzr($B$&aAT1+jYZ1vncV7T0I~0a^AP zI}GQ{a}~9nWQYF*TXCLRWJ2dFVFm^t)S*dvDGQLrzK z3Z^}xc=8E^N@$1761x7dAY=OhpRjD7c(Vm6^GU^C36)^r2JTt?aNOj zr}WvA??VN<8dR0pW_)+QD9=vTGg0u3EJyh4bR2P&|<+{|bQ(Yx*!C{i||E{aL9#siCqViI9 zpIlMjXFz)M;m|`aaqHpGrZk-M|63`!47)%UJkDQ7P0HU!x2gF+U?Dmy?QxIdE#1Gj z@L#o}bb28MHOs1T2lD^U$A%;4syWE1?)Hpj(?mbW*u-fK>VJb*5Q=w%d!x!Fh9tda z_|M}ib8J+3X&d=FoTIPvngUjb$%d-9`)D3^!#3-*;#-{GVIicv4Mb^B?ol7^k3|x9 z>mW_YhzId2+w17trQmKJM9RJT!*|Kmt83A>{J)LrC&y|pfhN#@q3{bbh0}iF`7e2v zvR!hnzRey5I-%rO9&`dGa}-A0Z~jFL?-ctFxD4FGrR%@;Lh)FCl|1oZ^OcYFR>|`J zn(u(OZKfZh`2V6;*F7Bf{#SaP^TM3{{{!>(J(xe2$%`wof6c>xjVgA^Si9lP?mzcc zjE)_LBclEq1OaTh47Z&|0d`S)#FtDsTNRNuUOo=~3eI`L*-$4piME%Lb)I{VM{|9A z)^WT$;6hH|wmlo&3lf(vhG01uad!>`z)jX@?)gKO-%L`ZB;~B*P7f(Al$DC{s zd@PXDW4NcO#zSRuct)MM#?^PH(!St42vvdB2r`2x zzqdgIW`|E+Li*(l+y09cRlML+5yMvqQUYA5^JTMz2+MEd%Z$~tOI{@w>aY|bU~vizU!EJ2&zE{)ofV1uZ(+}GB-bP==_+K z%qfRBk>a2cFzFDaSGY=V9e8{n84Nz;4m_mBZ!Vz>RZ{6A=%^^No zA;kphGkm-qD`*g^na{9LrnVIZ+MQMLcA+X_wcWIJ;1mqO@G+Au^)GW8s)WT8cC*hR z=TKe=9?hE-_^SC8ecf$!4n@jecj#*u#eI)2_(Byl_7LaL1wVWs_s_+${Rg_JL&zHXnuAAcY?mzQ1i1BBYzU5Pf-b)b#3LF z@M`*mGvb0uQN9@#E8^KFF8C^j8}S0JaGYxG6&36gc~7q0x1M`krs3~fy~;xF)2-ZN z6myS8&>XG-kY7s3WxQ=Bq=%ocPM?t zSO{t|f+hCfSE7xj|a%cUhu+ zmUwdQDmc4SIeodk6u;Pa$;x@JUy;OOd$wt_7FP7TLW8}q?oJg9K(4<9URbyv5hsUe9V+x~%QA*Ew% z4*s&$C3U)Ot&U%|a=(y?PWFH3!|97r=AkdZNo(`Cr0&6=x4MPawCkRGaIR5$ROq_73WuXYTA~V>G^b}wUy=$d z;0sTRR**vAU%X{m*Z%tlpJ{a$wyOUK*<1DEHZ}-auXZ&rKG(bdq7*w>6kfKRlPm+g z6dTCpP$$UO8}#1CWwbiAYXb2`Dvv7JoI*w`bVFCQasvck9t&vfhu+Ep2Y=e?dgmSY zvR21WTDkcYnVZlms4piF@wmCIm{BzMTD;kNT<;thoYm@H)S5<$kP3TSR4miCRKfc0 zS>9szn+N_kLlHHRQ7=~5w1xV=!+3Y-#*l@UN>Pf9vVu>|$|4MK2{?fO+9DV}LYcKL z%625dVy(zpO~Q4Mih4c@Mv6PQ-%_6DPX z^0?6*F3F&=c5=5|$keUF+Eyw`g;AHG{U<}x0@A37CVeX>P+ZN+QN`b+O;*sG@yNO; zx_9K{ymt)39G`HftV9T(?0Q9?3}J2Tg2;P zb%j`U1$8=qgvzSx%l6rh&N(%HMpKojI6u{>o1rjH{Z!KU3w3@apLi5E{rm??PI;Ws z*h~xO5R)iPUKmy{MkF_Xe6YzVv0(;dvcY&|y3sJb$GB!8&|KEKb6_PKYIt{V`C6D< zI*Ix%{I!9taIwjC60xzkIs$A%KCHj!v=|rDWm8Rjl>X9H?nl%RXg}5+WJ5uFcjrNF zIz`FLu!(4({+)+&y98~V&;#2ZkdVxuHuHd!UYx>-A{c<&>j!BJ2943=QID&3@?gb5 z_x^*#%qSd*Er#n!hR9^6ZeI2Lu8puXh_b7#?Y!U91F-Ugd{S%xprBr?mNcYARgKNV z2?tB}R_iz3ghbLlPv74>`78dR7w&RCR8eyAK`f&xSndjSzj~0nL(ODk1+}d(oV!T@ zZ>kW8VR=m(?JhmYU8iW9c$AOu;kD6~AqAz0OZfh4utxT6%o6S&l3NLYdm z<-VXeK|>a3r(j!(38(1<-@=R8%&Ins(6ICQm?_qxky1|@xIn&tu1&mCscrF=2+zyC zg-c9KL+%q9cHupYxj`AQnw?BKjMy8C79DhI&XsY;C?4JMQ*;Mbh2;miBNT%x)9<5JCMq=T;7T6gwA$NYobLlg-s-TOSrvkorQ6{;>DyZjFIi<4|0at+=x{;t~A!5IhL ziw{Cm3rn(-qStBYQeTP+A^kRR<0@x=`FtS(DNdD8Us%0*drX-%o(qs`(0XCQ*}00d zdx5g9+7pAp2i&c<-3=s#Tq0|unm zUt~x*XjsLDz-nxEp-KqSn-mRHYIXJw->xI@Gomr2bJuBb!mj?>9V#_3XR9g$gmh~* z#Fc(W@GzmKT-oir`lnl7zp>Z^*%yf2Q9g**jmZOBuF)*RGzpH|y7#sQ81@SZdO_iw zh4R~GZ8HMPGVWc1hIXc4WyE84%{TNeBSVMWD9-BMwtHb6m?gtHcLeQT&+Vg<+ctX3 zK>+$*IiS*qqdv7a_k)%J$Q}!`HaZlmnbJ}%4EA1L?@YOTb|duOW?8lb3{*P#^0k7V zVDSyiOkY0A2j3+$;9*Ecm{qFzgZjEh&Om6YuabS5n(bcsWUhh8#MQUdX}vfjjjN-$ zsI_6=uD?$@cDHzll}gL}*^MPW3RcQr?-Nch6x#RH2C{r< z`@z8y)V|&Qvld$X2e%=x!@ad7?VN{ADWR;OsZW+$%lhMbiyk~pGJ@M%miq37RKKEs zveWNqO#hm?kUG=RLT6(C;=#d7aPXxT_y4pw{-=eTN9A*J zu9v?v_yQ{X*uAbL?WQNJ({?lj7Uc3DZr}=A9EB}nTTewbd`ARYN|P%plksL#8sJ6Pe?W-X*hZ?E6 zvAQ4+4Tp+jl?9=)!;JbzivL~TUyvuX?V>%V^36G}m;ifW<(o5HF@g52m2XZfzS#!p zF1r&(=Bk|bkhX-XHv3RVRn?Fk6<1xf-@x~cuxjAVPR~zl7xLI(cr4qxlaCCD()xsL z;Uh!k|I3Xn+kA!RqO=op-VvA8H_a&TR!PTH+#i}i?XY}aw4{6(6DpLxc9xsmg8M*S zLDl`0!O?@kEiQ+zTia4%|M%u%C$7_mRavP|z^QsiI>th-;_}n@XARb_b%o|Yk7xq-W999opaD4~8j%`J6F*DUd(Pewl7p_7L z_n;TiE#bs;l;JuTnZpW>~%{o6WVl;!b{}I3EaCp<4T%f;VG}T7aT2 zzjRs2y6SgH#~`e~%|7F}+uc-G%Z64ppK)0C zaPJeKg*su+sR*6D`S$kQpHbCWQ3~#Nl)^i{M%^&tf%a{4T~xb^U0gdQVAF}9|LQ+` z>h`B$9dahrDeES%y1LZ5y-w$v$Lsbd>ZA{*0f!RNa6wgl!hvg8p-GDI%U-YIac~V4 z-;0AyD}91C_i5D0P@N9BPRVeu&L>^N5Y~Ri#qFb%@@E^AoRYq>KmD%ceUDIVAWFSQaM+tKm*vhSqO4YX<9R9a9)5F(Ci7g}CMp1iIzHBQ z;ZWfEcjM}t*zwNlt)cz84Ff#`&QBL;PY(2KWA4E4`^DUz-@m*y)LDHrH~Z3^b6S^_ zG#KM@$G9Bh(XovHBs8w?jO%QwK4Fz^+G4eQ?=)*8gpQtXV>%iHuB#uKQMhO7G{E{@ zT~LGSOiGpc=*AO%?MB|L%rg5f=DA?n$^;|J*djEJ`&Uij_-Pj=bVEPKA z3)Up`R15YZtDoBG$(bvPvzvsydhKae$!hv4Wd8B;aM8ET=pUM6i3Ed}%I=NFyUiGn zSDU#xlrXjk8)58!>t6j4rszO3=Ij+J z!tb9dqtQud)HTa%-9IQ{Y~59`3*iuy0aaS5s&i+H-lD>P^%kBc7d}9R-)-)r3%zZ* z5W0ov?KX8$bu$plCxqZ@K`mSARB$x}Wu^{-LR=0dU3L{&(QC9&4R~Qr_jVesrLQ;B zTKYya*GEZeOaRCRF;=CZhI4M53S~Zxvo)hb1+C@RlfcJE#e^LQ8T@s#OI+dpb+hBw z&D=~X%LWA)1HtSVr559 zQ7=%`k`7oBPugOD35pG!27doVi@+~?mtWQ?S*j&VCvU0eAMCp_;KPoq?&Jc9Orv(; zql`Y69=pB`>$vt_zuF#ZuYuq@w4H$*ZWl8BBt#ftAvikhyCn*Tsl=;M;8sJY?c zeo9GvjKX4hElk++NOQ$>9vcT#M8G&Z5}LWMiN>f%U#*G?eUw{6!REQmI7i|@O`N5B zPBXWXV$=K-jv38HQRIe4%iy}oC|-%0u6dHdax2E{<)WJ{MjqZ~O%`Ks8lK!@?(y34jG^2i9$R3bBM+4AcFnVoBqcqkSQO~cH$zoB zsS4;Ip~?H|k31w>A{;&{IiCGpT#d`miO!6%q*)M01h z;$F*{9f&-9Y=FblgiQiwPbj#KtOF2hO?-}%InEW|j%!l&o+Fbh_A=PM!2eMb96eP5 zVA+aq%Q11y$aKMLkOQVS#}Bi`$vwhUkLJQ0}U zOZc|MfPw+V+v&5A8taXC;nx2ZaW7~W9IhrRau!ilM0UG?AbaeGK|^uJSkMbkYWxEt zyfw)A!*xICI9p7Bqnd!qdFa<}cVtpm%Kb>7yq)gNBG>BeGuc1L1CK#c4SihCi{ka4 zQ48CwmYp9S{@xdyifFlh;3A@&RTj&07Qv_amrYIeWaPD&nrN*GkO!AgCCcN$T7}3d z)cHbHnPB0Pm@vuCQt!VBSEdwyt4Z- zTYtcYDiNWJWN72>WJvNND}F`P9u3f!pRIo)vV~E{dNcadm;aTo43z8*$QfBH{z1Fi zOE~Jeq+50{vJiP56|);^`|Mb5s&@M!Uru zMTwuzn#yoWT99}cTxpvUPOTBrAvoim(coF>-d9M#kKB=o>3z3w+yKpsFIoR1&9$Sr zXs;xD*VnDGQpG&(@3cnC)!hHDY5*5?$T`|@it(O#aF0nkJk;kCvJ*lJr}E12F6B6z z@;+;Pdqh>>Mk8D(26BHSfW6`odth6H5MSFBie4?4&!jyK=@7tmrsw1)_jpaB&}VOB zkAHqn&I(E@@E3YuNu%D-+xQ5=bp;j+?6(H`cQ!T6k<;ViRLDRBTZgc*VSp_2F8OlIlq-csOMY+K>ol31=`Y*{ z?a~|+K;HaGdH`Uz{d;)>xF*($YQ53LH2%GSOGw-p`-h!j)6Ua?=ugb)3UCJPrk=PM zi=P`e@w4^s`;KQah93oN`HCr7p|nU-_~;@C3G8H_9N zqX-qe;43*O+B!vJ&UDGkfs>G_qbcQ##}I%4q_MC-kyc9f=82%Mh*2Xw6cFe~&LAXi zF|UKei4a8eAMJT(qW|^{ukK}o#b4~*zU6`t=sI?11MoO@SB^Sa8`yJU5fE$oK566c zzrgK%Yx0K1z2dt8J!ptv2)~5JSr@K)K_5P383>3f*AD-FB>UG2{SylA=q zPiU_+r{g~#J7vL{()I=1QA*<*JWwc9LoXqx%)su--uRE=Hjp&jKll<;Qtx?T2!YH8 zaT-x%>OhI&?WhaZqz(^%1W;r9h`>OV5;ep`U-W}X8X*WBZ4DYZO%ik7Ja`X#2B&X@OVuQQiW@eq4q}al*nlP%B~`T3FE0zc zY1TahT(~TPE!vZ%RG*Wjfsik>r7}{gl20XqKQVj22L6oIM#0=k%wyc#1BmomtB zwOEEzt&b~8rfz(kK)-QADKQMHv@t%Y^~lC&+bfRa{`4*=EaQY9dx*?8;ikGgkxfjyMibW_!iEx;BJ(Jb?T?N!cJ_O_ z8uADULeQ4Ni7cGqwVCt2_1GGJ!5e+BB%&S}_UO4H^}XWnU4QD47$ruqp`O-STg*e? zBhmWe?j!I+YKr#k7b7US1N#6M8>F;?#dh<&R&Lh*7 zdGo|P)I9C~O7)R(Av}Rg^OS!}MWoDoaf;@U_rl?4isfD{uyqSsj)?a_zAt zTk^BNCZ7^-$ zoR9^|IriR;>htGkwW)Xl`6Z2)mv`TlahY2Dz0Sb?Y7m?wtI2N7zV9rlzqtkG1ZuM4)qR={Y}O6mU(`txdWgE0Pp ziJNg?)Rr@iH_k#>!TXaGYwc9LhcLC#AJb#*@=2&-xegK+zx?6@POo=$n{ec zm)&r@$khwFB*rgJhGpQ-Jmt6kn$@u2z~J(R=Bx(p6{>WU5U7V+++YtXtzw-ikr%;! z^Jdi#*P2{~kafHnj^RFlw=pH}VED(uXsf+j1NR%MP!cb*nf3dd7c_9y6uw7e4i3az zA3!xLlzWX5jxUD~xDP^Ld#-|lW_JLadAtQTY~>W(=$=T)z?%~&EX0=&k*8l@tE%Sb zbcV&dp}Jh4C|p&gLCko9uLT>HfQKlA-wp3cFcqZK27M_{Hr_xyDnftw4D$hK?r}{2P;8WGMR9Xp~ zr+Ui367*O~0D_i{dlA3B&ZqkQFrs7=pGrLCSPt-6e5(I~LOzwXrH&g;z3)`&_>|Ou ztW;Woc_u+qn1c=^WoX#p_iN`Qxthe_i%eevQABX2h}NC# zI$OGePxsOR`arZN^UOFt-Ajl2_;g||cxF7GE{oWw=x_MHJCA1q_;fnD@k}6}PK4o^ zAYL}m!7~&1bRtCYO<`k&5Ep(l2S&D1Q6s30&H|}zsW8AJYdWO*hC4x9Hil2tu&Y5) zzVVzbRW(Yb!V@?qPiuls&@&Uefn;A`GhBzz!*~aAhfoQ72m9|Cs%E^iCbB1 z)mfY9vFfbztr^xbK4Y3~Z8*PvJik7OUq6A*SOx$xPTsw{fzi8QG;ujPPyqWrL z$`x*a5K0?;Cr5GvlMnHrXhjsf0Y)oMFwJ=fr2QB3PJYU&vw96%`OBrOP=FlZ8TfMN zd2Kg*oLROSvbU*RHO|2&4!EB<;CSKy_W^;mMS~Gn^8FrbqAB>C6E-z`^Hx9V)NqZI ziV4?1{~=j$$rFZS?4}Z+@fPd!>(|_ve{sPVk4$S|&-?XH&fa&|9=ydq#b(xWj?t38 zrT^a6?eOa!m-daPDh!HqSXXe{;*zE{xnAy-ESdE+(@u@8W^@0lNXhllAN1iR_z<&A zR9UPqTO(Y1R3qxa9lC)sXJAe6DH$Z}eEIPqD(i9~&>4am6yjT&7$XD<+S_({(P@6* z0Lcv!3VNNdGca=EDKus~g>etN+pny{<*x{x0t(WX)6)4I_q+Ua}Z_Q60#VTo{yyf{=Ab(0rPn{rcG>o!6=+tqH{r0up>n5YeRT^dE92xF54M22|T;iB~LD zQ|HgC-nsa%I5ezS@XRn~fBb@(Fmc_&l*B2Z_n|J7bxeMu$cJoXlC1UdG-SkX{JVQYyX+4T9E@dcgB(XrQF@d9c2IHgtDv zN(Rb03dMsh!#-CMiG@#YZB_ip zcC=S+sy&_>dxed$p~c?j{_2O#?Hp_F`d(^o*}i+tRZpenhH1Uc-F2_I2FP-5{b75< zhk)V8-7|ehJW(r{lHYn#m}w~=-cCx&DcOf6u+z%F@y<5QqckC2AV}q}&@5d?pZU|Y zyofzVp5d9$mhYX&ZJ>mxMU48hyJ=yb6MkvfOR6jBZ2XqIc#Iy8KSomD>z@CeQnJUR z==OtE-RjETdxd)CJB5-79&jzbeUZc>f^UIr7$n0E49kLHm$#xH6JZQRk+*0la`BMm zdXi)gGDC11{PzllSh7>E^>JLNujx+3!w<%ed@^6XvG@k~r=Z0~E|n1Xug39kx6bit zeZJo(4JtYvFQoWlc*(^qZ3}2R)`MNU;&u`)J5YJ~3zqsGaoyhXR~!TN^bD)ONk68m zs?gP-CZ`ka%ek3U{$g)k@`d*d!+;k{;kR@{$rt0>-~+KYLi6dotS$2&gREmp4Di}u z?+JU}f7Ovh->?Gz)jPB~TY4biyrqrF>U)+wF7?K6c?1D#o}T{Kxn9&^C7eLtY)D-% zF9Rv@Rph)KcVSUnQoUhS+5WJqlT|$s$gZ4kZ(~5Gb>_T)p_AP5IGpH;w`|=@*gsd8TH4DtKxbcV_bbl(|E9pp45Tx^d6eH|Nw_183aW7U30a zy!u4F`^kD7VSY;KYNbNK!>gH-q@g3{UzA5re$00+_e4bGt-k1T8z^PvMX;zN=QH&g znqDyg^8sdi4p5_Db<=G?T5R)1_YAI;qRQ9P4ix}UlsJ*H?xlEqV-tJjBA4OBk(DXb zMxM{aqIO^SQaBt@=RLLx$EdU4areu3&#iBs1-vu2#zv?I!Lv=K*yfq_qtcl_uIDDn z_7|Qnv3Jhe?=yCzE<{*&k_?jQB(J zM?e2AZ(Ire7_c$Z@4{|->ENiXf$Sik!EftaV!!*_I>)zl++#8g9Tv&BrpaZ=dp{Gt zBe<(|yg7%vg5PZUcQyAlzE|-z=lHB%I;#zB=d+GbR}gr$DX& z@fdSqi$*$@zOnvpMl=lkAb*2u<0cL}6M z8+br}`=p6CUbfk1BX9hwF*NGCI5R?o_!!xnS|!C=Vsx+W-Tw%zi)NDuDR z1{Iop02JD-v%SHb*FhVA9#!IOdl^{UkndRig*J?Er4*&$v#FD7PAr8G+m<+&Udc~T z9!~Jm+^M!yTo>uh(CN3KKA_G|KGsklCW)Ko1wCK$&Qeqk6P;B5HBb54V()+fw$zxb zp6p|HF$ruo)LT&>!0%-$>f^$M4FcVb!^uV#W#6r-s0=u-k$_x1uJ1)`CzOcRx52Rs zFW>vt^eu&h9NJG%clO^DsBh#Vmc`M71oFw{l*adK7J2kdi;@shk}~1x84$f}Z}IjF$*@zk$U#5oHhE4HKFY zUWR%7C6Q^=UAMkRcj=$^iR-ye{gwA`{gr;NzwY@Ndy$zJ6agyxZw20$hxTQ%gu0W# zEsYmv?pe77URuJy_y)^$c(lZ&lWF4-PxhyG`3VgC$9Ow#OP2wnt6)Lko1>V?uD`vZ z=Xx8B8*C)01SaiE7&e7i*#?oYWBo1lBDaJR`mm$O^n}^=Fc=_%eRSRB`Mtq#he5?P zTr%Z~+mjRbNW7qd@xlj+O&g-%S$fq{dQV39}Eexz|xkfn{Jp&@+DNKWg;$CEp0oK_+<1{Str~9?_i3VfwQh)c&85| zQlgV+SpD6q7uR`19xN=Q zBVADDEW3VTW#C!?I!APy>7SoQ!+}_VnpRKt`a4@8ErtVureB#?y>y{&ZK;0t_qHHq z*ejKD$9HRJg0y(DZ`~3YONj^{9op)>RoZ!wnbe7Ko0bSKYDn)3eYJ5S1SPU7)mSF5 zTsC6zWuNuK!c#C|%8|pN7}&VyYExo(V#t+sV!f%iaIe>3-1W)}(UGkD zE@nj2b<4YAx9*f)x#4_IWb;ye_4nT1C}wEQ0Pf3PH}De~>{D42m}SDCC2wklz@J_X zRUca1o>}^oE=m{P03%G@%Dk&++2yrt$g;wnW_gNWRrkfnh2E-4wo#p31~K^@Z<>q) zNSO2?Z6h#okBM7-H%l%@t2*jNDw2?aos#z*V9P~*7+-uxzV4NGzqz@Jq&+!M2u^w| z-i8B+TUD{hu**%J#ZsPRY){IAg{`@D56=HremHFyt2e?2YZFd9u|-gAj$gxJwJ0{} zbt*P!&);fUHB_G2kZxaRe2RO~EO(|YH^kDSs4?DPSRq1-oxG+F_SfzT@vac%gPnEC2BmQYW0cW>f)rGX zlo*^a+^dXi@(4;;{0_Y-xmS#4ulW^B9o}eAMIQEq-^9eN%$Uaa|J7p2>DJ<^I62ad zN@oQt2evn;+L9xCJmvK-D%GFR`^ZAIH5rbulo;|GruH=Pf!$i`9vCJl)?&z~z?xkI zV=;;@_ab1J^_;@ILT*IL($g7g|DA1nB=;g~u79c35&6EyzZBELx(C)ZN=0ikHfBC~ z!`7bpTcD{3p$~^Wx3Y~%3qJ%!r)z;cY%5aQ;K~J=H?Lw)jRHbEFemx6)Jv z_W{k-;u}q6JD~;dC*($js55?=^5G4;3I<8-@WlaBq@i}G+=mHxZR6o(0DU4r|In(b zdBc<4@fW)TpvxEAo2%_kziy>nS?A~60|#DsRkBX*hg(AJZZJF7UG67g@^|La&qKTW zHJ~p7FHp+%YeGbW@?=b)A^J);lUL)(E<1~USVPL>Ds+Ok2WpOi6021pME3fxS$`Tn zPS5E}3;@wI$8ebbDO>_5^BgFDnu>zC`siMn>6g72Z#;5a$OY=TX_a8+B2<_Y4VpDq z&C4Uu2_p^<%u~lp5x(djez}20?=LV$2f?o_uL0m=BW#F$xwpsq_R4!gu3{Oj{mY*5 zuKg7){<@ z4;!Q}yWK~&hk>iNDd&B^ROjinvRti$UKv)$S$A5o7Qn%aY6rvT`i;EmDeiqk$B;4f zn(^pjJ<#^e^I4i7%IY5)*guhcCX@|wswp1KG*_vK>kcLsG?XaxdV*r6)E!i!sq(r&7{Zq zHJVx8WqgJwhTgO=pm0e3JhLtW!*V1On8qXglBYq_7C-W$2jh}y@Eu9R1u#B+8<;%b zrOv?fFc{7H!)qN)_nSh5J-(vtSv&{idtefq##!7L?-$2=dBu^3Wk&bh`tpB*Tl~_O zCJFe($j#vAWGsrAG*Vie>jrIEKkFa~-b?O~jqbQA&5Mk+$hybcY`wZTa7{#2ZWUMM zu;t!2QbzT6Xpk-MZ01usmpepq8}gEA>0b-O*9eT{dRW!G*Ki{2n~1S6gTX-yM8Gle zR0Pu-;gk0bfhdEM)0)RTb@6}{`IraS!ySg=qtdYlz**x2VAqsY72i;VCMg7m)6aWW zvikWOFWZV^P{N955UkXOJo=3p(c?F+hJF(w_4jD30CFJk+04@&-;oSJ%^Fy&Wngw^ z*qv}*-Rm}VYk*hec@5C1hIwIRjvUGy$Ov0h@(1j4R3e+E^4Ys`E8`+hXRdP1)UU@B zi|b~o-Wyc((ziMAx!?v`ae0TRX=lG-!w?wB@|>&pmaI9InZ5S99E+inee7o63D7;v zs3ws+nFrcvz}(-7MXy1X_n7CM(ED8zK_HkEE@sY!o(fjpQGe+Rk8UdJzh}ny@mw;H z1)rcNRB*54vxBh)Rz=|6a-;!^;N|V;Zl7+SU5?0h1kaVRVY?hPi#^50fwSE{)b1!m z7i%H^9^f#HFrb|_k0IfgF+Pi6%X`2**w*+wCkka>Qk_?NVdu5TVI=_{Nd@rR(=lXy z8hj$Z(8ye3I0b47WDKYfiJjG~LvWyamzySAUM>Q_pyt+H!r<0jF9ukL(E+NOpWvil z-1|=GM689Xnx;&wg{S~iKk^F7;VF}No5svyV}9t>T}7<6E|`+hJZ!9QQLV?QA0y`j zwKqb2&aKDc1P92x2aqJU9cll(i*uuQzn~<7TS8*6v=?BdTu!gKgf@;n{BjKfkWDgL zBSz+U4s=kv^YAg9eGu)MjiURu-Ur`Fu} ze+Ya3xTve_e|&y3z{>)}5dmw4dI6HX7t~E;Z5Lca9BfxiN2S^~P=m^{(%j<(% zgDoj|;|E1r<&G3sKUgbBgaO^fO=aqG!PaW@~w=EM!040oRC6oDfJ5&5NC5;~!-bMwP@%C~o~eKV$2+Mz)G*7~r9 zy82*IVtP$YEgVVcf@Lw>!qf|`(5c&gs-i)$cK+S;BGf^DzRGv~Ib*oG6(kzyAbcmk z=VU_DUNt6DyB%NqwwwGrbij`GT4iYQ)Y>;wg>2ocjq#MNRsOg%w}sjWZK>FE@01u( z6lmubJePtAoMGX_`n@zjAY6WBeu?&=)9c4o`X7n)r`Ig4m@i*gAE6o(2{<+%V4ms3#KP#m4=W#3ih|S6 zIcrb%s6{d9qJK4>RN_)_I5hkCMJBrkZCksnN0}I|BOu#tm9x87?35%gwK9@ryGSnr zgw(HOVw3heqz8|MoyD=4X?Tf=AX7w%McEyfJwKZ(|3-K}>4Vdq;n~pgScC<~IMfJ; z@7v2LQ$MhX&#epJQlB`4rxoh4GtAwVySF_kE0{g7!uo15yK}{5xV6DE5fXhLDYQb^ zm@mbA=E8H=4a?WbR{qLlm$t0IR@!A)W9rv9#QcF3tym8oVp>x@wiVg)xfJw)jK(o$ zaQ1;C|Ip;%2%Y35*5l#&tCNG#V1-bwW!L$7iiLQ-8=FRun8L$}-P)x6^J{->B+TivDp~pjd zaJJXCK18*@bHgtYDXALvnMt zSH4lqpTE7HG>S}`$adMrl5-ov@k&xgF}EnEIMtkw4PgqGQ=M46!<@2m?Gxu73h$NK zG?B(%5^7d<60%T`3Dw$->nMsVsfY;06B~FI(U>Vje;@fzSkRT^AY?bOatZ-ydu2v~uxB}ZM ze6SrpM}Dt@`W8ib(FahMaRz;(0>T~Ngck&wjZ_D+v4tzRoccqmuHbehyxVJ}_6&CO z3hW7k93}WX=?j1)n*s?Egna&g1&ACe#z`wd3V<9r0O(gM2pmTS&UyUfD+sWD>FLul zM+yI~4>p7Xgr4I*t~P9`7_U_+(>Zm0ts~zK^cgQvO$4qz$9Pfr4(c~La_lLdM_1Gz zim@pEEbKpx`w#4wBu4ej6DY{8+#CIE6^H9Nnp3qe9%z-gUObREOBVt1;vx*k9&#SH zIzkXRx9G-dp>*D6^-8hyr(saaZ+P?ySv8_O_U?zCC& z<k(9vN2nSXdy!xoaiKReXs2MdIj#Pv1YtS^>Ndw z%qD*sdwov}BUwc2JGCP^7b0rDp*uDiXEnt2y)0}9P|tqUL;Ee%b)8+s1*`gHrbMy9 zKI3|l(qLa;Z3$o2@RJW7J76C>IcxnKL&HO-E!N@S_!(-$z6I%)@VbvrpB_s;{ZU5s zMZ?Z}lcBZK50Bo4{ky80x!(h#07K#aq@c-%c~582o~=xXZQvE!?Qqzp!zjX_UTPcI zXHlr?CQ&aFjhfS2!ka7!NV5&>PuSg8`;!Cc8;}74fCyo^f{St_*l(Z->~%yUn-vmV z6j0d`?##QcrWs576wl$1a$JVl_`h*<=R~#_Px5w>*`m}>yoZB(Nq{Cc=yD9fvt#u^T;ok;42h@Okzq-f%HIl(+#y>G}{aExU8stLYP8@(`EWU z3R!4g(Mnkxf?qu^!>S<6&$dR6+B*e=_;UMrEVvqF+JxPNji$$&Xvl zuZ7~kHLe5R+x~9ufw~Z}!4hr?5g9CMQ;0+)=y8{ZNDW)W1{}YFjH*|_B6liUTCf8% zPNzVw2_{0eEYYg>CN^!rQx;;uD>?Zaw`GwCb=jUKcoip)M8*ur`0>6c?w$=V;1;C< z*OhgWJ8^4nT0*7*Q-=|(Klxw0yvh|D8+xjdp_NfEN;S**4Khwrow_w@)25}TupzJd z6kC>Q>>iwP2crv*B;nN&k|%%dBXI_Q&4)*pU5x%ox@fPbJuRo8w}Sg$2GjyiQQWw_ zPXzu-1NT9%n>b0$C@FgF8x|D32Djq=b~axjw8I6#KbJ|VxV|p*dg3&p9-4r2m+!6r z3OfU02f4E92HOUh(D9GSaGdln*kat76eJ`<}N~V0%loSep#S7pWSqBAqjsHYfvF zZ-Yj%5+?e|c(eqlis4`uVeK?n-!wo6*VZkD0oLi`$!)^vb8XsZ-)WtENqV*e0-g5e z%B9YFP44^{3zq_4jaYK&N=dV@QLwRqjRBeE@tmt)CUd*y;$WfB5BW@;VZ#+nh}*T;oU-hv z^mv;f*k*=g&+Lr6@DoWUD>~NcX2On*#~gS6k~+r3xFN&;>#k?2;5&G3cmMeiQA|(Y zG|{=C11NBcI~R^35V)?%aImO zTWDCzU`)&TJ4EMz=Z6W_WoaJ8Al|U*Y-vBwKv|hfU)&>Kl$$ai%4d z>dyfFqFN>8;tHeUPL)EqIe_PNpJGpVZSKwN$Jp~3TAKKN{MWZMd0bvty9i~emRo8) z#2YUwisG;at;(0gt3ri2bSXzcp{`NA?);;@U=gQDeYW9w81``{o*k(SQBaJc%mms zmrr!?EhmcH@Tw|NKlHsmh0+US*;ewLWMh7T)lhKe;G&$p(KG7%RKa264^CA+3#_1$qL(=tt3$AI` zn?EpVa?PKbG>@9UGCr{%Ltn2nHO88{)X|(xvu;}>Vl2wixUgrpZ2E1+qC?AF>x~<0 zJ$Fe>R>OlKrv8JfT;&qn@3YMlvYT0hFnVGzQWYW;68;g;RCbA=zV3hak#eM*yVB@ zGK)8c^Pi#$-0Yf-{0u2Bdp*0noUyJkw(g`lnhaU(!L`XkSAzFO%Z>B)WA;ql*JD2* z7Ta7|>r3>n5N`C#XplXb_)X>MH=WJQhVquxz*x|`z0vDIJIm!v46&*{?~6&2RUj8) zldH1N=y^%DcaQEhDOY|_YwxcEDQksrn2;#&U0R_BmmQp z3~D!^Es5vvgqhq49N%yooXmvi(xGo6tI=T`KJ;-)pF8iQrdM$^yj`|P z>^URcw$7N zq#ctiW+=_WX1_QtD;C}wEH0k+;h_zw;f}8$O(Ma^pQ&c9WTSE?=qKHr!~Y*fB}@N* zUKl=D-~J9Ay}S<{s|Ov08m(03!H{y?EKgAr?Jen|Qwk31-~4f(4$&Dv1G)|8TxPNU z5?Cdw0B>?(hX-0)-&ncEbGJZmcLOVlSaugr1HcN;lUQ-*;z{@=uNW!~$cYP7wvcGX zqxUA32$h)T!qUCc0&6nR=AHo>c$DOy#wFqehF1tY1-^1~oA?^Ik}J8-T|`HAY*J4X za?8f+6>DeNp6buz!plDwY?r3=bqH?ePX3lDrf^p9luoTht&-{p&o=4@?k1DJX_ctw zA6(cFkZD#Wc_JX7A@LBMBX6g}S1Hogdyyu{K=MVn!3xW+nh$;@<@bBD+nx*Qr5lxY z@b8{+CU#Tz4(GdJ&=!+=9!C^%>%3?)Ulf-wHl0x3FRO~n%UI0>nKf(o#@fU4Iy0~= zkNbMca1{i@P5$>NBw$LE-syBsq&wfsXmgLYN>8SE)_I{L+m73nC`jLI8i{AER~O=q zsW5EXtbl$q;BLoh1q;nv!U{APlD!3bpJ(c^`Jol3&fAkF)a=)xr8WDIS9J{S1@4!o z%Ig;>218775j~^XZvSHSl~+|Jr}YW-y|`6OwQcvB;_ftXh9-x~Mq-|Wnb1B4PW07Y z)qoc!pCZ&C_{PnS(mM4Dg<-#b{oXLB!r>If-T-ahucTcMQ-$?yAFVErV|8hRE(bP6>}4(14mlJ7 z9a5~}ZKuN6wX~=-DAPB6b9zg7_O2>+lgH2>>~@d#^@c%atGJkg2QfUK6V0P%arK&P z%W%qtx_`EHjvjj}`%G^rxa((BN3JaNUbW$y($1^Nsn)XJsjkVYI)}i1qhR-;fykSd zqxPVFtWBu7OCu~jgCP!SpDU{BcyA~=N9)<^T7YT71NZlN2~z%jU#pz0m2#T$lRmme zk78GNNkI;!@C-d+PvH^n{);Dss;D9lX*TI{Z+@q4et@Eq#@keKyHuYpz&mD_R<$5P z|1$~}kJ@ovi!%uOOA40Vn0I2Lv~yH-{(5K}44V9B)c{?W3nQ)T7L|4iyKVM!?HOTn zKKtq|n$~afLfP^=G+pYJK{1BN>EGHjxuQ6$NFgJ=b3cuN0h2X2rs$OXC5-%gBxD)sVZ+~ z3jUGuMYY5GpOaMS{d0($w@9P2Pj|l?HpH}(fr%T6R1x#u3ma1Sf%J?3$nd!LqJ#(u zR-w0jx{$CTQkhgB^bYdh3Gfx5gF6+v;yY+I=)ZuXz_hC8sUBr7sk;Q3Q#>eG#?Uu^ z*{2%_#g)2DsNy&@RN)d9dy7jxU2X`qB}MIMi$ChqX@luY4=`GkLvmr`6vR4Ee-I!N zrXs1hjwq%=`ZmC)Kn)6^@Gt+qg;cN~!8-83*5Xh&9#p;zE9 zI0owbe3>l*-*%Pl=NL`d{=ukrN#is7>qB}zR>J`t6u(VR`AW>8KniP(t?>&d`+lZe-ruh_(qg1f-Y)}uqF!YXD=*o(7tM()D#yU9~E=T z#tu)Q9sUA-W8o$e?RY+)$hPsHPnhCVrB;z{9W+?;0I>dHDW+*fjn#gTz3GF;<+z!) zI90{MFL0_{~%^Lk(Ac5*`j4(!D!q4{h90R+Qve$inEmc2t0XMnQi9j_O+N})KC`MwW&MF5^0HL$2_;M%GV;sU}( zlto3|Uzs|H3&;f(TaAY_Bd?-$|N8fPXo7y(PDh1H6T0Xx30fA@tLzFU8&+h{^LFIF zjPm~Fa|`lc>emEjFhT=gfbxa9*k`~HMo`Yb66?2NLXUJo->jYKPtkA1gu2)(@7J`Q zP}02kt*_il^Fmsu*|;;vV1S_mUBuLBij7}bEJn~Oo$*$fx{{;0jX4F-_oVw%i*kL2 z{u%8m9OC4j-)T+m1^!V3(&DTJ#6VgRkM}yBqHT!Gvsr5xCzmJ~KZQAk{TFzwt}L`y z*4DWI<<%e#U1dmDiqL--CAI{cG)1di&%|`0yxiDt!EPEJ$%Ad0qP;M_{S}Nw8JP{* z#`&Rj`5|rj!TfKuqR4bnOJmGZ?vNV_4=w6CO_8Iv;kZo@rrAsC8=DsN#8_fD!P)eA zxp@1i_2p!PIoV*4x#c3m={)BU%!k6^QP5DWKHVI48iKfo#MZy4V~(31BF+%A20$Hg zoG759tNx9NRX~Q^^Op%-8`CP+xBHlEtjjBXXxHR=rh%hTI}fApoUROy4(k_KE|;$w z4J&Xh06`UKhmF~t%zdqj{(Sxw@54C4%RoFBU|dq-X>CMwORFA1*iRsjA?8vGf_`d! z6v4q`2>Mw#1qHhFQ$Vi&P4fjTKe0WrVs{RSNO8np%yR(J=332X`34JsZ(2;2)6S~_ zGoGk{G_s~fTNrxdMmi&t)NF(C*E9<7c^;G5?o(}+-7hO2@c|=12MmU$Kr!VPK~;4k z8Yo!eS`1vY%Nn>_h+s2)gT5EL4kRAh9=bG z>1sA$7vtwm?BiP48W3~hAJbMBgXlnUv#)~J0EzlXwb*EkPX_kd*gux4d*1WsL2qay zA<8zjD$U{gt_c{@O7wr#YHqw#m^5d@o_^pPT*lKmfe{PwRvqxvOu(m4eZ{NzN(yIKQHBgZ6c>D_-wKC-6V8cfBlI@1tI}v^UDqMDr0ji5 zq8P&Ueav55rTn{8RrhtFUe8+;82=XanW5lWm=#rA9>cALmx~XOz2UF66H4ASf!am5 zC6buBCJlzohZ7|oCw*&i8`y;x5kHV0 ztZ5b=LUurNMejj&D)##cMYFJfgf1Cj2?*!i6DqApV|!`#RoZL4tc_2Sl+O_nP7Yzc z3Nv*DLHgH}I*1uA<=>gW`{-4TMG`heD`?HVBmk1V1^Y=T2clPJ#!)qncxK3b zNZ`=+mrymIs#T4Q?r zTJ{DYNXjqJo+-rB<=NctY`hV7~$Eu~pi_qzpn0tI<|h3gEm+gzMiP^LJ}u`$4#TiEtTH zzqjF^b=Jwf^}*mMQqOFy?$`ug?*7_;@o}&)`h>q6+l+49B(^JM=V)JS@pcSD|4Iys zmuz!ACt2ROD1YN2*tR|ly3B0H2F!DwzC$uwB}>59joKyuI7s8T5R$6Z>~DN9p7 zSasT5n^>_AjxpB#9Opl>toXwO3XTNRfo3(_8z2xJY|n=cUIiuOD!F_nw4f`jRl$~1 zgci&(dmi?Q>nGYFW?9YNtvwThvMbmy?Xas?AkP*3hde95DhEg0uGM|673~amwxOuH z2f~dfry@cSfpYpa#QP#8W)aF{^8kxkf3#l9WRq$g* z-SAwW)X5fN)P9SJB*}zmevT<2%u;fAos?N_DPbZ*x!9`ulF&tD{qY@TeitVAv>RN4#t0#sE zhP+9Cc@o%G=!*zXEpxb9MsApIS~x?JxmRSua-+oCKC^0$*5tthzU62Lv=w7|vRe`s zeS!YqaPR>?O+auwGlTnJwam?31J{EIKR#D)v~@&< zTAnd0nw5Yj8=j+9bH>)7&@O#2Rno1Q*Ad3ZW1uyM0-p4Vc_*S0e*C4vDALI;$0dXn zN`935p_-NKuGQr7 zot;6s8vbpva{kCliu{~%YR@CG($(kML{#E09r7tkHy~~+#qk#?U-u`-g54wB&hbLQ zWB)>WAi_gAT813?h;j>HeMT{rg9rp_(SJfQ#s5Str#gwf>d#X|?f|KoZ>0#H2vVp0 z7;OWjh7T%T!l3!FJ#e-U6zOmNUsSOB%y;uWiTXYk>iY;C^ygy-DMI@Xp~p)JaQ2@R zlk_1qxrv%&2Ph)CuM0N_tL^#v`Gno14=DH~sB9jBp>e9Tf0zFZYF|u@;g4=n6lvfd zbZ+Kqojo;%Kc3I)IjB+wp;u0r-eO$CXiK3whG(v5lIY)^)cr%uxrWdfJ_U{J|J$dkWjq`OiDBYbu4RaICc734I-sm`oSCq@sz_^o7)SP#10)T+alz(Sm z{vAg@(5InA#|I$!{!h8DR1K%;wLsy#0nY9@wVbx0&q?&_Ck9(uVP>PJ3+tZTR&Z~8 zK#@CwyrR#(N(UI3M>jE8Ou5)yNTh`WfwR?3ZIK)ezbZ52jNx;c%7$)GPT^K zO!~R>Ju>bWS%RfJ4uNI%5v&HK6G+G%V`5i!?+^r*5fh3}3Bqeu(RnL*J@Cs5*d`yn zJ%GC@T4s{8CK8e#DvW*{t1LSct30ru%(qMqL2HZTP7(;(Aa!aOf;ua4iHw*+K@1>eKb*k6 zchljkp~p!Iw0HtmuY!gmpD=MLjh?t{m3rbQVAH^W#YoxsiJk!y+)V5x4vD3YWr1ky z1BEkXVbryi9x`!u)A>Y<)QhwxGz^kf-J1$%17Pi|m#k0e*{In>`&_8mOPeg$R-Guq zvgI!F9A2)|P8OWNoU*w$dEQ!%Ys}IUTib(d?yW3Bl@q|9#|tbbdnVx#1ONIve4{Cs zWql`N-6!%eK$JwX%}Z&t5JvHbmJb%AHWxNQ%X_+o%}t{I;BxF=l0=$cT4+4}PW9-jX^mE?%^0k#GPNhFBHswfS6Ae92 zxN}Hv1PZv#V&ku=CI(v+&pp8H;0SiZ3|=8H7@mh|5#Aq&jXDBjaKVfk!x119gY}!5 z7VF6n=vI^QH`;|+_0zbWTy|8_u}B2F?8dt-OI(pFElxNvDs{y!3YzD-w|OQUfUPF& zgn3Vj_8ykNHhC_z!s`ShQr!6K2o$&-Yn8eq$@?6R8zL8rkWnOk6LM!>5s^I%KUAxGp4X1>X+15zd79k*SRM{6mTru zCCRJ##tD}bg-3%kVJrxl%b~$~OnJ9e!Ct=oMjXYT^~VSCUr@XtdpY!>KTZj`oPcGDt?!oxgPixclgI5)$EUw_ozSV`t$agCQWa!7rsW^?FJ zS3fgjVDMyyJ5e?8RB_pG%DEfPtc+z3N>*7#xfW)2=L1wFKAINdV>pFAFVN>{ixUkB zO{;xQgvO$3Hf^9y4nTe53{Y+ZrDl9cEMmpA3|Z<*dsY&A58Q zxfZF4VoRS@+_aDYF3O_-PqT||H89FdANT}7{zAJd z_t~-h(VVLroX+dc;e6m~o>i2F8v%g7Akd|R9|t~;2z|>L_?VeReG9G`a@JYJ)b+q; z)6b|Gcqp&g?;J}Fz>!68d?Whrex6SZ8~EqIPttyIv1B+y`>WxU+}{+JmQ4)jpZn5z zvC(?Pd9jwc>j?UO#|XN6WIlC$xJ1D00^wUg!-XFU-+qigK;M)jfk34XdhKc2aJ~RD zK7)rF0W~wL>8JEvd;1#@_t(~(+iV?jVsJjr>R$)2%ezHa&*hr!?$Gv-1P(-ykezKy=3=E_(v(6_*r2S%RzIN@_ zoZJn?+nXMw!SI9ABp{F45k@sD|28}VXt&_$^y3Yrc0(Yqt6PX>w-wdoZ5D02r++Jr z<)1{G@<6j2ZsVkEs5EgpXzFZH$v|dNK2Dv*1fev|l3K#dUMP&#fF&zGfCHhwQy91) zjM=$DYG>otnw6VH8}I4osGY6goM>mQ&<{WD77w9?zJdSEEo34S(1J{hF5M9DDxmIG zKekZyoBWk>flBL9sba%ar9`yCi)>^DI@P6}g2yPSU?lcx+@;Nzt z;mzg}UD*;w1;Y|0XEtZpGq~W&O1q_UB=M;F_s;&4r*Al6`*Cr}NdcCK zsCAmPn0v~~@&b(aPbn^sbDp<~&n}dlw~EdQ-{5h#zB_+H-_GMsBpnSlfF>OG4A0s! zEXlkiItZGGx=wDb5~Z% zGR(g_Muts7nBrE!34C`xaQhp4AMqd|!=IdEvv6H68og(D4z+(;;I~o4 zN&@YVUl~ndW{i9}Qfl4c%t}Gi%aAC~MYF^#{f%OM`R|7Cpx$@enG#_?40KBlFT8);>O(kz00fgY)WjCldvPqVD1R%0Hn+u*W+_(DYfbNB;*VvG4&9 z&Razz(1a!4sN9+X6fjXVeUw`@{%_11yh`RA)&P=oKIhmX>h^Mg)6qcS0Ab(j_sHdu zhqbw*n%v^z?O08j>1WY#X`0Zw&=$U$ZZ(gF1UNbcIEpj`36@%bzBT3h&8GyKUykO_ z&yk~j#UFmK{gDx^_Oa2F+^34471}3WFSOsZV5i!)?DFQKUAXNBi z0ecC+g1rQd`TD?EY^2UVF*O#H_x4a+8d;o{t$h?jfsG5bAJs`z;U5TEr-bevnHq>A zKfVQ102Q%KXq_3(=;oN%3&N+y$w8g;0>**CSC__cZnG2Pun~yp+6GX{AEI@WiMp`( zHd&1Kbe}>UfFyU4nT`$A|2fsqI8R@29$DcP>HFq@%v#E+2UAx&B0(S_F4N=gV#6Md za~|wL6`N1a8lHpl_Is>Cd7=z|`IalA7G}oPzc}4TO{I7GWE+>Joxp~g; zB7b?T)lqA|Ti)JmUEmyp%|Bw2#S59X-N0w?IWu|Wcp-34OO=Csght2^GYbi)(+5oqHxdH2F(LL+d z6BW)8kcNm6GgtEypfq^ z-o|VNwWTh>QtP&!gQO~Mxv8eh6Z(?UIr(3UH1+QAp zC2U%1t!iRsITV#u=7b{vIv57c3oFg$g?p)U&^5&MZL`KReR*A{x;?m8Vb{P{Z^Uwv zwb(WKh~>7WX0_ec~u zqQd!-%qK;K(?+!0 z)Z;_$+OWs8A;S2?o|9)?`aR|Z^9J(;bA`EyVz!v~9__Ro@yxi{e4|xTo+T0$?viBU zO2@u1bwT_NN%?M3?e;yE2>O{vKdbO#+_J~gSNrT9sERK!7nskRO=g>=6tRl=(9m*J z?2>f+uPFY2B=aL*NqcJJ_cZr4>Zlff4NHux z_gD&=OUr*DGDhvmw$;v}yvCVm-W*pubI(*$6tl`)S37-=Q3XKE?%Iew#>hREQgfR= z_-6Ap8;O=8@k6FVN_C| zLT)#LaH)B((YxE+ncZZZ*lj+-OMxU?=K@xGlZk?*iD^T4<^^A^h|uHPjhsNvSi7dE=M6 z8^79ZftG)BpRr?iTu47_@nihkZcxux zyFoq6rs(J77UPxOivI(gwU?<|mv)0h@Usd(M(1vz^9us+yCgEan^bZ{D0j+s=rRGs zd&V(si-WQkl5mgzj6#(Pyzd2}-wg^JlgA3GUToY=ga$|*Hp0;tw4^11wI<~>!z|-Z; z+zOZ=cY%y36}N3q;y+3(IMFl}s06wpxpD^B*OP*v?NBYGME{j878_rxeNGbRdW@k$ zoYEeIKeJ>B6b;X2F-pAO5?!xJF4|F3xeLboj54MWKK=XOSmKJQTS`~p8;+&{oDw{C zF%fdAqFo7PQ&IveM8qIeU;WOkSRoh^A3qroEJ|DHJT5iIThhTdW3%;C#C|&W*UU^%maIf>fLU{bt z5kQqk-z{TxGCU%HgAoYqmi*X=w@|80U185jq7#j|wfjluS9rM7RDieUBC8)Va5V;F zhOJR#N!fmQS=#+t&emwCNMpgVaqNZR#No_1UChGXNZ^|bE#v5X2NYMG&qDw6x z&Er|83VKQFPQw3GG+2q4V_CgWjevvh`>AR{i$KDEG9?oicsm+K^UO3T@cBjMw7;># z&f9Z1tL{}%bISICWsCl|u2Ho8*oH-G>*8j<{c&B~jQCl~y141k-`d;qqr47vz;I1{MIzOEUe$}d^E`j3YJZQqp|&QfKC8bVrTL5ojM` zrJv^cTIh-pR~^Ck_;uEH!mdU$xodBfs$$Vh&G@ePb<;FsyW;7=BfQ?B`DPd0WNU8h zim(5cIm$@()?Y!~tvA5n8rv!|{*&6Ie5m{F0#5RFqCH|?84N8FMSG*ND;6chuUj0? zEs0P4kv#&2Q1&=g3)G-RZA$sEt)Q*z4iU&>J}H zUuEUwr%mK3kduRIv{Uo7U~>3194VW34RYoP#$t}+q*#jgMm<%rC_H}Ms(9{+_*9k@ zYTn4igMER@ue4eF@dV0;qUH!yt{8@%Bj#x1zFm9kFEgQvV3Eik z!G5HGbRPe&$pq6#CdMAYua@JH0w$E~t|-hdRlvgwwXt&-4f?(~*nbW)7N`U*Vy<)d z$EQZeue+Pb+0=Et@%dfX6R+7LJikzsLo1!y;CuXS@UK!E7^*e$j<1FKwxPc7)^^8r z?nm*dvGMCP{>ef0Z9;ulQ7BtM)Is&>I~34<2V9M^Osdb5BhM7-+X?3znkRR)L!<5% z9)1ECQC~cl5T81oXeqw_e&(nq7f86uOeJ%a55t;$Bhn=mbRb=$OKnyaTm_2E2{TLCbqVh=P0UOLL zIXvIvY)Q6dQ+oMDUuD}5#N$0Qq!PMlPGC10hx-t^>;uhLALBx*w{l6h1<7F$x3n`7{$)~VH9c4N@%zP zu06&zJt5AI5GAodan;o z)*TV>|32a4LVCJDbhN4YAO9T}av&e{nN3Neg;D2e3Z5VhKfqENCx)_qL1)pBpb`&5 z0AT<@0G=1|DcqTH5zMSceVs*WnP2;4UGwxRJGLp!_gL`orXLd2gFJIgT}2jY?c;Uk zdo0t11jN&c?2H({{4?YMNthRH)~#Uf`pqK2w+7oU5CZbH-Gjb}`7bGciZ;|HbzZ!> z2xcPshq9U_PhHE43vXH&?v;1dUWPC4Rs}rzvX6b&F4%~*OpFx+n$U55@0d$G8ClC3 zBE9mkMuk5lM>HwOEHchy_ zqyYLEuY9*0?(1*2_4dXT>?k~GSx<*FEsPqH4X0o$Lx4*arDeg{tm;K`Hk#}gk$W*#(Vz!Nq@P<+4|5d=0#}tGA>+u<#}h+`WGhzBenZODjNLY32oruipyiD4|n|u#0$G@dA6%v z%F5uLu)5gjsWl`SW?!XQ4%e}!H~K0XX0;P5==N2Y32XIDza`Pzh7($Dz(AE_Iz-;)=qJ^}4wA7s%z#0r7THapQ+F($yN*ZJD=ZSlW>NMU#dSEy59JQ0&J`w(4SH|? zhW6XNp7Z#ftEb-uEd4Gnb*Vi!CRFZkGa-OmJk9Nj#nw{h83HHN6;C8yV4g-)tBku* zIS($u2PqmJ(r?6Xm6Wd)G20U_v`P-WP;1;qNY`~oK5L3|-D|$ki!CGF!Fv?Cfv>?v z?i}YX*f!iwZoE`e0nNZx zA9n#xfp4keLIW1~)JuVjn1TYkiMPX-O7|>P}9koUge)=VIL(H{k}$kx}zfSQDO=%&}<{cp&PMxmCvQ z@jVFj#tBDIs|>a4>k-@-q#7BcM{kG%BQd{WVZ%u0eRu+T#1AwE_9!P*3ls&eds`Ku zt37j+xsRCSB3)K!olDK!>FTIq?ihm2;GuVG>heYUIq(#X1YNP5Q!vr1T(Ks-$itzp zJ>9JeR!7o4tb83Z5!Ocbqh(lTRNbOh1^FUg?RPZamLRqV@rQj~DuaAgel)W4y|8<2&4 z7A*GViU>)^8DDRZ7O$HwDq(2{Nzy4{JusSU=vay49r#9IJu&_fahwtn7(4L%UTO7* zMq>@hT`-=31jM&XtLI@?>GgrJOpp~5;{1MA+1_P8=x3EbMTnziLdc7iJ<_ND8^!t8 zca7M1-%c=Oh;Rtq?+DGU*Q|0qg)g&wdANRt51cj}VgpqJ#$BzC#LK{fW|uw&-MW;9 z)T7IqQ&vD_4KvZ0uoJxidYBb}USYhKf|s5^%8F3^H82h}jbFIWIBh2y@NYC}|Na|9 zASb-4yVQoCKLJ&8oFO2%L9k6D=Hp@VCSz6z?eeT|0uDmvB8g4%T^cdD=4zXo_J)}h z()9UGxSr1>oi4?BUtJJR$v6v;rTHA-}yP3 zIM-b2JUkY_J9Hy2h6iU=G<4abcp3Wc0Ekh1sP8$Pd4P~8_+-W>>IY`w8DUG1D{(2>OM!?e*{7RT3F5Dg66;x;aL-&cQ>WW2o{JHwi zyY{9A8Ju~!Jr!@=;UDDkaM8jae@)I>?VXM<@Vu(b-Q&IQwYp3(M374|303ZVX|u-o zS8Bgr)0NWQyhJdH9gORh#tO0L;#IJmj$RQE}@R+4>c@w8`?+*%o) zXVlVp!co$v758bSZY{B=UR+>UG{zT%8Xje`dZqTD7F%{%y{1iQ!x3)MN|tEFby^z7 z)*#~_DQ9kZ+LkR*)mWkxo?f2<&ZR;7Hg}$0KSmmiPvSR=r{5@+eiJ=U;8(4u-!S%x zu_~LV0H5ph^t+O!-*i47zo~lqP4?uC(Ii2grEX{C+xst z&}Aeg&qTg*4Bq@9kLhXz0v)xyc?_>vLyFXejU*7rb7QLUaX<Q%F*s0ySN)_N%{j10(y%We zA{kw_rtJ{SAlW}5A{C`HNKRpQB9x9$3PKtB>M;hJ*5Q(KM|bt-H)pgePB-te9uLD6 z*=Wwb(5oAFB)h}%=7&9vxAPcfM+lsWwT2p+cY$TKhMsHQp^BDW4n2L5a$Ox#JO3x_ z^h>*Hc7#1GfikVsS*SLYs~h8A*q6nHU*cZ>H3S**swVv$$v&-7^Fn1y;2@5?@RQL2 zZvsgC?O5|nRiEs#3>$lQXB0i8*{SqM#jr<#u15$HwLj!w{SHUULHiCYsXgoM8CLVI zw~MZIrl0Rj?dwcN8Sn#0HuOni)woz8_@q?L7x#5WxjV@L5R&-W;!NE6iFAj@>5R{w z@nPd8(5M@)4r6UF_Ega<#GBo*rh?hRVEg$zOj(P@0&5Fy3q$!n!A(BiR>7I(Z^CL& zwSm?*{g}!6hbG5`QW_g9o&0bnWOHci$+5#p9|)~}oB3k*1exiBSFUm;7P#gvl56K< zmqU8ka!-h6R}a9Y|dcrBw=cEO0%XJDz|WX zqHxGPec<&I%>5i$ucFF&1vZ6pJ*yzIfBB_s9J5R;q$AkzVuNs&%$*?;yasE}F;2cP zs@C>KL&tev7gU$S*k6sQMkWLbPp^o64}6~Rkx%DZ{g<#S#S}xMyvrBBgn%+k*8+Fq zDtA;f)T_?Bp{ETvf~st|4$+l)uesCDyHoqz$v7Tj5cKnLJR}biUy^JO^?EMCdX>~@ z`LXZS3bRw#QnR0CLgoy}$Xf4QChcLyy&d0@FKCnn^}_^;cVJ^l+yhqr(4c=mvuh?< zhUs%Kf|t6h7E*v8%-rriGcK+=biOkSN8HY^*3hDv`YvSYX#lt~cnevD0E__ikz*>8 zNWek6AN_(Oy*VPW-NHd*R|O*`9_NluYE@tdrt-&B@ig-I=Di zXjMHhoCh4rTjl)Ma0qp&xXMid{UMYbY*+=CEJC8^DL$J<_J%z672PmQ;3eI8#mqIJ~i_}Er%r@6bw>!Bt6pIOkRkup8XDOm=hEO7z zF!VW+c zZdX))-RX9x`%9e@((j-v97=wflDnt5-HAf7=UT;kda#w=YGf!9+q zv6kRRiYJjC97j2=Jol}b<5*}sS4R=d1%E_4!gqrdXof}qmq@tOU8lKg2=A}7hgihd zG_k`uU2*)1aj$$xjsz}8NC?LhEphI+X!b|2MckynI7-8Naa4C$)D_VRm(vP;Jm4o< zynv)+J^iM#^t+P(9KXQyDDCk*_)h2PH;bj;BF|^|&C`E28fO*T-Z19H?|`cBYotaQ z7lzkoKW?vy`=B^3^JicQw9ul}X;`L;R#`($`SQzn?tzKv%7A;aj|hDY{U&!_e+CiE z)~*}~nd&Gu?JS*Sx9aa18%%3+=k;l1eiV~!=4;~e)xOX*x*y*Jb_>oNFV7Kpk5RNO zKktv~MVjR~8&gU(Ve>TM`w~qWg^s+?eV12q8dcY1QG`w&WQv<@ShowTb*b;+5WFi_ z81$Ug@J%IG@j%ag+;brnhvlUmlh!Rxtn26qDe!A6u?*0=Uq7S2+rY(hE6P`^VLb)AXAf~AVO zQT~zd#f9JASJA>%9Inpn^OaTghT8MJ^2>18?Z6S&lMTN5H8bn7oGTfUg>nYNy@uu; zfFab;m0>;ZY&pO$0+;#9=2m(pN3Dfd;h__c3|W05MY_Ro2!1(DKc@{RMK8twZ|x0p0g-peg4LhC7SS_H@}BFTik4zTUm?Mc&-(gf&(EK z{oGekpVGjsi`bIdSMlTb1!EDJQACKs9rOZ|aQ95>4Uj@8sXjJ^5ZpA0M5q$p+A};E zld7b#;t#t@9Qpd9NnQ8oeH8{nUVTa~R}3piXYsDeX86+(wyo7idV}CMQ4?nT*o+EG zJH(-D$~Nb%ah{KxJw*2FG-u{u_44+36P^Cwr%aWYAvard3IEfWJ%NeK!2<3Z?xJI89CmaCXn z<6(lurV+zA4UBO3dK}rHkx^%TvV9sZ*I!llQ4G}NBzsk z@yeufC}gOTyqR==2e&0LLvW^qwaXM2>$iInu1kjCBx~PR+FHC*7&T)X#J#l0!#DnC zz7DLvo@;0n01EqSO)!r9rdKyLaO*Yel%`iVH*lP?7Me&ordLzJma(Gs&ykuYT)k4ocd}wF`Va`I|LswxLST_5(T1z zOmD0thku++IZ?cY{V=l~;#l&e&$eXh`!dyO>f$Ziuhoa|3`$?_EL)1LW$?24M~XMt z6e2v$*^VxA=c)2l zAH7rlrSGY4^VWoyPye!HpY3DX-ej#r1(8*IT7 ztSi&xQrEo^R%vO@sSEZpN!I3M&pVl1&VGrNQ#1D+<8iNN5gr|f<|**=x6JJm^(FYi z{(Ad3ayxRYPFc+H?l><0$nN*45Uz2++xg3rTn>W!l|PA7tMjyRYD<{mwJ^1XO11~X zQ%8;^U;oAAPzaaPuC(44W{pdZjm~Qcx5kC7h&JHg9@ZOVjZ+_g>$DvcM$YfgL_PgE z&rLghCagX`Z>h6pOIXd8zceQ>7S{GEqT%;6YZGk`C#&CyiVtU zfgAe)CPGgG&g&mcZkN&9a}fJpRtyPlUhwSH!y%Wn#HGL%e~G|g8TP$sirt!tlx_r| zbYd(gY_uvIGuhLVc#>x(|Nf-vvm0|`x@KbkY^oHyN4fvjob3I<2^qk`UU~|fSyY?QD zzyomd5ohS}V+F(EL(ryy4C$RoUG%qH%BD@<7`9xcg*k{8W}Eo2&=;*(oiiRI$R&8n zE7Z34m_(K6i;t4Pc9+wr&p6|i@`~9K_%tbX#g2ww4(i4A1UC5HleoR0OJ+y_Q>d$| z@hbvW-Y!2%-a_<8CaupqvE87(#_%N*_8r)4WL+yiczX!w+y;#L$8ktrT@)v9nQG7Z zq}khj=f2ockT2pr-_JiM93|FkHl|c(Y35a_zLhi4+a+9hQB=D^ZwEE125@f*iEh*6 zb0Kd>cWEwztpi}GzI##=Uo|-1i$|aA;li_FgGqD>q^;2}GjWfm6L({aeB>Nbw5LX^nl#+g5qucW$Kz7154arJyeF_n0-4&1tMv@kjb>j~i>NW79@b2ylD-0n@U6gj6y!5D?JAP7HwgXzhXoRwA+|N4 z?QX~}HDv1=XsZadO|V5&+#=oGsw-WzWs6iPT3eCY=KY>YKzrZ&+|T>R`##U-!zau& zb7tnunVB=^%$YgU@E)b+ei4@fZ=dt$GD90QhWO$_;Br;5|K|BY=>PE5lle?Lv3Y82 z;?GC&N52Cy(&C??Kl+t%Bpp76Ao=5xCq9M}s59jaf6b();5x{!N*B5*ytpfqT^$c8 z{>{j}(0=7~f)85;oAGrcdAsjW2ueSL4|t`dkC29Mum^tDj7H)V_ui0x0>mx?c}EFt zxDC4qRP^oW$MA;RN8tP$+!69OwyE#ZVxzj@{mlH0A^EX#aM9S0r0p8!nW;Dw`EVGl zMH_IoQ%R_dO4@NXAEhR~F+y7(>>hb>N|k`U93RVt*bg)Jt{>nmSRJt1e6suDk^0h1d>G*8R|p^M?P&!%eQ9ILmu#G2Y6l+v0hB7v8Gs zyDP+!;PoU#;}^Dcb3%Y_UD?WTa_kdZ3dcu#WesUS!i|u;e*r=c5;agdjtZi6W!IbW zpeW{S!_R1B){M~DAR<0SpXO^w1LwqpbIh1IYkXpBdY)xG3;PO|hpW)>Dgj zY^v=r?Z#`mLCVPbKO@RCe0D8(eg^fi&RIs~AG?8-oxJZ%ZCJ{_zkvsWb|3b{ig&Q@ zQL2Axf6@rOCXF&-(342IUdrDi$L{cX8ql$`K#IUP=x1e^ubq$=GNVj!7L{TCdaTUE zZS4CiR%XMjk=U|tn2u<i-u)H0wzxCW!;uPue`r_&2VxOJc!p9xtF%;*v zj@Kv7M37FzO{Ts6@15IPuodgT*i3`}2ZMGi%-@GX&C7zNC+s70lPUC|_UpS-z@!@9 z1%>1BJZn4ERTF4Ue19iZR>zE2@eM~=b@61cdWUKpf9oQYpYgr=;JV9k78dlibUu3G z)V@@!x%_JNRre>nc%`XVc*B}X_y4nX@Lfs(^jpd2TNd_813v# zNW2#%<>y9;8XY%JIgM-FWwh?H)Tu1S4Z275CM~?rMXi_}D(y&?#C{Sk;yXSzNwOIB z(Idmk2>9+hB&*!6X~*-EpVs|7&fT6%@1N9l#M#@G5_$omi)&|F2G+yFxD;J!igz9! z=H4?p_86gXac>#pQUEx9mJ*9bXp07K^3Iavug1g9@e!4#Y38@g@6pp8ox|=uIC=?i zJRQL{Mfew}yT+fSfVQrY@VltT&NhhJ*wPTTuBxC5@!KgDpO_%$GnEnN@g_@Tr8%~4 zf8EC@yY9U>JD$!Me;1@h(+h{Hkk#TO7tbgoFmU6eD!0ehy;nDg`s+T5v)|gbLy`Sw zGhyQ>>QE`(L`TqxpPO@oWUzbg=8q5p7znJ-cC2r9B=GIQD2fyIy=#09=lu`KW)H}p zl$Knbt~;!6XPYs1@8KUKNL-6;<#=$p3;q3f(l+8pA4{qNvrkM&Uc3zMmN|x>XDaG0Q3D z$&p)P9%3-nV=;U#eFJ=~eS8K*RIH}*7crZB#%6D=bT~z>=w`+^v%zQE&GH!0V7`;46 z0!PNm_`COj@@Qs4c1Up|s2-qsk~Y>Q&z_VU`@UlJOU=+spEu^si%W1uNU+1@aBOg< zOX5&UfwQKg?I8gsHUEWwiqWgWmErJ`4Q3800R8`- zKep_KuaP65As4gw&xy`WFOJdXMMj&?DAEerL;y+5q_dOxa|~{KfG#_?0rRu-__HkX`9P#; z8@bQ{vnrKCfO;|l`&158Rza=5LLJ5#XH34GF#kM zW8pk!7>>_aBP0$SrePcQaVd%c|K14PrLyk(Sb;IaXaP!Sm6Hm$Y$<1ySfx2TqE6j) z_Kz3u;~QC9mZL2+MwT%;H2haO+{yoefh`F%c?r=$QXp4eU%B8TFoynG*6tg{p6|E2 zSf&-VHK~C%&)+>^6}4HDxZiKnWDWRV=@&JtZEToedo`{k*bK(IaV7{#22L8Gbnn~h z{y+IK2GJsHhZ27^K<^1J{yGDaoa4=CQi;}HdzMK=0XLV${nyB^TK~dp0e)zZ*rf3z z%Gr31Is=!UC(eZvj!y*jL8rP=FllM;t@Rxoewji-hfO1Dt9~^3z;5Z9wA;&?F=yt$ z!$)LI%Dqu(X;Gr;VO?Prk6+yizN{oSBR}UGwYo4cUhr!~?2Ue6~ zA#+hAbS)v1D|yQC+Ea-kS!*LbbQediXPk@1-qATxLlm z9uiiHyE0_o+j#9I?BijLN!)l1kA*@r>R)|rQDn!L{-lb00klYJ=+8*-CE>!uNT@3m zp5+_$WTpEw16I2JPcrC$R%-DNkOf=jN@(%)NhRc`Mc_EqvexsOq&Vvrm(8(dfLnubiGR<)oh?)qsE9ihMpYGas0un*Lc702+ATOj&THgBOo9ME z0017J2qYDLt%i6N7D#Hi2T8G`*P$BVvL35C>s{L4+$$^2y#{B#^8O{)dXCLp-Z8WL z@A|wI`e&t3q4V}Wbz0kSF0)bN_*&;^(C^r)oTjeZ)uWN^xBz1zA;0Jv;I?wO^Bg+^ zf-~>6m z{IoFTbWxM%j`rAKd68zRal_EFH5#wBp3-hx@Yr5O@u|$8MHWw#(DJ=k)-Xjv=CJ-K zJ&N`gu{ja%yC-_v0al}q<-0E;Hi*zo#ZF{|SS(M4Jd>IMO4R2*&kCTpPd43_k29g(VgS_`~Se3|aIa|Jw5y(oj9yeK_Z|ls9RX zMB_<~@vUc)H4{5HaD#>;T)%}Ile6&rNC#K^{`hy#MA;=dZsA9EN&$6A-uGs7&m@^L zIX9zI*8e*^l+^zMr~8xsX?OoldNt`KmXTzyuvd@H9H zTe4l4i0`Vamz{~p*ROD+tP;Z6xTs7 z@uE&4M4j&nxr9Bnp2;$0T4!j(^BlHM%v?F4(7%PMDE|zn=Fl1$J{e>T8q+CH+(I>? z+Nr{FntzAFK)vFDuh+su9~|JF2y;mrcHqGuigjna|g*(0+><> z$Cy?bd$m%=Uagb`U#)b?WJY(0(XHm65NKVOh2nk>J#HzJ8|`XArhw!FvA`({3xfS3 z02aYu!Wj&mSp!UCg;N$4gvt*IXrndZ+A%K`1}Zx=G8RI0iL02-W12r39hA4nW<S6$D0fS0qP-#IZy;Jt1Ak-%W)u?sK7Qvt>$QEL-2(ljo4x+SE_COHoG(m~cVv^}H zep3a*MEuJ!EyHhtU{K*-sa1oG!%5;Yi;WXB6ODElbJ5|Pc)3|KsZpa^sx!KHUiQ4X zt<~+eJ2eyQc1srP9A|jfvTirma@pfnwJd{}+^kU%NJ~Yl8+j}Td3T%ZGC>~Wy?Z%} zTQxxN=Ap77L|W|p;2^*}f940>mW!NcNM-Kz4sok{k?3jT&wPwT#~^=Z$a9)=^s3BV zmR>Hm>(~iI_kGM$v2OlYFK%=>zTx`(NcZ@;x~^k?KzjEHzOD-peZS`i&RIseZX^#m z{9NnM;1@{tzD?P?d-=h(fs@_WIWHw6#@oeP{G2-g1iZZ8a)WLU63=&|=s>iij`9M( zLHj_h=LYAXyk0+dCXj479mH&~cc31KStuEZ9SbA_G32JEcMUZC&xrZP5c?iV0W1gI z19~7ws15yR)U@A24Y@$9!7olxUs|s7aD4eWXCZ=nhPY$BjMT#)@MpRP^yUBH)V*8) zNxUfvNfC6i405Lp!vyab;#vdnAmW*}Qx$+5_VeBS-0&vOTcv`}L)ff`r~pv&loml0 z(Kp1KKX&(?`3Tj6uGRHt+=YMR%6mEQI<8vdDN!L9sLA~7a0ypeRZ|H_kh;dcUK5S7 zK<~UNuC%ETKL-2&xV(zrRN;Asb5yCkRa75ZSXyD;Ix3pCQ7bp`<|^@=<5!$cl3L6qsW{1@)aEa&~qca*?-JG+JT(q*Z5=pB26@c!w*w;p{CB0g$QwSji{Vrd6djsZ+SV6wZ@E09*>cpf>ML&YV)1z>Nj~(Y~bm zG(U(iRg99^jLja#H{fI^^ip8C+5Yek>VOKANzk zseCqP&KOSMR%hfcM+PpPPx7tKKn9SaK&xKCCGqrAiyveG{=;*)RX+0yE`cjsvAPIZ zK$9drqX`f$)u>B2ZwZ%FkN?O3EV&@8AQnnr$K$)77Bq9eesMe~hI8POq z)P(=Y06cZGxJ_Df6=*hERdWRyK%LR55e+h+{ysmZEtlj|`w1Lafo*1Bt0kva9{`uEijDHh`=#U(lSzME}(91h)b=dA1!{6q3;KL`Td;v z{BR1ldT8(wfa^ZxR}U4?(4x9~xq^E7(clN*-HZ6)m7MuQ-3ktFA8EvZ+m#Vh)culw z;$&$BNaN_@3YzjtxB>%ykOxRUQGv1a3$AYkjdaX6Oob8J^@}3gT(#93| zkR?die2l-o60SRoGZ&ARl>HL0Xn2{wRF5(+?J?XrjQC$u72XH=@(!-_BvR1EqrCaO zvaVA8SPAlgb0Dn6P`_oom#^gx^5(YT5)Qs5_XEm%k#BuxpceVj=sq_$putbx*qCoT zIlPDSx`TXU;H3GtXGF6Z*}yK$Zn56`Ia`nnSe0~vp>N_%=lH@cwsYpwHN5}?x2Ws& zzN{XGjmxe+V;^WjRhW3EYQe_9(gyf(cIFvWov!tqQRB^;zDsI=VbYzdHTS|1>kvu; z4Xof;8ARq8;!dLmo2Lj%Vz?p}OER54ovE zkitFG{kj?p>9AWp=w`ziWTghw@SXbME>73Qjnv~mbyyeQ_l@ePzVEoY8z|nOT0xFZ zy}1jkQOk!}!tQ2Zf`5(AlRAj??PCUe)lo>Qe@DFILKbp%~9`tdxY^Y2L-)HB*%!%Ync+V?p zYR^Sg-vzb#1-HHK_jP{PIb=Z>+jxhab7|?vfFH_W=TXS;KHgq|ZnF1%q;}gq7|(lD zowbf$bsaH`^4+|PI;im0iN;&@sr%e23+p2n4Ia!#bU>i5Fsj{ME%mN*5a(Jn_|e^* zp=Ofmu4c=(Wd_>#)frs```L&~FR&4zTJn^R+MSKvR81Yx@`sv2uCXouaZzCr3! zj7wLo*m>DzsxLG_q{A%l`+{l&79&mO-+`h07R9002DA`lNHz;f1)>k~K%U4A)ZUZ} zd;>l{SD?AhL`KjBBAdQ&h|{4m;tXgWMj2$d3wkDmeQzpjg7nZ5YK#{&jc0*ecR|Z! z#+#Dn2#BiwHwTyig)pARYJi+~;T)48>&7Ke$evUKD8K;>ZF+)dTngU3$Ff2GyP#o0 z?-^t}Wf&~!?xDg#3}nD^h{l2`YR8!(B*Gp;w7J(3kO@Nr!Sl9&fp64fX9f- zyHylg1##k00Ao2`ol#m*a1u*hffhfWu~oAB=iCzpa|vX%;MxeuqijVrkHRnuN^2on zmY&0pV@!}3UdwH&FjwiYj0J>JCV33dOors4dw^IP!VoH|>mfyy4{;D8sx|lzItGS_ zdGnd7nh${%h<*n08Ni83wOlWUgZubRHQ)!ZXpVW&O(8AKpX5uehtT0Y%t7Kvttd~R z>-Q&0VKp^t=3TQK;L?)?v?VxT(tpU z=*RLd&NGf{SKpi5XcqCT{fG|JG%b@eUZx* z-6Ftg;S*iafW<&GB|}AR6-{a4GP50e>KW0rTF^`y6z#dDvMkp(Y;Q=E8p|Ke6^o8| zP13@`!t54>I1!h4i$Y>oO4rOwSl&*@G_Vz1)4)qNLp{drV>k_3{GK;AyW#wIWhu(A zwes9YvSirt!`2hFffCPsSJdHMI8ykQcTMW&x)QykBpRDPD7!49WU69&Gq%QlC*ScY z9+x~JFMPvWw&MTDE=zN(*_*&uwJpEkdmo*+M#$WegsEhC367e zT#TcUx8xNZbi4uh^RszV0-wm6=F-n%`dLmtMPudk^Ei$o6pguQL>+WWK^dR;_wU#n z7VLZi8joYMJ7S72`Pn5=;9`&%-g$PpPr0-L)rtz+M9cdd5i*5}Qq@B`<3Zl?>eX&n zv^6`#W7Au+rN$RGTC?TGSFak4k6N=s9X2vsw|9zOZ=p3?v2as!w#=Cwh7z_d2yO4| zL^M2Gd}D-RRk;Ium{OdwWGFMzB|ak;q)`Gh0SOaBh4c_)6cVIHQ7@#^1|7meIy=uS zy0ZkFo zl?(_~PoR|L4Cpo%6w+yHR*=RC>14H2=cOE7nT%yaWN z)e`#DL3>f;ge7O>rsw?#%s~Yfk(Mz^Dw;8sm!Nw`_~9=(RTh16e(bX3`YkzXORCC| zq2e;sx&GAHoGED;tr>amaFCqx!0NMf-i3T?25N#t;CX{;LIl^wAr9>20V;O*U6ztX zpGe7d=e8LiRvRBsSpvnjrOwWola{eMqwg^=A{FT_Svp4=m^X@btmg9`p<)pMMuj*m zct4_IJwIhBS@emNTzl>c&%4^y#3!7dJQV1qxVV*CEeQqV8xkuEORpWD@!sqVW^Gf*&5kjZzBKT@$oL6!mmY9STteVwNi)(uF>ZOe6gf1>vOSd|wj)}30C zvnDO0E@Rlnd2lzu&ec(|L_sjrmwC%BDwZh7QXm+lQLzxVD1~(cQgT=1+8qz7`!ZCH zg={3)Q!9%))Hzk_kEqi!%o(2FL2goEL&Z#m6CI(OKhGPtP~k*JmIA)LfC{&mSPJV5 zq!5KIxoU5Q%96$k7xiELrYc6N_=#V+W>ZD=C6!)RzJ^z&)6!O6$(3FzEYo9gPMuKt zYFbHIRn81awoW&XS25MN@Nw+8=(!TEtZI`UXkhUIN`0I8!Zm~p0_7!K-qT!Pl?rXY8<=NGBCGHaHwB0;);2z#m$CbPF>(@r(K270IRn?1ZC+c?BxpN+(?%Tk+?*-nn zo6|j|FUMeX+qCMZINW~WsyLhG%G!z6yC&8B9{SK{HZ73D#vYI(iF7wttp$2CIPBb@ z9jHOV0&0c-=F2w|Y6$dIafP3&y+f+JA@yKYT~(b(efI_HyFc)d(T3~v1wXU=o&zp< zU-1LIgBhR`v4StSye~IpdIOM5AWR#F)ch`C-iH^)&zJcRHEL{bTXI!8umG`IQzn7|Zqig& zL{;U_f2+r*w%3|?+cMFB`Z6^P*{Tn2SG>k<45EpJm?IKguuHY(7`B`Q+1^ zeNS(0dU|vH)0=Cb*~~w)dA$R&a0Do{zDTlZ;`M|Jb5Acmy8IwH-4^B+O=pD{pM>u$ zq7%uFGlnn!V)MDe%_j>t`wBNV6>hFKY_2tI<_(*(E(U!rjLXEI-zpPdulfI5CSJ1n zrcC?^WMYtpWa8=l$7KIqCKeNz@JUS)6aH5cu{f6j{ofRbr89-}7$7N3gt$JLuwBn^ zV(z@x3+W^x3({Fa`cwuKEu>E)Ab|l*XF(x7j?yqS3IypKAw8Z!Lav{|q9YjS9f7cr zK9dDNI~)RSXp*rRf+!}23jv>_<{BFGfzg1ur)YN=r+3|@-K(TSzUfx7zJ~}9y2WVN zTYF?uV{ddf1?0wF#D{f@2!~1#NdX0=it6(++4dA{H=82u)3>|#Dj&S9^0&LJ$uoMA zVzr8rS|f%S-z1D$XKuJ;@Vok#)UA$&(7EuP$jx z9!cAMC?u}rOjV)H_|buoIJ*R^)^;Aho`3O@9G&ME^gH}3zOaMMh$+*diFF<6kG)hPC1;!4QXk1A-w2|3(Buyr>nDU^t#k|8ebc2K}EX7-H%F zB*Acppoum41kD{LUeHW7H3^y-rV2q5W2zT4@g@V5c2ljOi92b2*0d}^GZ9AUE1Kzc zJvK!I%`~fKYRhHZLaSbO-Y^qpgP-X7s<1&bu#7?_e8IATTJZb66-3V*MAOHo#Kr-! zM5h~~U;zd59wm^02{|(^oE(TB{Io!sb8>aY$~QyehH|S0|D6r$4iz+&*5{RQITZy4 zEM+;hx)N2+GTjPQPLq!0$(#yW;D*)^XenPdRO%~R^MtQzKRyTj*ZC3O=#^^U^^;Za zhK}%Yh`{>cso_-*>Ksf|a{QD;PkeetdFrVeKF`k~24N&s>*;H$8iFBKnF77BN|pCh zl_gn~_o!N3rC#l?sa*MPNLByqed#mpmSUC7a&DT{(9;&XW z8LDoo`OP(kWc^5Op&#a<(q(8C_QCwB0zR)q1w#`FqRebmHWUEG&LK!T{54-d{|k%x z99l^8GWhEi1xTYXq=^#j)j%vQIGF>d2|8V7FcslpbODGo#L`ErSfPWWsiJ0!si_M4 zE*02!F@&kpRH6@bQ5O(hj92JaT}qTP3cRs|6Meu81VK7}Z;3N5RjbWv+?FcPbD|oH zxp^y0SU)A|toE;bm&SO_1B2qVH$vjXk)M@5RQ=$gLn08#3RDAMzP4Jkbtpb8(L zN)u6~f~Zo9x(kR>WkaKDuI(q->WYyjBFiqB3vFEFD$*r&k7xMH!d6fbK|*d ztqRw&IJeUfYROT#^dNx+dJE>!LHf1S(0Fk)@{TIbrH8o@)IvIbZ;5j}qPDoyYPYJ? z2lc3Mh047LcKgRwg~fOk*|?@n>O2-Q=pOz2LhS+2#Xaz9_0EwUEw8k`Gx`}3s%?ZH zZ5uf`y7@EAqqdQHW??8bj8tGcVP|+qRl2OuufjnDY#mg=+==Pb#z0HYjVv2D2a5E4 z!Q+?a6Y-=%w04NgIakU)e#s-lI9FmtFm6>s@xg*x&tKpNd^JNCd{ys+4)Ae97xFmR}JZ}safZ@0cv^Y1Dm2AE14m3U=tAH8|7HuG6{2SZM!C?|)D;L0427EM3eon(=phAZtePe7T zkcweKoE?L%jinD(1xm%X?|yKOf?7)n$1P^c9HJjp;nVmBHDfB=a+UF6mHlLZr3~3l z23X2FQtQ!>R(s7m2lbz|5l{KH^is>kfg6LLwN@UWWv68s9wj{2dY}raL*OaC|HxB( z#8cYFcnSmk|CXn;vHFRpxN4zq)gEXKC9Z-`J&0gg&E^rrF>n-cj{rw00Y?E|AQjT_ zOB^K_=SWwpK^~WS)k)f)%fU3tdqmZE&UG?xwR&}x>l|&vytWbqaJL@la~r94_j%3C zo)fzEE?v~njyn{8{kB~*#r=Bc<<_^4P->^P@iC`nYF*vI2b~&Ki@udy{2XW{`nG88 zPg^z9%qFWQI?dkrE2k!U&q0hsm)2A#Xr_;j!1|G#WH)c42`g%C%kFYrZ-Eq5C%S5J zC612gO$uhPA2Xe?LClJ;v$-I0#kfVMCT33ydJcsR+D9Re*(#6*!E7?6O%A3BWh!eK zWUxDIJW_p-;wUwCJawYAjCQ?AoeSu&_vO3f$~nqZXTm(kwq1L}i$|m=ikH1qXdMx2xc*vHzQZWWK)WKAee#XFyzdls07EqTBwm=!h{_ zv?hc=0nMgXC?ic$`^4RKs_d2tVnUo~o7)v0hc`#;b6=mcW}bR^dmb)HDq!Ljps|~yGHmfWJaDr-5R}xj6C-R6V?psaqLESgDNXiI;yW@DpR7M1|?b;jB@NX$8!l(YbB78qDiQN^! zpQwQh#kb)XzFhFD;1AZ|?e3#BCOvHTQ|do0eZM zy0HGVaMapdq;eKTTZ<;nEt*GqRkAXbb8YGWC!ArTUxSJizq7_v!T-L-w2a?gV>0ls z5?W?L>*X55i-Z8y$PEI7ps|Um^2^6md8oGm;}*^t&0*kA(Dl7|;wM{azNcKuG@q1rlx((tpST^YRNK1ZlRA z&M`=kLd)W#S$T5H|J z76=RJKVo1@h4h6CEKx{b#A22R=|5&L6NU5#SO9}8X1zlS21+bs+b%HBVy6_m6REfE zc|274y*&?!AiLe@n5TAojL+%SqNh3e-YZG>o=?8VcC7P^=TCar4kU1ga+bGP=3U;u z31RPF`C+>QfxgdqTZF^TH7DKE*TyM_T%Fx+PWi2P$}4h}J2wv=28^ScZ&ykjc%`#& zp1Q;{?52#y?Y!ktJp!JCye%?n$T|C77zFa{oOAX)uxe1kDxB-kyE@94tM-f#OQ*g)L#o0i z$x+^#A#2Z&t8^5`JonfUqcGYwS~J3pmanWC5k?rZGa>=(u<=4hlr=*MV1xne5c&3u z(B_OVYsLg2<2Gjo&3ya?_*1=vzp}B&ch7oU6310)#Eap3X@;5<{EdyE`c8S;-;~#l zm%Q5+Jc9fEUYKq@g{|+se#7&IcW)TqPcVV2?lHLPzNbA3PQ{&(H!sDX2xa#v;qQ(= zwGrh8Uh;ug^B>`x@F32y*&qT#gGq-p4bTP2}$ z_>?wl+(kL@U1tKW`o%_uG^nKr^1c8wn&!^BB-LBzN!O-K=D3!0(D) zvg3Sny$CN{5;iummJ|XoxIVtp15|dLAy*K%IR>m=p}Yb3s}y8#=WyMB<0S=NP5PE7 zF5-y<{4*)ew)2TpXjAwmb`dyy6E@9x4MjcKzEw^wj$JKI6IS%VAWl!X4PG)##PuQx986a;bkoT zvq1bnHE!db&E&tMY)$6^k)Kw>YZex|m2ZS|&wI05q97=WAi*3kJ!r~pzv;$|7TT+S9kd0rzc70iy{Sz@$3oR{2$VVCvyh5-teNt2Yn zz5(Tmp+1@PvJTntfH;u^Wq4NW9XB>vOpHTaJBti>S8Z zmYkjT=>-{1|AZh0+t`9E6i`-~oB=4t0K&!qrU^1w4FC|%0O0vCP(sugz;r=2fdMEP zz-?myw~qnD@y|0P9q(qR(p_Fb24MgNsu=vlF?cNN0hHne*<=O~!vJE(0H%xq%qY34 z7i7~I%v1(5eGDdU4CW3&HiH4gGk`nB0A`K>%+zJ+=f3yd(&soYJo@(d%Dr9D0=tLq z?J&YQGY%FNpMgKl*|S2T9?dge^@`Evtu5>Xk}qj7T#SOR#Lx8=da%0t?(CdukF-64 z)zu!=nPht$ffG_O)n0FV%4p(DP5i+sjJ1PRmk(B*YpueKXeVw*_sZEb73zNY1K^cP zQAR^*i>)>=Vt3i<10(j2wx+;{ePecFNZk8^!{`OZO@uC<*l`i&9ma+a{7YA|XPF-g z2b(L5(dn*wOQO9Q(PfY4rtb~yajbO1_ssLwb_f4Jai|>`$6nhnSSBat2V+vc8IGF-bHl>(I0i{hQ-bVo$zli#M zQPt%IRp-*s?}=OVC;|n+!?L3IHNTD64Pxk1%p5Ci#aL;@W2J3e{yui|slYi@;GC+< zbE?iE7?QYIA1Fb9Lz_L3R8GIczx8i^iMJ}cX1!~UQYzvSD`%T8;?649DHF53Eu1)| zQvnIo*ttQ-kU;n|dN&%yjY5XZAtEN!VMJJAp>TwS!jT>dPw*HwIy2-B7YTwE8{%*C zh>#$qPLlSbnlqHbEz%y=Dd_U`G0^3oe+oS6=2{iI9I{Cap2)C(eUpzEe(h6WZnp5d zNQ1FAS@->EKb{=FTcUH!v+i@|r$xp-9`&j`w0c>;c~ zoEmBIX@8*D825+n_faY~q$b?-$^?Hf&3MnZLCvRAriAw4-1+MLUUiB$|H`N`f?r6D za5dov&XA`o@eQbZfi|3>2AzxCZ;)_X=$`*vjS{W=I%%Wwo9fW2AN@Kv{ng0Wa7Ff{ z!mb0DfA9qgFD%`-W>WK7Tvd7byygfv1Q8-79&{Q+81kwTaCN=)ivN}zC;od5<93dL z2mGsK@9I`YYziIobpm{LhE>azqlfU&0uZW7yRE>tHazXVZ(g4NV&o=x?g&EW?( z!MTrs+cG-;TjW!MGB_)IZ()ZT9z_YQv(rnpdx2$3Si`+v-Ww@~m5C-2xoHYeS?p}_ zq7(A|FBo`NnJCYPkJ5cM1`{QkDzT>`y6Y%`qMV9Ht>e4U} ztuV#=+N8UcYXya5>3ewVOfG8aNi*h->1?tX1_|g+yx|eH#ukO=i9_)+1*-$4gT(Fc zlb^l4VfIi{Sbw&j!A~|NGWe|aG!F^4Ni2OEES8OfU3G1kby%RikX522ShBHpJ8wrR(Z)J8)J;nx5B-@$O*0Qf zBsn@`;EsXia{4abK)TyS*JN5S6EyEObVDyO|3@3g?F?oa4L*rzi;XWjSiZc&=`Z}jBZ*`G=~ z{wYzWD#L6VI42_pWQySLW3UeIwil#ZKb8o-d*G7>JAZ8(&9mkn4tGjJ&{C^ZVsWdj zQWDi1cC}Lqi8ll>fy|<|5pWQT0Vij0B(X6#@@PPCcT**zc40*0)a~N#UB92}dyHBI z)nXQF&}pzx&$CGFkL!)oYBR!v-jj}x0g zSZsdU+Jyv&ZwSWNdV~b@6M~bi2@}QhEEp>sPC9`%5E``&XZ_8-d7WC9zW<+oWlp6S zY1`tGElt7bu|EaW7@*VlOtWtx13wM$3oJWO~Bk)VYT_XmD^o((s}Ggsj>3D4bG!=(oaI z@0@7C$0@&3K5Q+Gvvpca<87y`r88^;zLgt~wOdQ?usU9;tA&bK`MdSaN=fWb6?xqm z%_sWKC&gh~LNY@q8LFR~6fcR=aU5M8+DbDHnwWPCifk&)njNkv z6dk9cPKU>JEp4MQ_proOfhHjX8s#jFb1ie0#=8v8(itwjv-FO7%|^Eq{;JWQc3F7r zW7M8@)*fuj1~5!O3zRywx>k+$EFEu80$MO0{nPRGB%lT3(ZBj$dz54C`Bhk4$~P;T zkE?vLjh7imabV%C}i;8Z^GV z%3(J3RYm*0+MtYzhuAo^b@wqlc)Y|uYj#9fq1|DNu$F4#+Lnq54#_Q37LLAGR$%I~ zhY1uV6272<27iunO2>4+WM`<-RB5{18LoQf8;_gA9#Re+uG)$urzS2qCO|%Ck_z;5 zCPn)`+;FTNRCFdy1R3{6RQ|4_St*ZQ`hQ|ToURFuhx#`*oQQ~DiV@(h+&)Q&jFE_A zLqLfk;9lC*@f&}SW>%p0dkk}WGbq@O-25 zyru8n9&PQMT5k8aBHBA+tw%>LDH~&53mRv;mNee)TGboTm{rsfvsY31+uNeLdQOat z+cUjZ<9_wrS2%skKxF zG4*(T!@ovu;s{QE7Q$&(nZzn*JXFqjsC4lS>+MWGM_7Q=7UBa^g-j4LwILUZxK|xe8SpDBNRWm(%7YLmfwcE zF5Wh^Q+_+3JSgjRf)XbhcX2Iqu?>TRkK4HQ?J+HjJ!#x2ZS1$9i+!r7Ub`GB2TU(= zQ_K^0E|_;(n<(Jz%m!E?cZ@mLHOC*-WVFV^YdXhkV*n{!)?2W%K{v@N zNA6p66Y74&JptY14Bl~y{p0s_lLffa^uN(fdSxf@#1UyH{b$CslOIqUn0B%i@4Aku zCwEb-GK!uvacL4y{lC;t;waax`pJ3HPhzMcx9BG^1ohwalLUh-prYKXNcCCwoxN2@ z!TT)%9c8ym+bcaGRmS!I)n8-1McR)bX@6WpSwfRr0iS;PN5EEP>K--aX*m90^BBzm z9mUBqWbgXmKk6taLVKa3oV}@|yfXjAs9SUtNj}cdI6@#O+u=>|XmldBRiLJHMxXjm zYRW6|H9K!X{CjGO;(~u~IK7fV{ok>UHnju(OT$-b@s7$9nj&93T83Ro1^?G|)I*Ul zS~eQa`=&bEQ+r_vRV5yW;BUk8gQE8J_3;tHoS6`1g*7wbG*;12H^O_?(Wqj^O1wld zZ8joMj{6PwK!1S-T_T zP-y?YFQ1jJjeKVA>or1hh|!o)(lT@XkyLmikN z^YFNw+Ti)J*T=#-A zAskOqWG$CGvoL$VG%HWCB)n@LPTMb)*bZ0<=LsFdMq{Ba>G5Q{>m&PtXHMf3Udc4_ zlx*Git#i%7|ZEpGY}#U_U( zm(1QzGr8r6GUC7j3OSEnb~p;%N1RGkoSVFtKBz)t^J^lQGfrF_DBBG=T3Tt2X+7Kn zjhwUF1+g&&7ztoxNMx8s3VHm_m1O`HfTyjEsDYRDx4UD|jokm+@ zr+*QT-P-u0T09fcspaR;AhBwQ%Q5e=(-4CO2C9%MPWsgu-sF_z6&-)_-%Ez;fPQI^ z2t6S}xGBGLnzcPAV7v6XquqAbU9ew5rZNks7DzI5j!Qg@gq;xxcAw=>YfrwqzT0hS znf7W3V!X$B&zpMZbcw_r4FM@W3J;5hT+kd1soah$!R?Wfp788jdO-QPszUACxxRJF zsX3+dsln!@wc6x z(i^dA&J**mhgJR-wOpAXDGJZV8NVwJv3~9q=ZsL<#&tZ)Y#E@kQRXQsyYCX;{WrZZ zl~l}bk&B;Tn&LI1t$QP$m~(yp`8ATtohb8sq~uC?_V5sQJ~(!w+Ga;dvrhV5VJ%bm zpOfOl_P}VB5;CON_i|=PupBjK$gweo4KPm&_P}JYZ-U;gIn8rq*eOGhe|S7AEl`0i zGiOFP%Zhz5+B;)eqp)k%oI%S{u*2@>u^U!ubx1tT#=SJkd*O_DllEDu6>nN^rR@Xl z4ma3_EOBK(4r%@<#2O`Dyvtf&UH1a^QYT>KV@RIqIFBLOiy_%}b4WTJ688)4f7I=Q z#O&_unIP$g&sb*ad^Ix8jW_bX`ql|QpVJ$h_0{f-+-zlJoW0T%<8En}WA_Uj$O{gX z!Kk0n@h`gbf#XHL{<66qk7OtQcd@fB3X z@pB7q>kSK@AlX15k)s&*&Kwiys+_0H6!lOIi{@I3QbdETLMWQ247V2D2Rh?y4m4P6 zQ4BOz&&KHTjh}j|qSqhEsabCgi^way3Rw+1=S3Yk&am*ZBB<6;8F|Yz?O~CUu4s62 zX%3r!3&qwjrKL@64I^uZ2U>a<$!Rd)G>6@8YzsjWf1LCU2`;MrnfEm5`6Kk{ux+qr zMK~-Qtyz%{*weG3JhlzZSxRfx1doM{kF0QO)@{vMG(N0hVHT#^TeC=jv4)Yn=?s$q zqxLZA7&4&@8976SwgMP3B!n_#!h*=SzK_hG#*qmDG9XNrl%XSI=*X>Ep=0PstXT@H z<6LtVjdwK8a)Vj~= zatF_2jjbz=voDrQa47UVYfD@kRPJqe|1GM^Eau<%M)7EH{a;JN4qO>U+rl?RKPye} z(J2_al>cBYaC_2?^=|7NiJ%EtTq=p37qRkdugDtG?rxK2Jted4%@Lsh^V~Bl#EBQUj#;VJdOf!#EB=_*=a5VsDHVbq(Xdp04EaZ$t6?Bdikfd&3|? z?f2*B-sp@z)=~0y)HoXbcE5AZa>q+Xv}zB#FH@M|ElVj|(Luia=^#V+M%thX-#{Bw z;guMb7_-jsDvV0RTrtSb8>Fye7^(U=H@}(wV`_-e@e`EfH{F@D!J4xXn4-tg(Qxsk zapqLP5p7N-BKu$W=Z{_qVnh0(1qTp^74M>M$#U?<(2bZULg)t4wn8hh8qg+UC{+$^ z>$>JbG+z{~xR6y$ddY@f1*=)eWYsbaWJEI%?aZt~4MHZ>TioD}bq^}``ky@3@n_l> zMI6aT#QX4W)L;B0Lxu2ZYn{H;ND{-BXcqgPLC7Rur7ga49YZK_+BIcb;^b?GBC(%? zPxZB*;XMgCg8j)9tdqg#u!2%Jd|q_J>4Ows#XrOsz4??(A1V0g8l{5=_)IH#!@$p^ zb|9%C<{Dl!aQc#quU@;j0(Q;ruIOf88t`ejcpW7qk7#(C+sK4m8J_C=)af5-+;_QG*os0F@E$()JKsd zdw=#MAjI$|LN;mcie5qo$xKZ}nwZ#$Gq4v=0^if5!#otJi7AiGjliRH+JLV3Y~-GK z(ydIl?4|PD^8}fsq%$NgMR^MLL%jSp_b_g!Ni>S`65C^O=`Jn*A%ok4JyC*l11=J9 z2vLj;|MT45Mul#hH-6Q&yQCefCGk%-<(ES_Jxdw$TPIV=Qap3F~75N9sgeTHpy+>KOW3Kzf=7=fQSKN8f1w&9FY23^S-|&^b8%Slj2d?mavO=pJ;qb@yhlwF)!znId-zDTUCPXV<1`SH`<3}`jaXM-sghuguu;;v9(-O8< zR@{EA!|IomlqgyH_JI%u+-yrqDkJIp9>7DLZ3%mOY3r6K5k?ftpS?RHcehQ{bgyHE zB8mAh1hCz&48@5DQm-_I!TK6)i(U{ehMA^qea5fpT!&X%B@)e%b&5^+4+@Tr|5ui~ zq&w1DCKk6|b0$cO4`3!MaZULEb7l`7qYT}#ez_xL_C)D&ht%-Sy1vZl+^xu;6rSHM z$9fl~oX)>Z+QGAtXCqG@Sl8^>;M>2he*Zc?k##cGFy#c^N#PS=m}rF!4!()tyUK8` z?p2DcTGa|$n!Y`ueppeVw3?005o7D*Z%iqqT zZa#dXq;Hf68y0Ogu;qJyPIR;5k0*b#&i9*j^_$l5pZy9j2q-6ljUX7noZ(tuO_-gd zSf=;ZZ&=5F9Qb1C(0xH681vwZeXM^V_kQMoHnP5$vi1;g?c?iUQ>15>Hwa7v>>A-e zr35%N`rOZdLc#h~>jD#6iIokyV;HZe=v#1s;CtTBCsBi5i9GoT`t5z+@^$q&>-YoI zfQG*0{;QxXs{bUp_$#)?kS!1{An&*Z2imwU z929viK`FF9diDXm->%9fsl^5B#*}gw#=`M%2QWoenN>JAh(vylE`$;cW)Rw*7Z0m4z){JdITLcPBW=vB~(F9k*He&jWRv znkR&myj&Sqg2yM~O7KqDHm}hbGka=DxhEz+N>MK8jiQBv`+#3!c!if0<7pIGlYE^r ze9h$8_$QsA66>+A!A*8Alx9JMb}ZUmw|RC7oh>ldT{Q|Dtl7aQg6^(TS9ufmSw(r3 zZGuv&kV+)8>%<*9688OVtg~8Yz4Xm@3xQKaT*}ue{c%+gcm5yB-UY6yE88DGIp^d- zI4TARPi=A_5D=#lY{6G0DMEbo4n#$??H!?lXzLxNwo+^FkZ3U|oq^Df=%u|O77Zw^ zMyFCOGl(s<7H6t;>SJ)KwoD&dYY`O@a{k|aPEhCn|NlP!&)*N*oc-Q=?X~w_Ywfky z#%ks&r1M`K#)wlbaV|aWO#N!AA1`rGT(oXaUH6>LQ|QX`0{{M3*~9?*wx=h$onx!? zcsd5jW73>@oQCc{_g^JXhC{QKW6&O=TbmcMDVkB+h4ricS5Jf)c9! zQm6?YnmJ>>dFFq@z5nOdf9}OE=3(uifK%hH$9iyQH9itQ4tH7C}2CEe^usD$axHD^!^z9I8u@ zU`E@A1fW_V?#%c1X5N_D5hlMs3$@^V0sf?3SAuoJQD`t+IpNoFOikT=~iYty7 zDKC&Vfvh+92F@d^VUieq4s{`H!+>_aoU7lgF(qcG5-ec}Wl@!5sFd=}nxquxW=%>q zhkod92}W8Wqx5(380UXM? zp_$=utKqH#=6{7l-Km3uG47fR?{Wm|-xjR2b=f7BYL4q!Z=}6_sN#~EYF9V*iSR$0 z+!X~@5EZi5e0rDQq`i@dZ)STgSZPl@VfLit-;2r5B%_QtV_8=)ftKMLbb0Ru{U(xa zKuY&DN!N5L86j~_qWEbGaE9O6zoUMS=Aau%Oh_Ig_wR>Os=rEdBISLh#u3jMj_x?b671cX3 zn2fkf48<{16egAmPBV!uBUwKoS@Gm6wV`j^jW^N%j^C;>SHo5Omjr_e|0fvCKl+{^ z&70!GA`JEiV6a1BZXqym92dwwAXe4wC6%?16kQt1yjo}`mmKW9B*6YH`QOTF4^rSg zOTy&#%On7;H~f|S-*k~O*9%@&+j8$gKL|CR{i8ryEzV23i{zPsqp|Kmdx0>eoD~0i z5{Az4jiQi+`q5Jx>J#e?- zuEG5ajzYK^?l`_Lpe#3>3(g7m9^6N8>*4013=JF$H;D8){Qe5=8ovLGyqn-&hI;|d z1?PtQPq;6TcLCfJaFgMR;8w$phqEAm7xM3e+XZ(9?h0HD+(G2cfJ=tc!DYiOfYZQr zA@2b4UV%FYr+_~Ut`pxGa4V620o)w8a=1#k$#5;md#%Sj^Pg~M;0EDTq&)n#Azw1m ze1z}!@ck~F4t_114`E(7j31169^E1Ek+GktJdunql66JOuSAYl7SW0~WS1uB@Ys4j zAFbV>>fWxe6_SXwsH;-8d_}PS50Wo?ql}7J!Rf0dZ{_b~P@#vvD&T(={v7x#1O9dJ z^KNHikXrAMg1X9Ni72l^@@4;4Mk$g0wLtpkkzW65Ag)wKaT5-0p}U|Ewq5^nAb*i; z(F(PGZ6M8WWI6Dy3B)}uw)MAxxB?k4lQ zaQP1@pesde%RI@Kog}qob|9~Tv?V7H$IGbnt8{iArQM+D{!niTq=^;l5Mmtzcm74XY22EqE2Kt8`z zPf{S?AlAh|J%&K~TND}dxIp?o3iWV$T_F8+q}PuR#9gICIR8Ka&e4Iee@QqN5lDXp z>B*pn1^oY%Y75qn4Wz$-GEn~LK>SxoPwEQ}_)nAif&yv(Mp3bJ;#T$a)-V1Mk}#3( zlAyX>FOw1|J{9p>@Qah7@|ajRrymOV{{nxYuLDw0*I}`*wHQ1OB~IKd%PTdINQRAMn5XpssJFpsxQF>$(!i-yqfXbs&8`sq11O?k((l zmc%WzqK?d^bAkMSK<9$>Uj@=sOXK)uAiop&J#OdjAhrH)f%F@x#GqjP$w2xFslF3| z^czTh#{+S%P*lpOFYbkCXNGC36{xaPicq{roekpj9f3T*MV>_X+XMdR;Sa#`Ln)}M zMC{`s$(Q|{ROi7!`rnW`eSx@VC;+q+b-gDAC zK>ieQ`fUxw87Y00^r=XoAeHCFO&qgt;>M5o;Om#ZuG`yQy;tuD#E&N(=kyhUa&Xp0 zC9Yhie=XqGQdC2?_U=#)#`={&*jV&sUEq5($sVgce~-+nGAU3IMCsQA;z2Vdt_;_n zA0+WF1mZylKQDcb&=L$R+`1x}#3)6*kt6R&B7*zj-pq!TB27eSlBaVBt$~wR^T2R9 zt34Dv#MqQn@WLXNU6hbu3S(@?gxuWz4-BJi$2v@IS?<)jNt4!U(}D<5c2|JDnRbWD zerBjZRfv;yh0QSbk{kk!MRiIW%f>9s&0TBDA~cQ(g{Gyy73m6Nf~Vlwc4%6ZoF}q& zlmp&(uiB)PtzPKnxK@QLo~cQ5*#9!*lhgYSIPglP%@8J*n!%!3lSD`Wj6GYZsq216Jpi&EC#>_j;NQW*k@<+6R%OH&R!M zSBvET<2eNM0sgSpl*7@9JJkAx!$uR@TRfS>Vw~`8NX_9iposrt*r~$1%e+cP5}1%6 z5dWx{H`bm#Y*bZWaj016+Ukt%3H-JUTxukVAAPtkLrMFts^(c*+9!vI*xw8k&?N zem>N%VndEDcP(ZOdCAT`e%M-WvqqY%QTKt-YsjhoxSfH}8+>LZ;6bZl(zG_wY-0InMDO}NM5!pJXp)MNKJ zx_~j?7QEp}VM#2%j0j1<2NvEGniWna@1~HrV~m#^ON+cKs>u$aBwkzYWEu{8cHT&u zoRnoTjv`4|%KW|1%r->B(}i>a=K5Pf!BB)39wQZUnP6e>g(SjT8W|%mwlzrs7<%Q^ zCkU5D3KJPlg4m>=zXs2(LYs#Gs~d13tFe|AQs&QvyyIZ*O67YGr`Fs9ldY|!3O^Of zaEBpXAu`|1Y|-h)wxiz(jX#if6`v9l=>G&L zQd*Bnzy2OR?kD{4=tZGXlos^Ah5fQcR)LR31RORwIsSqhD}4_PLBk4wtl*ZBd(JXh#(5=J)Y@QgvCq zrg{pmVUwK`RRR35d`UalAxl$FgA?6vDob?4;gRbO*nfS4wYZn<&Y<1aj_Dgs7K)pX zS9rNJlZDRASZlE`CJUQ6>7`8TOD2olVj)T!uab_Ca=nm^?1MvFA-hUa;lC+_iz0Ec zcE|}N{4@In2ryIc2@YI1je|~3IQnD%tKc|CTUjq-iP!ShTx7$agzzZOOE=7c?eydE z^_r?E`*(sr^F{q({ogz!UBG?S!UPegUy zQGBw|2s3&^x~jSgQIJc<_Lv4rcb?Acq%-OKM=R^2Hwy9O) znfP-CRa6{A0RD%sEeK}OE4J$JSrs&Lo+=181p>DNZNBJQk^G=_E`!1TtSS-O1rFN zJ@dr{dw9|w`>A3KgCkV!u#vM|M`*AqF34dcQKllI=0=8}+OUTrbGIOryVfeR8OVFh zme62(r|sp0Ne_PWvt5*?z3n@_Uk+rvV=n7iT~|hQW5vUiTx)$_9n5? zj|VF%`YWu&qDDoQ&SK#*t!uft77JO_tf8LJ;H^+z>*wQTeCGhaOy-#%0KWYsndA4p z=pEqf(FAjIx3)-B5NNKJH2mk-H5vp(dK=BVDV>fL;n4Q$h|ImfQ+B<$zu4zPQrdVA zlckCX@G$y@c-}rB)D8&SN2U(-8?1xJ{E%IOD zV=vzpI`-;L;eUyOc}_{zJPOJRJyl$6U(-vMuM0@l%AL9{F2#jnJa@t|i;OYgTH-8n zB+XBVoRpBUIKNZ{3re%dpmQ&@E9N%g!@fv@VLQ90$-W@)t?UW|b7j1rR4FB$7l>9M z>Fkkg3i1Xr4ET%`8%S#&iAUNeM)G++39(Njo>CddS{9Oq=(210uthON9sJm$1&jEF zWIT8!r7Q}zyh~>sFfy6EPLxX%KyyFdoB8B63t5XYG~6fFPzW&>F?3}qwLzYn7E<6V zoy9$NX`u{;2DxzjF4EGK36$;F(jxa4OBaUZ-wG>i_Lt{t6C=vp9qZ~M=B_372gvOF zkg47h_-^_u1JYYrJfR@J$bDnwLQcEpicra5C6rgsA!FI1jVYPC(5ZBsV;rj6%f0^s zB{TP3#59ecDW&jgZEp9XI^EnXkQXc|{{YhvEq%SnaxpkxCIua5tiFOQZt|rpS&p2} zPNB>Wzv~Qbi(<74)y^z!8)Z8uv&k1E6w?aBL)~~?Wn0YJW|N7zx72Y?#?8yUrMse9 zfQP|x3ViBN09g!Xr85iBHY*{ppobe@hF?@%%=4c31u)c+SL?T-U_*+?9oUvF@Wkh8 zlVy2Z{RMJ_B6T1l*At)D5S{n>|2;g<@;+Ym3zV9w?5S&5pW)+TIF@HZaiVo_qxVU- zC!B!Bq{IN0uMvduxq=XhF|52O2-mL*?}k=v53zYG?*f0kh-X#NS zd05WDl1ub%rw$ZV^!}fP!)^OcZ0y*#Hh;S6=r!TQ@s7hA^S3Hbnf%A;b5DKI-ElFv zfivBbH`^RYJn2f2@ph>g7Z?&kkl;0POA@x8cIT-Q0mG?y;* zR}6?=LFf2u`;8m@AHWFhUq7#I^Zo5}E^;4M_KLL)=T3bgaPxfbOn{nQRAPf&@6A0?<8Kz08?4)-$5;~S!&n>W+Wn4TLYBAH8GG>OGY%$|k!M#bTGcz-5Gc#F} znKGH>CNnu+=>B%sRIsYMRz|)-Xbn`v8M|mI#98A}LGhJJ$WT{g+?Z-IF;)2+{Q=!( zQ19HP&68Q}fb1!`#%V&q)^PYAO9e>+~ec@d1~G#Med% zxNLNubp>rGi3)cdzq{c>hebBc;j$3Y2Y(pL2@a{alkEQ-fW{XoHDjbyagX)I?QEEa zi_tOEgd_G;5@m|pIYJX*&imp#58{)gc+W%eZN~!9zRzj9p2TMy8;K_CdE0@9;-kd) z1OFC3Qa#B(R*b)2Jyi(V{wz3@vwv9N8bGtDND|KKu&6j(y58f99qq7?z44-&n`gfz zyj(rYrJ`Z@$PEpz6S_W?40_WY!q`g!d2>us5^V}Hi-F>ftdhU#bj_tt z&n0{E+#`G^A;U3$mXrNLqQz3z4SU?7rnpt6xCju~J<(k3sR6;~p;`&=T`W^(%c)do z9jLKYb~2Rx;&TL?j6`3e4=Dv0W-bGRLxNi4`j%O-%*Bx%LZsZx1M zrG1b42H1)A&iywwc8>o->)G1UhULjPm;^+X5Ub^tHF1| zAYetC&bv5$2j@CZn}T+g#4%iCyDIf%e?8+m&$xn~>z=ZAYc^(5mC5}(Ocbb6)o~xS z%S>k35#C{@U1rin{Mp|k^#jJ}r`E;Zel9z2{IKO%SZ>xnC)-fa7TFL(AQ{Qpw~~Zb z))L0f;VtfWbJOQPZ0QmqQTXx$t= z3Q8i5kdjD9G^k3JawWY-=zR5Un=*Du;u4VhxSb=;H}lYtBfhoU>WF_&JHTo?+1vAH zlk)xmm}mOpTN{p`q>U&k3{wZKBx>+l;61S6YP}i{zHC?Hpv(m%hI~UL96h1xt%nb{ zxxE#gp(4$AozPHZZF5X8I#neZP#Pl5{2<18fSa9BP?M&cQUFkoyYVNCy&wo*3u7n& zx@@ax2xV+MmH(EE&@}fhe>nWm*f*UK$KHNqh*KM_iQ%KM@WN6VZ)Z=~p5r5La`lL%l$lI$lsb`&u^)k^ps z($M7s3gGCHs9@-2lk~67fR@Yg171$Ka_zieXc^;eNc;97Uk)kboWf^h54|1XJ4gFs z*y^hf3M#%TfM>Sg$B1A*|+atCT_Js2{8yN%v`Lln`CY#YmON5 zz;o0qbhHpkI}mxaiTH61P7Fdf6Qbzs36$skr7c;W_rKoF(%CwyQ^|I8uolC#>TdkE z{LR%!+!cX+C`ViDJ;2SiF4>o*JGE(O_7cliv@2^E(U!*PbFy36`cbZ=X`k88*COar z5LNrf;rbvC=HGd_FDUq2tt)y%E7Y)$?mJRbKkB&Ml{6KJEP%(dQC;5(<-iW>qM9t- z>_ryC{F1PU28;3WoT89+X6Y`{UNW{D6cFE%A$k3Y>ci6L6c_3Vu9+Vj;^S8~@07s)VMl0P0fTl*Sk~=UNGf#hg@t5 zjSZWBSEn=T?D@lxAv#qeeORP*Vy%>Ag1F&zjz(&;T8`cj zDCVQyFg(ScLC<}$y2Pm93)%cWGBe%ZVE4LwdPtg_KLyClrX3;-`7c=u9j-rhZ|@n) zmyCt|&9w#QYl4lHas4@O#rSrnc-(c|3x_{w*=f%&!aC8tl1#a$BVJRtD#%x_&|K(o zlbweQ^IoBO4L4Qt6#wkMs4g&@{w^~yD{gO8n~wcz(!RBv8chB4=Q(x4M!D(O;3RiH zr^@hEHEs@Nv8+vL0a@~@H&$Mcd?2972_nruu?N{)wAMuY;!JSRMe?_7ySh=@DN zpR&*npOpDV;TIBWMft8f{wUO(ZyQ|XyaIYb<6C_SlynJ3?;tfP>uFZPop@kO<>wyf*L zEv@Z@Ybt(5cthQ+Z!8mFG$B^KZR+^O6~eY9%ZBXwB8N9YyPOFz&N_}mOaGAXLq z5CKJ41>#7$<0v(d#mMcQTK;9j9UEHKG~rNzzE$3-MBD6tAk8XMGG(9As~1)**4xts z8|dhlf^909?UP$Lpt)^`w8gm_3LVlYDn9H7yqNp77;=Sn#YkV9N(EnBNV8u1sw_{C z7(lwjKz4Azr1t|qnr(|4_t7Vuyj|8RUo5lb5#R-)UmD#mE4=CV>dS9mQc`Pv@s}%` z^@}1x+SMnKxF|t`be9ysG+&&d;pyWs4Y4stP6t02Il~R4=cbJ`)KDgH(?%LrTq=Cu1@2G*aQpI^fIlbUSe`hyc)E@)nP2Axti z$yJv`yO|qRX5h|7(5EACf7xb8I20aS zW{@3yhxbn9vSC$>S!ok=$SH5!P>euVM=9Mc~u2}g3-f=&2 z7^cknPby-!dX5sI5*fRRir3VOc7&e#MEFVZV0p+?DE@GNd1xJ(LBEF$Pfr155*LQ< zP@5+7d9SU2#M(MvY?n(V8paFlqGp;-02CbZQpa&Wwv=Xl-lD2D zb13i>Co~WpXmUgn+xt-4;i(~OaXFprX%0Q3cQ}0w9sU$V)&n^qK znj)5s26G|xq$yq?mAgBvrc7(d<~L&=#J{) zSreBi8_iO0Y2tGqXv%X>4@tEo%yq17zB=x2D2$@53@Ar9zz|9?jonf+I zj4(y0jhd-^vtB37p+#hJ@BSbib7=RyLE-Y{!2rTf5A5N^<3*fb;~$>)D#yjX;#8)r ztN66;Lo@PK&KD*^c(WHYLNDe}NChbO8<5*HQU&xX8i$jYQm5o4rB2fIn+)@l%;r;f z^KYttSR*yna6n{3sv4YVI!dy3LGEPgEaI{eDf)g#>8f zGu#WYTQ4$|ILmKWcQ(Sn0nplTL#ue3dcJ#x`r_!CWM%b*nD4d-Y{sF}_8MeM<-$x8?bkklEGnec2h>r)+3= zzRg?`gENNTbt>O3bN_e8t97KeX#Azs{bfrCvaMNlH~*a=?TVmIcSUV25GuvBgn(pg zOO-^e+t2<6+l$NX#C*`W#KpEg+VxrN_UQY>ckdI?^_tSCqOR{(L^j|r z6}V2>wrINomw^`j4?9nu3n729(UncOj(R!79uWK&WH-JfxEF@hEYy*f$*6+t+BHJW z!jQ$;lAkNKiFNKx+Z)-=6r`S%d-cz^oR&zAeWYn7P4Lu(=V}AVp7O=NOHzn3A{1fY zEvAWo*P;mq**7Q6B%M(tj?;#+sq@L0co+(Dm!af#S%w~|=A=#0H_JQ^&DACq=4$E_ z!8@C3b78C`P;sWr@8P_|r5` z->Y_UQO?6g6*!9|Rjh+wKfx4LxG<^dyR6;goO+xNx|#b%HPtPK$5wG__w0yg~El(Ush4 zi@slgDsp#*aW+H|ct_8;7%eX`DtR0l6Mc}>pM-1~MRmJUuL@HLr_vQqx%h;H#iR7! z3rSd95v0%|K>^QC=~98AH{#9`fp6xtO43HE`$_%RLS?@oNnv-*n5fov`W>3k?hL0X zV&DoFd?@r+E@o6~Fc8wga9k*kqbjP#;%o_9Q|&1zC-?8Dui3e=_HV)tXT`L-Nr!ck zZh!C0?)Cc51%4R?F#wuDP=WN18_iA3?C5W2bndliNH3Qe(Alpc{8J&7S~I}MtHcJv`42YekA8VkU0~7anuv!(iG7__`OpQXi7!ykJS4zg1&o>Rv0PE zuF*i}t!e28>B@MVnZVSaC`&i79MoefGDK;2n$G?U5mf^Yn?qNflzEcac2*MG8@?eL zGh5`&skJ}AB3;O~`lp;~hjBiHRu;&szny9IdBeDVcg5sZRZcxNba4!N9e%5C1Md1y z#z9*R9|DFKXKkFuns#W}m`N>eQ*v$flcrGZx^HCS@Jetkq^z3qFl%UOnDu}&3kSGX zpEWDf623VMNT3LzPRd~VLOe`4k2{ju9sZKA(c~Bl7l%6k6AMND`eBG*2({9?-f#4A zj8UW0O?G?0tGnasuq?yIY6Zr}PNn(_VijqpZ$P7HJwlF3A%Veq#d@&+&6p*wDNJx{ITaH%nJsdu9r2PiH5ogWXks&Gi6 zoq7|=v;QhsBfMel>Z>iC5yAGsK{Tx(!Ww=xYj(ITf~-818mcrR^PtX?AS?PryQGCw z@QB|T;f-9{QsIb~b%q6ZcK--8_I<9kXOeL#3JYEdJ+}VRcUQ=|wK%hH9ch>_Y?bBr z4{~#1=V%py0wMYN4uHTK!HYCY!@&)=_|D|ag=9EP@z`7s;+#(xM`k0YCH@z2rv&uE zqoD1VYoMn?GJHu7>HFfpj(q!IUv6K(t9@jlyR}{ZMUw=pYFI*H>74fTz5`w|+ zxL4}9WMn9A1p6fSxXG#oZ__u!Ft>M};hH-mCp$nvt5)1Dm%EPRQ~(N1{a4)72Tr-; z_`Qlt{rkU!(h&D7AAA)DXXomS`t$bF?z%7d3SD=_iL^;K`cv26;p3tp$+; z;oS1Z7CxCAW#g9T*3TQImKs)KXVRV+KO#l|F;?wOMV$TOvly9Hd2QnWIpJZPsjjFE zt^zWKd+-h)Atp#iKl8VlovCxrxh7lySHY?8s}wX;`F6f=GN^Tw(|ovXJ0T10he?SS z)v!F#xMItu9j)!EDiTiMy~yue(@ri&H7$m!4f&0lURy0Vg042Fu9^0>aV zFhL7b8?d}2pc>h>i@Y5xI1L!c1D zvEk-aOX9e_lR1{K1hZw9hsX?rV^q7$9DmQ7YVF7BKwADFXRhCu7>}llw7$qOOYJfY zD^M|XF*n^$+xy>sZo)`Y4QQK4Gg>v)Fw4i0&8e9Wv&{!$MPzT~ewiX%{^ri9DI@g$ zZ$w-tlV^`aD-cQq$1nh6@oYKPA&8l#LPq+Kn91;rJX;C1D4C^zirr)pg-zCIYgi_E zp!mo8YAmG89Vjyeda{3z$~Jf3#`0^6XG?HHH(uWJNkF`=)SbHZ5Pgv^=NsuR8&!V5 z8Ct5e7-fks%jRZn7z}BVxkA-Np}{*!mM%S*3CrCeFjY8I!!<(K@_@Ms!vBgP4?Kw2+YmCc9zTDDH(l2U}(K@dW7{hJ@GD zB?T|{*m%Pf?G**&ZU^Uj>=7icdN4ae(itd@A!iKQL3VF`_Z^33c}#)Lwp?mgB~9ty z6ENFJ7}en%>kw%_a@;Jnx@r3iD11gopV|c|-anCUGRrGv6s64{t|XedSO*Jzcc#z7 z4O_NUQ7&I$E^1tX*GBVDhW%g9pfOxEuo^>Rze0TW-FI5$rK+N@m?G_lqWKSCRJ_%{ z=Q*6f$E*m;JtDhWSD1gh^GVJ#pNI;)FXL1r|1fLccBf6{`v>C-zEu)4$|JrMglH)5 z0&&b(2u`144!3!X7o@1xpibk3^J_>;{zU3Nx5hCsddm)xE~Gle+^s=AeLxsd9PV$n zBQ3_u@cPICUCXwEscI!lM(!>({szaENk!C97KC3A$PmJ=TTw z2ZBqf9mNy;Z>U>UlAN)(J`x5#9I8^7V->|Dw~YxY-e$b2@jx!}azHL3ce%-<0+L{4 zT1y)l4bm?)%5xz52&?)OK8EUr8ERFWa#tqp&0` zvK4C=$=sxB-ZUb)Y5KDYhoo{5FilDRvc#lk)n}Inalr&oq5fypWeMZ7cjTOsEL5+^ z${t#%n$odQwaVArz3aS#YC3nm&@OYDPaHpOynqYNwnU<#juX_x(O{OyZwP*$Rj2-Z zmuLbLiH0dr<8fDrp`d!irm=os0$=?PNHQA4)eWsEeqSNACe<(g790H;3noqNFfAa; zS{WJ(5d_Xj15F^w01-&mmiMr~Prw-l@Y;bh=t9M24O>8Oerr6D=j}Rsx#jkKVV(rj zrVw$hGw->Tk(C}E%tdkusOPbcNtJc5n*`V?MQJzzpm7ug2;AXD@_m^vbgDEuw(2B+S*mSSRMl=L17r^+W3u61$Prn+ef#|SP=tAdaN@?r z=#CJ~3!KD_oWY6F`~N(EiSr%zIds^EaDI4~7@-+W|l z-z%ilN}N)WLxA48NBZ0iugC^({iKc^)k@E+ZXel|b`0axdo=e3w*eR1k+k@knY=>w z!mV6BgwG~r;E(k9i|N`EWF5eF)1F7Nq4D(pcCfYxuz!rezmU>RTB~1p%V??AfHeai zsYyFRNA;La;i~X`*+$aG$^1A z+4Jw(klYIeQFDEsPvViHD##HA1IDMD=E~tJFEsoHN!gDl|1jP^iLSbgDhcQ^hb*8N8P^O+v-9%p*X6EH2(8F%-I2uw zEjmKGJ{im#=--j$a#KD-{IG%$@)wEKS3%PS*^JO zT2mTH9-3u;CUT9Yf3TIyDXQK+LMjp}t_lZ8<_YIeM zGzUDrU*jf8$adLJOhH)esCisFTNI`)4_~=%_4jQ*HD_u4sjv?<|=(s4| zb{WdABGXh1dElx2_zq}_5r4Vde)J9==3cs7hoBYmg8c)Hct#1|Xq(|Tn6cAGaT~s` z6%n>rKeLrBI9Oj>qhoLIvW!`U&k9RLHYjNpr^z@)g=RvdJZYhnyZ5(`5OKtv5XdUX zc?UWFE}%u-3apIM`FG*1!1?!|S;F&(+KM#hU%DY8;aFCRJl&xD=vcymrLaLcUA`!^ zpbK}1-1`=V2IKT;QD``{5%I5`m1GeA>#vM>*niqz2|YOTulp^~Wn?#1zs@nHtW9WK z3f`RFzVsS?a znb}I?Aq$b;|Hm*a@k5v-E-%GJk8qlCH`ap})RX?(!3RRW1gih}u7D1}U7`b^y9I?Q zf(h@5Fer@chegut%^IR#Qj{QH%6AmGY2B{6BIuWhJbo7tLEO!XqajS=G};|O&S-JL zA7*}1xWcgRKErvUgLgp1_+VzsSoEP@1tF=MCRflQvXpCJ9}Om8_N3dPtJuT#S3RXx_kJdqiBfzWJkN=GTBj zv;+r`fDA}`l2I*dNJjUuxy|FYlFaY0Ty&*{s-T_1=e3?*Z!30c*qavvcu5Q0P|EPfwH{gkf`Z+?WqBR8l^2lkPG^=xQjOUfm*!l$xeX`<-EB^SiTY%g07b z@rWWxR=9cQi{TU%3K;%3_!*ACvXR;kTOu#OK$2a#glsel^ZEW2wdL$`4Qr*BkH{Dx zT7Zn<_5M@81o*Dr%_0oN5k1F9GKSu7nd%GJ8XU$t|q|ADj2Po7G;=(Rhbp zmBSc}IxIMb^jg>zuEYzsH?b2o;T(?J!YOkO(`6qIdTTB2oh?~AvQ}no$#VQ1YJ68Y zMM)H%%DBcA)RflES%)3NV(@GWqg`Rt`bv(S_wtrkJce=ZWgBy%)#m@cr2WE<2d#1h#)R{%`11xKfR=+R&L^gu)M~u-O{pj_Qp4YwrPV3U%XSUNO^g6WnBwu zv>Ev7rqb~05}RDLXJO{73UMYj)eJ+s$W3}{_;RQvi)b=07A_(FiY$VLvl-La z(WX2i`!bCUv5XyK${S_ElE7H<2xovlQvW9ICoFA!N3~OAv7;R?VyDkdU*{c-$U&=J@{ zVXCi{y&G7{48IKF&;ryb2_&#P@I*tgNg<16Gt+b_tVtwEGkm146qVtd$IhOQ?`*8@ zkaAPh$5_*_r%5*gVd{yL@L6n6vbgSuAij$Ge%h4^sNm_}>oZzq@Btu(h)42X>vPJ@*M(GafpJ!Hr!Kpl!D!Ql$k^%Cu;-FmI)Jp$D@VKX+XfNd)EKKx z9#kl)h|;JH$0j*LQZEP@j5p08)?Yn`6s@KjOdft(^^f{90#075i$p%Mi4qUN0E?3} z2WwoS6!GF{0$(ZYb1AI2=RsKPt5TTZ9}k6XmBM<@i(%hy7xAu@20lUmd!Eldf=|gT zj)p19KBEshe|XGRveDQL7D<5X2K-Kr^sokX7AJA#+V$N2``9ETffqA^OGp`&vs-`@9&vtX~P z;fhn^jdc2)s>E{IbAp{K3hDc&Rj(A!kct%|d{#=j?A(Vbs>CTun5#;5;(maH6z9nc8v7`y}wFB-)z&8AUo$JP`EJVc18Kneu2{*N2|C z)I1WkB!n=O8A|A3dd#Hn9`_Tb&<#lWAWcw+uJYlu@z|@j)V*N-+n-lNfADh%}%l5LR; z@P9DFYM(yD{y%~zW_bq|Hht(72ssljZ25h~#tP43vO#+IaoS`m|4)=HX1VPXneAyJ z$+WS2NqESHTOKyvy>L=)E^Et*M=%!+b#rtWm0T$d=_@1~wY`dHkRMmtdgPrwOlRzd zQx4&aZVxxA;}dg7w;O+D@?cz`v{#UHFqt{Z{@>&)coE>_Uf2O;JdgYR7LUzJMx=~> zuS^u|5u^BO*=Z}`$~)rcx3QT80tKPk>Dav`qq zCDF-T3NNR8?^2U^w~@~9ncphFh3QN_#zv4H=O>G9VhqMst zNo$RMTpu_p{xoBvZzFYaZg6@FS!sKJ9XvzL(O4h0df;vPXnYKx&f`9Q8SXpo6wEUl z;8JQsGkaX2nXh9$U&A-#DyczzR$EaVWs9FiO1X>0p6EX_{kP}KKZtuxK|?ub5Usx* zw`NV-jf_PNU&ew!b;KnRDe7}cT!t$SPq2H@MCTOT6sww2mbj|tyH3ai8;@VUOzub$ z1;0xp=O2G##Irs}+=2!tNXo{~E}skBqdX_llPAS^?>ejvp63?el`bxzTiV+j>}j0j zez$^j`nNFBSZ4bQmGW+Rr<>yDHhuHU`kdwV|SP4$U(8X2X1p!nP2lZ-Re$l@vm>>iL!^Y_5TBH$oP!A=x@|8n_T z3N*M`cOzp#kAtuptw5a1d0>NaQibmK?h)}qlzTK)P6G#XL-+|=JmR%P65q_=y>H5P znzqS@VK*fh7I#SSD{(e{!76p`k`B+8wCOanzb=2@OPq$35!l)$5l! z$L@1_D*kAO@_B>N9@X#l0{`}H!PUnOx7Uq1u&;S+O-n~ji~Ea~vAYk^3O!|^gNUvt z82MSV*FZmgcAC`BPEG+u5F~*`ij>W!JAS=lZb}jI0C2&x`8-)ah*WU7q!p@G+kF6zv6#e)`Ww zn&(r*G2oY_p!r?1s01yYoki_Qll0c6PlF+w7D-b|p@H$Jm2zxMtqNGGo{PRV6*Hdr zY1}jI;^n!I{auey)~U*6#<_>N7jU-xJOLu+Gq)EfE`#JIaJ(gt5HA5pJv+u~DqNip z`NeD#NKsJ2KKDM%Fk9ALq_CWp+1vp}y!aizE$i|7Ih!7h19mZ{#_67-1)fe)q&=5Z z7MRM035=&v#Ra#B`}#0#mi;`{1kMh8-8ArZ(${qH>m*&k?r(*Wjv1mCyRapm^6mb@ z5 z2QCKolf~FDUmS$eQ0V&A-VjQ)+nlM~22!>0E-Q5Z>{K|67aYfBw~eBp zSm=3`@~tfPo=6&z3zK5ALTd~muMc@{AP$l{$4@`oxOlek8EP9!Xe*> z07Wj9t)e!+$vNcjllXa*LP6s8lepPrL2}43Gez8fp7L%t`$NLNC?HS)S6 zX`FU|E77RX0EcQQTSn@4sFd6m$CHat6%>_%U&Tq`x~YUq$VS;z+2{Af06M`45;9*R zA)8(%XjJT1`j~vr1qMpzXwf4TpKkQcxrvOiE%=WlpjG)N9wQ%-sMT;NBj zfa38Clys>Q4Oe!SDN_ywuM?Nsva|9sB{b%P*R3|zLDr>!u}4k=XmflvzQDYbzH(Q} znEb?TsT|G4B81blbFD1@Wa<4@_Wnwe^ckEWl4Y<`A?|6tp9b;FqP@I_$tNA#r|nsf zllBF|ew*Nt$_`t%hA)i1Wx>JSY5|%I(;Qu_ylB z#2?9V?&><=V&z&AwuhJq!j-T^)w|2YhLSp_Q?;(29F6%~HkAQ2J{<+@)Tkt20yzSJ z+#;nfi5MM>yS!@;P1PVPWL%W*^LwJ7PDmWninoCgj6U`*pdETvRN*jOlMcF+KoX$J z{|QfezXm>plB~Cs595j9yfXj9*avtr=HKw75*Yq(Skje7!Gx#CF#O`*f8Bo{PkQf( z=HDfCn>n;T<`cuXc8)udL_kaM>e^`B6jf3B5B>R{_Eml+=nwhxcmGZQ zfnPC0$fv)ooG0WPzkwlnLJ7Pj(L{RPPqx#tdRvkFoHi-aa$e?O7gnrhwaX)*wMEsW zyCJ~Q{>Z|%lX%)oYWWaM07z#<5a1gDI}p7AUm|??^j%;&pC%+UM?@t^X83@XQW$1w zTzV>IbH(bl_MGb(Dv|NMz3#x9qLzRm_nxF#al|kBi;wtI-yGy3%AO>PK&tL1C$q*i z*s6rk6@CDv6(-6Y#_9M%B~6FpFxP$ZGo-?`a!E;n zlP$7&`1#d8g7c0egbT=Kh^dO^bK{owvaO>yu1|iZo#^HufAQPnUbvlMv&AJXT^M2m z|1B?yn`RUFZ%*!sTd?@?MbQ&@e$^t^I86Dz#*3FJa&4IK;JZikZO_u+!Ho`(O19?~ zZ1@H?@i^`R&n}z2i5FGdr|{|R#L1{)L~UW1S#j?4zbi(_Y~uMxu+Fxb6U zClXJ+r$F(5grA1HT1e6~$zQ3bD9Z&Jx*=J2xk$?end2dvr`V0FJ?%6Pn&-I%b9bUb zdnmyn5IR0L<=$C=GJ5}uHh+3e7gvG(-w_YXX48;hZ&Dgc2eoq@x+Dchu^$(bLP$3}Q^7|L3ZT@;s{E`d75{{;G!owCnrSCX zs4OQd)!WK&FdP`*(WlP~zQT7P6X?8CS4IBxD5yO*mtrac+h)odLY4RaeQeLJ3%tVXt)?jDYQ3HNRP%dha~hn2w250 z!*u8ueun;pp+82@&S7vfD&!US`&Rts)Se~K1`6~of%L5je5(yp-^O?3|6R?Y6DAs> z94>51s)<%j(VJ-Hl(8mS(MQX#(Cn!n1XCepI!#y#WbQpcTb?A{vh0kUV`*hmmtG^t zK7#q|2BtF4c0a#Y1LF9tl28J3@a~dsL{Y_tgBaIdjU` znzRmxZ1D&4K9w9*QkQ6c9Ag>z?-cXMNQygJ?>_|v9%YcqBJT$Dm+>WE3S!!|xr~Wzyjo<_FM=RBoT_xb#coo867?NdIJI6Vf~lx9lo; zXM(mBroYA(6+T{F1ZK%n_*l=!pw-{K16rL8+|#`?)B4( z+qk>ATi`=OVtAMSF@8P~j`K+g2{tQx^gFZ-zGFSy>88{s}Sot6311m8q(~W zuLbO8+$xDwbU)Lz9c0+v6uSe#F5`u5v2^xZ4Ah7Zw>k8WPw?75&ci&eZc)@QROz}y zFVkIbFs`FIkK0{@)w{W(sBjYcK$eX+MiUUMaOfu~zfZ-I4-lpk<%&Y0abo`?DTbSe z{U`d=B(jbcuOd&b9o5AWl}1D9=rS}xhH)SrohYepT`fa>VV z|HAcElYX-2SjhfI1pC{h(EOI6u2ROt%3_PT2z!jsC8v_#>p;_r3lYXL9D8Gv_?#oaa2}xfZPA(aVVG*fsMaA2f@61I=$$*WWV8?{0lp zJE&dpt@e~|YNsx_U6nSfT^a=B zah6nR%0FX97*6-w8(u6t(N4eL6f$mKkKzT#kyE9CCkzk#m8)drROcn*bx9bB#k1!PRpesDCCq(%jqx<06ub4qg2~AZ#z!?AeU9lW= zFt5DDe{@$YA?KAN7Xrxy?0wTiCKocrUEV@WC8ZZz1GB$@Qjr(tn4IbfDKBGy<3Kt-G79rEw%q0Bl!ORDM+cRP zex*Dnfm5ntbez&F#*b4fdy__EJUwif44Xe?C7l~FDxnO60@12^r;HjNCCU&|6N_S) zJWQO(sHpjHPYI&zS1Z-_7J-WkduB*iArp1f_nQoyWtlLv7=;Nhl&zT+jyQ@8J8_1W zqi`y_v+bh}R()Wvr|wWqPe|r`1~Sb!+0plz@*JjxO-TI?Gn5ZMKMOc=E(09d_P%v; zv+ZT_E#RhEj6s)V{!lI9{(+mRyl!e*v$p2|n#pd3Gm*&YnUU@k0~@#^gHEgQZHWO@ z_J2`iskDbCyGdL>Wu6ApL-*UXbS)T-`svZ|u~PjIPha-5Aej(H5TjH+7e5FSG74i#BwOWa*S z7__;)XT+*VF@NTMwxZk-k#yN|6ZTBO$XOaxSb4 zF#{g)s(uDu#2c*AyH?r1Uy&!0zg2buGmYB){9Q>fCM_Dp*rEZu&?QeIQ}jj{9uod~ z73Qu&B;|_Dku*bE0iL9IyAvC^qc}(EYr|Dq@PF^y;u#nUVrK3=&l5Or9^RYs86nbC zv1XD7E^R3%)a+zwvA?eZAE~*4eD0dO)-YEEjiRbr31(32g<1b8nag|c)|Z6YWa?nB z9OqE|+^S+9yD)n$l3cpqdg{Hb*baBZpE3?7?Z_faiVazN+uDo-qP&ZDD#1`*USG}%G5GfJmn`B$K z*f{Y?m-ANRMC}vJjT0a4dYzha^;-}mN$}^?n>XU%$%aoplT124`TPFGCj8>f*GSqr zYUp>2vW#-bWiSPI$r)&@xo|34GEpj&N#cF%o%iT}yqC+VW(eK14mrXO!190R%Y1KV8kdEjyf9oZ^(9HSlLM!xBX>)kP)6~+=0)wBgl z$sk)j1nV?U?V|jlkQ~*N90hlWaEJ?1)0tF5wi=wqP(}Y3if?#*1Qqq`W^BFdh=~T+ zBsvx(?9S6Ke*|>pl%XuF!pBOYR)P;INU@eJgE5Ad$06%cf}&-oyJ8YAji~< zAJ|<_$}?$FlrNm-kkjxtm4Wmwk>61`{Yg;p(+q{FpyNUDrim19d7j(2w#r(?>egH>gH52d0VOjg0nFUct8n*RAzJy&Rnu|o|8=f$ucq7F$%*yV!M8bLw) zS9f(%b0I8GF%)=2F@Rsut8&k2I(4b-x~Wp`q95r;)T^IjGU4P! z{NA5ZSf3tZu||GH`Z8Io7A;fbWDs{NIffNP>AqR_krspa-Ouc%)_vl<~+1!;bUr)SY7)9wl^Nz+iB$*12l#-$x5sXvp2om z5@S9^V=pl)lX5K1_NW{JgQqp7^&!Oph5(p}O`B>cOhKLC1^j>Z)tF(4qL7*&*+6$K zMx!6|A_uROj&DE{EX4L_&;U6fW>bx3#=9iJ%&1CqX2z?;4;`xIxQ{Gv`wLTNx^%ErezvS)gR2-oJ~&n0NAYHaZtsM zROC6Cw5*P_h8hBu+_0^8bAP&*6*Q+jKXT5(H32x==VWwLPT8JC*Rp)+4=m7_)N=R&2b=@%!`Ta~Bgy3*j0_F)Myl?P zO`i#1su*PVdM<hB)31~XZ{IyXE{x6ge)xu{1W?LA)7ox zy`n5mk1^^k@Io;Ah_T^bOrd^3C>OIpZ(Q7~>3oJ&>m1+Ojahs}ARP0t5&AqUcD!vh zhdqR2q|00XzMffRP6@lB56Jfq6LVgw9vK|z_FRRwlmv(vu3mY&Rk(LrT| z*X5r1$e2xbPNjl|cR+StmLX|IQY{@wCyE7P1Ew~r;<-arjvf0_bShzQKx1-)xOK8 zt=Vdtmg7>;%ddI4&cae7>H2vxPae^=M=x|OU(nXvv3s?<@!&*oHsL~KzXygzXU3BX zr|VqH7qUK`yK>l&8$R~ji$2G@)2`KYoUb|grS|ic`yL$cg5YWnL527uXZ{^4U1yp7 zrnY}5YI8e}b+pgfj~Ae-oOIjM+K#7c(sq{NT#ks~`!qUFFG#=EN&VU_zej*#vx@yX zkYP7+=jhsA2T!&&PN#}CGv&XdG%t*ARUiQ^Y?z!<`0Y0^JU2%@cq+&Tp;k^VGn(f= zfVZYV73VxzoE!bt0@ugEm`%&g-a5s-CV$uQ;O2%f|8_NH{zO1yhN4O~JY_G;v2-kN z^3JM9(ac-uiP`z;^0yz~o#19?gf@TPhh*Cl(yViRnwPu~^{6)2>}(73UtTxaZZzws zuhxW{&(gIk&C00-ZIft4pE+xfv7FXSc ze~C;2I+9ySCTs1=t=?vAYZjjs=4wdt&%VWUoupQ9)u-}kWO6yTvd`#^&941q?%F~~ zgDe@0@(>lZPGQ=XP+m?I?!FuLo!)}=Y~h^2sH?yk=sG^)oLuIDs-?3EMo8^WJ4S%( zWT@@oXF{lK&R(c(yXER0#%!qF-9BF}#g8Dab$3(IO-$D=$~7Tics;uo1A?wP0x(tD zbwoWHV=%CNSlNW8gJCZ@p(%Q8SaV)4#UpsvS6PpDS;=8OX_@i+ExzVQ3DochTB#|ZJwRO?a==m&0^bL@mMgxNN7QBo z+0~Ua+Gth;x5_RuhZWt0cn8JQQz4fJ=YbpfQY0iN-FALS-8W>eMc$?#ip5KVP&|MX7YjqCq|Ak9sE>5deG3-MDH+GyGT|^|2|uG@j8pF( zmT`drpT;TXnj$1(NN_po) zWPWG^xy}*oM`fG>DOJ$E<(>9f4yLn*K~U;5{#164y|5$g7S}zT#$kT&^VOWV#hJWM zgq2nRqZ-`2LJbH0rdywIXBjyDGP&f-EwjwK;`O;tnOXmWh_XXMelB5J2O*G!o6Mq^ zS^Z2`xSND1Co0Tj_?`~ZTsx`{$lLXgVt*KR-#6!2FDP!boqaf%Cf4M_m zaGD1~x$a!fMAp3LefJ00ZhkK@p(pO24`4=zS3v(+%u0s(k#CCK54vag47myy5 z1W9^Whv17CHZX&@M)4idmPU6Eb_Y=G3wd&AHc>xohgr=*zfKj?0join)%5ta^=R8w z3M-844{8w|>iEGEj1(UtI`l7lUwnCY_ftFGkhBe-%}BtJ&C&ouod> zC(UZzzfJii6%<~W-ccgt0F=X;XW_{fz;s#9rQ<`Bf+#r5B~ z>TxyT`g)h>lU-mS{fkOT9m?{K3LYP&pG15k?!N7g%1Tv^hyFFD&HCNnyRW+3mZX8WLd9J#9%)76;(I|?tT_sxO5B|6tw9QBORx|E%`;9Z>VF$EEvJ1&w zKj3nn2K`+Or>=jvq=fnQg*o7t-E?b-8XWo%c@IX%v@(sl;OcbJh(j}2|6d+lb6r%= zxyueK}>ks_I@^+h6oCG zls;hhp031KoaOHeBt zx|UGD<%k7IRa*sOXotp%>`NkX?abRK-FNO{+9y!H*RdVmT^qc+rAtKv}& zKkq4&Hl+?1h7DwyqTf)P&oHb%mr3wm>o`3WE#vS6MB^Fb9>5SD?THRsq3-|y$13cZ z(ZN1!5UVdXX)PTE`87n5t#6xq(0N?qAB*wj8om+X1Z7F$i(WkaT!GhFQ?r^t>1om2 zx8Y7G*g5b6#(y*_CEN}jiWo0&F>GLbhF_Xpi$#YcCKqG;%AE!_jXA~=@Ki$5H+mU= zO3url?^(v3QgF<;Di<0}CLjXuh=w=~?n&1p^(1y4cS_DN&ubjwJiWx0)0FshgMC&} z4iyzkP>3Fi>7Hsz$prY4mFkH6j-8_P;0D-SZSdGvmHUvi6)&sOdT;?QedGMV?ONKw`NisO`fMxb(nc8XahXK7 zhuR$MrMu>?Xz!9@hszOL2zBdvzJ|$@ooSM}jz>sZ6ngN~;k-75<7QyEJ^i%ZaU*1Agf&H&k06-{QZr&ym_;-caU&Pi`Rn9VX1 zp2~D|U#Mv zo(|b};FwvXggc2mW?)L7bB-1iG3lN@5Y9)~D=bqHz=29*gB!!?jSO4I`X_$?r-E9$ z@AM3OF?pMu4UgxvXgT`fV#Mi%-s&e@W1LpGzAmh%v7b3&KTiWMeu^~>T?S$b z&^PQJbX9xs+g%aA?Ii2gnD%w<@4kj>I-+1rG|k!IglFBgqA(bQVySn|if72uC#FdZ zZT8MBINx!ytPGmckjNP0zjn>e?VL>*!Gd1niXgKHW8b4_xolvlF!nGVcYtGHg{EQy zViHP?8><_-4D4>;2~NV_{qfPR@)4{UlJhLHdD?j0Y`4{3eF^@fx)x%duRjujoz0Ne9;H{oKuNxL-2z4ayFAAPu7k!Ycym5ZMF= z+;(}w#mg|8Y?1+ZwsCF;LqT4gl?RvrdR*3gJa}UI;YnJ=dw^34j>7h+o@hDc68N!- zPG=eWC3_>`o3FEE!f8dq-!3D*LgAg!HN~~c()3pLvSIe)hGPqI{_b?Se&5#YJQnVn zPo~hfZ>kWi)3x|TJiBDn^`{QAt^m3+x6XL1@N?VMl}IMN+T*^m@|>-^u-0hLb&2X* zp$k7rJp1N*2>xUVpBEMVVAKS)K_%}=H>kW#Hz~tuS)S}BOGwmm8sN@Tso3y5aTZtt z#_^{iSSDMXlDs2 zi!^!_hPzb>e3`}VH(YqbbWk2v)v8!~)3$du#@oOXw2Hs3U~1oUlgBhqf)eY5hnzav_cALBVH%u7Zvub8%p@Djc;4!@C&Y5;}C}fpd^`5PaHNc z!Y26x0WaHBNw`m9J(Xj5wmRGVa_379dVPJp3Uh1m9 zr~Ub)G-==Qez;i4(>?HYEn)$S@tKCKbxsg-?fMy28#*WRN1J7>>=d7D`{vs3Q08sP zj%?GR{u|IO)-M(w3b4Mr&1#Jp%S zs;a(4hWMGn95OY&{jX%l=d{85z4#t&=j`{8g1>T5RBML`wRh0B-IL81+16ebkP$@G zSVpn$X#Yk=SBDQt$RFIgiXnr42pI5rL_J!n8Ewmbj?@cgJov4o!k>xb-X+ApLW6&b<~U)R zlH~70a#s%-f+nK;LXXr7EIvUrGJvm8Vq}0%62-D~?lf(PcpQ(*9{*MQb&LD;c@A!# zz&%zG69r8R)LPWEKTBu=I4u%|N(_8AU$?#uWt1(CW zn4_rZ7ZCPUUfifTL&d3F-n10~$7hpOH~gHZ1*6HZ#t=OSoDTt@9Fb26NA;+t=Gg_@%&HRe8nY?+=NC&~6bw$pTxHOpu{mVB-Dl5t>8B%H zTdiS#_ttmA*p51F=)pR}2i@C4AJtFmfV$~46j0*q^UQDuVs{yQ4t~(xbI(4l1M~VR zj}$%<7>O)ofV$zBTj2OAI}k0|&x@{)(VYnw`&t<`G-W<7sWx9joa9+%#3rKme~3Ci zyfFEh!B%-nk``(k9%Z10E2J=BZ~SLp?E*0D?Md5%qtVACA?9(R+bxtlF2qca4h|H; zeFRm2CD=y-cEh)mAVuCEk(D&(V9!pp%QFW57;6igJ?CB9(taQv0h@MqV~T37ik;k; zLbKB$6ep@$MMpxaj@v`*!PD%O3@=939g7E50^G&+iTDeCfxr1dU?|Lw%FgULG=n=^fZq)mdhz< zsj`m~JIuL2eWV@U;$T(X$II}x22X1K)q3KpgTTfHA5`GNosj38TvZ#qcCacH@=U@7 zA$WSw4Py(OkQ^$i0wZJnJ-$vE=31spM^(hcu>PCALvz+v#Q33Q*YLPfcNGnp4yhjnZ}x1s*4`1v zfMcy2KyqpY7o__d4_dZHcNykTcxMeS>w19F^&vG9IZ=+y1Je7C=9i=Vi7SXF!k$`m zd3+p`aQuo8?#o`rp~o67`jL6H+wJ!0$6U7&pDM8FbN-0^>;&=a%gQWQ0t`NMe0-oK z(aYfTkdg*?3_ii%RaNX^y1?6@ZFyum*h@F{Aa;wd!`X~r)ul~gf*MC=CNrEuu&0WN z#O_T{GnSOU2x^(h?uB!yN$+(^tuUQXw!b&A`k6}tB`7$n7E;b3U6b{fFfAvkEDJcMP+e@o&q2*>X}omd%$ zm<*0A7JmC3S)NIs8A>Jm{Sy4pIQ)Gy>58y2G$y%rt^t86aZavHpOG%870I>J1+^Du zRJ&T)X4fW|`*9peu8l&im0?fUJ#*QS=fh6KEHh-W2C?RpQWC4M6zw6KJj$%53;OiwB@9dEMT zB|k+xV58PBVJzf4ozF={VLWk`-l%v-@vP#aVh0Q-2otJnB6YW+xv^gjGsY7*lT{k) z5OUlYex!N_ns9`Y_JsBa#I=TUuzH%m zhD#sh=D)zDe~*m#BlF=Z@bwIepUKF5G=Ld)fM3}l*C+Pf_V}pyMdUxc>n8kFX{{`o zT6LUFH;52KWjG4)r|C|4F`F`6hA?~%=DFGQ(Bim_Oe~qON-v0=R0yHh*0zkzXzo2V zj@tL&S zp12rwnd3+xT>J|?~(Huu$z`4RcSGYtmTi(`-sL(we zm%Vwqa&Kjr@;q2-$G#9bbDIw`nP;foo$j_eXRWu>o2u_{BgU)vnCEL^*}$Y7C) z7gn^6{by%% zlQMRc+51#*{qJrz*7>`X%BT?f!d>$+vKTULcvqi_7vAP&fk;rkg5OWO8z-vk$A9ID zZS6S@K9_l!mgIeb>qC+?|kI5teBV+mhtX_@k z6^;l$97yX`c+Pc1_?teEo0pL#gz?!sZ|46}gX+4o&bR*VXye47`noS2v4*N+7nLWx zuITpXx?+jBj05&Dbz3ZUqhubF8RE|0#&YmjJH+~zD(KZy!IF;UiuY7~BfutSXoaPt zV!NEpEa0@oZ}e!oXv(aFCxGSqFh*Q9F4&1#bJ6wL+R052{XQA<>pTrHqkJq^Fe5y zN8I1N>B=PdKv0YWac5Al@hN1b5ds0ul@MnUKL{N;&5dS~q|fj>GnO)phX-)K{_)hML~Z|ghWg$w{(G%qyv&+NBjs#>a$k)%LiSLdnPi3L zeLNgmm-9=<_8_Jr^+RLemxhQxX~N~~X@inZPEr^@b|){;Aqb-c2$6k@c0}sk2HuY1 zB83F|p)e?Du4VJMv0h;wz&?Usi!5sgbGUfz!(@lR8Nele70+Fl+&N{=JkpMEN=Y+wGW-e>{V=xHvT);jkER%)tjTYqQVp|0 z!#7=bS6AQK96FEn-u!kE-$c!_|B($j*-Sg%)$Sk!VOIckLB&oySw~mbXzL8`5IO>9 zq#jw=nnhqv-GFXPI= zb1%97c?(1RG=Bu=M}J(wc>ZM@#$pNH{{!#;fO{+MPvQPH?$6_1iTgG1Cf0*T;eOTa zZou<=av`0?)$=w#b0aSA@rcEZJ0#Uq1nvr4ced~||B0&!R}L;Cd~n@`{vzZWF-8!Y zBD-I2EFSF1B%t}4wiRDAY?JZl7}mfB8$`z#3C&+}8FDUzVI5qC8rk$R=*;9X1sqej zxUh0%56{W}i!1cXQ__8ZclZ9?Z3$vBKZ9;C76`0k7;U09Dj*1k8Eqot9u<)JerHs9 z3_5_3!SFapmVzWq`el?=rsg7x@?$6zjtm(lKq4-^h9)4HeLs&X57HRH!*up`^4rVJ z?fNQuJyR~wE)g4XAK&e*b4L~J=&N(%!Q#&-+hM<+_wnskFNe}Q=iM^w2Qc5!)@i4B zRfm^3IbZNoR{KAQxXCb}MpHa3{9*JFCMNe;A{;5Nr#CXyWi^-9_^pm)v<7X?s|h)) zJ661^h)?$I6Ws7bXbO+mS+2RGjDybtf=6BfLjt!hEC@uAprMDkNvgMDc=TBk>2Xuk zapDIh^0=G)sG4IIivfFN+e269{5b)ta|M5f!t>^2IAd{c_uY|ensbknGrh%bODEHn zPH+v#E>=^Ld*OM~Y_QeM?q>;BAk|(TWdpjNqx=5g?){@XD#*kt*^ttYN845OBTA^q zyrW#PrHoTi+JoA*X=T-kzRLsrdly2-0*(tr_oH8pW-g=Q<*F^V<`UenMboXa6%Qw@ z;5vYK0J5!lecA5bY!4PAAG|O+MV9$Tdh)qv88$%kG#eIf$RKT8Vt<4Qdy)Nf*l+Ly zG$Z922SYh)v)_+Bblx~Br&*gfj-eL%ITvbO<6!+jBPDmLdr`=ro*aLq;oi}ZMU8QZ z?4w8C#^c<^xL`I&d^8^Ujr;g7P+YYK#xa?zS-nZ(C=@+VZ0A_=6KF9=fXw7o3emPV6&!6EUW^eQYHYDnIkHa{n^b;xJ;i&0x zE6y?C6S}HB~r8!p}vVPk9Rls`sS$EXjNK zkFpHmVfl0S$#)ugmKQO~zfz4wh{K9kS+Afa`-;4wh+yQh{(>L)BRww}zjDTft#{8_ z9^|6`w6&BAlyBvh7lHT1iF1#2A)hiIdQVQ<3eHa(<*P9ePH=;Hyrx-!+!|iN$>mZ0 z^HvwZF`G788CKv_u#PYv=j0kx!J4@Y@+r2I)bmGbE(7C(t&aaO4|0^7sNOB3WZ)|- z8X&yd(SO^EHjj1@!x<`$K`XZU+qm=u#vwFM(%yxV=@RmJbNJSDC=;ENSU-}I;ulGWvC}C9Q6#mJ1Q+XyzE*l!e`NVO{|wI`cInUO4l~u zMx_OPZr?_=ZTCH75Ig>xL+ka%rcC;58YM*?6s^*f+gu+ zA;r4bY`js@q%SP*0dt<16FKmg32>wTawtdoP~Tc2j)YunVqNDE?<8>RQ)p%6W8>kI zdC@30)(=87F0%N6){(}HZ0k4&P}GDM0=60|T!Hxw1dMQ`!rTt;a7^iCU%F7NG49qk%)bFfjr+vte1{d7UE z5_Oz{t{d0@c5aptjGzR-Q>4i}%*TO^qg_#`E4I(em z^$l>YNwy8_i<(lo0pPz(MeSvl52$)4jY_@CG8*J-C8wo(!?SurM}c|oPnM#JQC$%5 zw}BTYAA=&$RRZt8i^?zT`MDe}Khf^<(Fd5!BW~qC-1e2G?vI{Sl_!qLLTszQVEsXG zK@Q;PzJpW#Pz;DPA3Kp2ShtjEV|+o_#1eUjjcV%enU+a#6|bhl5@)?TIidIq0TJu< z_TZ{3lQT}xX>F@O9d3Pzj0tg;4(Byiao!HB9c5+Z<0<5>s zqNgElgFg9>+5&amGrY%rHXA=3VidhC_ z%Ey6%A7%Y>h)=5m5`_dmbBKmgL6?%^0eAns5dbF2KT5N5DE4BJBEGDo=y&Fps3#`u zx+sWa2hZ7gq8d><_)jXjohQJ+s2F&Ki~K2d^^lM4PQjhm>`srr_E8nd_XWex+=uH8T=BTZ z;rfo|XCB4%U$|D`io-Sa2nAh*t%E7;S?P#2o8k{4o3-L3T7h5*@tYZH|&{xA+tk>MFJ?%wn3&?|d;+o8O(a`7ISDJaTo>U{5zyXnusb zlKQB|yV}x09N!>pPImtZIIJN9HEtsM1CCI!7RIvX`5JI=(&wTqtDyqZsLXn^_p*Wv zEkAo1()5PpQqHSr66ai}h4aYygrkSQOc})mXb?pMuF}M`F-Z)pV*vk`CBAu8c$VzK z8(?QQ==$!u?|K9ZcSM20L!%HV2!U;DKd-L!?Tj#0tW`LiOm(f+6)~Sx9adFrnJj!t zyHtM&`=_%laL^eh1ioiI8F=zwy7QFr(nViuzwyWKeGvut?0bw;h=_yXm$q?O;o9{T zEWq}yg?T_)^%m`fYYNA%e^hVry+qs{Yp0(A?t@G(Y%N@j`8ar55YZnxWcytj))Q30 zRcu(T%e#N;nu z1C4uu@p1xEBHl#WqT4}&Y`H;Zfv_!Ogo55!Ndlh7;#)lnHWlFnD@=b#82 zs`*C1LE|$JwoJF376ewcv1Sw9i>qioVJsRw83mb)36E} zTuBMW&)1|E&ySwq75&y@U0{7vD}R5{5)u&omKrQTvvRvqi8QlUGf0)Ov_>T@rtOT< zgh)Hm2EVNb%$$kcY+X&SuxKToB@$MC>|O!mCbMbHYv#y+{O2_a1*Fn;I*&717i#V4 zd1e!rdJ_VKxo|Atj zhkGClHuw_wRiuJ^HnAg-PF6gCL#=_!$1z&0H=hmqaUo9G>=#IsQW9Qw*T3%O^WH-h z2J>S4511Cc5zJzX=C!#RI;@{zx|#UDk-P=m`UKk~9z-x+9j5h2Yx?##_@>SKvU&|X@#_pko&)%|ZG zq3GO5rW{U~&pGPPU$Y}>4dy3Vu=v9aK1TKfBc+}1Z*Vi3@0zNm}GqB|}6>5*xf3@~D;W9r2T1$=VCfSE{b zfR8n~QGG)Jjq{efO|%n!H@rGy|8QxCUuA+Q4uIIqp0K01)_6U(Ri%gL@;Fk6p-|+! z8;UF2JigJ2T1UzQLDLpF1ObTFxl{Yvbn`S8O)#aFLN%eaN9kfJKq@%c(V zUm{zL!Cq9aEHeFXHTc0*87k;ft>}%2Pf}wO!~W zn=)~79q92i{)R#hT2bj{Bm!Ojk4o@s$E5g#XsD`^Jq`oT;UD_nl?O z?a%IJ+Il+R>+?WaTidF$p?^+mt${+x*$@kVig5*Iti7J;OF7#Li1FOLw6?Ya{*y^9 zA9-Rq7JcaEa}}(7XD?ioOy_f9XAfJiTykc?9~r#${+Tq@fq>D?_eeuTSCmL^_!5o4 z8Y8@-z|!a~d@fnYRkI`)1o2aiS+=yCRaF5w^OvSu}qlW%9s(e zCs$_pl{@svb%-$}!u$5G=KZ0O!TQI3J@1SDD$RS>Syt6F8yHzI=L;etjSd;d#tJbC zt-eEW(%SVkj?Wp+dqIn^_8NA4=X#qJ-b!Z^#D5`A>RwTWTX*oRo~aKR9kUpU#Js-( zUBEIe6u5mU>Kyi4Nxy@v%=`z%{c`ij;JeMzekr*(e@$$YcBwPB@hl&;X^rB-8!qP> zKKc)rty5ALUhP%msV)vGR5`XOe6v$dn!4=loSPp`jtZd}W==MJBH@cj929@KP( zVl9Ls+-^t59y0J@sY+t`?97JF9A-rNWd8760YpkIP{{TN`{cw+zJyB2vMLbgkxg^fuBv|? zPHKNAQZ7RJd{p=EGXmhG>pzpi7w7R@lWv#03SJDguJBaUFz}sQPak^A5S4u>ot+SG zEr2qi1hl;FNdxJzGk6O6a09;6GGF6?sW*yF8S`oZa%Pf~s&OfR&r(_a~Hy(OIUWxYG> z9qs;JR_C5NzdCwf?6d5|oH{?q`%HS}G2Mab$+5Ey4*B)>$v{J9cFuhAob%kctgzs zF+;YXC$>UX&twQ%5>mq(8@P(xx6^=%#=Z2XI~{)a^J>``&tQUdG&ohpBaDgZ*Q zj$<%&_7zlq!0T=MMacaE;Giun{RZd#XaEDF#6Fxcd?=2}Q*KMy*TicVAq<6G|41=l zQ0@m`Xpq_@nx?*hCcO1)yf`pJYy)LC%#~#^6*9>oTjU+L-2nQ;fmn%V*{m1C3=#89 z?^6kRXD}JTmx>Bd&j@fPl5-5?t;=78mkvl@$!C8R^Wks!uKU$dxBP3-#?Awi>a&d8ci*T+rB(B$g>+t>ez{5dmJ)~cV4s`X4U!ePHUQ7o1Iu`KRt)c!)s6#j(6`VJVWq**tCvjC%;t+hP|+0-n9FO*}Q)tlFi^G&Q4WMs3$))$%xvO?g$ z$1nVeW2@uO)-r2Pxx6WDtgprYx3=4#*|S>C3HsC)d%LQ|0yoW7Q=m$}6G+Iqs$xc< zG7K!E$>Wg1ZVWS%T9iMlMS#M0ONxsj z9VU5p`CeFpPyp8vlSb~L{{u%1B9j&8li+uJQWnM6mNm_CZm6-=Y4=T3nbr{h)g5!AYGUszo&U)2Hsf zefN4l9V55KNpl{oj&_ylgWf^aB6$5k2tjtDD~p^|5B+)n5tk2Lb+>U+tSf5~Qpm^A zZAvry7vhoc-O8Wt^dXJ(C4bod)9t=4^76%>9jZk*Q^Cum_Mjip;y3l{#(}-&D5Qi! za>7S}0mTS=)&7%9W!k(Y0ZJZWj;uxOM92+bz~jg&mOe%*o;WPY(@Vjagkwcwl{wHZ ztL+tz4Vr2dQ4>rsByxG1*zZ_<>1Ut>G;1e{S;EG_*D<1COed@gpxDx*VhoJwK9)FDsd`aqRtwUz6O-%y8~5^! zIBWq=8Flz!3M7o}@<=M7?94{F1K~>6gB$%Rf*%Z}9du$hHt1()!j+`~0h*(VPFag3 z%)3;!(`R)#xXY9!E(||BADi|_srRBDglZt)!XIP=r2+3vSRQqKMyK~8QqJ3SznmX_ zjFnS#ibb;R6>k&fb2%)qPtyo>@rV~#ZJM7044;X=!20t zQqs_kK239SQ>lCr%*Z#&G_O&4pV0<|Nl+|e<;3mj?3G6SnJhVsN_qLsUO#uLF-BC7 zo?Td(`(nZvk96RRVFRt@lvH$$%0TNlB^@0w)pbOReiuy{)+yW^lj&vlM}-@D8Of%y z;oLCJ)0C8{A(g~RG5zb4On>ZKA36D%ui%=EYZ5Lwu4DfNT^C%Pz#S#HzlnPS?kq0Q z0EHi%UOTvkL=0(oHBoam%n}j=#|)fuyMYyahFPY|Or=G3ApFwayneGY3qtTgxor90=nH>l5kypuQz5cTI4H7bSP6E( zba(F=cl543tOnB6X!GQq?~*b9TatFKU)41IG_QU&1h)TjXMTgpO!b~1ah>K8=yVUW zN|cyj4k9(ndb|H!b3>Q-PL|EZW|SL%OM5%rTFwt%uNZu~k}L<*5c$!tUFT!32oLN% z2r4-Z1Abcpl@Pg?;eqUI3jx6_aA--98{m1gn(Ti(a*>@Z|YnBCt&Sn z3Go3Z$GM8F%5HBw#K0BU0ZzM15!>vn*R>zN$NKO44WwYdhtHF5p#bL7#%Y21ulo~^ zTw`FY)IH18(RVzwaO8pE+kUmQT^DNqMemrLI4vXXwT|s6<2X4rw;K5 z-_jV)(!i8b{FvrOse=RW5IXNuVlQB%c#ibBs4(%c>`9LSp7GhUF-n`Rd><{_z{*$JtQRCQ6?Fga&R{QLr$67vy(g?+Fl3|J6i z4P>hz0yOf~H_?!43iQdpz#aXE+db(lo{7z_=)`_beJTM9s?`ghNyhDJ6RRy@P7|!mP$i%V0wJoDqifk5C!XnBx*5-GdVH#H zlfm@dPxz7fiDYB&AyhgmF&=<_l_8%oy$RY7;Qr|@0yFjkg~)s!fvn`q zS^4TU55z6oJJnn08kzgijM>)NWv=?Aoe*uqO2HOQLO^J4DnLsL&Wjawpq8N+^=h2= zNhm5nQGo7rPO8qB-|F+i`svmh-IkhD-U@4V#O5a_JD8>WCPr7pG0U4KS-O2rMdnwG zsJh6MqADWBg?@gDUs~<33Zkqg)IN5?643rM&`0P4p|uCMjr47~^PlpUaB5^zG zO{n97Ry`$O%Bm_J^lN`_>tOvdewS5yu%qAkDljUsk{8XU$b1uq!I7cFb~?OO)|`k% zi>KUmwRKwa>WiH3bma6{+v+-wwlYwDe4#R+Q%!Z`EUS1Eo>{b=qUCdFbEy5>TCxih zI__=kL8~NqAC|%Wr-x$+U_W~YJX!L=mq}aW&Ortq)->65-;oBHB8x{wJ4PeU8bn4O zcIB|_X_5mWSa?R*RW0zcx!sU$3(iD%K*T5UhjNi7v0N{{{j6uK6SHJu{Jr~QJq=rwTLc7eNHwmZd>UR_;L28M_qf52O5)#U;16e1< zUrLIFgvuqpLlFr7EwRC%yx4WFh!e%%=Zw zC}(8-dL)a!7HbyI$OyUk06~JCQXKX@Q#+j}JxH<0`m@BYp>CoLbI^uhN=f~OtP%KJ zX~(P+mj^o2ck*`cZ8Gf!=8OHIsEhD2AGSe?LSq@ytXBy*G^y@Y)v}=^;$4M8+csED(Y0{legjMZ)=k7Q`<^kn2V1Tm6wOI{F@ zlaH5q;>&z`^1_7A+^I`inZ+bC8U`N46t##0dQbjJ>@kovOio6*V&A-}jvA>;ptzWu*?M$!#I)L5r2fs@dqAW#4B@E=_ix|vFv zyl15T|6u;2VejqD9Cqp|s|1jC#B`^+L02s^Rn=x=)dl1vgw6%-gS^ofS!rtAI zxC6j$NED$e&M$|*TJZPTVh>wfvE%o4fA)G)@L~0aW|)0iVM45yWX2tb~}*+HoFFmbOSjQOu6(_zT1ISvQgtU8}QK^Q;-XEo2N5l7X201S&0iZ!aV z)!G+TYeMV+O%wJL`Lpf$6nuXreQM3D=*|8s2i$ACG~dF3bxzwYyN2+vRyOz9y$uiZmwYXQ|9)tTv-2V?_?*bQ9wf%vgIrCr`4$5;x9?Cg5 zFd!5Rn)v7nqarGnOh#tb6?Dj~ZpO8@-I$>WmF~6Cdqv}RhmlF7D4X(;N+~8Hf;Y`> zX4o}zdRV5JBBC(=@7iYoyZ>LG|KE?pKIiPUUu(bCUTf{OG75Kc#!|Cbw+ z??E^l;e!b8K=@~bcOrZN;T(i35f&g^i|{Ul|3qj(xEbMGgzq9Oe&NRCJqQ;eY(;2A zcpTw8#CIaJBm5TOeuU={euMA|!m|h!_vB3WA`C+KEy7_4zehL<;W>n<2!BL47U53_ zvk{(0I0NC&2rURNAbbGf6_mRKVHd(bBkV@_Ji@C8S0TKNa6Q6n2;V^1gYZ3s*AX@& zyn(O-VIRUT5prcWCjWr27w?x5{(^qFj_@+dQ_jnotVMhLwFJ26A`NLo`z73 za4bR%!W@JF2xlYIBD^1A7Q%-S3J9M@I38g+!U+i1BAkeD6T(Rd-$i&k!jBOC0pY(9 zPDc1&gxLsxM)(NAYY1}?YVXaNybNIkLKW&6hp-6mqY?gsdftYx1n<)jK8SEG!X*e7 zA-sh6WeAtz{dt5u(qn(j$n+VNH5M{lanynx2a~{X295$-&Jtmg6MfWG8wwS49Oy@p zyl9KEbCnR=2p4-t^ZaKb!+)>4;8Xep;4@N=HM?VB>PcxU8i$!87==4CCi~9wLL5xR z@;SHT+QguI*?PbS&n0cV_{3LCLl5iw4Qht1jyaLq?N zScpR0d0mIDNjD=@xU*6DMgQKp7mMZfitW6Z%oFhHV71ios+kVF2S4?{NlDS-!sU z{yKX-1Y=|M$+*!cJWBP^qO9&dQ-4|RtpxKlFQlF4Gp_V?1PiZ?4;3e1AuncpZU=9w#c%1~3#h-b{)~gdg?!#aUjt#2a*OwgngTE;E z>j2TD%YD(*Mhi+hQA1Z#wc9Zha>YJs9m?$uAzL!`}6Vs)mON51v>p!@s++w*Qr(U zXW}soD*;727RIW(%2$~3ERE)z3RdI%`+yufhK4_W72=UbgJPi$buzFGS)u+=|L z+_*KBvW)7Nu>`$$ijUom^os}aS%&yOF>DqyY{nk0{Ufl{k4yJo5dVlU0(e@`C-_bc ziTx575?r;oT+ojL>k41txrf*cx*Ibn_D49SaxVU5!ELp32BwhVS>XFYAyXJ3-hmzw z?mRWb;~ZH#^Edc@3i!4PqW}Ze{`+>rio{G&{w!|E*h#7Bl_}LLuuWq9Xi-fs1_A2wXk{y%VUq)5!h$$ zUnji9zQXx)7(jpwKn+n2G{n0Q@u))Vg&YMNJo&3fLduht&KU{mNcW{te=OR9bt%hIr z(rTD`AJTsg2)Pbc3*slUt?RR1p+i`a`Y_^;4#v+x{OujONr%OM_ttjcJMA~r+JR;o z?m&M{q-JKc_FjS!WIq2nY!sVn_xBF;sdpCoV?2Y|$54^_9ExcgXohzN;>WT0-E#bD z#P1l4pN{ykEPk6D{~F@o9gLrb_)Hf6jvP;@eseH>3gRzt%87Ixi!!j31BqBv!zxULkH$5Rh%cHu+NT0Isd$Fn>m|c9f%?0E($C zLn{a7?^wi3tcd4X5tBxvh-U_hux27Yp2a^U$4^B3p9kYJ5I=&&KQ715Lj0qH@wXyA zmc>6T$KQwe2M6QR5FgEkqm>QENe|Z+Bes9dw2nr66l>xi(GRHqYFaa;+WCWRG$dm@ z=@}a5^%A8t={b}!cc2VI65=CRyruV;^F;u>27o&T0Ss6l!US5F^Qp6kOnQ?rSv!5O z46lUo5W?CqO>W0-WRU}a{tYf3^M42fn9Kn331H$t8Qu|y$3}ZzCytlnuOoi!V03uxO|6X_ga{kthuQA@w~^gX z0UucG{H)ReaY&TWraZ##a*a%?jumtNU5Oh~HAedhRefI7E-~o{N%m$#tVis!W}H#e zasJ+nunIpbemVG^eV5tThB6&a(WawazHuI2lgf$vKbwa#=9tUV?^=_7w^KCSF!J$t zJI|=a?SJKAxC5rr;%{*pc_+^q4|o{ww0$vSTDMHammKYpW}Fb(6GZXfc`8)o7)}(^ zT0#bZ^%1>d^uKfZIw}va*gRFUmNSov=wLQ8=4^|1 zNyLf42}Kjbbv}HwjhJV(vcV)n3-?BuVLH|RPT1=M$^j#TpY6H1r%SE9r2?qP2Z z?TwP*T0sg}8dL$nOG&bB{i*=E-AZP9K{m#wM!&kU1L`S`z>#a3*Lt+{oyt2)qA${M zieI=~EbD*@h=m`Cp%KYz=5q^k*DRds6t`bT&rNkoD$!EFTXF&{IiyT_ucAcaHHlo8 zDY$6ok%F2IriZABfV8+L9!8kMx{PGcCpV8XY98K)?t`aQb-meQ$UUv*+Iu90hLaNJ zt-D83TOuNo+yTb-C6=!V_0LR@qfrbjtr<1X-JCv;(zi*9fPuu@SmFRcBQYZI0G5pC zI5KNrld9lJxf|rdXUd5~`QIikAXcI&oRdOfQ(!Ly(_!hO${}^cS15hs0{G&NC2Chh zC|nY3p;hFxh67t|sS3-`Ge=G-n&bgmiT2rzCv8jKpM_ikhq{5<=Ng_w^w$~_$YttjP;T;W|Fj<97 zXSAWmv?0yfq3<#~{)UZ-M&49>64hdT37Z;okB~L@lQETDMq&cO+N-RL3SF3d5M?m8 zY_-;3)dy1u-bc7OR@#g)vHmYo8 z^es)-i4!)`sce6ucWKIFs8PQZ3S-R1rTWV*VR?)`Vy`Pm9W47X`buS|QrY*@8`U~J zBVG{%ooL|3HsT0$RJ2&1pl7^!e1!0&L#y>`3I*tAa@ASelS5y<>=Q5hHbe=5(ma~t z8?tVy1MnMs^Zc_afaBPPX6;B^&oP#>8wv(bKmJgM-VT`f z*1o>|p#b4tN8andZ6o5ZP<+(v_J@S=j(M{OqpAKsBTkB%P2I%i^0^MP)7k8-5`yYs zVEI^TSHW4_oYccwH0pTZOE4UYUL9J7L5 zc{IQWaW)!jeD60*Hj=Zc-BrhB!6*Ga7_|56I_E<=LTePHu~gu`9&*e9V^_^}Fxlm^ z{bWK_AK%;~XcpeKW?`nYGN3Hec}W@n;K<0MKl;dQ>A>>V)Kl>whkH3DfCnM9`Z1iW%nb!Ae>i6zlZd*Q>>GURLJtYRe z;a0A4KP(&d-#*FBQVDH-e43F*4l1z3QlnszRXLuP zd1SN^YwcGU)4|}mV_s5W9teR8PpWKxr#`8+*M$f%BHz))hrw>F{naN)vluPZ zo-`D)~0z5Ls6&rv0Q{q48c@Xh|!UTfRZzEf*=I#*Gd?)%snxO)IUqO09e8)W3|ynktgP=(w` zP+sC|?eoE`rlC$!B?_(m84b3?=Sk_ zqx|m^4x`pAR^EqJv_oW6t%Xs+FO;3-jY>~o(boF4vOwPvxNuE*IZaXx=Lv)vgGS{| zalMGcayCFg#$9|x9)q9-+Run3Xaqt+nRKZu|&mceH=cOJ5;-eMuNBhOYh@>o;Us za(}w5JvNRtH1A)iKK>&+=#>59^BWU!FY1wTZ=2MQuc)|-b~$g`IQg4htFOs&)GBP} z+ki92ygP5s>PmlB-UlcvE*oj%{OOb0zt`HoQX&0YPs8fPaNDW2)juN()DJ{5uF5;| z+;Uzt_U88h8yr>dVt=0Qds&`~pJaDN+bvMCgZPrOb+yH=!f-G?fQ3Q7s?Y8$u`|^Z z`2ATK4Pt)2%0829Pch5o$lLG|$Ui~rBr6Te8GhSM0*~U@HtBR31+_trs#%zGgf`*^`<3Rk+Z}YQNQ6EU!ejiD; zO7Q!rRlTIQ-g}u2CgLAuaq@%e9x1YlxJ;m*k_HKJhnd?ocb6;+#gdfjb z;GL;ma~nv99_=q}LxrG%0+(~Z*>=e&wO*3Vhp&Zojm;aP?5)>m6Ms5X{MENgf=Vf9 zf6`4xMmOQnwy@1_TSQ@Ry~1h&ML<8mU;Sm>^Z&0bVavKe5F$|!aWatk6#}9V$zsf-g>WKOq;oR&YV$B+zkxd z96mz7ixcl_8?YTQXR1>O8&4Ey1+{aaoTN6a9mu6J4|?>D6N;2d(~Pl>+Ho7{1+Y9Mo)}U7PhD{DvwjGo_pW zR8%a^fDLgB1!&J?;@vhU9dlKvYElU2?BU_|93pV>pC-OugDE>@l>EV8f10$HVZn*j z4Q$kNXw^pX>LJXp0ZSf_A`$CDYErMy_VFQU$)f{=?#IFGE<1dg2!VE7Hx0w3 zhGbYZM}Fe2Lqn_B?1^bW;CVor7`T2Y)0g z`L2D&`a{T$JSALu8|eco)e5 z{kpq`rz6PtNr^NH9wXFkwYCSS211qRK$L4=yzRdJw-DDpV}*i)f{@y^FT{2?eMeoq zfAhVfYf=i)EBDFmvu^hZ!-XK{2Wu<$ol>sBoxZZ=je#pe7nUu*v@hg*K=$6{D-|Dz z@mq;aLyak=PIFI;bUEuYx?7!Zl#yTig!@oQ_@s%}5XgC737j85kDd4fi;VU~iH=1{ zNL~R~kS4YGLuUogt$07%98waY7CkEs8eIcDNKX^_ zmE=W;7ksHzwHJJL#%4#|>F&#lOC|G`!QlvEzW2wpH6YHmfb}p>whc!?J_at&UF&NSEBL*61{^R{Lzjfm^>I->~72ACJN={sHOG%Vk zJmizY=%~-Vi1v*k-6ggU<9>OkR@~=HoH8O!TXNTMxbSSjL4r2qV6h3G1Mqkk55m#k z2e#b@84djiy_YQ;=|JXo&_o{3U!<&`HUd!v7)mgmlz>%)Zxfv}7!NP#2$y8Iq18f~b5qsJe808}1 z{^kM-fe2ux7G%-PK=~viXj`S`_K1}eTx}7A{TpeRU-BK49sZIbSxH6NMQm~vsBHPZ znsX?+#v#Ozhad%nYP`LOOBVe#u2ai3mTBcZt!VKHwWJkf+_U}9U3|s1jF9D;Hy5e! zpHOx)-}if}M-$W|!GHdhp6QI?lwA{XyFpV<_Of$orv>L+SaBK6&bfva*G#p%W+d_g zU1oMJU-9e9V;(b03jI1w&RqvhqvGBQ&v>!}<$?PTQn?V8eOl>&zC9C5#6b+#*z%X( zV|?&i!?M6KVR$qn*_fP%C>d_L0H|{wa(A@0<1YO6@%;&3eEEHrFgFaRoAIV$-$yj^ zIYkG}qabNwTqPA(fqMh+8(NZ;P;ahk6XzeKNgskqpNr#q>KUG+)AP|ojMeqnwKOFm zB_6htF&@P`0ql~JPs6XI7XGFo40G)zjF2;(c(-|S7=9}JuGBHSK*A;tJBTYH6902z z7}SAsM*t}+j%QDSN1~Dla;fSK)Zq+p5~pIEp+_^bc7`R_%=`PG-r6;4g!smBBaeD+ zM3HGa{jTd+H$i*AZi$dj>d#Y>kDm3V?~nI5{WR0!YqYo>xrJr=m9IkyVvC3=Z}BzM zagyAQ9MH^Bc4p3W#fdUkNJ;dNvht9`C|dUc3;QI5(2Q(mYDq) zyDx#kF0l;Qu<@e3|G}?iu-#v@^tW4$14`OW%`T?uWb{==$5a;l+_v2~8a$%`W;$_< z8UWz_+?TX5$UMz|lE8K*v1fsYU{;#;2#S>}>lPRSvB<#Ax7=34+yMl}i-m+hQ`IgR zfhkut3UmRX@HkM2E-#rvj)twf2xI4#VWhz9-KT(n!=z=ka=8*aX6k$`>#`< zyf|)5kllO_65v0mLb_4B8TIq7?bd*=1P5~gxFpK}gjknT^ziqHyABe)dQ53jkMx*j zSz~(hTDeU3h`ywaVU$4>9&kL9rj0-h;THj+f^e~?w1cju%e7LjTeednOJu-j( z-mLNtrDw(y$Z6%{{_H~gDj&aH%M3!7gr&Jos#&_=9nqrexEiVUnvmvp-ZL{83G$cr zr!x#eI;gZxdi75z(Q{Ji3F`SERETluYPxf9V{?lFEwUH&_f*<7*jzoPes$5oC4sFu z#hRLf{|IrZw=RVBX}vRXZJ~tAW1tz9OH*s4X*4XVo+|!aA>}T?qp>KddPv#M_+^{p zHk*1ODhN>q^|D%wm_m?0vX&Y%CQP+NRQ~t3%9#v1SNcqFxlt9zY8r`SgIAmuI zik@17B!s2gfF(SVy99!ZC8oG{SfBo+4zVbfp)s~+XO3rT58&{h0Iv1w=Lb~4cuI-% zC#0IhCUr!gUZ>faV?M5?CQfQVThUI>)WI=e?E?SocDPVrd8=6A`Sa0no~hKg_z${; z#i?1j`Ab2Dy_%mvnxH)q`SYo|x<38V z8=5nNG#fs;zw0g7d;sIr@)@j234%0p4YHh#^S50+vaF@tr2CiPrx3j7dT*qpXFAg} zFJ@;t?X{ya7A~QImp%v z_COi#dO;jUG{?Fh;D-pmPdw1uVEez}tKR!{spOE+cAYdGQiZ(@i&jtDf`c7UGn!Q+g$a8r$~&|>N!h{ONuMX=mGX=A;{r;ZYoC3hc$x!Apk&=_Z46kMLFIN( z{!JR;S){JaV5yPTdxFwFI~5H;kwVV?Q6GgU0(W` z5-`F!2IZN)0Dv;A`v}2Kn9!tiNU=iC8V22;ooD*}1AvBh0&Ex;%PK;JdHCg?=??&a z+npe1<~>9(OUK&7a;ASjOsIB9BVOJxR7izl{c0b!2*T$N-Nn*~;+892iIKRs^fHKK z>^#LzI{13O>n<)HK`3!KLp{<6yNX7fXF6SvMH}QY-217Flv_>HNC4n*zP@Xl(AHmJ z8N|AeKzdT~wMoNT9Gg-+eIX+K@gK4Hl-LRV={&s6GredKposv^8Dr;rr1-6C)J=i? zfR5?K15n;w1hpzLb|yi+`WvW415oar1eG!>b}2zQ2B6AX;~mo%4}clA6WA`%^_hy` z)(*gRKr7xclNJk?(}=&+zwZ%j&j1JnZY8*9V^R6klXB1VYaAly znUMzo8OHrK!AyY49CAx-*lJa8-KMTo6zAy?U+h%aSs(cGdz0li%q2-|*i)D7_c?K{ zSGZ;j15D~J6y+cP{_N{1$;=3r@L*|3SyGz@WIto<-EeUsDKaIx4~w^KZw61uR#nJP56 zZQoH^tWZlLu|~O^AJ55jHn?_%<|D4OR#m)+@M-&sfQr=)eTkaLWN@}d6rSn9WIl%y zS(S@#o-ePVSpB(fSdnqns-Mji^bIbLb8RtO_=HEcZYy)T%P8Ac$`<4!{ym#1N1Ifd zlJE#qZO{Z4)0Aj%6JR?5nlL%BJ7DF+-mq(%%gNZF>rhtGM&jLg|B6Ri897du&T+dy zbG{Q`p8W22=E*KRSIRV~IR4{Gb9+7L!ftl>gdhgoT9KvyhMU!q6t+jCL-jDU3u=`o?Y{u+oee zN8Vg&LD7L~ZRPNo%I(HmBq^?@Se(>`&l1mJ8!5Dd8#TlgxMs5lemwA*GS~%%A}A+T zj`ca%asUn5FTdN|Egoe4k*nBl@k}VSc>&!m1kYUYao!X$`LMo@$S>~#wm_#2sh?s_ zNvv5c-~tajKzjc|CYjw&;CM)iQR0Y?XEn^@Wsl|acvphT)uyr-CPB-}{}%O|IJ{9@ zCH##;xG$Ugp5uGvx;latRN1X`QtvAq}x7<&wZT94CeRF*0~Z!P(_~cIi1Tb3BxT1uDFWA zx(vf;Yr?_zjL+iq6N~N_x-E-_TQ)s1e{Q3?*%{xYv=q^rYP*;W%pz^!$tYLdi%qK?B8&IOyGTb3oJ40`na#^S&yRG~)j={_{pp-0fOFNIZK$Je_@R$l zJposqs9EHHfQZsDsVRtb7xlJBxA2fwqKyJ|N(ERH^n%LL8(eSdi;F^m6-rBd)hK&W zsJ$q}lQ6vGVX5Fqo5c_k;2n?UbmGxRVFLqTRA_N`DK5eOq<~|*)d4HE&ABh6Br*al z9INkqP@^DB1dR8O-Q>8l=(}d=xYF_;UeGo>6%O@CmosKf>spWbGP072!u=Ue>_va3 zJZVoD4(+e)MwO6fFA9@@Tu?7rK(3v6cL3Ea6-7u+MOkyXM+}qGTE|h^rKL!_-FT0E ze|)1Bw@~aw?5^_^La1GgI-2c!W0f@NdTG(H&2NtN)Sbny_-5=Ud44((VMWXoY{yg58 z3u8T!G=5LB#J|aoxYirD%+^t6!NUFU8E;Y+5pqDN#`dbu^G9QS!o)41jlzJAcYb$n zy$|Na3C!IMrUJ~RwruiQ*avG%5bT*9SIP1x>%JhfQq!wBukPT5i8C@_CT@mkQmT#y z;D`yMe2iSD`c3c7UCZ(mhDkU%4HpEv7~T}%%KJ5cLaCWJ?ov^h_*#pdA6a&&+`&*s z#f!kKLyRiixS*LIxp(8*j=oM9!6{()XMK zw?q;{3+;mF?K7G%+}h7%SeuP^6`E<^YPlacEv|dfB}Oh?jOnkDHaSdnb+&UxbXUr# z31iyZFxO$s!S*eEd251W)JZJK8oLeQ2m_asEKXX(aa`nc$20qd!M#bazxJ80 zVyr?u@oz9XT#`~h;V9Ztk)g1MbH$sMEL>dn_-W%))qf$pPVJSTru)Qx5Fkc)zbb>M z2raVxlPUvSX_`_R?!%TfuLZDI0J>0=W~2g*O&OIhcra9P@rGza+ZvVxdQ!2gip6j# z_ZU73e6f1uLY!_z0wFYOSr&=8LlhVw3)|aLgJ2E7w(16o!&=NvajwNDo0I_0?euw+ zOSX?zK%S{bpJBYUN$QdDI0EEdLA*alCG}y$A0*{Nl>bIwQbRn%AVR8y6cdj=)#oyI z0kZ$_@0Pl;NU#dhX92c06fS3+Tun@K4^N*7GkrH`K9Bc=kChJB#K&JQD(PFe@N&|2 z5M_(0HaM2Vp7Ymd-U=j=)46*nUBlQ{h%?Znlj&1*tE_0{a?bK+-%Vf>GCkorfb&=s z_zWIXB9wRY&&eR(D+Hp;kP?PB>9$LWrOiFtlSW9*z41%eVkhWKloDcv`-+=;yKd7# zn5I$Cx+%;;+aM?uQfE@#TFX7TbcNZU<3-Bx+-L#Zdin5}-{4WMki;;Lnbt)a%K9@w ztx6Wc2r6Z52cP<^N4cB^Zojez;pZoUN=ZU-;@N+@^ef^UTuyh6vjK#+GrNJ2Vt>}7 z?p+dJ0RvNYXV0B&9CD_AVtQBh}_`l!%EG)_*SZXveev%hmzdkC;~3wVve5gg_E$LV)hv^kh2KU zB_)YJjXU~1Vw!ZW{urRZ+k3SI8&3O^#pYf)1r3VS6wDd05j;GV0Wj+q44)DGI8UA6 z;mOyh_jCHL3kMd8)gwC{c5zr|UO(T)qkN?Bx=NTxbqM9qa7JW+a@bDoM8g>^fp?F; z-E3vp_5P%d(x{UX+RbCy`O80wa+0F!aIpbBEeQFm%*6SmMNA0GZZ4OhVBwAWQ|D1W z`lv2`_n-4OQhkfd7q-NkF+Qe}^D}>b?W{ck6i=hW<;?elPxj0uW}qLUjUbk)n{-n6 zWWB_Blx4(yoh@b_z(#1|;f;eMo>v}qxSYgrkSe*6B+Z6>>@g}MaG&w68XnuU_y%tE zU?HoTfOYJ7U0MLqh@k&*o@i8dnc`|Ty}S6T-p=7~qQt-Z<|o1iH-3gs%Z(vmbcjtpl#^YsPaMPHd;zv*XoUOPr0T%oL7Fz#Z` z$-PiUHj+Lxh1pbHuMf<(swMP3#s7UQ!aS90)FSi&_0lhtmv{VajU;Tc{^2V9Jxr`~ z_YbThGeR^&xf|sA0 zGv+oS%B(El!m{Bs04&#UGSuIKs|_=!2Zido$d7rBPhH zQj#L&^O?63wyrBx^-8l*kKADk>SNZ&nD4#5#vox5<*-lgTSMwad1Q>f$VP@Chq+t& zPZ$lON6-)B!AP|tL|LyE?S-O)DX<{vQ`lL#SB19*B z8P%bivn%wM3%;*c`Ebsr_zze-N3CqDg%#+%mFekOAzLd$Ofw4XGpLD$<^59j*o#Qv zHHA=ZWvl1ygm1%Vl#}7RE{YXz!(Z$6ung;7QY>FgorjR_c-bDKPTiA5?`FKic;<~I zfOe0#En_~Q>!Z*MRZ9oJ7qc;tM-VeZ=qq__A`O~M5F+hnD(ypHeQIRRe2AiBjw-_D{Gnp@- zwfLV4xsQ=SGzOnnk`#pi9tsmu20-Wol&{#tcFKI??RU{32M)%{GM!mUCrG6URKs>( zqA8-wtW6AiAW<5R3k)S@I=N^vw7`C1+Yq%ynAFzw}#9eeh&e z7m^qqEPdREmAFeXwm_YNi%$qi4Eqrf?ptv^L9gqh#&FVpf`JTG3INhdrasFGv!)Tq zNe$>^<`g@zPv&zp1U31U&8*(4#2FsA0Yg~f@PR91 z@-H`_`DxVP&?8^b6rcKt=^l@y!3D($qXxo^fmjK$Cftco#JBbDmz14;J}GABV4mMO z`PPxw)#l0X;kO#UV*DoH7mD8vm3eXpemn51!!Pk$^JL>&Y%bW+4>miO&_Rxq=fW$x zP6_ABAHms@?J#+txe9hZcSahO^PO+{hzJsB_gg16Jbe{CnLC%u3rjsvFWs|_PmGQe zMy4J)3VHU>rlB}*4V^(WBbsl0;;QDdkNdtk)@we}cl2}L^s!T*3iJK3yykhwAmaTl zc}(@0_8%tQ`2JGRhv4h%m^4ZQ#%9Uzs54RstT+a}y?OWWj=-bszNWy&z?T0)CWNb{ z=L5<34AJ0HT*O6%$%18 z1ehh!yd=UbN#-TP%zRZ^VDplYW3WP=w-Ax*BSM5!=dSpp8;J;}FSFY$@oR! zcWpnbSAnx7_USye>Epg}JEO2sU4b*UOHK?_zbx5Mc-8FGRT>24xN1e#SRn=iS(wWG z`X?Xwg)}MVUWNGiFEH>c3>79ZMOd%pDiHnk*S_}1uTR4+Ex?foP8bNh;Ho4I|N5k_ zJ?!f*5GUt$^c2Ne@4PDh^btrI+~AE`A^y<*|0ealcZXr@%Cp!z1-mWd)_Su+PXTAh z!)+i{T=!%qTYvk8&G8{LmcdKr9eL-`HTX|7T_^An0KVQH(rYf_=9xP0rfTxAt%wRy z2DvmZu+V4NKWujGkv`?kP6eh|Bg|bZY$6d^rFERyJOadgRHLRzW1d3B;T?vkHTC7C zmOtS6@qc|-9pEyLw3~^WIn;Uh3On& zK~D#GEZ_{eEWr-T5P+5@71SQ5frZ1xitp@v)G{c9CoKbQVkXXwc*Iv+Of5sby>(bq z9^7bR#FBwTtPgH9;5)P#=`mXVev-Weo2U3u(xSl1NvTP1K*potM>g>A9q&0gAA*u* zhI!9-CHx7Hh8t-Zf`ut0KC^DPknzGt!USyDO0MSDMz|V?DM?V~;>c2xNtba9_hHMa zpfXCP$f~v8d*#*EZNVnc*@mMzfqe=IZF`RZ2iE?2;iKC|3V((29KwEIm=KT=_aQpL zt~@WA4?rDUV}^u^_IB@@3v9bGd_*+1H6lp#;L3+lstO}Qn07gL6G}j6P&0k)(?f+Q zkMe?@p&>Dbueg;XlA&YBCFSwPU>Tu???YRXq259IL+*uedhXM&3Y6>XU3sB}Z2BiG z7bkyY>uxfgz)fKn6dIKmO2S0wdad|C(iW_NE1EnaCnc?gJ>w36G=)fz_GcmR#jy+Y zcL`kV=%7|olEPPD_nk0A-e492ZyE*RZ6+gr|46gMdo~TXcW_>eJ-Um0evXz`d3VfJ z()Q!HeK=RVEuSrH!#`SavQm2nJdd}rfyN8bwj)hUw&HPWE@>~ww4-;})!IMwXlPG` zM1*cMQCsyyXd8;LZz9vR$nSYMPf_)ekj&aC?ryRlgDi$R3#v^GC}9|K#F=LzIs_jn zgD})e6A0l}T$O{eeF7PxsAib){)w(v;hT3TD{6Tk>^sAz^P_S(8RtHPEQ5_H$TIRw z7G9ee9uCukFx9_6Ahmg`aCJow?y^%cPDn6V)#XiGceM!!Ei_;iCADY6^L^r)CaQxS zPKJ4>sXX1ekQ$bPaka=_US*SUO3g8lkuDx`t4P>OgxreC@v5(iZ+@JMZjvWSl>m3V zX|O0R?3O7h+3YWzY+`L*A zkY#0AgkeV0_yR$hoFkX-o+_m2@^Z4rm8YLx-u~w$1@{kGcu#rFSkmWPAiT-?!ti4k z(KC;5ojveizQE(uVPkSJq0pe-&-Y!L#VEAswx-h!p;?;h9O%KDhjSKonHXi^*nvws z6QCvl9dJR`@%JKMMQ$Nisd?4dWH&~-k~y^Em+N~p5?2{ue=NUn9%sAvn*FiajY@%- zpgCm;=K3e7?q*PgH=br?D5xcpDu%YQ!;<9#OZ^c-UCS3nRE^jX=qy`Ow? zdF$58g6`$pbwO`BotIHNr*ol@|MKnp>BFJUZBEbO8&0=#*Lw|{HrTE`j-ee5Jnq(pPP(XX48bf@5ATTXwm!==A!~jcZ>XcLXD_K9GGIfZh>YZJV{>o^juj ziPc1Sl}be+f;J=S+N-Dc*x6lxEKPl&DS_CrKxeP&yn0EI+)G(G(7q^UFvd-rzZEwyEHDnl&c@oVl5!K+}f-xZG( zLe#=}5mUlZ}XLiQp1vOv6DBUOCuwSQKL6SMB*scnKNU z7ZE>3BVg`juQ-6Lg|XN@=>@SN!V^>^zsf#^W{}>^2^E%vkcD^0TPTlr$RJ6I$Mu%b zzUzJ0{;2@5-9|XTDvN&@#BX5`v&muopiA6LM>$$IvMiV=`bDA5&YeO~&fl6El}43? z=WORFEqrf_A9O<=i2D|zdCxqH_|Sg6dx#LWqM~99a-CD^^dwExAJ*Ap!u3nA;AqGz zctV=9VNH62)2639TuBp~Px79Gw5_Yj@@uKCt~8G*p<}|eHK*WbC1A~(#qjv#$P2(n zm(G+pVQkmTpv34Qi79k|stDr}XHJ5U4*q^*m{MWY9@4Skipcmn( zg&RVaZHNbm3|n;&s>;I4_$0r_?Di~51DELpEJ1XYCZs)84_$W3OU=bdI54uPQf*h# zI9}80%k3sQsE7l-f#ntFhV*%B&3xecb-ZRO7xbc|mfxPo=i+X0PE)1U1=Zm2AUN{b zp2Fu&BKtb+->mp@E3M~zZWeg}#$i2y^i>mQlFU=$bu$!F4h^`H@F9*9N=IPN`gO$E zhw1Uo{jYwzyM9LXz3VsXAFwskJQuXaP z;o(xhUHe2*bq3C-4K$Y5UDr3Lv2Qggm>!{Z?e(fvkZLw5CN*I3o%OD)q35{%)+$C8 zx<~h(;yYBZUP=dH>w@0fb@KP|y^w?P>5g(F76Pe3z(9q9a+qK7voDRg`=&AF;kEUq zl9(7FQ0&=bmkOj^Kp0kDs zmxQNrwHQ?tN*QBT>dykVC&c5Z8tP@|BNC&w3#LTl>5{B3TQ1YYKpR}>b0)u6faipF zjDdPndpTj`a}AxNOTw8F$j_HrK(j~$JJh^*iLmo78*vmmZM91O+TI#59+hWPsMf|qw!HB=^m za|%TfT70qVDCemyhE(u9$D7hyZDKZU*`gB8iw!7RZg{%)7+Mi~Fgv=@?a!c2{T-6O5?96C>)y`!lPE(1C zqM&qZo4b2-9j;9n??Y4UFnLgd=q-ulaS9u-8Xnfu{LD$iqg^ex;AUBeYIK9`Mi0QV z5~1Z?5Vd>tlS>T_es1R%bCRf@OW&QmduNbmH0rs531))0>d;EX28r%or-9{=o?M>& zLHSlGWOJA>=PBowB&K>@F^pSkwdc-7#j1AV^LW}qU|9>~!f4bHUzTW!6YrC=)Nosp zszW=Ug(xQw`ZI3-;on=_RUMiZ2pZjA&tt5fNoKa`AwODzX{;UN1K~bzrv+mU;|u=w zh$j6R_cz18i96}*@SFUbFk45~`?|nyFNrd^ zn&Xk3_m*2g#q{8q07CmMy0w6hhI_l9KGu?r@=XcjZ)~c#>Z`YuL}%Ka|26YbWm7{+ z-cK^~iS1{F{PRj^`THsKySo~d&3vMVr(=*|-7gi`r#UkY#j5Lj&gNwXES_L?egYT7 zeAM)fJRGb8%DP7P)`U~a*wwA2vPS^x#o5GytKKCsf_Ng+vzi53zFSi6+C0EX+m zn=kQ9=sX7CAyF^h9uH=Xyh@2 zKwe;xRaZWBMk~EzlcrT77b!Bky;v|wcW2`zRw)@0lHQ9EVxerYAIcW}IxOC?Kpe06 z7U@e5jA;z8?IZrj_Ek~1ik)QM2gu`*?`lQ8WMaHazHe)D$%9@;vIg9W{BaioIpaJ-T5pzjnJz7iBjXU*0&z z<9yRacE)yXYjADI_(3FG%%V0{XM8Vq&OH6KS_)ir8VmTQwa)bAE$9r0Q3!Rb>ufm$ z*FF>7aL*Z-1S#10f%F+1Ke=(&&O(Mw1hvCim!{shE_KK;XI00L`CFPajw0o;B3L2k zsh|wGpcA3ri-plzsL3|Cn*#C$XI+^sg`)ncdwgHhjis*Xj{w?L8`R)#sN0Z%XBrJv zH>xw*+k^=7bfPKoA5?>5;F*TazI7j%Qy(>timr;l*2y5vEUUs_Iq{VbT+*Y3=V<-F z{e0ZJ1ML+foxD5I!dD#kWxVu(5M=q37s5@`9x+X$5=_vLomysbRy}DSo~4S(>`EBY zuj*m#y`hPLkI3Egr;0s&5Q3E4r8PM@lbNd3sPXjAS8vY^M8;&w5me=1t|VrZ7i zE`H~NZZ3W=;D`MlWP{!{G|QyFYiOe~r{Dt%ugjYY%(*+#Aa#fiDjqd)^FIoi-sdlv zo`ln@R~m7l*W=ua7w_0#$gs+oqRL&*>x3|np7qL_6!S@?W$rvwz3M6aq1-I$7-?2! zp2mC}F6z3H>Q2B?7`6tbnsOGRFDVsglpN_>sb+{~B@EV^lstU3nTq`L(s1z#D-t?b z#~9gp%P*F@SP2{Zb<3>za9mvx1iqR-Q**6a&Nn$jQmT?gNvTZ6bzJ`FK41Td(tYkq z8aUunfeUEF5I^B|UIIq;2b&U<`bEKh4&ENZ#j3!{32>OaL9u3Mh(1Uauq0*HF5wZ^ zzIfZ&ZVW?vSh(<(XJ5R~W6wzxjP`;EG+l1J_rD5Zc-aPp-~T8+;yl5v!GUh6(p9T< zy2@lY!;4qIbyKQkjBqP+>`K+j3^+htDcY6%O5g+r#=kcSbWu)D;r{9ht=9uh{=eIc zd%XR4bbon8s+I4ELq8y97c{c$pijYAstgfxuara)mFoU@Go=1Hg>3=0!#MZiV zMUUMWS7Wr{2yaZRs>hhCvh1|flfa&F0XZ)9_K57`vK*qe7jjW#Vyz0Py-v%udnFSX ze;s1`+b>&aRh8*!Th%WDpX`5+z+4N%FB){s)wAZwJ4>;Dg2qPA*rPEyaDjE)`{9jz z#wR;X37}BOh5{nt5Rh)y5g8z($9w;c_LF8O1i*@H_`GH@lfum;e^i5DPfQ3FM%^5T z=k)Z5lFV@#AM6B??Ha`>LLIlo{L_R+o@C9$8$e-fyE@^Y4=q0gxkRO1uwbn5A_MUt@Y)&*zl9iap>DMtJii`Qt}EeqiUzWny6#O+rA3i zZH48IrM9iKP`-7J-KF*Koeiurc3qM41}>i;#AQY;6AH6>+qhw8Re}-=U(WKKE3QLr z!CXn>9Xk72CTQ@>w+^#z+^!q8`172HQGq;InN7u7D&NyZzlouHah*3_WU?k2L&19; zPUXVnP?s?zU@1Q*Ikd9AJqpVi5xj`64-=SNripmUgYSlqe*11s@!NCt!yAmhefJuE z`|eKo?YmX{?Ym**|GdZj&wI@Oybu4M_hG+%cjtEhw*HG!@&4?$@LO_#o#isF?8v)I z2=81hOyb@P{Tg@1m1!!!1D@I|Sh?%N(rB^sehZHM0;K!)V>#V-znjke&zsV@KSR1H zNavK(iC^rXB@+3%L+eZgOr^&rZ625HKWW|2Q#F-!M#j}JV7sL2EFVb)F=TY$gorn9WoAOX0k3!_}jhx<=v%{Fz>G=9O zIT@YAG3TrP;kEn<<1<3UYS;cqMy%LA^p-AfJz5}T^E&_H%_WmUzyAnTIGhxf&HHR}{-wo>E zCs*phPu#IF@C%acQkQZi4T?iitLd~BckwISP3^!B={n711tLaBmAivh`}?lob{Mu2 zyHl$D*0(-O>s-0juI1K8-3m#THg)2YtA%3%w>~bOxd~t04%Q$HK0smX^7ck_81Y|- zmDHl+oRwQEqYAZs72Q7AJn)yoh(yEPS8U%CINuLWZTcE|K_BAh!tW*gP(Nz0o_mzX z?RmeoPx2bZ?STS6Ma{X=lHlI}!RVbJQvIVyrunlk{TsOR4wa>vhC8l^kN&KI)hh1s z!`p>b^8ze}`@rJ5U$Le3sNfV^C^jp*gmCPy;Fp&>J;sSO*TF8YsqcW*uX>H` zm+Po*dz(>jvT*2J{Kr6v$ppT}y?-QTYBo%5^PqHb*9$tJWHSk4rO!8Dr{1>Yg3M1G zBl8n8z+X(@^gPpYYrl@ExG`PBWR~KJ?UlS3^K(r%L|86kIJED!C<~X8)-d?4wkNh% zYU`nO7m4cl+e*zmc)e3+`@vVQt=edN1R!>(G~$>p%j-}V!Wj%4qnP2g6TV7qViq0E z4L8^wI*P<%!PWr45`9-Z3?HpbS<$QQaZZRYJja>dU~>Cz-zS-&xp#B(qRnl$mZ*oN z!oc^e23uH&7Z1H?+!hq%5$kAK4}Pbs3P}n z2t8w7Ls{H%x;^BR_IP3TC%Z$oB3uobfU`Y5RtI-}Yue)-D*aZ7NFhOLhjhp8cxSKt z+-$ZZwM8|o>ZPE&;jGWe&skY+FP1!Y1unXxy7j#>vnr~=WiCztoNYI?Ea!`Du-Q_z z+@xG%Vy#0Aq5B)3pd@MyeITEIzR^=B$=Ws-uaUZi#QMN}nvCqX+=a19XFSzqdkQS1 z<3?JSkw|w+WbE;5dW7AORoY&sLOpczY?sHiKi;;H-tE%K*lR)a?)iY6%1&8`WG>aO za$@&-ZTm*Het4_26*$hSewDop+f`S#8yWl(Kw4D(OA+oDZ`{*B>@UNMH^BbUC{Qn# zs>?OyAwbz9RybN~;J>go<`Sf&>v!>X+?2R$ETROhLv?w3@=#%(OXYL4^@0-b8n1Xo zXI~WSf+XS??1VK1+I2en6yhn^bwTzik5aOzW*pLYuJufzzNcH)z3lA?yqQ&f+35Qz zPvTkPS5%e4$w8JdD*nZCDLO!iknWmH8SwT+Bka16GIP0k3XK^2C;T6VP+_NRJCPc5 zD`RWL(mBaHewcX8SI_HvV3Bv5-3S?KAPrINN?yEoe?4ELv;A!w%{n@5`Xl-|lN`7H zoV=(0;Im`S@8mMdw^b?&^HeQ3e%fqES6?a5@|kq)pn{eG+jIEbKl?j3Leq;b;5HHm z1l4h0y-f0LI=gcj`{P_FkG6|muW3(vx*HO{YT+dgX zw8gTXlx5^Av30Ui!qYi!EpYKX`mzYWvW2X^@s{}ZW3@fVnw_p+jE4xeo*vyl0iA`b zhTzdqOOGdmbDr(XQYD5@D#;}FVo3r!fY$r|g;R%XKQDuDpe(WkbGn3J+ zw|Y_+$s2LTCuD~amfMhOe6(e$U*FfjBUO7F<@|S_kf{lP(2TA6%<#E&9h(v%q^0A9 z@9c196qbP3lRQod995$)rU5@m_^m1G?^Ev@A60q&kc^G*LmSMbJik357U>M@pxg@m z9KY_a;s2rRUErcB^Z)TPXRZuK<>rWr#T*zI)JjG-!Amj&B8s&_ie|Mdql08wN|x5z z#vEEiq)mr*1)^;j9Yq8$qZxVmE@F`xwt{BX(ymf$YKjPGd4c)AKj+M#?f?7#|6aeB z9Os|KF{a*Tu=1E?g5Hj3}b$T25bEEoKTSf+2qw{T_I!ioG8(TXrmXSL&GN0 zvovmE=MC@BxLxp%o8i-5y~7I!?_9EJiEov6lz1bG*Yo2B+VO}QVaX5aQ)K#;-c_5@T**=ze$e!LZn_xwc6T6 z<*`3v{*2bJq-XK_)tXB&FNPE_^;3SHrow#Q1T8xS>EKsVr7w$fT0AU%&VJMZa)#%h z#;PNDB#^PaN;;<`9u-M8Tdjz*GHGrNSMwB1Wvs}_!3;=+>6hGTngs=^OuzFiBpdQ* z@C{RyTudFC@vYZtVe%|AQcs0Qtu}m(R@+bgw620J{0(D|JA|7i6Z4QdqIiuwFZSv4 zknf*pIf-V>4&;`J-VC^5)>%xRINq7d{0N~H&V7_z%}72kiVz;FKe|t}uDGiI&%p!6u{8Y`_x)=6Ux(jDewhrCB)^~?HP{-(`LDfK z-ZNhPYgAsHzr0p|d1wCj@+`k9?}%8Q4~997`MrJ5!ors|$?GJoKaGZ~=cdGj2Emb9 z#B1E~aqCNAE|WO!^HN=-Iw8~HTvL+laJOwNU7TR`ROOZ=d)#e%_AO2@<%BL)4F%s; z*wZYWC<1-5u~_m6801aBZ{-r=p4s8<98h$$Gaj8=22Z7 z@SPF#{1B{(uq9zhKVp3xCVe<*eWZCGr}LEF_>=CSPX7#zVkUc4pMUTsGEMtJOTO1EArnI>P%M-v{d6Qo9tI z+8Ua>+v+Q$_3QUzvB9Cfs4oHkIaO@CLeugM!KHguZxeaTGAd`!^7-V|LKp-ZVgkd> z&lAC5Z1WMt$}hdIjo{RI3l(|d&cLZ(^Lt(#)Pr>Z;}<$0p&Wu|LMOW7J6xtT@A|(> zNM)hIB4)NMlUw}0K;4yqhOAjCU8qX3>G+rTaqUz#&$)z}|IfhHmuGI*g_8VttWP)4 z_FOMwb8t+NvQ90YvmC})!DuX}6^oXX$)JNhqqL!AE7S?0IEZ~8B^h#h^%df}cnkC4 zgS~NH{b!c}$9FuwbrI=@WBz}KW9$8J+_6!F<6Shb;yKDN#%teqSvOi?|Iq_RibHeN z&K*1uH(sb>Yg?%eyg1_f16=--B29pSv>u^>+8)BRT%`|ePxtz9c36(Ttu}+#UU^wp ztlo6WBYXp&ELZI}K0Yw|Ih3t4se}+lw*Wf_t9w}~#EoiOKwHXnk4TRC_LRfl0*>u% z2VBO8^IAsOB5NoLuB}i(&v%U=&N6KHu;ttzD&U^KR>8OL{4y|hwg|6&4UJu`f9zU5 z_TiM%p8pM}Jmb25?4Zd@qKJk8U!%5RUi*^E&~vgke0^)LtmOAu7x@Y|t8x6Zc?1(6~ zzw5bg53hZlEAtI(&#wm-XM;&`3#DDk{lY-IN?!1n>sEQ;l|+B}u$Hf4Z;pp+#c=%_ z9xPsQU6D>zxSjm2MkJsD)BW_pQs`d)Gr%(KUGRF#mWky7&rDIkd1fU!s{lt?vV}7I zrZ<(B=v;5VU3FP^TWz;uBwow-<36Y#eWfh2J*&tGHwGrdrzY1i<2TP{{N$aJ;!IOG zA%itQWeVoeT zN=3eR?c>ps`zGS>W$v7BA}(Rj#LObh@UsFWDwks)XNz(J%Y@_o8 zGY95JTH_s8`t1iV)y}f((4+^cNu?r5^Fu#;n1H20pp|%+Ansznw2H%W2l{iV-@fzG zJ$TXl7rgj1wSo%XNR>PhM01a!L0(u&=&nuLvr&r9i^Xvr^TBmJuy1 zZbU>L4>Ah0-93uZozf2{M;h8abhEtrpZs4vB)=c7Y!<+kum5fR_SY{R#!_T&@?*^B z_Qs#xTLhI+oO_32vDvHlk|*Ib()={SfOcSSlE$KP0LE?S`t7+G3;QSaH%*CHJfH5p zZ_a9$MG)zgny1YKydm$itlqtMZU1))oEwxz)_x0NkRGbmU{JvF91S45Ns$Vb zFp3tLo;Ddo0?b(O@q6yNYm6yo7pKV?Hf&vJnB|kZu);T%^2wxkg}*{Q{2sl!m;2TTMkx`JQ-L;Q z6f%J4-p9WGhcQG5xQlP_$=b0jW!Lb@fn!5>MBTktP?}P|WUnQ9Xf+*bfoz+9=JuO% z>IPKZuzBPR3o_;G;`SrWbe7FaCgs8eCB>c+=?@59{pV$K=H84sdrh#ZR+^%QlBhFk zuPdsq%~Uz!UaTvAaUBY1f?uDgT{$7cE0>v0v-iJ!7?3K~0XRa>d^C=)p_*~j@Rc4ERq`muX~O8s~ggH&080G)9o z#?#9nsz<%nV$;t_@6%#=_clDy_k905I*g%IV& zO@jLtkx=6ORFXm4|F{+rN4XQoI8|F)!8OA#_O320xoW=lM4GZF1O?Y7;O&mkw$V@m z$B>D#a0uh8Wbh5j9Ovsp>@La?QEV~5aGv(m+S^mYEaE~l|HC_T1Q%^V1AJ5(rOZV)0*s$7s`lULrzn z)j(c%X}^9R5h{w=`d{A)M}z2N-YoiR@PGcD`1yY6TL@+Xpgp}Yf7O-5aT^NS zF2=>9i?o@oW_V_5Y;KjPfag7YImUdX@>-R|=o z_?9%@m_5NvIf%quut!ANctYW|ug~^xwEq=#iqoJt!4J!}@A`O|go7~;n>rM{wUT)9&?F5~0i zS)SB&TwcaZD!U_tBUwf(UPUrKoAVuc6?@xR%dASy}z(EYd;ZpvIv z%GUAm9S6G;j@H)=AHfZFId``7dJ)xl@R7k#Ai{f_sfRY>Zn=Wfq&IH({yT1cFf!M% zB13@+*xiXfuaJBd>ifM8Cu7gJA)Fg*gw-pxP->pRBu2$O*Lm zp%PcXmVzBygljmMd0I*{B;QNpo|n=(%>J~K&@YwJ+)4c;@xk1{WmmedylMXLkmfJ% zF)?+pUs;X3HGVcPi=0 zN**Z@-tLzMOR`D)&O#^LFAkQR48AxR9^+cYV_f9mJmrL>)lOIJ9NjG0%KF`Tv9PNh zdtS{*>87P~T+wqXN5t>leZM*om~cqXK>E)APS0>f&qKQVztiWLRI-@RL{YPj{5kkw z!`@?xc9s+*B$p(^v>SWd+8)#N-3dvGAJywO~J3xOd#LPA91d{y^a76#mxk z_Hy5(Teum0DoClQR4NlCFMLfuVB59r7PqiZg|}t)@8A{Vh{C?vyF>3FWa0;;t+(?0 z1rzwa91wq?*K~SFeQ0HR+Q{Y<&q#&t5COqRlRT(-vBwF--{gh63Ho>&1zZD+RE^yY z%j|Cv1&?v00ZtW;1y7)%I7lud`PZnfM=sr&wjD6?4z5>mefha%+6TCb0OzZ49eyt? zsT=%rZ6IQ8<9(842&1SBOB#dwM~^b*u-#MH1#`3C>9w96@|1l+ic$5{CKQY|7;3xU z&}K%Ds{Alx+=obllNf|0Y5vOoJuMb+L7}*nZz#=ldf!%-BxlNZ3i4WRb{|U+=}^N_ zQ_HvBa{cgs@E+3>OH|{@m{Zf#F@6tcJ@Cjm_QX zTT2@}r60Y`HqB%_C%SbbLxgOue6Oz!^3m`=CExjRmG7AyU0+pnv}`YQkm1eaWB{c9 z>Ft`VZu!oZ_lWV(@nla`|L$C^^{mpW(&j~v;-~*)oqh$mDVJ`Tg8O+z&dVnQq%>w& z662|=W4kq3&p|nKCtNg3_1J#fbCLyOv&wf)xos7QGRy?ViGvO*nlCqMln60?u* z;^npc^h>zsW0;j|y|C`&dGcZfq*kX!n&dQsB^`yU1mI7vPcaj6Bq^=UjpAeZ`RgXMKTgq{79`=QiYw z2n-~fwb;qyiN%=Ncn8ose{wWT&>jjv#1OuHhML9a&D>;u6r8~97hN1yyVxJSo#?xyZSY_X8x)4u1f@^aWsLF*hA&!G0|p2n(< zQ_aD!?#!=_w13eFa$r-;F5%n3IDiE12;kz%wcITT0v%~2ZuHfCUbKCFW|w#d(~sE2 zJ;=?jgR@|P#{r)l+Df`fw$VMXThqe(2M%@O8sm5W-C>N2EDwrc9l3Fvih7CyB7KG^ zXkEu44QJFRo)au7QeD`~P)9hfo;v8=-~!%?@2@i^o1z@!#wpsOFaj`0-ypFHZBad` zu&-@yB=OU1ZUZ+0o{z}yN=(e}LMZ{dEs_j8vIkCllmO90ZbWly3*yY-Esg4f@SY$N zXf0t$52L<7Tvuzwdhy!u3y446GEqvmRU_esgBp&5;%BQm9LUDI~q)t4C{f#KC!ML^%_WZ52d3g z6S>VY?T#CA99$0_7u#h&SXYz=&86D#b|X3p7r-8;2BYF*pN8}lg1?Jsj%~&k1Tyj0 z;tuM8bOL}ek_JU`8)6$m^oi{{2<^NX(5(V)XP-7tJM)ueS~_ri9me4~Tu@`uVgMI?D&mf|89_cVA%qCsy^PI>LH=X6>xvV zAvb7x*4|F7LsO{kcQhFve+{pOT4DN(d+@eZlNF*qiK7JfkTidlG|0Bi8Xw=LjCkVh z7-)a*I#|pjKIw2{jOkNB)wV3-z%G$Pw!o&g`I*bF!yz@r;_IpriIi_N#7Y1?RW(Gl zj=9J-KXBoQx6IVdY6LbQCD{gf!U@HxEO8aTzYjUHU4XchQ>jpJ}5BCs4SS!U&x!=(&2su2D@(M~Mbt`4< zN@jh$pj1FhT`6B5oT+46TwedC=kD#UCS_jtTbfjvICzB`*s(R&xI%*J9kmN-DTv9B zcEf0~;lfwbYfwR)|gR_wBeYmtQd8+r7lgqs>}a<}{vH#_oBFyJ_?wp-kv>C;Ycd)m%+ zahJvbA)DcNchzOeZ`*yt{9IqPqNJEopMmsyJux(rf5LYbepJ>f?Ekn;9mcri7pcDe zx6DWDAYuT2Yr^-G@ZQf*boaO**^HWM2wG4b$s}< z$wI6!->b5pbqAr4g*`{&YZS;i_*_4td_!y5GWJB(EK9oSDH*4mV~EVmpqAjz?!AI} zA`<=iDk^cJR*(h0ugs4r6^`%D|7$7S&CCx8c6}C2&5P7JZKaDMSK)wxVr9^&Yq^fx z&6hJa)-6z4zfxFLL)&7&+?RIhQIM_mliN8MzM5;TXIVid<8Cf3TW0-AZdLt03!!GE zQvOb*=te6=D>;~8I^Fs9gOrw~Rp1jjs<$7Y=euy}Osx#p)-Dt4unp5zp0LQ&CRsqG zqB1H|rkU}VK^B>6Vwh4T!#*mP^apz&3rzu>bV~BFHaLK40gON_Nu||x7sfJ7;}$qE zfM1hCG$1bLu0(b$bO>u)G)M<1O>njr3v1;Oaj&jdn?!dYV~SkPn-N(1GRaKGUQ6G_ z!y#oT(-v-jq?WX$o&+9oI`_Y=ZIZ#fUX!1{pVPWTD_+Rn0D$r4Iw5l@Y5@&dB!>Zh zyCw~arR|m}NW&ks&;?z6z;*oc>)aC4>A|L`$6#DRQ5ezQ6*Mp<#R*Ya$NC?VlT1WZ znPk@ET#UUee$jPqHuBIB!Xm#5fXJC?wy;XrQ?X=E#mX(&;9IhmY^IyV*@D3qE7j_* z=(a>XRv1KqL=~tgiz+ILzVdC!E9;4$O$<3<@ndxg{f(W(*YUCZc=_Exo=?6jwZ2dW za`ACZ*1Kf>z|2OQzQZ0n(bu-bU$yPO+BGv6&6+9mwd%T`!r#=*oyhoRr^rT>VtTfX zhPD#+Xz&ZfE%2RglEuKQ)(hZMzV#DY{amy)>apfjYBWfD+E(G4s%_SD(P->Q-m>_y zf%Za64Fnlk?}#ppY{lJm3Xz`)s(w^gEVUbWX*PUuf@1R3SJbUnUk7#+_{ZWsJ4G8= z2mcu8r)Dm&EY4wl4E5R#1R7;>7`;Q9C$M)*as!J5F{Br=Wu3b#h@WAQ#^GG5KWCXX z1XtQSqHi&3@AzMMEt7GeHmjBFkPXb;tXXv9?!S|DP^(|I)8s6DLkssr**)Gg<)jH2 zo#5c{s(vnK4)2U3Qzf$ZF)G*AJAh#VHgEy?JA+&&e&)iRWd%@iY);%$R&co7p>R|N zENY7MG>VQ;z9Gh)Zp4Vg+gFFc$cgIvvj2T;ow{6)66x_|f0=%B%>z)0E|OK|Qug5| zRq38Ox%~v?ZJyM$0HTKUdB)(_zvMrpF0?SYO8%@o{&TNM-p{koCI)A*N=tPYeoHOga%aVRLt>9BImu%WyZeA3PfS0kJLL21yv8slQ0Jy&5JlJ7gnQBbsRXytLre z?|wm9p^udfYhgv+mU=#Bl8RKs?H_c#J|Q2$RFs0skq}iGE@GPWeF$M|cZ%g63=~>f zmu7?YG{Q3a)Xr>QYTnnnR2%%AwVl+I>5JmG$BoB_=)A32^ESQr2b8gWp~2g-VIpQS z9PWp2Vo4}>sDEjV>>)W6vXfS2Z}6H_{S)`Is&BnlO!jqNm*#HF2*lhggm=vrfdumD zD7(YjjsFc`xoVh}Hvo>lbX#_}L>5+gn=6{GdmmC1qJKX9+<05pau#cyTbe2F=j7() zUVyVvQPOx)eMFV+vfHSuB{ibrLVC)?zkE-e#o$28v|otdk?rG4+wOR$YVVFVu@v;{ zY52;IH?fLDy@{142Af#;x%VLo9zj+7tFmK$H@qG&yfy&D-_zY%WxG}Z%Sx>VmX%sH zEGxBeGYaLnIGSAQ-rR0!kGRy|HX?pv*9*TG<@tP29F063NOMLE(VM!2sw7ygCz$fb zN5UTkDFtGEVEr8(({+_?ZG&{pD-7ztnYQMpF-YX9Hk3Or#ZeJDv}N#4E}&|xH$iKX z?~ENF!i=C^!g+ENd>&&YDXe0t61vh%68yrXY7c%5mFOroDa_yk`y^O5aiF1fbU zZ!Q~DQ{7PGyfm81lrc=rp@u`wOJhbBL0R{4Qj5hO;kDuS;RT;|WJhW@$C_nbDoiU> z9_ovQq#|d6(G~S2;@HRUs1fDWWSfY6=Y%O~yt6ur3$#ZNw&FA+`SnqtyG&;W3-bPT zroV%4kbGZt%#Yi}`7*H>ZmN%_NJCSkrYTZ&v&wc>0m&ie3N57Zp4uIsd?!sF`>q^J zQe5%hUdscES>2!U0~HK+u+_?u@21a-t@xdb|FWbaBfrM)3M6w&{YfPk2w1lFUPkkf zY2n?ckqdZ#(=5o8GCdRE(@-6czzDoY+T_dpxX)>^-W=Aj#p9{h|Nhykh1uuCzYemtg8JCN>GB^f zg_I2M4F95cB?s`rs@jm%6yNkQ!|0+yP54m0!Rma;3kHLjy(BWpFMArXJ}bb`^b+y)N z?h#1^RyvqJw8j`>GSA@6Nz%8}C9b>C83)sBms8YFR*|=R_mCmtfrN)E7@l&r{LaUV zumyCh9s_#fgK#Q?1455eI{9{}MRa}2xLm9E>`4EOZlRK!!lk!l`to%sf$tEt4}w`K zUK4~0bW6dSz(kZUVE3kZGYwPm=yATmL0fLmpIc25Kf?nYk_`KxV)!}Ttb`_dVUbr?dLgyPD@xhj(mC^X805qR&6mHi@nmJDRldi8~OKs}k89}U zV7q!fGsw#ZJL3!B!c&_hYXkZYmz9jDd8VEs~wo0I&d1)X10ACijq%19hsg zp?EZWt6nrdIQz-YYMs-mD5>B~k$iCE_(<%i8)6&P3Iuy0GO%NX`seRwu2Hzu8}Z~` zpe$KtRPgM^R3CY5dtLpIRw7iglx?GN#yz7nn!$n#PN!R77#yM>QikrHIF#ziQMsw6%#PK9)8(0sMXqwH9$ z#_S$$)t}fkFq>CQ`aj;A+saH3F6flY-G+Wqmlvx=y?L-XeZtg5>Z31uxk1n}5GAci zn4VExZveJlz%>nf6KD|q+lW;sodFUo60_L-+4Z*ZfccyE&IY!o>#0Mr)S_Z)$B$jh zg~ublL31#jGX-(7rN+BIfIf$m01Cd+p?{AWk159X+FY-%-+`6PZsgkUi$mAZLb?RM zM|Iq(=8omHCplUNLdxoKv$J>_2#CBrMcFjfLg-27dLPZF@?B0&icD zY+cPm&*H>^dLgE8xHfDo&36S5__+hYM{}KJU0OOAbZ9l(UGirY%jKX-N3O0IA2Wk3 zg!g0>(dy4CazmO^tsx5CS#|d3-uVZlbz)fTg$EcbyA8%9COw-mdB?X6)JSrx}T;eKKjD zv)TZ$j6LTfngDK?K|Fb6f~r;auN!}aT;mYs$lFM8O|S$w9_L);vTVb%z;3NPqpMdi z^-+7?+c<9g#y#cEN#%DEHzw{IH-V0ysh}T^J6Lz~`uu_htP~X#9H5cGltO@fNFtvrJXzmHcysb<2pfmgge?>d7GDq=7a>1#`+EvgFh9~SdcNS z{Uno{|R`b96RFH=qA*q0&!_OD>d=dnQGmQ#++K(9Q2PK2AZYvHJ97I~$NU%ov} z4l)I|piel?Q`IYe0>P=c4^jJm0DXT7*W;gCri}qyh7xQ7&yL?Nej5MGc8hi`7|mI8 z5$J{3&WHj+)kuuzlZt`Ise7-IN0XCqMuOM;=<$3=Sc4}x@=(LUL;eckNs~C%H|LHb z$mD#D*?Zn6V;20yv0GS(JA$4@7G>a)YS8mZIB+ZSX#aU6H!`FORq;%&KbaE zHVYe{&|;FOLc~`@;YMp0bF(2;B)S-9`SC5E(eCD6a@}abwl^TXhh+hXCVbZ?5={tE zKrONU*H=ih#TiR{9MF}3F5t!ZOWV?(jDNTqnkTShCadw(F-+28XJ*Xgr({`Q1!QpB zU0dpllrZ$7S)hoHuAIru#hezTV{qozviyTi*YpYa!ZT)$ z?w`H`PRA6^rJP_txee}r?Kij8Yh0t}z)9lyKsW%}=1@+^f67@T6Kr!J?0SI4LACKr}4Pc<8x&MK!6J2^I-Eu6tv4a-Zi(p|aRa3B#NOxH~CL#54x$a_ro$!!kJ zggNC-R}AABJ=PUB2Q7siH|QWiGeI~$XHJo`T!VJdxITPc2-)>x?jQ&FKmkvHMK_Z) zN@i=yOdE@WN{$cslT!7jsu@SC+!b)3a+Waj06r44%oh22GiTFtRH^+R-gWB@LR8ke zH4jP^bX<>xBZV;{%-C-H?5fJDp*89r(<|;WHsg(LBed{4jL49p2hBP*GY01bk++Iy zL>1eNs%ofLl3@;*0!t^aZD+!gA}w}4Dm@8a;uoh&?{%mRpMcxRh+L-`kFu#A5e?Cp zMmks3UM^akevH$Wz3d4M-LvNmdAMwk(zGJR?`a?Vt0N=O6*?S^>)0ERDv9S_f?tiP zME{8U@XX-lEH~7A{3pwqA!&-plm@sPGZcWDwnT-+$#icqmb3EA6w;2#uxzt{D(f{D zN|Tuvswm@jqnm3eAe?J|c}w)*4v zm5*;LQ-kqJML_*7O20Z6Qyqql#OXdQOIQP z=>W_UTZ$>ESUG9i8nvYL1(T$lFZ0gI>wEOZn%d{5TyX`;woi#2Q{$R371>-{9;?|V z7DHvUQyI{9XBZDF<8E&ssl)r(@Jvv%&Hgu96Oal>*_y`JpRT+!n7oAq;d!j0on`Z8ImLTz`>em^&7xh{7~EztE=FWCyi9!r2|O$3 zQy<0+SK?|4=R+0sFY{RhZuqnBptjNJ$|9bXWIrE1s9)thsc3b=dEUx@?(ndTBX$%f z6vJ$>P!|2yAZ{x&U9r)4ptEWX;?VkN$FIDWP&uy~8W}xqyEW7()3r06t<0YNL5OHf zYxR6AkM~(ak<+N+`M9sGiJqvS!;`t_Iu#!y$LWC2@x$^`ne&l&r>o&?4QrIuW_q0K z_8iDxB!+djO;xC%NTFI+((ZDui)aJIf;mZ~ktN{&XAm~iLG@c)IW>N7eFn>QPRmCH zZx11!f%~oQBULayYqIQftxb zJU(&hW*s&FC~R6R^N~_RV@_pW?x|DN7tq~H3aRXw*Lj0OZRI>sqe`9^lMH1t;K^j{ zlW$_r#9kSn>oz9y?7Nl3wAervH=;I1e^((1;czY?o`f{G9e2A(8zEvLRq^v(9%t`N z{Tn+`J1-uH+w$xycNA$PErpzbl48XD6b})Hz17LQlcAke+}ic8x&`=&Gm^B5pct%+ z>SVm#JE&jMmqG=$HMB=nF+dHvW84C{?iDV*R4_cbT=T5P5*nJ6w7(<`OuMESQyS$yUOJEI42ey~ZT}oz+Zu+^V={ zQS|U;1`3CnW?7z@vzo&#=HVtYDN^DOP3kxsm^0LzJ;R+b-3>SMep`Xuy2=oX{PM^Y zOY&vu+d?A61o>qVuZxMl!my*d-oyCU;%w3i*qy^dDxlGS=F+o2|BybwDeq%)$n3TR&HvE4vpnhR$Hmn;4C?SgjcJ+ zsz>XnJ9KEVmG)#$z0Ac4a&5V4v*v-d-(WxGA|y8F+V}jCy$mzF!r38-;fTeZ(=SQz z1#BE;_OnDhNNSZ=>g~lh2A(R<*}L3D8i&SFndp=1fM*X}G@AH;x?+{%w7flFio_dj z+3XcfH3k<|m%6a3cxfnpl@6?i}G;#3LKIW0c4 ziY!}?ArHk-IokqVhPk?L8Cb4JJ_}K%ZLZ5OrxGk}N}|@llcAF_+2*(m83~G#Z-1Sd z!71anf14@t6*Au0nxJsu^cY3h5&2M>HSt&0EU45F1a)8hc5LYZr&5Zc4)mbk3AF-R zCsNZ42fL(j>bq_don8$Z{SI`vPT|^$YYi^o9)LO_8~0If{K~DC#_&2tW|W`TDcusU zvkoDq7s&T2`y^gxW-H5aRz9&#k*&mrdm`N2b(hDnrxO{0SYaW%y| z;%|bfh5Nn#@>-&&;?bN^ouf9<-fyG|++B~DFlHbq2tD#kt89RtV&?j4i;Kl0kx;O zDfOnfl^ zR+?0|4)>&c5FNsGG}6B5dn`ya0LN^}ovI1$R6`{uF?_$8LKozZf7x`uch&WGK`LQU zO@@V+Aiq7bNJzd7uud?k7pJ2j-OB;MFC__9)vdeRAZhlX7QD8v7Ar#Ga)imHss`%d zZljh(zplkya5up;W3g5gl3^ljrmiEbgzw!Oz1EP>wcj(kq;ccz)}dz?jSn)-JB#I* z8H$R~u-MRmwLc_40vKenV!M#moO_v1`|lBvXkS0qJdJ}MRC)m_&O;YcdmFsL60CIjG%4I`30&yF2~|egkNV`1 z?U87{;SXKunR{&?aW6pM?;P=NN-k$_-pYmNLbptARo%of^$6Beuur`G(NBoIc8|0$ ztnO01$uLQ4oY-<=H?w#UL)NU@_LJ&WCi@@Wm{Dx@7hXj7doFu@#%t!!j5dy zv>L$9d0kvMxMO)LCVzD#0x+OEE5~gSVn^x1m7IoqK`Q{e$B zcGSAzN|Y{WZb6HLst^VW?`o&QOGKH`U;(9zMWZ;B)nUCWsF-27gD?$ZqmkT{z?7L@ zxR}2_$fc-`nWW&t^VdhNHwn>mbww)2mR%JNSL|}g2OAs5A5lVn>$)?^RIX2yB~-Yo z#zC>DJ3OnMzud~wDFEwFR~!uJNySbeYdjk7IchRlh;}x`E_d;>O;Hqc3|}Fvv@G5u zGf|r?*Fk$?mR}WT83Tn+sNc_YkpdA0=cGpPHx}u(ik}ip>t0r8Jnx+|J}A*-!k{N> z`BumtZe@7__~^;FH?g+^;f0#k68=X^|56Fsnn26DIayU$;I<^07+M1i6YrgxD*M&Nhn7rnys zdq5B8^TGLiMRGc~#Eb+utz*|hqKg7DEt<~H`JlkE+=Y9*<6S$TH!l%&* z^wCb%#i+p$qmDD{bI>;hx+gWOBfJj;=iw35XeePwv5=>934FbY|F~IG`6LHNfsrQb z$DI6G`B()qi8L?M@}sYAm6n;mKsIvx$a#S&Srfb)o+NME;jW{^zcfT6Bm?4HMGy+u zAnDUh>{3Oe?F^!4&7564HV||i7vM^Ryvva$bEVOd%*B7~N>j|PaHT0nZgHilIIR?8 z=Y4G>Uk@LXo1anFoO7L!IUd)HERQIXB00?I>W%m_@XQ8^EHENdjNFjuXFxvEg}8fb zNm1=Pa&P6{FuKus3c7?6jxx-@ghboFflal*qeE~F$EC*=hs%V^f-4i(?{PhcD-V}| z>(970<8tDv!L<)p1FqXb(zMUQ2Iao2>D-jUsVE|mWpO|T!edvP1Cll3E;n0IAC@I71ZH*U(55zk?O@gA8CZMeW(TV*kLRIca?A?_pR;g3z5{1i-&7cebvam8`203j(9MLv zszY99k@KP^HI@NSUlySaEW(R*2iYbBviy+V6=|Fr=0c^n=>g_Kxi@tbBVe!AY`9S7 zjj4Z*{$H>dDbTqRPIMzH0#_d}W?cA-H)mn`1&22^n7L3&E~)8$F#X@eF&8#@Qv>P$ zrU2%`MlYl_^i!x}F1+K#VIThk#xMMt98KbXh-EI=y#mWz@9D|O)gr3U^ok~1N46ew05+sUudfCQi}Z}1Ql}xdq{!6o4M4gO$GD95#P{6+ zn+~{&0&vc03lPsap6JAy>hweQ*));RT6Sr65{O^B3I6gQ9nmlzM{a@& z=5tG$AZvN65c%E(|BgF@d5j2aBBaA9TwC&MstM99*&);-1+^%B3pWp(O-s!3vqtd7g!Z8(BOhEs1nEzvJP}*Ksu8(2%E*joci& zpKYPDRd`%@su> z&*Z<-uxL^5sial^8CG-tPY}QL=Fe-e_c)2yU<9v$e4IIpBXzIeXjIDn@F6U-8k_If ztIng?Jf$q~42nYLF{>+zU!Rw?wJ-hU*MfJxY&!PCq6H0!`uLB%3G&xuC0n3*tgNGs zfTG#d=B!gDARZto&EX&l9t%uXi!{w?oTd=xIPQg=`@kzIqACQrY-w()6f@{Y@8N^O zXyf5rhkT^n>7D=F3_(7ec6NCLBZZfghhu+t$VVXxgLv~`etED*{=lB{$n~Qg%it7t zP)=IKcS=d$8jYc~@Pk+K$ipzbUyF8VM`52I=yJY^mxg2T5o-UF^C2zSm*gTQ(0E7M z8DzwtsK*_V8*3cKg>SPwxJk2%VUsp)+>~D)l)ru)V42|??Ks{9M6M_663Km@WD7C& zd7nM>ixlo#N{c|1WOHp;elLyt$!8z&{C#P6q%`+3F|9#@)A(nc+~@Vl2_Xrgf*iif zf?|r;)M#whMV{(3F5!HU3=vsLL%4LKb5+)QHSAZijA~&_OwNKAXoxQ=xIrLOq=h5g z(B1CGK(}fX5kEx~Ipng9&M?=BKIeu4mmJ8V1g>j6&Z3VhSm8Je$?4hQOnOnZ#}g8$ zZ}XP1mE~N7=Zjum8_H>6ns}mMLtSyrsY4AlTfi&!^_K=dTT?=K%V71H3>l`wch7`x zBwN4})m^U`ECQc(WQ&dwuqN&5FZ~fD80LRCkuJ1HI3u0Cf^oU-95|T4x-~@l z0jU6(B5Q!qjy%pt@LS<K*NayWH? z?rhr3cEnxTMCebE75D^D_?o9?93Wbf{CWqGjSMP4TWy=KseVI9sZLp5^^{#oG$hA7 z4r5@E4QPoetJ)sMHjQB{@D+9j0t+RIh6j2o0|Db5*}fBShvQmVmE{|mRq>N6Zd8sg zR(C?3CP2HyqFSr;Cj==xgb(q?V{>lhfw;lm@o4;%2|4jK9-wMps%;xw=A27ZR& zwkw6WuAJ5*SGZCx;kN3Ovs&v(xnfPZh^Iy^lQSi(QE^#!g>ikWbZNEQ4=G(KS18Zy zKIK&vRAUrZM%J`!_o9{&vas*L5gK_q}517bjwS5A@cA){99o!iwq zdTO2W{2->R$`Ln87pDgCf~No3+bx&h8<~N&qu+7CMmtog;s}Y-{Je1u%j{^-l-s=G zB5$UXpzp;W?%-&mjDO!L3Xpj;k@tkI-Qy8(urS4dkw_L?WRQnfCj&j1t1B4qwpqnhf)wLtt}ZJ$A_t0W8$PbMwE4Kp^>GA;gS)-s zU%)5y-EYSuhQTxDX|c4X%*^2}fO(kv#B~dn6I59pl8~-!v68a!X9rb!aXGun!i4|?1%Shn zhV9`Gz`fq~aE-(1+#DWK$TF?~iVA20G*3bh^C7-q2lY4LV{hGvD|XM{FeeU;R`|S! z4i&~B1MIbL<=B9*EX&rFe+qpAYMJ|(T(p6M?#R}^3H)-7br?fphYuEvp|Rl_{VUrj z-b|$5H;{g%_#89vyj*;a9e93Dd^U>D-{P!Y@UM}{CDK^Om;5a)IeWibH-UrO1TIEU z&RYbjhH7az3x7$DsO8qvvOLwfl3T*!+4k?h1*O0EaSSbniaI+Yh#Lud54KBtc1BXW zlds`__kjUXtq_&41AC?ImZ<}+ zyaZ?HGVDzi2Q1S*0p0Q^{4U2e57$&&!MJXH2zzo|M{v1uy^ZS?T+iYfhbtVH8du+b z%d`$$CvkajmEn2`*KctdaSg(CYoBG>_qaa8wHw!FTyNr9g6j!f3Apa)%+uDOO#!%1 z1D?C?u}o_LviSzT@8epF>&*7Bq#XRt!8HljMLZA1Z`WSSv~#%5p99SzlP$2o6I;>; zFrdp0PBQ8(*+IN`Y$1TS-IHWo^}aUyf4mk$j5Ry-=3CnA!`?+pD1K;m==FN<%>qq! zy?4=KXLk%kXLTG^^+E0W(3bsPAsgb)Fll5B4_vE*G>JH%1Yzx7t0B>$4=eePOSRQy zPCzpLR6wKF-T7AW*=m?X4#7WUZzd0}OwNg73vba5lFG{wnT}2UH{0}}eC5yYb0bKx zA{P{zc%a(sJ5UX~=J+(cT8)it;|`p4sTJ7Hs@15I4!Qw&NdJrYzAoT@e=W(@lCEXK~m4W-HR}Q`AMr@UnT7RcmC?2lKEMy zVhf=i1@@`cFWfN%T(f{f5vI`Lq%&8+n0tbr1reoRt4~uysHw>VVm+}_MKu4(UK?E!9 z{phVg{T;|cgWG{g;FgH^_|KSLIGkxY*MI*s*uKYjI62`YTNuJ{f|OaDxR+?^EKZdK zTP*R99m*C7GT)@Ml zxZk#H?>{+&z@AD!+qov0OG9R12GJRLhD6=h$~>sCe75p}ixSifEI3Oxr*HH1>9M%gg-(S)g2#V+m_nfsaY0W+1(31e22))bc#i2hw1eJa*L# zM^H=7{i)!1!6e<(V(sT18~7BQOAE%R*V0&t^A>kt#sLlQJONfE@BBj0hvcIX^L>r* z8zM-E`eb%*NW@*VPU#ouX z$>Bp<9=gBZsgY^^aC zu!6<09(I4}O|ZwIQ4PI+RKfN|zLp{V?M`2AT3rLYYunCF9^mOxLLsW8QJ+ZQDJevi zSS9=zMhe%F9cp8l*}(zP`kb zrWL+kb2CMCAevJJ0gmRbDGZlk(&9gPPugtQxiJrn673fc^Z_B0U($|uJAtx7fwFuQ zX@tkQ2Za^}Lbd@m(H`uk4vctJcPU^cjr?nRT*O}qa+QNb3@7PU8l*GL!7)_ln#(n3wCO#Fu* zB*@dE;w#>}vI?sNJ8)Vu=8a(9$ztuaY@l&76n9#ppoHw(Y^04e@6&LLJ|qdtuz>tZ z;WPKT&N{W(^InlpLA_3${tDfTj#1S2QvGQX&V5C61T@=FdASJ6G1wCJPQUK=o&w#H zGzF3@(UZWm_G9acK0pJ4RDI|=8b7cC?6U|6#&UoMt3G(c0eh+l6uSF?!nR8U3UrV_ zVm@`vw)TE_6rn@(3uk-vw*Jlc64?96uje!gkY3e6S8g(n`C!JvuM25}5aS?$-J8-U zDaXgBz6X3jPED5ZCKxl$-ZMV9Wh!PKR+!>`@Jc7VgmFjYkaHF$gR=nfvMT;}#0as- z8Q{9Cxytq}#7mJLn?Gsay2t$Z2e}H1yKT`Ar8N94qx;0NY%4l3Mn=}cPr~=0PO(~= z8$Db1=*=G_u)Mo%oTrqUhtW%YnQ3{T4+WEh01<56&ua9kzwV>^X-&*r-OVL~uzP=k zjq-qTP^t|uy^1YdjDC)lv^j0nmwr7GCLZ0mZzz<$p+If%^7qig!}9nT>Yz!^!2I}1 z6!zi1yM7}n!;;REFw4Vmpf6}E3_a7Zep+tal^_k%)R8TY89qt9i8D3YcEHFsJ;ucU zR3WZ056I2*2e=2X2DV(fdomC*T`jBX&3zL5EbN>w{&289LsTnv$CT1y-#%EtC`fk=~B49wd&OYJ!!o?924?_JT=tMjOhh!v-f-&zs#@Dl&t z2*wBHeY_JIrShXom;p05kNt0?+6D&uaF}Ets!Wf_li>ui+G#pZDK1Xmp>bG8M9%j+~gd*qoWGTv207 zZtK%3cpgADRs80gdxNxieVJzUhXjo z0~tGMXWS#{8Mt#ULA;E8Jtl`A)VK{!3#;T67<`*d9Iuh%0H@jT6xs{nnXWr@4XfK8 zI@8CS?KsnaM4_t*DYf0v?!xah3_zrZgW5K1YmjPg+nPz}S|8qI; z-u)YdS~L6DMBQ@ubtky@nV&s}!bVa}lb(rF>wwmBV@{nzTk~ zRs2tXWicE)01M|V4l?n}O;N+Ke>FPyaATT$tC%|H{trqX27P@FJlh={_(QmYPBF|V z{C0o`*bCmxp+L*DYFrstEYp}3`@T2=o}kxz&1*;6R^ks{U}kQ5s_MDDIt!D@Gi_fr zI%QD6yMCFZL>Df3TW}+yMuVbL^xqlE7IQ>h zv}sE5isKDZj*eesQ?;+wW|D1*`m%!-L4@P*UD2V!9VV&_!SVBrAskVJMLVCfSmzP+QRM^Eru+X{iy6f zbQC0N@RQYY)Yn?ut*cz%(Ieif$7v2aPWKnCkTLKd*!?9URfF*L;h&pV^()HLr$Y<) z)492;5hXG8Pl$ojoh^v$ZDi)b`9M9ZZBt3i3)=Qlo4P$XuuBU9?D7>xr@#5mM)h`L>B)>$6u zQY)C6;Egqr4z&`3aHLfITubfF0oQp`Yndai?(5-M2cCHH|B&e_GCgu~&>udkV_UO> zQ5m4bHn~&M2>f$&t3FtFa42}#KE13*I#rEXFxBYZ7M89@dP!QeRHP%vF+nBL%R4N=z!LY_j5>ZO2 zjJ#}sie}g@W@U=4QY4_biHWAd{D03mgZAzB?f?HTUguoi^S;mfytn6lu1)_1c>iC4 z;y>P5uF5J7S)yvnV73ds>hi3QgCVG1xEkvuvtcIy9jYtmpbMbd@JYpqM7IdUMINd) zA@`zcRJDnrU_as}YaJ2WwTWT=+p%C3xf7$Xh`Mq-Yc{UU0qo%7Q(pqG;>7wxy%Q7n) z=$FR$$`-duHmrQ>ay-D%d9q^(`9x2dd};DClXE7wOm3Jgn>=%}VRDFT!_u<)cM{pm zSw2^YE<47pmhrc@)Um~XWU34$)upa_uLd5rNLf6&589*#w$>m+q3BLyPq*v3(;kjH zP?AnjtB4+Qd~3loH(pTXG@>v4esB3Qsy%AiO{rw5!Qj) zwdMhv%J%O?y9$?Cv<+EnK9uE>9g46Uv&KA|d_Pa&k0)fL1w*Secm1&Pao7UTIuw1q zP9{A~xSsutE?jg3x1b+(OA6_+t$(Jz%x)aI=k?tq56fq!R-D~e5Z-9uy#@^}Nb-Uw z>soLD{+B#E??etgRxl}HW%gLND(?|qQIKNKJuq7rWmmGQyfElkO>0?=1A;gAfYh?~ zo!vET-6ewS*ZnY4tC8Ea;WG}BkgWaLI6G^$AM3~d+H2I>wYnjBJg=+Imu3HX-jn(! z8I%3zoM-yyJsCXb&)IVOmxB`(_Al66g^a&Bd-F&>&}I23f~{4{F2Igr$q9$u7*R#z z0`OH#!J4+9-@m3+zSRwnSjs^-I?U_m|A_PKXVQV|&2BN|pV58e#P%9FeAZx{ham}p zw!GggF7%>jN)rDt$=J`fl1?dnfu7j{KR7~Ks{rS`SXiR=?b(TsjOE>#p$PnPYm519 zj#Ox&5A1?r@nay4;L1kfcWd+bw7@nY9W2SKM1Hovj(wxi(^8w84i6YLT&?K)PQqQ~ zpRnJmQ15Ki8v|VqHR*HYFoqAdz&={69}!Wh{t~hN4c*F_a9SRf(W0`$__kT#V`UxBs5CV_{=w_k*UZ~gG z&kswf+EVQ$n~ItA_%!PbeQ`s=?$R=E>06fcw2;z~^`#>{_#5G>Zwh4cH^!OYjuXddL@<|1W>h`!i?d^9y@gkab?lev)>jx%L-clBDz&1aZ=n3ld zi;$~zAhjZ`1U>%*QYg}e_aIY4+KJ>uT7;B=Gz6(1G|m~Ka}2CKXH=nQue~Ao`47Dw zCO2nH?tt!bUg^!c(CldEu?Tl)pgZT$t!3_<(QZh6H?hiNvPp@Cr&P-wRDEShuGhxr<+txjp~DMdmHp@=a{hJw{nbMp*n18dYV) z1v~XM`ouAMw>3f;olz^KS+j|f*WFdYuH)eDN?bcCTahK}oHA>t(lVpL%4Mn+u*y8v z614?_Rx7uoIjlH`wHih-J7R&-yr24-e#=%x*wmhkC%lGH^zVCL?A#H~bHyyvXc+bM zOOuk-RG3Z0o?@#)mYyDV70yhj?)M%etqX^fKxyu> z;$zAobUbo$q*zVt=V;Y>CKr zo`EjSmH3GNa_|jKb*8F7<9NIiFHOv?g2zzTp=`(P3P2?p-j!5|9IN&Ty_qpMen&Vh zRd`qHDRaW9#M1}r9`C{}fUC^59$jrPl2zJ@Ix%#v$1xhSC@e}h-2oZY%j zvnFaf|8$#>Hucq_I{jJ8KWg;OoX6ZbV^8O#wdIgt(veE-a2g(S8^)eCq_r7RTdTc6 zJD|sL;)z;tO>;)oh!zjDzmG+>9#2`Dr|Oj2Yl!sbJmRGszw#QQyg5l;L$o(%nAZ^F z%}MqehNjBBIm0n*?%e6>stGu47*%T^dAc}9({!OL&2YK?1gmV50qQ-W)Nxf7N4PkC zrYv{7{{Sn^Hgtkc{hZSeJ3%2_I$_RESxzD z=tGYmI8+P`OQ>6TLbAcT6J3hnq*oZDHQCR*=%>aiF?4muJtU?+#0BcxS zdWNU|gOK!t^;YfB0|)oUg`^Ah>4FDA;q?cf$i%UjG5q(|DlgO@_8tiF9IW>o^uXG| z1cNtF&PpZTlaYyb=mTpAxnvC?@x)$~G0*(E+Nv5HjXf#eI%zOqXgSj#FR;ZI8Nu*q zZ!VkpoGf{rW#(o?0tpYY#uXHX^FsI_X-H86Nb$EDY!!?Zm^%f)KS9f}{*`{RrgB(! zPuLu3s4ahZLjI7Xt1ntYH)Cb+f|nT;gHJUr2e9Yw8u-A}E#*erKn{G^->n*8>9xmq zXFefgwsV`UNlOsgY|jVYq-`H9j^{PIzf67qu)0UCHe;FjF1FF|=q}l=5U&TTBe5_6 zB7n;uA~YU*2&9X$$1nyj);S7AU<^m1UoaB>iUjW@GRk-)g&=Ig}1ERcpg z&OmPT#v-;!D?0Qd$rvXRB9-CYHA*z+0(V%^XcD+l><4!$nLFM&%7;8N$dr0+0T z2yD|S3>6`Q0jm!7FtGk<877`O<=u^*dgbHj)n{J*k8S!J z<8BC^IuQ&R{^`9R7lG1{)|h{tlh(+Ju@DeJtzqADyBuBqXIYDrr*TlZPG25unfJQ; z4C{{MaOMC)OlDQ?#68P_3m~Ef?h*(U06FilEGsJU-|-MA;USSGn!qY6{CK$6r#uV~ z{6kvq_T6Vz8*g1zZek#_8C?`Bwz>4*+k9P=M*VcP(c`Ia#OYh|>HGJ-%wn{EBHAB9 zeM*O(X`CCdmj_!?s58Cj%xZ-$Q7vxgfYlh#hqf(U8g_Ndp63e{(MzARI5i(HFN=}5 zu~o9H=VMlcx`q^n20l-u2TD{XF}nEZ!mr>ngj&7aQ=34oHohxU{^Z-UazpyAs>o_r zSz>j$*YIfR*80-YpVmgZp9sXKBK3k_$mSVlccp}VeA~|SUC;o|($X*t;;WxxMXuZz zzDwH2kl(R}I)jhK%sv(1`D`OHVJ|D0_K%NOXZ=O%#r`JtBSG;{A5DX~42Esi@!-O^ zC4~v$tz+)LuRME|-Y0QQVT?O&+t-WWoC)lcH@D0>&ub2=ZSU{?vWHWI5B&e2esTkHM zmz|cg$H&3fTAQedTv@A%7XC6a-}+C(O>#Y2(!(dWRbJ%BK|o1P;Vbu2IUhr}4CTyp zZ}O7FZE7uqH!iYZ@l+KF1!Q06c=`-=EkN&+!?RvD=jVSlVQhn|*gR@4SgDUmY6;s# zUtQ~P&cWFxJG6>oSyYzBW`|~nPkD0KlLd+u;hq^k>7U$t+vpmdKbVbIKeF&Gc6o&M z$IbP8^p;0ud|=U<`n|6DTLs|=Ljd2Fu*V@KVyWqspIluzdXX_#Fs%s9*3Hc@4Vfa? z;^$^04#9ectymDpFt0^!qWK>IZxd~Mg++6Q%XRYuO`#jAKdaJ~MV40A?-JL*a?|?= z|0aDFs~;KMXo{~jC6s&-UBPRQr)kAABPiiv6@o{0JdGU@t3Oa%TaW-bQ;EAE-Z}*V zbQgO(kagFDde> zpnhxL7YODXfQP$@RB{A~m=~ShqaB7nz3yU#9bA$LF7#cgtsM^ICYP;Zh1&%0LRtim zDLLT=|2*KmlF5R1kKR;7IKX#~=uM)^SoeMtoz>y|+F{v5BycMAE3{a%o_zAjM;(l1 z_UpXz1*Hw_6id*iMKcGS4!6&Wy#Qa93$^URjU3|HPJ`4^R(r~4J=%Bay6@umwdwzr zl76jzx^nN7)fe|J>m9~oL-u3@XNC14jwV+l`{tR>9BR%su{oXmJ+GYY$N}@{;?!;ik=EKEv5Y-pg3^HIxeh?edqBygRr83!sw0 zFFGf$VOB9hg=u8GHq889pJ0p^8vDjL)hGkyw!_90J&1mQgOo;b_vwVYRDa)iy?YMJ ztG!Y_dOAiLv-IGiZ{Iy1q!n<5==c?Z067i9iCYOPPC4VW5{8^|nNu`3fWA zjl9`yI4o5uu(bXV3?I|CZNL21*n+$R`pNj#_z#~ciHf59h{i1KeZyj>i)SsUkh5dP zhJh@gthanlk8J1rtlp`A03W2U5*O{0D^3ryCMrO@Gv0-bQ<11B{flxd4_`rG;*(+> zl}4$K_J8@tjP-}+xZ&IRK4PYded<`Z31Pw_Q9OSLQ|c^NmHJAcXYFceg(f`CLhIRM z3N95!NXpo~-InoDNT>_Esay|h_v}R=mk|?6*Jb!4P<(inhT1uSWA%b3<)&|hP+Ba5 z@3`3;zm$Z*Kk(vu4gY2(?+=^MC}&=wDT#dzY_gw8i&2dCvE*uTB;4JpPX{vF*r~2) z;p?$MwQ$|_n0iFHM~Ea({QA;m$J1K*4qm$^Y8-WsPGkst=fiHovoJ&4#x)`+J3BQa zS~xy7)Eyw(nKo#I%OgavdVO3baVGRd%RWl|T7nC0LLrq>D5dK@^bxPEK#Vh~r+)JF zCIMwY6LsQPjPYM1T}N9kHH&-21(u%{vtBf3YhU)mZ#AtH7y7nuTA`D z0GMDw7hnbL3OKBQ;U}Vd2E@4dwazEWrp6_++SqiP#cGrD88FE<+ho=!iK@XL0aq7K z^Yex#cd?lfmT44q|Es&2&mG#JRRFD^q4Egbf9`UM>F)#b7G)|qT zp`d@ahF#TD5$R5ns}{i-Sz|br+4n(( zq(`jbFJdQrNBiyNxbrRD{&o?JQ?FW%x`ns7q*(X`9MIok1vo?D^sz-Sq4I|}dhpH) zFLk0|kx(PVe!?dwS_HWQQ4e5sV-VzWGEHI=(Xl;pS(}`JKNJbm#)((?Xkp?#+`)z4 z{7>%tK6aeQ~PFc^)0D1!cdMC+IMNIr=3 z#$N?NL*D_HjsTy;RRXFFJNmY!)n&3-YbESmw82Zvacx~{q6KDfO~}}=rHJi$Hhvxe9Cu~ z1Aml!5K|FAG25tq*PqFHMK|_dF=9sGjZ?ihKUF0jNb(-JR?=f7Ri=0IN2#PWariYF z^j+|{jbGzyqL^XcJ?h7y2r4ej_ZM0Dqsa3XIp4-(kPO`4)9ABqdRe)WK5mb`A}tYO zdv9;B##ZT$at6doeb^1Dv6q}bhJ(}3D_m@q(rMD!=T`x0;0hn-ZTUGDdE!cQHN^PrLe=S80muc>7 zd6n{ht++<5b7o1E7RbHAV^VF9>6l}o_C)5mK&R*luUT0KgK1Q2Od^fK%AYTbbK26l zfozigy}p;OrCi>S)4t*VsxRX~ePjQ3eN^_EpXuKdKjSPR8jT!P)@4WrhM{1FD{1*C6@$y$(!zJ?e%VG(e0$iiAi;xMlq9 zZQ{AdvK0N6WgYDG&d4d-AW(qP-+>V_PJ?*zx~)Mo-*xj^_c@tMAuN7EfH#}7ibD1d zh_FKIyM>br(AdIrtTQ6OIeRFsS11b;ez$8DoWc^SRg8B&9S{kpJB1fCkm7tPw=3@2 zt?%GzHliSE$Goljx2z5t%3($Is zUAlJ}Uew6bh}@#W1Fz(p)e>Vk?{@e0PqEtJaeEbMW>&(a9c)6A5+Ws0F9+=sM;_JX zA6b>0=#8CW>dS_tk2)vX{aTVXWM^c(EaJt!!KC~pTDpSlKnBE=8fb z0<=h>b?+~bGVYg1$@m3Qh$#oq@-|CIQEanY>GVMu3Av9ECHzOSdyC~j2ypaXrVMM5 zgp;wl5B)fK)#teMj{%%W8~>dyd+gXWMZP)ke!vamWr)du6~e|A<@OVFkykDv=e#dd z@o%`HSt8;_8u{%#N@+21!Di~xDa1KO+K)5}DFErw?aVk<*v{^U4Nn1M==JGiJto!z zW_~mdul^XC#`9OG_M<+-pWg)eE3i6&!6?JCc6jueiR3_xBOk%QSgA5W^Zs7^OG6ii>ruciF^eEG3Y(Z%0hSUq zr#h_VZJ(&Y0r-qfS4bvna7fh;?O`L^*a)gVhN_RI>Z72r0#ZS=^^{uL-ss;Luy(lL zn+MNB@BNdH*cmVkVlZC4#T^@yJ4VpKiNJ)&4Y#&2@&`@?nq-Xmh=bRTaBd{`0h2*H zBm{|c!(n-AAs=(;uCK@Q2gw)M9dV&KbOj1XuJ`$5=wrLoMa{vEtkQ~PfgK&)SYN+v zzYrKmzfIzp9#tfG1Yt&n;1oSQPP?mUWSitGZz7HM4{5%M_Xn^SA zCs62&^)icdH5&Cj!>qI(% zJ99|#fR3#U>haiqvQCs+Pu@du-idVNbX|wao^n3pIiQEr3n^n>P|qtG-MS&e4)ENC zKybRAey$`u{r0m(Z-%rDVoKlALlr6gH~Q!c-2tbM${IbgN#nr#-dgdG>3T49pDPKq zPNB1D(Kj;C@9ER^rDc}DYGN`BZlrUZ6x~@wK ziAkRN5wwupQy7%ueeXC_M4*K-Y==98+OzgL{&5_jGJ1}N!Qg6eV`yMO!@TBt$A|P# z2n{qh1Sj3$x2F5*UDwwal(iV9HSa7BL1_2EhdEO&7#cL^;p=NRe8zbj^lPvGe2L1e zq|cM7!KYl;8yobn9#E?aE%A6)s8$wQi0~^^D+)zlZiQ;D(2^)Vkr#^Y*$UNcS&Q@2 z9+SJ!G7K+bR7D?cynQK+fJTMz$2b?~pb|tLuJ!08J+{2pu3(X5PBa{r@2+&bO!Z?_ zzy%pr$&}AtLo7f4&LY{mUd32Ai?k1EHH{0LDMACWk;j-{kej!2v(0SrRK$Kcs})W~ z%(YvOUCZT|#F>I;TEgZifs^IehShQiCm|j&4(hF+(Ky&2r`TCR_L(1|LgD#&z^loH#T7E;h1FSAjUb+v(7;>GO<3&I>oM~N-_uu-lJiSLjoI{ zZ`aLqq|<{_>;+lMxzEsjIqSWt^FhOn(?GoSH(%>q7>pge4NofPh#@sjQ!%CGV+!f< z0KS!1#68CX|A0zIGL=@t&0O}H35;J-rX_+2bMiUhGZ*PGq(Ow+O9QyA%@r}L`~xEj z8BPGRg~xuds+h!~-t0u3k<{r$YmKPKDJBwka`pfLs*VBhGCRzxed_ zb=|t)+V_sfB!frq%@AjgS_VHRQ|r7UeuwS&`MLy#qw8~2M`#WOnt!Zoar{6J#OZ*N zcGo%D=sqUEtO}?J*y^A;0q2zf)T~nbolB}zW^pE%RZ6os6U-`wS)2)G71(z)1yYuL zwqj$-wO6ra*2*=>^4iaO_P)LPu%_VB*oO7*+`hh625`!0LSIy2f^=(8ciHupw-D^| zR6y;qA8XW(1%wxxJ1tu&W_JuIS8484{_is8u|#3I#ve@h@*#gewY4WNV|mp@G%qekO7xr0UUc*9r+^ z#>)7B1LeHjn;Y-WP4J4IsoX+5RyMPZO_qF+AB5W3s36X+RA%iCs`2NvX?{zCUzxy8r zJm2Wu3etKMFI43IN1=rKpX{W%vH1!%J}_d<>+F{34`yW7^D(niu{Q7hxW4{i{jDq8 z%g3%w!VXgwnjHa-dn7pSCUD#%v1WqL@IN^2;Q6O&vqQbdM6TR#?~c#NYuaGH{FYmP|FHhcC)|@?%@L4I1ShsToBk8+#s+qI3BzbkSF2_h0+O|saICrY_A^tnGC|@RCJPf_xLhd0b zFMF2$G-CPQvIN(d`~ZkVmd!3_7e;7HDp0qJ5x4+qEJdp1C{iV-g!TM@tUWL3O6D;b z+-!L5r1L2C5oA|f*-CzJTSL0%?IQG&Q!;N4z+US#gz45@LuaWQ4Z)5dPN9Qd(cnC!lX3WE1CnYqFs8M);&MoIX}S`wr6f>mBnod_gcbAJvZUI(rF1P za#_5VP`5?v9Ps$kBop;_!cmU1f!bC5Hb1yg8`#zm9P`Ry&aKsKZC)#L*iX7#$byE9 z)3jo(fRjf*)3H6`N^uBtB1asWyQ!&AyT9>@4a)Y(SUy*Mzv?zM$`xWh5-ha~!EYdA z4z_yg6Ximvx=09yaij2r8s*em5B)LvBkTB9$YjrL1Ya?SaYFAi+H+9W8UbYt94;i; ztMOl501Yn{(14A?VUp$68l0>g4jWn4A5HpS_fKbeSG(Y#J?Y@-OILg_Vq*h(a%i8Y zy4Q6{HD`gNBl6AuZC_Cej3e+Br%AQ*HU&~^f8T)J#i?MhyI_y`#RkLvrwzuQ#MUOs zf3d~v`rlj3=Kud&4Dt51v4$T?_=tHfXdEu9E9BXtgTV=JykH7-e08KM!lm_KPZou{ z)yb?rL+=%(yWW6uuZ{6|7U-+>UK`QZ=EQjHzSxU#EksTi#0^*octa33U?DL<5I10N zVuGj2;*~bw2z(?Qky#TH#+cc?>-AGy!lHPHy%r^)5OFO7e5Y7`m{;^!*Y3FK$19$J z?Im&yi^ON2wSsqnvARDPv^lP?pSO-bkMuVrtc8})f3A8C9{tzzQQ2IZB6HrgU_Kt^ zEfAWvP0U1PX$Bz&?vG??GI&YuK}_?j1J*02y1XJQ!#J#~b!(4z%%vZEr{pu<52=yE zevUf*GjZTEsqpN6dA^n^Y5Aw*)}->&u1sur*-2(E1INf&q5V=(c{BTUbNis~m<@0G zCWU@DCrsQ5An->8UhFY(H0;-z?eem$(ZO&*Zl%{wcI$o<`43a3Gx^3jhzP|$RqIhp zW4OJsYv!{h5Tu9ovE6LJL>#r`s39?AvdkOrXGv9_Y0ov*By47jjSS_=9>_JBav3R? z=-)m+e0h;=uRg<*8yBYAuN02Xg8tGT3N*c5;t?AUb>++{;h{)I)wnWIQ4cqx9PZM* zCVyS+r`nzIHG9rNY3Zq!H_6kl$nUM|>SmGE51lP@4t*0h0N=@W!>(^(g)840jezi5 z0T2GbJY*Ov>ot2S#+tJ-eoUF_DgJ~67WZBkWd$^mmfyAfb7&JjpY4T&Xu7#KWOD~(_LDOslWSe%2Smnfgx zESA5a-{)BUZB?X8T^F$HL{*|YfdBaHt`1R35+Qvk(y@m0l9ZL-g6S@wS}h5Gz|4|U z*c0J#9CE}wjwL6c%7wDA7CAb6ZI$Pt;eVJt#g#==sRLK9`J2XB**d09xlN6|XPdlY z*p|nF@Z_U9P0`5u5ml@4w1zdz`!;Zt3%1JVcbzRiF~+Q#+yO9}z{II+J^sEdt;2k* z4QeNXXT=zE5WuwC{9tB+4eQxFyEYIHh4(fK1J41^-A^!202BNqBhc;&>-K_}&O~)u z#AK7(#!HO^o{j(V#dP$^F&cnO(K+;5j2yhCKG@oYa%N$FI+PHeUWKH*AeLzv^{st- z!KxcBZmo7s$IM$kv+WI3k#>TY+Y3VZ_O!Eho6UY!W}X-v({3)9XwNRWUtxPMCfm%e z^)5vfM_3)VuTzZF2AhE@xHyKb$GCm%K9Y7k#lwmoL11$inYZs4Yc}UD7SKpGxJ~^A zh&8n>(O`n>Khuds+3m9sL`pDMzD6*=c}?8kQYzPzW%(B{!Gxe0JqAHAEZo4p7dMv5 zxpLju{^9ccq+mG5Mo2EKB{W;tcda6CY8WfuPY6$c0%es<5&8GeY*>$8sG5{}PL`{j zm9GnR^jv^Uo0O*2j-PzF;t6JIbx!Si7X&7vt`$PmDXS(83?H5^5byT==2C9_xQo!G z^)O)j?)yRH^&aWWAFFHmo+1^)tAnI1=>#lF5?;f}=U~Uda*X4gGZ$RS?dRd&lGh5h zOtXzRz>{y=3rOTe@c$@ECEmoE`RLz8JXq$hhhP~rP0L*>TwFA%e^+%m;}*H9oC_9Z zWg~8z+qqydA~e3eMmu;w9@%bEh)TI6CygQrFqOZuOOmH0D$H?oL50 z!3K{BnJJUjtN!>0ewwtD^>C9QsHXa!?c&uo);TNo{%A1n?X)ah@8YUhbiHIfV#CeU zYRS3*9~qA!-rmsWk)p&sl})_?s*Zv00J*J)*QUpA)a&PAk|Rsj>V9P}77^?(DJ2&~ zU9H0i`2h;u!F>j_L05f(SE?ADE6GNk7vSeq?Y8~<^Hu0p!S?q3+$htr9M2NZrk%jU z``;JRGn%S+f;>PnHe9msG0#L~AznEmZ}q&r%d#HjlKDxG|N5Ey3;Nu>-UP;z@3H2G z;re2JNN&@tdDTTI!to|SS*0TJ(&B2z*C%n8GWSA(JSpFvS%wT!HU4^f%?~;3Xk}TM zI%#dSOE2u@HC}Q2l?on`aEAG_Gw))*bRy* zWliT2O-!q|AuJ`PBpVd-VLy{5nT^efx~fi?(;6Ix~ZG(69;(9F?>I; zkmdcR%cOiB?1`q#N2M@>pvcE@SbE1Pb7U88E3N;OAzIm__gUpRpVi};GLct8g1|8I z9`%eE`D6M>PjU<tU13b`A|0xC(Wk{oze7qxf~*IlaZb()EY>Xy%_XePtebhXwLp=p z%$zrmTZA!cvMhu@_a~T`+mlksvcBbCzCIOLQ-CDxzFwhmY!^(Bfsn8E?!Dk?^Bn8y zb_Zeg+7Vqo@14D+rOWE8J?`Jo5UoC#3?s_&XTI6cx&3Dc_0~daVc9chH*{>j?YR29 z)IY8q?{66L24J4v>|_8>Nz&WhK*8X%8@1j zuQS`4q<1ZqKEXz9km}4!=#p5=Oaj)$NNH`{zOWU2fUqT<&#eur)6@p+y5`2*f!(-^uekX3`5*?ZMP#4K$VlW=UY?2N~Mk}$8Xx}mbNA2AKeHtv$e3Ud< z@yHn6wOtfPSFR_ztp7!Z>G$~_{<+&az6t5CNK=uDK%2`L=Hd+cE%N!W23k(6g(|$2 z3?^#wql9KJwpQBCXyavc6GGwDf^MnWur6L+Y-941hP9Fh%m*8AXjhY#IiyAKiTlrt zS~Q|qg)*A8@D-i)eo#uQFEb7ISXjE*CJ2Wa-TN|2t8bD#9FaD#&51Vas3$K7duVYA z_z?}tR&XCyeUCUhe58%%)k^|a-8mf>%75P!z-+NcMAxl*#V#(QOk_HfwSC6i5i^9~ zy+zBS%L6l-ML}ygQ#u2Y;`;y~6TSbVCq~cnEZfHJFNT-v+wMjj{+3}f>>QRApQ?zWVfW2aNv~CG8>Y7+4*~%;VhgSLc4`PmGJ#ojx zJFPSzM5YK{TXy*-Ia%d#U(=EMdOcs9r#TD$MBztWJY?_AcG&TWNg-qw_%hsCt~-XQJ~`v%?8B1QBCzaM$z?d zXEGvHf_eNC-PLV^XLk@)SYDhtk+nHd_=d*K-}SJBsx?HFbYZ`>Qm{y#W@53D@rrlg ziy^4yL8@WuL6qa#nrbmj)TatbWj&p2~Ks(#W%agNZna1dT$bKR~` zH#Uqy31SNXK8-tkUwQxg%bl*4qI@7Bxadi}>k<`por?K>PqjmaQapQ1PDHUh`v8)? zdugVltmbtt3qEx{gD;)$y;bybaLI}dulcsT%w|5t#K$M~KC0r4S(!0d1oIKf^;b-B z5gII?l*jSmnSWq%t&gA_{53t#K5awIPe($&!O%?MDiDtU=HlIIMYk zfZRUYNphf;MLy*mNFUjS3XUJGv@v!zU`~piRnIZlS=F2n#0ZlNb6bAzn?sv=@pL9< zE?`_F!WbsP7$m}|7GY$SU-=g12Z`{hMfh0d*FJy-0RJO&VbTGnWs)Db_Rl*rp>sFU zr!=FvB6JFVVzC~kIR6Q}$0YEYj0|%eczqeH`617$hvX~x%3{oTx5u$XF?wEU*q)#B z<71cSMcaYE(cyMONPdTEAwFowGPfMRHm+p7YxjZGo~LkCQjT+8q0XlG(c3t6^_#RnBwiGqIn7%0_Sw^|MFgJ`+dWCS^0mb&|CAC6ym8Rs4W>UZrd8AC% zSgs0wW`Fm17rZ#^`l5L*-V_TAQ!9iwdVcLtoWR`j3Q-j&L3tw^>|iM0cO@tJ)>?tq zEyum%$g+Lj+A59r9Ou=9i}mSIU!#UU*ox_n7zFvzEY#cchmAj3U8@;XJK^{E(5ngZ zPWT-zo{Bor(>iTubP_*p-{0?61=d&VchSmDXQ!RfVJ6jpGLtfrV*EZq>teU!6bL?- z!OJdnPWUYzxI&n-v2`-97*u570A_s0H*=`Vq^g2oRSNhF&UxJQG}gRey5%=Vg)jzu zm02|Hr99NfB5Q)q36AzPd;nBfyxXDB_Zx3Es8Iz9#a{|M~+mvG&Wy`~wz9r#uK z-8%l$&#mJ(Bh5y77|Dz0hw#1_X)e+~AM5u$g5PV{mzt0umwJX-JDoKV|Ie6xCwUr? z+j_?w>N}T;kF=kKB3h0+Cp>5Gn&xNiU$J(@TlSoAyD}o*7$8dhnvTmbufJm5ipo8o zy3ca%fbcz&-J$YHoe#TX^{wTI4j?TL?i|7q>S-_OG$dDN#xP|YYXY1(L+-muZqLQ~ zJ>=?KuOaM&)~%6kiIKz93I>f~>%GH1MoxV12qk$3vfbM%Ana(HfI1KuY-4p5|8(uq zV1Fi!KU3=zKCw1bUiwC`E=^{bd;#$aMI!}qf6pwJF{Vr}A^>Y<<AY8ULzu7%KxEBj8e<3P9v|WzXjA1yAD>;8=@ef- zeY-X>aW7N>47%p6&P9o@(5{ym$;4=&7>@hf|!7F!pMUqmN3PV0RoZ(Nsd9nqeZUrK7U?S)+q`31M!B~eLE`KtUCY~~Ay zM)b$5+Yj+X3}vo(P=)cdFD9@m)18zE5v_ai{id{!>8h2xHe7G56&sQ@C0c3-NhQUG zkkl`Tq<(`)>c^ARFOZ~u8Ze7t7usU;2?MS6bSS>z0mWtANTWY8si`VK}b;Vc*fl88WY{p#BF$nzhY=T$Fv;j_XoXepVaA6 zp}B5#M0_M{R^Kf7#;w@ayq3JO!)UzeFNi%kBtLSvQ&DksMNI(zc*}mcZF#UBP3Qy$ zQ>?A-2~;@#n%smxh0H5o6%Q9*BbdPQ>vcEav(sDogVLyIB+DbTmOAi*1Y)6 zlvC%i^Jl9HJ;6|^&Q<(=(wHGo3LxNJKF{-HpHIzRU0WMT(Z~&-#5vaBvow>t|@Y|v~mzVuv03d1+lLD z3|^FBjOeu7d^zRD%Ol zUoKNPr%b}-TFumI_&Z%o9IT50OqnE{kUXIBzd03D#*t$sr$OEFmCw1zuMFC*>XhPO z-Mtnw)w^9BoEODum)*Z%&&v>b1UOCMz^>O*1jnh)#EIfwF>yoX%T6OhFGUoHb{&;x z)ZMT`lDK7Vb!NH2X_|=O(#{;2HwPhzBve*1osOqp7ED8%t<;l$gAkR!Vit}d}Tb#rQV5`BevzfStS>FW|%NXXDF~jpdd04kjJvBZxGiyEz zR;SenV=0Q_J#bAP8KGUx+7$aV=cPE_@CWHxkP3HQlB*;o&RmRPQv*R11jL(VuH z^0Bop=(d0-b``R%M;%PcUty~!%p^90Np5A|fF~_-JuSM+q$43nVj*;~Ag~bG5gr%a z)18$3kmY4|EYY-XZS=@4J#o8d$8or-n%=YsU$VQLW?(;upRgY*kN$69n5tsXfs7D3A-viJ_?I<3FB5d+( z?s9vCr0cNYo=IM^*ml~~NasUaTPFVbemG9UL!XylK8G8>X+qD)3)66fh8=hlX<5)XsA+c7m@E@-&n`DAz@ui>HiA6UE+eS zcgf-ItVf+m(4Xw6k~eBHRZmaktDqRa=W$wbz#f+*bSLnZ;_FPUPWj*^S}4UJSxP2V zvt@n|9=?Z%b=A^ig6$jB$ztq%3;6aUy@K^^1rlVnmX$Aw@(Z9jJA;q1ocn48SE$NF zbps-V%7zXqW4P=E&Bf{vy4=SaHoxRz{b52EH&3F6@3C0nRysAAV>-AY1sc(ro^uNI zmE)b4A+bD*DA!M2RU<<}L3FNWQfn4kGp*%37E-Clj0ZaoD zDRN=qh-!mr<+WfOsoJbmsPs+yM;%ywG#G1)d56pe`F6+{#1b6NOs!7!`_D`r@Fl)A zl{0?h>>b&C0WNU>H8=o_9rrJq?8Ly!o6AVn!3xW+A~RKb_LPF}%MR zwi$%CLGe_=C%$4ZtG9FnP}}b}JgQ~0fBD?i;U8zlE$GWm^yS}?_9NvYIk3K9Ey1{h zHd6ew5W}Lc;3C&7&s2REN-9b>!7G&N-O-^OjwQE;+z1c&>8pIZLkgd@=Z9GzVvXw;G3B%f;1ZBjb&OU{@?Q;;U@9;fOMX<2Rg`)UE(DrzQrBt?ZXO#U*2iN7cS^`QXt1InmePi1Jp zv*1&sFQ(Dc{LR*QT}n}Aqr!j94N%r6$yHjAk44xPoOm& zz=AVEcDVp@^W;FvZiSEoRphae_=mQu%UVuO^)TJN|O!`vd{z^(Y5ThwuJD}L{`Dv$|(N)3Z=xS9aRRr;XyfS8@O zb}_qLyg+t3OD)dNR+Nn9ZEKVpaYuCBU+S#mCn60;Lf={iJ>*p-bWE!pcFn3`ARxLo zOZSArviYStFp=zmGFRB_vLJXx{ES_{!|`{jvP5$)&w2*YQh!fWexzN{gXa!I2@l^aif&>{yVhaR%rdokj3pv4thgWB}2m&YM zgbPM4mt{9LH1|F)3gL&Bye3*IatC_o6REvZwkxb+U0GZTen0hik+WZj*DZ8pNNxj|gL4M-$INOt9u5 zd9j9NY%H%pMKENQ=W^jzk8Iut8}Vu0WuG@kI~yk9GPa5p&T#@8J{$Xa4pX<$6qOq& zo6`WVBTc`BO5L!7-jhVRKp_b|u|(lnuv$NHb$Vv--o9h=6QbSG}z4QsJk;UZoq1SIDfe^1j(7ZqYSQjA~+TymG4eVAuwG!jDL;4NY6WN6XiU~LF-dp2&(CeUgW zj=#_+8}=7N1Akp(z{7lG{i4;U$mlZ9)UBI)r%Ci4o+osJ&Y05xK#NgCVgq6>YqPJ< zh@(YI$O$oYpE0ol`}QO)Tb#w%Hp1h4$wVv1X3YvOxZbSGoWcV4y0&234c(?-O9rst zY~+mt$gfoYY=QfdI0`djQ%Dynx9{z*nbvSst&Q-~cu$a`GvxJ)lf7Pk!dW&S}1vf9{Nb}9GM zU=yp#h1twurK!Q&80WY^xmq>%i*$TL4|LDpYSH^Le~a%bcGzGYa#%@deuQvlH)utK zG+`5k>6_r$m}CMH$$%uD-p`!iJj~j^WPw)2!+dx>2IWybn$fQ9)+OD|JPz3i*n|}; z1w*)3NGrJmqqb_>B`{GP2dL^jQwka&9GRCFyr^N(rPlpuW9GkP;uHk`C6T5gk#p!y z?b=JcwyZ&)I0Btf;uZ`cC03w2t9F{fC3Z`uDmUEl!ZuPiiZe_`RCa9c`1~TvWVhrR z!c5`W+U8yZBxZtJl?$Fd0r{3shu1EFnez`_1%@Y)BECKT^BJ12eL(ZA9 zOSXm;YG9Gb=PKa8{3;T&cYT45-onvA;EaT7E zxE0*a%PeH-h||So7A@DeRQ4BU_~|>?(Qb($3A9+Rqoxc>bwEHj`hEDr&fvSNjXC{z{baj?KhliFz0(Wcb=T zOzlV$+cCE5|7b_ve@hkpRcc53;10_msT~XT_ZzXo-w0|kPM+v*!vCNSi~P(5rD-E6 z9j<6(Wwi$4JheCX(fJF(Wc^q}qtG;|U3s(m4W$l2_)|rriLG#2cD`)mY+)ro;S?&3 zl}iudK!VZq%o>M~u*3?H)fuH=KtK$o;F;mXncomrYFe$2vlJ$5OZYnQnYJ$q6A<|< zQ?)P?QCM5bT2@3fMg+QSZ4ka(i*%~rGQytIrfDOyIBSTD8kL7 ztpRIb(X!yIueq{It&{6ES_5U1Dh6Rg_cp2>=ZVLAD9OsYE8@iEp!Qg>2N7+*r02S7 zndy?wJ^|8>W5HI;+_N;zzK@dJ~UeMe_X8?vNDa7h6uxvtD1nu1|m!+6P z#?H&c-)m{_1GVPDvZFWAqElh5NHF|{7M9a;w_%9)(>Q_sJxu4}@0SK$@y$j<*mTWd zxukPlIiufkuV}aiKFPcRGXCc+TNCat5P6%r-Edi90Q0h?*)NSj5tWxNLzm?sqhph^ zGIZd6qvSf8(v^?&PS@z7`VAdLb;Js4Pr2!Y=dFOc>wIzz#C^TFw%ZsY_@kGTd*Q=n zw=_Fv;2Q40C3d9oorg{-4$GyR_PM>H?lm7+y~FWb1MpL_rtXsXRi&}7hT~O@^1s%~ zU!W2Xb<}`U1bU`0V-K7(Ix}{oDsX#YygO+D|#uwy;r^zuAtEQ z&PnU=VvE*fxKNA>a9DuNE$+zL*zhgUN<7JgmcgmO#}80l|G3d4gSja#^Ip7ZY8i1v z4~Io z;@P-vxi~#*RE~~g=)DRJ7`sFRh}*1nHS4XglI{QwOiuT8?eU%#ngA6@1rUQn7j&5V)yD%PAqa^^-2 zx4s$+tW;X%bA3?d(lEMZKC@|PR;X{)hpD4rOp!JU?=fA=8nVi7D!=oA9w^Z^{oL1d z1E*|JUw0de2@XGnu?m>7#C`NXO<9D*c*&3@+RKO5CMG1nyi|T*q<2xg$8*!X2==9P z5`uXO`4trHTkr+!TLe+zYrt}p;#FTKJ+=*A2_*t-H+EMSY zzKV|F1NFEZPnUwoPLt|Jeo(m8)ZN>l=0!4oc#b(o_Z1KBX_6S<@ZTfU$J(Q$wi5vZW?0DBFs0ZQZgFzzB^m~^X`g|Lz$U!Go$wTIbFJA^SoIC>+ z$*b9h6W~@ID=F2r5}dapld8e;2VJRB_G*bs5|`k zPr z`RVeruu(W*bS&~wvH#tP3~4fz%nM}p=dm9rtWJ$ zDC%-#OXJY8?nNPvNx28fwZz`40SD7C!v|NeaZsBH!a`<^`!&c3v$akF8*nW1kKs(f zhnBl-^vbdPMa+VqX%_Le?93%ELdcN<^IwRV9z)g| z*j?I<3<9aaK+A6YH8?WAtS{~-v41us`Bt?#1HyF?3RAi3;|exks}KSLGi8V=tPmoD zi=0j&GKi17ST5UIR#bkvROq>Cg?BLMCepB*E_#QVGRSe&H>}#@HF#~btm2P&4$z8a zm}%0yb@Yn{6?h}zJcajR3*sZ}@-VABw4}#5A&gf$Cy*`_{*){v)uFD`veH&{9jRZ)Sm>{M1xchuJdNl^$5W6{ajH$cf%ZC!_|Ig ztIQh^=d~?B;d`cd1B_nVDpK-)0r%d3c<)mU;zfcxz~r_4)opu`fC#1j=%&CE`?`JJ zr*rU3>=m)zl&=wnU1pUJ%^zhom}o}bbAu!X2$eN)P7e#HC(zi%AAyLO8V#fKhs1V8 z^HUtrbcF?Ft|OAJ#J-kz>g`5(^H#$r{bs>daYPvd8`Zy>SPVgoKV6d2@G;_;Mj(PI zOi`H>jStT6vyOm`j6q_#{8uL=VWt9ovr=6dWovqb zIx?@!hqtW+O65(Tb>L-j0vdc8-du60&7DXNU9-ODEUW)&a2GCQ=@V(P~Tg%;AGPYDWWk*lGN zv?lUbZZ2%i|HIh3z(sXk`{QTMJQ#+f;U)4Ibmri|K(J*76Y!BZjEJL2S{;?3X<9@< zG^SBvA|~20#|8$%uZ1zaf~CEOF+?PCixV~YY9^Q{Nw8PV%O<8p4ADfB7-M`Q=l@;j z44U5m{rx|GKOEukPVubkTI%GYdE9hU=&Om zqp_ErQ_n4SeeQa(m~$@efLEgTWZ>e6AHA< zX$r+ER+er+T%U_taU4{;1$5C%w9x}!g}<@lv{c6WIT=fC-FD)HRFixagi1Fmj!&1# zGEwTs4WC8u8gCq{<>TIt^J^+Z72+^&L5>4Ur~}wV`chX4spWOGeCiYa)D@@lw!rB` zSO=exHn%d!C9K0&_;Ehizz09FGhEoju!^#keAyEfl+&lO0K{tFxKL{tU3_3EG57yYVX-%4S2UQz6+z6rM zsWu@G)71iHsj^^mF!+vmd?+gTpEj|tE0DSz2zPB7i*t(o&0Dw5-AVo+rokV?@#bJx zbMPaGG7Nt<1X5RV#;(6rT~2-S+*GzlWNz|v9}8w=!i~pNxbZ;MLBOyC?iD9$A=FEo ztli>^dvt${RgJ44aR!o#mSZ>y648#i?*_T#qf^+EB4gSYgj(qS1Hpf3yAGLxeN{D6 zBXCC(G*8z}qpj)UN4cbR_pwmW$*_uT!Hj$1x$8bw5E+bEl|XZ2_XVCeJgQP%vJ>e4Xz0tnhgCEiMd#xzzLKH)scsRfk{&mJ9yW`by;d z7TJcR2KM+@P|w_NasK|_jN|?{nY{9iRt*}>iz-ZtB|p8L`%1?)atp)SQ^$7kRXG8+ zx{68LtmjkX()P2}8*#+1^wFx>;lV`P*NE02>%o<7-RPD*Xn$#wg^RK7HFXHclab+0 z9lPCL#;FV9naaJmYZ_r`Lh9IccGUygy6l1&rszKqhT6P)``*s5a${@k85^|)wDm6= zGpa%tQMFFUNLy!5sfLqnORPICk{esKP{^>rD-`s0rsPz!7+!8nja~J)Vak1cS)9FW ztW{(C<58=>Jv63hYb#oQpc8K$&onQ#Ka);ox_}t;hqa?FXK+;|j;@DAVC!`CcyZXf{oOxmm?xc5v%X zlno3;sn2B}({ZDw9Plj}h0LC9WQX;DA*tiDh%+A!amJ<-1Zr-7U-5pSxKAkV@MqlH zv6G8Ky_{}wM@Jd|>$s9%Q@!AoK7+!@yauUL3kubI;va8PZ=mCIc&J@7z{izjJm}BJ z|8f_F;xv{!zf1gE5I45y7?)1PocdOq*@~KmAHl65u#3?Sz*`2{915KtA^2pmxr&d}r? zt@3K(teRu0yuS?1J6sjeM7S;E+IQP!RZj4g^Zvd8yk}mq1i{7#Q{Sm6(sqaVP95nE zu$cVVK2TlVe;S%zbrLw5$Lx&u)DCf@Y`3I#wmZ7V8sWJ~aCB{UbOfz2rjDLD9Xm#j z&}4q&wGCdqavIOYADrWPZpd#<6s$=#+1i|@3EKl^)7BtJTlAJGs)-R^ z;SwMA|Hy6N^oqxAhRLUOWeH>Z%Lba~d@>nxm9b#kYcoV5`>@1yv{-k;f!%Y=tu(k@(Z;05>n~K`N}vQ85w&`9Ku< z!x(2tyt^a;iHNlP(t;@?U~6Kf_KDjCZfuiESzP+-aP4IOc3R<9uP*kNtg0w^GL)-M z=9!Gr>6H~=abB-rN7o;Pi9gSdQ*@rR5_%L?@`PMxn5Uu+qvJ^)f@8*($=q1$dDDbo z+9WD>VNjL@GW^c?j0xP>S$5UN@Q8(rA9a@}6{-31MDxqo^0_TjYvoRBnI^S2%AcO^ zwkB4jKS&MU6XZCs%jgzrOe?t`eHx4=7nmzmBsWUQcc+B$MsQ%a}RUB+kdAXXVyDo}XxrMZe+VP47xt5z9Y^MzY>c68kPqwVZHg-?&>p2N)c zOrid{v|!PYD=u-A@Ygq-{IaUwMHcpI1bIj>E)w-l+G{9l&kKjUVjoF`d#K5^eN(>L$sqjqP!-5F(fMk5S= zxmtw*w3jmQwGlUiak4Q7C)m=5FgfeLCp~u>ef;UbkwGzpkA$5zfCJcBPN)jsC0QN# zQ%u0J7*gM`#L-5W__Lk2d?E}MLiJEG<-Tc(YrDlkY6>1r<)2O4P{lM1X$GfE7=dle ztsm`E!B>}AN7%VZclO%7&yUR2`gc7Iw*>1lnXfDT0x3`rHW45&kU1S3FrU5!(o-K@ z_!^<|U>G4~+gB;(ZxEc^a=GOLbJZ=c4~mWyeskEfOD3sI|C!S|+ zLB48%+H_L2ilNLFPUiM!PuRI~XFk3MKNjd_v@am|R04z@p!VE%fTix@r)8=x#LuCUi z8^f!k3;jd#VqH3$ExdWlu8!?ns(jz0z+y?uEw_%zE4X!V=Df8YWOZxFw)kS{yz<`` zXtME6m&<-9~r+w-9n$mie1S4)qqP5m-&TQzLml*Ge;Lm9DoW}N0>y@6UYFUv>c zmR{!bNtD2UfT{RF%Yd1GUfw<<2(!F?3a?4%BX)3*n{Y&h?&G!X?69qH*!nceeHzJ* zbW4Q+P23lHTMUgZh>_A}rGy@E3w@+6Huy)WCDE=4$Hk9wM-eTbg`?YWCXpYStH2%1 z|7a%~c<``Y6YrKA*YY%^x|ESbx}79UMS-PX597ehoo~34nQHlmav;|hq2Ny5zMH1G z@Dqs1BxpvZHL=zAW9&xbrC<_X?n#TM^$6fSjlH;1kYdx5iFWW9TVB@e`ZDF9)_n6O z=HX7k{C*W0;Aa^~`%ZV5kN$?|nK)i$KKz?EAHEIw*RF?dgFk^J0{LSK?b<@MRz~N+ zT3jH|vQiLLAf|A=A;=@E^rh{DWIOy^t{T7t)bGHoO^{$KW+0gVbC|Q!e2FXhp#UCA zqEQBio)dC}z;R6Cl=YkBJa#IbWF|&M z!|gQ*1%>e5!pdQVZT@YDK3Z^qh7=0@1g)Xf2Bw+wMD^fy{>tc2_T= zlukfcDyKz4zmrpfWg0kd!E;Uv(;B#~=ov#MkaSbXCpN{a1xtosWP!>qaBpW3f%L-( zT!MEu8j_K*9b9ktlxBVze7cVIElWCm63MH_Gflg>F&XJwz~AeqEJUn(SrQ^}>Svzs zHMRT2wmd6%;$H*Vs6YV@-mTis2<+*4KR_6)J1MwfxNv_MY>w(rqVPC2jQ@c6m%<8C zFR`p2Sr3aBZDVQs-d$Iu6O0e^|oE0h3gxzZCS*^(6$0%tPC~ z0WLA1rzJ+#x|HRa2s0>QplhLZ9|=lVhT2egQZDIxo{JGvHj8|et#3UJ+SR*0{Y0>? z0v`ogCO%=?jd$N)-|+n524B8xi*S!0m$^eU{%f=iUhRk$LLlK6mmx?;szYUbJcL1h z{X4j9!isB@FNwLENtXU=%;7R;w7IXQz1!3wBL{^75t1+E9+%}<=4WsC`DKbwUiPq1 z`YU-Veaq+iPQS(_^@wVyh+3Ya=1vec1V>(RXXLxy$ zm=kUQme~2VzB*(t(W9M6D6q*n@x;WpRiG-k!1ar2;f|!aST2<$&A$x2E%rP%QvL@h ze*%rChgrh{hF}w~;NnyvY*b-olBY%T>b<=(z`3OJTexZR9v52NRM+4j4!&0jkA`vZ zB06=u{n1hOk5$5qAU}&%RF199!;j}wHyY^8OTWf1kK~twnxvSM*E^v|Fd<)<6(h`w z6=s|lW`zs0X!=lh4A3C-FE=4Ru-tq%z$gb0{~k&)4gtk8qXNsvD`EcS(ShYLeKVb6 zf+Ig__UkBRMEKj1n3q`7{<=dRY!^HBQYPd8t&G`w9p`Ov z^G!%>87Fgg_oq-set6#AN+>J0&c0<&nF@wFSKImxd{)sbMDcE_5?iB|`AS8W$5WIW zN=!1j>@wwQOGMQG&tK+Kz<3NS(O+IdEGU5YKm?Zr)N%4A$DCjVZ$9t2Y!06_g)M== zC_7l39fT4$jQdvFm#Ca0MyqXa3}UxfFMD%>>0j&;FeGZHGXg?!d&lVcJ$-vUj+CgZNqfH5anp9>&3$=~=r=*; z^n$c+-l4D^DsE-1d2)!34R#DFka+S8s2DF2nv|M{`TRFg7ygLIHK>@=Pmgh2!t>H& zV;sN3vlh=XJPYvT$Tja&XP5Gh0^2!4h|bz|0y|3irsF` z?ta7VjBvXnCttQZ$Jh%tx^b9N@PX1|bYSopOs=>mTHlvqtR_WF3I!;H1txY%4 z2DpFJ>^dfYPa}}Y-|x!beKpLehVbX?tUA-0PQ7D*b9=~1@E|>G#JWw zfKRafaV?Mr4;F;Evn~L*BdJjXn)6fWd-+Jxt`{e6)-+2fEM$$Nc6kdJf zZn*HH_qtG-Ubc08s7Nc@S`V8U@)8LSZpPgff)@r|nSlpwo!7ojo?{$cjpE3TlD#m& z`e+)CJy+6T34^CAMr1wppTJ=FvJ{H4cm{1tuLsq?&$KN}zSzV!Z-?QUdI*DcIQ|3D-UnAMXKfp0zJCx!J zA9ea4IKBvJN+6*Qe59fdA@BPj2T;!DQEy|$H6i^4oR+S5!E@v`7S^VEsun|t(kY%t zd^N>ad;Ui8?LUfN6VlZgmQcTm7pwS&kv4g5+zo?KfZRvyPr+23-rRUu66?eUZ-d>X zx4VcMY3nij9c^Fg%7Oh{1%IlXhyr6j~*r#$hybXR;en92g zTQCYGE5+(?RKoPd+^H!qWt|sy&)wfVZ^x2-Yu=I9Q9T2XY`V|1-D}#HG|lVH+h;z+ z?tr^m**XO6YTKl-YIr_J<}*>hpmK06*wLv_wZc!-ybgeUrh=lo&YO2kPosMSSC9xQ zX6mj^tsqY!m?NlEwQ51d){>0U^ZH&H$T+9ratSgR!8{W;nbSicHu(fMBmK?VHNGV} zVTneNosr*hC-I$TqM@%NDcjh*W%HLDi|eyji);N$;I}R7tm)*Nkv(t52i`RL-!z4w zdPVZ9Ox`zZw!YWC#(QFW(_8IJw!icS)PJ?)E^qQdG^6k)yiCftM7Oq4m$xS?*QpT> zz=xX}B$R?bPoZFhg}%Qa)e1_}$(H^wKJ>1{m0lV7R`_YT+`;D+@6&P~Fxg6Fm7%~p z0bMTZp)E6vG^!>5?{SEXNKE!hn&o(@Dc-G+gOgTfx5CS7suU=c05^tL!YTmq`u1RHfSvq$`vL#9I;R{yV2KN~_X#$#Wzk6| z`~-;q3Po5_2Nk`�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 literal 845376 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