Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ee5344a4 | ||
|
|
957182611d | ||
|
|
69d0780bac | ||
|
|
33af122964 | ||
|
|
22e3bb4a18 | ||
|
|
29fd832bcd | ||
|
|
67203cd4a8 | ||
|
|
08162fa126 | ||
|
|
81e505e040 | ||
|
|
0b3e22c97e | ||
|
|
d3ba7c71f4 | ||
|
|
00cab6b204 | ||
|
|
ff53831be4 | ||
|
|
3cb2e466d8 | ||
|
|
408b29896c |
@@ -4,7 +4,25 @@
|
|||||||
"Bash(go get *)",
|
"Bash(go get *)",
|
||||||
"Bash(go build *)",
|
"Bash(go build *)",
|
||||||
"Bash(wails generate *)",
|
"Bash(wails generate *)",
|
||||||
"Bash(npm run *)"
|
"Bash(npm run *)",
|
||||||
|
"Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)",
|
||||||
|
"Bash(timeout 20 git status --short)",
|
||||||
|
"Bash(echo \"exit: $?\")",
|
||||||
|
"Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)",
|
||||||
|
"Bash(echo \"push exit: $?\")",
|
||||||
|
"Bash(git config *)",
|
||||||
|
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
|
||||||
|
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
|
||||||
|
"Bash(which git-credential-manager *)",
|
||||||
|
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
|
||||||
|
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
|
||||||
|
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
|
||||||
|
"Bash(gofmt -w internal/qslcard/*.go)",
|
||||||
|
"Bash(awk '{print $3}')",
|
||||||
|
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
|
||||||
|
"Bash(ls -la .claude/)",
|
||||||
|
"Bash(cat .claude/settings.local.json)",
|
||||||
|
"Bash(cat .claude/settings.json)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,29 @@ to this in your browser, and you can call your Go code from devtools.
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `wails build`.
|
To build a redistributable, production mode package, use `wails build`.
|
||||||
|
|
||||||
|
## QSL Card Designer
|
||||||
|
|
||||||
|
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
|
||||||
|
|
||||||
|
1. Pick 1–6 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
|
||||||
|
grid) and proposes **3 designs** — callsign in the calmest zone of the best
|
||||||
|
photo, operator name, CQ/ITU zones + locator line, country flag, the other
|
||||||
|
photos as bordered inserts, and a per-QSO confirmation box.
|
||||||
|
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
|
||||||
|
change font / style preset (gel gold, gel silver, classic white outline,
|
||||||
|
script, flat) and per-preset knobs in the right panel.
|
||||||
|
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
|
||||||
|
originals can move). One template can be the default per profile.
|
||||||
|
|
||||||
|
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
|
||||||
|
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
|
||||||
|
and sent through the configured SMTP account to the address found by the
|
||||||
|
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
|
||||||
|
e-mail subject/body templates live in the designer
|
||||||
|
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
|
||||||
|
|
||||||
|
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
||||||
|
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||||
|
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||||
|
commonly-worked DXCC entities.
|
||||||
|
|||||||
@@ -0,0 +1,693 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// QSL card designer bindings. All logic lives in internal/qslcard — this file
|
||||||
|
// only adapts it to the Wails boundary (thin methods on *App, same receiver
|
||||||
|
// as app.go so bindings keep working). Template documents cross the bridge
|
||||||
|
// as JSON strings: the schema is the single source of truth and the frontend
|
||||||
|
// mirrors it in qslTypes.ts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/email"
|
||||||
|
"hamlog/internal/qslcard"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings keys for the eQSL e-mail templates (subject/body share the
|
||||||
|
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
|
||||||
|
const (
|
||||||
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
|
keyQSLEmailBody = "qsl.email_body"
|
||||||
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
|
)
|
||||||
|
|
||||||
|
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
||||||
|
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||||
|
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||||
|
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||||
|
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }
|
||||||
|
|
||||||
|
// QSLTemplateInfo is the template-list row shown in the designer.
|
||||||
|
type QSLTemplateInfo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProfileID *int64 `json:"profile_id,omitempty"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLFontInfo is one font face offered by the designer, TTF bytes included
|
||||||
|
// so the frontend can build @font-face rules (and inline them into the SVG
|
||||||
|
// before rasterization).
|
||||||
|
type QSLFontInfo struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
Kind string `json:"kind"` // "display" | "script" | "system"
|
||||||
|
Variable bool `json:"variable"`
|
||||||
|
DataB64 string `json:"data_b64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLPresetInfo describes one style preset for the picker.
|
||||||
|
type QSLPresetInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Params []string `json:"params"` // accepted style_params keys
|
||||||
|
Defaults qslcard.StyleParams `json:"defaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
|
||||||
|
// "browse" path (drag & drop hands the webview File objects without paths,
|
||||||
|
// so picking through the native dialog is the reliable route to real paths).
|
||||||
|
func (a *App) QSLPickPhotos() ([]string, error) {
|
||||||
|
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose card photos (1–5)",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLGenerateProposals analyzes the photos and returns 3 distinct template
|
||||||
|
// documents (JSON) from the automatic placement engine.
|
||||||
|
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
||||||
|
if len(photoPaths) == 0 {
|
||||||
|
return nil, fmt.Errorf("no photos selected")
|
||||||
|
}
|
||||||
|
if len(photoPaths) > 5 {
|
||||||
|
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
|
||||||
|
}
|
||||||
|
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
||||||
|
for _, p := range photoPaths {
|
||||||
|
ph, err := qslcard.AnalyzePhoto(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
|
||||||
|
}
|
||||||
|
photos = append(photos, ph)
|
||||||
|
}
|
||||||
|
info, err := a.qslProfileInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
|
||||||
|
templates, err := engine.Propose(photos, info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, len(templates))
|
||||||
|
for i, t := range templates {
|
||||||
|
b, err := qslcard.Encode(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[i] = string(b)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLListTemplates returns all saved templates (defaults first).
|
||||||
|
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
recs, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]QSLTemplateInfo, 0, len(recs))
|
||||||
|
for _, r := range recs {
|
||||||
|
out = append(out, QSLTemplateInfo{
|
||||||
|
ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
|
||||||
|
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLGetTemplate returns a stored template document (JSON).
|
||||||
|
func (a *App) QSLGetTemplate(id int64) (string, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return "", fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
rec, err := a.qslTemplates.Get(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return rec.JSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
|
||||||
|
// the document references at their original (absolute) paths are copied into
|
||||||
|
// the template's asset folder so the design survives the user moving files.
|
||||||
|
// Returns the template id.
|
||||||
|
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return 0, fmt.Errorf("template name required")
|
||||||
|
}
|
||||||
|
t, err := qslcard.Parse([]byte(doc))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
t.Name = name
|
||||||
|
if err := qslcard.Validate(t, nil); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := qslcard.Record{ID: id, Name: name}
|
||||||
|
if forActiveProfile {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
rec.ProfileID = &p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two-phase save: the row first (a new template needs its id to own an
|
||||||
|
// asset folder), then photo import + the final document.
|
||||||
|
rec.JSON = doc
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
|
||||||
|
if err := qslcard.ImportPhotos(&t, dir); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
final, err := qslcard.Encode(t)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rec.JSON = string(final)
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
|
||||||
|
return rec.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLSetDefaultTemplate marks a template as default for its profile scope.
|
||||||
|
func (a *App) QSLSetDefaultTemplate(id int64) error {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.qslTemplates.SetDefault(a.ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLDeleteTemplate removes a template and its asset folder.
|
||||||
|
func (a *App) QSLDeleteTemplate(id int64) error {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return qslcard.RemoveTemplateDir(a.qslDir(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
|
||||||
|
// renders at save time, shown in the template list.
|
||||||
|
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(pngB64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode preview: %w", err)
|
||||||
|
}
|
||||||
|
dir := qslcard.TemplateDir(a.qslDir(), id)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create template dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write preview: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
|
||||||
|
// template has none yet).
|
||||||
|
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read preview: %w", err)
|
||||||
|
}
|
||||||
|
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
|
||||||
|
// a draft photo at its original absolute path (pre-save editing); otherwise
|
||||||
|
// ref is a name inside the template's asset folder.
|
||||||
|
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
|
||||||
|
var path string
|
||||||
|
switch {
|
||||||
|
case templateID > 0 && !qslcard.IsDraftPhoto(ref):
|
||||||
|
path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
|
||||||
|
case qslcard.IsDraftPhoto(ref):
|
||||||
|
path = ref
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("photo %q not available without a template id", ref)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read photo: %w", err)
|
||||||
|
}
|
||||||
|
mime := "image/jpeg"
|
||||||
|
if strings.EqualFold(filepath.Ext(path), ".png") {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
|
||||||
|
func (a *App) QSLFlagDataURL(iso string) (string, error) {
|
||||||
|
if iso == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
svg, err := qslcard.FlagSVG(iso)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
|
||||||
|
// machine has it) for @font-face registration.
|
||||||
|
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
|
||||||
|
fonts, err := qslcard.Fonts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]QSLFontInfo, 0, len(fonts))
|
||||||
|
for _, f := range fonts {
|
||||||
|
out = append(out, QSLFontInfo{
|
||||||
|
Family: f.Family, Kind: f.Kind, Variable: f.Variable,
|
||||||
|
DataB64: base64.StdEncoding.EncodeToString(f.Data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLStylePresets lists the built-in style presets for the picker.
|
||||||
|
func (a *App) QSLStylePresets() []QSLPresetInfo {
|
||||||
|
out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
|
||||||
|
for _, p := range qslcard.Presets {
|
||||||
|
params := make([]string, 0, len(p.AllowedParams))
|
||||||
|
for k := range p.AllowedParams {
|
||||||
|
params = append(params, k)
|
||||||
|
}
|
||||||
|
sort.Strings(params)
|
||||||
|
out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLResolvePreview fills a template's placeholders with the active profile
|
||||||
|
// and a representative sample QSO, for the live editor preview. Returns the
|
||||||
|
// RenderModel as JSON.
|
||||||
|
func (a *App) QSLResolvePreview(doc string) (string, error) {
|
||||||
|
t, err := qslcard.Parse([]byte(doc))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
vars, country, err := a.qslVars(sampleQSO())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return encodeRenderModel(qslcard.Resolve(t, vars, country))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLDefaultTemplateID returns the template the eQSL send flow should use
|
||||||
|
// for the active profile (0 when none exists yet).
|
||||||
|
func (a *App) QSLDefaultTemplateID() (int64, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("no active profile: %w", err)
|
||||||
|
}
|
||||||
|
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil // no templates yet — the UI offers to open the designer
|
||||||
|
}
|
||||||
|
return rec.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderEQSL loads a QSO and a template and returns the fully-resolved
|
||||||
|
// render model (JSON) for the frontend to rasterize.
|
||||||
|
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
|
||||||
|
if a.qso == nil || a.qslTemplates == nil {
|
||||||
|
return "", fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
q, err := a.qso.GetByID(a.ctx, qsoID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rec, err := a.qslTemplates.Get(a.ctx, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("template %d: %w", templateID, err)
|
||||||
|
}
|
||||||
|
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := qslcard.TemplateDir(a.qslDir(), templateID)
|
||||||
|
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
vars, country, err := a.qslVars(q)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return encodeRenderModel(qslcard.Resolve(t, vars, country))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
|
||||||
|
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
|
||||||
|
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||||
|
if a.qso == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
q, err := a.qso.GetByID(a.ctx, qsoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
to := strings.TrimSpace(q.Email)
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(jpegB64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode card image: %w", err)
|
||||||
|
}
|
||||||
|
outbox := filepath.Join(a.qslDir(), "outbox")
|
||||||
|
if err := os.MkdirAll(outbox, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create outbox: %w", err)
|
||||||
|
}
|
||||||
|
safeCall := strings.Map(func(r rune) rune {
|
||||||
|
if r == '/' || r == '\\' || r == ':' {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, q.Callsign)
|
||||||
|
path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write outbox file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _ := a.GetEmailSettings()
|
||||||
|
subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
|
||||||
|
if subject == "" {
|
||||||
|
subject = defaultQSLEmailSubject
|
||||||
|
}
|
||||||
|
body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
|
||||||
|
if body == "" {
|
||||||
|
body = defaultQSLEmailBody
|
||||||
|
}
|
||||||
|
if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
|
||||||
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||||
|
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||||
|
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||||
|
// row unchanged apart from this field.
|
||||||
|
if q.Extras == nil {
|
||||||
|
q.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||||
|
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
|
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
||||||
|
type QSLEmailTemplates struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
AutoSend bool `json:"auto_send"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||||
|
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||||
|
out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if s := m[keyQSLEmailSubject]; s != "" {
|
||||||
|
out.Subject = s
|
||||||
|
}
|
||||||
|
if b := m[keyQSLEmailBody]; b != "" {
|
||||||
|
out.Body = b
|
||||||
|
}
|
||||||
|
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
|
||||||
|
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := "0"
|
||||||
|
if t.AutoSend {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||||
|
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
|
||||||
|
// default template for the active profile, and the QSO not already confirmed.
|
||||||
|
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
|
||||||
|
// the frontend acts on — never sends from here. Silent when any condition fails.
|
||||||
|
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
|
||||||
|
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
|
||||||
|
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func encodeRenderModel(m qslcard.RenderModel) (string, error) {
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode render model: %w", err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslProfileInfo extracts the placement-engine inputs from the active
|
||||||
|
// profile, filling missing zones from cty.dat.
|
||||||
|
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
|
||||||
|
if a.profiles == nil {
|
||||||
|
return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
|
||||||
|
}
|
||||||
|
info := qslcard.ProfileInfo{
|
||||||
|
Callsign: p.Callsign,
|
||||||
|
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
|
||||||
|
Grid: p.MyGrid,
|
||||||
|
Rig: p.MyRig,
|
||||||
|
Antenna: p.MyAntenna,
|
||||||
|
}
|
||||||
|
if p.MyCQZone != nil {
|
||||||
|
info.CQZone = *p.MyCQZone
|
||||||
|
}
|
||||||
|
if p.MyITUZone != nil {
|
||||||
|
info.ITUZone = *p.MyITUZone
|
||||||
|
}
|
||||||
|
if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
|
||||||
|
if m, ok := a.dxcc.Lookup(p.Callsign); ok {
|
||||||
|
if info.CQZone == 0 {
|
||||||
|
info.CQZone = m.CQZone
|
||||||
|
}
|
||||||
|
if info.ITUZone == 0 {
|
||||||
|
info.ITUZone = m.ITUZone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Most users define rig/antenna in the operating manager (band defaults),
|
||||||
|
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
|
||||||
|
// default so the designer knows the station line has data to show.
|
||||||
|
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
|
||||||
|
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
|
||||||
|
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
|
||||||
|
if e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Rig == "" {
|
||||||
|
info.Rig = d.StationName
|
||||||
|
}
|
||||||
|
if info.Antenna == "" {
|
||||||
|
info.Antenna = d.AntennaName
|
||||||
|
}
|
||||||
|
if info.Rig != "" && info.Antenna != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
|
||||||
|
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
|
||||||
|
// default for the QSO's band (so the preview and back-entered QSOs aren't
|
||||||
|
// blank).
|
||||||
|
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
|
||||||
|
rig, ant = q.MyRig, q.MyAntenna
|
||||||
|
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
band := q.Band
|
||||||
|
if band == "" {
|
||||||
|
band = "20m"
|
||||||
|
}
|
||||||
|
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
|
||||||
|
if rig == "" {
|
||||||
|
rig = d.StationName
|
||||||
|
}
|
||||||
|
if ant == "" {
|
||||||
|
ant = d.AntennaName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslVars builds the full placeholder map (profile + QSO) and the country
|
||||||
|
// block resolution for one render.
|
||||||
|
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
|
||||||
|
info, err := a.qslProfileInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, qslcard.CountryInfo{}, err
|
||||||
|
}
|
||||||
|
zone := func(z int) string { // unknown zone reads better blank than "0"
|
||||||
|
if z == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(z)
|
||||||
|
}
|
||||||
|
vars := map[string]string{
|
||||||
|
"profile.callsign": info.Callsign,
|
||||||
|
"profile.operator_name": info.Operator,
|
||||||
|
"profile.grid": info.Grid,
|
||||||
|
"profile.cq_zone": zone(info.CQZone),
|
||||||
|
"profile.itu_zone": zone(info.ITUZone),
|
||||||
|
"profile.rig": info.Rig,
|
||||||
|
"profile.antenna": info.Antenna,
|
||||||
|
|
||||||
|
"qso.callsign": q.Callsign,
|
||||||
|
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
|
||||||
|
"qso.time_on": q.QSODate.UTC().Format("15:04"),
|
||||||
|
"qso.band": q.Band,
|
||||||
|
"qso.mode": q.Mode,
|
||||||
|
"qso.submode": q.Submode,
|
||||||
|
"qso.rst_sent": q.RSTSent,
|
||||||
|
"qso.qsl_msg": q.QSLMsg,
|
||||||
|
"qso.name": q.Name,
|
||||||
|
}
|
||||||
|
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||||
|
if q.FreqHz != nil {
|
||||||
|
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
||||||
|
}
|
||||||
|
return vars, a.qslCountryInfo(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslCountryInfo resolves the operator's own country (label + flag) from the
|
||||||
|
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
|
||||||
|
// callsign. No match → label only or nothing (graceful).
|
||||||
|
func (a *App) qslCountryInfo() qslcard.CountryInfo {
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return qslcard.CountryInfo{}
|
||||||
|
}
|
||||||
|
out := qslcard.CountryInfo{Label: p.MyCountry}
|
||||||
|
entityNum := 0
|
||||||
|
if p.MyDXCC != nil {
|
||||||
|
entityNum = *p.MyDXCC
|
||||||
|
if out.Label == "" {
|
||||||
|
out.Label = dxcc.NameForDXCC(entityNum)
|
||||||
|
}
|
||||||
|
} else if a.dxcc != nil {
|
||||||
|
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
|
||||||
|
if out.Label == "" {
|
||||||
|
out.Label = m.Entity.Name
|
||||||
|
}
|
||||||
|
entityNum = dxcc.EntityDXCC(m.Entity.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.FlagISO = qslcard.FlagISO(entityNum)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleQSO is the canned contact used by the editor's live preview.
|
||||||
|
func sampleQSO() qso.QSO {
|
||||||
|
return qso.QSO{
|
||||||
|
Callsign: "DL1ABC",
|
||||||
|
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
||||||
|
Band: "20m",
|
||||||
|
Mode: "SSB",
|
||||||
|
RSTSent: "59",
|
||||||
|
QSLMsg: "TNX FB QSO — 73!",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secret-vault settings keys (NOT themselves sensitive — they hold the salt and
|
||||||
|
// an encrypted verifier token, never a password).
|
||||||
|
const (
|
||||||
|
keySecretSalt = "secret.salt" // base64 PBKDF2 salt
|
||||||
|
keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// sensitiveSettingKeys are the password fields encrypted at rest when the user
|
||||||
|
// sets a passphrase. Everything else stays plaintext.
|
||||||
|
var sensitiveSettingKeys = map[string]bool{
|
||||||
|
keyQRZPassword: true,
|
||||||
|
keyHQPassword: true,
|
||||||
|
keyEmailPassword: true,
|
||||||
|
keyExtClublogPassword: true,
|
||||||
|
keyExtLoTWKeyPassword: true,
|
||||||
|
keyExtLoTWWebPassword: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|
||||||
|
// SecretStatus tells the UI whether a passphrase is configured and unlocked.
|
||||||
|
type SecretStatus struct {
|
||||||
|
HasPassphrase bool `json:"has_passphrase"`
|
||||||
|
Unlocked bool `json:"unlocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI
|
||||||
|
// shows the unlock prompt.
|
||||||
|
func (a *App) GetSecretStatus() SecretStatus {
|
||||||
|
if a.settings == nil {
|
||||||
|
return SecretStatus{}
|
||||||
|
}
|
||||||
|
salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||||
|
return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadAfterSecretChange rebuilds anything that read passwords at startup while
|
||||||
|
// they were still locked (lookup providers + external-service config).
|
||||||
|
func (a *App) reloadAfterSecretChange() {
|
||||||
|
a.reloadLookupProviders()
|
||||||
|
if a.extsvc != nil {
|
||||||
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockSecrets validates the passphrase against the stored verifier and, on
|
||||||
|
// success, installs the cipher so passwords decrypt for the rest of the session.
|
||||||
|
func (a *App) UnlockSecrets(passphrase string) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("not initialized")
|
||||||
|
}
|
||||||
|
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||||
|
verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier)
|
||||||
|
if saltB64 == "" || verifier == "" {
|
||||||
|
return fmt.Errorf("no passphrase configured")
|
||||||
|
}
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(saltB64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("corrupt salt")
|
||||||
|
}
|
||||||
|
key, err := secret.DeriveKey(passphrase, salt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive key: %w", err)
|
||||||
|
}
|
||||||
|
c, err := secret.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cipher: %w", err)
|
||||||
|
}
|
||||||
|
if !c.CheckVerifier(verifier) {
|
||||||
|
return fmt.Errorf("wrong passphrase")
|
||||||
|
}
|
||||||
|
a.settings.Unlock(c)
|
||||||
|
a.reloadAfterSecretChange()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassphrase sets a new passphrase or changes an existing one, then
|
||||||
|
// re-encrypts every stored secret under the new key. Changing an existing
|
||||||
|
// passphrase requires the vault to be unlocked first (so current secrets are
|
||||||
|
// readable as plaintext).
|
||||||
|
func (a *App) SetPassphrase(passphrase string) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("not initialized")
|
||||||
|
}
|
||||||
|
if len(passphrase) < 4 {
|
||||||
|
return fmt.Errorf("passphrase too short (min 4 characters)")
|
||||||
|
}
|
||||||
|
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||||
|
if saltB64 != "" && !a.settings.Unlocked() {
|
||||||
|
return fmt.Errorf("unlock with the current passphrase first")
|
||||||
|
}
|
||||||
|
// Snapshot current plaintext (Get decrypts when unlocked, passes through
|
||||||
|
// when no passphrase yet).
|
||||||
|
current := map[string]string{}
|
||||||
|
for k := range sensitiveSettingKeys {
|
||||||
|
if v, _ := a.settings.Get(a.ctx, k); v != "" {
|
||||||
|
current[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
salt, err := secret.NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("salt: %w", err)
|
||||||
|
}
|
||||||
|
key, err := secret.DeriveKey(passphrase, salt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive key: %w", err)
|
||||||
|
}
|
||||||
|
c, err := secret.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cipher: %w", err)
|
||||||
|
}
|
||||||
|
// Persist salt + verifier first so an interrupted migration is still
|
||||||
|
// recoverable (the prompt appears next launch; plaintext secrets read
|
||||||
|
// through transparently until re-encrypted).
|
||||||
|
if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.settings.Unlock(c)
|
||||||
|
for k, v := range current {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts
|
||||||
|
applog.Printf("secret: re-encrypt %q failed: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.reloadAfterSecretChange()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePassphrase decrypts all secrets back to plaintext and clears the vault.
|
||||||
|
// Requires the current passphrase (validated) so it can't be cleared blindly.
|
||||||
|
func (a *App) RemovePassphrase(passphrase string) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("not initialized")
|
||||||
|
}
|
||||||
|
if !a.settings.Unlocked() {
|
||||||
|
if err := a.UnlockSecrets(passphrase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read decrypted, then store back as plaintext and drop salt/verifier.
|
||||||
|
plain := map[string]string{}
|
||||||
|
for k := range sensitiveSettingKeys {
|
||||||
|
if v, _ := a.settings.Get(a.ctx, k); v != "" {
|
||||||
|
plain[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.settings.Lock()
|
||||||
|
for k, v := range plain {
|
||||||
|
if err := a.settings.SetRaw(a.ctx, k, v); err != nil {
|
||||||
|
applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = a.settings.SetRaw(a.ctx, keySecretSalt, "")
|
||||||
|
_ = a.settings.SetRaw(a.ctx, keySecretVerifier, "")
|
||||||
|
a.reloadAfterSecretChange()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
|
||||||
|
// launches on startup (JSON array of AutostartProgram).
|
||||||
|
const keyAutostartPrograms = "autostart.programs"
|
||||||
|
|
||||||
|
// AutostartProgram is one external application OpsLog can launch when it starts
|
||||||
|
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
|
||||||
|
type AutostartProgram struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutostartLaunchResult reports what happened for one program when launching.
|
||||||
|
type AutostartLaunchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"` // launched | already_running | missing | disabled | error
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAutostartPrograms returns the active profile's autostart list.
|
||||||
|
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
|
||||||
|
out := []AutostartProgram{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||||
|
return []AutostartProgram{}, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAutostartPrograms persists the autostart list for the active profile.
|
||||||
|
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(progs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseExecutable opens a native file picker for choosing a program to launch.
|
||||||
|
func (a *App) BrowseExecutable() (string, error) {
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose a program to launch on startup",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchAutostartPrograms starts every enabled program that isn't already
|
||||||
|
// running, returning a per-program result. Used at startup (best effort) and by
|
||||||
|
// the "Launch now" button in settings.
|
||||||
|
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
|
||||||
|
progs, _ := a.GetAutostartPrograms()
|
||||||
|
running := runningProcessNames()
|
||||||
|
out := make([]AutostartLaunchResult, 0, len(progs))
|
||||||
|
for _, p := range progs {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, launchProgram(p, running))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchAutostartProgram launches a single program by id on demand (the per-row
|
||||||
|
// "launch now" action), regardless of its enabled flag.
|
||||||
|
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
||||||
|
progs, err := a.GetAutostartPrograms()
|
||||||
|
if err != nil {
|
||||||
|
return AutostartLaunchResult{}, err
|
||||||
|
}
|
||||||
|
for _, p := range progs {
|
||||||
|
if p.ID == id {
|
||||||
|
return launchProgram(p, runningProcessNames()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchProgram starts one program unless its executable is already running.
|
||||||
|
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
||||||
|
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
||||||
|
path := strings.TrimSpace(p.Path)
|
||||||
|
if path == "" {
|
||||||
|
res.Status, res.Message = "error", "no path configured"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
res.Status, res.Message = "missing", "executable not found: "+path
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
// Skip if a process with the same executable name is already running, so we
|
||||||
|
// never spawn a second copy of WSJT-X / JTAlert / etc.
|
||||||
|
base := strings.ToLower(filepath.Base(path))
|
||||||
|
if running[base] {
|
||||||
|
res.Status, res.Message = "already_running", base+" already running"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
cmd := exec.Command(path, splitArgs(p.Args)...)
|
||||||
|
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
res.Status, res.Message = "error", err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
// Don't wait on the child — it runs independently of OpsLog. Release the
|
||||||
|
// handle so we don't accumulate zombies.
|
||||||
|
go func() { _ = cmd.Wait() }()
|
||||||
|
res.Status, res.Message = "launched", "started "+base
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitArgs does a minimal shell-like split of an argument string, honouring
|
||||||
|
// double quotes so a path with spaces stays one argument.
|
||||||
|
func splitArgs(s string) []string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '"':
|
||||||
|
inQuote = !inQuote
|
||||||
|
case r == ' ' && !inQuote:
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// runningProcessNames returns the set of lowercase executable names currently
|
||||||
|
// running, via the Windows `tasklist`. Best effort — on failure the set is
|
||||||
|
// empty (we then just attempt to launch, which is acceptable).
|
||||||
|
func runningProcessNames() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
|
data, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("autostart: tasklist failed: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CSV row: "image.exe","PID",... — take the first quoted field.
|
||||||
|
field := line
|
||||||
|
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
|
||||||
|
field = line[1 : i+1]
|
||||||
|
}
|
||||||
|
field = strings.Trim(field, "\"")
|
||||||
|
if field != "" {
|
||||||
|
out[strings.ToLower(field)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus,
|
GetStartupStatus, CheckForUpdate,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||||
RefreshCtyDat,
|
GetSecretStatus, UnlockSecrets,
|
||||||
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
@@ -32,14 +34,19 @@ import {
|
|||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
|
|
||||||
import { Menubar, type Menu } from '@/components/Menubar';
|
import { Menubar, type Menu } from '@/components/Menubar';
|
||||||
|
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||||
|
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||||
|
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||||
|
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
import { MainMap } from '@/components/MainMap';
|
import { MainMap } from '@/components/MainMap';
|
||||||
@@ -84,9 +91,10 @@ type CATState = Omit<catModels.RigState, 'convertValues'>;
|
|||||||
|
|
||||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||||
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
|
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||||
// app.go — digital modes carry no useful audio and are never recorded.
|
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
||||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
|
// so neither is recorded (no REC badge / timer for them).
|
||||||
|
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||||
|
|
||||||
const emptyDetails: DetailsState = {
|
const emptyDetails: DetailsState = {
|
||||||
state: '', cnty: '', address: '',
|
state: '', cnty: '', address: '',
|
||||||
@@ -316,6 +324,7 @@ export default function App() {
|
|||||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -429,7 +438,18 @@ export default function App() {
|
|||||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [migratedBanner, setMigratedBanner] = useState(false);
|
// Secret vault (encrypted passwords): prompt to unlock at launch when a
|
||||||
|
// passphrase is configured but not yet entered this session.
|
||||||
|
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||||
|
const [unlockPass, setUnlockPass] = useState('');
|
||||||
|
const [unlockErr, setUnlockErr] = useState('');
|
||||||
|
const [unlockBusy, setUnlockBusy] = useState(false);
|
||||||
|
const doUnlock = async () => {
|
||||||
|
setUnlockBusy(true); setUnlockErr('');
|
||||||
|
try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
|
||||||
|
catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
|
||||||
|
finally { setUnlockBusy(false); }
|
||||||
|
};
|
||||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||||
// like "spot sent" where a blocking error banner would be overkill.
|
// like "spot sent" where a blocking error banner would be overkill.
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
@@ -460,9 +480,14 @@ export default function App() {
|
|||||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [recording, recTick]);
|
}, [recording, recTick]);
|
||||||
// restartRecordingForNewTarget (re)starts the take for a new programmatic
|
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||||
// target (clicked spot / external app via UDP) and resets the elapsed timer.
|
// Lets us restart from zero when the operator edits the call to a different
|
||||||
const restartRecordingForNewTarget = () => {
|
// station mid-recording, instead of continuing the old take.
|
||||||
|
const recordingCallRef = useRef('');
|
||||||
|
// restartRecordingForNewTarget (re)starts the take for a new target (clicked
|
||||||
|
// spot / external app / edited callsign) and resets the elapsed timer.
|
||||||
|
const restartRecordingForNewTarget = (forCall?: string) => {
|
||||||
|
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
||||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
};
|
};
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -474,6 +499,8 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('recent');
|
const [activeTab, setActiveTab] = useState('recent');
|
||||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
|
const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
|
||||||
function closeQslTab() {
|
function closeQslTab() {
|
||||||
setQslTabOpen(false);
|
setQslTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||||
@@ -525,11 +552,9 @@ export default function App() {
|
|||||||
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
|
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
|
||||||
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
|
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
|
||||||
const [showSpotModal, setShowSpotModal] = useState(false);
|
const [showSpotModal, setShowSpotModal] = useState(false);
|
||||||
// "You have been spotted" banner — set when a cluster spot's DX call is our
|
// Holds our station callsign for the (one-shot) cluster spot listener, so a
|
||||||
// own station callsign. Ref holds our call for the (one-shot) spot listener.
|
// self-spot can be surfaced in the shared header toast.
|
||||||
const [selfSpot, setSelfSpot] = useState<{ spotter: string; freqKHz: number; band?: string; comment?: string; at: number } | null>(null);
|
|
||||||
const myCallRef = useRef('');
|
const myCallRef = useRef('');
|
||||||
const selfSpotTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// === WinKeyer CW keyer ===
|
// === WinKeyer CW keyer ===
|
||||||
const [wkEnabled, setWkEnabled] = useState(false);
|
const [wkEnabled, setWkEnabled] = useState(false);
|
||||||
@@ -541,9 +566,21 @@ export default function App() {
|
|||||||
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
||||||
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
||||||
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
||||||
|
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
|
||||||
|
// until a reply is entered or it's stopped. Persisted as UI prefs.
|
||||||
|
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
|
||||||
|
const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
|
||||||
|
const wkAutoCallRef = useRef(wkAutoCall);
|
||||||
|
const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
|
||||||
|
const autoCallGenRef = useRef(0); // bump to cancel the running loop
|
||||||
|
const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
|
||||||
|
useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
|
||||||
|
useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
|
||||||
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
|
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
|
||||||
const wkActiveRef = useRef(false);
|
const wkActiveRef = useRef(false);
|
||||||
const wkEscClearsRef = useRef(true);
|
const wkEscClearsRef = useRef(true);
|
||||||
|
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
||||||
|
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
||||||
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
|
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
|
||||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||||
|
|
||||||
@@ -609,6 +646,19 @@ export default function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
// Single band map docked beside the table (toggled by the toolbar button,
|
||||||
|
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
||||||
|
const [showBandMap, setShowBandMap] = useState(false);
|
||||||
|
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
||||||
|
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
|
||||||
|
);
|
||||||
|
const toggleBandMapSide = useCallback(() => {
|
||||||
|
setBandMapSide((s) => {
|
||||||
|
const next = s === 'right' ? 'left' : 'right';
|
||||||
|
writeUiPref('bandmap.side', next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||||
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
@@ -627,8 +677,19 @@ export default function App() {
|
|||||||
// close so the next plain "Preferences" launch reverts to default.
|
// close so the next plain "Preferences" launch reverts to default.
|
||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
||||||
|
// Check GitHub for a newer release once at startup (unless disabled in
|
||||||
|
// General); surface a toast if one exists. Best effort — silent on failure.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
|
CheckForUpdate().then((u: any) => {
|
||||||
|
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
|
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||||
|
|
||||||
// === ADIF ===
|
// === ADIF ===
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
@@ -694,6 +755,7 @@ export default function App() {
|
|||||||
callsign: '', operator: '',
|
callsign: '', operator: '',
|
||||||
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
|
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
|
||||||
});
|
});
|
||||||
|
const [showFirstRun, setShowFirstRun] = useState(false);
|
||||||
myCallRef.current = (station.callsign || '').toUpperCase();
|
myCallRef.current = (station.callsign || '').toUpperCase();
|
||||||
|
|
||||||
// Bearing/distance from operator's grid to the DX — used by the entry-strip
|
// Bearing/distance from operator's grid to the DX — used by the entry-strip
|
||||||
@@ -762,7 +824,11 @@ export default function App() {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
}), [filterCallsign, activeFilter, qsoLimit]);
|
}), [filterCallsign, activeFilter, qsoLimit]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
||||||
|
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||||
|
// MySQL especially) can take a few seconds to connect while the UI is already
|
||||||
|
// mounted, and we don't want to flash "db not available" during that window.
|
||||||
|
const refresh = useCallback(async (silent = false): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const f = buildActiveFilter();
|
const f = buildActiveFilter();
|
||||||
const list = await ListQSOFiltered(f as any);
|
const list = await ListQSOFiltered(f as any);
|
||||||
@@ -773,8 +839,10 @@ export default function App() {
|
|||||||
setTotal(n);
|
setTotal(n);
|
||||||
setMatchCount(matched);
|
setMatchCount(matched);
|
||||||
setError('');
|
setError('');
|
||||||
|
return true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
if (!silent) setError(String(e?.message ?? e));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [buildActiveFilter]);
|
}, [buildActiveFilter]);
|
||||||
|
|
||||||
@@ -786,7 +854,8 @@ export default function App() {
|
|||||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||||
const offDone = EventsOn('qslmgr:done', ping);
|
const offDone = EventsOn('qslmgr:done', ping);
|
||||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
const offEqsl = EventsOn('qsl:sent', ping);
|
||||||
|
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
@@ -815,6 +884,40 @@ export default function App() {
|
|||||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||||
useEffect(() => { setBandRx(band); }, [band]);
|
useEffect(() => { setBandRx(band); }, [band]);
|
||||||
|
|
||||||
|
// Logbook connection label for the status bar (MySQL host:port/db, or the
|
||||||
|
// local SQLite file path).
|
||||||
|
useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// The logbook can switch at runtime when the active profile changes (each
|
||||||
|
// profile can target its own SQLite/MySQL database). Refresh the grid and the
|
||||||
|
// status-bar label when that happens.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('logbook:changed', () => {
|
||||||
|
GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
return () => { off(); };
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
|
||||||
|
// QSOs another operator's instance adds/removes show up here within a few
|
||||||
|
// seconds, without a manual refresh. Pointless on local SQLite (single writer).
|
||||||
|
useEffect(() => {
|
||||||
|
if (dbConn?.backend !== 'mysql') return;
|
||||||
|
let alive = true;
|
||||||
|
let last = '';
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const rev = await GetLogbookRevision();
|
||||||
|
if (alive && last && rev !== last) await refresh();
|
||||||
|
if (alive) last = rev;
|
||||||
|
} catch { /* logbook briefly unavailable — try again next tick */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [dbConn, refresh]);
|
||||||
|
|
||||||
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
|
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
|
||||||
// where the RX freq is genuinely different). It stays editable by hand:
|
// where the RX freq is genuinely different). It stays editable by hand:
|
||||||
// a manual RX edit sticks until the next TX-freq change re-syncs it.
|
// a manual RX edit sticks until the next TX-freq change re-syncs it.
|
||||||
@@ -917,18 +1020,55 @@ export default function App() {
|
|||||||
if (s.band) setBand(s.band);
|
if (s.band) setBand(s.band);
|
||||||
}
|
}
|
||||||
if (m) applyModeFromSpot(m);
|
if (m) applyModeFromSpot(m);
|
||||||
onCallsignInput(s.dx_call);
|
onCallsignInput(s.dx_call, { force: true });
|
||||||
applySpotPOTA((s as any).pota_ref);
|
applySpotPOTA((s as any).pota_ref);
|
||||||
if (s.dx_call?.trim()) restartRecordingForNewTarget();
|
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
// Initial grid load. The logbook (remote MySQL) may still be connecting while
|
||||||
|
// the UI is mounted, so retry quietly until the first load succeeds rather
|
||||||
|
// than leaving the grid empty until the next manual refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
let tries = 0;
|
||||||
|
let timer = 0;
|
||||||
|
const attempt = async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
const ok = await refresh(true);
|
||||||
|
if (ok && alive) {
|
||||||
|
// The logbook is now connected — refresh the status-bar label too, in
|
||||||
|
// case its one-shot fetch ran during the startup race (before the
|
||||||
|
// backend was determined) and grabbed the wrong/stale value.
|
||||||
|
GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
|
||||||
|
} else if (!ok && alive && tries++ < 360) {
|
||||||
|
// Quick retries at first (normal startup connects in ~2 s); then keep
|
||||||
|
// trying for several minutes, because the very first migration against a
|
||||||
|
// slow remote MySQL can legitimately take that long before the logbook
|
||||||
|
// is ready. Stays silent so no "db not available" flashes meanwhile.
|
||||||
|
timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
|
||||||
|
} else if (!ok && alive) {
|
||||||
|
refresh(); // give up quietly retrying; surface the error now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
attempt();
|
||||||
|
return () => { alive = false; if (timer) window.clearTimeout(timer); };
|
||||||
|
}, [refresh]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const st = await GetStartupStatus();
|
const st = await GetStartupStatus();
|
||||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||||
if ((st as any).migrated_from_app_data) setMigratedBanner(true);
|
// First launch (or a never-configured profile): collect the mandatory
|
||||||
|
// station identity before anything else.
|
||||||
|
try {
|
||||||
|
const ss = await GetStationSettings();
|
||||||
|
if (!ss.callsign?.trim()) setShowFirstRun(true);
|
||||||
|
} catch {}
|
||||||
|
} catch {}
|
||||||
|
// Prompt to unlock encrypted passwords if a passphrase is configured.
|
||||||
|
try {
|
||||||
|
const ss: any = await GetSecretStatus();
|
||||||
|
if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
|
||||||
} catch {}
|
} catch {}
|
||||||
loadStation();
|
loadStation();
|
||||||
loadLists();
|
loadLists();
|
||||||
@@ -974,13 +1114,27 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (!lk.band && s.band) setBand(s.band);
|
if (!lk.band && s.band) setBand(s.band);
|
||||||
|
|
||||||
// Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode.
|
// Mode resolution.
|
||||||
|
// FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
|
||||||
|
// But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
|
||||||
|
// when it's digital we still pick FT8/FT4/RTTY from the frequency's
|
||||||
|
// watering hole (e.g. 14.080 → FT4), else the operator's default.
|
||||||
|
// OmniRig & other rigs often can't tell digital from SSB on a digital
|
||||||
|
// freq, so for them we infer from the frequency regardless of reported mode.
|
||||||
if (!lk.mode) {
|
if (!lk.mode) {
|
||||||
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
|
||||||
let nextMode = '';
|
let nextMode = '';
|
||||||
if (inferred) nextMode = inferred;
|
if (s.backend === 'flex') {
|
||||||
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
|
if (s.mode === 'DATA') {
|
||||||
else if (s.mode) nextMode = s.mode;
|
nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
|
||||||
|
} else if (s.mode) {
|
||||||
|
nextMode = s.mode;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
||||||
|
if (inferred) nextMode = inferred;
|
||||||
|
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
|
||||||
|
else if (s.mode) nextMode = s.mode;
|
||||||
|
}
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
||||||
@@ -1032,13 +1186,13 @@ export default function App() {
|
|||||||
const next = [sp, ...arr];
|
const next = [sp, ...arr];
|
||||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||||
});
|
});
|
||||||
// Self-spot: someone spotted OUR callsign on the cluster.
|
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||||
|
// toast (same place as the other notifications), not a separate banner.
|
||||||
const mine = myCallRef.current;
|
const mine = myCallRef.current;
|
||||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||||
setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
|
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||||
// Auto-hide 3 s after the last self-spot; a new one resets the timer.
|
const c = (sp.comment ?? '').trim();
|
||||||
if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
|
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
||||||
selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubState?.(); unsubSpot?.(); };
|
return () => { unsubState?.(); unsubSpot?.(); };
|
||||||
@@ -1063,15 +1217,21 @@ export default function App() {
|
|||||||
lastUdpCallRef.current = upper; // remember this broadcast either way
|
lastUdpCallRef.current = upper; // remember this broadcast either way
|
||||||
if (current === upper) return false; // already shown → no-op
|
if (current === upper) return false; // already shown → no-op
|
||||||
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
||||||
onCallsignInput(call);
|
onCallsignInput(call, { force: true }); // programmatic → always look up
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||||
// External app moved to a new station → fresh recording for the new target.
|
// External app moved to a new station → fresh recording for the new target.
|
||||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget();
|
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||||
});
|
});
|
||||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||||
if (applyUdpCall(raw)) restartRecordingForNewTarget();
|
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
|
||||||
|
});
|
||||||
|
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||||
|
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
||||||
|
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
|
||||||
|
const call = String(p?.call ?? '');
|
||||||
|
if (applyUdpCall(call)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||||
@@ -1090,7 +1250,7 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
else setError('UDP auto-log: ' + msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1109,6 +1269,16 @@ export default function App() {
|
|||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Every setting is per-profile, so when the active profile changes the whole
|
||||||
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('profile:changed', () => {
|
||||||
|
loadStation(); loadLists(); loadCATCfg(); reloadWk();
|
||||||
|
});
|
||||||
|
return () => { off(); };
|
||||||
|
}, [loadStation, loadLists, loadCATCfg, reloadWk]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await reloadWk();
|
await reloadWk();
|
||||||
@@ -1166,9 +1336,54 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return out.replace(/\s+/g, ' ').trim();
|
return out.replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
function wkSend(rawText: string) { setWkSent(''); WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); }
|
async function wkSend(rawText: string) {
|
||||||
function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
|
setWkSent('');
|
||||||
|
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||||
|
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||||
|
// finished sending — wait for the busy flag to rise then fall, so the QSO
|
||||||
|
// isn't logged (and the form cleared) while the CW is still going out.
|
||||||
|
if (!doLog) return;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
|
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
||||||
|
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
|
||||||
|
void save();
|
||||||
|
}
|
||||||
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
|
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
|
||||||
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
|
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
||||||
|
async function runAutoCall(i: number) {
|
||||||
|
const gen = ++autoCallGenRef.current;
|
||||||
|
autoCallMacroRef.current = i;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
|
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
|
||||||
|
const m = wkMacros[i];
|
||||||
|
if (!m) break;
|
||||||
|
await wkSend(m.text);
|
||||||
|
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
||||||
|
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish
|
||||||
|
if (gen !== autoCallGenRef.current) break;
|
||||||
|
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function wkSendMacro(i: number) {
|
||||||
|
const m = wkMacros[i];
|
||||||
|
if (!m) return;
|
||||||
|
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop
|
||||||
|
else wkSend(m.text);
|
||||||
|
}
|
||||||
wkSendMacroRef.current = wkSendMacro;
|
wkSendMacroRef.current = wkSendMacro;
|
||||||
|
function wkToggleAutoCall(on: boolean) {
|
||||||
|
setWkAutoCall(on);
|
||||||
|
writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
|
||||||
|
if (!on) stopAutoCall();
|
||||||
|
}
|
||||||
|
function wkSetAutoCallSecs(n: number) {
|
||||||
|
const v = Math.max(0, Math.min(120, n || 0));
|
||||||
|
setWkAutoCallSecs(v);
|
||||||
|
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
||||||
|
}
|
||||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||||
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
||||||
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
||||||
@@ -1253,7 +1468,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||||
await AddQSO(payload);
|
await AddQSO(payload);
|
||||||
resetEntry();
|
resetEntry(); // clears the call AND the Worked-before matrix
|
||||||
|
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
@@ -1266,7 +1482,7 @@ export default function App() {
|
|||||||
function resetEntry() {
|
function resetEntry() {
|
||||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||||
// log, or if the recorder is off).
|
// log, or if the recorder is off).
|
||||||
QSOAudioCancel(); setRecording(false);
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
setCallsign(''); setComment(''); setNote('');
|
setCallsign(''); setComment(''); setNote('');
|
||||||
if (!locks.start) setQsoStartedAt(null);
|
if (!locks.start) setQsoStartedAt(null);
|
||||||
if (!locks.end) setQsoEndedAt(null);
|
if (!locks.end) setQsoEndedAt(null);
|
||||||
@@ -1470,17 +1686,23 @@ export default function App() {
|
|||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
}));
|
}));
|
||||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||||
// Begin the recording once the call resolves (a real, ≥3-char callsign,
|
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
|
||||||
// not 1–2 stray letters). Covers the fast CW workflow (type → Enter to log
|
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
|
||||||
// via the WinKeyer, no blur). No-op if the recorder is off or already
|
// we're already recording a DIFFERENT call (the operator edited the
|
||||||
// running; the pre-roll covers the lead-in.
|
// callsign), restart from zero instead of continuing the old take.
|
||||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
const recCall = call.toUpperCase();
|
||||||
|
if (recordingCallRef.current && recordingCallRef.current !== recCall) {
|
||||||
|
restartRecordingForNewTarget(recCall);
|
||||||
|
} else {
|
||||||
|
recordingCallRef.current = recCall;
|
||||||
|
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupError(String(e?.message ?? e));
|
||||||
} finally { setLookupBusy(false); }
|
} finally { setLookupBusy(false); }
|
||||||
}
|
}
|
||||||
function scheduleLookup(value: string) {
|
function scheduleLookup(value: string, force?: boolean) {
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
||||||
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
|
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
|
||||||
@@ -1490,8 +1712,14 @@ export default function App() {
|
|||||||
if (lastLookedUpRef.current !== '') resetAutoFill();
|
if (lastLookedUpRef.current !== '') resetAutoFill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
|
// Option: defer the (network) callsign lookup until the operator leaves the
|
||||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
// Call field instead of firing it as they type. `force` (programmatic set —
|
||||||
|
// clicked spot / external app) always looks up, since there's no blur to
|
||||||
|
// wait for. Worked-before stays live (local, feeds the band matrix).
|
||||||
|
if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
|
||||||
|
lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
|
||||||
|
}
|
||||||
|
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||||
}
|
}
|
||||||
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||||
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||||
@@ -1501,7 +1729,15 @@ export default function App() {
|
|||||||
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||||
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||||
}
|
}
|
||||||
function onCallsignInput(v: string) {
|
function onCallsignInput(v: string, opts?: { force?: boolean }) {
|
||||||
|
// Programmatic call-sets (force: spot click, UDP, external app) count as
|
||||||
|
// "not manually typed", so a later UDP DX call (DXHunter remote control) can
|
||||||
|
// still replace it. Without this, clicking a cluster spot froze the call:
|
||||||
|
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
|
||||||
|
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
|
||||||
|
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
|
||||||
|
// stop auto-calling so we don't key over the contact.
|
||||||
|
if (v.trim() !== '') stopAutoCall();
|
||||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||||
// on every status packet. If it matches what's already in the entry,
|
// on every status packet. If it matches what's already in the entry,
|
||||||
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
||||||
@@ -1512,21 +1748,23 @@ export default function App() {
|
|||||||
// keeps the pre-roll from before this); clearing it discards the take.
|
// keeps the pre-roll from before this); clearing it discards the take.
|
||||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||||
// you may type a call and work it minutes later. Clearing it cancels.
|
// you may type a call and work it minutes later. Clearing it cancels.
|
||||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
|
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
|
||||||
const wasEmpty = callsign.trim() === '';
|
|
||||||
const isEmpty = v.trim() === '';
|
const isEmpty = v.trim() === '';
|
||||||
if (wasEmpty && !isEmpty && !locks.start) {
|
if (!isEmpty && !locks.start) {
|
||||||
// First keystroke of a new QSO — freeze the start time so it doesn't
|
// Restart the start time on every callsign change (each keystroke, a
|
||||||
// drift even if the lookup or typing takes 30 seconds. Skip when
|
// clicked spot, a new call): "Start UTC" should mark when you actually
|
||||||
// start is locked: the user is back-entering a past QSO and set a
|
// began this contact, not a stale time frozen at the first keystroke.
|
||||||
// specific time manually.
|
// Skip when start is locked (back-entering a past QSO at a chosen time).
|
||||||
setQsoStartedAt(new Date());
|
setQsoStartedAt(new Date());
|
||||||
} else if (isEmpty && !locks.start) {
|
} else if (isEmpty && !locks.start) {
|
||||||
// Callsign wiped → user abandoned this QSO; reset the timer.
|
// Callsign wiped → user abandoned this QSO; reset the timer.
|
||||||
setQsoStartedAt(null);
|
setQsoStartedAt(null);
|
||||||
}
|
}
|
||||||
setCallsign(v);
|
setCallsign(v);
|
||||||
scheduleLookup(v);
|
// opts.force = the call was set programmatically (clicked spot / external
|
||||||
|
// app): there's no "leaving the field", so look it up now regardless of the
|
||||||
|
// lookup-on-blur option.
|
||||||
|
scheduleLookup(v, opts?.force);
|
||||||
}
|
}
|
||||||
function markEdited(field: string) { userEditedRef.current.add(field); }
|
function markEdited(field: string) { userEditedRef.current.add(field); }
|
||||||
|
|
||||||
@@ -1555,10 +1793,8 @@ export default function App() {
|
|||||||
if (!path) return;
|
if (!path) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const res = await ExportADIF(path, includeAppFields);
|
const res = await ExportADIF(path, includeAppFields);
|
||||||
// Reuse the error banner area for a brief success note (4s auto-dismiss).
|
// Green success toast (auto-dismiss) — not the red error banner.
|
||||||
const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
|
showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
|
||||||
setError(msg);
|
|
||||||
setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1608,6 +1844,7 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'tools', label: 'Tools', items: [
|
{ name: 'tools', label: 'Tools', items: [
|
||||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||||
|
{ type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||||
@@ -1615,11 +1852,12 @@ export default function App() {
|
|||||||
// Maintenance — bumped here while we only have one entry. Will move
|
// Maintenance — bumped here while we only have one entry. Will move
|
||||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||||
|
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||||
]},
|
]},
|
||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);
|
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -1632,9 +1870,26 @@ export default function App() {
|
|||||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
|
case 'help.about': setShowAbout(true); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRefs() {
|
||||||
|
if (refsDownloading) return;
|
||||||
|
setRefsDownloading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const summary = await DownloadAllReferenceLists();
|
||||||
|
showToast(`Reference lists updated — ${summary}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(`Reference download failed: ${String(e?.message ?? e)}`);
|
||||||
|
} finally {
|
||||||
|
setRefsDownloading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1749,21 +2004,26 @@ export default function App() {
|
|||||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||||
value={callsign}
|
value={callsign}
|
||||||
onChange={(e) => onCallsignInput(e.target.value)}
|
onChange={(e) => onCallsignInput(e.target.value)}
|
||||||
// Start the QSO recording when leaving the callsign field (the pre-roll
|
onBlur={() => {
|
||||||
// covers the seconds before). No-op when the recorder is off.
|
const c = callsign.trim();
|
||||||
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
|
if (!c) return;
|
||||||
|
// Lookup-on-blur mode: run the deferred lookup now (it also starts the
|
||||||
|
// recording). Otherwise just start the recording (lookup already ran).
|
||||||
|
if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
|
||||||
|
else QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstTxBlock = (
|
const rstTxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
|
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
|
||||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstRxBlock = (
|
const rstRxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
|
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
|
||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
// Deferred-entry date: only shown when the start time is locked (back-entering
|
// Deferred-entry date: only shown when the start time is locked (back-entering
|
||||||
@@ -2015,7 +2275,7 @@ export default function App() {
|
|||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
||||||
<span className="text-[11px] text-muted-foreground">v0.1</span>
|
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menubar menus={menus} onAction={handleMenu} />
|
<Menubar menus={menus} onAction={handleMenu} />
|
||||||
@@ -2206,10 +2466,10 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
|
variant={showBandMap ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setActiveTab('bandmap')}
|
onClick={() => setShowBandMap((v) => !v)}
|
||||||
title="Open the Band Map tab (several bands side by side)"
|
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
Band map
|
Band map
|
||||||
@@ -2252,38 +2512,98 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* First launch: mandatory station identity. Blocks until filled. */}
|
||||||
|
{showFirstRun && (
|
||||||
|
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateInfo && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
||||||
|
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
||||||
|
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAbout && (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
|
||||||
|
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-1">
|
||||||
|
<div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
||||||
|
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
||||||
|
{updateInfo ? (
|
||||||
|
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
|
||||||
|
Update available: v{updateInfo.latest} — download
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[11px] text-emerald-600">You're up to date</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-sm">
|
||||||
|
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
|
||||||
|
<button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||||
success toast; both auto-dismiss. */}
|
success toast; both auto-dismiss. */}
|
||||||
{migratedBanner && (
|
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
|
skipping leaves lookups/uploads without their passwords until unlocked. */}
|
||||||
<span className="flex-1">
|
{unlockOpen && (
|
||||||
<strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
Please <strong>restart OpsLog</strong> to use the new location.
|
<div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
|
||||||
</span>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
|
<Lock className="size-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold">Unlock saved passwords</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
value={unlockPass}
|
||||||
|
placeholder="Passphrase"
|
||||||
|
onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
{unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
|
||||||
|
<Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
|
||||||
|
{unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* "You have been spotted" banner — shows when our own callsign appears
|
|
||||||
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
|
|
||||||
so it never shifts the layout (push-down / spring-back) and never
|
|
||||||
covers the entry fields; auto-hides 3s after the last self-spot. */}
|
|
||||||
{!compact && selfSpot && (
|
|
||||||
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
|
||||||
<RadioTower className="size-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
|
|
||||||
{' '}on <strong className="font-mono">{selfSpot.freqKHz?.toFixed(1)} kHz</strong>
|
|
||||||
{selfSpot.band ? ` (${selfSpot.band})` : ''}
|
|
||||||
{selfSpot.comment ? <span className="text-amber-800"> — {selfSpot.comment}</span> : null}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button className="text-amber-700 hover:text-amber-900" title="Dismiss" onClick={() => setSelfSpot(null)}>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== ENTRY STRIP =====
|
{/* ===== ENTRY STRIP =====
|
||||||
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
||||||
@@ -2294,7 +2614,7 @@ export default function App() {
|
|||||||
className={cn('bg-card shadow-sm border-border',
|
className={cn('bg-card shadow-sm border-border',
|
||||||
compact
|
compact
|
||||||
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
|
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
|
||||||
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
|
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2368,7 +2688,11 @@ export default function App() {
|
|||||||
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
|
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
|
||||||
tabs, then reserved free space. Hidden in compact mode. */}
|
tabs, then reserved free space. Hidden in compact mode. */}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
|
// relative + absolute inner: the panel's content can't grow the row, so
|
||||||
|
// the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
|
||||||
|
// scrolls inside this fixed height instead of pushing everything down.
|
||||||
|
<div className="w-[560px] shrink-0 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||||
<DetailsPanel
|
<DetailsPanel
|
||||||
callsign={callsign}
|
callsign={callsign}
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
@@ -2385,6 +2709,7 @@ export default function App() {
|
|||||||
onTab={setDetailTab}
|
onTab={setDetailTab}
|
||||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
@@ -2419,7 +2744,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{wkEnabled && (
|
{wkEnabled && (
|
||||||
<div className="w-[500px] shrink-0 min-h-0">
|
<div className="w-[380px] shrink-0 min-h-0">
|
||||||
<WinkeyerPanel
|
<WinkeyerPanel
|
||||||
status={wkStatus}
|
status={wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
@@ -2434,12 +2759,16 @@ export default function App() {
|
|||||||
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
||||||
onSend={wkSend}
|
onSend={wkSend}
|
||||||
onSendMacro={wkSendMacro}
|
onSendMacro={wkSendMacro}
|
||||||
onStop={() => WinkeyerStop().catch(() => {})}
|
onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }}
|
||||||
onClose={() => wkSetEnabled(false)}
|
onClose={() => wkSetEnabled(false)}
|
||||||
sendOnType={wkSendOnType}
|
sendOnType={wkSendOnType}
|
||||||
onToggleSendOnType={wkToggleSendOnType}
|
onToggleSendOnType={wkToggleSendOnType}
|
||||||
onSendRaw={wkSendRaw}
|
onSendRaw={wkSendRaw}
|
||||||
onBackspace={wkBackspace}
|
onBackspace={wkBackspace}
|
||||||
|
autoCall={wkAutoCall}
|
||||||
|
autoCallSecs={wkAutoCallSecs}
|
||||||
|
onToggleAutoCall={wkToggleAutoCall}
|
||||||
|
onSetAutoCallSecs={wkSetAutoCallSecs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2470,14 +2799,14 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
|
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
|
||||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||||
<TabsList className="px-3 shrink-0">
|
<TabsList className="px-3 shrink-0">
|
||||||
<TabsTrigger value="main">Main</TabsTrigger>
|
<TabsTrigger value="main">Main</TabsTrigger>
|
||||||
<TabsTrigger value="recent">
|
<TabsTrigger value="recent">
|
||||||
Recent QSOs
|
Recent QSOs
|
||||||
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="cluster">Cluster</TabsTrigger>
|
<TabsTrigger value="cluster">Cluster</TabsTrigger>
|
||||||
<TabsTrigger value="worked">
|
<TabsTrigger value="worked">
|
||||||
@@ -2527,7 +2856,7 @@ export default function App() {
|
|||||||
value={filterCallsign}
|
value={filterCallsign}
|
||||||
onChange={(e) => setFilterCallsign(e.target.value)}
|
onChange={(e) => setFilterCallsign(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={refresh}>
|
<Button variant="outline" size="sm" onClick={() => refresh()}>
|
||||||
<RefreshCw className="size-3.5" /> Refresh
|
<RefreshCw className="size-3.5" /> Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2587,6 +2916,7 @@ export default function App() {
|
|||||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
onSendTo={bulkSendTo}
|
onSendTo={bulkSendTo}
|
||||||
onSendRecording={bulkSendRecording}
|
onSendRecording={bulkSendRecording}
|
||||||
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onRowSelected={(id) => setSelectedId(id)}
|
onRowSelected={(id) => setSelectedId(id)}
|
||||||
@@ -2933,7 +3263,8 @@ export default function App() {
|
|||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||||
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||||
@@ -2997,6 +3328,21 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{showBandMap && (
|
||||||
|
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
|
||||||
|
<BandMap
|
||||||
|
side={bandMapSide}
|
||||||
|
onToggleSide={toggleBandMapSide}
|
||||||
|
band={band}
|
||||||
|
spots={spots.filter((s) => s.band === band)}
|
||||||
|
spotStatus={spotStatus}
|
||||||
|
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
|
onSpotClick={handleSpotClick}
|
||||||
|
onClose={() => setShowBandMap(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
@@ -3041,6 +3387,17 @@ export default function App() {
|
|||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{dbConn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSettingsSection('database'); setShowSettings(true); }}
|
||||||
|
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
|
||||||
|
>
|
||||||
|
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||||
|
<span className="font-mono truncate">{dbConn.label}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -3089,6 +3446,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AutoEQSL
|
||||||
|
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
||||||
|
onError={(msg) => showToast(msg)}
|
||||||
|
/>
|
||||||
|
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||||
|
<SendEQSLModal
|
||||||
|
open={eqslQsoId !== null}
|
||||||
|
qsoId={eqslQsoId}
|
||||||
|
onClose={() => setEqslQsoId(null)}
|
||||||
|
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{deletingQSO && (
|
{deletingQSO && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete QSO?"
|
title="Delete QSO?"
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ export type AwardDef = {
|
|||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||||
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AwardOrRule = {
|
||||||
|
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
|
||||||
|
leading_str?: string; trailing_str?: string; prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||||
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setErr('');
|
setErr('');
|
||||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||||
.catch((e) => setErr(String(e?.message ?? e)));
|
.catch((e) => setErr(String(e?.message ?? e)));
|
||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional OR searches: a QSO earns a reference if the
|
||||||
|
primary rule OR any of these match. */}
|
||||||
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||||
|
<Button size="sm" variant="outline" className="h-7"
|
||||||
|
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||||
|
<Plus className="size-3.5" /> Add OR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(cur.or_rules ?? []).map((r, ri) => {
|
||||||
|
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
|
||||||
|
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||||
|
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
{['code', 'description', 'pattern'].map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||||
|
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||||
|
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||||
|
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ interface Props {
|
|||||||
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
||||||
// automatically — we surface that so the operator needn't pick it by hand.
|
// automatically — we surface that so the operator needn't pick it by hand.
|
||||||
fieldValues?: Record<string, string>;
|
fieldValues?: Record<string, string>;
|
||||||
|
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
|
||||||
|
// the live entry panel passes "flex-1 min-h-0" so it fills the available
|
||||||
|
// space instead of overflowing and forcing a scrollbar.
|
||||||
|
heightClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
|
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||||
const [awardCode, setAwardCode] = useState('POTA');
|
const [awardCode, setAwardCode] = useState('POTA');
|
||||||
@@ -63,13 +67,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
|||||||
// for a French call but not for others.
|
// for a French call but not for others.
|
||||||
const awards = useMemo(() => {
|
const awards = useMemo(() => {
|
||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||||
const m = metas[String(d.code).toUpperCase()];
|
|
||||||
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
|
||||||
if (!hasRefs) return false;
|
|
||||||
const scope = d.dxcc_filter ?? [];
|
const scope = d.dxcc_filter ?? [];
|
||||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||||
|
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||||
|
// award (e.g. "Worked All Provinces of China") has no built-in list, so
|
||||||
|
// requiring one would make it impossible to assign references by hand. The
|
||||||
|
// operator can pick from a loaded list when present, or add an unlisted
|
||||||
|
// reference (the right-hand panel). Dynamic / list-backed awards behave as
|
||||||
|
// before — they just always pass here now.
|
||||||
return true;
|
return true;
|
||||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
@@ -169,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 h-[210px]">
|
<div className={`flex gap-2 ${heightClass}`}>
|
||||||
{/* Left panel */}
|
{/* Left panel */}
|
||||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean };
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
@@ -108,7 +109,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
try {
|
try {
|
||||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||||
const list: AwardListItem[] = defs
|
const list: AwardListItem[] = defs
|
||||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
@@ -121,6 +122,53 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
|
|
||||||
const current = byCode[selected];
|
const current = byCode[selected];
|
||||||
|
|
||||||
|
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||||
|
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||||
|
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
||||||
|
// the bands the operator actually has contacts on (so DXCC, which has no band
|
||||||
|
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
||||||
|
// no basis yet → callers fall back to all bands.
|
||||||
|
const awardBands = useMemo(() => {
|
||||||
|
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||||
|
if (vb.length > 0) return new Set(vb);
|
||||||
|
const worked = (current?.bands ?? [])
|
||||||
|
.filter((b) => (b.worked ?? 0) > 0)
|
||||||
|
.map((b) => String(b.band).toLowerCase());
|
||||||
|
return new Set(worked);
|
||||||
|
}, [awardList, selected, current]);
|
||||||
|
|
||||||
|
const gridBands = useMemo(() => {
|
||||||
|
if (awardBands.size === 0) return GRID_BANDS;
|
||||||
|
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
||||||
|
return filtered.length ? filtered : GRID_BANDS;
|
||||||
|
}, [awardBands]);
|
||||||
|
|
||||||
|
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||||
|
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||||
|
// shows only when the award has no emission restriction or lists that emission.
|
||||||
|
const statsRows = useMemo(() => {
|
||||||
|
if (!stats) return [];
|
||||||
|
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
|
||||||
|
if (em.size === 0) return stats.rows;
|
||||||
|
return stats.rows.filter((r) => {
|
||||||
|
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
|
||||||
|
return !m || em.has(m[1]);
|
||||||
|
});
|
||||||
|
}, [stats, awardList, selected]);
|
||||||
|
|
||||||
|
// Indices into stats.bands to display (and to slice each row's cells by), same
|
||||||
|
// band basis as the grid.
|
||||||
|
const statsBandIdx = useMemo(() => {
|
||||||
|
if (!stats) return [] as number[];
|
||||||
|
const all = stats.bands.map((_, i) => i);
|
||||||
|
if (awardBands.size === 0) return all;
|
||||||
|
const idx = stats.bands
|
||||||
|
.map((b, i) => ({ b: b.toLowerCase(), i }))
|
||||||
|
.filter((x) => awardBands.has(x.b))
|
||||||
|
.map((x) => x.i);
|
||||||
|
return idx.length ? idx : all;
|
||||||
|
}, [stats, awardBands]);
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
@@ -228,10 +276,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{/* Band breakdown */}
|
{/* Band breakdown */}
|
||||||
{(current.bands ?? []).length > 0 && (
|
{(current.bands ?? []).length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-border/60">
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(current.bands ?? []).map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||||
@@ -245,9 +293,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||||
<button key={k} onClick={() => setRefFilter(k)}
|
<button key={k} onClick={() => setRefFilter(k)}
|
||||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
@@ -255,10 +303,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> Missing refs
|
<AlertTriangle className="size-3" /> Missing refs
|
||||||
@@ -283,27 +331,27 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{statsLoading || !stats ? (
|
{statsLoading || !stats ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
|
||||||
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
|
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
||||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stats.rows.map((row, i) => {
|
{statsRows.map((row, i) => {
|
||||||
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
||||||
return (
|
return (
|
||||||
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
||||||
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
|
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
{row.cells.map((c, j) => (
|
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||||
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||||
))}
|
); })}
|
||||||
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||||
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -313,30 +361,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</div>
|
</div>
|
||||||
) : view === 'grid' ? (
|
) : view === 'grid' ? (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||||
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||||
{GRID_BANDS.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||||
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||||
</td>
|
</td>
|
||||||
{GRID_BANDS.map((b) => {
|
{gridBands.map((b) => {
|
||||||
const s = cellStatus(r, b);
|
const s = cellStatus(r, b);
|
||||||
return (
|
return (
|
||||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||||
{s === 'none' ? <span className="block w-9 h-5" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<button
|
||||||
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
@@ -351,22 +399,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
|
||||||
<th className="py-1 pr-2 font-medium">Name</th>
|
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||||
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||||
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||||
<th className="py-1 font-medium">Bands</th>
|
<th className="py-1.5 font-medium">Bands</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||||
<td className="py-1 pr-2">
|
<td className="py-1 pr-2">
|
||||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||||
@@ -401,23 +449,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
// MissingQSOModal lists contacts within an award's scope that carry NO
|
// MissingQSOModal lists contacts within an award's scope that carry NO
|
||||||
// reference — the silent gaps. Rows open the QSO editor so the operator can add
|
// reference — the silent gaps. Rows open the QSO editor so the operator can add
|
||||||
// the missing reference (e.g. a department for DDFM).
|
// the missing reference (e.g. a department for DDFM).
|
||||||
|
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||||
|
|
||||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||||
|
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [assignRef, setAssignRef] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setSel(new Set());
|
||||||
AwardMissingQSOs(code)
|
AwardMissingQSOs(code)
|
||||||
.then((r) => setQsos((r ?? []) as any))
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
.catch(() => setQsos([]))
|
.catch(() => setQsos([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [code]);
|
useEffect(() => { load(); }, [code]);
|
||||||
// Distinct stations vs total contacts (a station may appear on several QSOs).
|
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
||||||
|
useEffect(() => {
|
||||||
|
ListAwardReferences(code)
|
||||||
|
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||||
|
.catch(() => setRefs([]));
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
const val = (q: any): string => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'qso_date': return String(q.qso_date ?? '');
|
||||||
|
case 'callsign': return String(q.callsign ?? '').toUpperCase();
|
||||||
|
case 'band': return String(q.band ?? '');
|
||||||
|
case 'mode': return String(q.mode ?? '');
|
||||||
|
case 'country': return String(q.country ?? '').toUpperCase();
|
||||||
|
case 'qth': return qthOf(q).toUpperCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
|
||||||
|
}, [qsos, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
|
||||||
|
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
|
||||||
|
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
|
||||||
|
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
const setSort = (k: MissingSortKey) => {
|
||||||
|
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
else { setSortKey(k); setSortDir('asc'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyAssign() {
|
||||||
|
if (!assignRef || sel.size === 0) return;
|
||||||
|
setBusy(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const assignedIds = Array.from(sel);
|
||||||
|
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
|
||||||
|
// Optimistic: the assigned contacts now carry a reference, so drop them
|
||||||
|
// from the list locally instead of re-running the slow whole-log scan.
|
||||||
|
const done = new Set(assignedIds);
|
||||||
|
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||||
|
setSel(new Set());
|
||||||
|
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(String(e?.message ?? e));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
|
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
|
||||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
|
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
|
||||||
|
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
|
||||||
|
<span className="inline-flex items-center gap-0.5">{label}
|
||||||
|
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
<AlertTriangle className="size-4 text-amber-600" />
|
<AlertTriangle className="size-4 text-amber-600" />
|
||||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
||||||
@@ -432,8 +548,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||||||
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
|
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk-assign toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||||
|
<span className="text-xs text-muted-foreground">{sel.size} selected →</span>
|
||||||
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{refs.map((r) => (
|
||||||
|
<SelectItem key={r.code} value={r.code}>
|
||||||
|
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||||
|
</Button>
|
||||||
|
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
||||||
@@ -443,22 +579,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||||
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
|
<tr>
|
||||||
|
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||||
|
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
||||||
|
<SortTh k="callsign" label="Callsign" />
|
||||||
|
<SortTh k="band" label="Band" />
|
||||||
|
<SortTh k="mode" label="Mode" />
|
||||||
|
<SortTh k="country" label="Country" />
|
||||||
|
<SortTh k="qth" label="QTH / Note" />
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{qsos.map((q, i) => (
|
{sorted.map((q, i) => {
|
||||||
<tr key={q.id ?? i}
|
const id = q.id as number;
|
||||||
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
|
return (
|
||||||
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
|
<tr key={id ?? i}
|
||||||
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<td className="py-1 pr-2">{q.band}</td>
|
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
|
||||||
<td className="py-1 pr-2">{q.mode}</td>
|
</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
|
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
|
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
|
||||||
</tr>
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
))}
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
|
||||||
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface Props {
|
|||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
@@ -78,7 +79,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -100,6 +101,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [wb]);
|
||||||
|
|
||||||
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
|
// worked on — not just the operator's configured column list).
|
||||||
|
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
||||||
|
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
||||||
|
// lists every real (band, mode) the entity was worked on.
|
||||||
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
|
const curMode = (currentMode || '').toUpperCase().trim();
|
||||||
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
|
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
||||||
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
||||||
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
|
// exact band+mode is NOT yet:
|
||||||
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
|
// New Band = the band is new (the mode was worked on another band).
|
||||||
|
// New Mode = the mode is new (the band was worked in another mode).
|
||||||
|
// New Slot = both band and mode already worked — just not together.
|
||||||
|
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
|
||||||
|
const newBandMode = slotNew && !bandWorked && !modeWorked;
|
||||||
|
const newBand = slotNew && !bandWorked && modeWorked;
|
||||||
|
const newMode = slotNew && bandWorked && !modeWorked;
|
||||||
|
const newSlot = slotNew && bandWorked && modeWorked;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -135,6 +159,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{(newBand || newMode || newBandMode || newSlot) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||||
|
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||||
|
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||||
|
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : busy ? (
|
) : busy ? (
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||||
@@ -181,7 +213,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
</th>
|
</th>
|
||||||
{cols.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = b.tag === currentBand && classCurrent;
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||||
|
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||||
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
|
// there's nothing notable to show.
|
||||||
|
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||||
|
switch (s?.status) {
|
||||||
|
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||||
|
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||||
|
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||||
|
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
const COL_CATALOG: ColEntry[] = [
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Time', colId: 'time',
|
group: 'Spot', label: 'Time', colId: 'time',
|
||||||
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Spot', label: 'Status', colId: 'status',
|
||||||
|
headerName: 'Status', width: 96, sortable: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||||
|
// is obvious at the row level, not just a single coloured cell.
|
||||||
|
valueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (s?.status === 'new') return 'NEW DXCC';
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||||
|
return s?.worked_call ? 'WKD CALL' : '';
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => {
|
||||||
|
const b = statusBadge(statusFor(p));
|
||||||
|
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
||||||
|
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||||
|
}}>{b.text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||||
|
if (s?.worked_call) return 'Already worked this call';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -118,9 +119,43 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
|
// Live award detection: run the SAME engine used at log time over the current
|
||||||
|
// contact (callsign + looked-up address/state/zones) so award references the
|
||||||
|
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||||
|
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||||
|
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||||
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||||
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const q: any = {
|
||||||
|
callsign, band, mode,
|
||||||
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||||
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
|
qso_date: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
|
||||||
|
setDetected(all as any);
|
||||||
|
const cur = details.award_refs ?? '';
|
||||||
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
|
let next = cur;
|
||||||
|
for (const r of all) {
|
||||||
|
if (!r.pickable) continue;
|
||||||
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||||
|
}
|
||||||
|
if (next !== cur) onChange({ award_refs: next });
|
||||||
|
} catch { /* leave detection empty on failure */ }
|
||||||
|
}, 400);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -179,10 +214,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -205,8 +240,18 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
|
bearing and distances move to the row below. */}
|
||||||
|
<Field label="DXCC #">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={details.dxcc ?? ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Azimuth LP">
|
<Field label="Azimuth LP">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
@@ -247,13 +292,24 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'awards' && (
|
{open === 'awards' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||||
<AwardRefSelector
|
<AwardRefSelector
|
||||||
dxcc={details.dxcc}
|
dxcc={details.dxcc}
|
||||||
value={details.award_refs ?? ''}
|
value={details.award_refs ?? ''}
|
||||||
onChange={(v) => onChange({ award_refs: v })}
|
onChange={(v) => onChange({ award_refs: v })}
|
||||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||||
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
|
{detected.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||||
|
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||||
|
{detected.map((r) => (
|
||||||
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||||
|
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting…</span>}
|
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> Stop
|
<Square className="size-3" /> Stop
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Radio } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
|
|
||||||
|
// FirstRunModal collects the mandatory station identity on the very first launch
|
||||||
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
|
||||||
|
const [refsMsg, setRefsMsg] = useState('');
|
||||||
|
|
||||||
|
async function downloadRefs() {
|
||||||
|
setRefsState('loading');
|
||||||
|
try {
|
||||||
|
const summary = await DownloadAllReferenceLists();
|
||||||
|
setRefsMsg(summary);
|
||||||
|
setRefsState('done');
|
||||||
|
} catch (e: any) {
|
||||||
|
setRefsMsg(String(e?.message ?? e));
|
||||||
|
setRefsState('idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
|
||||||
|
|
||||||
|
const callsign = (p?.callsign ?? '').trim().toUpperCase();
|
||||||
|
const grid = (p?.my_grid ?? '').trim().toUpperCase();
|
||||||
|
const operator = (p?.operator ?? '').trim().toUpperCase();
|
||||||
|
const canSave = callsign.length >= 3 && grid.length >= 4;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!p || !canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await SaveProfile({
|
||||||
|
...p,
|
||||||
|
callsign,
|
||||||
|
my_grid: grid,
|
||||||
|
operator: operator || callsign,
|
||||||
|
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
|
||||||
|
op_name: (p.op_name ?? '').trim(),
|
||||||
|
} as any);
|
||||||
|
onDone();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Radio className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||||
|
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||||
|
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Operator</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Owner</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Name</Label>
|
||||||
|
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||||
|
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium">Award reference lists</div>
|
||||||
|
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||||
|
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
||||||
|
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,12 +121,21 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||||
if (from && beamAzimuths && beamAzimuths.length) {
|
if (from && beamAzimuths && beamAzimuths.length) {
|
||||||
const half = (beamWidth ?? 30) / 2;
|
const half = (beamWidth ?? 30) / 2;
|
||||||
const D = 9000; // lobe length (km)
|
const D = 5500; // lobe length (km)
|
||||||
const radial = (b: number): [number, number][] =>
|
// A great circle pointing poleward runs to lat ±90, where Mercator is
|
||||||
Array.from({ length: 14 }, (_, i) => {
|
// infinite — the line then snaps across the top of the map. Generate the
|
||||||
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
|
// radial with plenty of points (smooth curve) and STOP it just before the
|
||||||
return [d.lat, d.lon] as [number, number];
|
// pole, so a north/south beam draws a clean line toward the edge instead.
|
||||||
});
|
const radial = (b: number): [number, number][] => {
|
||||||
|
const pts: [number, number][] = [];
|
||||||
|
const N = 64;
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||||
|
pts.push([d.lat, d.lon]);
|
||||||
|
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
};
|
||||||
for (const az of beamAzimuths) {
|
for (const az of beamAzimuths) {
|
||||||
const arc: [number, number][] = [];
|
const arc: [number, number][] = [];
|
||||||
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
||||||
@@ -139,10 +148,15 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
...arc,
|
...arc,
|
||||||
...radial(az + half).reverse(),
|
...radial(az + half).reverse(),
|
||||||
]);
|
]);
|
||||||
L.polygon(ring as L.LatLngExpression[], {
|
// Near a pole the lobe's two edges diverge wildly (one runs NW, the
|
||||||
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
// other NE) and look broken on a Mercator map — so for a poleward beam
|
||||||
}).addTo(wo);
|
// show ONLY the clean boresight (below). Otherwise draw the filled lobe.
|
||||||
// Boresight (dashed centre line).
|
if (!ring.some(([la]) => Math.abs(la) > 78)) {
|
||||||
|
L.polygon(ring as L.LatLngExpression[], {
|
||||||
|
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
||||||
|
}).addTo(wo);
|
||||||
|
}
|
||||||
|
// Boresight (dashed centre line) — always; great-circle polyline is safe.
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
||||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Props = {
|
|||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
|||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -80,16 +81,27 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onSendRecording && (
|
{(onSendRecording || onSendEQSL) && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
<button
|
{onSendEQSL && (
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
<button
|
||||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
>
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
<Mail className="size-4 text-rose-600" />
|
>
|
||||||
<span>Send recording by e-mail</span>
|
<Mail className="size-4 text-amber-600" />
|
||||||
</button>
|
<span>Send OpsLog QSL by e-mail</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onSendRecording && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<Mail className="size-4 text-rose-600" />
|
||||||
|
<span>Send recording by e-mail</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
|
|||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
const date = new Date(d);
|
const date = new Date(d);
|
||||||
if (isNaN(date.getTime())) return '';
|
if (isNaN(date.getTime())) return '';
|
||||||
|
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
|
||||||
|
// doesn't apply to a time struct), so a QSO with no end time arrives as a
|
||||||
|
// year-1 date. Treat anything that old as unset — otherwise the datetime
|
||||||
|
// field shows a garbage value and fights the user's typing.
|
||||||
|
if (date.getUTCFullYear() <= 1) return '';
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||||
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||||||
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
const [dateOff, setDateOff] = useState(dateOffISO);
|
||||||
|
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||||||
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||||
const [localErr, setLocalErr] = useState('');
|
const [localErr, setLocalErr] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||||
|
const on = !!c;
|
||||||
|
setEndEnabled(on);
|
||||||
|
// Prefill an empty end with the start time so the user
|
||||||
|
// only tweaks the minutes instead of typing a full date.
|
||||||
|
if (on && !dateOff) setDateOff(dateOn);
|
||||||
|
}} /> QSO End (UTC)
|
||||||
</Label>
|
</Label>
|
||||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type Props = {
|
|||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -100,8 +101,8 @@ export const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
@@ -149,6 +150,8 @@ export const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
|
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
@@ -215,7 +218,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -361,6 +364,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
|
onSendEQSL={onSendEQSL}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
|
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
|
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
@@ -23,7 +25,10 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetDataDir,
|
GetDataDir,
|
||||||
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
@@ -113,7 +118,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
|
|||||||
const emptyProfile = (): Profile => ({
|
const emptyProfile = (): Profile => ({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
callsign: '', operator: '', owner_callsign: '',
|
callsign: '', operator: '', op_name: '', owner_callsign: '',
|
||||||
my_grid: '', my_country: '',
|
my_grid: '', my_country: '',
|
||||||
my_state: '', my_cnty: '',
|
my_state: '', my_cnty: '',
|
||||||
my_street: '', my_city: '', my_postal_code: '',
|
my_street: '', my_city: '', my_postal_code: '',
|
||||||
@@ -122,6 +127,7 @@ const emptyProfile = (): Profile => ({
|
|||||||
tx_pwr: undefined,
|
tx_pwr: undefined,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
|
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
|
||||||
created_at: '' as any,
|
created_at: '' as any,
|
||||||
updated_at: '' as any,
|
updated_at: '' as any,
|
||||||
});
|
});
|
||||||
@@ -154,6 +160,7 @@ type SectionId =
|
|||||||
| 'cluster'
|
| 'cluster'
|
||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
|
| 'autostart'
|
||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
@@ -187,16 +194,16 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database', id: 'database' },
|
{ kind: 'item', label: 'Database', id: 'database' },
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
{ kind: 'item', label: 'Autostart', id: 'autostart' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
{ kind: 'item', label: 'Rotator', id: 'rotator' },
|
||||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
|
{ kind: 'item', label: 'Antenna', id: 'antenna' },
|
||||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -214,11 +221,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
|
autostart: 'Autostart',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
winkeyer: 'CW Keyer (WinKeyer)',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Antenna',
|
antenna: 'Antenna',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
@@ -314,6 +322,167 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutostartPanelComponent manages the per-profile list of external programs to
|
||||||
|
// launch when OpsLog starts. It's a self-contained component (its own state) so
|
||||||
|
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
|
||||||
|
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
|
||||||
|
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
|
||||||
|
|
||||||
|
function AutostartPanelComponent() {
|
||||||
|
const [progs, setProgs] = useState<AutostartProg[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setLoaded(true); }
|
||||||
|
}
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('profile:changed', () => load());
|
||||||
|
return () => { if (typeof off === 'function') off(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function commit(next: AutostartProg[]) {
|
||||||
|
setProgs(next);
|
||||||
|
try { await SaveAutostartPrograms(next as any); setErr(''); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
const patch = (id: string, p: Partial<AutostartProg>) =>
|
||||||
|
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
|
||||||
|
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
|
||||||
|
|
||||||
|
async function addProgram() {
|
||||||
|
try {
|
||||||
|
const path = await BrowseExecutable();
|
||||||
|
if (!path) return;
|
||||||
|
const base = path.split(/[\\/]/).pop() || path;
|
||||||
|
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
|
||||||
|
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
commit([...progs, { id, name, path, args: '', enabled: true }]);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function rebrowse(id: string) {
|
||||||
|
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function launchNow(id: string) {
|
||||||
|
try {
|
||||||
|
const r: any = await LaunchAutostartProgram(id);
|
||||||
|
const txt = r?.status === 'launched' ? '✓ launched'
|
||||||
|
: r?.status === 'already_running' ? 'already running — not started again'
|
||||||
|
: r?.status === 'missing' ? '✗ executable not found'
|
||||||
|
: (r?.message || r?.status || 'done');
|
||||||
|
setLaunchMsg((m) => ({ ...m, [id]: txt }));
|
||||||
|
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Autostart"
|
||||||
|
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 max-w-3xl">
|
||||||
|
{loaded && progs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No programs yet — add one below.</p>
|
||||||
|
)}
|
||||||
|
{progs.map((p) => (
|
||||||
|
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
|
||||||
|
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
|
||||||
|
onChange={(e) => patch(p.id, { name: e.target.value })} />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
|
||||||
|
<Play className="size-3.5" /> Launch
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Program</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
|
||||||
|
<FolderOpen className="size-3.5" /> Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Arguments</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
|
||||||
|
onChange={(e) => patch(p.id, { args: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" onClick={addProgram}>
|
||||||
|
<Plus className="size-4" /> Add program…
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-xs text-destructive">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
|
||||||
|
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||||
|
// can own its state; embedded inside GeneralPanel.
|
||||||
|
function TelemetryToggle() {
|
||||||
|
const [on, setOn] = useState(true);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
|
Send anonymous usage statistics
|
||||||
|
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
||||||
|
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
||||||
|
// the hook-less CATPanel).
|
||||||
|
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
async function scan() {
|
||||||
|
setBusy(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const r = ((await DiscoverFlexRadios()) ?? []) as any[];
|
||||||
|
setFound(r as any);
|
||||||
|
if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(String(e?.message ?? e));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
|
||||||
|
</div>
|
||||||
|
{found.map((r) => (
|
||||||
|
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
|
||||||
|
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
|
||||||
|
<span className="font-mono font-semibold">{r.ip}</span>
|
||||||
|
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
|
||||||
|
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
const label = SECTION_LABELS[id] ?? id;
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
const IconCmp = Icon ?? Construction;
|
const IconCmp = Icon ?? Construction;
|
||||||
@@ -361,7 +530,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -424,21 +593,48 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
|
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
|
||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
|
// Password-encryption (secret vault) state.
|
||||||
|
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||||||
|
const [ppNew, setPpNew] = useState('');
|
||||||
|
const [ppConfirm, setPpConfirm] = useState('');
|
||||||
|
const [ppErr, setPpErr] = useState('');
|
||||||
|
const [ppBusy, setPpBusy] = useState(false);
|
||||||
|
const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
|
||||||
|
useEffect(() => { refreshSecret(); }, []);
|
||||||
|
const applyPassphrase = async () => {
|
||||||
|
if (ppNew !== ppConfirm) { setPpErr('Passphrases do not match'); return; }
|
||||||
|
setPpBusy(true); setPpErr('');
|
||||||
|
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
||||||
|
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
||||||
|
finally { setPpBusy(false); }
|
||||||
|
};
|
||||||
|
const removePassphrase = async () => {
|
||||||
|
setPpBusy(true); setPpErr('');
|
||||||
|
try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
||||||
|
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
||||||
|
finally { setPpBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
// E-mail / SMTP (send QSO recordings).
|
// E-mail / SMTP (send QSO recordings).
|
||||||
type EmailCfg = {
|
type EmailCfg = {
|
||||||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||||||
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||||||
};
|
};
|
||||||
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||||||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||||||
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
|
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||||
|
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||||
|
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
|
||||||
|
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
|
||||||
// ClubLog Country File (cty.xml) exception status.
|
// ClubLog Country File (cty.xml) exception status.
|
||||||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||||||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||||||
@@ -511,6 +707,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||||||
const [dbMsg, setDbMsg] = useState('');
|
const [dbMsg, setDbMsg] = useState('');
|
||||||
|
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string };
|
||||||
|
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
||||||
|
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||||||
|
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||||
|
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
|
||||||
|
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
|
||||||
const [dataDir, setDataDir] = useState('');
|
const [dataDir, setDataDir] = useState('');
|
||||||
|
|
||||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||||
@@ -600,6 +802,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||||
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
|
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||||
try { setDataDir(await GetDataDir()); } catch {}
|
try { setDataDir(await GetDataDir()); } catch {}
|
||||||
try {
|
try {
|
||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
@@ -609,6 +813,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
|
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||||
reloadAudioDevices();
|
reloadAudioDevices();
|
||||||
reloadDvk();
|
reloadDvk();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -619,6 +824,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Every setting is per-profile, so when the active profile changes WHILE this
|
||||||
|
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
|
||||||
|
// — otherwise they keep showing the previous profile's values until reopen.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('profile:changed', () => {
|
||||||
|
(async () => {
|
||||||
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
|
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||||
|
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||||
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
|
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||||
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
|
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||||
|
try {
|
||||||
|
const ls: any = await GetListsSettings();
|
||||||
|
setLists(ls);
|
||||||
|
setRstText({
|
||||||
|
phone: (ls.rst_phone ?? []).join(' '),
|
||||||
|
cw: (ls.rst_cw ?? []).join(' '),
|
||||||
|
digital: (ls.rst_digital ?? []).join(' '),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
return () => { off(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||||||
// are derived values, so they always recompute when the callsign or grid
|
// are derived values, so they always recompute when the callsign or grid
|
||||||
@@ -768,6 +1007,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
|
await QSLSaveEmailTemplates(eqslCfg as any);
|
||||||
await SaveBackupSettings(backupCfg as any);
|
await SaveBackupSettings(backupCfg as any);
|
||||||
await SaveQSLDefaults(qslDefaults as any);
|
await SaveQSLDefaults(qslDefaults as any);
|
||||||
await SaveExternalServices(extSvc as any);
|
await SaveExternalServices(extSvc as any);
|
||||||
@@ -842,6 +1082,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
||||||
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Operator name</Label>
|
||||||
|
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
|
||||||
|
<div className="text-[10px] text-muted-foreground">Your first name — used as the signature on QSL cards.</div>
|
||||||
|
</div>
|
||||||
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
||||||
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
|
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
|
||||||
</div>
|
</div>
|
||||||
@@ -1427,10 +1672,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CAT interface (OmniRig)"
|
title="CAT interface"
|
||||||
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
|
hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-lg">
|
<div className="space-y-4 max-w-3xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable CAT
|
Enable CAT
|
||||||
@@ -1442,11 +1687,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
|
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||||
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
|
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>OmniRig rig slot</Label>
|
<Label>OmniRig rig slot</Label>
|
||||||
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||||||
@@ -1457,6 +1703,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'flex' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>FlexRadio IP</Label>
|
||||||
|
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input type="number" value={catCfg.flex_port || 4992}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||||
|
</div>
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||||
|
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Poll interval (ms)</Label>
|
<Label>Poll interval (ms)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1473,6 +1743,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Default digital mode (when rig reports DIG)</Label>
|
<Label>Default digital mode (when rig reports DIG)</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -1488,6 +1760,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={catModeBeforeFreq}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
|
||||||
|
/>
|
||||||
|
Set mode before frequency <span className="text-xs text-muted-foreground">(older rigs that drop the mode after a band change)</span>
|
||||||
|
</label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
|
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
|
||||||
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
|
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
|
||||||
@@ -1495,6 +1776,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||||
{' '}is the specific mode OpsLog will surface (and log).
|
{' '}is the specific mode OpsLog will surface (and log).
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'flex' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in
|
||||||
|
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
|
||||||
|
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
|
||||||
|
logs when the slice is in a digital mode (DIGU/DIGL).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1605,7 +1896,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator (PstRotator)"
|
title="Rotator"
|
||||||
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
@@ -1678,7 +1969,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CW Keyer (WinKeyer)"
|
title="CW Keyer"
|
||||||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
@@ -1794,7 +2085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||||
<p className="text-[11px] text-muted-foreground mb-2">
|
<p className="text-[11px] text-muted-foreground mb-2">
|
||||||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono"><LOGQSO></span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU <LOGQSO></span>).
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||||
{Array.from({ length: 12 }).map((_, i) => {
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
@@ -2135,7 +2426,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database backup"
|
title="Database backup"
|
||||||
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
hint={mysqlCfg.enabled
|
||||||
|
? "On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each."
|
||||||
|
: "OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2647,8 +2940,68 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database"
|
title="Database"
|
||||||
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
|
||||||
/>
|
/>
|
||||||
|
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
|
||||||
|
target its own database; choosing here and Save switches the live
|
||||||
|
logbook immediately (no restart). */}
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||||
|
<Label className="text-sm">Backend</Label>
|
||||||
|
<Select
|
||||||
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
|
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
|
||||||
|
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases — switching profile switches the logbook.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Save (always visible) applies the active profile's DB target live. */}
|
||||||
|
<div className="max-w-2xl mb-4 flex items-center gap-3">
|
||||||
|
<Button size="sm" className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
SaveMySQLSettings(mysqlCfg as any)
|
||||||
|
.then(() => setRestartMsg(mysqlCfg.enabled
|
||||||
|
? 'Logbook switched to MySQL ✓'
|
||||||
|
: 'Logbook switched to local SQLite ✓'))
|
||||||
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
|
}}>
|
||||||
|
Save & switch logbook
|
||||||
|
</Button>
|
||||||
|
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
|
||||||
|
{backendStatus && (
|
||||||
|
<div className="max-w-2xl mb-4">
|
||||||
|
{backendStatus.fallback ? (
|
||||||
|
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
|
||||||
|
MySQL is enabled but the connection failed at startup — OpsLog is running on the local <strong>SQLite</strong> database.
|
||||||
|
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
||||||
|
</div>
|
||||||
|
) : backendStatus.active === 'mysql' ? (
|
||||||
|
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
||||||
|
<strong>Logbook:</strong> shared MySQL <span className="text-emerald-700">connected ✓</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground">·</span>
|
||||||
|
<strong>Config:</strong> local SQLite
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
||||||
|
Active backend: <strong className="uppercase">{backendStatus.active}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mysqlCfg.enabled && (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Current database</Label>
|
<Label>Current database</Label>
|
||||||
@@ -2668,13 +3021,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
|
||||||
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
|
||||||
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
|
||||||
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
|
||||||
Any database change takes effect on the next launch.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dbMsg && (
|
{dbMsg && (
|
||||||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
||||||
<span>{dbMsg}</span>
|
<span>{dbMsg}</span>
|
||||||
@@ -2682,14 +3028,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared MySQL database (multi-operator) */}
|
||||||
|
{mysqlCfg.enabled && (
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
Several OpsLog instances pointed at one MySQL database see each other's QSOs live (refreshed every 2 s). <strong>Test & create</strong> the database, then <strong>Save & switch logbook</strong> above to start logging there.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">Host</Label>
|
||||||
|
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||||
|
<Label className="text-sm">Port</Label>
|
||||||
|
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
|
||||||
|
<Label className="text-sm">Database</Label>
|
||||||
|
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
|
||||||
|
<Label className="text-sm">User</Label>
|
||||||
|
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
|
onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
|
||||||
|
Test & create database
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Data location */}
|
{/* Data location */}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">Data location</div>
|
<div className="text-sm font-medium">Data location</div>
|
||||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
|
||||||
OpsLog is fully portable — all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Current data directory</Label>
|
<Label>Current data directory</Label>
|
||||||
@@ -2896,63 +3268,65 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
function GeneralPanel() {
|
function GeneralPanel() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
|
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
||||||
<div className="space-y-4 max-w-lg">
|
<div className="space-y-3 max-w-3xl">
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||||
checked={autofocusWB}
|
Auto-focus "Worked before" for known stations
|
||||||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Auto-focus "Worked before" for stations already worked
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
|
||||||
|
Show the antenna beam heading on the Main map
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||||
|
QSO start time = end time <span className="text-xs text-muted-foreground">(matches LoTW when you call a while)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
|
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
|
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||||
|
</label>
|
||||||
|
<TelemetryToggle />
|
||||||
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<Checkbox
|
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||||
checked={showBeamMap}
|
{secret.has_passphrase ? (
|
||||||
onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }}
|
<>
|
||||||
className="mt-0.5"
|
<p className="text-xs text-muted-foreground">
|
||||||
/>
|
Passwords encrypted{' '}
|
||||||
<span>
|
{secret.unlocked
|
||||||
Show the antenna beam heading on the Main map
|
? <span className="text-emerald-700 font-medium">— unlocked</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
: <span className="text-amber-600 font-medium">— locked (unlock at launch)</span>}.
|
||||||
Draws the beam lobe at the rotor heading (and the opposite/both directions when an Ultrabeam is reversed or bidirectional). Turn off to hide it.
|
</p>
|
||||||
</span>
|
{secret.unlocked && (
|
||||||
</span>
|
<>
|
||||||
</label>
|
<div className="flex items-center gap-2 max-w-md">
|
||||||
|
<Input type="password" placeholder="New passphrase (to change)" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Checkbox
|
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>Change</Button>
|
||||||
checked={startEqEnd}
|
</div>
|
||||||
onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }}
|
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>Remove encryption (store passwords in clear)</Button>
|
||||||
className="mt-0.5"
|
</>
|
||||||
/>
|
)}
|
||||||
<span>
|
</>
|
||||||
QSO start time = end time (log at completion)
|
) : (
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
<>
|
||||||
Sets TIME_ON equal to TIME_OFF — the moment you log the QSO — instead of when you first entered the call. Useful when you call a station for a while: the logged time then matches the other operator's, so LoTW/eQSL confirmations match.
|
<p className="text-xs text-muted-foreground">
|
||||||
</span>
|
Encrypt saved passwords with a passphrase (asked at launch). Stays portable — re-enter it on another PC.
|
||||||
</span>
|
</p>
|
||||||
</label>
|
<div className="flex items-center gap-2 max-w-md">
|
||||||
|
<Input type="password" placeholder="Passphrase" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Checkbox
|
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>Encrypt</Button>
|
||||||
checked={catModeBeforeFreq}
|
</div>
|
||||||
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
|
</>
|
||||||
className="mt-0.5"
|
)}
|
||||||
/>
|
{ppErr && <div className="text-xs text-destructive">{ppErr}</div>}
|
||||||
<span>
|
</div>
|
||||||
Set CAT mode before frequency (older rigs)
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
When clicking a spot, send the mode to the rig first, then the frequency. Some older transceivers drop the mode command if it arrives right after a band change, needing a second click. Both commands are also spaced out slightly to let the rig settle.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||||
@@ -3037,6 +3411,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||||
<Label className="text-sm">From address</Label>
|
<Label className="text-sm">From address</Label>
|
||||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||||
|
<Label className="text-sm">Reply-To address</Label>
|
||||||
|
<div>
|
||||||
|
<Input className="h-8" placeholder="(optional — where replies go)" value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" className="h-8"
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
@@ -3045,6 +3424,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">OpsLog QSL card e-mail</Label>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||||||
|
</div>
|
||||||
|
<Input className="h-8" placeholder="Subject" value={eqslCfg.subject}
|
||||||
|
onChange={(e) => setEqslField({ subject: e.target.value })} />
|
||||||
|
<Textarea rows={3} className="text-sm" placeholder="Body" value={eqslCfg.body}
|
||||||
|
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
||||||
|
Auto-send OpsLog QSL when a QSO is logged
|
||||||
|
</label>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -3066,6 +3463,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
|
autostart: () => <AutostartPanelComponent />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface Props {
|
|||||||
onToggleSendOnType: (on: boolean) => void;
|
onToggleSendOnType: (on: boolean) => void;
|
||||||
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||||
onBackspace: () => void; // remove last not-yet-keyed char
|
onBackspace: () => void; // remove last not-yet-keyed char
|
||||||
|
autoCall: boolean; // repeat the clicked macro on a timer
|
||||||
|
autoCallSecs: number; // gap (s) after the message before repeating
|
||||||
|
onToggleAutoCall: (on: boolean) => void;
|
||||||
|
onSetAutoCallSecs: (n: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||||
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
|
|||||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
onSend, onSendMacro, onStop, onClose,
|
onSend, onSendMacro, onStop, onClose,
|
||||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
|
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [cwText, setCwText] = useState('');
|
const [cwText, setCwText] = useState('');
|
||||||
const [speed, setSpeed] = useState(wpm);
|
const [speed, setSpeed] = useState(wpm);
|
||||||
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
|
||||||
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||||
|
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
|
Auto-call
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||||
|
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||||
|
<input type="number" min={0} max={120}
|
||||||
|
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||||
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
|
</div>
|
||||||
|
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{macros.map((m, i) => (
|
{macros.map((m, i) => (
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type Props = {
|
|||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||||
@@ -64,7 +65,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -238,6 +239,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
|
onSendEQSL={onSendEQSL}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
|
||||||
|
// log (auto-send on, recipient e-mail known, default template, not already
|
||||||
|
// sent) and emits "qsl:autosend"; this component does the part Go can't —
|
||||||
|
// render the card off-screen, rasterize it to JPEG and hand it back to
|
||||||
|
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsOn } from '../../../wailsjs/runtime/runtime';
|
||||||
|
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
|
||||||
|
import type { RenderModel } from './qslTypes';
|
||||||
|
import { loadCardAssets, type CardAssets } from './qslAssets';
|
||||||
|
import { CardPreview } from './CardPreview';
|
||||||
|
import { rasterizeCard } from './rasterize';
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
qsoId: number;
|
||||||
|
templateId: number;
|
||||||
|
callsign: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSent?: (callsign: string) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoEQSL({ onSent, onError }: Props) {
|
||||||
|
const queue = useRef<Job[]>([]);
|
||||||
|
const busy = useRef(false);
|
||||||
|
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||||
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
|
||||||
|
|
||||||
|
// Keep the callbacks in refs so they never change the effects' identity — a
|
||||||
|
// toast/grid re-render from onSent must NOT re-run the send effect (that
|
||||||
|
// re-sent the same eQSL many times in a row).
|
||||||
|
const onSentRef = useRef(onSent);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
|
||||||
|
|
||||||
|
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||||
|
// effect below rasterizes once the DOM has it).
|
||||||
|
const pump = useCallback(async () => {
|
||||||
|
if (busy.current) return;
|
||||||
|
const job = queue.current.shift();
|
||||||
|
if (!job) return;
|
||||||
|
busy.current = true;
|
||||||
|
try {
|
||||||
|
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
|
||||||
|
const assets = await loadCardAssets(model.template, job.templateId);
|
||||||
|
setCurrent({ job, model, assets });
|
||||||
|
} catch (e) {
|
||||||
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
|
busy.current = false;
|
||||||
|
void pump();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
||||||
|
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
|
||||||
|
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
|
||||||
|
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||||
|
void pump();
|
||||||
|
});
|
||||||
|
return () => off();
|
||||||
|
}, [pump]);
|
||||||
|
|
||||||
|
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
||||||
|
// Sends exactly once per job (guarded by sentForRef), independent of renders.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return;
|
||||||
|
if (sentForRef.current === current.job.qsoId) return; // already sent this one
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||||
|
if (cancelled || !svgEl.current) return;
|
||||||
|
sentForRef.current = current.job.qsoId;
|
||||||
|
const card = current.model.template.card;
|
||||||
|
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||||
|
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||||
|
onSentRef.current?.(current.job.callsign);
|
||||||
|
} catch (e) {
|
||||||
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCurrent(null);
|
||||||
|
busy.current = false;
|
||||||
|
void pump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [current, pump]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
// Off-screen at full card resolution so the rasterized output matches the
|
||||||
|
// editor preview exactly.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<CardPreview
|
||||||
|
model={current.model}
|
||||||
|
assets={current.assets}
|
||||||
|
width={current.model.template.card.w}
|
||||||
|
svgRef={(el) => { svgEl.current = el; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
// CardPreview — the single SVG rendering implementation for the QSL card.
|
||||||
|
// Proposals, the live editor, thumbnails and final rasterization ALL go
|
||||||
|
// through this component so the output can never drift from the preview.
|
||||||
|
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
|
||||||
|
// section 4 of the design spec); the gel stack renders the text 7 times,
|
||||||
|
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
|
||||||
|
// gradient face, wavy gloss band clipped to the glyphs.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
|
||||||
|
import { QSO_FIELD_LABELS } from './qslTypes';
|
||||||
|
import type { CardAssets } from './qslAssets';
|
||||||
|
import { FONT_WEIGHTS } from './qslAssets';
|
||||||
|
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';
|
||||||
|
|
||||||
|
// An editor selection: an element index or the QSO box.
|
||||||
|
export type CardSelection = number | 'box' | null;
|
||||||
|
|
||||||
|
// Fraction of the font size between a glyph's em-box top and its cap line.
|
||||||
|
// Used to make a text element's y align to the visible letter tops.
|
||||||
|
const CAP_INSET = 0.18;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
model: RenderModel;
|
||||||
|
assets: CardAssets;
|
||||||
|
width: number; // display width in CSS px
|
||||||
|
selected?: CardSelection;
|
||||||
|
onSelect?: (sel: CardSelection) => void;
|
||||||
|
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
|
||||||
|
svgRef?: (el: SVGSVGElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks mirroring the preset defaults in presets.go, applied when a
|
||||||
|
// document omits a knob the stack needs.
|
||||||
|
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
|
||||||
|
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
|
||||||
|
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
|
||||||
|
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
|
||||||
|
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
|
||||||
|
|
||||||
|
// fontSpec maps a template font name to a concrete family + weight.
|
||||||
|
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
|
||||||
|
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
|
||||||
|
if (!font || font === 'system-bold-sans') {
|
||||||
|
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
|
||||||
|
}
|
||||||
|
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets whose call text is rendered with the ported canvas FX (the only way
|
||||||
|
// to get the exact glossy/western look). Everything else stays plain SVG text.
|
||||||
|
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
|
||||||
|
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
|
||||||
|
function fxKindFor(preset?: string): TextFxKind | null {
|
||||||
|
if (preset && FX_GLOSSY.has(preset)) return 'glossy';
|
||||||
|
if (preset && FX_WESTERN.has(preset)) return 'western';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function buildFxParams(e: CardElement): TextFxParams | null {
|
||||||
|
const kind = fxKindFor(e.style_preset);
|
||||||
|
if (!kind || !e.size) return null;
|
||||||
|
const fs = fontSpec(e.font);
|
||||||
|
const sp = e.style_params ?? {};
|
||||||
|
const fx = sp.fx ?? {};
|
||||||
|
const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
|
||||||
|
const silver = e.style_preset?.includes('silver');
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
text: e.text ?? '',
|
||||||
|
weight: String(fs.weight === 'normal' ? 400 : fs.weight),
|
||||||
|
family: fs.family,
|
||||||
|
size: e.size,
|
||||||
|
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
||||||
|
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
||||||
|
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
||||||
|
// default near-black edge (the navy outline adaptStyle sets is for the old
|
||||||
|
// SVG stack, not this look).
|
||||||
|
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
||||||
|
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
||||||
|
// Per-call overrides from the editor (undefined → renderer default).
|
||||||
|
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
||||||
|
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
||||||
|
depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
|
||||||
|
bevel: fx.bevel, seed: fx.seed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
|
||||||
|
// doesn't trigger an expensive re-render of the canvas text.
|
||||||
|
function fxSignature(e: CardElement): string {
|
||||||
|
if (!fxKindFor(e.style_preset)) return '';
|
||||||
|
return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementTransform(e: CardElement): string {
|
||||||
|
const r = e.rotate ? ` rotate(${e.rotate})` : '';
|
||||||
|
return `translate(${e.x} ${e.y})${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared <text> layer of a style stack. All layers must carry identical
|
||||||
|
// glyph geometry — only paint/transform/filter differ.
|
||||||
|
function Layer({
|
||||||
|
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
|
||||||
|
}: {
|
||||||
|
e: CardElement;
|
||||||
|
content: string;
|
||||||
|
fill: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
transform?: string;
|
||||||
|
filter?: string;
|
||||||
|
opacity?: number;
|
||||||
|
paintOrder?: 'stroke';
|
||||||
|
}) {
|
||||||
|
const f = fontSpec(e.font);
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
fontFamily={f.family}
|
||||||
|
fontWeight={f.weight}
|
||||||
|
fontSize={e.size}
|
||||||
|
dominantBaseline="text-before-edge"
|
||||||
|
fill={fill}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
paintOrder={paintOrder}
|
||||||
|
transform={transform}
|
||||||
|
filter={filter}
|
||||||
|
opacity={opacity}
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// glossWavePath builds the gel highlight band: full glyph width, bottom
|
||||||
|
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
|
||||||
|
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
|
||||||
|
// is what produces the "gel" look; the band is clipped to the glyphs.
|
||||||
|
function glossWavePath(e: CardElement, content: string, coverage: number): string {
|
||||||
|
const size = e.size ?? 100;
|
||||||
|
const w = 0.78 * size * Math.max(content.length, 1);
|
||||||
|
const capTop = 0.16 * size; // cap height starts below the em-box top
|
||||||
|
const bottom = capTop + coverage * 0.72 * size;
|
||||||
|
const amp = 0.045 * size;
|
||||||
|
const period = 0.25 * size;
|
||||||
|
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
|
||||||
|
let i = 0;
|
||||||
|
for (let x = w; x > 0.1; x -= period, i++) {
|
||||||
|
const x2 = Math.max(0, x - period);
|
||||||
|
const mid = (x + x2) / 2;
|
||||||
|
const dy = i % 2 === 0 ? amp : -amp;
|
||||||
|
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
return d + ' Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextStack renders one text element through its style preset.
|
||||||
|
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
|
||||||
|
const p: StyleParams = e.style_params ?? {};
|
||||||
|
const preset = e.style_preset ?? 'flat_modern';
|
||||||
|
const ow = p.outline_width ?? 0;
|
||||||
|
|
||||||
|
if (preset === 'flat_modern') {
|
||||||
|
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
|
||||||
|
}
|
||||||
|
if (preset === 'script_white' || preset === 'outlined_white') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{p.shadow && (
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
|
||||||
|
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
|
||||||
|
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill={p.color ?? '#ffffff'}
|
||||||
|
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gel family + classic white outline: the layered stack.
|
||||||
|
const gradient = p.gradient ?? GOLD;
|
||||||
|
const shadow = p.shadow ?? DEF_SHADOW;
|
||||||
|
const outline = p.outline_color ?? '#2a3f5c';
|
||||||
|
const outlineW = ow || 9;
|
||||||
|
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
|
||||||
|
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
|
||||||
|
const halo = p.halo ?? DEF_HALO;
|
||||||
|
const bevel = p.bevel_offset ?? DEF_BEVEL;
|
||||||
|
const shine = p.shine ?? DEF_SHINE;
|
||||||
|
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isGel && (
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill="none" stroke={halo.color}
|
||||||
|
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
|
||||||
|
transform={`translate(${shadow.dx} ${shadow.dy})`}
|
||||||
|
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
|
||||||
|
/>
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill={outline} stroke={outline}
|
||||||
|
strokeWidth={outlineW} paintOrder="stroke"
|
||||||
|
/>
|
||||||
|
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
|
||||||
|
{isGel && (
|
||||||
|
<Layer
|
||||||
|
e={e} content={content} fill={bevel.light}
|
||||||
|
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
|
||||||
|
{isGel && (
|
||||||
|
<path
|
||||||
|
d={glossWavePath(e, content, shine.coverage)}
|
||||||
|
fill={`url(#qsl-gloss-${idx})`}
|
||||||
|
clipPath={`url(#qsl-clip-${idx})`}
|
||||||
|
transform={faceT}
|
||||||
|
opacity={shine.opacity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
|
||||||
|
{isGrunge && (
|
||||||
|
<rect
|
||||||
|
x={0} y={0}
|
||||||
|
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
|
||||||
|
height={(e.size ?? 0) * 1.05}
|
||||||
|
fill="#241405"
|
||||||
|
filter={`url(#qsl-grunge-${idx})`}
|
||||||
|
clipPath={`url(#qsl-clip-${idx})`}
|
||||||
|
transform={faceT}
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* defs that belong to this stack (unique ids per element index) */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
{gradient.map((c, i) => (
|
||||||
|
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
|
||||||
|
))}
|
||||||
|
</linearGradient>
|
||||||
|
{isGel && (
|
||||||
|
<>
|
||||||
|
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
|
||||||
|
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id={`qsl-clip-${idx}`}>
|
||||||
|
<text
|
||||||
|
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
|
||||||
|
fontSize={e.size} dominantBaseline="text-before-edge"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</text>
|
||||||
|
</clipPath>
|
||||||
|
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation={halo.blur} />
|
||||||
|
</filter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isGrunge && (
|
||||||
|
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
|
||||||
|
{/* sparse dark specks: fractal noise → threshold into the alpha */}
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
|
||||||
|
<feColorMatrix in="n" type="matrix"
|
||||||
|
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
|
||||||
|
</filter>
|
||||||
|
)}
|
||||||
|
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation={shadow.blur} />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSO box field column weights — the date string is far wider than the rest,
|
||||||
|
// so equal columns let it spill into the next field. Wider weight = more room.
|
||||||
|
const QSO_FIELD_WEIGHT: Record<string, number> = {
|
||||||
|
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
|
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||||
|
const avail = box.w - 56;
|
||||||
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||||
|
let cursor = 28;
|
||||||
|
const cols = box.fields.map((f) => {
|
||||||
|
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
|
||||||
|
const col = { f, x: cursor, w };
|
||||||
|
cursor += w;
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||||
|
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
||||||
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
|
{box.title}
|
||||||
|
</text>
|
||||||
|
{cols.map(({ f, x }) => (
|
||||||
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||||
|
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
||||||
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
|
</text>
|
||||||
|
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
||||||
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
|
{values[f] ?? ''}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{box.footer && (
|
||||||
|
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
||||||
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||||
|
{box.footer}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
|
||||||
|
const { template: t, qso_fields } = model;
|
||||||
|
const card = t.card;
|
||||||
|
const scale = card.w / width;
|
||||||
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const groupRefs = useRef(new Map<string, SVGGElement>());
|
||||||
|
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
|
||||||
|
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
|
||||||
|
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
// Embedded fonts may still be loading when the card first mounts; the canvas
|
||||||
|
// FX needs them for correct metrics, so (re)render once they're ready.
|
||||||
|
const [fontsReady, setFontsReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
|
||||||
|
void ready.then(() => { if (alive) setFontsReady(true); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
// Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
|
||||||
|
// so dragging doesn't regenerate it. Maps element index → PNG + size.
|
||||||
|
const fxSig = t.elements.map(fxSignature).join(';');
|
||||||
|
const fxImages = useMemo(() => {
|
||||||
|
const map = new Map<number, TextFxResult>();
|
||||||
|
if (!fontsReady) return map;
|
||||||
|
t.elements.forEach((e, idx) => {
|
||||||
|
const params = buildFxParams(e);
|
||||||
|
if (!params) return;
|
||||||
|
try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fxSig, fontsReady]);
|
||||||
|
|
||||||
|
// Measure the selected group for the dashed selection outline. For plain SVG
|
||||||
|
// text we use the glyphs' real ink metrics — getBBox would span the font's
|
||||||
|
// full ascent/descent. FX call images fall through to getBBox (already tight).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected === null || selected === undefined) { setSelBox(null); return; }
|
||||||
|
const key = String(selected);
|
||||||
|
const g = groupRefs.current.get(key);
|
||||||
|
if (!g) { setSelBox(null); return; }
|
||||||
|
const transform = g.getAttribute('transform') ?? '';
|
||||||
|
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
|
||||||
|
const isFx = el ? !!fxKindFor(el.style_preset) : false;
|
||||||
|
if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
|
||||||
|
const cv = (measureCanvas.current ??= document.createElement('canvas'));
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
const f = fontSpec(el.font);
|
||||||
|
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
|
||||||
|
const m = ctx.measureText(el.text ?? '');
|
||||||
|
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
|
||||||
|
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
|
||||||
|
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
|
||||||
|
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
|
||||||
|
const left = -(m.actualBoundingBoxLeft ?? 0);
|
||||||
|
const right = m.actualBoundingBoxRight ?? m.width;
|
||||||
|
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const b = g.getBBox();
|
||||||
|
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
|
||||||
|
}, [selected, model, assets, width, t.elements, fxImages]);
|
||||||
|
|
||||||
|
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
|
||||||
|
(ev: React.PointerEvent<SVGGElement>) => {
|
||||||
|
onSelect?.(sel);
|
||||||
|
if (!onMove) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
|
||||||
|
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
const d = drag.current;
|
||||||
|
if (!d || !onMove) return;
|
||||||
|
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
|
||||||
|
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
|
||||||
|
onMove(d.sel, x, y);
|
||||||
|
};
|
||||||
|
const endDrag = () => { drag.current = null; };
|
||||||
|
|
||||||
|
const hero = assets.photos[t.hero.photo];
|
||||||
|
const crop = t.hero.crop;
|
||||||
|
|
||||||
|
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
|
||||||
|
if (el) groupRefs.current.set(key, el);
|
||||||
|
else groupRefs.current.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
|
||||||
|
viewBox={`0 0 ${card.w} ${card.h}`}
|
||||||
|
width={width}
|
||||||
|
height={Math.round(width * (card.h / card.w))}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ display: 'block', borderRadius: 4 }}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerLeave={endDrag}
|
||||||
|
onPointerDown={() => onSelect?.(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
|
||||||
|
canvas) sees them; document styles don't apply there. */}
|
||||||
|
<style>{assets.fontCss}</style>
|
||||||
|
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* hero photo, source crop mapped onto the full card */}
|
||||||
|
<rect width={card.w} height={card.h} fill="#1a2433" />
|
||||||
|
{hero && crop.w > 0 && crop.h > 0 && (
|
||||||
|
<image
|
||||||
|
href={hero.url}
|
||||||
|
x={(-crop.x * card.w) / crop.w}
|
||||||
|
y={(-crop.y * card.h) / crop.h}
|
||||||
|
width={(hero.w * card.w) / crop.w}
|
||||||
|
height={(hero.h * card.h) / crop.h}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{t.hero.scrim?.enabled && (
|
||||||
|
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t.elements.map((e, idx) => {
|
||||||
|
const key = String(idx);
|
||||||
|
if (e.hidden) return null; // toggled off in the editor
|
||||||
|
if (e.type === 'insert') {
|
||||||
|
const ph = assets.photos[e.photo ?? ''];
|
||||||
|
if (!ph || !e.w) return null;
|
||||||
|
const h = (e.w * ph.h) / ph.w;
|
||||||
|
const b = e.border_px ?? 0;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={key} ref={setGroupRef(key)}
|
||||||
|
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
|
||||||
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||||
|
style={{ cursor: onMove ? 'move' : undefined }}
|
||||||
|
>
|
||||||
|
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
|
||||||
|
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
|
||||||
|
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e.type === 'country') {
|
||||||
|
const size = e.size ?? 30;
|
||||||
|
const flagH = size * 1.4;
|
||||||
|
const flagW = flagH * (4 / 3);
|
||||||
|
const hasFlag = !!assets.flagUrl;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||||
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||||
|
style={{ cursor: onMove ? 'move' : undefined }}
|
||||||
|
>
|
||||||
|
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
|
||||||
|
<text
|
||||||
|
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
|
||||||
|
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
|
||||||
|
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
|
||||||
|
strokeLinejoin="round" dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{e.label === 'auto' ? '' : e.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Text elements. Glossy/western calls render through the ported canvas
|
||||||
|
// FX as an <image> (its trimmed bounds already give a tight frame, so
|
||||||
|
// no CAP_INSET shim). Everything else is plain SVG text; the inner
|
||||||
|
// translate pulls the glyphs up to the em-box cap line so the element's
|
||||||
|
// y means "top of the visible letters".
|
||||||
|
const fx = fxImages.get(idx);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||||
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||||
|
style={{ cursor: onMove ? 'move' : undefined }}
|
||||||
|
>
|
||||||
|
{fx ? (
|
||||||
|
<image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
|
||||||
|
) : (
|
||||||
|
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
|
||||||
|
<TextStack e={e} idx={idx} content={e.text ?? ''} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{t.qso_box?.enabled && (
|
||||||
|
<g
|
||||||
|
ref={setGroupRef('box')}
|
||||||
|
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
|
||||||
|
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
|
||||||
|
style={{ cursor: onMove ? 'move' : undefined }}
|
||||||
|
>
|
||||||
|
<QSOBoxView box={t.qso_box} values={qso_fields} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* selection outline (editor only) */}
|
||||||
|
{selBox && onSelect && (
|
||||||
|
<g transform={selBox.t} pointerEvents="none">
|
||||||
|
<rect
|
||||||
|
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
|
||||||
|
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
|
||||||
|
strokeDasharray={`${10 * scale} ${6 * scale}`}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// EditorPanel — the designer's right-hand controls: card-level options
|
||||||
|
// (scrim, name, default) and per-selection editing (font, size, rotation,
|
||||||
|
// style preset + params; insert width/border; QSO box geometry).
|
||||||
|
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
||||||
|
import type { CardSelection } from './CardPreview';
|
||||||
|
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
template: CardTemplate;
|
||||||
|
sel: CardSelection;
|
||||||
|
presets: QSLPresetInfo[];
|
||||||
|
fontFamilies: string[]; // embedded + detected system faces
|
||||||
|
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
|
||||||
|
onPatchBox: (patch: Partial<QSOBox>) => void;
|
||||||
|
onScrim: (enabled: boolean) => void;
|
||||||
|
onSelect?: (sel: CardSelection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
callsign: 'Callsign',
|
||||||
|
operator: 'Operator name',
|
||||||
|
info_line: 'Info line',
|
||||||
|
country: 'Country + flag',
|
||||||
|
insert: 'Photo insert',
|
||||||
|
};
|
||||||
|
|
||||||
|
// A readable layer name, disambiguating the two info lines and numbering inserts.
|
||||||
|
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
|
||||||
|
if (el.type === 'info_line') {
|
||||||
|
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
|
||||||
|
}
|
||||||
|
if (el.type === 'insert') {
|
||||||
|
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
|
||||||
|
return `Photo insert ${n}`;
|
||||||
|
}
|
||||||
|
return TYPE_LABELS[el.type] ?? el.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
|
||||||
|
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
|
||||||
|
onPatch: (idx: number, patch: Partial<CardElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
|
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
|
||||||
|
<SelectTrigger className="h-8 w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fontFamilies.map((f) => (
|
||||||
|
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
|
||||||
|
onChange={(size) => onPatch(idx, { size })} />
|
||||||
|
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
|
||||||
|
onChange={(rotate) => onPatch(idx, { rotate })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||||
|
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
|
||||||
|
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<StylePresetPicker
|
||||||
|
presets={presets}
|
||||||
|
preset={e.style_preset ?? 'flat_modern'}
|
||||||
|
params={e.style_params ?? {}}
|
||||||
|
onChange={(style_preset, style_params: StyleParams) =>
|
||||||
|
onPatch(idx, { style_preset, style_params })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
|
||||||
|
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
|
||||||
|
const box = template.qso_box;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
|
||||||
|
<div className="space-y-2 rounded-md border border-border p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!template.hero.scrim?.enabled}
|
||||||
|
onCheckedChange={(v) => onScrim(v === true)}
|
||||||
|
/>
|
||||||
|
Darken photo behind text (scrim)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layers: show/hide each piece, click a name to edit it. */}
|
||||||
|
<div className="space-y-1 rounded-md border border-border p-3">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
|
||||||
|
{template.elements.map((el, i) => (
|
||||||
|
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
|
||||||
|
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
|
||||||
|
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
|
||||||
|
{layerLabel(el, i, template.elements)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{box && (
|
||||||
|
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
|
||||||
|
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
|
||||||
|
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
|
||||||
|
QSO info box
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-border p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
|
||||||
|
</div>
|
||||||
|
{!e && sel !== 'box' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click an element on the card to edit it; drag to move it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
|
||||||
|
<TextControls e={e} idx={sel as number} presets={presets}
|
||||||
|
fontFamilies={fontFamilies} onPatch={onPatchElement} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{e && e.type === 'insert' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
|
||||||
|
onChange={(w) => onPatchElement(sel as number, { w })} />
|
||||||
|
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
|
||||||
|
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
|
||||||
|
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
|
||||||
|
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={!!e.shadow}
|
||||||
|
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
|
||||||
|
Drop shadow
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{e && e.type === 'country' && (
|
||||||
|
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
|
||||||
|
onChange={(size) => onPatchElement(sel as number, { size })} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sel === 'box' && box && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={box.enabled}
|
||||||
|
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
|
||||||
|
Show QSO box
|
||||||
|
</label>
|
||||||
|
<NumberField label="Width" value={box.w} min={300} max={1400}
|
||||||
|
onChange={(w) => onPatchBox({ w })} />
|
||||||
|
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||||
|
onChange={(h) => onPatchBox({ h })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
|
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||||
|
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
|
||||||
|
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders — they fill in
|
||||||
|
automatically on every card.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// QslDesignerModal — the QSL card designer. Flow: drop/choose photos →
|
||||||
|
// "Generate designs" (3 automatic proposals from the placement engine) →
|
||||||
|
// pick one → live editor (click/drag elements, style controls) → save.
|
||||||
|
// Saved templates are listed with thumbnails and can be edited, deleted or
|
||||||
|
// marked default (used by "Send eQSL by e-mail" on the QSO grid).
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Images, Loader2, Sparkles, Star, Trash2, Pencil, ChevronLeft } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
|
} from '../../../wailsjs/go/main/App';
|
||||||
|
import type {
|
||||||
|
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
|
||||||
|
} from './qslTypes';
|
||||||
|
import { loadCardAssets, loadFonts, type CardAssets } from './qslAssets';
|
||||||
|
import { CardPreview, type CardSelection } from './CardPreview';
|
||||||
|
import { EditorPanel } from './EditorPanel';
|
||||||
|
import { rasterizeCard } from './rasterize';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Proposal {
|
||||||
|
template: CardTemplate;
|
||||||
|
model: RenderModel;
|
||||||
|
assets: CardAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Editing {
|
||||||
|
templateId: number; // 0 = new (photos still at their original paths)
|
||||||
|
template: CardTemplate; // raw document ({placeholders})
|
||||||
|
model: RenderModel; // resolved copy for display
|
||||||
|
assets: CardAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = 'home' | 'proposals' | 'editor';
|
||||||
|
|
||||||
|
async function resolveModel(template: CardTemplate): Promise<RenderModel> {
|
||||||
|
return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QslDesignerModal({ open, onClose }: Props) {
|
||||||
|
const [view, setView] = useState<View>('home');
|
||||||
|
const [photoPaths, setPhotoPaths] = useState<string[]>([]);
|
||||||
|
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Editing | null>(null);
|
||||||
|
const [sel, setSel] = useState<CardSelection>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [asDefault, setAsDefault] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||||
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
|
const [deleteArm, setDeleteArm] = useState(0);
|
||||||
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setView('home');
|
||||||
|
setError('');
|
||||||
|
setPhotoPaths([]);
|
||||||
|
setProposals([]);
|
||||||
|
setEditing(null);
|
||||||
|
void refreshSaved();
|
||||||
|
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
|
||||||
|
void loadFonts().then(({ fonts }) =>
|
||||||
|
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function refreshSaved() {
|
||||||
|
try {
|
||||||
|
const list = ((await QSLListTemplates()) ?? []) as QSLTemplateInfo[];
|
||||||
|
setSaved(list);
|
||||||
|
const p: Record<number, string> = {};
|
||||||
|
await Promise.all(list.map(async (t) => {
|
||||||
|
const url = await QSLPreviewDataURL(t.id);
|
||||||
|
if (url) p[t.id] = url;
|
||||||
|
}));
|
||||||
|
setPreviews(p);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePhotos() {
|
||||||
|
try {
|
||||||
|
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||||
|
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const docs = (await QSLGenerateProposals(photoPaths)) as string[];
|
||||||
|
const out: Proposal[] = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
const template = JSON.parse(doc) as CardTemplate;
|
||||||
|
const model = await resolveModel(template);
|
||||||
|
const assets = await loadCardAssets(model.template, 0);
|
||||||
|
out.push({ template, model, assets });
|
||||||
|
}
|
||||||
|
setProposals(out);
|
||||||
|
setView('proposals');
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(templateId: number, template: CardTemplate, model: RenderModel, assets: CardAssets) {
|
||||||
|
setEditing({ templateId, template, model, assets });
|
||||||
|
setName(template.name);
|
||||||
|
setAsDefault(false);
|
||||||
|
setSel(null);
|
||||||
|
setError('');
|
||||||
|
setView('editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editSaved(info: QSLTemplateInfo) {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const template = JSON.parse(await QSLGetTemplate(info.id)) as CardTemplate;
|
||||||
|
const model = await resolveModel(template);
|
||||||
|
const assets = await loadCardAssets(model.template, info.id);
|
||||||
|
openEditor(info.id, template, model, assets);
|
||||||
|
setAsDefault(info.is_default);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edits patch the raw template AND the resolved display copy in lock-step;
|
||||||
|
// only a text change needs a backend re-resolve (placeholders may differ).
|
||||||
|
function patchBoth(fn: (t: CardTemplate) => void, reResolve = false) {
|
||||||
|
setEditing((cur) => {
|
||||||
|
if (!cur) return cur;
|
||||||
|
const template = structuredClone(cur.template);
|
||||||
|
const model = structuredClone(cur.model);
|
||||||
|
fn(template);
|
||||||
|
fn(model.template);
|
||||||
|
if (reResolve) {
|
||||||
|
void resolveModel(template).then((m) =>
|
||||||
|
setEditing((c) => (c ? { ...c, model: m } : c)));
|
||||||
|
}
|
||||||
|
return { ...cur, template, model };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchElement = (idx: number, patch: Partial<CardElement>) =>
|
||||||
|
patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch);
|
||||||
|
|
||||||
|
const patchBox = (patch: Partial<QSOBox>) =>
|
||||||
|
patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch);
|
||||||
|
|
||||||
|
const onMove = (target: Exclude<CardSelection, null>, x: number, y: number) =>
|
||||||
|
patchBoth((t) => {
|
||||||
|
if (target === 'box') {
|
||||||
|
if (t.qso_box) { t.qso_box.x = x; t.qso_box.y = y; }
|
||||||
|
} else {
|
||||||
|
t.elements[target].x = x;
|
||||||
|
t.elements[target].y = y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onScrim = (enabled: boolean) =>
|
||||||
|
patchBoth((t) => {
|
||||||
|
t.hero.scrim = { enabled, color: t.hero.scrim?.color ?? '#0b1a2e', opacity: t.hero.scrim?.opacity ?? 0.25 };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const id = await QSLSaveTemplate(editing.templateId, name, JSON.stringify(editing.template), true);
|
||||||
|
if (svgEl.current) {
|
||||||
|
const card = editing.template.card;
|
||||||
|
const png = await rasterizeCard(svgEl.current, 480, Math.round(480 * (card.h / card.w)), 'image/png');
|
||||||
|
await QSLSavePreview(id, png);
|
||||||
|
}
|
||||||
|
if (asDefault) await QSLSetDefaultTemplate(id);
|
||||||
|
await refreshSaved();
|
||||||
|
setView('home');
|
||||||
|
setEditing(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTemplate(id: number) {
|
||||||
|
if (deleteArm !== id) { setDeleteArm(id); return; }
|
||||||
|
setDeleteArm(0);
|
||||||
|
try {
|
||||||
|
await QSLDeleteTemplate(id);
|
||||||
|
await refreshSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeDefault(id: number) {
|
||||||
|
try {
|
||||||
|
await QSLSetDefaultTemplate(id);
|
||||||
|
await refreshSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-[1260px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-5 text-amber-500" />
|
||||||
|
QSL card designer
|
||||||
|
{view !== 'home' && (
|
||||||
|
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||||
|
onClick={() => setView(view === 'editor' && proposals.length && editing?.templateId === 0 ? 'proposals' : 'home')}>
|
||||||
|
<ChevronLeft className="size-3.5" /> Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'home' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">New design</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pick 1–5 photos — OpsLog analyzes them and proposes three card designs
|
||||||
|
with your callsign, name, zones and country placed automatically.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={choosePhotos}>
|
||||||
|
<Images className="mr-1 size-4" /> Choose photos…
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
|
||||||
|
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
|
||||||
|
Generate designs
|
||||||
|
</Button>
|
||||||
|
{photoPaths.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||||
|
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{saved.map((t) => (
|
||||||
|
<div key={t.id} className="rounded-md border border-border p-2">
|
||||||
|
{previews[t.id]
|
||||||
|
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
|
||||||
|
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||||
|
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 gap-0.5">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
|
||||||
|
onClick={() => editSaved(t)}>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{!t.is_default && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
|
||||||
|
onClick={() => makeDefault(t.id)}>
|
||||||
|
<Star className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
||||||
|
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||||
|
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The eQSL e-mail message and the auto-send option are in Settings → E-mail (SMTP).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'proposals' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{proposals.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
||||||
|
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||||
|
>
|
||||||
|
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'editor' && editing && (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="rounded-md bg-slate-800/60 p-3">
|
||||||
|
<CardPreview
|
||||||
|
model={editing.model}
|
||||||
|
assets={editing.assets}
|
||||||
|
width={760}
|
||||||
|
selected={sel}
|
||||||
|
onSelect={setSel}
|
||||||
|
onMove={onMove}
|
||||||
|
svgRef={(el) => { svgEl.current = el; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||||
|
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Vietnam sunset" />
|
||||||
|
<label className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
|
||||||
|
Default for this profile
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
|
||||||
|
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditorPanel
|
||||||
|
template={editing.template}
|
||||||
|
sel={sel}
|
||||||
|
presets={presets}
|
||||||
|
fontFamilies={fontFamilies}
|
||||||
|
onPatchElement={patchElement}
|
||||||
|
onPatchBox={patchBox}
|
||||||
|
onScrim={onScrim}
|
||||||
|
onSelect={setSel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// SendEQSLModal — per-QSO eQSL flow: render the default template with the
|
||||||
|
// QSO's data, show the exact card, send it as a JPEG e-mail attachment on
|
||||||
|
// explicit confirmation (never automatic). On success the backend stamps
|
||||||
|
// eqsl_sent and emits "qsl:sent" so the grid refreshes.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
QSLDefaultTemplateID, RenderEQSL, SendEQSL,
|
||||||
|
} from '../../../wailsjs/go/main/App';
|
||||||
|
import type { RenderModel } from './qslTypes';
|
||||||
|
import { loadCardAssets, type CardAssets } from './qslAssets';
|
||||||
|
import { CardPreview } from './CardPreview';
|
||||||
|
import { rasterizeCard } from './rasterize';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
qsoId: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenDesigner: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||||
|
const [model, setModel] = useState<RenderModel | null>(null);
|
||||||
|
const [assets, setAssets] = useState<CardAssets | null>(null);
|
||||||
|
const [templateId, setTemplateId] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !qsoId) return;
|
||||||
|
setModel(null);
|
||||||
|
setAssets(null);
|
||||||
|
setSent(false);
|
||||||
|
setError('');
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const tid = await QSLDefaultTemplateID();
|
||||||
|
setTemplateId(tid);
|
||||||
|
if (!tid) return; // no template yet — offer the designer below
|
||||||
|
const m = JSON.parse(await RenderEQSL(qsoId, tid)) as RenderModel;
|
||||||
|
setModel(m);
|
||||||
|
setAssets(await loadCardAssets(m.template, tid));
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [open, qsoId]);
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!qsoId || !model || !svgEl.current) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const card = model.template.card;
|
||||||
|
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||||
|
await SendEQSL(qsoId, templateId, jpeg);
|
||||||
|
setSent(true);
|
||||||
|
// Auto-close shortly after the success tick (the grid refreshes on the
|
||||||
|
// qsl:sent event); the user doesn't need to dismiss it manually.
|
||||||
|
window.setTimeout(onClose, 1100);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-[820px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 px-6 py-5">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && !templateId && !error && (
|
||||||
|
<div className="space-y-3 py-2 text-sm">
|
||||||
|
<p>No QSL template yet — design one first (drop a few photos, pick a layout).</p>
|
||||||
|
<Button size="sm" onClick={() => { onClose(); onOpenDesigner(); }}>Open the designer</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateId > 0 && !model && !error && (
|
||||||
|
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> Preparing the card…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{model && assets && (
|
||||||
|
<div className="rounded-md bg-slate-800/60 p-3">
|
||||||
|
<CardPreview model={model} assets={assets} width={740}
|
||||||
|
svgRef={(el) => { svgEl.current = el; }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{sent ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
|
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button size="sm" disabled={!model || busy} onClick={send}>
|
||||||
|
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Send
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// StylePresetPicker — preset choice + per-preset parameter controls for the
|
||||||
|
// selected text element. Only the knobs the preset declares (params) are
|
||||||
|
// shown, matching the backend whitelist so saving can't fail on a stray key.
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
presets: QSLPresetInfo[];
|
||||||
|
preset: string;
|
||||||
|
params: StyleParams;
|
||||||
|
onChange: (preset: string, params: StyleParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick colour palettes per FX family (mirrors the reference generators):
|
||||||
|
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
|
||||||
|
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
|
||||||
|
const GLOSSY_PALETTES: Palette[] = [
|
||||||
|
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
|
||||||
|
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
|
||||||
|
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
|
||||||
|
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
|
||||||
|
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
|
||||||
|
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
|
||||||
|
];
|
||||||
|
const WESTERN_PALETTES: Palette[] = [
|
||||||
|
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
|
||||||
|
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
|
||||||
|
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
|
||||||
|
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
|
||||||
|
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SliderRow({ label, value, min, max, step, onChange }: {
|
||||||
|
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
|
||||||
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
|
||||||
|
const info = presets.find((p) => p.name === preset);
|
||||||
|
const has = (k: string) => info?.params.includes(k) ?? false;
|
||||||
|
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch });
|
||||||
|
|
||||||
|
// gel_* presets are rendered by the canvas FX, not the SVG stack: show the FX
|
||||||
|
// knobs instead of the old shine/halo/shadow/outline rows.
|
||||||
|
const isFx = preset.startsWith('gel_');
|
||||||
|
const fxWestern = preset.includes('grunge');
|
||||||
|
const fx: FxParams = params.fx ?? {};
|
||||||
|
const setFx = (patch: Partial<FxParams>) => set({ fx: { ...fx, ...patch } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={preset}
|
||||||
|
onValueChange={(name) => {
|
||||||
|
const next = presets.find((p) => p.name === name);
|
||||||
|
// Switching presets resets the params to that preset's defaults —
|
||||||
|
// stale keys from the previous preset would be rejected on save.
|
||||||
|
onChange(name, next ? { ...next.defaults } : {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="Style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isFx && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Palette</Label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.name} type="button" title={p.name}
|
||||||
|
className="h-6 w-6 rounded border border-border"
|
||||||
|
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
|
||||||
|
onClick={() => onChange(preset, {
|
||||||
|
...params,
|
||||||
|
gradient: [p.top, p.mid, p.bot],
|
||||||
|
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{has('color') && (
|
||||||
|
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
||||||
|
)}
|
||||||
|
{has('gradient') && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Gradient</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
|
||||||
|
<input
|
||||||
|
key={i} type="color" value={c}
|
||||||
|
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
|
||||||
|
onChange={(e) => {
|
||||||
|
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
|
||||||
|
g[i] = e.target.value;
|
||||||
|
set({ gradient: g });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isFx && has('outline_color') && (
|
||||||
|
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
|
||||||
|
onChange={(v) => set({ outline_color: v })} />
|
||||||
|
)}
|
||||||
|
{!isFx && has('outline_width') && (
|
||||||
|
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
|
||||||
|
onChange={(v) => set({ outline_width: v })} />
|
||||||
|
)}
|
||||||
|
{!isFx && has('shine') && (
|
||||||
|
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
|
||||||
|
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
|
||||||
|
)}
|
||||||
|
{!isFx && has('halo') && (
|
||||||
|
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => set({
|
||||||
|
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
|
||||||
|
})} />
|
||||||
|
)}
|
||||||
|
{!isFx && has('shadow') && (
|
||||||
|
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => set({
|
||||||
|
shadow: {
|
||||||
|
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
|
||||||
|
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
|
||||||
|
},
|
||||||
|
})} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFx && !fxWestern && (
|
||||||
|
<>
|
||||||
|
<SliderRow label="Plump" value={fx.plump ?? 0.03} min={0} max={0.06} step={0.005}
|
||||||
|
onChange={(v) => setFx({ plump: v })} />
|
||||||
|
<SliderRow label="Gloss" value={fx.gloss ?? 0.85} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ gloss: v })} />
|
||||||
|
<SliderRow label="Gloss height" value={fx.gloss_h ?? 0.45} min={0.2} max={0.8} step={0.05}
|
||||||
|
onChange={(v) => setFx({ gloss_h: v })} />
|
||||||
|
<SliderRow label="Inner shadow" value={fx.inner_b ?? 0.7} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ inner_b: v })} />
|
||||||
|
<SliderRow label="Dark edge" value={fx.edge ?? 0.5} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ edge: v })} />
|
||||||
|
<SliderRow label="Silver rim" value={fx.outerw ?? 0.45} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ outerw: v })} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isFx && fxWestern && (
|
||||||
|
<>
|
||||||
|
<SliderRow label="3D depth" value={fx.depth ?? 12} min={0} max={40} step={1}
|
||||||
|
onChange={(v) => setFx({ depth: v })} />
|
||||||
|
<SliderRow label="Slant" value={fx.slant ?? 0.08} min={-0.4} max={0.4} step={0.02}
|
||||||
|
onChange={(v) => setFx({ slant: v })} />
|
||||||
|
<SliderRow label="Grunge" value={fx.grunge ?? 0.8} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ grunge: v })} />
|
||||||
|
<SliderRow label="Top light" value={fx.bevel ?? 0.45} min={0} max={1} step={0.05}
|
||||||
|
onChange={(v) => setFx({ bevel: v })} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny labelled numeric input shared by the designer's right panel.
|
||||||
|
export function NumberField({ label, value, onChange, min, max }: {
|
||||||
|
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<Input
|
||||||
|
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Asset loading for the QSL card renderer: designer fonts (registered as
|
||||||
|
// @font-face AND kept as CSS text so rasterization can inline them into the
|
||||||
|
// serialized SVG — an SVG drawn through an <img> cannot see document fonts),
|
||||||
|
// photos with their natural dimensions, and the country flag.
|
||||||
|
|
||||||
|
import { QSLFonts, QSLPhotoDataURL, QSLFlagDataURL } from '../../../wailsjs/go/main/App';
|
||||||
|
import type { CardTemplate, QSLFontInfo } from './qslTypes';
|
||||||
|
|
||||||
|
export interface PhotoAsset {
|
||||||
|
url: string; // data URL
|
||||||
|
w: number; // natural pixel size
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardAssets {
|
||||||
|
photos: Record<string, PhotoAsset>; // keyed by template photo ref
|
||||||
|
flagUrl: string; // data URL, '' when no flag
|
||||||
|
fontCss: string; // @font-face rules with data: sources
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight each embedded family renders at (variable fonts need an explicit
|
||||||
|
// heavy weight to match the printed-card look).
|
||||||
|
export const FONT_WEIGHTS: Record<string, number> = {
|
||||||
|
'Baloo 2': 800,
|
||||||
|
Oswald: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fontsPromise: Promise<{ css: string; fonts: QSLFontInfo[] }> | null = null;
|
||||||
|
|
||||||
|
// loadFonts fetches the embedded fonts once, registers them with the
|
||||||
|
// document and returns the @font-face CSS for SVG embedding.
|
||||||
|
export function loadFonts(): Promise<{ css: string; fonts: QSLFontInfo[] }> {
|
||||||
|
if (!fontsPromise) {
|
||||||
|
fontsPromise = (async () => {
|
||||||
|
const fonts = (await QSLFonts()) as QSLFontInfo[];
|
||||||
|
const rules = fonts.map((f) => {
|
||||||
|
const weight = f.variable ? '100 900' : 'normal';
|
||||||
|
return `@font-face{font-family:'${f.family}';src:url(data:font/ttf;base64,${f.data_b64}) format('truetype');font-weight:${weight};}`;
|
||||||
|
});
|
||||||
|
const css = rules.join('\n');
|
||||||
|
const el = document.createElement('style');
|
||||||
|
el.dataset.qslFonts = '1';
|
||||||
|
el.textContent = css;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
await document.fonts.ready;
|
||||||
|
return { css, fonts };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return fontsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageSize(url: string): Promise<{ w: number; h: number }> {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
await img.decode();
|
||||||
|
return { w: img.naturalWidth, h: img.naturalHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos are multi-MB base64 payloads; cache them so the three proposals
|
||||||
|
// (which share the same photo set) and editor re-renders fetch each once.
|
||||||
|
const photoCache = new Map<string, Promise<PhotoAsset>>();
|
||||||
|
|
||||||
|
function loadPhoto(templateId: number, ref: string): Promise<PhotoAsset> {
|
||||||
|
const key = `${templateId}:${ref}`;
|
||||||
|
let p = photoCache.get(key);
|
||||||
|
if (!p) {
|
||||||
|
p = (async () => {
|
||||||
|
const url = await QSLPhotoDataURL(templateId, ref);
|
||||||
|
const size = await imageSize(url);
|
||||||
|
return { url, ...size };
|
||||||
|
})();
|
||||||
|
photoCache.set(key, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCardAssets resolves everything a template needs to render: each
|
||||||
|
// referenced photo as a data URL with its natural size, the flag and the
|
||||||
|
// font CSS. templateId 0 reads draft photos at their original paths.
|
||||||
|
export async function loadCardAssets(template: CardTemplate, templateId: number): Promise<CardAssets> {
|
||||||
|
const refs = new Set<string>([template.hero.photo]);
|
||||||
|
for (const e of template.elements) {
|
||||||
|
if (e.type === 'insert' && e.photo) refs.add(e.photo);
|
||||||
|
}
|
||||||
|
const photos: Record<string, PhotoAsset> = {};
|
||||||
|
await Promise.all(
|
||||||
|
[...refs].map(async (ref) => {
|
||||||
|
photos[ref] = await loadPhoto(templateId, ref);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let flagUrl = '';
|
||||||
|
const country = template.elements.find((e) => e.type === 'country');
|
||||||
|
if (country?.flag && country.flag !== 'auto') {
|
||||||
|
flagUrl = await QSLFlagDataURL(country.flag);
|
||||||
|
}
|
||||||
|
const { css } = await loadFonts();
|
||||||
|
return { photos, flagUrl, fontCss: css };
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// TypeScript mirror of the QSL template JSON schema (v1) defined in
|
||||||
|
// internal/qslcard/template.go. Template documents cross the Wails boundary
|
||||||
|
// as JSON strings; these types are the frontend's contract with that schema.
|
||||||
|
|
||||||
|
export interface CardGeom {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
dpi: number;
|
||||||
|
bleed_px: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scrim {
|
||||||
|
enabled: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hero {
|
||||||
|
photo: string;
|
||||||
|
crop: CropRect;
|
||||||
|
scrim?: Scrim;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shine {
|
||||||
|
coverage: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HaloFx {
|
||||||
|
color: string;
|
||||||
|
blur: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShadowFx {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
blur: number;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BevelFx {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
dark: string;
|
||||||
|
light: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FxParams — per-call tuning of the canvas renderer (textFx.ts). All optional;
|
||||||
|
// omitted values fall back to the renderer's per-preset default.
|
||||||
|
export interface FxParams {
|
||||||
|
plump?: number;
|
||||||
|
edge?: number;
|
||||||
|
outerw?: number;
|
||||||
|
gloss?: number;
|
||||||
|
gloss_h?: number;
|
||||||
|
gloss_i?: number;
|
||||||
|
inner_b?: number;
|
||||||
|
depth?: number;
|
||||||
|
angle?: number;
|
||||||
|
slant?: number;
|
||||||
|
grunge?: number;
|
||||||
|
bevel?: number;
|
||||||
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleParams {
|
||||||
|
gradient?: string[];
|
||||||
|
shine?: Shine;
|
||||||
|
outline_color?: string;
|
||||||
|
outline_width?: number;
|
||||||
|
halo?: HaloFx;
|
||||||
|
shadow?: ShadowFx;
|
||||||
|
bevel_offset?: BevelFx;
|
||||||
|
color?: string;
|
||||||
|
fx?: FxParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
|
||||||
|
|
||||||
|
export interface CardElement {
|
||||||
|
type: ElementType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotate?: number;
|
||||||
|
hidden?: boolean; // toggled off in the editor
|
||||||
|
// text elements
|
||||||
|
text?: string;
|
||||||
|
font?: string;
|
||||||
|
size?: number;
|
||||||
|
style_preset?: string;
|
||||||
|
style_params?: StyleParams;
|
||||||
|
// country
|
||||||
|
flag?: string;
|
||||||
|
label?: string;
|
||||||
|
// insert
|
||||||
|
photo?: string;
|
||||||
|
w?: number;
|
||||||
|
border_px?: number;
|
||||||
|
shadow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QSOBox {
|
||||||
|
enabled: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
bg: string;
|
||||||
|
bg_opacity: number;
|
||||||
|
radius: number;
|
||||||
|
title: string;
|
||||||
|
fields: string[];
|
||||||
|
footer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardTemplate {
|
||||||
|
schema: number;
|
||||||
|
name: string;
|
||||||
|
card: CardGeom;
|
||||||
|
hero: Hero;
|
||||||
|
elements: CardElement[];
|
||||||
|
qso_box?: QSOBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderModel mirrors qslcard.RenderModel: a template with placeholders
|
||||||
|
// resolved plus the values for the QSO box fields.
|
||||||
|
export interface RenderModel {
|
||||||
|
template: CardTemplate;
|
||||||
|
qso_fields: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding result types (see app_qsl_designer.go).
|
||||||
|
export interface QSLTemplateInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
profile_id?: number;
|
||||||
|
is_default: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QSLFontInfo {
|
||||||
|
family: string;
|
||||||
|
kind: 'display' | 'script' | 'system';
|
||||||
|
variable: boolean;
|
||||||
|
data_b64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QSLPresetInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
params: string[];
|
||||||
|
defaults: StyleParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human labels for the QSO box fields the renderer knows.
|
||||||
|
export const QSO_FIELD_LABELS: Record<string, string> = {
|
||||||
|
qso_date: 'Date',
|
||||||
|
time_on: 'UTC',
|
||||||
|
band: 'Band',
|
||||||
|
mode: 'Mode',
|
||||||
|
rst_sent: 'RST',
|
||||||
|
freq: 'Freq',
|
||||||
|
submode: 'Submode',
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Rasterizes the CardPreview SVG to bitmap bytes. The SVG is serialized and
|
||||||
|
// loaded through an <img>, so every resource it needs (fonts, photos, flag)
|
||||||
|
// must be inline data URLs — CardPreview guarantees that. Fonts are awaited
|
||||||
|
// before drawing so glyphs never rasterize with a fallback face.
|
||||||
|
|
||||||
|
// rasterizeCard returns base64 image bytes (no data: prefix).
|
||||||
|
// type 'image/jpeg' targets e-mail (maxBytes enforced by stepping quality
|
||||||
|
// down); 'image/png' is used for template thumbnails.
|
||||||
|
export async function rasterizeCard(
|
||||||
|
svgEl: SVGSVGElement,
|
||||||
|
outW: number,
|
||||||
|
outH: number,
|
||||||
|
type: 'image/jpeg' | 'image/png',
|
||||||
|
maxBytes = 800 * 1024,
|
||||||
|
): Promise<string> {
|
||||||
|
await document.fonts.ready;
|
||||||
|
|
||||||
|
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
||||||
|
clone.setAttribute('width', String(outW));
|
||||||
|
clone.setAttribute('height', String(outH));
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
const xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
await img.decode();
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = outW;
|
||||||
|
canvas.height = outH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('canvas 2d context unavailable');
|
||||||
|
if (type === 'image/jpeg') {
|
||||||
|
ctx.fillStyle = '#ffffff'; // JPEG has no alpha — avoid black background
|
||||||
|
ctx.fillRect(0, 0, outW, outH);
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, outW, outH);
|
||||||
|
|
||||||
|
if (type === 'image/png') {
|
||||||
|
return canvas.toDataURL('image/png').split(',')[1];
|
||||||
|
}
|
||||||
|
// Step the JPEG quality down until the e-mail size target is met.
|
||||||
|
for (const q of [0.9, 0.8, 0.7, 0.6]) {
|
||||||
|
const b64 = canvas.toDataURL('image/jpeg', q).split(',')[1];
|
||||||
|
if (b64.length * 0.75 <= maxBytes) return b64;
|
||||||
|
}
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.5).split(',')[1];
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
// Canvas-2D call-text renderer, ported faithfully from the user's two
|
||||||
|
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
|
||||||
|
// SVG filters can only approximate these looks; the exact technique is
|
||||||
|
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
|
||||||
|
// boolean compositing, a candy gloss that hugs the outline, an orange inner
|
||||||
|
// shadow, and organic multi-layer grunge.
|
||||||
|
//
|
||||||
|
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
|
||||||
|
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
|
||||||
|
// embeds that as an <image> so the same output drives the editor preview,
|
||||||
|
// thumbnails and the final e-mail rasterization.
|
||||||
|
|
||||||
|
export type TextFxKind = 'glossy' | 'western';
|
||||||
|
|
||||||
|
export interface TextFxParams {
|
||||||
|
kind: TextFxKind;
|
||||||
|
text: string;
|
||||||
|
weight: string; // canvas font weight, e.g. "800" or "400"
|
||||||
|
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
|
||||||
|
size: number; // font size in card px
|
||||||
|
space: number; // letter spacing in px
|
||||||
|
cTop: string;
|
||||||
|
cMid: string;
|
||||||
|
cBot: string;
|
||||||
|
cDark: string; // dark edge / outline
|
||||||
|
cOuter?: string; // silver rim (glossy only)
|
||||||
|
// glossy
|
||||||
|
plump?: number; // 0..0.06 — inflation as a fraction of size
|
||||||
|
edge?: number; // 0..1 — dark edge weight
|
||||||
|
outerw?: number; // 0..1 — silver rim weight
|
||||||
|
gloss?: number; // 0..1 — gloss intensity
|
||||||
|
glossH?: number; // 0..1 — gloss height
|
||||||
|
glossI?: number; // 0..0.14 — gloss inset
|
||||||
|
innerB?: number; // 0..1 — bottom inner shadow
|
||||||
|
// western
|
||||||
|
depth?: number; // 3D extrusion depth in px
|
||||||
|
angle?: number; // extrusion direction in radians
|
||||||
|
slant?: number; // shear, -0.4..0.4
|
||||||
|
grunge?: number; // 0..1 — distress amount
|
||||||
|
bevel?: number; // 0..1 — top-edge light
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFxResult {
|
||||||
|
url: string; // PNG data URL of the trimmed text
|
||||||
|
w: number; // logical (card-px) width
|
||||||
|
h: number; // logical (card-px) height
|
||||||
|
}
|
||||||
|
|
||||||
|
const SS = 2; // supersample for crisp downscaling
|
||||||
|
|
||||||
|
function supportsFilter(c: CanvasRenderingContext2D) {
|
||||||
|
return typeof c.filter === 'string';
|
||||||
|
}
|
||||||
|
function shade(hex: string, pct: number) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
|
||||||
|
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
|
||||||
|
}
|
||||||
|
function deepen(hex: string) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
|
||||||
|
}
|
||||||
|
function rustOf(hex: string) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.min(255, Math.round((n >> 16) * 0.72));
|
||||||
|
const g = Math.round(((n >> 8) & 255) * 0.49);
|
||||||
|
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
|
||||||
|
return 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||||
|
}
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
return function () {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function hashStr(s: string) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
|
||||||
|
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
|
||||||
|
const probe = document.createElement('canvas').getContext('2d')!;
|
||||||
|
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||||
|
probe.letterSpacing = p.space + 'px';
|
||||||
|
probe.font = font;
|
||||||
|
const m = probe.measureText(p.text);
|
||||||
|
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||||
|
const capH = asc + desc;
|
||||||
|
const plumpPx = p.size * p.plump;
|
||||||
|
const edgeW = p.size * 0.1 * p.edge;
|
||||||
|
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
|
||||||
|
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
|
||||||
|
const w = Math.ceil(m.width + pad * 2);
|
||||||
|
const h = Math.ceil(capH + pad * 2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * scale;
|
||||||
|
canvas.height = h * scale;
|
||||||
|
const c = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
const x = pad, y = pad + asc;
|
||||||
|
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
|
||||||
|
|
||||||
|
const mk = () => {
|
||||||
|
const k = document.createElement('canvas');
|
||||||
|
k.width = canvas.width;
|
||||||
|
k.height = canvas.height;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const setT = (cx: CanvasRenderingContext2D) => {
|
||||||
|
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
cx.letterSpacing = p.space + 'px';
|
||||||
|
cx.font = font;
|
||||||
|
cx.textBaseline = 'alphabetic';
|
||||||
|
cx.lineJoin = 'round';
|
||||||
|
cx.lineCap = 'round';
|
||||||
|
};
|
||||||
|
const glyph = (delta: number, dx?: number, dy?: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
setT(g);
|
||||||
|
g.fillStyle = '#fff';
|
||||||
|
g.strokeStyle = '#fff';
|
||||||
|
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
if (delta > 0.1) {
|
||||||
|
g.lineWidth = delta * 2;
|
||||||
|
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
} else if (delta < -0.1) {
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.lineWidth = -delta * 2;
|
||||||
|
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
g.drawImage(mask, 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
|
||||||
|
const k = glyph(plumpPx, 0, 0);
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
|
||||||
|
};
|
||||||
|
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
const gr = g.createLinearGradient(0, top, 0, bot);
|
||||||
|
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
|
||||||
|
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
|
||||||
|
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
|
||||||
|
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
|
||||||
|
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
|
||||||
|
g.fillStyle = gr;
|
||||||
|
g.fillRect(0, 0, k.width / scale, k.height / scale);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
if (supportsFilter(g) && px > 0.3) {
|
||||||
|
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
g.filter = 'none';
|
||||||
|
} else {
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'source-in';
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.fillRect(0, 0, k.width, k.height);
|
||||||
|
g.restore();
|
||||||
|
if (alpha < 1) {
|
||||||
|
const k2 = mk();
|
||||||
|
const g2 = k2.getContext('2d')!;
|
||||||
|
g2.globalAlpha = alpha;
|
||||||
|
g2.drawImage(k, 0, 0);
|
||||||
|
return k2;
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
|
||||||
|
cx.fillStyle = paint;
|
||||||
|
cx.fillText(p.text, x, y);
|
||||||
|
if (plumpPx > 0.1) {
|
||||||
|
cx.strokeStyle = paint;
|
||||||
|
cx.lineWidth = plumpPx * 2;
|
||||||
|
cx.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setT(c);
|
||||||
|
|
||||||
|
// drop shadow
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = 'rgba(10,15,25,0.40)';
|
||||||
|
c.shadowBlur = p.size * 0.045 * scale;
|
||||||
|
c.shadowOffsetY = p.size * 0.03 * scale;
|
||||||
|
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
|
||||||
|
c.strokeStyle = 'rgba(10,15,25,0.40)';
|
||||||
|
c.strokeText(p.text, x, y);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
const face = mk();
|
||||||
|
const f = face.getContext('2d')!;
|
||||||
|
setT(f);
|
||||||
|
|
||||||
|
if (p.outerw > 0) {
|
||||||
|
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
|
||||||
|
gOuter.addColorStop(0, '#ffffff');
|
||||||
|
gOuter.addColorStop(0.45, p.cOuter);
|
||||||
|
gOuter.addColorStop(1, shade(p.cOuter, -22));
|
||||||
|
f.lineWidth = outerW;
|
||||||
|
f.strokeStyle = gOuter;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
if (p.edge > 0) {
|
||||||
|
f.lineWidth = edgeW + 2 * plumpPx;
|
||||||
|
f.strokeStyle = p.cDark;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||||
|
gBody.addColorStop(0, p.cTop);
|
||||||
|
gBody.addColorStop(0.55, p.cMid);
|
||||||
|
gBody.addColorStop(1, p.cBot);
|
||||||
|
paintText(f, gBody);
|
||||||
|
|
||||||
|
if (p.innerB > 0) {
|
||||||
|
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
|
||||||
|
band = blurred(band, p.size * 0.04);
|
||||||
|
band = clipTo(band, glyph(plumpPx, 0, 0));
|
||||||
|
band = vRamp(band, 0.45, 0.8, false);
|
||||||
|
band = tint(band, deepen(p.cBot), 0.7);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.globalAlpha = Math.min(1, p.innerB);
|
||||||
|
f.drawImage(band, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.gloss > 0) {
|
||||||
|
const shiftY = p.glossH * capH;
|
||||||
|
const shiftX = 0.12 * shiftY;
|
||||||
|
const inset = p.size * p.glossI;
|
||||||
|
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
|
||||||
|
let band = contourBand(shiftX, shiftY, inset);
|
||||||
|
band = vRamp(band, clip0, clip1, true);
|
||||||
|
let halo = blurred(band, p.size * 0.014);
|
||||||
|
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
|
||||||
|
let core = mk();
|
||||||
|
core.getContext('2d')!.drawImage(band, 0, 0);
|
||||||
|
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
|
||||||
|
core = blurred(core, p.size * 0.007);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
|
||||||
|
f.drawImage(halo, 0, 0);
|
||||||
|
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
|
||||||
|
f.drawImage(core, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.drawImage(face, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
|
||||||
|
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
|
||||||
|
const sw = Math.max(2, Math.round(W / featurePx));
|
||||||
|
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
|
||||||
|
const small = document.createElement('canvas');
|
||||||
|
small.width = sw;
|
||||||
|
small.height = sh;
|
||||||
|
const sg = small.getContext('2d')!;
|
||||||
|
const id = sg.createImageData(sw, sh);
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
for (let i = 0; i < sw * sh; i++) {
|
||||||
|
const v = (rnd() * 255) | 0;
|
||||||
|
id.data[i * 4] = v;
|
||||||
|
id.data[i * 4 + 1] = v;
|
||||||
|
id.data[i * 4 + 2] = v;
|
||||||
|
id.data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
sg.putImageData(id, 0, 0);
|
||||||
|
const big = document.createElement('canvas');
|
||||||
|
big.width = W;
|
||||||
|
big.height = H;
|
||||||
|
const bg = big.getContext('2d')!;
|
||||||
|
bg.imageSmoothingEnabled = true;
|
||||||
|
bg.imageSmoothingQuality = 'high';
|
||||||
|
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
|
||||||
|
bg.drawImage(small, 0, 0, W, H);
|
||||||
|
if (supportsFilter(bg)) bg.filter = 'none';
|
||||||
|
const src = bg.getImageData(0, 0, W, H);
|
||||||
|
const out = bg.createImageData(W, H);
|
||||||
|
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
|
||||||
|
for (let i = 0; i < W * H; i++) {
|
||||||
|
const v = src.data[i * 4] / 255;
|
||||||
|
let a = (v - thresh) * k;
|
||||||
|
a = a < 0 ? 0 : a > 1 ? 1 : a;
|
||||||
|
out.data[i * 4] = 255;
|
||||||
|
out.data[i * 4 + 1] = 255;
|
||||||
|
out.data[i * 4 + 2] = 255;
|
||||||
|
out.data[i * 4 + 3] = (a * 255) | 0;
|
||||||
|
}
|
||||||
|
const mask = document.createElement('canvas');
|
||||||
|
mask.width = W;
|
||||||
|
mask.height = H;
|
||||||
|
const mg = mask.getContext('2d')!;
|
||||||
|
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
|
||||||
|
const tmp = document.createElement('canvas');
|
||||||
|
tmp.width = W;
|
||||||
|
tmp.height = H;
|
||||||
|
tmp.getContext('2d')!.putImageData(out, 0, 0);
|
||||||
|
mg.drawImage(tmp, 0, 0);
|
||||||
|
if (supportsFilter(mg)) mg.filter = 'none';
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
|
||||||
|
const probe = document.createElement('canvas').getContext('2d')!;
|
||||||
|
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||||
|
probe.letterSpacing = p.space + 'px';
|
||||||
|
probe.font = font;
|
||||||
|
const m = probe.measureText(p.text);
|
||||||
|
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||||
|
const capH = asc + desc;
|
||||||
|
const edgeW = p.size * 0.07;
|
||||||
|
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
|
||||||
|
const slantPad = Math.abs(p.slant) * capH;
|
||||||
|
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
|
||||||
|
const w = Math.ceil(m.width + pad * 2);
|
||||||
|
const h = Math.ceil(capH + pad * 2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * scale;
|
||||||
|
canvas.height = h * scale;
|
||||||
|
const c = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
|
||||||
|
const top = y - asc, bot = y + desc;
|
||||||
|
|
||||||
|
const mk = () => {
|
||||||
|
const k = document.createElement('canvas');
|
||||||
|
k.width = canvas.width;
|
||||||
|
k.height = canvas.height;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const setT = (cx: CanvasRenderingContext2D) => {
|
||||||
|
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
|
||||||
|
cx.letterSpacing = p.space + 'px';
|
||||||
|
cx.font = font;
|
||||||
|
cx.textBaseline = 'alphabetic';
|
||||||
|
cx.lineJoin = 'round';
|
||||||
|
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
|
||||||
|
};
|
||||||
|
const glyph = (dxx?: number, dyy?: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
setT(g);
|
||||||
|
g.fillStyle = '#fff';
|
||||||
|
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
g.drawImage(mask, 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
if (supportsFilter(g) && px > 0.3) {
|
||||||
|
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
g.filter = 'none';
|
||||||
|
} else {
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'source-in';
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.fillRect(0, 0, k.width, k.height);
|
||||||
|
g.restore();
|
||||||
|
const k2 = mk();
|
||||||
|
const g2 = k2.getContext('2d')!;
|
||||||
|
g2.globalAlpha = alpha;
|
||||||
|
g2.drawImage(k, 0, 0);
|
||||||
|
return k2;
|
||||||
|
};
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setT(c);
|
||||||
|
|
||||||
|
// drop shadow
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = 'rgba(15,8,4,0.50)';
|
||||||
|
c.shadowBlur = p.size * 0.035 * scale;
|
||||||
|
c.shadowOffsetX = p.size * 0.02 * scale;
|
||||||
|
c.shadowOffsetY = p.size * 0.035 * scale;
|
||||||
|
c.lineWidth = edgeW;
|
||||||
|
c.strokeStyle = 'rgba(15,8,4,0.50)';
|
||||||
|
c.fillStyle = 'rgba(15,8,4,0.50)';
|
||||||
|
c.strokeText(p.text, x, y);
|
||||||
|
c.fillText(p.text, x, y);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
// 3D extrusion
|
||||||
|
if (p.depth > 0) {
|
||||||
|
const deepCol = shade(p.cDark, -4);
|
||||||
|
c.lineWidth = edgeW;
|
||||||
|
const steps = Math.ceil(p.depth);
|
||||||
|
for (let i = steps; i >= 1; i--) {
|
||||||
|
const t = i / steps;
|
||||||
|
c.strokeStyle = deepCol;
|
||||||
|
c.fillStyle = deepCol;
|
||||||
|
c.strokeText(p.text, x + dx * t, y + dy * t);
|
||||||
|
c.fillText(p.text, x + dx * t, y + dy * t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const face = mk();
|
||||||
|
const f = face.getContext('2d')!;
|
||||||
|
setT(f);
|
||||||
|
|
||||||
|
f.lineWidth = edgeW;
|
||||||
|
f.strokeStyle = p.cDark;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
|
||||||
|
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||||
|
gBody.addColorStop(0, p.cTop);
|
||||||
|
gBody.addColorStop(0.5, p.cMid);
|
||||||
|
gBody.addColorStop(1, p.cBot);
|
||||||
|
f.fillStyle = gBody;
|
||||||
|
f.fillText(p.text, x, y);
|
||||||
|
|
||||||
|
const M = glyph(0, 0);
|
||||||
|
|
||||||
|
if (p.bevel > 0) {
|
||||||
|
let band = glyph(0, 0);
|
||||||
|
const g = band.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
|
||||||
|
g.restore();
|
||||||
|
band = blurred(band, p.size * 0.008);
|
||||||
|
band = clipTo(band, M);
|
||||||
|
band = tintAlpha(band, '#ffeba8', p.bevel);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.drawImage(band, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.grunge > 0) {
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const S = p.size * scale;
|
||||||
|
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
|
||||||
|
const rust = rustOf(p.cBot);
|
||||||
|
const layers = [
|
||||||
|
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
|
||||||
|
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
|
||||||
|
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
|
||||||
|
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
|
||||||
|
];
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
for (const L of layers) {
|
||||||
|
let k = clipTo(L.mask, M);
|
||||||
|
k = tintAlpha(k, L.color, L.a * p.grunge);
|
||||||
|
f.drawImage(k, 0, 0);
|
||||||
|
}
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.drawImage(face, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim transparent padding so the element's x/y maps to the visible glyphs.
|
||||||
|
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const data = ctx.getImageData(0, 0, W, H).data;
|
||||||
|
let minX = W, minY = H, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (data[(y * W + x) * 4 + 3] > 8) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX < minX) return { canvas, w: W, h: H };
|
||||||
|
const cw = maxX - minX + 1, ch = maxY - minY + 1;
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = cw;
|
||||||
|
out.height = ch;
|
||||||
|
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
|
||||||
|
return { canvas: out, w: cw, h: ch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTextFx(p: TextFxParams): TextFxResult {
|
||||||
|
let raw: HTMLCanvasElement;
|
||||||
|
if (p.kind === 'western') {
|
||||||
|
raw = renderWestern(
|
||||||
|
{
|
||||||
|
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||||
|
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
|
||||||
|
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
|
||||||
|
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
|
||||||
|
},
|
||||||
|
SS,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
raw = renderGlossy(
|
||||||
|
{
|
||||||
|
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||||
|
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
|
||||||
|
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
|
||||||
|
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
|
||||||
|
},
|
||||||
|
SS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const t = trim(raw);
|
||||||
|
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
||||||
// can't hold a typo'd value that isn't in the list.
|
// can't hold a typo'd value that isn't in the list.
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
value, onChange, options, placeholder, className, allowFreeText = false,
|
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -14,6 +14,10 @@ export function Combobox({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
allowFreeText?: boolean;
|
allowFreeText?: boolean;
|
||||||
|
// Commit each keystroke to the parent immediately (not just on blur). Use for
|
||||||
|
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||||
|
// leaving the field uses the value just typed.
|
||||||
|
commitOnType?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -41,9 +45,13 @@ export function Combobox({
|
|||||||
// Defer so a click on an option registers first.
|
// Defer so a click on an option registers first.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
|
const trimmed = query.trim();
|
||||||
if (exact) { onChange(exact); setQuery(exact); }
|
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||||
else if (allowFreeText) { onChange(query.trim()); }
|
// Only fire onChange when the value actually changed — committing an
|
||||||
|
// unchanged value on a plain tab-through would wrongly flag the field as
|
||||||
|
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
|
||||||
|
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
|
||||||
|
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
|
||||||
else { setQuery(value); } // revert typo
|
else { setQuery(value); } // revert typo
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
@@ -56,7 +64,7 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyAwardRefs writes picked references onto a QSO payload using each award's
|
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||||
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
|
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||||
|
// The award engine honours these regardless of how the award matches, so a
|
||||||
|
// reference can be assigned even for awards that scan a free-text field by
|
||||||
|
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
|
||||||
|
// the field wouldn't match.
|
||||||
|
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||||
|
|
||||||
|
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
|
||||||
|
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
|
||||||
|
// awards backed by a standard ADIF column also get that column written so the
|
||||||
|
// data lives in its conventional place and exports correctly. Awards that match
|
||||||
|
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||||
|
// on the override — we don't pollute the text field with a code.
|
||||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||||
const byCode = parseAwardRefs(awardRefs);
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
for (const [code, ref] of Object.entries(byCode)) {
|
||||||
|
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
overrides.push(`${code}@${r}`);
|
||||||
|
}
|
||||||
const field = fieldOf[code] || code.toLowerCase();
|
const field = fieldOf[code] || code.toLowerCase();
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'iota': payload.iota = ref; break;
|
case 'iota': payload.iota = ref; break;
|
||||||
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
|
|||||||
extras['SIG'] = 'WWFF';
|
extras['SIG'] = 'WWFF';
|
||||||
extras['SIG_INFO'] = ref;
|
extras['SIG_INFO'] = ref;
|
||||||
break;
|
break;
|
||||||
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
|
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
|
||||||
// scans the note for "D06"). Picking such a reference appends its code(s)
|
// "D06" in the note): append the code so the in-field matcher finds it too.
|
||||||
// to that field so the matcher finds it.
|
|
||||||
case 'note': case 'notes':
|
case 'note': case 'notes':
|
||||||
payload.notes = appendTokens(payload.notes, ref);
|
payload.notes = appendTokens(payload.notes, ref);
|
||||||
break;
|
break;
|
||||||
case 'comment':
|
case 'comment':
|
||||||
payload.comment = appendTokens(payload.comment, ref);
|
payload.comment = appendTokens(payload.comment, ref);
|
||||||
break;
|
break;
|
||||||
|
// address / qth / name / custom: the override above is authoritative; do
|
||||||
|
// not write a code into a free-text field the award matches by name.
|
||||||
default:
|
default:
|
||||||
extras[field.toUpperCase()] = ref;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
|
||||||
|
else delete extras[MANUAL_REFS_KEY];
|
||||||
if (Object.keys(extras).length > 0) payload.extras = extras;
|
if (Object.keys(extras).length > 0) payload.extras = extras;
|
||||||
|
else if (payload.extras) delete payload.extras;
|
||||||
}
|
}
|
||||||
|
|
||||||
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
|
export const APP_VERSION = '0.11';
|
||||||
|
|
||||||
|
// Author / credits, shown in Help → About.
|
||||||
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
import {adif} from '../models';
|
import {adif} from '../models';
|
||||||
import {qso} from '../models';
|
import {qso} from '../models';
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cat} from '../models';
|
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
@@ -25,14 +25,20 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
|
|||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
@@ -87,6 +93,10 @@ export function DisconnectAllClusters():Promise<void>;
|
|||||||
|
|
||||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
@@ -109,6 +119,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string):Promise<award.Result>;
|
export function GetAward(arg1:string):Promise<award.Result>;
|
||||||
|
|
||||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||||
@@ -135,6 +147,10 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
|||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
|
|
||||||
|
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
|
||||||
|
|
||||||
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
@@ -151,8 +167,12 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -163,10 +183,14 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
@@ -185,6 +209,10 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
|||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
|
|
||||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||||
@@ -233,6 +261,40 @@ export function PickSaveDatabase():Promise<string>;
|
|||||||
|
|
||||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function QSLDefaultTemplateID():Promise<number>;
|
||||||
|
|
||||||
|
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function QSLFlagDataURL(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLFonts():Promise<Array<main.QSLFontInfo>>;
|
||||||
|
|
||||||
|
export function QSLGenerateProposals(arg1:Array<string>):Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
|
||||||
|
|
||||||
|
export function QSLGetTemplate(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
||||||
|
|
||||||
|
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLPickPhotos():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function QSLPreviewDataURL(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLResolvePreview(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLSaveEmailTemplates(arg1:main.QSLEmailTemplates):Promise<void>;
|
||||||
|
|
||||||
|
export function QSLSavePreview(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function QSLSaveTemplate(arg1:number,arg2:string,arg3:string,arg4:boolean):Promise<number>;
|
||||||
|
|
||||||
|
export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
|
||||||
|
|
||||||
export function QSOAudioBegin():Promise<boolean>;
|
export function QSOAudioBegin():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
export function QSOAudioCancel():Promise<void>;
|
||||||
@@ -245,6 +307,10 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
|||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|
||||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
@@ -265,6 +331,8 @@ export function SaveADIFFile():Promise<string>;
|
|||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
||||||
@@ -283,6 +351,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
@@ -309,6 +379,8 @@ export function SendClusterCommand(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
@@ -323,6 +395,10 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
@@ -339,6 +415,8 @@ export function TestLoTWUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
@@ -349,6 +427,8 @@ export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
|||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function ApplyAwardPreset(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -34,10 +38,18 @@ export function AwardMissingQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BrowseExecutable() {
|
||||||
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1, arg2) {
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CheckForUpdate() {
|
||||||
|
return window['go']['main']['App']['CheckForUpdate']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ClearLookupCache() {
|
export function ClearLookupCache() {
|
||||||
return window['go']['main']['App']['ClearLookupCache']();
|
return window['go']['main']['App']['ClearLookupCache']();
|
||||||
}
|
}
|
||||||
@@ -146,6 +158,14 @@ export function DisconnectClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DiscoverFlexRadios() {
|
||||||
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadAllReferenceLists() {
|
||||||
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -190,6 +210,10 @@ export function GetAudioSettings() {
|
|||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAutostartPrograms() {
|
||||||
|
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAward(arg1) {
|
export function GetAward(arg1) {
|
||||||
return window['go']['main']['App']['GetAward'](arg1);
|
return window['go']['main']['App']['GetAward'](arg1);
|
||||||
}
|
}
|
||||||
@@ -242,6 +266,14 @@ export function GetCtyDatInfo() {
|
|||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDBBackendStatus() {
|
||||||
|
return window['go']['main']['App']['GetDBBackendStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDBConnectionInfo() {
|
||||||
|
return window['go']['main']['App']['GetDBConnectionInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDVKMessages() {
|
export function GetDVKMessages() {
|
||||||
return window['go']['main']['App']['GetDVKMessages']();
|
return window['go']['main']['App']['GetDVKMessages']();
|
||||||
}
|
}
|
||||||
@@ -274,10 +306,18 @@ export function GetLogFilePath() {
|
|||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogbookRevision() {
|
||||||
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLookupSettings() {
|
export function GetLookupSettings() {
|
||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetMySQLSettings() {
|
||||||
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -298,6 +338,10 @@ export function GetRotatorSettings() {
|
|||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSecretStatus() {
|
||||||
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -306,6 +350,10 @@ export function GetStationSettings() {
|
|||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTelemetryEnabled() {
|
||||||
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetUIPref(arg1) {
|
export function GetUIPref(arg1) {
|
||||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
}
|
}
|
||||||
@@ -342,6 +390,14 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LaunchAutostartProgram(arg1) {
|
||||||
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LaunchAutostartPrograms() {
|
||||||
|
return window['go']['main']['App']['LaunchAutostartPrograms']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAudioInputDevices() {
|
export function ListAudioInputDevices() {
|
||||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||||
}
|
}
|
||||||
@@ -438,6 +494,74 @@ export function PopulateBuiltinReferences(arg1) {
|
|||||||
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSLDefaultTemplateID() {
|
||||||
|
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLDeleteTemplate(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLDeleteTemplate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLFlagDataURL(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLFlagDataURL'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLFonts() {
|
||||||
|
return window['go']['main']['App']['QSLFonts']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLGenerateProposals(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLGenerateProposals'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLGetEmailTemplates() {
|
||||||
|
return window['go']['main']['App']['QSLGetEmailTemplates']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLGetTemplate(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLGetTemplate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLListTemplates() {
|
||||||
|
return window['go']['main']['App']['QSLListTemplates']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLPhotoDataURL(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['QSLPhotoDataURL'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLPickPhotos() {
|
||||||
|
return window['go']['main']['App']['QSLPickPhotos']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLPreviewDataURL(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLPreviewDataURL'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLResolvePreview(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLResolvePreview'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLSaveEmailTemplates(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLSaveEmailTemplates'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLSavePreview(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['QSLSavePreview'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLSaveTemplate(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['QSLSaveTemplate'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLSetDefaultTemplate(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLSetDefaultTemplate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLStylePresets() {
|
||||||
|
return window['go']['main']['App']['QSLStylePresets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOAudioBegin() {
|
export function QSOAudioBegin() {
|
||||||
return window['go']['main']['App']['QSOAudioBegin']();
|
return window['go']['main']['App']['QSOAudioBegin']();
|
||||||
}
|
}
|
||||||
@@ -462,6 +586,14 @@ export function ReloadUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RemovePassphrase(arg1) {
|
||||||
|
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderEQSL(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1, arg2) {
|
export function ReplaceAwardReferences(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -502,6 +634,10 @@ export function SaveAudioSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAutostartPrograms(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1) {
|
export function SaveAwardDefs(arg1) {
|
||||||
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
||||||
}
|
}
|
||||||
@@ -538,6 +674,10 @@ export function SaveLookupSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveMySQLSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1) {
|
export function SaveOperatingAntenna(arg1) {
|
||||||
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
|
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -590,6 +730,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendEQSL(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1) {
|
export function SendQSORecordingEmail(arg1) {
|
||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
@@ -618,6 +762,14 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPassphrase(arg1) {
|
||||||
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTelemetryEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetUIPref(arg1, arg2) {
|
export function SetUIPref(arg1, arg2) {
|
||||||
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -650,6 +802,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestMySQLConnection(arg1) {
|
||||||
|
return window['go']['main']['App']['TestMySQLConnection'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestPTT(arg1) {
|
export function TestPTT(arg1) {
|
||||||
return window['go']['main']['App']['TestPTT'](arg1);
|
return window['go']['main']['App']['TestPTT'](arg1);
|
||||||
}
|
}
|
||||||
@@ -670,6 +826,10 @@ export function UltrabeamRetract() {
|
|||||||
return window['go']['main']['App']['UltrabeamRetract']();
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UnlockSecrets(arg1) {
|
||||||
|
return window['go']['main']['App']['UnlockSecrets'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateAwardReferenceList(arg1) {
|
export function UpdateAwardReferenceList(arg1) {
|
||||||
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,30 @@ export namespace award {
|
|||||||
this.confirmed = source["confirmed"];
|
this.confirmed = source["confirmed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class OrRule {
|
||||||
|
field: string;
|
||||||
|
match_by?: string;
|
||||||
|
exact_match?: boolean;
|
||||||
|
pattern?: string;
|
||||||
|
leading_str?: string;
|
||||||
|
trailing_str?: string;
|
||||||
|
prefix?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OrRule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.field = source["field"];
|
||||||
|
this.match_by = source["match_by"];
|
||||||
|
this.exact_match = source["exact_match"];
|
||||||
|
this.pattern = source["pattern"];
|
||||||
|
this.leading_str = source["leading_str"];
|
||||||
|
this.trailing_str = source["trailing_str"];
|
||||||
|
this.prefix = source["prefix"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Def {
|
export class Def {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -126,6 +150,7 @@ export namespace award {
|
|||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
add_prefixes?: string[];
|
add_prefixes?: string[];
|
||||||
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
valid_modes?: string[];
|
valid_modes?: string[];
|
||||||
@@ -164,6 +189,7 @@ export namespace award {
|
|||||||
this.multi = source["multi"];
|
this.multi = source["multi"];
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
this.add_prefixes = source["add_prefixes"];
|
this.add_prefixes = source["add_prefixes"];
|
||||||
|
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||||
this.dxcc_filter = source["dxcc_filter"];
|
this.dxcc_filter = source["dxcc_filter"];
|
||||||
this.valid_bands = source["valid_bands"];
|
this.valid_bands = source["valid_bands"];
|
||||||
this.valid_modes = source["valid_modes"];
|
this.valid_modes = source["valid_modes"];
|
||||||
@@ -175,7 +201,26 @@ export namespace award {
|
|||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.builtin = source["builtin"];
|
this.builtin = source["builtin"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Ref {
|
export class Ref {
|
||||||
ref: string;
|
ref: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -340,6 +385,28 @@ export namespace awardref {
|
|||||||
|
|
||||||
export namespace cat {
|
export namespace cat {
|
||||||
|
|
||||||
|
export class FlexRadio {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
model: string;
|
||||||
|
nickname: string;
|
||||||
|
serial: string;
|
||||||
|
callsign: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexRadio(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ip = source["ip"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.nickname = source["nickname"];
|
||||||
|
this.serial = source["serial"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RigState {
|
export class RigState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -682,6 +749,44 @@ export namespace main {
|
|||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AutostartLaunchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutostartLaunchResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.message = source["message"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AutostartProgram {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
args: string;
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutostartProgram(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.path = source["path"];
|
||||||
|
this.args = source["args"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AwardImportResult {
|
export class AwardImportResult {
|
||||||
awards: number;
|
awards: number;
|
||||||
references: number;
|
references: number;
|
||||||
@@ -792,6 +897,9 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
|
flex_host: string;
|
||||||
|
flex_port: number;
|
||||||
|
flex_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
@@ -805,6 +913,9 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
|
this.flex_host = source["flex_host"];
|
||||||
|
this.flex_port = source["flex_port"];
|
||||||
|
this.flex_spots = source["flex_spots"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
@@ -846,6 +957,36 @@ export namespace main {
|
|||||||
this.file_mod_time = source["file_mod_time"];
|
this.file_mod_time = source["file_mod_time"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DBBackendStatus {
|
||||||
|
active: string;
|
||||||
|
fallback: boolean;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DBBackendStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.active = source["active"];
|
||||||
|
this.fallback = source["fallback"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DBConnectionInfo {
|
||||||
|
backend: string;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DBConnectionInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.label = source["label"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DVKMessage {
|
export class DVKMessage {
|
||||||
slot: number;
|
slot: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -903,6 +1044,7 @@ export namespace main {
|
|||||||
smtp_user: string;
|
smtp_user: string;
|
||||||
smtp_password: string;
|
smtp_password: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
reply_to: string;
|
||||||
encryption: string;
|
encryption: string;
|
||||||
auth: boolean;
|
auth: boolean;
|
||||||
auto_send: boolean;
|
auto_send: boolean;
|
||||||
@@ -921,6 +1063,7 @@ export namespace main {
|
|||||||
this.smtp_user = source["smtp_user"];
|
this.smtp_user = source["smtp_user"];
|
||||||
this.smtp_password = source["smtp_password"];
|
this.smtp_password = source["smtp_password"];
|
||||||
this.from = source["from"];
|
this.from = source["from"];
|
||||||
|
this.reply_to = source["reply_to"];
|
||||||
this.encryption = source["encryption"];
|
this.encryption = source["encryption"];
|
||||||
this.auth = source["auth"];
|
this.auth = source["auth"];
|
||||||
this.auto_send = source["auto_send"];
|
this.auto_send = source["auto_send"];
|
||||||
@@ -1009,6 +1152,28 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MySQLSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MySQLSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
this.database = source["database"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
activator: string;
|
activator: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -1126,6 +1291,96 @@ export namespace main {
|
|||||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class QSLEmailTemplates {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
auto_send: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLEmailTemplates(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.subject = source["subject"];
|
||||||
|
this.body = source["body"];
|
||||||
|
this.auto_send = source["auto_send"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class QSLFontInfo {
|
||||||
|
family: string;
|
||||||
|
kind: string;
|
||||||
|
variable: boolean;
|
||||||
|
data_b64: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLFontInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.family = source["family"];
|
||||||
|
this.kind = source["kind"];
|
||||||
|
this.variable = source["variable"];
|
||||||
|
this.data_b64 = source["data_b64"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class QSLPresetInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
params: string[];
|
||||||
|
defaults: qslcard.StyleParams;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLPresetInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.params = source["params"];
|
||||||
|
this.defaults = this.convertValues(source["defaults"], qslcard.StyleParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class QSLTemplateInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
profile_id?: number;
|
||||||
|
is_default: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLTemplateInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.profile_id = source["profile_id"];
|
||||||
|
this.is_default = source["is_default"];
|
||||||
|
this.updated_at = source["updated_at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSOAwardRef {
|
export class QSOAwardRef {
|
||||||
code: string;
|
code: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
@@ -1180,6 +1435,20 @@ export namespace main {
|
|||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SecretStatus {
|
||||||
|
has_passphrase: boolean;
|
||||||
|
unlocked: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SecretStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.has_passphrase = source["has_passphrase"];
|
||||||
|
this.unlocked = source["unlocked"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class SpotQuery {
|
export class SpotQuery {
|
||||||
call: string;
|
call: string;
|
||||||
band: string;
|
band: string;
|
||||||
@@ -1224,7 +1493,6 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
db_path: string;
|
db_path: string;
|
||||||
migrated_from_app_data: boolean;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StartupStatus(source);
|
return new StartupStatus(source);
|
||||||
@@ -1235,7 +1503,6 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.err = source["err"];
|
this.err = source["err"];
|
||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
this.migrated_from_app_data = source["migrated_from_app_data"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationInfoComputed {
|
export class StationInfoComputed {
|
||||||
@@ -1324,6 +1591,24 @@ export namespace main {
|
|||||||
this.moving = source["moving"];
|
this.moving = source["moving"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string;
|
||||||
|
available: boolean;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UpdateInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.current = source["current"];
|
||||||
|
this.latest = source["latest"];
|
||||||
|
this.available = source["available"];
|
||||||
|
this.url = source["url"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
label: string;
|
label: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -1527,11 +1812,34 @@ export namespace operating {
|
|||||||
|
|
||||||
export namespace profile {
|
export namespace profile {
|
||||||
|
|
||||||
|
export class ProfileDB {
|
||||||
|
backend: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProfileDB(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
this.database = source["database"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Profile {
|
export class Profile {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
op_name: string;
|
||||||
owner_callsign: string;
|
owner_callsign: string;
|
||||||
my_grid: string;
|
my_grid: string;
|
||||||
my_country: string;
|
my_country: string;
|
||||||
@@ -1552,6 +1860,7 @@ export namespace profile {
|
|||||||
tx_pwr?: number;
|
tx_pwr?: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
db: ProfileDB;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -1567,6 +1876,7 @@ export namespace profile {
|
|||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
this.operator = source["operator"];
|
this.operator = source["operator"];
|
||||||
|
this.op_name = source["op_name"];
|
||||||
this.owner_callsign = source["owner_callsign"];
|
this.owner_callsign = source["owner_callsign"];
|
||||||
this.my_grid = source["my_grid"];
|
this.my_grid = source["my_grid"];
|
||||||
this.my_country = source["my_country"];
|
this.my_country = source["my_country"];
|
||||||
@@ -1587,6 +1897,7 @@ export namespace profile {
|
|||||||
this.tx_pwr = source["tx_pwr"];
|
this.tx_pwr = source["tx_pwr"];
|
||||||
this.is_active = source["is_active"];
|
this.is_active = source["is_active"];
|
||||||
this.sort_order = source["sort_order"];
|
this.sort_order = source["sort_order"];
|
||||||
|
this.db = this.convertValues(source["db"], ProfileDB);
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
@@ -1612,6 +1923,165 @@ export namespace profile {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace qslcard {
|
||||||
|
|
||||||
|
export class Bevel {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
dark: string;
|
||||||
|
light: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bevel(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dx = source["dx"];
|
||||||
|
this.dy = source["dy"];
|
||||||
|
this.dark = source["dark"];
|
||||||
|
this.light = source["light"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FxParams {
|
||||||
|
plump?: number;
|
||||||
|
edge?: number;
|
||||||
|
outerw?: number;
|
||||||
|
gloss?: number;
|
||||||
|
gloss_h?: number;
|
||||||
|
gloss_i?: number;
|
||||||
|
inner_b?: number;
|
||||||
|
depth?: number;
|
||||||
|
angle?: number;
|
||||||
|
slant?: number;
|
||||||
|
grunge?: number;
|
||||||
|
bevel?: number;
|
||||||
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FxParams(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.plump = source["plump"];
|
||||||
|
this.edge = source["edge"];
|
||||||
|
this.outerw = source["outerw"];
|
||||||
|
this.gloss = source["gloss"];
|
||||||
|
this.gloss_h = source["gloss_h"];
|
||||||
|
this.gloss_i = source["gloss_i"];
|
||||||
|
this.inner_b = source["inner_b"];
|
||||||
|
this.depth = source["depth"];
|
||||||
|
this.angle = source["angle"];
|
||||||
|
this.slant = source["slant"];
|
||||||
|
this.grunge = source["grunge"];
|
||||||
|
this.bevel = source["bevel"];
|
||||||
|
this.seed = source["seed"];
|
||||||
|
this.dark = source["dark"];
|
||||||
|
this.outer = source["outer"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Halo {
|
||||||
|
color: string;
|
||||||
|
blur: number;
|
||||||
|
opacity: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Halo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.color = source["color"];
|
||||||
|
this.blur = source["blur"];
|
||||||
|
this.opacity = source["opacity"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ShadowFx {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
blur: number;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ShadowFx(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dx = source["dx"];
|
||||||
|
this.dy = source["dy"];
|
||||||
|
this.blur = source["blur"];
|
||||||
|
this.color = source["color"];
|
||||||
|
this.opacity = source["opacity"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Shine {
|
||||||
|
coverage: number;
|
||||||
|
opacity: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Shine(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.coverage = source["coverage"];
|
||||||
|
this.opacity = source["opacity"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StyleParams {
|
||||||
|
gradient?: string[];
|
||||||
|
shine?: Shine;
|
||||||
|
outline_color?: string;
|
||||||
|
outline_width?: number;
|
||||||
|
halo?: Halo;
|
||||||
|
shadow?: ShadowFx;
|
||||||
|
bevel_offset?: Bevel;
|
||||||
|
color?: string;
|
||||||
|
fx?: FxParams;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StyleParams(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.gradient = source["gradient"];
|
||||||
|
this.shine = this.convertValues(source["shine"], Shine);
|
||||||
|
this.outline_color = source["outline_color"];
|
||||||
|
this.outline_width = source["outline_width"];
|
||||||
|
this.halo = this.convertValues(source["halo"], Halo);
|
||||||
|
this.shadow = this.convertValues(source["shadow"], ShadowFx);
|
||||||
|
this.bevel_offset = this.convertValues(source["bevel_offset"], Bevel);
|
||||||
|
this.color = source["color"];
|
||||||
|
this.fx = this.convertValues(source["fx"], FxParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace qso {
|
export namespace qso {
|
||||||
|
|
||||||
export class BandMode {
|
export class BandMode {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
github.com/moutend/go-wca v0.3.0
|
github.com/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
github.com/wneessen/go-mail v0.7.3
|
github.com/wneessen/go-mail v0.7.3
|
||||||
@@ -16,6 +17,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||||
@@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
|
|||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
func BatchRecordsADIF(records []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
now := time.Now().UTC().Format("20060102 150405")
|
||||||
|
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
|
||||||
|
len(adifVersion), adifVersion, now)
|
||||||
|
for _, r := range records {
|
||||||
|
r = strings.TrimRight(r, "\r\n")
|
||||||
|
if r == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(r)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
|
|||||||
@@ -5,11 +5,26 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
|
||||||
|
// Defaults to a no-op so the package is usable without wiring.
|
||||||
|
var LogSink = func(string, ...any) {}
|
||||||
|
|
||||||
|
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||||
|
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||||
|
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||||
|
// — but it catches any Go-level panic in capture/mix.)
|
||||||
|
func recoverGoroutine(what string) {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
|
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
|
||||||
// recording can begin a few seconds BEFORE the operator entered the callsign.
|
// recording can begin a few seconds BEFORE the operator entered the callsign.
|
||||||
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
|
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
|
||||||
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder capture (radio)")
|
||||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder capture (mic)")
|
||||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder mixer")
|
||||||
t := time.NewTicker(40 * time.Millisecond)
|
t := time.NewTicker(40 * time.Millisecond)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -221,27 +239,43 @@ func (r *Recorder) RestartQSO() {
|
|||||||
r.active = true
|
r.active = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveQSO writes the accumulated recording to path as a WAV and stops
|
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||||
// accumulating. Returns an error if no recording was active.
|
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||||
func (r *Recorder) SaveQSO(path string) error {
|
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
||||||
|
// a long recording doesn't delay logging.
|
||||||
|
func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
if !r.active {
|
if !r.active {
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
return fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active = nil, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return fmt.Errorf("recording was empty")
|
return nil, fmt.Errorf("recording was empty")
|
||||||
}
|
}
|
||||||
data := int16sToBytes(samples)
|
return int16sToBytes(samples), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
|
||||||
|
// .mp3). Slow for MP3 — call off the logging path.
|
||||||
|
func WritePCM(path string, data []byte) error {
|
||||||
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
||||||
return writeMP3(path, data)
|
return writeMP3(path, data)
|
||||||
}
|
}
|
||||||
return writeWAV(path, data)
|
return writeWAV(path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveQSO snapshots and writes the recording in one call (synchronous).
|
||||||
|
func (r *Recorder) SaveQSO(path string) error {
|
||||||
|
data, err := r.TakeQSO()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return WritePCM(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ type Def struct {
|
|||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||||
|
|
||||||
|
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
||||||
|
// earns a reference if the primary match OR any of these match. Lets a
|
||||||
|
// French department (DDFM) be found from "D74" in the note AND from a postal
|
||||||
|
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
||||||
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||||
@@ -84,6 +90,20 @@ type Def struct {
|
|||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||||
|
// Same knobs as the primary (field + how to match), plus Prefix which is
|
||||||
|
// prepended to each reference it finds so a captured value can be normalised to
|
||||||
|
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
|
||||||
|
type OrRule struct {
|
||||||
|
Field string `json:"field"` // QSO field to scan
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
|
Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference
|
||||||
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||||
func Defaults() []Def {
|
func Defaults() []Def {
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
||||||
@@ -414,7 +434,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
return candidates(&d, re, q, rl, len(metas) > 0)
|
found := candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
|
// Merge operator-assigned references (manual override). These let the
|
||||||
|
// operator tag a QSO for an award whose field/description matching can't
|
||||||
|
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
||||||
|
// province NAME, so a contact whose address doesn't spell it out needs the
|
||||||
|
// province picked by hand. For a predefined award the override is still
|
||||||
|
// validated against its reference list.
|
||||||
|
if man := manualRefs(q, d.Code); len(man) > 0 {
|
||||||
|
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||||
|
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
|
||||||
|
// comma list). Honoured by MatchQSO regardless of how the award matches.
|
||||||
|
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
|
||||||
|
|
||||||
|
// manualRefs returns the reference codes the operator assigned to award `code`
|
||||||
|
// on this QSO (from the ManualRefsKey extra).
|
||||||
|
func manualRefs(q *qso.QSO, code string) []string {
|
||||||
|
if q == nil || q.Extras == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
var out []string
|
||||||
|
for _, entry := range strings.Split(raw, ";") {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
at := strings.IndexByte(entry, '@')
|
||||||
|
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
|
||||||
|
if r = strings.TrimSpace(r); r != "" {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
||||||
|
// When the award is predefined, only references present and valid in its list
|
||||||
|
// are kept (so a typo can't invent a reference).
|
||||||
|
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, c := range found {
|
||||||
|
seen[normalizeRef(c)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range manual {
|
||||||
|
c = normalizeRef(c)
|
||||||
|
if _, dup := seen[c]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if predefined {
|
||||||
|
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[c] = struct{}{}
|
||||||
|
found = append(found, c)
|
||||||
|
}
|
||||||
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
@@ -445,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
|
|||||||
|
|
||||||
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||||
// a predefined list when one applies.
|
// a predefined list when one applies.
|
||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
|
||||||
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
|
// returns the reference codes it finds, each prefixed with `prefix` (so a
|
||||||
|
// captured "74" becomes "D74"). predefined enables list-aware matching.
|
||||||
|
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
|
||||||
|
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
predefined := hasList && !d.Dynamic
|
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
|
||||||
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
|
|
||||||
|
|
||||||
var found []string
|
var found []string
|
||||||
switch {
|
switch {
|
||||||
@@ -464,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// the field equals the name; otherwise the name is a substring of it.
|
// the field equals the name; otherwise the name is a substring of it.
|
||||||
up := strings.ToUpper(raw)
|
up := strings.ToUpper(raw)
|
||||||
for _, nc := range rl.names {
|
for _, nc := range rl.names {
|
||||||
if d.ExactMatch {
|
if exact {
|
||||||
if up == nc.name {
|
if up == nc.name {
|
||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
@@ -472,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case predefined && !d.ExactMatch:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// the list — O(tokens), not O(all references) — plus test the few
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
// references that declare a regex.
|
// references that declare a regex.
|
||||||
@@ -492,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// counts each reference separately.
|
// counts each reference separately.
|
||||||
found = splitRefs(raw)
|
found = splitRefs(raw)
|
||||||
}
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
for i := range found {
|
||||||
|
found[i] = prefix + found[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
|
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
||||||
|
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
||||||
|
for i := range d.OrRules {
|
||||||
|
r := &d.OrRules[i]
|
||||||
|
var rre *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
|
c, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
|
}
|
||||||
|
rre = c
|
||||||
|
}
|
||||||
|
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
||||||
|
}
|
||||||
|
|
||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
return dedupe(found)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
|
|||||||
return 0, fmt.Errorf("clear refs: %w", err)
|
return 0, fmt.Errorf("clear refs: %w", err)
|
||||||
}
|
}
|
||||||
stmt, err := tx.PrepareContext(ctx,
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
`INSERT OR REPLACE INTO award_references
|
`REPLACE INTO award_references
|
||||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
|
|||||||
return fmt.Errorf("empty award or reference code")
|
return fmt.Errorf("empty award or reference code")
|
||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT OR REPLACE INTO award_references
|
`REPLACE INTO award_references
|
||||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
||||||
|
|||||||
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
return dstPath, nil
|
return dstPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
|
||||||
|
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
|
||||||
|
// ADIF export is a portable, re-importable backup of the actual contacts.
|
||||||
|
// writeADIF must write the full ADIF to the path it's handed.
|
||||||
|
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
|
||||||
|
if rotation <= 0 {
|
||||||
|
rotation = 5
|
||||||
|
}
|
||||||
|
if folder == "" {
|
||||||
|
return "", fmt.Errorf("backup folder not set")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create backup folder: %w", err)
|
||||||
|
}
|
||||||
|
base := "opslog-log-" + time.Now().Format("2006-01-02")
|
||||||
|
tmp := filepath.Join(folder, base+".adi.tmp")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := writeADIF(tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("export adif: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dstPath string
|
||||||
|
if doZip {
|
||||||
|
dstPath = filepath.Join(folder, base+".adi.zip")
|
||||||
|
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
} else {
|
||||||
|
dstPath = filepath.Join(folder, base+".adi")
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
if err := os.Rename(tmp, dstPath); err != nil {
|
||||||
|
if cerr := copyFile(tmp, dstPath); cerr != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
|
||||||
|
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
|
||||||
|
}
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// copyFile performs a plain file copy. We don't use os.Rename because
|
// copyFile performs a plain file copy. We don't use os.Rename because
|
||||||
// the source is the live database; we want a fresh standalone file.
|
// the source is the live database; we want a fresh standalone file.
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
|
|||||||
return out.Close()
|
return out.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
|
||||||
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
// opslog-*.db.zip) and deletes the rest.
|
||||||
// are touched — never user files that happen to live in the same folder.
|
|
||||||
func rotate(folder string, keep int) error {
|
func rotate(folder string, keep int) error {
|
||||||
|
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateMatch keeps the most recent `keep` files in folder whose name has the
|
||||||
|
// given prefix and one of the given suffixes, deleting older ones. Only matching
|
||||||
|
// files are touched — never unrelated user files in the same folder. The suffix
|
||||||
|
// filter keeps the .db family and the .adi family from rotating each other even
|
||||||
|
// though both share the "opslog-" prefix.
|
||||||
|
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
|
||||||
entries, err := os.ReadDir(folder)
|
entries, err := os.ReadDir(folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if !strings.HasPrefix(name, "opslog-") {
|
if !strings.HasPrefix(name, prefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
ok := false
|
||||||
|
for _, sfx := range suffixes {
|
||||||
|
if strings.HasSuffix(name, sfx) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
|
|||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasBackupToday returns true if a backup for today's date already exists
|
// HasBackupToday returns true if a SQLite backup for today's date already
|
||||||
// in folder. Used by the startup auto-backup to skip when the user has
|
// exists in folder. Used by the auto-backup to skip when the user has already
|
||||||
// already restarted the app once today.
|
// restarted the app once today.
|
||||||
func HasBackupToday(folder string) bool {
|
func HasBackupToday(folder string) bool {
|
||||||
|
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
|
||||||
|
func HasADIFBackupToday(folder string) bool {
|
||||||
|
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBackupToday(folder, prefix string, exts ...string) bool {
|
||||||
if folder == "" {
|
if folder == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
for _, ext := range []string{".db", ".db.zip"} {
|
for _, ext := range exts {
|
||||||
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type RigState struct {
|
|||||||
// Manager owns the active backend and runs the polling loop.
|
// Manager owns the active backend and runs the polling loop.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
|
||||||
state RigState
|
state RigState
|
||||||
emit func(RigState)
|
emit func(RigState)
|
||||||
backend Backend
|
backend Backend
|
||||||
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
|
|||||||
// state.Error rather than returned, so the UI can keep retrying via the
|
// state.Error rather than returned, so the UI can keep retrying via the
|
||||||
// poll loop on next reconnect attempt.
|
// poll loop on next reconnect attempt.
|
||||||
func (m *Manager) Start(b Backend) {
|
func (m *Manager) Start(b Backend) {
|
||||||
m.Stop()
|
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
|
||||||
|
// Start (or Start+Stop) calls could otherwise interleave and leave the
|
||||||
|
// previous poll goroutine alive — two pollers then fight, e.g. flipping
|
||||||
|
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
|
||||||
|
m.startMu.Lock()
|
||||||
|
defer m.startMu.Unlock()
|
||||||
|
m.stopLocked()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.stopCh = make(chan struct{})
|
m.stopCh = make(chan struct{})
|
||||||
m.doneCh = make(chan struct{})
|
m.doneCh = make(chan struct{})
|
||||||
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
|
|||||||
|
|
||||||
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
||||||
func (m *Manager) Stop() {
|
func (m *Manager) Stop() {
|
||||||
|
m.startMu.Lock()
|
||||||
|
defer m.startMu.Unlock()
|
||||||
|
m.stopLocked()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state = RigState{Enabled: false}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopLocked tears down any running poller and blocks until it exits. The
|
||||||
|
// caller must hold startMu so it can't race a concurrent Start.
|
||||||
|
func (m *Manager) stopLocked() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.stopCh
|
stop := m.stopCh
|
||||||
done := m.doneCh
|
done := m.doneCh
|
||||||
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
|
|||||||
if done != nil {
|
if done != nil {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
|
||||||
m.state = RigState{Enabled: false}
|
|
||||||
m.mu.Unlock()
|
|
||||||
m.emitState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||||
@@ -169,6 +184,48 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SpotInfo is one cluster spot to render on a backend that supports a spot
|
||||||
|
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
|
||||||
|
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||||
|
// driven later by setting Color per spot.)
|
||||||
|
type SpotInfo struct {
|
||||||
|
FreqHz int64
|
||||||
|
Callsign string
|
||||||
|
Mode string
|
||||||
|
Color string
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
|
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
|
||||||
|
type Spotter interface {
|
||||||
|
SendSpot(SpotInfo) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
|
||||||
|
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
|
||||||
|
// missed spot on the panadapter is harmless.
|
||||||
|
func (m *Manager) SendSpot(s SpotInfo) {
|
||||||
|
m.mu.RLock()
|
||||||
|
cmds := m.cmdCh
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if cmds == nil || b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := b.(Spotter); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case cmds <- func() {
|
||||||
|
if sp, ok := b.(Spotter); ok {
|
||||||
|
_ = sp.SendSpot(s)
|
||||||
|
}
|
||||||
|
}:
|
||||||
|
default: // queue busy → drop this spot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
@@ -195,23 +252,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
defer runtime.UnlockOSThread()
|
defer runtime.UnlockOSThread()
|
||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
if err := b.Connect(); err != nil {
|
|
||||||
m.update(RigState{
|
|
||||||
Enabled: true, Backend: b.Name(), Connected: false,
|
|
||||||
Error: err.Error(), UpdatedAt: time.Now(),
|
|
||||||
})
|
|
||||||
// Stay idle until Stop is called — let the user fix config and re-Start.
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case fn := <-cmds:
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer b.Disconnect()
|
defer b.Disconnect()
|
||||||
|
|
||||||
|
// Connection is (re)established lazily and retried with a backoff, so a rig
|
||||||
|
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
|
||||||
|
// reconnects on its own instead of staying dead until the user toggles CAT.
|
||||||
|
const reconnectEvery = 5 * time.Second
|
||||||
|
connected := false
|
||||||
|
var lastAttempt time.Time
|
||||||
|
tryConnect := func() {
|
||||||
|
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAttempt = time.Now()
|
||||||
|
if err := b.Connect(); err != nil {
|
||||||
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connected = true
|
||||||
|
}
|
||||||
|
tryConnect()
|
||||||
|
|
||||||
ticker := time.NewTicker(pollEvery)
|
ticker := time.NewTicker(pollEvery)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -223,12 +284,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
fn()
|
fn()
|
||||||
m.applyCommandDelay()
|
m.applyCommandDelay()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if !connected {
|
||||||
|
tryConnect()
|
||||||
|
continue
|
||||||
|
}
|
||||||
ns, err := b.ReadState()
|
ns, err := b.ReadState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.update(RigState{
|
// Lost the rig — drop the backend so the next attempt reconnects
|
||||||
Enabled: true, Backend: b.Name(), Connected: false,
|
// cleanly, then back off before retrying.
|
||||||
Error: err.Error(), UpdatedAt: time.Now(),
|
connected = false
|
||||||
})
|
lastAttempt = time.Now()
|
||||||
|
b.Disconnect()
|
||||||
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ns.Enabled = true
|
ns.Enabled = true
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
|
||||||
|
// API on port 4992 — a line-based text protocol — and tracks slice state pushed
|
||||||
|
// by the radio in REAL TIME, so frequency/mode/split are always current (unlike
|
||||||
|
// the polled, lagging OmniRig path that needed a second click to fix a mode).
|
||||||
|
// Pure Go, no CGO, and no OmniRig install required for Flex users.
|
||||||
|
type Flex struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
conn net.Conn
|
||||||
|
wmu sync.Mutex // serialises writes to conn
|
||||||
|
seq int
|
||||||
|
handle string
|
||||||
|
model string
|
||||||
|
gotHandle bool
|
||||||
|
|
||||||
|
slices map[int]*flexSlice
|
||||||
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
|
|
||||||
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
|
||||||
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
|
// clicks one of our spots on the panadapter, with the spot's callsign and
|
||||||
|
// frequency. The host wires this to fill the entry form. Set before Connect.
|
||||||
|
OnSpotClick func(callsign string, freqHz int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
type flexSlice struct {
|
||||||
|
freqHz int64
|
||||||
|
mode string // raw Flex mode (USB/LSB/CW/DIGU/…)
|
||||||
|
active bool
|
||||||
|
tx bool
|
||||||
|
inUse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
|
||||||
|
// when the user clicks one of our spots on the panadapter.
|
||||||
|
var flexTriggerRe = regexp.MustCompile(`spot (\d+) triggered`)
|
||||||
|
|
||||||
|
// NewFlex builds a Flex backend for the given radio IP (host) and port (4992).
|
||||||
|
// spotsEnabled turns on the panadapter spot overlay (subscribe + clear leftovers
|
||||||
|
// on connect + accept SendSpot).
|
||||||
|
func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||||
|
if port == 0 {
|
||||||
|
port = 4992
|
||||||
|
}
|
||||||
|
return &Flex{
|
||||||
|
host: strings.TrimSpace(host), port: port,
|
||||||
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) Name() string { return "flex" }
|
||||||
|
|
||||||
|
// Connect dials the radio and subscribes to slice/radio status. The reader
|
||||||
|
// goroutine then keeps our cached state current from the radio's push messages.
|
||||||
|
func (f *Flex) Connect() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
already := f.conn != nil
|
||||||
|
host := f.host
|
||||||
|
port := f.port
|
||||||
|
f.mu.Unlock()
|
||||||
|
if already {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("flex: no radio IP configured")
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.conn = conn
|
||||||
|
f.gotHandle = false
|
||||||
|
f.slices = map[int]*flexSlice{}
|
||||||
|
f.mu.Unlock()
|
||||||
|
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
||||||
|
|
||||||
|
go f.reader(conn)
|
||||||
|
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
||||||
|
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
||||||
|
f.send("client program=OpsLog")
|
||||||
|
f.send("sub slice all")
|
||||||
|
f.send("sub transmit all")
|
||||||
|
f.send("sub radio all")
|
||||||
|
if f.spotsEnabled {
|
||||||
|
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||||
|
// then wipe the panadapter so stale spots from a previous session or
|
||||||
|
// another logger are cleared before we start adding our own.
|
||||||
|
f.send("sub spot all")
|
||||||
|
go f.clearSpotsOnConnect(conn)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) Disconnect() {
|
||||||
|
f.mu.Lock()
|
||||||
|
c := f.conn
|
||||||
|
f.conn = nil
|
||||||
|
f.gotHandle = false
|
||||||
|
f.mu.Unlock()
|
||||||
|
if c != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
debugLog.Printf("Flex: disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
|
||||||
|
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
|
||||||
|
// new spot's index). Returns 0 when not connected. Best effort.
|
||||||
|
func (f *Flex) send(cmd string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
c := f.conn
|
||||||
|
f.seq++
|
||||||
|
seq := f.seq
|
||||||
|
f.mu.Unlock()
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f.wmu.Lock()
|
||||||
|
_, err := fmt.Fprintf(c, "C%d|%s\n", seq, cmd)
|
||||||
|
f.wmu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
debugLog.Printf("Flex: → %s", cmd)
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// reader consumes the radio's line stream until the connection drops.
|
||||||
|
func (f *Flex) reader(conn net.Conn) {
|
||||||
|
sc := bufio.NewScanner(conn)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimRight(sc.Text(), "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Panadapter spot click → "…spot <index> triggered…". Resolve the index
|
||||||
|
// back to the callsign we stored at spot-add time and notify the host.
|
||||||
|
if mm := flexTriggerRe.FindStringSubmatch(line); mm != nil {
|
||||||
|
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||||
|
f.mu.Lock()
|
||||||
|
call := f.spotCall[idx]
|
||||||
|
handler := f.OnSpotClick
|
||||||
|
f.mu.Unlock()
|
||||||
|
if call != "" && handler != nil {
|
||||||
|
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
||||||
|
go handler(call, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch line[0] {
|
||||||
|
case 'V': // version banner, e.g. "V1.4.0.0"
|
||||||
|
debugLog.Printf("Flex: radio %s", line)
|
||||||
|
case 'H': // our client handle
|
||||||
|
f.mu.Lock()
|
||||||
|
f.handle = line[1:]
|
||||||
|
f.gotHandle = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
debugLog.Printf("Flex: handshake ok, handle=%s", line[1:])
|
||||||
|
case 'S': // status push: S<handle>|<object ...>
|
||||||
|
if i := strings.IndexByte(line, '|'); i >= 0 {
|
||||||
|
f.handleStatus(line[i+1:])
|
||||||
|
}
|
||||||
|
case 'M': // message
|
||||||
|
debugLog.Printf("Flex: msg %s", line)
|
||||||
|
case 'R': // command response: R<seq>|<hex>|<message>
|
||||||
|
parts := strings.SplitN(line[1:], "|", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seq, _ := strconv.Atoi(parts[0])
|
||||||
|
ok := parts[1] == "0" || parts[1] == "00000000"
|
||||||
|
if !ok {
|
||||||
|
debugLog.Printf("Flex: cmd error %s", line)
|
||||||
|
}
|
||||||
|
// A successful "spot add" returns the new spot's index in the message;
|
||||||
|
// pair it with the callsign we stashed under this seq.
|
||||||
|
f.mu.Lock()
|
||||||
|
call, pending := f.pendingSpot[seq]
|
||||||
|
if pending {
|
||||||
|
delete(f.pendingSpot, seq)
|
||||||
|
}
|
||||||
|
if pending && ok && len(parts) >= 3 {
|
||||||
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
|
f.spotCall[idx] = call
|
||||||
|
f.spotIdx[idx] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Connection ended.
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.conn == conn {
|
||||||
|
f.conn = nil
|
||||||
|
f.gotHandle = false
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus parses one status payload, e.g.
|
||||||
|
// "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …"
|
||||||
|
func (f *Flex) handleStatus(payload string) {
|
||||||
|
fields := strings.Fields(payload)
|
||||||
|
if len(fields) < 2 || fields[0] != "slice" {
|
||||||
|
// radio … model=FLEX-6400 — grab the model when present.
|
||||||
|
if len(fields) >= 1 && fields[0] == "radio" {
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
if strings.HasPrefix(kv, "model=") {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.model = strings.TrimPrefix(kv, "model=")
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fields) >= 1 && fields[0] == "transmit" {
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
}
|
||||||
|
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||||
|
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||||
|
// spot status, which we'll use to fill the callsign once we see its shape.
|
||||||
|
if len(fields) >= 2 && fields[0] == "spot" {
|
||||||
|
// The click ("spot N triggered") is handled in the reader; here we
|
||||||
|
// just keep the set of live spot indices for ClearSpots.
|
||||||
|
if idx, err := strconv.Atoi(fields[1]); err == nil {
|
||||||
|
removed := false
|
||||||
|
for _, kv := range fields[2:] {
|
||||||
|
if kv == "removed" || kv == "in_use=0" {
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if removed {
|
||||||
|
delete(f.spotIdx, idx)
|
||||||
|
delete(f.spotCall, idx)
|
||||||
|
} else {
|
||||||
|
f.spotIdx[idx] = true
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Slice status — log it so split/freq/mode issues are diagnosable.
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
idx, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
s := f.slices[idx]
|
||||||
|
if s == nil {
|
||||||
|
s = &flexSlice{}
|
||||||
|
f.slices[idx] = s
|
||||||
|
}
|
||||||
|
for _, kv := range fields[2:] {
|
||||||
|
eq := strings.IndexByte(kv, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val := kv[:eq], kv[eq+1:]
|
||||||
|
switch key {
|
||||||
|
case "RF_frequency":
|
||||||
|
if mhz, e := strconv.ParseFloat(val, 64); e == nil {
|
||||||
|
s.freqHz = int64(math.Round(mhz * 1e6))
|
||||||
|
}
|
||||||
|
case "mode":
|
||||||
|
s.mode = val
|
||||||
|
case "active":
|
||||||
|
s.active = val == "1"
|
||||||
|
case "tx":
|
||||||
|
s.tx = val == "1"
|
||||||
|
case "in_use":
|
||||||
|
s.inUse = val == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState returns the cached state derived from the radio's push messages —
|
||||||
|
// no round-trip, so it's always current.
|
||||||
|
func (f *Flex) ReadState() (RigState, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.conn == nil {
|
||||||
|
return RigState{}, fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
st := RigState{Connected: f.gotHandle, Rig: f.model}
|
||||||
|
if !f.gotHandle {
|
||||||
|
return st, nil // connected TCP but radio hasn't handshaked yet
|
||||||
|
}
|
||||||
|
rx, tx := f.pickSlicesLocked()
|
||||||
|
if rx == nil && tx == nil {
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
tx = rx
|
||||||
|
}
|
||||||
|
if rx == nil {
|
||||||
|
rx = tx
|
||||||
|
}
|
||||||
|
st.FreqHz = tx.freqHz
|
||||||
|
st.Mode = flexModeToADIF(tx.mode)
|
||||||
|
if rx.freqHz != tx.freqHz {
|
||||||
|
st.Split = true
|
||||||
|
st.RxFreqHz = rx.freqHz
|
||||||
|
}
|
||||||
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
|
if sig != f.lastStateSig {
|
||||||
|
f.lastStateSig = sig
|
||||||
|
debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
|
||||||
|
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
|
||||||
|
// (preferring the active/focused one), NOT simply the active slice: tuning the
|
||||||
|
// TX slice makes it the active/focused slice, which would otherwise collapse RX
|
||||||
|
// onto TX and hide the split. Caller holds f.mu.
|
||||||
|
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
|
||||||
|
idxs := make([]int, 0, len(f.slices))
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s.inUse {
|
||||||
|
idxs = append(idxs, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
var active, txS, nonTx, first *flexSlice
|
||||||
|
for _, i := range idxs {
|
||||||
|
s := f.slices[i]
|
||||||
|
if first == nil {
|
||||||
|
first = s
|
||||||
|
}
|
||||||
|
if s.active {
|
||||||
|
active = s
|
||||||
|
}
|
||||||
|
if s.tx {
|
||||||
|
txS = s
|
||||||
|
} else if nonTx == nil {
|
||||||
|
nonTx = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx = txS
|
||||||
|
if tx == nil {
|
||||||
|
if active != nil {
|
||||||
|
tx = active
|
||||||
|
} else {
|
||||||
|
tx = first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RX = the receive slice: the active one if it isn't the TX slice, else the
|
||||||
|
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
|
||||||
|
switch {
|
||||||
|
case active != nil && active != tx:
|
||||||
|
rx = active
|
||||||
|
case nonTx != nil:
|
||||||
|
rx = nonTx
|
||||||
|
default:
|
||||||
|
rx = tx
|
||||||
|
}
|
||||||
|
return rx, tx
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSliceIndexLocked returns the slice index to send commands to (the active
|
||||||
|
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
|
||||||
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
|
best, found := 1<<30, false
|
||||||
|
for idx, s := range f.slices {
|
||||||
|
if !s.inUse {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.active {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
if idx < best {
|
||||||
|
best, found = idx, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetFrequency(hz int64) error {
|
||||||
|
if hz <= 0 {
|
||||||
|
return fmt.Errorf("flex: invalid frequency")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
idx := f.activeSliceIndexLocked()
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
// "slice t <rx> <freq_MHz>" — tune command per the SmartSDR API (MHz, 6 dp).
|
||||||
|
f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetMode(mode string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
idx := f.activeSliceIndexLocked()
|
||||||
|
var freq int64
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
freq = s.freqHz
|
||||||
|
}
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
fm := adifModeToFlex(mode, freq)
|
||||||
|
if fm == "" {
|
||||||
|
return fmt.Errorf("flex: unsupported mode %q", mode)
|
||||||
|
}
|
||||||
|
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
||||||
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
|
||||||
|
// a lifetime so the radio expires them on its own (the API has no "spot clear").
|
||||||
|
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
|
||||||
|
func (f *Flex) SendSpot(s SpotInfo) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil && f.gotHandle
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
call := flexEncode(s.Callsign)
|
||||||
|
if call == "" || s.FreqHz <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := s.Color
|
||||||
|
if color == "" {
|
||||||
|
color = "#FFFFA500" // opaque orange default
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
||||||
|
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
||||||
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
|
cmd += " mode=" + m
|
||||||
|
}
|
||||||
|
if c := flexEncode(s.Comment); c != "" {
|
||||||
|
cmd += " comment=" + c
|
||||||
|
}
|
||||||
|
seq := f.send(cmd)
|
||||||
|
if seq > 0 {
|
||||||
|
// Remember which call this add was for; the R<seq> response carries the
|
||||||
|
// radio-assigned spot index, which we map to the call so a later click
|
||||||
|
// (trigger) can be resolved back to the callsign.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.pendingSpot[seq] = s.Callsign
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSpotsOnConnect waits until the radio handshake completes (we're truly
|
||||||
|
// connected), then sends "spot clear" so launching OpsLog — or enabling the
|
||||||
|
// option — starts from a clean panadapter, including spots left by another
|
||||||
|
// logger or a previous session.
|
||||||
|
func (f *Flex) clearSpotsOnConnect(conn net.Conn) {
|
||||||
|
for i := 0; i < 50; i++ { // up to ~5s for the handshake
|
||||||
|
f.mu.Lock()
|
||||||
|
ready := f.gotHandle && f.conn == conn
|
||||||
|
gone := f.conn != conn
|
||||||
|
f.mu.Unlock()
|
||||||
|
if gone {
|
||||||
|
return // reconnected/closed in the meantime
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
f.ClearSpots()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSpots wipes ALL panadapter spots in one command ("spot clear") — removes
|
||||||
|
// stale spots from a previous session or another logger, not just our own.
|
||||||
|
func (f *Flex) ClearSpots() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.spotIdx = map[int]bool{}
|
||||||
|
f.spotCall = map[int]string{}
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send("spot clear")
|
||||||
|
debugLog.Printf("Flex: spot clear sent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexEncode prepares a value for the Flex command line: trimmed, with any
|
||||||
|
// internal spaces replaced by 0x7F as the SmartSDR API requires.
|
||||||
|
func flexEncode(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(s, " ", "\x7f")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetPTT(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
f.send("xmit 1")
|
||||||
|
} else {
|
||||||
|
f.send("xmit 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
|
func flexModeToADIF(m string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
|
case "USB", "LSB":
|
||||||
|
return "SSB"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
case "AM", "SAM":
|
||||||
|
return "AM"
|
||||||
|
case "FM", "NFM", "DFM":
|
||||||
|
return "FM"
|
||||||
|
case "DIGU", "DIGL":
|
||||||
|
return "DATA"
|
||||||
|
case "RTTY":
|
||||||
|
return "RTTY"
|
||||||
|
case "FDV":
|
||||||
|
return "DIGITALVOICE"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifModeToFlex maps an ADIF mode to a Flex slice mode. SSB picks USB/LSB from
|
||||||
|
// the frequency (LSB below 10 MHz, USB above) — the standard convention.
|
||||||
|
func adifModeToFlex(mode string, freqHz int64) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "SSB":
|
||||||
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
|
return "LSB"
|
||||||
|
}
|
||||||
|
return "USB"
|
||||||
|
case "USB":
|
||||||
|
return "USB"
|
||||||
|
case "LSB":
|
||||||
|
return "LSB"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
case "AM":
|
||||||
|
return "AM"
|
||||||
|
case "FM":
|
||||||
|
return "FM"
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return "RTTY"
|
||||||
|
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
|
||||||
|
return "DIGU"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FlexRadio is one radio found by discovery.
|
||||||
|
type FlexRadio struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
|
||||||
|
// second. DiscoverFlex listens for that broadcast for the given duration and
|
||||||
|
// returns the unique radios seen. Best effort: if the port can't be bound
|
||||||
|
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
|
||||||
|
// user falls back to entering the IP by hand.
|
||||||
|
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 2 * time.Second
|
||||||
|
}
|
||||||
|
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
|
||||||
|
// listens for the same broadcast.
|
||||||
|
lc := net.ListenConfig{
|
||||||
|
Control: func(_, _ string, c syscall.RawConn) error {
|
||||||
|
var serr error
|
||||||
|
_ = c.Control(func(fd uintptr) {
|
||||||
|
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||||
|
})
|
||||||
|
return serr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
_ = pc.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
found := map[string]FlexRadio{}
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
n, _, err := pc.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
break // deadline reached or socket closed
|
||||||
|
}
|
||||||
|
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
|
||||||
|
if _, dup := found[r.IP]; !dup {
|
||||||
|
found[r.IP] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]FlexRadio, 0, len(found))
|
||||||
|
for _, r := range found {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reFlexModel = regexp.MustCompile(`model=(\S+)`)
|
||||||
|
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
|
||||||
|
reFlexPort = regexp.MustCompile(`port=(\d+)`)
|
||||||
|
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
|
||||||
|
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
|
||||||
|
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
|
||||||
|
// The payload carries a space-separated key=value ASCII blob after a binary
|
||||||
|
// header, so we scan the whole packet text for the keys we need.
|
||||||
|
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
|
||||||
|
s := string(pkt)
|
||||||
|
m := reFlexIP.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return FlexRadio{}, false
|
||||||
|
}
|
||||||
|
r := FlexRadio{IP: m[1], Port: 4992}
|
||||||
|
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
|
||||||
|
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
|
||||||
|
r.Port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Model = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Serial = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Nickname = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Callsign = mm[1]
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
@@ -3,11 +3,21 @@ package cat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-ole/go-ole"
|
"github.com/go-ole/go-ole"
|
||||||
"github.com/go-ole/go-ole/oleutil"
|
"github.com/go-ole/go-ole/oleutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
|
||||||
|
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
|
||||||
|
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
|
||||||
|
// split ON = 0x8000, split OFF = 0x10000.
|
||||||
|
const (
|
||||||
|
pmSplitOn = 0x8000 // PM_SPLITON
|
||||||
|
pmSplitOff = 0x10000 // PM_SPLITOFF
|
||||||
|
)
|
||||||
|
|
||||||
// OmniRig talks to the user's installed OmniRig server over COM.
|
// OmniRig talks to the user's installed OmniRig server over COM.
|
||||||
//
|
//
|
||||||
// All methods MUST be called from the same OS thread (the one Manager.run
|
// All methods MUST be called from the same OS thread (the one Manager.run
|
||||||
@@ -21,6 +31,15 @@ type OmniRig struct {
|
|||||||
|
|
||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
|
||||||
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
|
// async Freq property, which still reports the OLD band for a poll or two
|
||||||
|
// after a QSY — that lag is why a clicked spot needed a second click to fix
|
||||||
|
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
||||||
|
lastSetFreq int64
|
||||||
|
lastSetFreqAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||||
s.Mode = omniRigMode(modeVar.Val)
|
s.Mode = omniRigMode(modeVar.Val)
|
||||||
}
|
}
|
||||||
|
rawVfo := int64(0)
|
||||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
|
rawVfo = vfoVar.Val
|
||||||
s.Vfo = omniRigVfo(vfoVar.Val)
|
s.Vfo = omniRigVfo(vfoVar.Val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read both VFO frequencies separately so we can expose split TX/RX.
|
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
|
||||||
// Fall back to generic Freq if the rig only exposes the merged property.
|
// generic Freq is what the rig is operating on — the reliable source for the
|
||||||
freqA, freqB := int64(0), int64(0)
|
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
|
||||||
|
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
freqMain = v.Val
|
||||||
|
}
|
||||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
freqA = v.Val
|
freqA = v.Val
|
||||||
}
|
}
|
||||||
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
freqB = v.Val
|
freqB = v.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split detection: trust the explicit Split property when it's set,
|
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
|
||||||
// BUT only call it a real split if both VFO frequencies are non-zero
|
// compared to PM_SPLITON, not "!= 0".
|
||||||
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
|
splitRaw := int64(0)
|
||||||
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
|
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
|
||||||
// slice model doesn't map to VFO A/B — that would yield a useless
|
splitRaw = v.Val
|
||||||
// permanent SPLIT badge.
|
|
||||||
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
|
|
||||||
s.Split = true
|
|
||||||
}
|
|
||||||
if s.Split && (freqB == 0 || freqA == freqB) {
|
|
||||||
s.Split = false
|
|
||||||
s.RxFreqHz = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
||||||
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
|
// normal operation stays quiet but toggling split on the radio is captured —
|
||||||
switch s.Vfo {
|
// needed to pin down this rig's PM_SPLITON value.
|
||||||
case "AB":
|
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
||||||
s.FreqHz = freqB // TX
|
o.lastSig = sig
|
||||||
s.RxFreqHz = freqA // RX
|
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
|
||||||
case "BA":
|
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
||||||
s.FreqHz = freqA // TX
|
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
||||||
s.RxFreqHz = freqB // RX
|
return v.Val
|
||||||
case "B", "BB":
|
}
|
||||||
s.FreqHz = freqB
|
return -1
|
||||||
default: // "A", "AA", "" — single VFO on A or unknown
|
}())
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
}
|
||||||
if s.FreqHz == 0 {
|
|
||||||
// Last resort — some rigs only update generic Freq.
|
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
|
||||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
// distinct and non-zero, AND they're in the same band. The same-band test
|
||||||
s.FreqHz = v.Val
|
// kills the common false positive where VFO B just holds a leftover from
|
||||||
|
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
|
||||||
|
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
|
||||||
|
genuineSplit := splitRaw == pmSplitOn &&
|
||||||
|
freqA != 0 && freqB != 0 && freqA != freqB &&
|
||||||
|
BandFromHz(freqA) == BandFromHz(freqB)
|
||||||
|
|
||||||
|
if genuineSplit {
|
||||||
|
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
||||||
|
// ADIF: FreqHz = TX, RxFreqHz = RX.
|
||||||
|
s.Split = true
|
||||||
|
switch s.Vfo {
|
||||||
|
case "BA":
|
||||||
|
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
|
||||||
|
default: // "AB" and the usual "TX on the other VFO" case
|
||||||
|
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
||||||
|
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
||||||
|
s.Split = false
|
||||||
|
s.RxFreqHz = 0
|
||||||
|
s.FreqHz = freqMain
|
||||||
|
if s.FreqHz == 0 {
|
||||||
|
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||||
|
s.FreqHz = freqB
|
||||||
|
} else {
|
||||||
|
s.FreqHz = freqA
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
return fmt.Errorf("frequency out of OmniRig int32 range")
|
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||||
}
|
}
|
||||||
hz32 := int32(hz)
|
hz32 := int32(hz)
|
||||||
|
// Remember the commanded frequency so a mode change moments later (a clicked
|
||||||
|
// spot sets freq then mode) picks the sideband from the TARGET band, not the
|
||||||
|
// not-yet-updated OmniRig Freq property.
|
||||||
|
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
|
||||||
|
|
||||||
// Log the rig's writable-params, status and VFO state up front so a
|
// Log the rig's writable-params, status and VFO state up front so a
|
||||||
// friend's session shows exactly what OmniRig reports for their rig.
|
// friend's session shows exactly what OmniRig reports for their rig.
|
||||||
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
case "CW":
|
case "CW":
|
||||||
bit, bitName = pmCWU, "PM_CW_U"
|
bit, bitName = pmCWU, "PM_CW_U"
|
||||||
case "SSB":
|
case "SSB":
|
||||||
// Read current freq to decide USB vs LSB.
|
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
|
||||||
|
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
|
||||||
|
// property still reports the OLD band for a poll or two after a QSY, so
|
||||||
|
// reading it here picked the wrong sideband and the user had to click a
|
||||||
|
// second time. Fall back to the live read for a standalone mode change.
|
||||||
var freq int64
|
var freq int64
|
||||||
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
|
||||||
|
freq = o.lastSetFreq
|
||||||
|
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
freq = freqVar.Val
|
freq = freqVar.Val
|
||||||
}
|
}
|
||||||
if freq > 0 && freq < 10_000_000 {
|
if freq > 0 && freq < 10_000_000 {
|
||||||
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||||
|
// documented PM_VFO* constants.
|
||||||
func omniRigVfo(v int64) string {
|
func omniRigVfo(v int64) string {
|
||||||
switch {
|
switch {
|
||||||
case v&1024 != 0:
|
case v&0x40 != 0: // PM_VFOAA
|
||||||
return "A"
|
|
||||||
case v&2048 != 0:
|
|
||||||
return "B"
|
|
||||||
case v&64 != 0:
|
|
||||||
return "AA"
|
return "AA"
|
||||||
case v&128 != 0:
|
case v&0x80 != 0: // PM_VFOAB
|
||||||
return "AB"
|
return "AB"
|
||||||
case v&256 != 0:
|
case v&0x100 != 0: // PM_VFOBA
|
||||||
return "BA"
|
return "BA"
|
||||||
case v&512 != 0:
|
case v&0x200 != 0: // PM_VFOBB
|
||||||
return "BB"
|
return "BB"
|
||||||
|
case v&0x400 != 0: // PM_VFOA
|
||||||
|
return "A"
|
||||||
|
case v&0x800 != 0: // PM_VFOB
|
||||||
|
return "B"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,143 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MySQLConfig targets a shared MySQL database for multi-operator logging
|
||||||
|
// (multiple OpsLog instances on one logbook, à la Log4OM).
|
||||||
|
type MySQLConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MySQLConfig) dsn() string {
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3306
|
||||||
|
}
|
||||||
|
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
|
||||||
|
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
|
||||||
|
// or wrong-port server fails fast with a real error instead of hanging
|
||||||
|
// startup (which would surface only as "db not initialized"); read/write
|
||||||
|
// timeouts cap a stuck statement (generous, so normal migrations/imports
|
||||||
|
// never trip them).
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
|
||||||
|
c.User, c.Password, c.Host, port, c.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
|
||||||
|
// use a placeholder). Only plain identifiers allowed.
|
||||||
|
func validDBIdent(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingMySQL verifies a shared-database connection and creates the logbook
|
||||||
|
// database if it doesn't exist yet. It connects at server level first (no
|
||||||
|
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
|
||||||
|
// database is actually usable. A restricted user (common on shared hosting)
|
||||||
|
// may lack the CREATE DATABASE privilege but still have full rights on a
|
||||||
|
// pre-created database — so a denied CREATE is not fatal as long as the
|
||||||
|
// database already exists and we can connect to it. Backs the "Test
|
||||||
|
// connection" button.
|
||||||
|
func PingMySQL(c MySQLConfig) error {
|
||||||
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
|
return fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
server := c
|
||||||
|
server.Database = "" // connect to the server, not a specific DB
|
||||||
|
conn, err := sql.Open("mysql", server.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetConnMaxLifetime(15 * time.Second)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(c.Database)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !validDBIdent(name) {
|
||||||
|
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||||
|
}
|
||||||
|
createErr := error(nil)
|
||||||
|
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||||
|
if !isAccessDenied(err) {
|
||||||
|
return fmt.Errorf("create database %q: %w", name, err)
|
||||||
|
}
|
||||||
|
createErr = err // remember it in case the DB also turns out to be unreachable
|
||||||
|
}
|
||||||
|
// Confirm the database is usable (it may have been pre-created by an admin
|
||||||
|
// even though this user can't CREATE one).
|
||||||
|
dbConn, err := sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open mysql db: %w", err)
|
||||||
|
}
|
||||||
|
defer dbConn.Close()
|
||||||
|
dbConn.SetConnMaxLifetime(15 * time.Second)
|
||||||
|
if err := dbConn.Ping(); err != nil {
|
||||||
|
if createErr != nil {
|
||||||
|
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("connect to database %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
|
||||||
|
// dialect; run through the dialect translator for MySQL.
|
||||||
|
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)`
|
||||||
|
|
||||||
|
// Dialect names the backend opened at startup. Repos consult it for the few
|
||||||
|
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
|
||||||
|
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
|
||||||
|
var Dialect = "sqlite"
|
||||||
|
|
||||||
|
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
|
||||||
|
// server. Config tables always live in local SQLite, so this only governs
|
||||||
|
// qso-table SQL (see columnExpr).
|
||||||
|
func IsMySQL() bool { return Dialect == "mysql" }
|
||||||
|
|
||||||
|
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
|
||||||
|
// startup after the logbook backend is chosen, so it doesn't depend on the
|
||||||
|
// order in which the local and logbook connections were opened.
|
||||||
|
func SetDialect(d string) {
|
||||||
|
if d == "mysql" {
|
||||||
|
Dialect = "mysql"
|
||||||
|
} else {
|
||||||
|
Dialect = "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
|
||||||
|
// *_at / date columns — millisecond precision, matching the old SQLite
|
||||||
|
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
|
||||||
|
// same INSERT/UPDATE works on both backends.
|
||||||
|
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
|
||||||
|
|
||||||
|
|
||||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||||
// enables performance PRAGMAs, and applies embedded migrations.
|
// enables performance PRAGMAs, and applies embedded migrations.
|
||||||
func Open(path string) (*sql.DB, error) {
|
func Open(path string) (*sql.DB, error) {
|
||||||
@@ -30,7 +160,8 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
}
|
}
|
||||||
if err := migrate(conn); err != nil {
|
Dialect = "sqlite"
|
||||||
|
if err := migrate(conn, nil); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -39,12 +170,18 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
|
|
||||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||||
// skipping those already applied. Intentionally minimal in-house system
|
// skipping those already applied. Intentionally minimal in-house system
|
||||||
// (no external dependency).
|
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||||
func migrate(conn *sql.DB) error {
|
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||||
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
func migrate(conn *sql.DB, translate func(string) string) error {
|
||||||
name TEXT PRIMARY KEY,
|
// A non-nil translator means this is the MySQL connection (use the
|
||||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||||
)`); err != nil {
|
// determined by the caller's argument, NOT the global Dialect, so the
|
||||||
|
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
|
||||||
|
mysqlPath := translate != nil
|
||||||
|
if translate == nil {
|
||||||
|
translate = func(s string) string { return s }
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
|
||||||
return fmt.Errorf("create schema_migrations: %w", err)
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +211,30 @@ func migrate(conn *sql.DB) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read migration %s: %w", name, err)
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
sqlText := translate(string(content))
|
||||||
|
|
||||||
|
// MySQL implicitly commits each DDL statement, so a wrapping transaction
|
||||||
|
// gives no atomicity — a mid-file failure would leave columns/tables
|
||||||
|
// behind, unrecorded, and every restart would re-run and choke on
|
||||||
|
// "duplicate column". Instead apply statement-by-statement and tolerate
|
||||||
|
// "already exists" errors, making migrations idempotent (and self-healing
|
||||||
|
// for a previously half-applied database).
|
||||||
|
if mysqlPath {
|
||||||
|
if err := applyMySQLMigration(conn, sqlText); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite supports DDL inside a transaction, so keep it atomic there.
|
||||||
tx, err := conn.Begin()
|
tx, err := conn.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin tx for %s: %w", name, err)
|
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(string(content)); err != nil {
|
if _, err := tx.Exec(sqlText); err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- QSL card designer templates. The json column holds the full template
|
||||||
|
-- document (schema v1, internal/qslcard). Photos referenced by a template
|
||||||
|
-- are copied into <dataDir>/qsl/templates/<id>/ — never referenced at their
|
||||||
|
-- original path. profile_id scopes the "default template" choice; a NULL
|
||||||
|
-- profile keeps the template usable by everyone.
|
||||||
|
CREATE TABLE qsl_templates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
profile_id INTEGER REFERENCES station_profiles(id) ON DELETE SET NULL,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Operator's personal name (e.g. "Greg"), distinct from the ADIF OPERATOR
|
||||||
|
-- callsign. Used by the QSL card designer for the signature/script line; may
|
||||||
|
-- feed correspondence and exports later. Optional, blank by default.
|
||||||
|
ALTER TABLE station_profiles ADD COLUMN op_name TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Per-profile logbook database. Each profile can target its own logbook:
|
||||||
|
-- the local SQLite file, or a specific shared MySQL database. Switching the
|
||||||
|
-- active profile switches the logbook accordingly. Stored as a small JSON
|
||||||
|
-- document {backend, host, port, user, password, database}; empty = inherit
|
||||||
|
-- the default (local SQLite).
|
||||||
|
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SQLite → MySQL schema translation ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// OpsLog's migrations are authored in SQLite dialect (the default backend).
|
||||||
|
// For the shared-MySQL backend we translate that DDL on the fly rather than
|
||||||
|
// maintaining a second set of migration files, so the two backends can never
|
||||||
|
// drift apart. The translation is deliberately narrow — it only rewrites the
|
||||||
|
// SQLite-isms that actually appear in our migrations:
|
||||||
|
//
|
||||||
|
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
|
||||||
|
// - bare INTEGER → BIGINT (so FK child/parent types match)
|
||||||
|
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
|
||||||
|
// - TEXT → VARCHAR(255), except a few free-text
|
||||||
|
// columns that stay TEXT/LONGTEXT
|
||||||
|
// - reserved column `key` → backticked
|
||||||
|
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
|
||||||
|
//
|
||||||
|
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
|
||||||
|
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
|
||||||
|
// are all valid MySQL as written.
|
||||||
|
|
||||||
|
var (
|
||||||
|
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
|
||||||
|
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
|
||||||
|
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
|
||||||
|
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
||||||
|
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
|
||||||
|
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
|
||||||
|
// uses backticks. Our schema has no double-quoted string literals, so it's
|
||||||
|
// safe to convert every "ident" to `ident`.
|
||||||
|
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
|
||||||
|
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
|
||||||
|
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
|
||||||
|
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
|
||||||
|
// actually needs in-row storage: it's indexed, a primary key, or carries a
|
||||||
|
// DEFAULT (TEXT can't hold a literal default in MySQL).
|
||||||
|
|
||||||
|
// varcharColumns must be VARCHAR because they're indexed or part of a primary
|
||||||
|
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
|
||||||
|
// below can't catch them). MySQL can't index a TEXT column without a prefix
|
||||||
|
// length. Keyed by column name (a name may repeat across tables — harmless).
|
||||||
|
var varcharColumns = map[string]bool{
|
||||||
|
// qso indexes (0001, 0003, 0019)
|
||||||
|
"callsign": true, "qso_date": true, "band": true, "mode": true,
|
||||||
|
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
|
||||||
|
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
|
||||||
|
// integrations_udp index (0011)
|
||||||
|
"direction": true,
|
||||||
|
// award_references composite primary key (0017)
|
||||||
|
"award_code": true, "ref_code": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// longTextColumns override the default TEXT with a bigger type where the
|
||||||
|
// content can be large (a full QSL-template document).
|
||||||
|
var longTextColumns = map[string]string{
|
||||||
|
"json": "LONGTEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
|
||||||
|
// file) into the MySQL dialect. See the package note above for the rules.
|
||||||
|
func mysqlDDL(stmt string) string {
|
||||||
|
s := stmt
|
||||||
|
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
|
||||||
|
// baseline dump, etc.).
|
||||||
|
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
|
||||||
|
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
|
||||||
|
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
|
||||||
|
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
|
||||||
|
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
|
||||||
|
// inserts that omit the id still get one.
|
||||||
|
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
|
||||||
|
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
|
||||||
|
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
|
||||||
|
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
|
||||||
|
s = reStrftimeDefault.ReplaceAllString(s, "''")
|
||||||
|
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
|
||||||
|
s = reBareInteger.ReplaceAllString(s, "BIGINT")
|
||||||
|
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
|
||||||
|
// a primary key, or default-bearing — keeping the row under MySQL's limit.
|
||||||
|
s = translateTextColumns(s)
|
||||||
|
// `key` is a MySQL reserved word — backtick the settings column declaration.
|
||||||
|
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
|
||||||
|
// right MySQL type, deciding per line so it can see whether the line carries a
|
||||||
|
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
|
||||||
|
func translateTextColumns(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := reColText.FindStringSubmatchIndex(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
col := line[m[2]:m[3]]
|
||||||
|
var typ string
|
||||||
|
switch {
|
||||||
|
case longTextColumns[col] != "":
|
||||||
|
typ = longTextColumns[col]
|
||||||
|
case varcharColumns[col],
|
||||||
|
strings.Contains(line, "DEFAULT"),
|
||||||
|
strings.Contains(line, "PRIMARY KEY"):
|
||||||
|
typ = "VARCHAR(255)"
|
||||||
|
default:
|
||||||
|
typ = "TEXT"
|
||||||
|
}
|
||||||
|
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||||
|
// then applies the (translated) embedded migrations. multiStatements is enabled
|
||||||
|
// so multi-statement migration files run in a single Exec.
|
||||||
|
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||||
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
|
return nil, fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(c.Database)
|
||||||
|
if !validDBIdent(name) {
|
||||||
|
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||||
|
}
|
||||||
|
// Ensure the database exists (connect server-level first).
|
||||||
|
if err := PingMySQL(c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
// The UI fires bursts of concurrent queries (the Preferences dialog alone
|
||||||
|
// loads ~8 settings in parallel, plus the grid and live cluster status). A
|
||||||
|
// low cap turns such a burst into a deadlock-like stall against a remote
|
||||||
|
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
|
||||||
|
conn.SetMaxOpenConns(50)
|
||||||
|
conn.SetMaxIdleConns(10)
|
||||||
|
conn.SetConnMaxIdleTime(90 * time.Second)
|
||||||
|
// No max lifetime: a slow server's first migration can run for minutes on a
|
||||||
|
// single connection, and reaping it mid-migration drops the selected database
|
||||||
|
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||||
|
// after 90s, and the driver retries stale pooled connections.
|
||||||
|
conn.SetConnMaxLifetime(0)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||||
|
}
|
||||||
|
// Set the dialect before migrating so the runner takes the MySQL path
|
||||||
|
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||||
|
Dialect = "mysql"
|
||||||
|
fresh, err := isFreshMySQL(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
Dialect = "sqlite"
|
||||||
|
return nil, fmt.Errorf("inspect database: %w", err)
|
||||||
|
}
|
||||||
|
if fresh {
|
||||||
|
// Empty database: build the whole final schema in one pass (one CREATE
|
||||||
|
// per table, no ALTERs) — far faster than replaying 21 migrations,
|
||||||
|
// especially on a server with slow DDL.
|
||||||
|
err = applyMySQLBaseline(conn)
|
||||||
|
} else {
|
||||||
|
// Existing database: apply only the migrations it's missing.
|
||||||
|
err = migrate(conn, mysqlDDL)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
|
||||||
|
// table), so the baseline fast-path applies. A partially-migrated database is
|
||||||
|
// NOT fresh and goes through the incremental migrator.
|
||||||
|
func isFreshMySQL(conn *sql.DB) (bool, error) {
|
||||||
|
var name string
|
||||||
|
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
|
||||||
|
// in a single pass, then records every migration as already applied. The schema
|
||||||
|
// is derived from the migrations themselves (replayed on a throwaway in-memory
|
||||||
|
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
|
||||||
|
// ALTER-added columns folded in) and translated to MySQL — so there's no second
|
||||||
|
// schema to maintain and the two backends can't drift. Future migrations apply
|
||||||
|
// incrementally on top.
|
||||||
|
func applyMySQLBaseline(conn *sql.DB) error {
|
||||||
|
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||||
|
}
|
||||||
|
defer mem.Close()
|
||||||
|
if err := migrate(mem, nil); err != nil {
|
||||||
|
return fmt.Errorf("build baseline schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migNames []string
|
||||||
|
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read baseline migrations: %w", err)
|
||||||
|
}
|
||||||
|
for mrows.Next() {
|
||||||
|
var n string
|
||||||
|
if err := mrows.Scan(&n); err != nil {
|
||||||
|
mrows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
migNames = append(migNames, n)
|
||||||
|
}
|
||||||
|
mrows.Close()
|
||||||
|
|
||||||
|
var tables, indexes []string
|
||||||
|
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read baseline schema: %w", err)
|
||||||
|
}
|
||||||
|
for srows.Next() {
|
||||||
|
var typ, s string
|
||||||
|
if err := srows.Scan(&typ, &s); err != nil {
|
||||||
|
srows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case "table":
|
||||||
|
tables = append(tables, s)
|
||||||
|
case "index":
|
||||||
|
indexes = append(indexes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srows.Close()
|
||||||
|
|
||||||
|
// Apply on one connection with FK checks off so table order doesn't matter.
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := conn.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire conn: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
||||||
|
return fmt.Errorf("disable fk checks: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
for _, tb := range tables {
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("baseline table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ix := range indexes {
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("baseline index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range migNames {
|
||||||
|
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("record migration %s: %w", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMySQLMigration executes a translated migration one statement at a time.
|
||||||
|
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
|
||||||
|
// so running statements individually (and tolerating "already exists" errors)
|
||||||
|
// keeps migrations idempotent and lets a half-applied database self-heal.
|
||||||
|
//
|
||||||
|
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
|
||||||
|
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
|
||||||
|
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
|
||||||
|
// connection returns to the pool, so runtime queries keep FK enforcement.
|
||||||
|
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := conn.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire conn: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
||||||
|
return fmt.Errorf("disable fk checks: %w", err)
|
||||||
|
}
|
||||||
|
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
|
||||||
|
if _, err := c.ExecContext(ctx, stmt); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
// A coalesced multi-column ALTER fails wholesale if even one
|
||||||
|
// column already exists (partial prior apply). Re-run it column
|
||||||
|
// by column so the missing ones still get added.
|
||||||
|
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
|
||||||
|
for _, one := range cols {
|
||||||
|
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
|
||||||
|
return fmt.Errorf("statement %q: %w", firstLine(one), e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
|
||||||
|
|
||||||
|
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
|
||||||
|
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
|
||||||
|
// column, but each is a full table operation in MySQL — and on a slow server
|
||||||
|
// that's seconds apiece, turning a ~50-column migration into minutes (long
|
||||||
|
// enough to trip connection lifetimes and stall startup). One combined ALTER is
|
||||||
|
// a single table rebuild instead of fifty.
|
||||||
|
func coalesceAddColumns(stmts []string) []string {
|
||||||
|
out := make([]string, 0, len(stmts))
|
||||||
|
for i := 0; i < len(stmts); {
|
||||||
|
m := reAddColumn.FindStringSubmatch(stmts[i])
|
||||||
|
if m == nil {
|
||||||
|
out = append(out, stmts[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
table := m[1]
|
||||||
|
cols := []string{strings.TrimSpace(m[2])}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(stmts) {
|
||||||
|
m2 := reAddColumn.FindStringSubmatch(stmts[j])
|
||||||
|
if m2 == nil || m2[1] != table {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cols = append(cols, strings.TrimSpace(m2[2]))
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
|
||||||
|
// one ALTER per column, for the idempotent fallback path.
|
||||||
|
func decomposeAddColumns(stmt string) []string {
|
||||||
|
m := reAddColumn.FindStringSubmatch(stmt)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
table := m[1]
|
||||||
|
var out []string
|
||||||
|
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
|
||||||
|
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStatements breaks a migration file into individual SQL statements,
|
||||||
|
// dropping comments (full-line and inline "-- …") and blank fragments. Our
|
||||||
|
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
|
||||||
|
func splitStatements(sqlText string) []string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range strings.Split(sqlText, "\n") {
|
||||||
|
if i := strings.Index(line, "--"); i >= 0 {
|
||||||
|
line = line[:i] // strip inline / full-line comment
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(b.String(), ";") {
|
||||||
|
if strings.TrimSpace(part) != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
|
||||||
|
// restricted user trying to CREATE DATABASE. Such users can still operate on a
|
||||||
|
// pre-existing database they've been granted, so the caller treats this as
|
||||||
|
// non-fatal and verifies database access separately.
|
||||||
|
func isAccessDenied(err error) bool {
|
||||||
|
var me *mysqldriver.MySQLError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch me.Number {
|
||||||
|
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
|
||||||
|
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
|
||||||
|
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIgnorableDDLError reports whether a MySQL error means the object the
|
||||||
|
// statement creates/drops is already in the intended state — safe to skip when
|
||||||
|
// re-applying a forward-only migration.
|
||||||
|
func isIgnorableDDLError(err error) bool {
|
||||||
|
var me *mysqldriver.MySQLError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch me.Number {
|
||||||
|
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
|
||||||
|
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
|
||||||
|
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
|
||||||
|
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
|
||||||
|
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLDDL_Translations(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
|
||||||
|
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
|
||||||
|
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
|
||||||
|
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
|
||||||
|
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
|
||||||
|
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
|
||||||
|
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
|
||||||
|
// The column-name/type whitespace collapses to a single space — harmless.
|
||||||
|
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
|
||||||
|
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
|
||||||
|
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
|
||||||
|
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
|
||||||
|
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
|
||||||
|
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
|
||||||
|
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := mysqlDDL(c.in); got != c.want {
|
||||||
|
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
|
||||||
|
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
|
||||||
|
got := mysqlDDL(in)
|
||||||
|
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
|
||||||
|
t.Errorf("key column not backticked/translated:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitStatements(t *testing.T) {
|
||||||
|
in := "-- a comment\n" +
|
||||||
|
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
|
||||||
|
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
|
||||||
|
"\n" +
|
||||||
|
"CREATE INDEX idx ON qso(a);\n"
|
||||||
|
got := splitStatements(in)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0], "ADD COLUMN a") ||
|
||||||
|
!strings.Contains(got[1], "ADD COLUMN b") ||
|
||||||
|
!strings.Contains(got[2], "CREATE INDEX") {
|
||||||
|
t.Errorf("unexpected split: %#v", got)
|
||||||
|
}
|
||||||
|
// No fragment should be a comment-only or blank statement.
|
||||||
|
for _, s := range got {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("empty statement in result: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every embedded migration must split into at least one runnable statement
|
||||||
|
// and never produce an empty fragment.
|
||||||
|
func TestSplitStatements_AllMigrations(t *testing.T) {
|
||||||
|
entries, _ := migrationsFS.ReadDir("migrations")
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||||
|
stmts := splitStatements(mysqlDDL(string(raw)))
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
t.Errorf("%s: produced no statements", e.Name())
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("%s: empty statement fragment", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
|
||||||
|
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
|
||||||
|
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
|
||||||
|
// clear of it. TEXT columns are off-page and don't count here.
|
||||||
|
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
|
||||||
|
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
|
||||||
|
varchars := 0
|
||||||
|
for _, name := range qsoMigrations {
|
||||||
|
raw, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
|
||||||
|
}
|
||||||
|
const bytesPerVarchar = 255 * 4 // utf8mb4
|
||||||
|
rowBytes := varchars * bytesPerVarchar
|
||||||
|
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
|
||||||
|
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
|
||||||
|
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
|
||||||
|
// fails if any SQLite-only construct survives — a fast guard against a new
|
||||||
|
// migration sneaking in a dialect-ism the translator doesn't cover.
|
||||||
|
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reInteger := regexp.MustCompile(`\bINTEGER\b`)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := mysqlDDL(string(raw))
|
||||||
|
if strings.Contains(out, "AUTOINCREMENT") {
|
||||||
|
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "strftime") {
|
||||||
|
t.Errorf("%s: strftime survived", e.Name())
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "IF NOT EXISTS idx") {
|
||||||
|
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
|
||||||
|
}
|
||||||
|
// Strip comment lines before checking for bare INTEGER (comments are
|
||||||
|
// prose and may legitimately mention the word).
|
||||||
|
var code strings.Builder
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code.WriteString(ln)
|
||||||
|
code.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if reInteger.MatchString(code.String()) {
|
||||||
|
t.Errorf("%s: bare INTEGER survived in code", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,36 +278,38 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
|
|||||||
|
|
||||||
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
||||||
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
|
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
|
||||||
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
|
// operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
|
||||||
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
|
// handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
|
||||||
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
|
// (the Scotland prefix) isn't mistaken for it.
|
||||||
var suffixModifiers = map[string]bool{
|
var suffixModifiers = map[string]bool{
|
||||||
"P": true, "M": true, "QRP": true, "A": true,
|
"P": true, "M": true, "QRP": true, "A": true,
|
||||||
"PM": true, "LH": true,
|
"PM": true, "LH": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
// normalizeCallsign uppercases, trims, and resolves the "active" call when the
|
||||||
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
|
// operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
|
||||||
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
|
// (maritime/aeronautical mobile) is stripped so the home entity still resolves
|
||||||
|
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
|
||||||
|
// should still show the operator's country.
|
||||||
func normalizeCallsign(s string) string {
|
func normalizeCallsign(s string) string {
|
||||||
s = strings.ToUpper(strings.TrimSpace(s))
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
if !strings.ContainsRune(s, '/') {
|
if !strings.ContainsRune(s, '/') {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, "/")
|
parts := strings.Split(s, "/")
|
||||||
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
|
|
||||||
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
|
|
||||||
for i, p := range parts {
|
|
||||||
if i > 0 && (p == "MM" || p == "AM") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keep := parts[:0]
|
keep := parts[:0]
|
||||||
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
|
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
|
||||||
for _, p := range parts {
|
for i, p := range parts {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
|
||||||
|
// the operator's home entity is kept, so the contact still resolves to a
|
||||||
|
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
|
||||||
|
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
|
||||||
|
if i > 0 && (p == "MM" || p == "AM") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if suffixModifiers[p] {
|
if suffixModifiers[p] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
User string
|
User string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
|
||||||
Encryption string // "ssl" | "starttls" | "none"
|
Encryption string // "ssl" | "starttls" | "none"
|
||||||
Auth bool // SMTP requires authorization (send username/password)
|
Auth bool // SMTP requires authorization (send username/password)
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,11 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
|
|||||||
if err := m.To(to); err != nil {
|
if err := m.To(to); err != nil {
|
||||||
return fmt.Errorf("bad recipient %q: %w", to, err)
|
return fmt.Errorf("bad recipient %q: %w", to, err)
|
||||||
}
|
}
|
||||||
|
if cfg.ReplyTo != "" {
|
||||||
|
if err := m.ReplyTo(cfg.ReplyTo); err != nil {
|
||||||
|
return fmt.Errorf("bad reply-to %q: %w", cfg.ReplyTo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Subject(subject)
|
m.Subject(subject)
|
||||||
m.SetBodyString(mail.TypeTextPlain, body)
|
m.SetBodyString(mail.TypeTextPlain, body)
|
||||||
if attachPath != "" {
|
if attachPath != "" {
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package extsvc
|
package extsvc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
|
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
|
||||||
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
|
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
|
||||||
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
||||||
|
|
||||||
|
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
|
||||||
|
// file in one multipart request and dedupes server-side, so a manual upload of
|
||||||
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
|
||||||
|
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
|
||||||
|
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
|
||||||
|
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
|
||||||
|
//
|
||||||
|
// Multipart form fields: email, password, callsign, api, clientident, and the
|
||||||
|
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
|
||||||
|
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
|
||||||
|
email := strings.TrimSpace(cfg.Email)
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
switch {
|
||||||
|
case email == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
||||||
|
case call == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
||||||
|
case strings.TrimSpace(adifDoc) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
|
||||||
|
}
|
||||||
|
api := strings.TrimSpace(cfg.APIKey)
|
||||||
|
if api == "" {
|
||||||
|
api = clublogAppAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("email", email)
|
||||||
|
_ = mw.WriteField("password", cfg.Password)
|
||||||
|
_ = mw.WriteField("callsign", call)
|
||||||
|
_ = mw.WriteField("api", api)
|
||||||
|
_ = mw.WriteField("clientident", "OpsLog")
|
||||||
|
fw, err := mw.CreateFormFile("file", "opslog.adi")
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(fw, adifDoc); err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
// style check. Club Log has no dedicated status endpoint, so we report the
|
// style check. Club Log has no dedicated status endpoint, so we report the
|
||||||
// fields look complete; a real failure surfaces on the first upload.
|
// fields look complete; a real failure surfaces on the first upload.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
||||||
@@ -112,10 +114,10 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
|
|||||||
direction = ?, name = ?, port = ?, service_type = ?,
|
direction = ?, name = ?, port = ?, service_type = ?,
|
||||||
multicast = ?, multicast_group = ?, destination_ip = ?,
|
multicast = ?, multicast_group = ?, destination_ip = ?,
|
||||||
enabled = ?, sort_order = ?,
|
enabled = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
c.Direction, c.Name, c.Port, c.ServiceType,
|
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||||
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID)
|
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update udp: %w", err)
|
return fmt.Errorf("update udp: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned by providers when a callsign is unknown.
|
// ErrNotFound is returned by providers when a callsign is unknown.
|
||||||
@@ -357,24 +359,29 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
return r, true
|
return r, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put upserts a lookup result.
|
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
|
||||||
|
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
|
||||||
func (c *Cache) Put(ctx context.Context, r Result) error {
|
func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
updateCols := []string{
|
||||||
|
"name", "qth", "address", "state", "cnty",
|
||||||
|
"country", "grid", "lat", "lon",
|
||||||
|
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
|
||||||
|
"source", "fetched_at",
|
||||||
|
}
|
||||||
|
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||||
|
// upsert syntax is used unconditionally.
|
||||||
|
sets := make([]string, len(updateCols))
|
||||||
|
for i, c := range updateCols {
|
||||||
|
sets[i] = c + " = excluded." + c
|
||||||
|
}
|
||||||
|
q := `
|
||||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||||
country, grid, lat, lon,
|
country, grid, lat, lon,
|
||||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
source, fetched_at)
|
source, fetched_at)
|
||||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,
|
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||||
ON CONFLICT(callsign) DO UPDATE SET
|
_, err := c.db.ExecContext(ctx, q,
|
||||||
name = excluded.name, qth = excluded.qth, address = excluded.address,
|
|
||||||
state = excluded.state, cnty = excluded.cnty,
|
|
||||||
country = excluded.country, grid = excluded.grid,
|
|
||||||
lat = excluded.lat, lon = excluded.lon,
|
|
||||||
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
|
|
||||||
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
|
|
||||||
image_url = excluded.image_url,
|
|
||||||
source = excluded.source, fetched_at = excluded.fetched_at`,
|
|
||||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||||
nullable(r.State), nullable(r.County),
|
nullable(r.State), nullable(r.County),
|
||||||
nullable(r.Country), nullable(r.Grid),
|
nullable(r.Country), nullable(r.Grid),
|
||||||
@@ -382,7 +389,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||||
nullable(r.ImageURL),
|
nullable(r.ImageURL),
|
||||||
r.Source,
|
r.Source, db.NowISO(),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Station is a radio / TRX line. The display Name is also what gets
|
// Station is a radio / TRX line. The display Name is also what gets
|
||||||
@@ -56,6 +58,59 @@ type Repo struct{ db *sql.DB }
|
|||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
|
// CopyProfile duplicates the entire stations→antennas→bands tree from one
|
||||||
|
// profile to another (used when a profile is duplicated). New ids are assigned;
|
||||||
|
// the band defaults and ordering are preserved.
|
||||||
|
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
|
||||||
|
tree, err := r.ListTree(ctx, srcProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
now := db.NowISO()
|
||||||
|
for _, st := range tree {
|
||||||
|
var pwr any
|
||||||
|
if st.TXPower != nil {
|
||||||
|
pwr = *st.TXPower
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy station: %w", err)
|
||||||
|
}
|
||||||
|
newStationID, _ := res.LastInsertId()
|
||||||
|
for _, ant := range st.Antennas {
|
||||||
|
ares, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy antenna: %w", err)
|
||||||
|
}
|
||||||
|
newAntID, _ := ares.LastInsertId()
|
||||||
|
for _, b := range ant.Bands {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
|
||||||
|
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
|
||||||
|
return fmt.Errorf("copy band: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// ListTree returns every station for the profile with its nested antennas
|
// ListTree returns every station for the profile with its nested antennas
|
||||||
// and bands. One round-trip per level — three queries total regardless of
|
// and bands. One round-trip per level — three queries total regardless of
|
||||||
// tree size, so the Settings panel stays snappy on big setups.
|
// tree size, so the Settings panel stays snappy on big setups.
|
||||||
@@ -190,8 +245,8 @@ func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
|
|||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE operating_stations
|
`UPDATE operating_stations
|
||||||
SET name = ?, tx_pwr = ?, sort_order = ?,
|
SET name = ?, tx_pwr = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
|
WHERE id = ?`, s.Name, pwr, s.SortOrder, db.NowISO(), s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update station: %w", err)
|
return fmt.Errorf("update station: %w", err)
|
||||||
}
|
}
|
||||||
@@ -230,8 +285,8 @@ func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
|
|||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`UPDATE operating_antennas
|
`UPDATE operating_antennas
|
||||||
SET name = ?, sort_order = ?,
|
SET name = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
|
WHERE id = ?`, a.Name, a.SortOrder, db.NowISO(), a.ID); err != nil {
|
||||||
return fmt.Errorf("update antenna: %w", err)
|
return fmt.Errorf("update antenna: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,27 @@
|
|||||||
// credentials (LoTW / Clublog / QRZ.com) so each callsign can export to
|
// credentials (LoTW / Clublog / QRZ.com) so each callsign can export to
|
||||||
// its own account.
|
// its own account.
|
||||||
package profile
|
package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
|
||||||
|
// the local SQLite file; "mysql" = the shared MySQL server described by the
|
||||||
|
// rest. Switching the active profile switches the live logbook to this.
|
||||||
|
type ProfileDB struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
// Profile is one operating configuration. A user typically keeps a few:
|
// Profile is one operating configuration. A user typically keeps a few:
|
||||||
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
@@ -21,28 +35,30 @@ type Profile struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
|
OpName string `json:"op_name"` // operator's personal name (e.g. "Greg")
|
||||||
OwnerCallsign string `json:"owner_callsign"`
|
OwnerCallsign string `json:"owner_callsign"`
|
||||||
MyGrid string `json:"my_grid"`
|
MyGrid string `json:"my_grid"`
|
||||||
MyCountry string `json:"my_country"`
|
MyCountry string `json:"my_country"`
|
||||||
MyState string `json:"my_state"`
|
MyState string `json:"my_state"`
|
||||||
MyCounty string `json:"my_cnty"`
|
MyCounty string `json:"my_cnty"`
|
||||||
MyStreet string `json:"my_street"`
|
MyStreet string `json:"my_street"`
|
||||||
MyCity string `json:"my_city"`
|
MyCity string `json:"my_city"`
|
||||||
MyPostalCode string `json:"my_postal_code"`
|
MyPostalCode string `json:"my_postal_code"`
|
||||||
MySOTARef string `json:"my_sota_ref"`
|
MySOTARef string `json:"my_sota_ref"`
|
||||||
MyPOTARef string `json:"my_pota_ref"`
|
MyPOTARef string `json:"my_pota_ref"`
|
||||||
MyRig string `json:"my_rig"`
|
MyRig string `json:"my_rig"`
|
||||||
MyAntenna string `json:"my_antenna"`
|
MyAntenna string `json:"my_antenna"`
|
||||||
MyDXCC *int `json:"my_dxcc,omitempty"`
|
MyDXCC *int `json:"my_dxcc,omitempty"`
|
||||||
MyCQZone *int `json:"my_cqz,omitempty"`
|
MyCQZone *int `json:"my_cqz,omitempty"`
|
||||||
MyITUZone *int `json:"my_ituz,omitempty"`
|
MyITUZone *int `json:"my_ituz,omitempty"`
|
||||||
MyLat *float64 `json:"my_lat,omitempty"`
|
MyLat *float64 `json:"my_lat,omitempty"`
|
||||||
MyLon *float64 `json:"my_lon,omitempty"`
|
MyLon *float64 `json:"my_lon,omitempty"`
|
||||||
TxPower *float64 `json:"tx_pwr,omitempty"`
|
TxPower *float64 `json:"tx_pwr,omitempty"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repo is a SQLite-backed profile store. All ops take a context so the
|
// Repo is a SQLite-backed profile store. All ops take a context so the
|
||||||
@@ -51,10 +67,10 @@ type Repo struct{ db *sql.DB }
|
|||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
||||||
is_active, sort_order, created_at, updated_at`
|
is_active, sort_order, db_config, created_at, updated_at`
|
||||||
|
|
||||||
// List returns every profile, active first then by sort_order/id.
|
// List returns every profile, active first then by sort_order/id.
|
||||||
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
|
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
|
||||||
@@ -100,12 +116,12 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
if p.ID == 0 {
|
if p.ID == 0 {
|
||||||
res, err := r.db.ExecContext(ctx, `
|
res, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO station_profiles
|
INSERT INTO station_profiles
|
||||||
(name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
(name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
||||||
is_active, sort_order, created_at, updated_at)
|
is_active, sort_order, created_at, updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
|
||||||
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
||||||
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
||||||
p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
|
p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
|
||||||
nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
|
nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
|
||||||
@@ -119,13 +135,13 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE station_profiles SET
|
UPDATE station_profiles SET
|
||||||
name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
|
name = ?, callsign = ?, operator = ?, op_name = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
|
||||||
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
||||||
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?,
|
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?,
|
||||||
my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
|
my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
|
||||||
sort_order = ?, updated_at = ?
|
sort_order = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
|
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry,
|
||||||
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
||||||
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
|
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
|
||||||
nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
|
nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
|
||||||
@@ -134,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDB updates only a profile's logbook database target, leaving the rest of
|
||||||
|
// the profile untouched (the general Save deliberately doesn't write db_config).
|
||||||
|
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
|
||||||
|
b, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
string(b), now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SetActive atomically switches the active profile. Clears the flag on all
|
// SetActive atomically switches the active profile. Clears the flag on all
|
||||||
// rows first to keep the "only one active" invariant from the schema doc.
|
// rows first to keep the "only one active" invariant from the schema doc.
|
||||||
func (r *Repo) SetActive(ctx context.Context, id int64) error {
|
func (r *Repo) SetActive(ctx context.Context, id int64) error {
|
||||||
@@ -200,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
|
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
|
||||||
src.ID = 0
|
src.ID = 0
|
||||||
src.Name = newName
|
src.Name = newName
|
||||||
src.IsActive = false
|
src.IsActive = false
|
||||||
@@ -207,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
|
|||||||
if err := r.Save(ctx, &src); err != nil {
|
if err := r.Save(ctx, &src); err != nil {
|
||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
|
if dbCfg.Backend != "" {
|
||||||
|
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
src.DB = dbCfg
|
||||||
|
}
|
||||||
return src, nil
|
return src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,23 +256,28 @@ type scannable interface {
|
|||||||
func scan(row scannable) (Profile, error) {
|
func scan(row scannable) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
var (
|
var (
|
||||||
callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty,
|
callsign, operator, opName, ownerCall, myGrid, myCountry, myState, myCnty,
|
||||||
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
||||||
myRig, myAntenna sql.NullString
|
myRig, myAntenna sql.NullString
|
||||||
myDXCC, myCQZ, myITUZ sql.NullInt64
|
myDXCC, myCQZ, myITUZ sql.NullInt64
|
||||||
myLat, myLon, txPwr sql.NullFloat64
|
myLat, myLon, txPwr sql.NullFloat64
|
||||||
isActive, sortOrder int
|
isActive, sortOrder int
|
||||||
createdAt, updatedAt string
|
dbConfig sql.NullString
|
||||||
|
createdAt, updatedAt string
|
||||||
)
|
)
|
||||||
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
||||||
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
||||||
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
|
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
|
||||||
&isActive, &sortOrder, &createdAt, &updatedAt)
|
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
|
if s := dbConfig.String; s != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &p.DB)
|
||||||
|
}
|
||||||
p.Callsign = callsign.String
|
p.Callsign = callsign.String
|
||||||
p.Operator = operator.String
|
p.Operator = operator.String
|
||||||
|
p.OpName = opName.String
|
||||||
p.OwnerCallsign = ownerCall.String
|
p.OwnerCallsign = ownerCall.String
|
||||||
p.MyGrid = myGrid.String
|
p.MyGrid = myGrid.String
|
||||||
p.MyCountry = myCountry.String
|
p.MyCountry = myCountry.String
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2013 Panayiotis Lipiridis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 640 480">
|
||||||
|
<path fill="#d0103a" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fedf00" d="M0 0h435.2v480H0z"/>
|
||||||
|
<path fill="#0018a8" d="M0 0h204.8v480H0z"/>
|
||||||
|
<path fill="#c7b37f" d="M300.4 136.6c7.7 0 10.9 6.6 18.6 6.6 4.7 0 7.5-1.5 11.7-3.9 2.9-1.6 4.7-2.5 8-2.5 3.4 0 5.5 1 7.3 4 1 1.6 1.8 4.9 1.3 6.7a40 40 0 0 1-2.7 8.3c-.7 1.6-1.3 2.5-1.3 4.2 0 4.1 5.6 5.5 9.4 5.6.8 0 7.7 0 12-4.2-2.3-.1-4.9-2-4.9-4.3 0-2.6 1.8-4.3 4.3-5.1.5-.1 1.3.3 1.7 0 .7-.3.4-1 1-1.4 1.2-1 2-1.6 3.6-1.6q1.3-.1 2.5.7.5.7 1 .8c1.2 0 1.8-.8 3-.8a5 5 0 0 1 2.3.6c.6.3.6 1.5 1.4 1.5.4 0 2.4-.9 3.5-.9 2.2 0 3.4.8 4.8 2.5.4.5.6 1.4 1 1.4a6 6 0 0 1 4.8 3c.3.4.7 1.4 1.1 1.5.6.3 1 .2 1.7.7a6 6 0 0 1 2.8 4.8q-.1 1.2-.5 2.2c-1.8 6.5-6.3 8.6-10.8 14.3-2 2.4-3.5 4.3-3.5 7.4 0 .7 1 2.1 1.3 2.7-.2-1.4.5-3.2 2-3.3a4 4 0 0 1 4 3.6l-.3 1.8a10 10 0 0 1 4-1.4h1.9c3.3 0 7 1.9 9.3 3.8a21 21 0 0 1 7.3 16.8c-.8 5.2-.3 14.8-13.8 18.6 2.5 1 4.2 3 4.2 5.2a4.5 4.5 0 0 1-4.4 4.7 4 4 0 0 1-3.5-1.4c-2.8 2.8-3.3 5.7-3.3 9.7 0 2.4.4 3.8 1.4 6s1.8 3.5 3.7 5.1q1.3-2.4 4-2.6 2.7-.1 3.9 2.2c.2.5 0 .9.3 1.4.3.6.8.7 1.1 1.3.5 1 0 1.8.5 2.7.3.7.9.8 1.2 1.4.4 1 .5 1.6.5 2.7 0 3-2.7 5.2-5.7 5.2-1 0-1.4-.4-2.3-.3 1.7 1.7 3 2.5 4.3 4.5a18 18 0 0 1 3 10.3 22 22 0 0 1-2.8 11.2 20 20 0 0 1-7 8.5 35 35 0 0 1-16 6.4 74 74 0 0 1-11 1.4l-14.1.8c-7.2.4-12.2 1.5-17.3 6.6 2.4 1.7 4 3.5 4 6.4q-.2 4.7-4.7 6.2c-.7.2-1.2 0-1.9.4s-.7 1.3-1.4 1.7a6 6 0 0 1-3.8 1 8 8 0 0 1-6.4-2.5c-2.2 1.8-3 3.4-5.5 4.9-.8.4-1.2 1-2.1 1-1.5 0-2.2-1-3.4-1.8a23 23 0 0 1-4.4-4c-2.3 1.3-3.6 2.4-6.3 2.4a7 7 0 0 1-4-1c-.6-.5-.8-1.2-1.5-1.6s-1.3-.3-2.1-.7c-3-1.3-5-3.5-5-6.8 0-2.9 1.8-4.7 4.4-6-5-5-10-5.8-17-6.2l-14-.8c-4.4-.3-6.8-.7-11-1.4-3.3-.5-5.2-.7-8.2-2.1-10.2-4.8-16.8-11.3-18-22.5-.2-1-.2-1.5-.2-2.5 0-5.8 2.3-9.4 6.4-13.5-1-.3-1.7 0-2.8-.3-2.5-1-4.4-2.7-4.4-5.5q-.1-1.4.5-2.6c.4-.6 1-.7 1.2-1.4.2-1 0-1.6.4-2.5.3-.5.8-.6 1-1.2 1-1.9 2-3.4 4.1-3.4q2.7.1 3.8 2.5c1.8-.8 2.2-2.1 3.2-3.7a16 16 0 0 0 1.4-13.3c-.4-1.5-.6-2.5-1.8-3.7q-1.4 1.4-3.4 1.4c-2.9 0-5-2.5-5-5.3a5 5 0 0 1 3-4.6c-1.6-1.4-3-1.5-4.7-2.6-2.6-1.6-3.5-3.4-5.2-6-1.2-1.6-1.5-2.8-2-4.7a19 19 0 0 1-1-7.8c.6-5 1.5-8 4.6-11.9 1.8-2.3 3-3.7 5.8-4.9 2.3-1 3.7-1.7 6.2-1.7l2 .1a7 7 0 0 1 2.8.8c.4.2 1.1.9 1.1.4s-.3-.8-.3-1.3c0-2 1.5-4 3.6-4 1.5 0 2.1 1.4 2.9 2.7q.6-1 .7-2.3c0-3.4-1.9-5.2-4-7.9-4.7-5.8-10.5-8.5-10.5-16q0-3.2 3-4.9c.5-.3 1.3 0 1.8-.3s.4-1 .7-1.4q.7-.9 1.6-1.6c1-1 2-.6 3.1-1.5q.8-.7 1.2-1.4c1.3-1.6 2.5-2.4 4.6-2.4q1.3-.1 2.5.4l1 .5q.5-.5 1.5-1.1a4 4 0 0 1 2.2-.6c1.1 0 1.8.6 3 .6l.8-.6c1-.7 1.5-1 2.7-1s1.8.3 2.8 1c1 .5 1 1.3 2 1.8l1.5.4c2.6.9 4.5 2.6 4.5 5.3q.1 2.1-1.4 3.5c-.9.7-1.7.6-2.8 1a16 16 0 0 0 11.3 3.5c4.2 0 9.3-1.7 9.3-5.9 0-2-1-3-1.8-4.8a19 19 0 0 1-2.1-8.5c0-2.8.3-4.5 1.9-6.7s3.6-2.9 6.5-2.9"/>
|
||||||
|
<g fill="none" stroke="#703d29">
|
||||||
|
<path stroke-linejoin="round" stroke-width=".7" d="M272.4 159a4 4 0 0 0 2.4 2.4c.8.3 2.7.2 3.8-1.4 1-1.2 1-2.8.6-4a5 5 0 0 0-1.7-2.2z"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M401 236.1c-1.2-2.9-4.3-1.6-4.4 0-.5 3.7 2.7 4.8 5 4.2a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.8-1.6-1.3-1.2q-1.2-.7-3.4-.6c-5.5 0-10.4 6.5-12 13.4-.6 2.2-1.3 7.3-.3 12a22 22 0 0 0 5.9 11.3 26 26 0 0 0 9.9 5.8 8 8 0 0 0 4 .1c3.2-.7 4.7-3.8 3-7-1.3-2.5-5.3-4-7.2-.6q-.3.5-.4 1.5c0 .9.4 2 1 2.4 1.5.9 3.8.6 3.7-2"/>
|
||||||
|
<path stroke-width=".8" d="M383.8 274a11 11 0 0 1 6.6-3.7q4.4-.4 8.2 2a19 19 0 0 1 10.8 17c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14-7.1 3-18 3.6-25.7 4-10.4.3-20 .7-25.5 7.6"/>
|
||||||
|
<g stroke-width=".7">
|
||||||
|
<path d="M386.4 285.7q-.4-1.5.8-3.3c1.2-1.6 3.7-2.1 6-1a7 7 0 0 1 2.5 2.2l1.1 1.6q1 1.7 1 2.5c2.5 7-1.4 14.5-6.5 17.6-4 2.4-8.7 3.4-14.4 4-2.5.4-4 .3-6.5.5h-16.8c-2.9.3-5 .4-7.6.8q-2.4.3-5.4 1-.9 0-1.8.4l-1.2.3q-5.5 1.6-9.8 4.2-1.3.7-2.5 1.7l-1.3 1.2c-2 2-3.9 4-4.4 6.7v1.6c0 1.8 1.4 4.3 5.4 5m5.5-170c.8 1.4 1.3 2.3.8 3.9q-.9 2.7-3.6 2.8c-4 0-6.3-4.8-4.5-7.8 3.2-5.3 9.3-2.3 15 .3-.3-1.3-.8-1.8-.7-3.5.1-4.2 3.2-6 4.5-10 .7-2.3 1-4.3-.7-6q-2.2-1.8-5.1-.6c-3.8 1.5-8.5 5.9-16.6 6-8.2-.1-12.8-4.5-16.7-6q-3-1.2-5.1.7c-1.7 1.6-1.4 3.6-.7 6 1.3 3.8 4.4 5.7 4.5 10 0 1.6-.4 2-.7 3.4 5.7-2.6 12-5.9 15-.3 1.7 3.2-.5 7.7-4.5 7.7q-2.7 0-3.6-2.7-.5-2.2.8-4"/>
|
||||||
|
<path stroke-linecap="round" d="M314.6 159.9a5 5 0 0 1 2.4 5c-.2 2.5-.8 3.1-2.8 4.5m2.4-3.8q0 2.2-2.3 3.1"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#c7b37f" stroke="none" d="m276.7 153.3.7.5.8.8.5 1 .2.8v1.9l-.2.8-.5.6-.6.6-.9.5-1 .2-1 .2-1-.5-.9-.6-.5-.8-.4-1v-.4z"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M275.2 157.2c-.3-1.7-2.2-2-3-1-1.1 1.5-.3 4 2 4.7a4 4 0 0 0 3.9-1.4c1-1.3.9-2.8.5-4a5 5 0 0 0-1.7-2.2c-2.7-2-7.1-1.6-8.6 2-1.8 4.4 2.2 7.8 6 10.3 4.6 3.2 10 3.8 14 3.8 9.2-.1 16.2-4.5 20.7-7q1.7-.9 2.7.2a2 2 0 0 1-.3 2.7"/>
|
||||||
|
<path stroke-width=".7" d="m248 281.2-2 .7-2 1.6-1 1.3-1.1 2-.5 1.5-.4 1.8-.2 1.4m19-10.1-.1 1.8-.3 1.2-1 2.2-1.3 1.8-1.5 1.2-1.1.5-1.6.4"/>
|
||||||
|
<path stroke-width=".8" d="M319.7 329.1c-.3 1.7-1.9 3.6-5.3 4.2l-.6.2"/>
|
||||||
|
<path stroke-width=".9" d="M404.2 276.2a18 18 0 0 1 5.6 13.5c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14a85 85 0 0 1-25.6 4c-10.3.3-19.8.7-25.4 7.3"/>
|
||||||
|
<path stroke-width=".6" d="M387.5 282.9c.8-1 3.5-2.4 5.8-1.1a6 6 0 0 1 2.3 2"/>
|
||||||
|
<path stroke-width=".9" d="m401.6 273.8 1.4.5a7 7 0 0 0 4 0c2.8-.8 4.6-3.4 3.2-6.9a6 6 0 0 0-1.8-2.1"/>
|
||||||
|
<path stroke-linecap="round" stroke-width=".7" d="M240.3 199.8c-2 1.1-3.3 1.4-4.8 3.1a28 28 0 0 0-2.6 6.8m46-51.7q-.1 2.7-3 3.2"/>
|
||||||
|
<path stroke-width=".6" d="M397.1 192a19 19 0 0 1 18.6 19.8c0 16-9.9 18.5-13.8 19.6"/>
|
||||||
|
<path stroke-width=".7" d="M398.4 192c8.1-.3 16.5 5.7 16.9 20.7.3 11.7-8 17-12 18"/>
|
||||||
|
<path stroke-width=".6" d="m393.8 248.4.1-1.6.6-2.5.7-2 .9-1.6 1-1.3m7.8-3.4v1.5l-.5 1-.7 1.1-.8.6-1.2.5h-1.1l-.8-.1m-14.3-52.8.3-1.7.8-1.6 1-1.5 1.6-2.2 1.4-1.4 2-2.2 2-1.9 1.1-1.3 1.5-1.9 1.4-2 .8-1.7.5-2.2.1-2.7-.2-.8m-12.3 128.2 1.6-.4 1.2-.6.7-.7.5-.8.3-1.2v-.9m-158.2-12.1h2.7l1.6-.6m5-36.5-.2 1.4-.4.6-.4.6-.7.5-.7.3-1 .1h-.6m9.9-15.5-.3 2.1-.5 1-.8 1.2-1.2.9-1.2.6-2.3.5m15.3-39.7-.5 1.3-.5 1-.8 1-1 1-1.2.5-1.1.3-.6-.1m.3-6.2v1"/>
|
||||||
|
<g stroke-width=".6">
|
||||||
|
<path stroke-linecap="round" d="M254.3 224a7 7 0 0 1-2.1 1.4m150.5 44.8.5.2c1.4.8 4.2-.2 3.4-2.4"/>
|
||||||
|
<path d="M397.8 239.6c1 1.3 2.9 1.7 4.4 1.3a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.9-1.6-1.3-1.5-.4-.2m6.4 34 .1-.7a4 4 0 0 0-1.3-3l-.8-.8m.4.5c0-1.8-1.5-3.2-3.4-3.5m-4.2 2.8-1.3-1a16 16 0 0 1-4.3-10.7c0-4.2 1.6-8.4 3.6-10M341.2 324l1.8-1.6 1.2-1 2.3-1.4 2.2-1 1.6-.5 3-.6 3.6-.6m-29.5 19.4a17 17 0 0 1-7.6 6.1 18 18 0 0 1-7.6-6.1"/>
|
||||||
|
<path stroke-linecap="round" d="M314.4 332.6a10 10 0 0 1-2.2 4.2"/>
|
||||||
|
<path d="m314.7 330.5-.4 2.2M312 337l-1 1-1.7.9-2 .6m-5.6-177.8c.3-.8.5-1.4.5-2.6-.1-4.2-3.2-6.1-4.5-10-.7-2.3-1-4.3.7-6q2.2-2 5-.6c4 1.5 8.6 5.8 16.7 6-8.1-.2-12.8-4.5-16.6-6-2-.8-3.8-1-5.3.5-1.7 1.6-1.2 3.8-.5 6.1 1.3 3.9 4.2 5.8 4.3 10q-.1 1.6-.5 2.6M320 148c8-.4 14.9-5.8 17.1-6.3 2-.4 3-.2 4.5 1.1-1.4-1.3-3-1.2-5-.5-3.8 1.5-8.4 5.8-16.6 6m79.6 112.9a16 16 0 0 1-6.2-12.4c0-4.1 1.7-8.4 3.6-10m-70 97.6c-1.3 2-4.3 5-7.6 6.2a18 18 0 0 1-7.6-6.2"/>
|
||||||
|
<path stroke-linecap="round" d="m306.7 163.7 2.3-1.3c1-.6 2.3-.5 2.9.2s.7 2-.2 2.8"/>
|
||||||
|
<path d="M294.7 169.3c5.5-1.2 10-3.6 13.4-5.5M340.3 328c.5.3.8 1 .8 1l.3.8c.3 1.5-.7 2.4-2 2.6-1.7.2-3-.8-3.5-2M294.4 169c5.5-1.1 10-3.6 13.4-5.5m97.6 106.9c-1 .4-1.6.3-3-.2l-1.8-1a21 21 0 0 1-8.4-9 19 19 0 0 1-1.7-4.6 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.7-15.3c1.1-1.6 2.1-2.5 4.2-2.6m-143.7-39.3a7 7 0 0 1 2.7 5.7c0 3.1-2.6 8.2-9 10a8 8 0 0 1-6.3-.8"/>
|
||||||
|
<path d="M256.3 205.6q1.6 1 1.6 3.3 0 1.6-1.9 3.7a12 12 0 0 1-8.8 4q-3 .1-6-1.7a9 9 0 0 1-3.8-5.4"/>
|
||||||
|
<path d="M256.2 212.3q1.8 1.8 1.7 4.6.1 3.9-3.7 7-.8.8-2 1.5m129.5-22.1v3.5m-.3-4.4v5m.3-15.8v6.6m-.3-8v8.9m-1.9 82a19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.8 4.1 25 25 0 0 1-6.6 2.2 33 33 0 0 1-6.8.9c-2.5 0-3.9 0-6.4-.2s-4-.6-6.7-.8c-2.2-.2-3.4-.4-5.6-.3a28 28 0 0 0-11 1.8c-2.6 1-5.7 3-6.3 3.8a22 22 0 0 0-6.4-3.8 22 22 0 0 0-5.1-1.4c-2.3-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.4.2a33 33 0 0 1-13.4-3 20 20 0 0 1-6.4-4.8m42.1 53.4 1.8-.2m30.3-2.4 1.8-.1 1.7-.7 1.2-.8 1.7-2 .3-.6.3-1.7v-.8m47-136.7c.7-2.6-.2-5.4-2.8-5.3m-132 46.5a8 8 0 0 1-3.5 4.7m3.6-46.7a7 7 0 0 1-3.6 4c-1.9.8-4 0-5.2-.8"/>
|
||||||
|
<path stroke-linecap="round" d="M243.8 202.4c1.5.8 3.1-.4 2.8-2.4a3 3 0 0 0-2.5-2.2"/>
|
||||||
|
<path d="M250.2 286.6q.3.5.8.8c.7.2 1.2.4 1.9-.5.8-1.1.3-2.8-.5-3.9a5 5 0 0 0-5.8-1q-1.2.6-2.6 2.2l-1.1 1.6q-1 1.7-1.1 2.4c-2 5.9.4 12 4.1 15.7"/>
|
||||||
|
<path stroke-linecap="round" d="m340.2 327.8.7.8.2.9c.3 1.5-.7 2.4-2 2.6-1.6.2-2.8-.8-3.3-2"/>
|
||||||
|
<path d="M389.4 154.8a7.4 7.4 0 0 1 6.3 7c0 4.4-1.5 6-3.8 9.2-2.5 3.4-10.7 9.6-10.7 16.7q-.2 6.4 4.3 8.4c2 1 4.3 0 5.4-1 2.6-2.4 1.5-6.5-1.2-7-3.2-.6-3.9 4.6-.7 4.3m17.9 69a4 4 0 0 0-3.6-3 3.7 3.7 0 0 0-3.7 3.7q0 1.6 1 2.6"/>
|
||||||
|
<path d="M383.9 195.1a7 7 0 0 0-2.7 5.7c0 3.1 2.6 8.2 9 10 2.4.7 4.8.6 6.2-.3m-156-10.3a9 9 0 0 0-4.8 3.5 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.2 1.2 1m64 92c4.9 2.1 8.4 3.7 11.4 8.5a10 10 0 0 1 1.2 4.9c0 2.7-1 5.7-3.3 7.6a8 8 0 0 1-6.7 2c-1.9-.2-3.7-1.6-4-2.6M254 224.1c2.7 2.2 3.9 4.2 3.9 7.5a8 8 0 0 1-4 7.5"/>
|
||||||
|
<path stroke-linecap="round" d="M251.5 236.4c4 5.1 6.3 8.1 6.4 14.1.1 5.7-1.7 9.6-5 13.7"/>
|
||||||
|
<path d="M329.8 169.3a4 4 0 0 0 1.5-2.2q.8-2.2-.2-4 1.3 2 .7 4c-.1 1-.8 1.5-1.6 2.3m51.5 86.1v16.2l-.1 2.5-.3 1.7"/>
|
||||||
|
<path d="M381.4 254v19.9l-.5 2.6m.5-43v14.6m.3-13.4v11.8m0-26.8v8.8m-.3-9.9v11m.3-19v3.5m-.3-4.2v5m-1.8 65.2-.4.7a19 19 0 0 1-4.1 5.7 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.2c-2.7.6-4.2.8-6.9.9-2.5 0-3.9 0-6.3-.2-2.7-.2-4.1-.5-6.8-.8-2.2-.2-3.4-.3-5.6-.3s-3.5 0-5.7.4a22 22 0 0 0-5.2 1.4c-2.7 1.1-5.7 3-6.4 3.8-.6-.8-3.7-2.7-6.3-3.8a22 22 0 0 0-5.2-1.4c-2.2-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.3.2a33 33 0 0 1-13.5-3 20 20 0 0 1-5.8-4.1l-2.5-2.8m-2-3.2a10 10 0 0 1-2.3 7.7c-.8.9-2.6 2.6-5 2.6-3.7 0-4.8-2.5-5-3.2"/>
|
||||||
|
<path d="M255.6 278.9q1 1 1.9 2.5c1 1.8.6 4.8-.1 6.2l-.3.4m-20.3 18q3.3 3.8 10.9 7.1c7.1 3 18.1 3.6 25.7 4 10 .3 19.3.7 25 7m17.3-4a12 12 0 0 1 4 5.5m-7.3 11.5-.7.7a8 8 0 0 1-6.6 2c-2-.2-3.8-1.6-4.3-2.6m-5.4-2.9.3.4a8 8 0 0 0 5.1 2.4m27 0a18 18 0 0 1-7.7 6.1 18 18 0 0 1-7.6-6.1l-.3-.5m15.6.4.7.7a8 8 0 0 0 6.7 2 6 6 0 0 0 4-2.5l.5-.7"/>
|
||||||
|
<path d="m339 336.6-.7 1.2-1.1 1-1.7.7h-1.6"/>
|
||||||
|
<path d="M343 325.3a8 8 0 0 1 2.4 2.9q.4 1 .5 2.3a6 6 0 0 1-1.5 4.2 8 8 0 0 1-5.4 2.4h-.4m.2-.2a7 7 0 0 1-5.2-2.2m63.7-67.9a24 24 0 0 1-4.8-6.4 19 19 0 0 1-1.7-4.5 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.6-15.3c.7-.8 1.4-1.8 2.1-2.2m-1.3-75.9c2.5.2 4.8 3 4.8 5.7 0 3.8-1.3 5.5-4.4 9.3-2.6 3.2-10.6 9-10.3 14.5q.1 1.6 1.1 2.8m-3.2 3.5a7 7 0 0 0 2 1.4 5 5 0 0 0 4.3-.3M369 153a6 6 0 0 1 2.2 2.6c1.8 4.5-2.2 7.9-6 10.4a21 21 0 0 1-8.3 3.3"/>
|
||||||
|
<path d="M364.6 161.6a4 4 0 0 1-3.1-1.5l-.7-1m-15 4.9-1.2-1q-1.7-1.4-.8-4.4c.6-1.9 3.7-7.2 3.8-10.9.2-5.6-2-9-5.3-10.2"/>
|
||||||
|
<path stroke-linecap="round" d="m347.3 146.5-.1 2-.6 2.2-1 3-1 1.9-.8 1.9-.4 1.3-.2 1 .1.9m38 126.3.6.8c.7 1 3.2 3 5.5 3 3.7 0 4.6-2.6 4.7-3.2.5-2.9-.5-3.6-2-4.5 0 0-.8-.4-1.9-.2"/>
|
||||||
|
<path d="M237 274.4a7 7 0 0 1-3.7 0c-2.9-.9-5.2-3.6-4-7m13.4-31.8q.4.5.4 1c.4 3.8-2.8 4.8-5 4.2a6 6 0 0 1-3-2.3 5 5 0 0 1-.7-2.3m22-23.6q.9.7 1.3 1.7m-1.1-8.5q.8.7 1.1 1.3"/>
|
||||||
|
<path stroke-linecap="round" d="M257.9 210.5a9 9 0 0 1-1.6 2.4 12 12 0 0 1-8.8 4q-3 .1-6-1.7a10 10 0 0 1-4-5.6"/>
|
||||||
|
<path d="M255.4 195.3a8 8 0 0 1 2.4 3.4"/>
|
||||||
|
<path stroke-linecap="round" d="M257.8 203.2c-.9 3-3.5 6.6-8.6 7.9-2.4.6-5.6-.2-6.6-1"/>
|
||||||
|
<path d="M240 202.6c.3 2.6 2 4.6 5.4 4.6 4.7.1 7.6-6.7 3.4-11.5"/>
|
||||||
|
<path stroke-linecap="round" d="M229.4 225.5q1 1.3 2.4 2.4a17 17 0 0 0 6 3.3m5.2.5c4.2-.5 6.6-3.7 6-7.3-.3-2.8-2.8-5-4.6-5.1"/>
|
||||||
|
<path d="M249.8 188.1c1.9 0 3 1.6 2.9 3"/>
|
||||||
|
<path stroke-linecap="round" d="M249.4 163a12 12 0 0 0 5 5.9m144.2 31c1.7 2.3.6 7-4 7a5 5 0 0 1-4.5-2.5"/>
|
||||||
|
<path d="M381.7 169.1V185"/>
|
||||||
|
<path stroke-linecap="round" d="M243.8 202.3c1.4 1 3.3-.7 2.5-2.6-.5-1.2-2.2-2.6-4.7-.9-2.8 1.9-2 7.8 3.2 7.9 4.7 0 7.6-6.8 3.4-11.6-4-4.6-11.3-3.6-16 .2A21 21 0 0 0 225 207a23 23 0 0 0 0 9.2 21 21 0 0 0 3 7.5l1.3 1.7c.8.8 1 1.2 2 2a15 15 0 0 0 10.4 3.7c4.6-.2 7.3-3.4 6.8-7.3-.4-3.8-4.2-5.7-6.7-3.9-1.7 1.2-2.3 4.9.7 5.8 1.6.5 3.1-1.7 2-3M374 150.9q4-2.2 6.3 1a10 10 0 0 1 1.6 7.2 9 9 0 0 1-3.5 5.8"/>
|
||||||
|
<path stroke-linecap="round" d="M380.5 152c3.1-2 6.5-1.1 8.3 1.6 1.3 2 1.7 3.6 1.6 6.1a11 11 0 0 1-5.7 9.2"/>
|
||||||
|
<path d="M395 159.2c2.6.2 4.6 2.5 4.6 5.1 0 3.8-1 5.5-4 9.3-2.7 3.3-10.6 9-10.4 14.6 0 2.1 1.8 4 3.3 4.2"/>
|
||||||
|
<path stroke-linecap="round" d="M395.4 202.3c-1.5 1-3.3-.6-2.5-2.4.5-1.2 2.2-2.8 4.7-1.1 2.7 1.9 2 7.8-3.3 7.9-4.7 0-8-6.6-3.4-11.6 4-4.6 11.7-3.7 16.5.1 2 1.6 6.1 6 7 12 1 7 .9 15.6-6.4 21-3 2.1-7 3.1-10.6 3-4.6-.2-7.3-3.5-6.8-7.4.5-3.8 4-5.4 6.7-3.9s2.3 5.4-.7 5.8c-1.7.2-3.1-1.7-2-3"/>
|
||||||
|
<path d="M392.9 199.9c.8-3.5 3.7-3.8 6.2-3.8 6.5.1 11.1 8 11.2 15.5 0 9.5-4 15.2-11 15.5-1.9 0-5-.8-5-3"/>
|
||||||
|
<path stroke-linecap="square" d="M397 198.3c6.9 1.6 9.3 7.8 9.3 13.8 0 4.9-.5 11.6-10 13.9"/>
|
||||||
|
<path d="M408.4 265.3a3.9 3.9 0 1 0-6.3 2.4"/>
|
||||||
|
<path stroke-linecap="round" d="M394.4 259.4c1.4 2 3 4.1 6.3 6m-1.3 10.5c-3.2-2.2-9.5-5-15-2.2a8 8 0 0 0-4.4 4.4 10 10 0 0 0 1.8 9.5c.9 1 2.7 2.6 5 2.7 3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.1-.7-.3-2.8-.2-3.2 1.3q-.3.9.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="M340.5 328.4c1 2.2-.2 3.2-1.6 3.4-2.2.3-3.3-1.4-3.4-3a4.4 4.4 0 0 1 4.3-4.7c2.3 0 4.1 1.5 5 3.5q.5 1 .5 2.4a6 6 0 0 1-1.4 4.1 8 8 0 0 1-5.4 2.5c-4.2.1-7.5-3.8-7.5-7.8 0-7.7 11.4-12 16-13a84 84 0 0 1 17.9-2.4c3.5-.1 6.2 0 10.1-.5 3.5-.3 5.4-.5 9-1.3a27 27 0 0 0 12.6-6.4c2.9-2.7 4.5-4.5 5.9-8.2a17 17 0 0 0-1.3-13.9 14 14 0 0 0-10.3-6.8c-3.7-.5-7 1.1-9 4.8-1 1.8-.6 4.8.1 6.2a6 6 0 0 0 4.8 3c3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.2-.7-.2-2.8-.1-3.2 1.4q-.3.9.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="M337.2 316.2c-4.8 2.1-8.4 3.7-11.4 8.5a10 10 0 0 0-1.2 4.9c0 2.7 1.1 5.7 3.3 7.6a8 8 0 0 0 6.7 2c2-.2 3.7-1.6 4-2.6"/>
|
||||||
|
<path d="M385.1 224.1c-2.3.8-3.9 4.2-3.9 7.5a8 8 0 0 0 4 7.5"/>
|
||||||
|
<path stroke-linecap="round" d="M387.6 236.4c-4 5.1-6.3 8.1-6.4 14.1 0 5.7 1.7 9.6 5.1 13.7"/>
|
||||||
|
<path d="m365.9 152 .3-.5c1.7-2.4 4.7-3.1 6.9-1.5 2.6 2 3.3 5.4 2.6 9q-.9 3.3-4 5.5"/>
|
||||||
|
<path stroke-linecap="round" d="M265.1 150.8q-3.9-1.9-6.3 1a9 9 0 0 0-1.6 7.2c.6 2.7 1.4 3.8 3.5 5.8"/>
|
||||||
|
<path d="M258.6 152a6 6 0 0 0-8.3 1.6 9 9 0 0 0-1.6 6.1c.2 4.2 2.8 7.6 5.8 9.2"/>
|
||||||
|
<path d="M249.7 154.8a7 7 0 0 0-6 6.6c0 4.5 1 6.3 3.5 9.6 2.5 3.4 10.7 9.6 10.7 16.7q.2 6.4-4.3 8.4c-2 1-4.3 0-5.4-1-2.6-2.4-1.5-6.5 1.2-7 3.3-.6 3.9 4.6.7 4.3"/>
|
||||||
|
<path d="M244 159.2c-2.5.2-5 2.3-5 5 0 3.8 1.5 5.6 4.6 9.4 2.6 3.3 10.1 9 9.9 14.5 0 2-1.5 4.6-2.9 4.3"/>
|
||||||
|
<path stroke-linecap="round" d="M238 236.1c1.3-2.9 4.4-1.6 4.6 0 .4 3.7-2.8 4.8-5.1 4.2a4 4 0 0 1-2.5-2 5 5 0 0 1-.4-3.7l.9-1.6 1.2-1.2q1.3-.7 3.4-.6c5.5 0 10.4 6.5 12 13.4.6 2.2 1.3 7.3.3 12a22 22 0 0 1-5.8 11.3 26 26 0 0 1-10 5.8 7 7 0 0 1-3.9.1c-2.8-.9-4.6-3.5-3.2-7 1.2-2.6 5.4-4 7.3-.6q.3.5.4 1.5c0 .9-.4 2-1 2.4-1.4.9-3.7.6-3.6-2"/>
|
||||||
|
<path d="M233.8 270.4c1 .4 1.6.3 2.9-.2l1.8-1c2.6-1.5 5.6-3.8 8.4-9.1a19 19 0 0 0 1.7-4.5q.4-1.6.6-3.3a26 26 0 0 0-4.8-15.3c-1.1-1.6-2-2.5-4.2-2.6m-9.5 31a3.9 3.9 0 1 1 6.3 2.3"/>
|
||||||
|
<path d="M232.2 261.4a4 4 0 0 1 3.7-3 3.7 3.7 0 0 1 3.6 3.7 4 4 0 0 1-1 2.6"/>
|
||||||
|
<path d="M239.4 261.3a16 16 0 0 0 6.2-12.4c0-4.1-1.6-8.4-3.6-10"/>
|
||||||
|
<path stroke-linecap="round" d="M244.7 259.4a17 17 0 0 1-6.3 6"/>
|
||||||
|
<path d="M254.6 273.7q-1.4-3.2-5.8-3.5-4.3-.3-8.2 1.9a19 19 0 0 0-10.8 17 25 25 0 0 0 2 9.5c.9 1.6 3 9 15.3 14a86 86 0 0 0 25.7 3.9c10.4.4 20 .8 25.6 7.6"/>
|
||||||
|
<path stroke-linecap="round" d="M239.7 275.9c3.3-2.2 9.5-5 15.1-2.2a8 8 0 0 1 4.3 4.4 10 10 0 0 1-1.8 9.5c-.9 1-2.7 2.6-5 2.7-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.4.9-.2 2"/>
|
||||||
|
<path d="M252.7 285.7q.6-1.6-.8-3.3a5 5 0 0 0-6-1q-1.1.6-2.4 2.2-.7.7-1.2 1.6-1 1.7-1 2.5c-2.5 7 1.5 14.4 6.5 17.6 4.4 2.8 8.8 3.6 14.4 4 2.5.3 4 .3 6.5.5h16.8c3 .3 5.1.4 7.6.8q2.5.3 5.4 1 .9 0 1.8.4l1.2.3q5.5 1.6 9.8 4.2 1.3.7 2.5 1.7l1.3 1.2c2 2 4 4 4.4 6.7v1.6c0 1.8-1.4 4.3-5.3 5"/>
|
||||||
|
<path d="M298.6 328.4c-1 2.2.2 3.2 1.6 3.4 2.2.3 3.3-1.4 3.5-3a4.4 4.4 0 0 0-4.4-4.7 6 6 0 0 0-5 3.5 7 7 0 0 0-.5 2.4 6 6 0 0 0 1.4 4.1 8 8 0 0 0 5.4 2.5c4.2.1 7.5-3.8 7.5-7.8 0-7.7-11.4-12-16-13a84 84 0 0 0-17.9-2.4c-3.5-.1-6.2 0-10.1-.5-3.5-.3-5.4-.5-9-1.3a27 27 0 0 1-12.5-6.4 17 17 0 0 1-4.7-22 14 14 0 0 1 10.3-6.9q5.9-.7 9 4.8c1 1.8.6 4.8-.1 6.2a6 6 0 0 1-4.8 3c-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.3.9-.2 2"/>
|
||||||
|
<path stroke-linecap="round" d="m273.3 152-.4-.5c-1.7-2.4-4.7-3.1-6.9-1.5-2.6 2-3.3 5.4-2.5 9a9 9 0 0 0 4 5.5"/>
|
||||||
|
<path d="M366.8 159.6c-4 4.4-8.1 5.8-14.1 6-2 0-5.5-.6-7.6-2.1-1.3-1-2.8-2.6-1.9-5.5.6-1.9 3.7-7.2 3.8-10.9.3-5.6-1.9-8.7-5.3-9.9-6.2-2.2-13 4-17 5.4-2.1.7-3.2.8-5.1.8-2 0-3-.1-5.2-.8-4-1.4-10.7-7.6-17-5.4-3.4 1.2-5.5 4.3-5.3 10 .1 3.6 3.2 9 3.8 10.8 1 2.9-.5 4.5-1.9 5.5-2 1.5-5.7 2.1-7.5 2-6-.1-10.1-1.5-14.1-5.9"/>
|
||||||
|
<path stroke-linecap="round" d="M297.3 314.4c.8.3.2-.2 5.3 2a22 22 0 0 1 11.3 8.9 11 11 0 0 1 .9 7.3"/>
|
||||||
|
<path d="M297.7 336a8 8 0 0 0 3.2.9c4.2.1 7.5-3.8 7.5-7.8 0-2.8-1.5-5.2-3.6-7"/>
|
||||||
|
<path stroke-linecap="round" d="M298.6 328.4c-1 2.3.4 3.5 1.8 3.7 2.2.2 3.4-1.4 3.6-3a5 5 0 0 0-2.2-4.2"/>
|
||||||
|
<path d="M390.1 154.8c3.2 0 6 3.6 6 7.2 0 4.3-2.2 6.9-3.9 8.8q-1.9 2.3-4.4 4.7"/>
|
||||||
|
<path stroke-linecap="round" d="M386.3 151.4a9 9 0 0 1 2.8 2.4c1.3 2 1.7 3.7 1.6 6.2-.2 4.2-3.2 7.1-6 9m-4.7-17.6.6.7c1.9 2.2 2 5.4 1.6 7.2a8 8 0 0 1-3.8 5.4m-5-14.4c2.6 2 3.4 5.4 2.5 9q-1 3.6-4.2 5.2m11.1 41.1c.3 1 .9 1.3 1.5 2a14 14 0 0 0 6.2 3.5q3.7.9 6.3-.9m-163 54q2 .1 3.3 2.3.3.4.4 1.5 0 1.5-1 2.2c-1.5 1-4 .5-4-2"/>
|
||||||
|
<path d="M241.5 231.3c5 1 9.7 6.9 11.2 13.3.6 2.3 1.3 7.3.3 12a22 22 0 0 1-6 11.4l-2.1 1.9-1 .7m-8-12.1c2 0 3.8 1.9 3.8 4a4 4 0 0 1-1 2.6"/>
|
||||||
|
<path d="M234.6 260.7c2.1 0 4.1 2 4.1 4.2a4 4 0 0 1-1.4 3"/>
|
||||||
|
<path stroke-linecap="round" d="M254 239.5a18 18 0 0 1 3.8 7.7m0 8.5a17 17 0 0 1-1.5 4 18 18 0 0 1-3.6 4.7"/>
|
||||||
|
<path d="M254.3 224.3q2.7 2.2 3.5 4.8"/>
|
||||||
|
<path stroke-linecap="round" d="M257.9 219.5a10 10 0 0 1-3.4 4.6m-9.2-17.2 2.2-.6 1.3-1 .8-1.1.7-1.8.3-1.5"/>
|
||||||
|
<path d="m241 199.3-2.5.8a9 9 0 0 0-3.5 3 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.4c1.4 1.3 2.6 2 4.6 1.7"/>
|
||||||
|
<path stroke-linecap="round" d="M253 189.8c-.3 1.3-1 2.9-3 2.7"/>
|
||||||
|
<path d="M245.7 198.5c-2-1.9-6-2.4-10.1.2L234 200l-1.4 1.6a18 18 0 0 0-2.4 5c-.7 3-.7 5.6-.6 6.3q0 1.5.3 2.7 1 4.2 2.3 6.2c.9 1.5 3 5 7.7 5.4 1.8.1 4.8-.7 5-3"/>
|
||||||
|
<path stroke-linecap="round" d="M363.8 157c.3-1.6 2.3-1.9 3-1 1.2 1.6.4 4.2-2 4.9a4 4 0 0 1-3.8-1.4c-1-1.3-.9-2.8-.5-4q.4-1.2 1.7-2.2c2.7-2 7.1-1.6 8.6 2 1.8 4.4-2.2 7.8-6 10.3-4.6 3.2-10 3.8-14 3.7-9.2 0-16.1-4.4-20.7-7q-1.7-.7-2.7.3a2 2 0 0 0 .3 2.7"/>
|
||||||
|
<path stroke-linecap="round" d="M365.6 155.5c1 0 1.2.4 1.5.8 1.2 1.5.3 4.1-2 4.9m17.8 51.5c-3.5 3.8-.2 10.3 2.4 11.8.9.7 1.3.3 2 .7"/>
|
||||||
|
<path d="M383.1 205.4q-1.5 1-1.6 3.3a5 5 0 0 0 1.4 4 14 14 0 0 0 9.3 3.7q3 .1 6-1.7a9 9 0 0 0 3.8-5.4m-20.8 61.8-.2 2.5a19 19 0 0 1-2 7 19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.3 44 44 0 0 1-13.2.6c-2.7-.2-4.1-.5-6.8-.8-2.2-.1-3.4-.3-5.6-.3a28 28 0 0 0-10.9 1.9c-2.7 1-5.7 3-6.4 3.8-.6-.9-3.7-2.8-6.3-3.8a22 22 0 0 0-5.2-1.5c-2.2-.4-3.5-.4-5.8-.4s-3.4.2-5.6.4c-2.6.2-4 .6-6.7.7-2.5.2-3.9.3-6.3.2a33 33 0 0 1-7-.8 25 25 0 0 1-6.5-2.2 20 20 0 0 1-5.8-4.1 19 19 0 0 1-4.2-5.7 19 19 0 0 1-2-6.9c-.2-1-.2-2.5-.2-2.5V169.3h123.2z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#c7b37f" stroke="#c7b37f">
|
||||||
|
<path stroke-width=".3" d="M248 285.6a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0zM232.5 268q.2-2.1 1.8-2.3c1.6-.2 1.7 1 1.7 2.3q-.2 2-1.7 2.2-1.6-.2-1.8-2.2z"/>
|
||||||
|
<path stroke="none" d="M241.3 223.6q.2-1.7 1.7-1.8 1.6.2 1.7 1.8c.1 1.6-.7 1.8-1.7 1.8s-1.7-.8-1.7-1.8M272 158c0-1 .5-2 1.4-2q1.5 0 1.8 1.6c0 1-.5 2-1.4 2q-1.4 0-1.8-1.6"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".6">
|
||||||
|
<path d="M239.3 234q-.6.1-.8.5-.4.3-.6.9l-.2 1.2m4.7 26.7 1-1 .6-1 .5-1 .7-1.3m-1.3 14-1.5.7-1.1.6-1.3.8-1.2 1m15-37.9-.8-.8-1-.8-.9-.8"/>
|
||||||
|
<path stroke-linecap="butt" d="m254.2 225-1.2.5-1.5.3"/>
|
||||||
|
<path d="m237.4 208.4.5 1.5q.3 1 .9 1.7a8 8 0 0 0 2.6 2.7l1.5.8m-1-5.8 1.3.6a7 7 0 0 0 3 .6l1.8-.1m7.2-40.7-2-1.2q-1.2-.7-2-1.5l-1.1-1.3-.8-1.3m7.5-4.6.6 1.7 1.4 2c1 1 1.7 1.3 2.8 2.2m1.4-6 .7 1.6.8 1.2 1.3 1.3q1 .7 2 1.1"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#703d29" stroke-width=".2" d="M333.3 151.6c0-1.7-1.7-1.8-2.4-1.8-1.8 0-2.3 1.1-4.6 2.3a12 12 0 0 1-6.7 2 12 12 0 0 1-6.7-2c-2.3-1.2-2.7-2.3-4.6-2.3a2.3 2.3 0 0 0-2.2 2.4v.9l.3.2q0-1 .5-1.7a2 2 0 0 1 1.6-.8c1.8 0 2.5 1.2 4.8 2.4 3 1.6 4.2 1.9 6.7 2a12 12 0 0 0 6.8-2c2.3-1.2 3-2.5 4.8-2.5q.9 0 1.3 1v.9l.2.1z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#703d29">
|
||||||
|
<path d="M264.4 294c.5-.5.9-.3 1-.6q0-.2-.3-.3l-.9-.2-.8-.4h-.5c-.1.4 1 .4.6 1.4l-.8 1.2-2.6 3-.2.1v-4.3l.1-1.8c.2-.4.8 0 .9-.4q0-.2-.3-.3t-1.1-.3l-1-.5h-.6l.1.3q.5.2.5 1v7.4q0 .7.2.7l.4-.3z"/>
|
||||||
|
<path d="M267.5 295.2c.3-1.1 1-.4 1-.8q.1-.2-.2-.3l-1.3-.4-1.2-.4h-.4c-.1.5 1.1.5.8 1.5l-1.7 5.5c-.3 1-1 .6-1.1 1v.1l1.2.4 1.6.5h.3c.2-.4-1.2-.3-.7-1.7zm3.7 1q.3-.7.9-.4 1.6.6 1 2.5c-.2.6-.4 1.2-2 .8q-.6-.1-.6-.5l.7-2.3zm-2.8 5c-.5 1.4-1.2.8-1.3 1.2q0 .2.3.3l1.6.4.8.3h.4c.1-.5-1-.3-.7-1.5l.6-2q0-.6.6-.3.8.1.8.8l.3 2q.1 1.4 1 2 1 .1 1.4-.4l-.2-.2h-.3s-.3 0-.3-.3l-.7-3.6q.1-.2.8-.3a2 2 0 0 0 1-1.3c.1-.5.4-2.2-1.8-2.9l-2.1-.5-1.2-.4h-.3c-.1.5 1.1.4.7 1.7zm8.4 2.5c-.4 1.4-1.4.5-1.5 1q0 .3.3.3l1.5.3 1.4.4q.4.2.6-.1c0-.3-1.3-.3-1-1.8l1.3-5.2q0-.8.6-.5l1 .2c1.1.3.5 1.5 1 1.6q.2-.1.2-.6l.1-1v-.4l-3.3-.7-3.2-.8q-.2 0-.2.2l-.5 1.5q-.2.3 0 .4c.5.1.5-1.5 1.7-1.2l.9.2q.6 0 .4.8zm12.7-3.3c.4-.6.8-.5.9-.7q0-.2-.4-.3h-.9l-.9-.3q-.3-.1-.4.1c-.1.4 1 .2.8 1.3q0 .3-.6 1.3l-2 3.3-.3.2v-.2l-.7-4-.1-1.8c0-.5.7-.2.7-.5q.1-.2-.4-.3l-1.1-.1q-.6 0-1-.3-.4-.1-.6.1l.1.2q.7.2.7.9l1.3 7.3.3.7.4-.3zm.6 6.8q0 .3.2.5l1.7.7c1.4.2 2.6-.7 2.8-2.2.3-1.5-.3-2.1-1.4-2.9-1.3-.9-1.8-1.1-1.7-2q.3-1 1.4-1c1.8.3 1.6 2.6 1.8 2.6q.4 0 .3-.4l.2-1.6v-.4h-.6c-.4 0-.7-.5-1.6-.7q-2-.1-2.5 2-.1 1.6 1.2 2.4c1.6 1.1 2.2 1.4 2 2.4q-.3 1.5-1.7 1.3c-1.2-.2-1.6-1.4-1.8-2.6q0-.3-.2-.3-.2.1-.2.5v1.7zm15.8-4.5c.3-.7.8-.6.8-.9q0-.2-.4-.2h-.9l-.9-.1q-.3 0-.4.2c0 .4 1 0 1 1.1q0 .3-.5 1.4l-1.8 3.5-.1.3-.1-.3-1.1-4-.3-1.6c0-.5.7-.3.7-.6q.1-.2-.4-.2h-1.2l-1-.2q-.4-.1-.6.1l.2.2q.6.2.7.8l2.1 7.1.4.7q.2.1.3-.4z"/>
|
||||||
|
<path d="M307.6 308.5c0 1.2-1 1-1 1.5q0 .2.3.1h2.2l.4-.1c0-.6-1.4.2-1.4-2v-4.2l.1-.1.2.1 5.1 6.3.3.1.2-.3v-6.7c0-1.3 1-1 1-1.3l-.3-.2h-2.3q-.2 0-.2.2c0 .4 1.3.2 1.3 1.3v4l-.1.4-.4-.3-4.2-5.3q-.1-.4-.4-.3h-1.8l-.2.1c0 .6 1.2-.2 1.2 2.1zM318 303c0-1.1.8-.7.8-1.1q.1-.2-.4-.2h-2.6s-.3 0-.3.2c0 .4 1.1 0 1.1 1.2v5.7c0 1.1-.8.8-.8 1.2l.2.2h2.8q.3 0 .3-.2c0-.4-1.2.2-1.2-1.3zm4.5 5.5c0 1.5-1.2 1-1.2 1.4q0 .3.4.2h3q.5 0 .5-.3c0-.3-1.4 0-1.4-1.4V303q-.1-.7.5-.6h1c1.2-.1.8 1.2 1.3 1.2q.2-.1.1-.6l-.1-1q0-.3-.2-.4l-3.3.1h-3.3l-.2.3-.1 1.6.1.4c.5 0 .2-1.6 1.4-1.6h.9q.5-.1.6.6v5.6zm6.3-2.2h-.4l.1-.5.7-2.2v-.2l.2.1 1 2.1.2.4q0 .3-.4.2zm1.8.5c.3 0 .3 0 .8 1l.2.8c0 .7-.7.6-.7 1q0 .2.4 0h1.2l1.3-.1q.4 0 .4-.2c0-.4-.6 0-1-.7l-3.4-7-.3-.4q-.2 0-.3.4L327 309c-.2.7-.8.7-.7 1h2.3q.4 0 .5-.3c.1-.3-1.2 0-1.3-.9l.2-1q.3-1 .6-.8l2.1-.2zm8.3-5c-.1-.8 0-.8 1.2-1 2-.2 1.4 1.3 2 1.2q.2-.1 0-.6l-.1-1.1q0-.2-.3-.2-1.4 0-2.4.3l-2.8.4q-.3 0-.3.2c.1.5 1.3 0 1.4 1l.7 5.5c.2 1.5-.7 1-.6 1.5h.2l1.4-.1 1.2-.1q.5 0 .5-.3c0-.3-1.2.1-1.4-1.2l-.2-1.7q-.2-1 .3-1h.8c1.1-.2 1 1.1 1.3 1q.4-.2.1-.5l-.3-2.1q-.1-.4-.2-.3c-.3 0-.1 1.1-1 1.2l-.7.1q-.6.2-.6-.5zm4 2.8c.4 2.3 2.1 3.7 4.2 3.3 3.4-.7 3.5-3.6 3.2-5.3-.5-2.5-2.3-3.7-4.4-3.3-2.5.5-3.5 2.7-3 5.3m1.1-1c-.3-1.6 0-3.4 1.7-3.7 1.4-.3 3 .8 3.4 3.4.3 2 0 3.6-1.8 4s-3-2-3.3-3.6zm8.3-4.1q0-.9.6-.9 1.6-.2 2.1 1.6c.2.7.3 1.4-1.3 1.8q-.5.1-.8-.2l-.5-2.3zm0 5.7c.4 1.4-.5 1.3-.5 1.6q.2.3.4.1l1.6-.4 1-.2q.3 0 .2-.2c0-.4-1 .3-1.3-1l-.5-2c0-.4-.2-.4.4-.5q.6-.3 1.1.3l1.3 1.6c.5.6 1 1.3 1.8 1.1q.9-.2 1-.9l-.2-.1-.3.1s-.3.1-.4 0l-2.4-2.9.5-.6q.4-.6.2-1.6c-.1-.5-.7-2.1-3-1.6l-2.1.6-1.2.2q-.3 0-.2.2c0 .5 1.1-.2 1.4 1zm8.7-2c.3 1.4-1 1.2-.9 1.6q.1.3.5.2l1.4-.5 1.5-.3q.5.1.4-.4c0-.3-1.3.4-1.7-1l-1.3-5.3q-.2-.7.3-.7l1-.2c1.1-.4 1.1 1 1.5.9s0-.5 0-.7l-.4-1s0-.3-.2-.2l-3.2.9-3.2.7v.3l.1 1.6q0 .4.3.4c.5-.1-.3-1.6 1-1.9l.8-.2q.6-.2.7.5zm5.5-7.3c-.3-1 .6-.9.4-1.3h-.3l-1.4.4-1.2.3s-.3 0-.3.2c.1.4 1.2-.2 1.5.8l1.6 5.6c.2 1-.6 1-.5 1.3q0 .2.2.1l1.1-.3 1.6-.4q.4 0 .3-.3c-.1-.3-1.1.5-1.5-.9zm2.3 2.7c.7 2.3 2.6 3.4 4.7 2.7 3.2-1.1 3-4.1 2.4-5.7-.8-2.4-2.8-3.3-4.8-2.7-2.4.9-3.2 3.2-2.3 5.7m1-1c-.6-1.7-.6-3.5 1.1-4 1.3-.5 3 .4 3.9 2.9.6 1.8.5 3.6-1.2 4.2-1.8.6-3.2-1.5-3.8-3.2zm7.6-5.5q-.2-.9.4-1 1.7-.3 2.4 1.4c.2.6.4 1.3-1.1 1.9q-.6.2-.8 0zm.8 5.6c.6 1.4-.4 1.4-.2 1.7q0 .3.4.1l1.5-.7.9-.2q.3-.1.2-.3c-.2-.4-1 .4-1.4-.8l-.8-1.9q-.4-.5.3-.7.6-.3 1.1.3l1.6 1.4c.5.5 1.1 1.1 2 .8.3-.2.9-.7.7-1l-.2-.1-.2.2h-.5l-2.8-2.5.4-.7a2 2 0 0 0 0-1.6c-.1-.6-1-2-3.1-1.2l-2 .9-1.2.4-.2.2c.2.4 1.1-.4 1.6.8l2 5z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fedf00" transform="matrix(.64 0 0 .64 0 16)">
|
||||||
|
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
|
||||||
|
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13 13 0 0 1-3.8 4q-3 1.9-6 1.6c-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6q-2 1.7-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
|
||||||
|
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
|
||||||
|
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6 6 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5q-3.5-.1-5 3c-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a9 9 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
|
||||||
|
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8s7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
|
||||||
|
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#d52b1e">
|
||||||
|
<path fill="#fedf00" d="M322.3 175.5h52.6V228h-52.6z"/>
|
||||||
|
<path d="M329.7 175.5h7.8V228h-7.8zm15 0h7.8V228h-7.8zm15 0h7.9V228h-7.9z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
|
||||||
|
<path fill="#fedf00" stroke="none" d="M264.3 273.5q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4z"/>
|
||||||
|
<path stroke-width=".3" d="m270.4 283.1 2.5 1.5 3.4 1.2v-52.2h-5.9zm29.2 2.4v-51.9h-5.8v52.8l5.8-.7zm11.7-51.9h-5.8v52.1l5.8 1zm-23.4 0V287s-3.8.2-5.8 0v-53.4z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(.64 0 0 .64 0 16)">
|
||||||
|
<path fill="#fedf00" d="M585.5 402.4a21 21 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26 26 0 0 1-13 7 52 52 0 0 1-16.6 1.6q-6.3-.4-10.3-1c-3.8-.6-6.7-.9-11-1h-6.2a83 83 0 0 0-18.3 4.2V340h82.2v58.5z"/>
|
||||||
|
<g id="ad-b">
|
||||||
|
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8q-.5.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1q-.1.5-.6 1t-1 1l-1.2 1-.3.1h-.6l-.8.8.3.6.8 1.4q.2.6.5.8.7.3 1.3.1l2 .5 1.5.8q.6.4 1.3.5h1.8v.3l2 1-.1.4q-.2.5-.1.8 1 2.9 1.5 3.2.8.4 1.1 1.5l-.3.3q-1 .8-1.7 1.8c-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4.8 2q.3 1 .3 2l1 .3.7-.6.6-1.2v-1q-.3-.2-.2-.7c0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9q-.1-.3.4-.7l2-1.8q.4.2 1 .1l1.3.4h1.6l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7q0 .6-.5 1t-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5.9-.1q.2-.3 0-.8 0-.8-.3-1.6l-.1-2.8q0-.8.2-1.5c.1-1 .4-1.4.6-2.2l.4-2.5a24 24 0 0 0 10.1-.6q1.2 1 2.7 1.6v1q0 .4.2.7l.3.3q.4 0 .7-.2t.2-.7v-.7h1.8v1.1q.2.4.5.4h.6q.3-.4.3-1v-.7l1-.4v.9l-.3.9c-.2.6-.5.8-.8 1.4q-.4.8-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2q0-.5.3-.8t.7-.4q.6 0 1-.2.4-.6.7-1.5a13 13 0 0 1 3-3.9l1.7-1.4q.4-.4.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9q0 1.1-.3 2.3 0 1-.5 2a4 4 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7q.6 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6q-.7-.9-.7-1.5v-5.7l.4-.2c1.2-.7 1.7-.9 2.4-2.5l.3-1.5v-1l-.4-1-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1l-3.8.2-2.2-.7c.9-.3 1.1-.5 1.5-1s.2-.7.6-1.1l.7-1-.9-.4h-1l-1.2.3-.8.6-2.2-1.2a9 9 0 0 0-3-.9zm2 11.8"/>
|
||||||
|
<g fill="none" stroke="#fedf00" stroke-linecap="round">
|
||||||
|
<path d="m568.8 359.5-.8.3q-1.2.5-2.6.5c-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6l-1.7.2.5-1s-1.2.3-2 .3l-1.6-.2 1-1-1.3-.2-1-.7 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3q1-.5 1.1-1.8 0-1.2-.8-1.8l-1.1-.4"/>
|
||||||
|
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6q-.7 0-1.3.3-.6.5-1 1.1.7.3 1.2.3t.8-.5q.4-.5.4-1.2z"/>
|
||||||
|
<path d="m536 363.8 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
|
||||||
|
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8 8 0 0 0 2.7-1.8"/>
|
||||||
|
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
|
||||||
|
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2q.4 0 .7.2.2 0 .3.3t.1.5q0 .3-.6.4-.3 0-.5-.3v-.4a1 1 0 0 1-.9 0z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#0065bd" d="m525.1 364.2-2-.9 1-.5.5-1.3q.1-1 .7-1.4t1.1-.1q.7.1.9.7 0 .8-.3 1.5l-.2 1.4q0 .6.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2l-.6-1.2-.3-1.2v-2q0-.5-.2-.9c0-.2-.4-.3-.3-.4h.4q.5 0 1 .4t.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9 9 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2q-1.2 2-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a9 9 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2h-2.3l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1v.4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8q-1-.6-.6-1.6l.6-.4q.2-.4 0-.8l-.1-1-.2-1q-.1-.8.4-1.6.5-.5.8-.6.2.3 0 .8 0 .6.3 1.2l.7 1.3.4 1.4-.2 1.2-.6.8-.8.4z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
|
||||||
|
</g>
|
||||||
|
<path fill="none" stroke="#703d29" stroke-width=".5" d="M264.1 175.5h52.6V228h-52.6zm58.2 0h52.6V228h-52.6zm-58 98q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4zm110.4 0a13 13 0 0 1-1.4 4.3c-1 1.5-.6 1.4-2.7 3.8a15 15 0 0 1-4 2.9c-1.3.7-2.3 1-4.4 1.6a33 33 0 0 1-10.6 1q-4-.3-6.5-.7l-7.2-.6H334q-2.2 0-4.7.6c-3.5.7-7 2-7 2v-54.8H375v37.5l-.2 2.4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 640 480">
|
||||||
|
<path fill="#00732f" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#000001" d="M0 320h640v160H0z"/>
|
||||||
|
<path fill="red" d="M0 0h220v480H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,81 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="#000001" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#090" d="M426.7 0H640v480H426.7z"/>
|
||||||
|
<path fill="#bf0000" d="M213.3 0h213.4v480H213.3z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(1 27.3)scale(1.06346)">
|
||||||
|
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
|
||||||
|
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
|
||||||
|
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2q-1.4-.1-1.9-1.3c-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4q-.6 1.3-2 1.6c-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
|
||||||
|
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
|
||||||
|
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6q0 3 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
|
||||||
|
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
|
||||||
|
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
|
||||||
|
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
|
||||||
|
<path d="M315.4 210.3h1v7.1h-1z"/>
|
||||||
|
<g id="af-a">
|
||||||
|
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7v5.8c-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
|
||||||
|
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6 6 0 0 0-2.4-1.8 5 5 0 0 1-2.4-1.5l-.8-1.5v-2l-.3.3c-2.3 1.6-4 5.9-4 8.7v2.3q.2.8.6 1.3l1.1.8 2.7.7a7 7 0 0 1 2.6 2 11 11 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
|
||||||
|
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2s3.7 3 4.5 4.8"/>
|
||||||
|
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
|
||||||
|
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
|
||||||
|
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3s-1 4.3-.6 6.1q.1 1 .5 1.5.3.5.6.5.5 0 .7-.3l.2-.8q-.2-3 .3-5.4a8 8 0 0 1 3-4.4q.4-.3.5-.7l-.3-.7q-.7-.5-1.5-.1m.2.4q.6-.2 1 .1l.1.2-.3.4a8 8 0 0 0-3.1 4.6 17 17 0 0 0-.3 5.6l-.2.6s0 .1-.2 0l-.4-.3-.4-1.2q-.4-2.9.7-6 1.1-2.9 3-4z"/>
|
||||||
|
<path fill="#bd6b00" stroke="none" d="M295.2 157.7q-2.3 1.2-3 4.2a14 14 0 0 0-.3 5.9q.5 2 1.6 2 .5.2.8-.3t.2-1q-.6-2.5-.3-5.1.4-2.6 2.2-4.1.5-.4.5-.8l-.2-.6q-.7-.5-1.5-.2m.2.5q.6-.2 1 0l.1.3-.3.4a7 7 0 0 0-2.4 4.4q-.4 2.8.2 5.2v.8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7q.8-2.9 2.8-3.8"/>
|
||||||
|
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17 17 0 0 0-4.9 6z"/>
|
||||||
|
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
|
||||||
|
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
|
||||||
|
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
|
||||||
|
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
|
||||||
|
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
|
||||||
|
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
|
||||||
|
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
|
||||||
|
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
|
||||||
|
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1s.5-4.6 2-5.2c1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a15 15 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7s-2.5-2.7-4-3.8a18 18 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
|
||||||
|
<path stroke="none" d="M220.6 183q-1.8-2 1-1.9c1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
|
||||||
|
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
|
||||||
|
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
|
||||||
|
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
|
||||||
|
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5q5.3 1 8.9 5.3a94 94 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
|
||||||
|
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
|
||||||
|
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
|
||||||
|
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
|
||||||
|
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
|
||||||
|
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
|
||||||
|
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
|
||||||
|
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
|
||||||
|
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
|
||||||
|
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
|
||||||
|
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
|
||||||
|
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
|
||||||
|
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
|
||||||
|
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
|
||||||
|
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
|
||||||
|
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
|
||||||
|
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
|
||||||
|
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
|
||||||
|
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
|
||||||
|
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
|
||||||
|
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
|
||||||
|
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
|
||||||
|
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5 5 0 0 1 0-3.4 8 8 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4s.7 2.8.2 3.7q-.7 2.2-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1s2.4.4 1.2 1.2c-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4s-2-.8-1.3.9c.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5q.6-2.9 0-5.3c-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a10 10 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a9 9 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
|
||||||
|
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1 1 0 0 1 .7-1.2l2.6-.8c1-.3 1.6.4 1.6.9v2q0 .9-.7.9-1.2 0-2.4.7-1 .5-1.5-.5zm10.6 0q0-1-.6-1.2a5 5 0 0 0-2.4-.4q-1.3 0-1.1.6v2.1c0 .8 0 .8.4 1q1.3-.1 2.5.6.9.4 1.1-.6z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
|
||||||
|
<g stroke="none">
|
||||||
|
<path d="M328.5 286.6q-.1 1.8 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a7 7 0 0 0-5-2.9zm3.8-21.7q-2-.7-4 1.4c-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6s1.5-3.4 2.4-3.5c2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a67 67 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
|
||||||
|
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2 2 0 0 0-1.5 0q-.8.2-1.3.7a116 116 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1q.8-.3 1.3-1l.1-.8-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4q-.5-.7-1.5-.5zm0 .5q.9 0 1.1.3c3 4.3 7.7 7 11.9 10.5l.4.7v.4q-.3.5-1 .7c-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4q6.4-4.9 11.8-10.4l1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3l1.5-.4z"/>
|
||||||
|
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a7 7 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7s-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8q-.6-.1-.4-.7zm4.3.3a8 8 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7q-.3.5.1.8 1 .3 2 0c.6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a34 34 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5s-3.2 0-4.8.6c-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2 1.5-1.8c.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3s2.4 0 2.4-1.2c0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3l-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47 47 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17 17 0 0 0-5-6.3"/>
|
||||||
|
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3q-1.8 3.8-7.5 3.2"/>
|
||||||
|
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1 1 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6q-.8 1.1-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4l-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
|
||||||
|
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a6 6 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
|
||||||
|
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3s-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3l-.1 1.6q-.4.4-1 .2c-.2-.2 0-.7-.6-1q-.4-.1-.8.2c-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2s-3-2.7-3.7-4c-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2zm4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a4 4 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a4 4 0 0 0-1.2 2q.1.5.5.5a3 3 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4s-1.5-1-1.7-.7c-1 1.3.8 1 .5 1.4q-.8 1.3-1.3 2.6c-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4s-1.9-5.9-2.7-8z"/>
|
||||||
|
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
|
||||||
|
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25 25 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3q1.5-.9 2.4-.4.5.3.2.7"/>
|
||||||
|
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2s-.9 2-1.7 2-2-1.5-1.3-1.5c2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7s-.5 1.7-1.3 1.6c-.4-.1.2-1.9-.2-2.4h-.7c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5-.2 2.3c0 .5 1 .2 1 .1 0-.8.2-2 0-2.3q-.5-.3-.8-.1"/>
|
||||||
|
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6q7.5.1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path fill="#fff" d="m311.3 295-.3 2.6h-.4l-.1-1.8-.5-1.6-.5-1.3-1-1.4.8-2.2a7 7 0 0 1 1.5 2.4 9 9 0 0 1 .5 3.2m7-4.2q0 1-.5 1.5-.3.5-1.3.7l.4 1.5v2l-.1 1.3h-.4l-.1-1.3-.2-1-.4-1-.7-1.4-1-1.7.6-2 1 1q.4.3 1 .3 1.2 0 1.2-1.3h.4v1.4m6.4 4.8-.5 2.1q-.6 0-.8-.7l-.4-1.3-.1-1.7-1 .2a2 2 0 0 1-1.3-.4 1 1 0 0 1-.5-1q0-1.4.7-2.3.8-1 1.5-1.1.7 0 1 .4l.3.9v2q0 1.3.3 1.9 0 .4.8 1m-2-3.5q0-.9-.8-.8l-.6.1q-.2.1-.2.3 0 .5 1 .5zm8.7 3-.3 2.6q-.8-.5-1.4-2l-1.3-4.1-1.8 5.5-.8.7v-2.5q.9-1 1-1.5l.8-1.7.5-2.7h.4l.9 2.7q.3 1 .9 1.6l1 1.4"/>
|
||||||
|
<path fill="#bf0000" d="M350.8 319.4q.6.6.7 1.2l.4 1.6-.8.1-1-1.5-1.1-1.2-1.7-1.5-2-1.7q-.6-.3-.6-.5l-.3-.8-.2-1.6 2.7 2.2 2.5 2.2zm-9.5-5.8-.2 2H338l.3-2zm8.4 8.9-7.6 2.3-1.3-2 6.5-2-.7-.8-.9-.6a1 1 0 0 1-.4 1l-1 .6a3 3 0 0 1-1.8 0 2 2 0 0 1-1.3-.7 4 4 0 0 1-.7-2.2q0-1.5.9-1.8 1.1-.3 3 .7a8 8 0 0 1 3 2.4zm-5.8-4-.8-.3h-.6l-.5.3v.6l.5.2h.6l.4-.3zm-8-1.6-.5 2-3.2-.3.5-2zm7.5 7.7-1.7.4H340l-1.5-.4q-.5.8-1.5 1.2l-1.6.6-1.2.3-1-2 1.1-.3 1.3-.4.9-.5-1-.5h-.9l-.2.3h-.5q-.8-1.2-.3-2c.5-.8.9-.8 2-1a7 7 0 0 1 2.6-.2q1.2.1 1.5.9.2.3.2.7l-.4 1.2h1.1l1.7-.3zm-8 1.8-1.6.3a3 3 0 0 1-2.2-.4 6 6 0 0 1-1.7-2.6l-.8-2.2a2 2 0 0 0-.8-1l-.9-.5.6-2.1q.9.4 1.4 1l1 1.7.5 1.5 1.1 2.2q.5.4 1 .3l1.7-.2zm-7-7.5-1 1.9-3-.7 1-1.9zm1.8 8.4-7.5.7-.4-2 6.2-.7-.6-.8-1-.6.5-2q1 .6 1.6 1.3.5.8.8 2.1zm-6 1-2.2-.2-1.7-.5-1.3.4h-3.7l-1.2-.3q-.4-.3-.8-1a4 4 0 0 1-1.5 1l-1.7.1h-1.7l.2-2.1h1.7q1.2.1 2.1-.4a2 2 0 0 0 1.3-1.8l.7.1-.1 1.3q0 .4.3.7.4.3 1 .3h1.5q1.5 0 2-.2.9-.2 1-1.1l.1-.4s.3 0 .5-.2l.5-.2v.7l-.3 1.1 2 .5q.1-.3-.1-.7l-.3-.6.1-.3.3-.2 1-.9.5 1v1zm-11.3-8.7-2 1.3-1.3-.9-1.4 1-1.9-1 1.8-1.3 1.5.8 1.5-1 1.8 1m-3 8.2-7.3-1.2.8-2 6.2 1q0-.6-.2-1l-.5-.8 1.6-1.7q.6.8.7 1.6t-.5 2.1zm-6.1-1-1.6-.3q-1.3-.3-1.5-1.2-.3-.9.8-2.8l1.2-2q.4-.7.3-1.2l-.3-.7 2.2-1.6q.4.8.3 1.4 0 .8-.7 1.8l-.8 1.4a6 6 0 0 0-.9 2.2q0 .6.5.7l1.6.4zm-3.8-8-2.5 1.1-1.8-1.7 2.6-1zm-1 6.6-1.6 1.4-1.7.6-2.4-.1-2.8-.7a8 8 0 0 1-3.4-2q-.9-1.2 0-2.2a7 7 0 0 1 2-1.6q1.1-.7 3.8-1.6l.4.5-2.8 1.2q-.8.4-1.3 1t.2 1.6a11 11 0 0 0 6.3 2.2q1.8 0 2.3-.7.4-.4.5-1l.2-1.6 2.5-1.5-.1 1.5a4 4 0 0 1-1 1.6z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="ag-a">
|
||||||
|
<path fill-opacity=".7" d="M-79.7 0H603v512H-79.7z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(74.7)scale(.9375)">
|
||||||
|
<path fill="#fff" d="M-79.7 0H603v512H-79.7z"/>
|
||||||
|
<path fill="#000001" d="M-79.6 0H603v204.8H-79.7z"/>
|
||||||
|
<path fill="#0072c6" d="M21.3 203.2h480v112h-480z"/>
|
||||||
|
<path fill="#ce1126" d="M603 .1V512H261.6L603 0zM-79.7.1V512h341.3L-79.7 0z"/>
|
||||||
|
<path fill="#fcd116" d="M440.4 203.3 364 184l64.9-49-79.7 11.4 41-69.5-70.7 41L332.3 37l-47.9 63.8-19.3-74-21.7 76.3-47.8-65 13.7 83.2L138.5 78l41 69.5-77.4-12.5 63.8 47.8L86 203.3z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 640 480">
|
||||||
|
<path fill="red" d="M0 0h640v480H0z"/>
|
||||||
|
<path id="al-a" fill="#000001" d="M272 93.3c-4.6 0-12.3 1.5-12.2 5-13-2.1-14.3 3.2-13.5 8q2-2.9 3.9-3.1 2.5-.3 5.4 1.4a22 22 0 0 1 4.8 4.1c-4.6 1.1-8.2.4-11.8-.2a17 17 0 0 1-5.7-2.4c-1.5-1-2-2-4.3-4.3-2.7-2.8-5.6-2-4.7 2.3 2.1 4 5.6 5.8 10 6.6 2.1.3 5.3 1 8.9 1s7.6-.5 9.8 0c-1.3.8-2.8 2.3-5.8 2.8s-7.5-1.8-10.3-2.4c.3 2.3 3.3 4.5 9.1 5.7 9.6 2 17.5 3.6 22.8 6.5a37 37 0 0 1 10.9 9.2c4.7 5.5 5 9.8 5.2 10.8 1 8.8-2.1 13.8-7.9 15.4-2.8.7-8-.7-9.8-2.9-2-2.2-3.7-6-3.2-12 .5-2.2 3.1-8.3.9-9.5a274 274 0 0 0-32.3-15.1c-2.5-1-4.5 2.4-5.3 3.8a50 50 0 0 1-36-23.7c-4.2-7.6-11.3 0-10.1 7.3 1.9 8 8 13.8 15.4 18s17 8.2 26.5 8c5.2 1 5.1 7.6-1 8.9-12.1 0-21.8-.2-30.9-9-6.9-6.3-10.7 1.2-8.8 5.4 3.4 13.1 22.1 16.8 41 12.6 7.4-1.2 3 6.6 1 6.7-8 5.7-22.1 11.2-34.6 0-5.7-4.4-9.6-.8-7.4 5.5 5.5 16.5 26.7 13 41.2 5 3.7-2.1 7.1 2.7 2.6 6.4-18.1 12.6-27.1 12.8-35.3 8-10.2-4.1-11 7.2-5 11 6.7 4 23.8 1 36.4-7 5.4-4 5.6 2.3 2.2 4.8-14.9 12.9-20.8 16.3-36.3 14.2-7.7-.6-7.6 8.9-1.6 12.6 8.3 5.1 24.5-3.3 37-13.8 5.3-2.8 6.2 1.8 3.6 7.3a54 54 0 0 1-21.8 18c-7 2.7-13.6 2.3-18.3.7-5.8-2-6.5 4-3.3 9.4 1.9 3.3 9.8 4.3 18.4 1.3s17.8-10.2 24.1-18.5c5.5-4.9 4.9 1.6 2.3 6.2-12.6 20-24.2 27.4-39.5 26.2-6.7-1.2-8.3 4-4 9 7.6 6.2 17 6 25.4-.2 7.3-7 21.4-22.4 28.8-30.6 5.2-4.1 6.9 0 5.3 8.4-1.4 4.8-4.8 10-14.3 13.6-6.5 3.7-1.6 8.8 3.2 9 2.7 0 8.1-3.2 12.3-7.8 5.4-6.2 5.8-10.3 8.8-19.9 2.8-4.6 7.9-2.4 7.9 2.4-2.5 9.6-4.5 11.3-9.5 15.2-4.7 4.5 3.3 6 6 4.1 7.8-5.2 10.6-12 13.2-18.2 2-4.4 7.4-2.3 4.8 5-6 17.4-16 24.2-33.3 27.8-1.7.3-2.8 1.3-2.2 3.3l7 7c-10.7 3.2-19.4 5-30.2 8l-14.8-9.8c-1.3-3.2-2-8.2-9.8-4.7-5.2-2.4-7.7-1.5-10.6 1 4.2 0 6 1.2 7.7 3.1 2.2 5.7 7.2 6.3 12.3 4.7 3.3 2.7 5 4.9 8.4 7.7l-16.7-.5c-6-6.3-10.6-6-14.8-1-3.3.5-4.6.5-6.8 4.4 3.4-1.4 5.6-1.8 7.1-.3 6.3 3.7 10.4 2.9 13.5 0l17.5 1.1c-2.2 2-5.2 3-7.5 4.8-9-2.6-13.8 1-15.4 8.3a17 17 0 0 0-1.2 9.3q1.1-4.6 4.9-7c8 2 11-1.3 11.5-6.1 4-3.2 9.8-3.9 13.7-7.1 4.6 1.4 6.8 2.3 11.4 3.8q2.4 7.5 11.3 5.6c7 .2 5.8 3.2 6.4 5.5 2-3.3 1.9-6.6-2.5-9.6-1.6-4.3-5.2-6.3-9.8-3.8-4.4-1.2-5.5-3-9.9-4.3 11-3.5 18.8-4.3 29.8-7.8l7.7 6.8q2.3 1.5 3.8 0c6.9-10 10-18.7 16.3-25.3 2.5-2.8 5.6-6.4 9-7.3 1.7-.5 3.8-.2 5.2 1.3 1.3 1.4 2.4 4.1 2 8.2-.7 5.7-2.1 7.6-3.7 11s-3.6 5.6-5.7 8.3c-4 5.3-9.4 8.4-12.6 10.5-6.4 4.1-9 2.3-14 2-6.4.7-8 3.8-2.8 8.1 4.8 2.6 9.2 2.9 12.8 2.2 3-.6 6.6-4.5 9.2-6.6 2.8-3.3 7.6.6 4.3 4.5-5.9 7-11.7 11.6-19 11.5-7.7 1-6.2 5.3-1.2 7.4 9.2 3.7 17.4-3.3 21.6-8 3.2-3.5 5.5-3.6 5 1.9-3.3 9.9-7.6 13.7-14.8 14.2-5.8-.6-5.9 4-1.6 7 9.6 6.6 16.6-4.8 19.9-11.6 2.3-6.2 5.9-3.3 6.3 1.8 0 6.9-3 12.4-11.3 19.4 6.3 10.1 13.7 20.4 20 30.5l19.2-214L320 139c-2-1.8-8.8-9.8-10.5-11-.7-.6-1-1-.1-1.4s3-.8 4.5-1c-4-4.1-7.6-5.4-15.3-7.6 1.9-.8 3.7-.4 9.3-.6a30 30 0 0 0-13.5-10.2c4.2-3 5-3.2 9.2-6.7a86 86 0 0 1-19.5-3.8 37 37 0 0 0-12-3.4zm.8 8.4c3.8 0 6.1 1.3 6.1 2.9s-2.3 2.9-6.1 2.9-6.2-1.5-6.2-3c0-1.6 2.4-2.8 6.2-2.8"/>
|
||||||
|
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 640 0)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 640 480">
|
||||||
|
<path fill="#d90012" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#0033a0" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#f2a800" d="M0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 640 480">
|
||||||
|
<path fill="#74acdf" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<g id="ar-c" transform="translate(-64)scale(.96)">
|
||||||
|
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23s3.8 13.2 5 16.5c1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8s-4.2-6-3.4-16.3-4.2-12.7-3-22"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||||
|
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||||
|
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||||
|
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 320 240)"/>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 320 240)"/>
|
||||||
|
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 320 240)"/>
|
||||||
|
<circle cx="320" cy="240" r="26.7" fill="#f6b40e" stroke="#85340a" stroke-width="1.4"/>
|
||||||
|
<path id="ar-h" fill="#843511" stroke-width="1" d="M329 234.3c-1.7 0-3.5.8-4.5 2.4 2 1.9 6.6 2 9.7-.2a7 7 0 0 0-5.1-2.2zm0 .4c1.8 0 3.5.8 3.7 1.6-2 2.3-5.3 2-7.4.4q1.6-2 3.8-2z"/>
|
||||||
|
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(18.1)"/>
|
||||||
|
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
|
||||||
|
<path fill="#85340a" d="M316 243.7a1.8 1.8 0 1 0 1.8 2.9 4 4 0 0 0 2.2.6h.2q1 0 2.3-.6.5.7 1.5.7a1.8 1.8 0 0 0 .3-3.6q.8.3.8 1.2a1.2 1.2 0 0 1-2.4 0 3 3 0 0 1-2.6 1.7 3 3 0 0 1-2.5-1.7q-.1 1.1-1.3 1.2-1-.1-1.2-1.2c-.2-1.1.3-1 .8-1.2zm2 5.4c-2.1 0-3 2-4.8 3.1 1-.4 1.8-1.2 3.3-2s2.6.2 3.5.2 2-1 3.5-.2l3.3 2c-1.9-1.2-2.7-3-4.8-3q-.7 0-2 .6z"/>
|
||||||
|
<path fill="#85340a" d="M317.2 251.6q-1.1 0-3.4.6c3.7-.8 4.5.5 6.2.5 1.6 0 2.5-1.3 6.1-.5-4-1.2-4.9-.4-6.1-.4-.8 0-1.4-.3-2.8-.2"/>
|
||||||
|
<path fill="#85340a" d="M314 252.2h-.8c4.3.5 2.3 3 6.8 3s2.5-2.5 6.8-3c-4.5-.4-3.1 2.3-6.8 2.3-3.5 0-2.4-2.3-6-2.3"/>
|
||||||
|
<path fill="#85340a" d="M323.7 258.9a3.7 3.7 0 0 0-7.4 0 3.8 3.8 0 0 1 7.4 0"/>
|
||||||
|
<path id="ar-e" fill="#85340a" stroke-width="1" d="M303.4 234.3c4.7-4.1 10.7-4.8 14-1.7a8 8 0 0 1 1.5 3.4q.6 3.6-2.1 7.5l.8.4q2.4-4.7 1.6-9.4l-.6-2.3c-4.5-3.7-10.7-4-15.2 2z"/>
|
||||||
|
<path id="ar-d" fill="#85340a" stroke-width="1" d="M310.8 233c2.7 0 3.3.6 4.5 1.7 1.2 1 1.9.8 2 1 .3.2 0 .8-.3.6q-.7-.2-2.5-1.6c-1.8-1.4-2.5-1-3.7-1-3.7 0-5.7 3-6.1 2.8-.5-.2 2-3.5 6.1-3.5"/>
|
||||||
|
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-18.4)"/>
|
||||||
|
<circle id="ar-f" cx="310.9" cy="236.3" r="1.8" fill="#85340a" stroke-width="1"/>
|
||||||
|
<path id="ar-g" fill="#85340a" stroke-width="1" d="M305.9 237.5c3.5 2.7 7 2.5 9 1.3 2-1.3 2-1.7 1.6-1.7s-.8.4-2.4 1.3c-1.7.8-4.1.8-8.2-.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-at" viewBox="0 0 640 480">
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#c8102e" d="M0 0h640v160H0zm0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 195 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
|
||||||
|
<path fill="#00008B" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||||
|
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||||
|
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||||
|
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||||
|
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ax" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="ax-a">
|
||||||
|
<path fill-opacity=".7" d="M106.3 0h1133.3v850H106.3z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#ax-a)" transform="matrix(.56472 0 0 .56482 -60 -.1)">
|
||||||
|
<path fill="#0053a5" d="M0 0h1300v850H0z"/>
|
||||||
|
<g fill="#ffce00">
|
||||||
|
<path d="M400 0h250v850H400z"/>
|
||||||
|
<path d="M0 300h1300v250H0z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#d21034">
|
||||||
|
<path d="M475 0h100v850H475z"/>
|
||||||
|
<path d="M0 375h1300v100H0z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-az" viewBox="0 0 640 480">
|
||||||
|
<path fill="#3f9c35" d="M.1 0h640v480H.1z"/>
|
||||||
|
<path fill="#ed2939" d="M.1 0h640v320H.1z"/>
|
||||||
|
<path fill="#00b9e4" d="M.1 0h640v160H.1z"/>
|
||||||
|
<circle cx="304" cy="240" r="72" fill="#fff"/>
|
||||||
|
<circle cx="320" cy="240" r="60" fill="#ed2939"/>
|
||||||
|
<path fill="#fff" d="m384 200 7.7 21.5 20.6-9.8-9.8 20.7L424 240l-21.5 7.7 9.8 20.6-20.6-9.8L384 280l-7.7-21.5-20.6 9.8 9.8-20.6L344 240l21.5-7.7-9.8-20.6 20.6 9.8z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ba" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="ba-a">
|
||||||
|
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g fill-rule="evenodd" clip-path="url(#ba-a)" transform="translate(80)scale(.9375)">
|
||||||
|
<path fill="#009" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||||
|
<path fill="#FC0" d="m56.5 0 511 512.3V.3z"/>
|
||||||
|
<path fill="#FFF" d="M439.9 481.5 412 461.2l-28.6 20.2 10.8-33.2-28.2-20.5h35l10.8-33.2 10.7 33.3h35l-28 20.7zm81.3 10.4-35-.1-10.7-33.3-10.8 33.2h-35l28.2 20.5-10.8 33.2 28.6-20.2 28 20.3-10.5-33zM365.6 384.7l28-20.7-35-.1-10.7-33.2-10.8 33.2-35-.1 28.2 20.5-10.8 33.3 28.6-20.3 28 20.4zm-64.3-64.5 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9l28.2 20.5-10.8 33.2 28.6-20.2 27.9 20.3zm-63.7-63.6 28-20.7h-35L220 202.5l-10.8 33.2h-35l28.2 20.4-10.8 33.3 28.6-20.3 28 20.4-10.5-33zm-64.4-64.3 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9L138 192l-10.8 33.2 28.6-20.2 27.9 20.3-10.4-33zm-63.6-63.9 27.9-20.7h-35L91.9 74.3 81 107.6H46L74.4 128l-10.9 33.2L92.1 141l27.8 20.4zm-64-64 27.9-20.7h-35L27.9 10.3 17 43.6h-35L10.4 64l-11 33.3L28.1 77l27.8 20.4zm-64-64L9.4-20.3h-35l-10.7-33.3L-47-20.4h-35L-53.7 0l-10.8 33.2L-35.9 13l27.8 20.4z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-bb" viewBox="0 0 640 480">
|
||||||
|
<path fill="#00267f" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#ffc726" d="M213.3 0h213.4v480H213.3z"/>
|
||||||
|
<path id="bb-a" fill="#000001" d="M319.8 135.5c-7 19-14 38.6-29.2 53.7 4.7-1.6 13-3 18.2-2.8v79.5l-22.4 3.3c-.8 0-1-1.3-1-3-2.2-24.7-8-45.5-14.8-67-.5-2.9-9-14-2.4-12 .8 0 9.5 3.6 8.2 1.9a85 85 0 0 0-46.4-24c-1.5-.3-2.4.5-1 2.2 22.4 34.6 41.3 75.5 41.1 124 8.8 0 30-5.2 38.7-5.2v56.1H320l2.5-156.7z"/>
|
||||||
|
<use xlink:href="#bb-a" width="100%" height="100%" transform="matrix(-1 0 0 1 639.5 0)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 628 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bd" viewBox="0 0 640 480">
|
||||||
|
<path fill="#006a4e" d="M0 0h640v480H0z"/>
|
||||||
|
<circle cx="280" cy="240" r="160" fill="#f42a41"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-be" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="#000001" d="M0 0h213.3v480H0z"/>
|
||||||
|
<path fill="#ffd90c" d="M213.3 0h213.4v480H213.3z"/>
|
||||||
|
<path fill="#f31830" d="M426.7 0H640v480H426.7z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bg" viewBox="0 0 640 480">
|
||||||
|
<path fill="#fff" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#00966e" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#d62612" d="M0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bh" viewBox="0 0 640 480">
|
||||||
|
<path fill="#fff" d="M0 0h640v480H0"/>
|
||||||
|
<path fill="#ce1126" d="M640 0H96l110.7 48L96 96l110.7 48L96 192l110.7 48L96 288l110.7 48L96 384l110.7 48L96 480h544"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bj" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="bj-a">
|
||||||
|
<path fill="gray" d="M67.6-154h666v666h-666z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#bj-a)" transform="matrix(.961 0 0 .7207 -65 111)">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="#319400" d="M0-154h333v666H0z"/>
|
||||||
|
<path fill="#ffd600" d="M333-154h666v333H333z"/>
|
||||||
|
<path fill="#de2110" d="M333 179h666v333H333z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,97 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bm" viewBox="0 0 640 480">
|
||||||
|
<path fill="#cf142b" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M559.7 130.6v161.6c0 43.1-86.2 57.2-86.2 57.2s-86.4-14-86.4-57.4V130.6h172.5z"/>
|
||||||
|
<path fill="#2f8f22" d="M559.7 292.2c0 43.1-86.2 57.2-86.2 57.2s-86.4-14-86.4-57.4c0 0 0-3.5 1.8-5.4 0 0-1 7.1 4.5 12.6 0 0-4.3-7.8 0-15.3 0 0-1.7 9.8 4.4 15.3 0 0-3.3-7.9.4-16.7 0 0-1.8 14.3 4.7 17.3 0 0 1.8-8.4-.8-13.6 0 0 4.5 1.8 4.3 13.8 0 0 1.4-1.8 1.8-10.5 0 0 .2 10 3.5 12.3 0 0 1.2-1-.3-5.5-1.6-4.4.6-6 1-6 0 0-.8 5 3.4 8.8 0 0-1.8-7.9.7-9 0 0-.6 6.7 4.8 8.1 0 0 .3-1.9-.8-4 0 0-1-2.5-.3-4.5 0 0 1.7 6 4 7 0 0-1.4-3.6 0-7 0 0 .2 5 4.7 7.1 0 0-3-4-1.9-8.2l28.7 1.4 15 .7 44.7-3 7.6-6.7s3.1 4.1-1.8 10.8c0 0 4.8-.8 6.3-8.3 0 0 2 4.1-.7 8.8 0 0 5.3-5.4 6-11.3 0 0 2.1 5.8-2.9 12 0 0 4.4-1.6 6.3-8.1 0 0 1.6 4-2.7 9.5 0 0 8.1-4.1 7.9-13 0 0 3.3 4.8-.5 11.6 0 0 4-3.7 4.5-9.3 0 0 2.3 2.5-.2 9.4 0 0 5-4.8 5.8-9.9 0 0 1 4.8-3.2 10.8 0 0 2.9-.8 5.7-6.6 0 0 .7 2.4-1.8 6.6 0 0 2.8-.5 4.6-5.9 0 0 .3 3.2-.5 6 0 0 2.1-1.3 2.6-7.3a9 9 0 0 0 1.2 4.4v.8z"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M515.4 198.7s-3 .6-7-.6c-3.8-1.2-5.3-.7-6.5 0 0 0 1.5-3.1-2.3-5.6 0 0 1.2 3.2-.5 4.6 0 0-.7.7-1.6-.3 0 0-1.3-1.5-2.8-2.4 0 0 3.3-1.2 2.7-4.5s-2.4-3.6-3.3-4.1a6 6 0 0 0 0 2.4s-3.5-2 1.2-4.7 4-4.4 3.1-5.8a13 13 0 0 0-3.5-3.6s1 1.6.7 2.9c-.1 1.2-2.4 2-2-.1.5-2.4 0-2 0-4.3 0 0 4.2 1.4 6-2.8 0 0 1.6-4.3-3.8-6.4 0 0 1.3 1.8.7 3 0 0-1.2 2.2-2.5.6s-2.2-2.2-2.1-4.2c0 0 4.9.7 3.6-4.7 0 0-.9 3.6-7.2-1.2 0 0 4.2-4.2 2.5-7.6 0 0-.5-1.5-4.9-.7 0 0 3.8-2.4 2.3-4.5 0 0-.9-1.3-4.6.4 0 0 1.5-2.3-2.1-5 0 0-2.4 1.2-3.6 2.4 0 0-2.4-3-4-4.3 0 0-2.8 1.1-3.5 4.3 0 0-1.3-1.5-4.3-2.4 0 0-1.4 2.8.5 5 0 0-1.4 0-4-1.1 0 0-2.8-1.2-2.3 1.1.3 2.4.6 3 1.2 4.2 0 0-6.5-1.5-6.2 2a9 9 0 0 0 3 6.2s-3.5 4.8-6.6 1.2c0 0-1.2 1.2 1.2 4.2 0 0 2.4 2.5.3 4.2 0 0-2.5 2-3.7-2 0 0-4.1 4.1.8 7.2 0 0 3 1.8 6.3-1 0 0-1 8-4 6.4 0 0-1.9-1.2 1.4-2.9 0 0-4.8-.6-5.5 4 0 0-.6 3.5 3.5 5 0 0 3.1 1.2 0 3.6 0 0-2.5 1.7-.9 4.4 0 0 1.8 2.8-2.9 3.2 0 0-2.4 0-3.3-.4 0 0-1 2-.4 4 0 0-2.3-1.7-7.5.2-5.2 2-4.9.6-5.2 1.2l-1.6 2.4s2.8 3.6 2.9 3.3l-.6 4 1.4.6 10.8-4.8 11-5.7 8.9.4 5.6 1.3 7.2.6 5.4-2.8h7.6l8.4 4.2 9.5 5.7 5 1 3.9-.3v-7.8z"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M428.2 207.7s4.2 2.2 6-.3c0 0 2.2-4.4-2.8-6 0 0 2.8-3.2-.2-6.2 0 0-1.6-1.6-4.2-.5 0 0-1.2-2.3-3.7-2.2 0 0-2.4 0-3 2.5 0 0-3.2-1.2-4.9.6 0 0-3 3.2 1.1 5.8l2.9.3 2.8-1.4 3.3.9s-.9 3.2 2.7 6.5z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#000" stroke-width="1.2" d="M521.6 200.1a7 7 0 0 1 6.9 3.8c2.6 6.2-3 9.6-3 9.6.4 1.3.5 3.2.5 3.2 7.8 1 6.4 9.8 6.4 9.8l-2.6-2.2c-4.5-1.8-9.3 2.2-12.6 8.6-3.4 6.6-1.8 9.5-1.2 17.3.6 7.7 13 12.4 13 12.4l-9.7 25c-3.8 10-12 5.9-14.3 3.7-2.3-2-2.9-.8-4 0-1.1 1 5.3 6-6.5 10.8-11.7 4.7-13.7 8.3-15.7 9.5s-10 .5-10.8-.6c-1-1-.4-1-3.5-2.9-3-1.7-8.2-3.5-13.8-6.2s-5.4-6.2-5.3-6.9c0-.7 1.9-6.5-4.7-1.9-6.5 4.7-12.1-2.2-12.1-2.2-1.2-1.7-6.8-16.4-6.8-16.4a92 92 0 0 0-4.3-12s-.4.8 4.5-2c4.8-2.7 7-7.3 8.7-11.9s0-12.5-.6-14c-.6-1.6-4.2-8.9-8.7-10.4-4.4-1.6-7.5 2.4-7.5 2.4s-1.3-9 6.6-10c0 0 0-1.8.4-3.1 0 0-5.6-3.4-3-9.6 0 0 1.8-4.3 6.8-3.8l-1.1 2.4s-1.2 12.6 17.2 4.2c18.5-8.6 18-10.2 28.7-4.8l7.6-.1s11-5.3 14.7-3c3.5 2.3 16.3 9.4 16.3 9.4s12.4 4.7 14.9-4z"/>
|
||||||
|
<path fill="#fff" stroke="#00247d" stroke-miterlimit="10" stroke-width="1.2" d="M465.8 255s-.6-3.9-1.2-6.4c0 0-1.5-3.9 1-6.8l2.8-3.3s1.8-2.4 4-2.7c0 0 2.3 0 2.4-.5.3-.5 2.8-4.5 8.6 0 0 0 1.8-3 4.8-3.6 0 0 3-.8 4.5 1.4 0 0 3.5-2.6 6.4 1.7 0 0 4.1-2.4 7.2 2.3 0 0 4-2 6.4 2.1 2.5 4.3 2 6 2 6l2 6.8 6.5 8-15.2 5.8h-7l-13.7 3.6-24.3 1.9-6.7-8 9.6-8.3z"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M445.3 295.5s-3.6.1-5.5 1.2c-1.8.8-3.3 1.8-5.4 3.2 0 0-1 1.3-5.2.5 0 0-7.2-1.7-7.2 4 0 0-8.8.6-5.2 8.3 0 0 2.3 6 7.3 1.8 0 0-3.2 4.5 3 6.3 0 0 4.4 1.2 5.8-3.5 0 0 .7-1.8-1-4 0 0 2.1-.4 3.6-2.7 0 0-4.6 5.8.5 8 0 0 6.3 1.5 6.6-5 0 0-.6-3.2 2-4.3 0 0 5-1.2 7-6.6 0 0-7-3.9-6.3-7.2zM430.6 238s-5.6-2.5-8.4 0c0 0-3.5-2.2-7.5 0 0 0-3.7 2.4-6.3 5.2 0 0-1.8 1.5-1.2 6.4 0 0 1 3.5.5 5.1 0 0-1.2-.2-3.7 2.8 0 0-3.1 3.5-6 .3 0 0 1 4.6 6.1 3.8 0 0-2.5 2-.3 7 0 0 1.6 3.6-1 8.2 0 0 4.5-1.8 4.4-7.2 0 0-.5-3.6 1-6 0 0-1.5 2.2 1.6 7.1 0 0 2.4 3.6.5 7.2 0 0 4.4-1.6 4-7s-3-3.2-1.4-8.2c0 0 .5 2.6 1.8 4a7 7 0 0 1 2.4 7s2.6-3.4 2-6.8a13 13 0 0 0-1.3-4.4l8.4-4.8 4.3-7.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M417.8 259.5s-3.5-.2-4-3m-6.1-1.8s1.2 0 2.4 1.2c0 0 .7 1.2 1.9 1"/>
|
||||||
|
<path d="M423 249.2s-1.8 0-3-.8c0 0-.9-.7-1.5.4 0 0-1 1.6.8 2.3 0 0 2.2 1.2-1.2 3.4 0 0 4-1.6 2.4-3.6 0 0-1.8-1.2-1.2-1.7 0 0 .2-.4 1 0 .6.6 2 .4 2.7 0"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m477.6 259 2.1-.6-14-48h-.4z"/>
|
||||||
|
<path d="M431.2 312.3s-3.2-1.9-6 .3c0 0 .4-1.2 2.4-1.7 0 0 1.2-3.2 4.3-2.9 0 0-1.3 1.6-3 2.8 0 0 1.8.2 2.4 1.5z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M419.7 319.8s-5.2-3.2-2.3-8.7c0 0 .8-1.5 2.3-1.2 0 0 3.3 1 .8 5.5 0 0-1.4 2.9-.8 4.5zm10.5 5.3s-7.4-3.4-5.1-9.2c0 0 .7-2 2.4-1.8 0 0 2.8.2 2.3 4 0 0-.9 3.6.4 7z"/>
|
||||||
|
<path d="M428.7 303.6s-2.3 1.7-3.1 2.8c0 0-1-1.2-2.2-1.8 0 0 1.5-.3 2.2.3 0 0 1.2-1 3-1.3z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M435.2 323.6s6-1.4 4.8-7.5c0 0-.6-2.6-3-2.2 0 0-3 .8-1 4.6 0 0 1.1 2.6-.8 5.1z"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="m461.6 196.7 5.7 1.9s5.4 2.3 12.3 0l5.2-2-3.5 5.4v2.9l2.1 3.2s-1.3.6-5.4-2c0 0-4.4-3.4-9.6 0 0 0-3 2-5.4 2l3.5-3.8-1.3-3.5-3.6-4.3z"/>
|
||||||
|
<path fill="#fff" d="M437.5 316.5s.2-.4 0-.5l-.5.2s-.6 1 0 2.4c0 0 .5.9.3 1.8v.4s.3-.1.3-.4c0 0 .4-.8-.2-1.8 0 0-.6-1.4.1-2.1"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M518 207.7s-4.2 2.2-6-.3c0 0-2.1-4.4 2.9-6 0 0-2.8-3.2.1-6.2 0 0 1.6-1.6 4.2-.5 0 0 1.2-2.3 3.7-2.2 0 0 2.4 0 3 2.5 0 0 3.3-1.2 5 .6 0 0 2.8 3.2-1.2 5.8l-2.9.3-2.9-1.4-3 .9s.8 3.2-3 6.5z"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M484.8 204.4s0 .8-.8 1c0 0-1 .3-1.5-.9v-.3c0-.5-.3-2 1.4-3.3 0 0 2.6-2.1 7.6.5a192 192 0 0 0 14.8 7.5s3.1 1.6 7.9 1.8c0 0 6.5.5 9.3-4.1 0 0 2.1-3.5 0-5.8 0 0-.9-1-2.3-.8a3 3 0 0 0-1.8 1.2s-1 1.4.1 2.5c0 0 1.6 1 2.2-1 0 .2.5 1.7-.4 2.9 0 0-4 6-14.9-.1l-14.3-8.1s-7.1-3.7-11.6 2.1c0 0-3.4 4.8 1.1 7.9 0 0 3.4 2 5.4-1.2 0 0 1.8-3.1-1-4.4 0 0-2.4-1.2-3.3 1.2-1 2.4 1.8 3 2 1.3 0 0 0-.4.3 0z"/>
|
||||||
|
<path fill="#e4cb5e" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M428 229.2v-9c0-.8 0-1.2 1.2-2.2 1-.8 2.1-2.3 3.7 1.7 0 0 3.2-3.5 4.2-4 0 0 2-1.5 3.3.7 0 0 1.6-2.6 3-3.2 0 0 3.2-2 3.3 4.2l2.5-2.4s2-1.5 4.2.7c0 0 3.5 3.6 4 4.6 0 0 .8 1 1 2.7 0 0 0 2 1 3 0 0 1.1 1 2.2 1.2 0 0 2.5.1 3.6 2.6 0 0 .3-.5 1.6 11.2v21.5l-14.4 17.1-23.1-6.6-9.2-3.9-2.2-6.6 9-5.9 4.8-13-1.5-9.3-2.2-5.2z"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m491.6 240.4 1.2-1.5 2.5-2s4 10.4 4.2 12.7v3.2s6 1.6 7.2 10.7l-5 9.2-7.8-4.4-2.3-1.5z"/>
|
||||||
|
<path fill="#fff" d="M418.3 312.4s.2-.3 0-.6l-.5.4s-1.5 1.7-.2 4.4c0 0 0 .4.3.2 0 0 .3 0 0-.3 0 0-1.1-2.4.4-4z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M441.7 310.6s.1-.8-.4-1.6q-.2 0-.2-1.5m5.6-90.1s0 2.3.7 4c.6 1.6 2.9 4.5 3 5.9"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M501 295.5s3.6.1 5.5 1.2c2 .8 3.4 1.8 5.5 3.2 0 0 1 1.3 5.2.5 0 0 7.2-1.7 7.2 4 0 0 8.7.6 5.2 8.3 0 0-2.4 6-7.4 1.8 0 0 3.2 4.5-3 6.3 0 0-4.4 1.2-5.7-3.5 0 0-.8-1.8 1-4 0 0-2.1-.4-3.5-2.7 0 0 4.5 5.8-.5 8 0 0-6.3 1.5-6.7-5 0 0 .5-3.2-2-4.3 0 0-5-1.2-7.2-6.6 0 0 7.2-3.9 6.5-7.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="m443.8 227.4 6 47.8m-4.5-47.9 6.6 44.6m-5.5-44.8 7.8 44.2"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m458.6 267.5 2.2-.7-14-48h-.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="m450.7 225.3 14.1 35.5m-15-35 13.6 37"/>
|
||||||
|
<path fill="#fff" d="M427.6 321.6s-1.7-1.5-1.4-4.3c0 0 0-.6-.3-.7 0 0-.3-.1-.3.6 0 0-.5 2.9 1.5 4.5q0 .2.4.2v-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="m451.5 224.8 14.3 32.4m-18.3-30.4 9 43.7"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M520 259c-6.3-3.6-7.7-11.4-7.7-11.4a25 25 0 0 1 1.6-14.3c4-8.6 11.1-10.3 11.1-10.3s-6.9 4.8-8.8 11.2c0 0-1.4 5.5-.6 10.7.8 5.5.5 4.1 1.8 8.3l2.6 6z"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m492.7 238.7-26.6 8.5-1.4 12c-6.3 9.1-19.2 10.7-19.2 10.7l10 10.7 19 4 10.2-7.8 9.2-8.4c-1-4.5-.5-11.2-.5-11.2 0-1.2.5-3.8.5-3.8s-1.5-10.7-1.2-14.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M465 256.2s17.6-5.5 28-10m-36.4 20s22-4 37.3-12.8l5.6-3.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="m499.5 252.8-6 4.4s-24.5 11.2-43.2 11.6m43.1-3.6S474.6 275 464 275"/>
|
||||||
|
<path fill="#d40000" stroke="#000" stroke-linejoin="round" stroke-width="1.2" d="M515.8 238s5.6-2.5 8.4 0c0 0 3.6-2.2 7.6 0 0 0 3.6 2.4 6.2 5.2 0 0 1.8 1.5 1.2 6.4 0 0-1 3.5-.3 5.1 0 0 1.1-.2 3.5 2.8 0 0 3.3 3.5 6 .3 0 0-.8 4.6-6 3.8 0 0 2.4 2 .3 7 0 0-1.6 3.6 1 8.2 0 0-4.6-1.8-4.4-7.2 0 0 .4-3.6-1-6 0 0 1.4 2.2-1.7 7.1 0 0-2.4 3.6-.4 7.2 0 0-4.5-1.6-4-7 .3-5.4 3-3.2 1.3-8.2 0 0-.4 2.6-1.7 4a7 7 0 0 0-2.4 7s-2.6-3.4-2-6.8c.6-3.5 1.2-4.4 1.2-4.4l-8.3-4.8-4.2-7.5z"/>
|
||||||
|
<path fill="#fff" stroke="#00247d" stroke-miterlimit="10" stroke-width="1.2" d="M420.6 268.4s.8-3.7 4.3-2.4c0 0 1.2-6 7.7-6.2s6.8 6.4 6.8 6.8c0 0 2-2.8 5.1-2.5 0 0 5.5-.3 3.6 8.5l1 1.2s4.1-9.9 12.7-7.4c0 0 8.4 2.6 3 10.7 0 0 4.2 5.5 7.6 5 3.6-.5 6.7-1.6 10.2-7.8 3.6-6.2 11.7-7.2 13.7-6.8 2 .3 3.7 1.8 4 3.2 0 0 4.3-14.3 19.8-12.2l6.2 3.3 2.4 1-3.6 10-8.4 19.2-6.6 2-6.8-3.9-2.4 1.2v5.9l-9 6.4-6.2 2.5-6.8 4.8-1.7 4.2s-3.7-1.7-8 0l-1.4-3.6-4-3.7-15.6-7.4-2.7-9-2.9-1.3-3.2 3.6-4.5.6-7-4.8-7.4-21z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M488.2 198s-6-.2-5.7 6"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M527 263.3c-10.2-3.2-13-14.3-13-14.3a26 26 0 0 1 1.2-15.5c4.8-11 12.7-11 12.7-11a4 4 0 0 1 4.5 3.2c.4 2.4-1.3 3.3-1.3 3.3-2.7 1.5-4.5-.5-4.5-.5-1.2-1.6-.3-2.9-.3-2.9.7-1 2-.4 2-.4 1.3.2 1 1.6 1 1.6s.4-1.6-1.2-1.8c0 0-3.2-.7-6.8 3.8 0 0-4.7 6.1-4.7 14.1 0 0-.6 14.8 14.7 18.6 0 0-1.6 2.4-4.6 11.1 0 0-3.3 11.2-6.4 17.3 0 0-4.3 8.6-13.9 5.1 0 0-5.6-2.5-5.6-6.5 0 0-.3-3.8 3-4 0 0 3.3-.3 3.3 2.5 0 0 0 3-3.5 2.4 0 0-1.2-.3-1.1-1.5 0 0 .2-1.2 1.8-.6 1.5.6 0 0 0-.1 0 0-.6-.3-1.2 0 0 0-.6 0-.6 1q-.2.2.7 1.1l1.7.2s1 2 3 2.7a8 8 0 0 0 7.1-1.2 11 11 0 0 0 3-4.3 113 113 0 0 0 5.2-13.5s1.8-5.7 3.4-8.7z"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M498 291.4s2-.2 2 1.5c0 0-.1 2.4-3.2 1.9 0 0-3-.6-2-4 0 0 .8-2.3 3.6-2 0 0 2.8 0 4 3.8 0 0 1 3.3-.7 6-1.8 3-6.2 5-8.4 6 0 0-8.7 3.3-11.3 5.3 0 0-4 2.9-2.2 5.6 0 0 .7 1.1 1.7 1.1 0 0 1.2 0 1.3-1.2 0 0 0 .7-.7 1 0 0-.8.4-1.7-.3 0 0-1-1-.2-2.4 0 0 1-1.6 3.6-.6 0 0 2 1.1 1.2 3.3 0 0-.9 2.1-3.4 2 0 0-1.9 0-3.2-1.2-2-2.1-2-6-.2-8 0 0 1.5-2.1 4.7-3.6l9-3.5q4-1.5 6.4-4.1s1.3-1.4 1.8-4c0 0 .4-2-.8-2.4l-1.2-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M498.2 291.4s2.1-.4 3.5 2.2c0 0 .7 1.5.8 2.5m.7-9s-2.1.3-1 3.4c1.1 2.9 3 3.7 3.5 4.2"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M461.6 204.4s0 .8 1 1c0 0 .8.2 1.3-.9v-.3c0-.5.3-2-1.2-3.3 0 0-2.8-2.1-7.8.5 0 0-4 2-6.7 3.6 0 0-7.1 3.8-8 4 0 0-3.2 1.6-8 1.8 0 0-6.5.5-9.2-4.1 0 0-2.2-3.5 0-5.8 0 0 .8-1 2.2-.8q.9 0 1.8 1.2s1 1.4-.1 2.5c0 0-1.6 1-2.2-1 0 .2-.3 1.6.5 3 0 0 4 5.9 14.8-.3l14.3-8s7.2-3.7 11.6 2c0 0 3.4 4.8-1 8 0 0-3.3 2-5.5-1.2 0 0-1.6-3 1-4.4 0 0 2.4-1.2 3.5 1.2 1 2.4-2 3-2.2 1.3 0 0 0-.4-.1 0zm-35.2 54.7c6.3-3.5 7.8-11.5 7.8-11.5 1.4-8-1.7-14.3-1.7-14.3-4-8.6-11-10.3-11-10.3s6.8 4.8 8.7 11.2c0 0 1.6 5.5.6 10.7-.8 5.5-.5 4.1-1.8 8.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M458.4 198s6-.2 5.6 6"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M419.3 263.3c10.3-3.2 13-14.3 13-14.3a26 26 0 0 0-1.1-15.5c-4.8-11-12.7-11-12.7-11a4 4 0 0 0-4.5 3.2c-.3 2.4 1.3 3.3 1.3 3.3 2.8 1.5 4.6-.5 4.6-.5 1.1-1.6.2-2.9.2-2.9-.7-1-2-.4-2-.4-1.2.2-1 1.6-1 1.6s-.3-1.6 1.2-1.8c0 0 3.2-.7 6.8 3.8 0 0 4.8 6.1 4.8 14.1 0 0 .6 14.8-14.8 18.6 0 0 1.7 2.4 4.6 11.1 0 0 3.4 11.2 6.6 17.3 0 0 4.2 8.6 13.7 5.1 0 0 5.6-2.5 5.6-6.5 0 0 .4-3.8-3-4 0 0-3.3-.3-3.3 2.5 0 0 0 3 3.6 2.4 0 0 1.2-.3 1-1.5 0 0-.2-1.2-1.7-.6-1.6.6 0 0 0-.1 0 0 .5-.3 1.1 0 0 0 .6 0 .6 1q.2.2-.7 1.1l-1.5.2s-1 2-3.2 2.7a8 8 0 0 1-7-1.2 11 11 0 0 1-3-4.3 114 114 0 0 1-5.3-13.5s-1.8-5.7-3.2-8.7l-.6-1.2z"/>
|
||||||
|
<path fill="#f5ce00" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M448.5 291.4s-2-.2-2 1.5c0 0 0 2.4 3.2 1.9 0 0 3-.6 1.9-4 0 0-.7-2.3-3.6-2 0 0-2.7 0-4 3.8 0 0-.9 3.3.8 6 1.8 3 6.2 5 8.3 6 0 0 8.7 3.3 11.4 5.3 0 0 3.9 2.9 2.1 5.6 0 0-.6 1.1-1.7 1.1 0 0-1.2 0-1.3-1.2 0 0 0 .7.7 1 0 0 .9.4 1.7-.3 0 0 1-1 .2-2.4 0 0-1-1.6-3.4-.6 0 0-2.2 1.1-1.2 3.3 0 0 .7 2.1 3.2 2 0 0 2 0 3.2-1.2 2-2.1 2-6 .4-8 0 0-1.7-2.1-4.8-3.6-1.9-.6-5.5-2.1-9-3.5q-4.1-1.5-6.5-4.1s-1.3-1.4-1.8-4c0 0-.3-2 .9-2.4l1.2-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M448.2 291.4s-2.1-.4-3.4 2.2c0 0-.7 1.5-.9 2.5m-.7-9s2.2.3 1.1 3.4c-1.2 2.9-3 3.7-3.6 4.2m27.4-91.1s5.2-3.3 10.4.3m-39.7 31.3s.7-3-2.6-8c0 0-2.2-4.7-3.2-7.5m7.4-3.3 2.3 6.6 3 6.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M441.2 238.3s2 2.1 1.8 5.6m-8.1-7.9s2.1 1 1.9 6.8c0 0-.1 4 3 6.2m-5.3 1.4s6.7-.7 7.2 3.5c0 0 0 4 2.4 4.3 0 0 3.3.3 4 3.6m-11.4-6.8s1.2 1.7 2.4 2.6m6.2-7s1.6 2.7 2 4.1m5.3-35s1.6 2.1 3.1 3m3.1 9.6s3.4 1.6 3 10c0 0-.6 5 .9 7.7"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m443.1 226.6.4.8s6 .3 9.1-3.8l-.3-.8s-5.3-.3-9 3.8z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M464.7 258.5s20.6-6 28.6-9.6"/>
|
||||||
|
<path fill="#784421" stroke="#000" stroke-width="1.2" d="m462.5 220.5-.2-.6 10.1-4 .3.8z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="m463.4 220.1 6.8 33.3m-5.6-33.6 7.7 33m-6.4-33.6 8.4 32.9m-5-34.1 11.5 24.6m-10-25 12.3 24.2M471.8 217l13.4 24.3m-15.5 12.4 5.2-1.6m-9.1-1.6s15.2-4.5 27-9.2M465.4 252s19.5-5 27.2-8.8m-21.2 24.6 1 3.3 2.8-1-1-3.1m5-1.3 1 3 2.8-.9-1-3m-16-17.6-.2-4.2 25.5-7.7 1.3 3.6m-16 .8 1.3 3.8m7.6-6.4 1.6 3.6m.5-4.3 1.6 3.3m.4-4 1.6 3.6m0-4 2.8-1.6 1.2 3.1m-2.6-2.3 1.4 3.3"/>
|
||||||
|
<path fill="none" stroke="#00247d" stroke-miterlimit="10" stroke-width="1.2" d="M429.5 283.1s-5-5.1 0-10c0 0-5.5-2.6-4.6-7m13.6 21s-5.3.7-3.7-9.4c0 0-2.2 4-2.9 6a5 5 0 0 0 2 5.4c.9.6 5.5 1.8 7-1.2m-8.3-16.2s-2.1 1.9-.6 5.4m2.8-5s.2 3.5 2 5.4m-.5-6.7s0 4 2.9 6.6m-1.5-8s0 4.5 3.4 7.4m13.5-.7s1.6-3.9 5.2-3.6c0 0-1.7.8-1.7 2.5 0 0-.2 3 2.9 3.2 0 0 2.5.4 3.8-1"/>
|
||||||
|
<path fill="none" stroke="#00247d" stroke-miterlimit="10" stroke-width="1.2" d="M463.1 293.1s-8.9-4.2-8-10.1c0 0 .4-3.6 4-5.4m-2 3.8s-.8 2.5 1 4.3m1.1-6s-1.2 2.8.7 4.8m1.5-6.3s-1.4 2.4.2 4.5m-8.4 11s4.8 4.3 8.6 4.3m-10.1-2.9s4.5 3.6 7.4 4.2m-9-3.2s5.5 4.9 9 5.2m13.7 4.6s-5.5 3-1.2 6.2m-2.4-9.4 9-4.6m-6.7 6.2s9-5.5 11.7-6.7m-7 7.2s7.7-5.2 11.9-7.5m-6.8 7.1s5.3-4 10.5-7.1m1.7-5.1s-2.6-3.9-11.2 0c0 0 3.3-2.9 9.5-5.2m8.5-16.3s.8 3.1 0 5"/>
|
||||||
|
<path fill="none" stroke="#00247d" stroke-miterlimit="10" stroke-width="1.2" d="M486.5 285.7s1.8-2.6 6.3-4c0 0 1.2 3 4.8 2.5 0 0 5.2-1.1 3.6-7 0 0-1.1-4.2-7.2-4.7m18.1-7.3s6.1 1.2 6 6.1c0 0 .6 7.6-7.4 9m6.6-12s1.6-3.7 5.5-5.2m-4.6-2.2s1.7 2.8 2.8 3.1m-3.6-1s1.5 2.1 2 2.5m-2.6-.5 1.5 1.6M485 235.8s1.2-2 4.3-1.2m12.1 15.8s2-7.8 8.5-5m-2.6-.6s.6-3.6-1-5.5m-3.6 5.2s.9 1.5.1 2.7"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M516.2 325s7.4-3.3 5.1-9.1c0 0-.7-2-2.4-1.8 0 0-2.8.1-2.3 4 0 0 1 3.8-.5 7z"/>
|
||||||
|
<path fill="#fff" d="M518.8 321.6s1.7-1.5 1.5-4.3c0 0-.2-.6 0-.7 0 0 .4-.1.4.6 0 0 .5 2.9-1.4 4.5q0 .2-.4.2-.2 0 0-.3z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M526.7 319.8s5.2-3.2 2.4-8.7c0 0-.9-1.5-2.4-1.2 0 0-3.2 1-.7 5.5 0 0 1.2 2.9.7 4.5z"/>
|
||||||
|
<path fill="#fff" d="M528 312.4s-.2-.3 0-.6l.4.4s1.5 1.7.3 4.4c0 0-.1.4-.5.2v-.3s1.2-2.4-.2-4z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M511 323.6s-5.8-1.4-4.7-7.5c0 0 .6-2.6 3.1-2.2 0 0 3 .8 1 4.6 0 0-1.1 2.6.7 5.1z"/>
|
||||||
|
<path fill="#fff" d="M508.8 316.5v-.5s.4 0 .5.2c0 0 .6 1 .1 2.4 0 0-.5.9-.2 1.8l-.3.4-.2-.4s-.3-.8.2-1.8c0 0 .6-1.4 0-2.1z"/>
|
||||||
|
<path d="M515.2 312.3s3.4-1.9 6 .3c0 0-.2-1.2-2.4-1.7 0 0-1-3.2-4.2-2.9 0 0 1.2 1.6 3 2.8 0 0-1.8.2-2.4 1.5m2.4-8.7s2.4 1.7 3.2 2.8c0 0 .9-1.2 2-1.8 0 0-1.2-.3-2 .3 0 0-1.1-1-3-1.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M504.8 310.6s-.3-.8.3-1.6c0 0 .4-.6.2-1.5m23.4-48s3.5-.2 4-3m6-1.8s-1.2 0-2.2 1.2c0 0-.9 1.2-2 1"/>
|
||||||
|
<path d="M523.6 249.2s1.8 0 2.7-.8c0 0 1.1-.7 1.7.4 0 0 1 1.6-.8 2.3 0 0-2.2 1.2 1.3 3.4 0 0-4.2-1.6-2.4-3.6 0 0 1.8-1.2 1-1.7 0 0-.2-.4-.9 0-.7.6-2.1.4-2.6 0"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M526 205.6s.8-1.2.5-4.4 2.6-3.7 3.6-2.8c0 0 1.2 1 0 3.1a8 8 0 0 1-4.1 4.1z"/>
|
||||||
|
<path fill="#fff" d="M527 203s1.2-1.1.6-3.6l.2-.2h.3s.6 2.8-.6 4.2c0 0-.2.3-.5 0z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M525.1 204.4s0-1.4-2.3-3.6c-2.4-2.4-.5-4.4 1-4.4 0 0 1.3 0 2 2.3a8 8 0 0 1-.7 5.7z"/>
|
||||||
|
<path fill="#fff" d="M524.8 201.4s0-1.6-2.2-3.3v-.4h.4s2.3 1.8 2.3 3.8q0 .2-.3.3-.1.1-.2-.4"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M522 203.3s-.7-1-3.5-2.1c-2.9-1-2.2-3.5-1-4 0 0 1.3-.5 2.6 1.1a8 8 0 0 1 1.8 5z"/>
|
||||||
|
<path fill="#fff" d="M520.6 201.3s-.6-1.4-3.2-2.1l-.2-.3.3-.1s2.9.6 3.7 2.4l-.1.3s-.3 0-.4-.2z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M523.7 204.4s-1.3.5-4.4-1-4.4 1.1-3.8 2.3c0 0 .5 1.6 2.8 1.2a8 8 0 0 0 5.4-2.4z"/>
|
||||||
|
<path fill="#fff" d="M516.6 205.4s1.4 1 3.9 0h.3s0 .2-.2.3c0 0-2.6 1.2-4.4.1v-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M519.3 194.7s1.4.8 1.2 2.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M526 195s.6 1.9 0 3"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M420.2 205.6s-.8-1.2-.5-4.4c.4-3.4-2.5-3.7-3.5-2.8 0 0-1.2 1-.2 3.1a8 8 0 0 0 4.2 4.1z"/>
|
||||||
|
<path fill="#fff" d="M419.1 203s-1-1.1-.6-3.6v-.2h-.4s-.5 2.8.7 4.2c0 0 .2.3.3 0z"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M421.2 204.4s0-1.4 2.4-3.6c2.3-2.4.4-4.4-1-4.4 0 0-1.6 0-2 2.3a8 8 0 0 0 .6 5.7z"/>
|
||||||
|
<path fill="#fff" d="M421.5 201.4s0-1.6 2-3.3c0 0 .3-.3 0-.4h-.2s-2.4 1.8-2.4 3.8q-.1.2.4.3.1.1.2-.4"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M424.3 203.3s.6-1 3.5-2.1c2.8-1 2-3.5.9-4 0 0-1.2-.5-2.6 1.1a8 8 0 0 0-1.8 5z"/>
|
||||||
|
<path fill="#fff" d="M425.6 201.3s.6-1.4 3.2-2.1l.2-.3-.2-.1s-2.9.6-3.7 2.4v.3s.4 0 .5-.2"/>
|
||||||
|
<path fill="#64b4d1" stroke="#00247d" stroke-miterlimit="2.6" stroke-width="1.2" d="M422.5 204.4s1.4.5 4.4-1 4.4 1.1 4 2.3c0 0-.7 1.6-3 1.2a8 8 0 0 1-5.4-2.4z"/>
|
||||||
|
<path fill="#fff" d="M429.6 205.4s-1.3 1-3.9 0h-.2v.3s2.8 1.2 4.5.1q.2 0 0-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="2.6" stroke-width="1.2" d="M427 194.7s-1.5.8-1.2 2.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.2" d="M420.2 195s-.6 1.9 0 3"/>
|
||||||
|
<path d="M486.7 177.6s1 2.7.6 5.2c-.3 2.4.2 2.9 1 3.4.7.3 2.4-.4 2.5-2.2 0 0 2 3.8-1.8 6.3 0 0-2.7 1.7-5-.4-.8-.9-1.2-3-.7-5 0 0 .6-2.7-.5-5.5 0 0 1.6 1.6 1.2 4.9 0 0-.8 6 3.6 5.6 0 0 3 0 3-3.6 0 0-1.1 1.2-2.4.8a2.4 2.4 0 0 1-1.8-2.8c.1-1.6.6-4.3.3-6.7M482 187s-.4 4.8-4.6 5.9c0 0 .6-.9-.3-3.3 0 0-1-1.2-1-3.3 0 0-1.2 1 .3 3.8 1 1.7 0 3.6-.1 3.7-.4.1 6.9-1 5.6-6.7zm-5.6-4.5s-1.1-1.2-1.2-3.8c0-2.7-.5-3.4-.9-3.6 0 0 .5 2.3.4 3.6a7 7 0 0 0 .4 3s-2.4.3-3.9-3c-1.3-3.3-2.7-3-3.4-3 0 0 .8.1 2.4 3 1.6 3 2.1 3.7 6.2 3.7zm-10.8-7s1.2 3.2 1.4 4.8c0 0-3.1-1-4-3.5 0 0-3.2 1-2 4.7 0 0-2.9-1-4.2-3.2 0 0 1.6 1.2 3.1 1.7 0 0-.2-3.1 3.6-4.2 0 0 .6 2.7 2.6 3.3zm5-5.4s1.2 1.5 3.4 1.5a4 4 0 0 0 3.2-1.5s-.1 2.6-3.2 2.7c0 0-3.3 0-3.3-2.7zm-13.4 18.5 2 1.9s1.2 1.2 2-.3c0 0 1.3-2.4 2.9-2.2 0 0-1.2.7-2.4 3 0 0-.2.7-1.2.8-.5 0-1 .2-1.8-.7q-.2 0-1.5-2.4zm16.7-29.4c-3.2 0-.5 2.4-.5 2.4 0 4-3.1 5.4-5.3 4.2s-.4-4-.4-4-2.4 1.5-1 3.9c1.6 2.4 5.7 1.2 7.2-.9 1.5 2.2 5.7 3.3 7.1.9 1.7-2.4-.8-4-.8-4s1.8 2.9-.5 4c-2.1 1.3-5.4 0-5.2-4 0 0 2.7-2.5-.5-2.5zm2-4.4s1.2 2 1 5c0 0 .9-3-1-4.9zm-10 4.7s-.3-2.2-2.4-2.4c0 0 1.9 1.3 2.4 2.4m16 0s.3-2.2 2.5-2.4c0 0-1.8 1.3-2.4 2.4zm-19.2 3.2s1.6.5 2.6-.7zm-5.4-13.1s1.9-.1 3.8 2.7c0 0-1.9 1-2.4 1.6 0 0 0-1 1-1.7 0 0-.4-1.4-2.4-2.5zm32 0s-2-.1-3.9 2.7c0 0 2 1 2.4 1.6 0 0 0-1-1-1.7 0 0 .4-1.4 2.5-2.5zm-14.7 2.2s1.7.5 3.8-.7c0 0 2.6-1.4 4.3 0 0 0-1.4-.7-4.3.7 0 0-2.7 1.6-3.8 0"/>
|
||||||
|
<path fill="#fff" d="M476.4 153.6s1.4-2.5 6-2c0 0-1.3 3.6-6 2"/>
|
||||||
|
<ellipse cx="479.4" cy="152.8" fill="#784421" rx=".8" ry="1.1"/>
|
||||||
|
<ellipse cx="479.4" cy="152.8" rx=".5" ry=".7"/>
|
||||||
|
<path d="M466.8 148.1s2.8.3 4.6 1.2c0 0 1.8 1.2 3.8-.3 0 0 2.2-1.4 3.7-3.1 0 0-3.5 2.3-4.8 2.7 0 0-1.2-1-1.5-2.3 0 0 0-1 2-2.7 0 0-2.5.8-3 2.9a7 7 0 0 0 1.4 2.3s-.4.3-1.6-.6c0 0-2.8-.8-4.6 0zm7.2 17c-2.4 2.1 0 1.9 0 1.9s2.4.2 0-2zm-1.4-13.2s-1.8.5-4-.7c0 0-2.6-1.4-4.3 0 0 0 1.5-.7 4.3.7 0 0 2.9 1.6 4 0"/>
|
||||||
|
<path fill="#fff" d="M470.8 153.6s-1.5-2.5-6-2c0 0 1.2 3.6 6 2"/>
|
||||||
|
<ellipse cx="467.7" cy="152.8" fill="#784421" rx=".8" ry="1.1"/>
|
||||||
|
<ellipse cx="467.7" cy="152.8" rx=".5" ry=".7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width="1.2" d="M560 130.6v161.6c0 43.1-86.2 57.2-86.2 57.2s-86.4-14-86.4-57.2V130.6z"/>
|
||||||
|
<path fill="#006" d="M0 0h320v240H0z"/>
|
||||||
|
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||||
|
<path fill="#c8102e" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||||
|
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||||
|
<path fill="#c8102e" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-bn" viewBox="0 0 640 480">
|
||||||
|
<path fill="#f7e017" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M0 33.3v213.4l640 100V233.3z"/>
|
||||||
|
<path fill="#000001" d="M0 146.7v100l640 200v-100z"/>
|
||||||
|
<g fill="#cf1126" transform="translate(-160)scale(.66667)">
|
||||||
|
<path d="M695.7 569.7a117 117 0 0 1-49.4-17.2c-2.4-1.6-4.6-3-5-3s-.6 1.9-.6 4.1c0 6.4-2.6 9.6-9 11.3-6.2 1.6-15.6-1.6-23.2-8a68 68 0 0 0-24.7-13.5 40 40 0 0 0-28 3.6 9 9 0 0 1-2.8 1.3c-1.1 0-1-6.9.2-9 1.5-3 5.1-5.8 9.4-7.3 2.2-.8 4-1.8 4-2.3s-.8-2-1.7-3.6q-4.3-7.9 3.4-13.9c5.2-4 14-4.6 21.7-1.7l4 1.4c1 0 .4-1.5-2.4-5.6-3.2-4.7-3.9-7-3.5-12.7a15 15 0 0 1 13.5-13.5c5.8-.4 9.4 1.6 18 9.7a144 144 0 0 0 86 41.6c8.3 1 24.8.5 34.5-1a156 156 0 0 0 81.8-40.8c6.4-6 9.4-7.6 14.7-7.6 4.5 0 7.7 1.4 11 5 3 3.3 4 6.4 3.6 11.5-.2 3.2-.7 4.7-2.6 7.9-2.8 4.5-2.3 5 3.2 2.8 7.6-3 16.9-1.6 21.9 3.2 4.4 4.2 4.8 8.4 1.4 14-1.3 2.1-2.3 4-2.3 4.4 0 .6 1 .8 5.5 1.6 6 1 9.5 5.4 9.5 12.2 0 2-.3 3.7-.6 3.7s-2.6-.9-5-1.9c-7-2.9-11-3.6-19.2-3.5-6.2 0-8.3.3-12.6 1.7a58 58 0 0 0-19.5 11.5c-6.4 5.7-10.4 7.5-16.6 7.4q-8.7 0-11.8-5c-1.1-1.8-1.3-2.8-1-6.8.2-2.6.1-4.7 0-4.7-.3 0-2.5 1.4-5 3.1A81 81 0 0 1 778 560a182 182 0 0 1-82.3 9.7"/>
|
||||||
|
<path d="M706.3 525.2a136 136 0 0 1-97.9-55.7c-24.4-33.2-32-77.1-24.6-117.2 4-18.3 12-36.6 25.5-49.6a115 115 0 0 0-8.7 74.3c9 49.8 51 91.9 101.3 99.2 20 5.7 40.5-.4 59.5-6.5 42-14.8 74-54.6 77.8-99.1 3.3-24-.3-49.1-11.2-71 6.2 3.3 14 16.2 18.6 24.8 16 31 16.7 68.1 7.3 101.2-12.8 42.1-45 79-87.5 92.4a166 166 0 0 1-60 7.2z"/>
|
||||||
|
<g id="bn-a">
|
||||||
|
<path d="M512 469.9c-2.5-.4-5.3 2.1-4.3 4.7 1.8 2.6 5 4 7.8 5.2a54 54 0 0 0 23.2 3.6 50 50 0 0 0 17-3c3-1 6.8-2 8-5.4 1.3-2.1-1-4.3-3.1-3.9-3 .7-6 2-9 2.9a58 58 0 0 1-20.3 2 54 54 0 0 1-14.4-4.2c-1.6-.7-3.1-1.7-4.9-1.9"/>
|
||||||
|
<path d="M514.8 459.5c-2.5-.4-4.7 2.6-3.7 5 2 2.8 5.3 4.3 8.4 5.6a42 42 0 0 0 17 2.9h1.5a38 38 0 0 0 14.4-2.8c2.7-1.1 6.1-2.2 7.3-5.2.9-1.7.2-4.1-2-4.3-1.8 0-3.5 1.2-5.3 1.7a44 44 0 0 1-20.6 3.2c-4.4-.5-8.5-2.1-12.5-4-1.5-.7-2.8-1.8-4.5-2z"/>
|
||||||
|
<path d="M518.3 449.6c-2.2-.3-3.7 2.2-3.3 4.1.3 1.8 1.8 3 3.1 4a30 30 0 0 0 18.6 5.3h1.6a28 28 0 0 0 12-2.8c2.5-1 5.4-2.3 6.3-5.2.4-1.3.6-3.2-.9-4-1.6-.8-3.1.5-4.5 1a34 34 0 0 1-15.5 3.9 27 27 0 0 1-13.1-4c-1.5-.7-2.7-2-4.3-2.3"/>
|
||||||
|
<path d="M481.5 302.7c-3.2 3.3-.7 9.3-1 13.5 1.8 13.2 3.9 26.5 8.8 39 6 12 18.8 18.5 26.5 29.2 2.8 5.1 1.8 11.3 2.4 17q.5 23 0 46c7 3.6 14.5 7 22.5 5.7 4.7-1.1 13.5-1.8 14.5-6.5l-1-79.5c-2.7-8.1-11-12.3-17.1-17.5a156 156 0 0 1-14.2-16.1c-2.6-4.5-12.9-6-9.2 1.6 2.2 6.7 7.7 11.6 9.1 18.6.3 3.9 5 11 1 13.2a25 25 0 0 0-10.7-10c-4.4-3.3-11.7-4.7-13.3-10.5a43 43 0 0 0-11-22c1.5-7.4 0-16.7-6.4-21.5z"/>
|
||||||
|
<path d="M491.4 304.2c-3 .5-2.8 4.2-1.5 6.2a27 27 0 0 1 1.1 13.4 44 44 0 0 1 10.6 21.7c0 3 3.2 4 5.3 5.5 4.9 3.1 10.3 5.4 14.7 9.3.9 1 1.6 2 1 0-.7-2.6-1-5.4-3-7.3-2.8-3-6.2-5.6-10.2-6.4-.3-4.2-2.3-8-4.1-11.6-2-3.5-4.1-7.2-7.5-9.4 0-6.1 0-12.5-2.6-18.2-.8-1.4-2-3.1-3.8-3.2"/>
|
||||||
|
<path d="M499.7 306.6c-2 .6-1.6 3.2-1 4.7a54 54 0 0 1 1 13.2c3.9 3 6.2 7.4 8.4 11.6q2.2 4.2 3.1 8.9c3.1 1 5.8 3 8.2 5-1-2.8-3-5-4.5-7.7s-3-5.6-3.7-8.7c-3-3.1-4.6-7.6-4-12 .2-4.7-1.3-9.6-4.5-13.2-.8-.8-1.8-1.7-3-1.8"/>
|
||||||
|
<path d="M509.2 308c-1.2.2-1.8 1.2-2.4 2.1-.3.9.8 1.8 1 2.8a22 22 0 0 1 1.4 10.4q0 3.9 2 7a4 4 0 0 1 3.5-2.8c.5 0 1.4.2 1-.7q-.4-7.3-2.8-14a10 10 0 0 0-2.8-4.5q-.4-.4-1-.3z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#bn-a" width="100%" height="100%" transform="matrix(-1 0 0 1 1440 0)"/>
|
||||||
|
<path d="M715.7 476a36 36 0 0 1-29.9-24c.3-2.2 3 1.2 4.3 1.5a19 19 0 0 0 8 2.6c3.5 1.5 5.7 5 9.1 6.9 1.6 1.2 7.2 3.6 6.1-.3-1.3-2-2.2-4.6-1-7 1.8-4.1 4.7-7.7 7.7-11.2 2.1-.7 3.6 3.6 5.1 5 2.1 3.3 4.7 7.3 3.4 11.3-1.2 1.5-2 6 1.3 4.6 4-1.8 7.3-4.8 10.6-7.6 3-2 6.7-2.1 9.7-4 1.5-.3 4.4-3.1 5-1.6a45 45 0 0 1-7.4 12.3 32 32 0 0 1-18.8 10.9q-6.6 1.2-13.2.6"/>
|
||||||
|
<path d="M731.5 460.2q.4-4-1.7-8-3.3-6.2-8-11.9c-2.8-1.6-4.3 3.7-6.1 5.2-2.9 4.3-6.5 8.7-6.7 14-1.6 2.5-4.6-2-5.9-3.5a19 19 0 0 1-4-12 51 51 0 0 1 3.6-20.6c2-5.6 5.1-11 4.8-17 .2-4.7-.7-9.7-4.4-12.8-3.6-2.8 2.3-3.4 4.1-2 3.2.3 4.9 5.5 7.8 4.2 1.1-2.7 1.4-6 3.8-8.1 2.3-3.2 4.7 1.3 5.5 3.5 1.7 1.8 0 6.5 2.6 6.6 3.2-2.3 5.5-6 9.6-6.9 1.7-1 4.5 0 2.3 1.8-3 2.9-5.6 6.4-6.2 10.7-.9 5.3.4 10.7 2.7 15.4 4.5 9.4 8 20 5.7 30.5-1 4.6-4.2 8.6-8 11.3-.5.3-1.3.3-1.5-.4"/>
|
||||||
|
<path d="M726.7 389.6a21 21 0 0 0-5.6-7c-2.4 0-3.9 3-5.5 4.6-1.1 2.1-2.5 5.6-5.3 2.9-4.5-2.6-5.2-8.3-5.2-13-.3-7.6 2.8-14.7 5.5-21.6 1.7-4.3 1.3-9.2.2-13.6-1.3-5-5.4-8.6-8.5-12.6.2-1.5 4.2-.7 5.7-.4 3.4.9 5.4 3.8 7.9 6 1.8-.6 1-4.2 1.9-5.9 0-2.4 3.2-5.5 4.5-2.1 2 2.2 0 6.5 2.5 7.8 2.4-.9 3.6-3.5 5.8-4.7a8 8 0 0 1 7.8-.5c.9 2.2-2.6 4-3.6 6a20 20 0 0 0-3.8 18c1.4 5 3.8 9.5 4.7 14.5a40 40 0 0 1-.5 17.2c-.9 3.4-3.8 5.6-6.8 7q-1-1.1-1.7-2.6"/>
|
||||||
|
<path d="M711.6 326.9c-3.4-2.5-4.5-4.8-4.5-9.5 0-2.3.5-3.6 2-5.8q3.6-4.7-1.3-3.3-7.8 2.3-8-4.3c0-2.2.4-3.1 3.3-6.7q3.6-4.1 2.8-4.8-.6-.7-9 7.8a124 124 0 0 1-11.4 10.6c-9.8 6.6-19.2 7.6-23.5 2.5-2.2-2.6-2.1-4 .4-5.6a27 27 0 0 0 4.4-3.7 86 86 0 0 1 16.1-11.6q5.5-2.9 2.1-3c-3 0-12.5 6.2-19.8 12.8-2.1 2-5.2 4.2-6.8 5a25 25 0 0 1-13.9 1c-2.2-.7-6.3-4.5-6.3-5.9 0-.3 1-1.1 2-1.8a30 30 0 0 0 4.6-3.2c5.8-5 16.8-10.3 25.5-12.2 2.8-.5 1.7-2-1.4-1.8a56 56 0 0 0-25 11.7c-8.3 6.9-20.8 6.2-24.8-1.3-.7-1.3-1.2-2.5-1-2.7a93 93 0 0 0 20.4-7.8 52 52 0 0 1 18.1-6.5c2.8-.5 3-1.9.3-2.2-3.6-.4-9 1.4-18.5 6-12.3 6.1-15.8 7.2-22.2 6.8-6-.4-9.3-1.9-14-6.4-3.2-3-7.6-10.5-6.8-11.4a64 64 0 0 0 15.8 1.3c8.3 0 10.6-.2 15-1.5a84 84 0 0 0 24-12.1 58 58 0 0 1 36.8-13.6c12.4 0 20.2 2.8 27.2 9.9 2.4 2.4 4.4 3.9 4.7 3.6s.6-4.5.7-9.3c0 0 3.7-.4 4.5.7 0 7.7 0 8.4 1.2 8.4q1.2-.1 2-2c1-2.5 5-6 9.2-8.2 9-4.5 24.7-4.7 37.3-.3a62 62 0 0 1 16.7 9.5 90 90 0 0 0 24 12c6.8 2 19 2.5 25.1 1l5.4-1c2.3 0-1.6 7.6-6.2 12.1-8.4 8.2-19.3 8.1-34.6-.1-9.6-5.2-21-8-21-5.2q0 1 1.5 1 5 0 18.7 6.5a54 54 0 0 0 18.3 6.5q3.5 0 .2 4.7-3.5 5-11.7 5c-5.3 0-8.3-1.1-13-5-8-6.6-27.6-14-26.9-10q.2 1 3.2 1.5a56 56 0 0 1 23.1 11l5.9 4.3c1.1.6 1.1.8.2 2.5-1.4 2.8-5.2 4.9-9.2 5.3q-7.6 1-14.5-5c-10-8.3-19.3-14.3-22.3-14.3q-3.7.1 3 3.7a80 80 0 0 1 15.8 11c2 1.9 4.3 3.7 5 4.1 1.9 1 1.8 2.4-.2 5s-5.4 3.8-9.7 3.3c-8.6-.9-15.4-5-26-16a71 71 0 0 0-8.2-7.8q-2 .2 2.2 5c3.4 3.7 4 5.8 2.7 9-1.1 2.6-3 3.3-6.8 2.2q-6-1.5-2 3.1c3.8 4.9 3.3 10.7-1.5 14.8a12 12 0 0 1-3.4 2.3q-.8-.1-2.3-2.4-4.3-6.9-8.7 0l-2 3z"/>
|
||||||
|
<path d="m726.7 233-5.2 4-4.6-3.4v27.8h9.8z"/>
|
||||||
|
<path d="M694.9 204.3a88 88 0 0 1-9 32.3l11.1-10.3 7.7 9.2 8.4-9.4 8.5 8 8.2-8.3 8.5 10 7.4-8.2 12.6 9c-4.6-10-10.7-18.6-10-32.8-12.1 9-41 10.6-53.4.4z"/>
|
||||||
|
<path d="M717 197.6q-7-.2-13.4 1a20 20 0 0 0-7.8 3c.3 8.6 41 12.1 51.9.2a20 20 0 0 0-8.2-3.3c-4-.8-8.6-.8-12.9-1v7.1H717z"/>
|
||||||
|
<path d="M724.9 154h-6.3v49.4h6.4z"/>
|
||||||
|
<path d="m724.9 155.2-2.4 23.7 24.3 11.9-12.3-16.5 16.8-5.5zm-2.7-6.1c-3.7 0-6.4 1.4-6.4 3s2.7 3 6.4 3 6.4-1.4 6.4-3-2.7-3-6.4-3"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#f7e017">
|
||||||
|
<path d="M314 375.9q3.8-1.1 5.3-5.6c.5-1.6.9-3.2.8-3.6-.2-1-1.4-1-2.6.1-.9.7-1 1.1-.8 2.6.7 3.7-.7 4.7-7.7 5.4-.7 0-2.8 0-4.5-.3q-5.1-.6-3.4 1l2.1.9c1.9.5 8.8.2 10.8-.5m14.7-.6c.4-.4 1.7-1 3-1.5q2.4-.6 3.3-2.2c2.1-3 1.7-5.7-1.3-9.3-1.7-2-2.4-1.9-3.7.3-1.2 1.8-1.1 2 .5 2.4q1.4.4 2.1 1.6 2.7 4.8-1.4 5c-2.4 0-3 .4-3.7 2l-.6 1.9c0 .6 1 .5 1.8-.2m-4.8-3.5c.4-1.3.6-3.5.5-8a33 33 0 0 0-.3-6.4c-.4-.4-2.3.8-2.6 1.7a3 3 0 0 0 .5 2.1c.6 1.2.7 2.4.5 7q-.5 8.9 1.4 3.6"/>
|
||||||
|
<path d="m312.6 369 .7-5c.1-1.7.5-3.8.7-4.7q1-3.5-1.8-1.6l-1.3 1 .2 3.3c.3 3-.2 8.5-.9 10.7q-.3.9.9-.5c.6-.7 1.3-2.2 1.5-3.3zm-10 1.6c2.4-2 2.1-5.6 2.7-8.4 0-1.9 1.2-4.1.4-5.8-2.3.4-3.7 2.6-2.5 4.7 0 2.5 0 5.2-1.3 7.4-1 1.5-4.4 1.1-4.2-1 .8-3-2.9-1.5-4.3-.7-1.1.8-3.5.9-2.6-1-.6-2.7-3.9-1-5.7-1-1.7 0-.1-3.5-2.6-3-4.5-.3-9.5.1-13.5-2.6-2.3-1.1-2-3.9-.7-5.7 1.4-2.4 1.8-5.5 4-7.4 2.3-2.1-2-1.2-3-.5-2.2 1.2-.2 4.3-2 6-1 1.8-2.4 4.2-4.8 3.9-3.5-.7-5.5-4-8-6-2.2-.5-1 3.4.2 4.2a22 22 0 0 0 7.4 3.6c2.6-.5 2.7 3 5 3.5 4 2 8.6 2.5 13.1 2.8 1.8.1.8 3.3 3.1 2.6 1.3.4 4.3-.5 4.4 1-2 2.4 1.9 2.3 3.3 2 1.9-.4 4.2-1 4.7 1.4 1.5 1.7 4.3 1.4 6.2.5z"/>
|
||||||
|
<path d="M262.8 350.4a24 24 0 0 0 2.4-4.2 16 16 0 0 1 2-3.6q1.6-2-1.4-1.6-1.4.2-1.5 1.5a23 23 0 0 1-2.5 7c-1.7 2.5-1.7 2.6-1 2.6q.6-.2 2-1.7m-25-15.7c-1.9 0-2 1.2-.2 1.8q1.4.4 2.3 2.3c1.7 3.5 2.8 4.2 7.5 4.6l3 .2.2 1.9q.1 1.7.5 1.8l2.6-1c2.2-1.2 4.3-3.8 4.3-5.5 0-1-1.8-2.2-3.4-2.2-.7 0-2 .6-3.1 1.4-3.4 2.4-7 2-9-1.2q-2.3-4-4.7-4zm16.3 6.5q1.3 0 .6 2a.9.9 0 0 1-1.7 0c-.4-1 .1-2 1-2zm126.5-4c-1.3 0-1.6.2-2.4 1.4-1.3 1.9-1.4 6-.2 7.4.7.9.8.9 2.3.2 2.2-.9 2.6-.8 2.5.3 0 3-4.2 8.7-8.6 11.7a10 10 0 0 0-2.4 2c-.3.8 1.3.7 3.3-.4a21 21 0 0 0 7.9-8c1.1-2.3 1.3-3 1.5-7 0-3.8 0-5-.6-6.2-.8-1.4-1-1.5-2.8-1.5h-.5zm.1 2.5c1 0 1 .2 1.2 1.6q.1 1.4-.7 2.3c-.8.7-1 .7-1.6-.4-1-1.6-.4-3.5 1.1-3.5m-20.2 28.5c3.9-2 6.2-4.1 7.6-7.2l1.3-3.1c0-.6-1.9-1.5-3-1.5s-1.4-.8-1-3c.5-2.1 0-4.8-1-4.8q-.7.1-1.3 1.1c-.6 1-.7 1.4-.2 2.7q1 3-1.7 4.9-1.3 1-1.3 2l.1 1 2.1-1 2-1.2 1.1 1q1.1.8 1.2 1.7c0 2.4-6.8 6.4-11.4 6.8-2.5.2-3 0-3.8-.8q-1-1-.7-2l.5-2.6q.5-3-2 .6c-1.2 2-1.6 4.1-.9 5.2.6 1 4.4 1.8 7.2 1.6q2.5 0 5.2-1.4m26-14.5c2.4-2.5 3.5-5.5 3.5-10v-3.5l2-1c2.7-1.2 5.2-3.7 5.2-5.1q0-2.1-1.8.2c-.9 1.1-2 1.8-6 3.7-1 .4-1.1.7-1.4 5-.2 5-1 6.8-3.7 10.2-1.7 2-1.8 2.4-.6 2.4.5 0 1.8-.9 2.8-2zm-26.7-2.8c.2-.7-1.2-1.2-1.7-.6q-.5.4-.2.9c.4.6 1.6.4 1.9-.3m36.8-9.5c.3-.8-1.1-1.3-1.7-.7q-.4.5-.1 1c.4.6 1.6.4 1.8-.3m-44.3-25.9q-1.4 1-2.1 2.3c-.5.3-.1.6.1 1 1.7 1.7 2.4 4.2 3.2 6.5.8 2.7 1.8 5.6 1 8.4-.3 1-1.2 2.1-2.4 1.8-2-.1-4-.7-6-.7-1.9.1-3.3 1.8-5.1 1.6-1.2 0-1.2-2.4-2.2-1.7-.6 1.3-.3 2.7-.4 4q.5.3 1.1.2h3.7c.2 1.2.2 2.7 1 3.7q2 .4 4-.5c1.2-.6 1.4-2.1 1.8-3.3.4-1.3 2-1 3-1.5a6 6 0 0 0 4-5.7c-.2-3.9-1.6-7.4-2.8-11l-1.5-4.9zm-6 21.8c1.3 0 1.9 1.6 1.6 2.7-.5 1.5-2.4.6-2.7-.5-.7-1-.3-2.3 1-2.2z"/>
|
||||||
|
<path d="M296 324.8q-1 0-2 .7c-3.5 2.5-4.5 5.4-2 6.6q2.6 1.1-1.5 3.2c-4 2-7.5 1.7-14.2-1q-2.3-1-1.7 1c.4 1.5 1.8 2.3 5.1 3 3.6 1 8 .7 10.8-.5a14 14 0 0 0 4.3-3.4l2.2-2.3 2.5.3c3.1.4 3.2.4 3.2 1.9 0 1.2 0 1.2 2.9 1.5l4.7.2q1.9 0 2.4.9c.6.7.9.8 5.6.4 4.4-.4 5.2-.4 7.2.3q2.3.6 4.1.5c3.4-.4 8-3.1 8.7-5.1 0-.3 1.3-.7 2.7-1q5-1.1.4-1.8a23 23 0 0 1-4.6-1.1 12 12 0 0 0-3.5-.9c-1.7 0-3.3 1-3.3 2.2 0 .7.2.8 2.3.6 1.8-.2 2.4-.1 3.4.7q1.1.9 1 1.3c-.5.8-4.5 2.6-6.2 2.9a5 5 0 0 1-3-.5c-1.6-.8-3.8-.9-4.3-.2q-.3.4-1.3-.5l-1-1-2.4 1q-3.4 1.6-3.3-.2c0-.5-.7-.6-4.2-.3-3.9.2-4.3.1-5-.7q-1-.9-.2-1.7c.4-.8.4-1 0-1.5q-.4-.6-2.5 0c-3.9 1-5 .5-5-2.5q-.2-3-2.3-3m-1 2.8q.3 0 .7.4.5.6.3 1.3c-.3.9-2 .9-2.3 0q-.3-.8.5-1.3z"/>
|
||||||
|
<path d="M288 330.4c2.4-1.5 2.4-1.4 2.7-5.5.2-3 .2-3.2-.6-3.2q-1.8 0-1.8 3.7c0 1.6-.2 2.3-1 3-2 2-6.8 1.1-7.5-1.3q-.3-1 1.1-3c2.1-3 1.7-3.8-1-1.5-1.7 1.6-2 1.6-1.7.3q.4-2-1.8-1.4-1.1.2-1.3 1.6-.3 1.2-1.3 1.5c-1.2.3-3.2-.8-3.2-1.8 0-.7 3-4.4 6.9-8.4 1.4-1.5 2.6-3 2.6-3.1q-.2-.4-1.7-.4-2 0-1.8.8c0 .4-1.9 3-4.3 5.7-5 5.6-5.4 6.7-3 8.2a6 6 0 0 0 6.6-.2l1.6-1.1v2c0 2.5.5 3.5 2.5 4.5a8 8 0 0 0 8-.4m104.4-34.6c-1.8 1.1-.4 3.4 0 5-.8 2-3.5 2.6-5.5 3-2.8.5-4.8 2.8-5.8 5.3-.6 1.6-2 4-3.5 1.6-1.3-1.3-3.7-2.4-5.2-.8-1.2 1.1-1.5 2.7-2 4.2-.7-1.1-1-2.8-2.4-3.2-2.4.3-1.5 3.3-.4 4.5 1 1.5 2 3.3 1 5-1 2-4 3.4-5.7 1.7-1.6-.9-.5-4-2.2-4.2-.8.6-.8 3.9-2.1 2.1-1-1.5-.4-3.6-1.6-4.9-1.3.2-2.4 2.5-2 3.7 1.8 2.4 2.6 5.4 3.3 8.3.4 1.2-.1 3.5 1 4 .7-1.9 0-4 .6-5.9 1.8-.2 3.7.6 5.5.2 2.7-.3 4.7-2.6 5.6-4.9q.3-2.7-.1-5.4c2 .4 4.2.4 6.2 1 1 1.5-.3 3.7-.6 5.3-1 3.4-3.7 5.8-6.2 8-1.1.7-1.2 2.4.3 1.5a15 15 0 0 0 7.5-8c1.1-2.6.2-5.5 1-8.1 1-2 3.5-1.6 5.4-1.6s3.5-2.3 2.9-4.2c-.6-2.2 1.7-3.2 3.2-4 2.1-1 3.7-3.1 3.5-5.5 0-1.3 0-3.6-1.7-3.7m-7.3 12.5c2.2.6-.4 4.8-1.6 2.1-.4-1 .5-2.1 1.6-2.1m-10.3 3c2.9-.1 1.8 4-.6 2.2-1.3-.7-.9-2.2.6-2.2M270 327.6q0-.6-.6-.7c-.7 0-1.2.7-.9 1.3.4.7 1.3.3 1.5-.6m34-3.6q0-1-.8-.8c-1.1.2-1.3 1.7-.1 1.7q.9 0 .9-1zm-42-20.4c-1.3-.3-2.2.9-2.7 2q-1.4 2.8-4 4.3-2 .4-3.9-.8c-1.3-.7-1-2.3-1.6-3.4-1-.8-2.7.3-2.6 1.5-.1 1.6 1.3 2.5 2.6 3.1 1 .7 2.6 1 3 2.3 0 1.1.4 2.4 1.7 2 1.5 0 2 1.8 1.3 2.9a6 6 0 0 0-.7 4c.7.7 1.4-1 2-1.4l1-1.4q4 .4 8 .4c2 0 3.5-1.2 4.7-2.6 1.8-1.8 3.2-3.9 5.1-5.4 1.4-.4.7-3-.8-2.2-1.3.5-1.7 2-2.6 2.9a31 31 0 0 1-5 5.2c-1.5.6-3.1.3-4.6 0-.6-.5 1.2-1 1.5-1.6q1.4-1.1 2.3-2.7c-.5-1-1.9-1-2.9-1-2.4.2-4.3 2.3-6.8 2.5-1.9 0-.9-2 0-2.7q2.5-3 5.1-5.7c.5-.6 2.3-1.2 1.2-2q-.6-.2-1.3-.2m1.2 10c1.3.7-.8 1.8-1.6 1.7-1.1.3-1.2-.8-.2-1q.9-.6 1.8-.7m-3.8 2.6c.7 0 2.2.7.8 1.1-1 .8-2-.8-.8-1.1"/>
|
||||||
|
<path d="M289.4 317.8c0-1-1.6-.8-1.8.2q-.3.8.8.6 1-.1 1-.8m74.7-6.6c.2-.9-1-1.5-1.7-.8s0 1.9.8 1.7q.6-.1 1-.9zM248 302.1c1.1-1 1.2-1.1.7-3.3-.8-3.1-.7-3.5.5-3.8 1.5-.3 5.3 1.7 6 3.3.8 1.3.7 1.4-.4 2.4-1.2 1.1-1.2 2.4 0 2.4 1 0 3.7-2.6 3.7-3.5 0-1.3-3-4.4-5.4-5.5a11 11 0 0 0-4.6-1c-3.1 0-3.5.7-2.7 4.2q1.4 6.4-3.7 1.6a10 10 0 0 1-3.5-8.6q0-6 5.1-6.6 3.5-.6 0-1.2c-3.6-.6-6.6 1.8-7.7 6-1.3 4.7 1.6 10.4 6.7 13.3 2.7 1.5 3.7 1.6 5.3.3m139.2-5.2q.5-.5.5-1.4.1-1.3 1.1-2.4 1-1.4 1-1.8c0-.8-1.3-.8-2.3 0q-1.8 1.4-2 0l1.2-.9q2.4-1.1.4-2.1c-1.7-.8-3.5.6-3.6 3-.1 1.6 0 1.8 1.2 2.5s1.3 1 1.1 2q-.3 2.6 1.4 1.1m13-1.4q1.6-1.6 1.8-2.2t1.9-2c2.8-2 3.5-4 2.2-7.3-.5-1.3-2-3-5.5-6a26 26 0 0 0-5.4-4.4c-.9 0-.7 3.4.2 3.7 1.7.6 2.8 1.3 5.4 3.7 3.2 2.8 4.6 5.5 3.8 7-.7 1.4-1.7 1-4.5-2a14 14 0 0 0-3.2-2.9q-.5.1-.5 1.4-.1 1.4 2 3.5c2.3 2.6 2.5 4.1.7 5.5l-1.4 1a33 33 0 0 0-9-10q-.5 0-.5 1.4c0 1.3.2 1.7 1.2 2.2a38 38 0 0 1 7 7l1.8 2zm6-16.8c-.5-1.2-8.4-9.4-9.3-9.4q-.6 0-.4 1.8c0 1.6.3 1.8 1.4 2.1a20 20 0 0 1 4.6 3.7 17 17 0 0 0 3.7 3zm-47.8 92.6a1.2 1 0 1 1-2.3 0 1.2 1 0 1 1 2.3 0m4.2-1.4a1.2 1 0 1 1-2.4 0 1.2 1 0 1 1 2.4 0"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,673 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bo" viewBox="0 0 640 480">
|
||||||
|
<path fill="#007934" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#ffe000" d="M0 0h640v320H0z"/>
|
||||||
|
<path fill="#d52b1e" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m368.6 210.7-98 97.9-1.3-1 98-97.8 1.3 1z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M374.7 204.5c-.4.7-1.4 2.5-1 3.8l-2-1.5q.7 1.4-.1 1.8c-.3.4-1.4.3-2.1.2q1 .7 2.4 1h2c-.7.3-2.2.8-3.3 1-.5.1-1.6.2-2 0-.6.5-1.9-.4-1.3-1q-.4-.5-.4-1.4-.1-1.2.7-3l.5 1.7q.4.8 1.1 1.4-.5-.8.1-1.8.9-.7 2-.2l-1.9-1.3c.8 0 3.3-1 4.1-1.5l4.9-3.8z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m387 222.8-125.7 70.9-.9-1.2 125.7-71 1 1.3z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M394.9 218.3c-.6.6-2.2 2-2.2 3.4l-1.4-2q.2 1.4-.7 1.7c-.4.4-1.4 0-2-.3a5 5 0 0 0 2 1.5l1.7.6c-.6.1-2.3.3-3.4.2-.5 0-1.5-.2-1.9-.6-.7.4-1.6-.8-1-1.2l.2-1.4q.3-1.2 1.6-2.8v1.8q.1.7.5 1.6-.2-1 .7-1.7 1-.5 2 .3l-1.4-1.7c.8.1 3.5-.2 4.4-.5l6-2.5c-1.7 1-4.6 3-5.1 3.6z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m376.3 217.5-113 85.2-1-1.1 112.9-85.2 1.1 1z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M383.4 212.1c-.5.6-1.8 2.3-1.6 3.6L380 214q.4 1.4-.4 1.7c-.4.4-1.5.2-2.1 0q.9.8 2.2 1.2l1.9.3c-.7.3-2.3.6-3.4.6-.5 0-1.6 0-2-.3-.6.5-1.7-.6-1.1-1.1q-.2-.5-.1-1.4a6 6 0 0 1 1.1-3l.2 1.8q.3.8.9 1.6-.4-.9.4-1.8 1-.5 2 0l-1.6-1.5c.7 0 3.3-.6 4.2-1s3.8-2 5.5-3.1c-1.4 1.2-4 3.5-4.4 4z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m271.4 210.7 98 97.9 1.3-1-98-97.8-1.3 1z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M265.3 204.5c.4.7 1.4 2.5 1 3.8l2-1.5q-.7 1.4.1 1.8c.3.4 1.4.3 2.1.2q-1 .7-2.4 1h-2c.7.3 2.2.8 3.3 1 .5.1 1.6.2 2 0 .6.5 1.9-.4 1.3-1q.4-.5.4-1.4.1-1.2-.7-3l-.5 1.7q-.4.8-1.1 1.4.5-.8-.1-1.8-.9-.7-2-.2l1.9-1.3c-.8 0-3.3-1-4.1-1.5l-4.9-3.8z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m253 222.8 125.7 70.9c.2-.3.6-1 .9-1.2l-125.7-71-1 1.3z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M245.1 218.3c.6.6 2.2 2 2.2 3.4l1.4-2q-.2 1.4.7 1.7c.4.4 1.4 0 2-.3a5 5 0 0 1-2 1.5l-1.8.6c.7.1 2.3.3 3.4.2.6 0 1.6-.2 2-.6.7.4 1.6-.8 1-1.2l-.2-1.4a6 6 0 0 0-1.6-2.8v1.8q-.1.7-.5 1.6.2-1-.7-1.7-1-.5-2 .3l1.4-1.7a17 17 0 0 1-4.4-.5l-6-2.5c1.7 1 4.6 3 5.1 3.6z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m263.7 217.5 113 85.2 1-1.1-112.9-85.2-1.1 1z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M256.6 212.1c.5.6 1.8 2.3 1.6 3.6l1.7-1.7q-.4 1.4.4 1.7c.4.4 1.5.2 2.1 0a5 5 0 0 1-2.2 1.2l-1.9.3c.7.3 2.3.6 3.4.6.5 0 1.6 0 2-.3.6.5 1.7-.6 1.1-1.1q.2-.5.1-1.4a6 6 0 0 0-1.1-3l-.2 1.8q-.3.8-.9 1.6.4-.9-.4-1.8-1-.5-2 0l1.6-1.5a15 15 0 0 1-4.2-1c-1-.4-3.8-2-5.5-3.1 1.4 1.2 4 3.5 4.4 4z"/>
|
||||||
|
<path fill="#00e519" stroke="#000" stroke-width=".1" d="M300.1 283.4c4-2.6 15.1-4 16.7-3.6-8 6-16 6.3-16.7 3.7z"/>
|
||||||
|
<path fill="#ffe533" stroke="#000" stroke-width=".1" d="M300.2 283.5c.7 2.6 8.7 2.4 16.6-3.6a69 69 0 0 1-16.6 3.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M300.2 283.5c.7 2.6 8.7 2.4 16.6-3.6a69 69 0 0 1-16.6 3.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M300.1 283.4a41 41 0 0 1 16.7-3.6c-8 6-16 6.3-16.7 3.6z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="M347.6 220.2 322 272.5c-1.3 1-2.3-.3-2.7-.2-1.5 1.7-3.6 2-4 2.5-1.8 2.4-.8 4.3-.7 4.5 1.3 1.8-1.6 3.5-1.5 4-.6 1-2.7.9-3.1 2l-4.8 9.3c-.6.5-3.7 6.1-3.7 6.1-2 0-10.2-5.2-10.4-5.1 4.6-7.3 15.6-18.5 15.3-19.2 3.1-5.2 8-10.9 10.1-10.8 3-1.6 4.5-5.7 4-6.6 2.2 0 3.5-1.4 3.6-1.5l18.8-37.6c1.6-.5 1.4.1 1.9 1 0 0 1-1.2.9-1.3.9-.4 1.8.2 1.8.6z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M348.6 208.8c-.6 1 .2 1 .5 1.1l1 .3c1.3 0 2 .7 2 1l-30 61.3c-1.3 1-2.5-.3-2.9-.1l20.6-41.8 10-18.8-2.8-1.2q-1.2-.4-.7-1.6l12.6-21.6-10.2 21-.1.4"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M348.6 208.8c-.6 1 .2 1 .5 1.1l1 .3c1.3 0 2 .7 2 1l-30 61.3c-1.3 1-2.5-.3-2.9-.1l20.6-41.8 10-18.8-2.8-1.2q-1.2-.4-.7-1.6l12.6-21.6-10.2 21-.1.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M308.8 272.4c-3 0-4.6 2-2.7 4.7m1.4-2.4c-1 .6-1.7-.3-1.7-.3m15.2-13c-1.5 5.6-4.3 9.3-5 10.4-2 2.2-3.9 7.2-3.5 8.1l-8.1 13.3"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M322 272.7c-1.4-.5-5.7-4.5-10-3.8-3.3 3.8-5.3 7.7-5.9 8.2q6.1 4.9 7.6 5.4c.7-.4 1-1.5 1-1.5.9-1-.2-1.7-.2-1.7.2-2.5 2-4.2 3.8-4.3 2.2-.2 1.6-.4 1.9-.4 1-.6 1.8-1.9 1.8-1.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M322 272.7c-1.4-.5-5.7-4.5-10-3.8-3.3 3.8-5.3 7.7-5.9 8.2q6.1 4.9 7.6 5.4c.7-.4 1-1.5 1-1.5.9-1-.2-1.7-.2-1.7.2-2.5 2-4.2 3.8-4.3 2.2-.2 1.6-.4 1.9-.4 1-.6 1.8-1.9 1.8-1.9z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M317.6 272.8c-2 0-4 .9-4.8 2.6l4.8-2.6"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M317.6 272.8c-2 0-4 .9-4.8 2.6m-3.5.7q.3.6 1.1.5.9-.4.6-1-.4-.8-1.2-.5t-.5 1zm2.5-3.4q.3.6 1.2.4.8-.3.5-1t-1.2-.5q-.8.4-.5 1z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M345.3 226.1q1.3-.5.8-2l-4.7-1.7s-.6 0-1 .7.1 1.2.1 1.2l4.8 1.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M345.3 226.1q1.3-.5.8-2l-4.7-1.7s-.6 0-1 .7.1 1.2.1 1.2l4.8 1.8"/>
|
||||||
|
<path fill="#00e519" stroke="#000" stroke-width=".1" d="M294.5 286c3.9-2.7 15-4.2 16.6-3.8-7.8 6.2-15.8 6.5-16.6 3.9z"/>
|
||||||
|
<path fill="#ffe533" stroke="#000" stroke-width=".1" d="M294.6 286c.7 2.7 8.7 2.3 16.5-3.8a62 62 0 0 1-16.5 3.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M294.6 286c.7 2.7 8.7 2.3 16.5-3.8a62 62 0 0 1-16.5 3.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M294.6 286a40 40 0 0 1 16.6-3.9c-7.9 6.2-16 6.6-16.6 4z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m340.7 222-24.4 52.8c-1.3 1-2.4-.3-2.7-.2-1.5 1.7-3.6 2.1-4 2.5-1.8 2.5-.7 4.4-.6 4.6 1.3 1.8-1.5 3.5-1.4 4-.6 1-2.7.9-3.1 2.1-.1-.1-4.2 8.4-4.6 9.3-.6.5-3.5 6.2-3.5 6.2-2.1 0-10.3-5-10.5-5 4.4-7.3 15.1-18.7 14.9-19.4 3-5.3 7.7-11 9.9-11 3-1.6 4.3-5.7 3.7-6.6 2.3-.1 3.5-1.5 3.7-1.6l18-37.8c1.6-.6 1.3 0 1.9 1 0 0 1-1.2.9-1.4.9-.4 1.7.2 1.8.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m340.7 222-24.4 52.8c-1.3 1-2.4-.3-2.7-.2-1.5 1.7-3.6 2.1-4 2.5-1.8 2.5-.7 4.4-.6 4.6 1.3 1.8-1.5 3.5-1.4 4-.6 1-2.7.9-3.1 2.1-.1-.1-4.2 8.4-4.6 9.3-.6.5-3.5 6.2-3.5 6.2-2.1 0-10.3-5-10.5-5 4.4-7.3 15.1-18.7 14.9-19.4 3-5.3 7.7-11 9.9-11 3-1.6 4.3-5.7 3.7-6.6 2.3-.1 3.5-1.5 3.7-1.6l18-37.8c1.6-.6 1.3 0 1.9 1 0 0 1-1.2.9-1.4.9-.4 1.7.2 1.8.6z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M341.4 210.6c-.5 1 .3 1 .6 1.2l1 .3q2 .2 2 1l-28.7 61.7c-1.3 1-2.5-.3-2.9 0l19.7-42.2 9.6-19-2.8-1q-1.2-.4-.7-1.7l12-21.8-9.6 21.1-.2.4"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M341.4 210.6c-.5 1 .3 1 .6 1.2l1 .3q2 .2 2 1l-28.7 61.7c-1.3 1-2.5-.3-2.9 0l19.7-42.2 9.6-19-2.8-1q-1.2-.4-.7-1.7l12-21.8-9.6 21.1-.2.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M303 275c-3-.2-4.6 2-2.6 4.6m1.3-2.4c-1 .6-1.7-.3-1.7-.3m15-13.3c-1.5 5.7-4.2 9.4-4.7 10.6-2.2 2.2-3.9 7.3-3.5 8.1l-7.8 13.4"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M316.1 275c-1.3-.5-5.8-4.5-10-3.7-3.3 3.9-5.1 7.8-5.7 8.3a50 50 0 0 0 7.7 5.3c.7-.4 1-1.5 1-1.5.8-1-.2-1.8-.2-1.8 0-2.4 1.8-4.2 3.6-4.3 2.2-.2 1.6-.4 1.9-.4 1-.6 1.7-2 1.7-2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M316.1 275c-1.3-.5-5.8-4.5-10-3.7-3.3 3.9-5.1 7.8-5.7 8.3a50 50 0 0 0 7.7 5.3c.7-.4 1-1.5 1-1.5.8-1-.2-1.8-.2-1.8 0-2.4 1.8-4.2 3.6-4.3 2.2-.2 1.6-.4 1.9-.4 1-.6 1.7-2 1.7-2z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M311.8 275.1c-2 0-4 1-4.7 2.7l4.7-2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M311.8 275.1c-2 0-4 1-4.7 2.7m-3.6.8q.4.6 1.2.4.8-.4.5-1t-1.2-.5q-.8.4-.5 1zm2.4-3.5q.4.6 1.3.5.8-.4.5-1-.4-.8-1.2-.5t-.6 1z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M338.5 228q1.3-.5.7-2l-4.7-1.6s-.5 0-1 .7.2 1.2.2 1.2l4.8 1.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M338.5 228q1.3-.5.7-2l-4.7-1.6s-.5 0-1 .7.2 1.2.2 1.2l4.8 1.8"/>
|
||||||
|
<path fill="#00e519" stroke="#000" stroke-width=".1" d="M340.6 283.3a39 39 0 0 0-16.8-3.7c8 6.1 16.1 6.3 16.8 3.7z"/>
|
||||||
|
<path fill="#ffe533" stroke="#000" stroke-width=".1" d="M340.6 283.3c-.7 2.7-8.8 2.4-16.8-3.6a63 63 0 0 0 16.8 3.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M340.6 283.3c-.7 2.7-8.8 2.4-16.8-3.6a63 63 0 0 0 16.8 3.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M340.6 283.3c-4.2-3-15.8-4-16.9-3.7 8 6 16.2 6.3 16.9 3.6z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m292.7 219.3 25.7 52.9c1.3 1 2.4-.3 2.7-.2 1.6 1.7 3.7 2.1 4.2 2.5 1.8 2.4.8 4.4.7 4.6-1.4 1.8 1.5 3.5 1.4 4.1.6 1 2.7.8 3.2 2l4.8 9.3c.7.5 3.7 6.2 3.7 6.2 2.1 0 10.3-5.3 10.5-5.2-4.6-7.3-15.7-18.7-15.4-19.4-3.1-5.2-8.1-11-10.2-10.9-3-1.6-4.5-5.7-4-6.6-2.3 0-3.6-1.5-3.7-1.6l-19-37.9c-1.6-.5-1.3.1-1.9 1 0 0-1-1.2-.9-1.3-.9-.5-1.8.2-1.8.5z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M291.7 207.8c.5 1-.2 1-.5 1.2l-1.1.3c-1.2 0-1.9.7-1.9 1l30.2 61.9c1.3 1 2.5-.3 3-.1l-20.8-42.3-10-19 2.7-1.1q1.2-.3.7-1.6l-12.7-21.9 10.2 21.2.2.4"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M291.7 207.8c.5 1-.2 1-.5 1.2l-1.1.3c-1.2 0-1.9.7-1.9 1l30.2 61.9c1.3 1 2.5-.3 3-.1l-20.8-42.3-10-19 2.7-1.1q1.2-.3.7-1.6l-12.7-21.9 10.2 21.2.2.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M331.9 272.1c3 0 4.6 2.1 2.7 4.7m-1.4-2.3c1 .6 1.7-.4 1.7-.4M319.5 261c1.6 5.7 4.4 9.5 5 10.6 2.2 2.2 4 7.3 3.7 8.2l8.1 13.4"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M318.6 272.4c1.3-.4 5.7-4.6 10-3.8 3.4 3.8 5.4 7.7 6 8.2q-6.1 5-7.7 5.5c-.7-.4-1-1.5-1-1.5-.9-1 .2-1.8.2-1.8-.1-2.4-2-4.2-3.8-4.3-2.3-.2-1.6-.4-2-.4-1-.6-1.7-1.9-1.7-1.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M318.6 272.4c1.3-.4 5.7-4.6 10-3.8 3.4 3.8 5.4 7.7 6 8.2q-6.1 5-7.7 5.5c-.7-.4-1-1.5-1-1.5-.9-1 .2-1.8.2-1.8-.1-2.4-2-4.2-3.8-4.3-2.3-.2-1.6-.4-2-.4-1-.6-1.7-1.9-1.7-1.9z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M323 272.5c2 0 4 1 4.8 2.7l-4.8-2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M323 272.5c2 0 4 1 4.8 2.7m3.6.6a1 1 0 0 1-1.2.5.7.7 0 0 1-.5-1q.3-.7 1.2-.5.8.4.5 1zm-2.5-3.4q-.4.6-1.3.5-.7-.4-.5-1 .4-.8 1.2-.5t.6 1z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M295 225.4a1.4 1.4 0 0 1-.8-2l4.8-1.8s.5.1 1 .7-.2 1.2-.2 1.2l-4.8 1.9"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M295 225.4a1.4 1.4 0 0 1-.8-2l4.8-1.8s.5.1 1 .7-.2 1.2-.2 1.2l-4.8 1.9"/>
|
||||||
|
<path fill="#00e519" stroke="#000" stroke-width=".1" d="M345.6 286a38 38 0 0 0-16.6-3.8c7.9 6.2 15.9 6.5 16.6 3.9z"/>
|
||||||
|
<path fill="#ffe533" stroke="#000" stroke-width=".1" d="M345.6 286c-.8 2.7-8.7 2.3-16.5-3.8a62 62 0 0 0 16.5 3.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M345.6 286c-.8 2.7-8.7 2.3-16.5-3.8a62 62 0 0 0 16.5 3.9z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M345.6 286a40 40 0 0 0-16.6-3.9c7.8 6.2 15.9 6.6 16.6 4z"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m299.5 222 24.4 52.8c1.3 1 2.3-.3 2.7-.2 1.5 1.7 3.6 2.1 4 2.5 1.7 2.5.7 4.4.6 4.6-1.4 1.7 1.5 3.5 1.4 4 .5 1 2.6.9 3 2.1.2-.1 4.2 8.4 4.7 9.3.6.5 3.5 6.2 3.5 6.2 2 0 10.3-5 10.5-5-4.4-7.4-15.2-18.7-15-19.4-2.9-5.3-7.7-11-9.8-11-3-1.6-4.3-5.7-3.7-6.6-2.3-.1-3.6-1.5-3.7-1.6L304 222c-1.6-.6-1.4 0-2 1 0 0-.9-1.2-.8-1.4-.9-.4-1.8.2-1.8.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m299.5 222 24.4 52.8c1.3 1 2.3-.3 2.7-.2 1.5 1.7 3.6 2.1 4 2.5 1.7 2.5.7 4.4.6 4.6-1.4 1.7 1.5 3.5 1.4 4 .5 1 2.6.9 3 2.1.2-.1 4.2 8.4 4.7 9.3.6.5 3.5 6.2 3.5 6.2 2 0 10.3-5 10.5-5-4.4-7.4-15.2-18.7-15-19.4-2.9-5.3-7.7-11-9.8-11-3-1.6-4.3-5.7-3.7-6.6-2.3-.1-3.6-1.5-3.7-1.6L304 222c-1.6-.6-1.4 0-2 1 0 0-.9-1.2-.8-1.4-.9-.4-1.8.2-1.8.6z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M298.8 210.6c.4 1-.3 1-.6 1.2l-1 .3q-2 .2-2 1l28.7 61.7c1.3 1 2.5-.3 2.9 0L307 232.5l-9.6-19 2.7-1q1.4-.4.8-1.7L288.8 189l9.7 21.1.2.4"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M298.8 210.6c.4 1-.3 1-.6 1.2l-1 .3q-2 .2-2 1l28.7 61.7c1.3 1 2.5-.3 2.9 0L307 232.5l-9.6-19 2.7-1q1.4-.4.8-1.7L288.8 189l9.7 21.1.2.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M337.2 275c3-.2 4.5 2 2.6 4.6m-1.4-2.4c1 .6 1.8-.3 1.8-.3m-15-13.3c1.4 5.7 4.2 9.4 4.7 10.6 2.1 2.2 3.8 7.3 3.5 8.1l7.8 13.4"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M324 275c1.4-.5 5.8-4.5 10.1-3.7 3.2 3.9 5.1 7.8 5.7 8.3a54 54 0 0 1-7.8 5.3c-.6-.4-1-1.5-1-1.5-.7-1 .3-1.8.3-1.8 0-2.4-1.8-4.2-3.6-4.3-2.3-.2-1.6-.4-2-.4-1-.6-1.7-2-1.7-2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M324 275c1.4-.5 5.8-4.5 10.1-3.7 3.2 3.9 5.1 7.8 5.7 8.3a54 54 0 0 1-7.8 5.3c-.6-.4-1-1.5-1-1.5-.7-1 .3-1.8.3-1.8 0-2.4-1.8-4.2-3.6-4.3-2.3-.2-1.6-.4-2-.4-1-.6-1.7-2-1.7-2z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M328.4 275.1c2 0 4 1 4.7 2.7l-4.7-2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M328.4 275.1c2 0 4 1 4.7 2.7m3.6.8a1 1 0 0 1-1.2.4q-.8-.4-.6-1 .4-.7 1.3-.5.7.4.5 1zm-2.5-3.5q-.4.6-1.2.5-.8-.4-.5-1 .4-.8 1.2-.5t.5 1z"/>
|
||||||
|
<path fill="#cce5e5" stroke="#000" stroke-width=".1" d="M301.6 228q-1.2-.5-.7-2l4.7-1.6s.6 0 1 .7-.2 1.2-.2 1.2l-4.8 1.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M301.6 228q-1.2-.5-.7-2l4.7-1.6s.6 0 1 .7-.2 1.2-.2 1.2l-4.8 1.8"/>
|
||||||
|
<path fill="#a05a2c" stroke="#000" stroke-width=".1" d="m315.3 250.7 35.5-38-1.4-.9-35.6 38z"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M349.8 212.2c-1.2-1-3.4-2.2-5-1.8-.5-2.2 2.5-4.4 3.8-4-.3 2.3 3.2 3.8 3.1 3.8z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M352.1 210.4c1 .8 1.4.8 2.8 1.6 1.4.7 3.2-1.1 4.1-1.7 0 0 1 3.4-1.1 5.6-2 2.3-4.6 2.8-6.5 2.1 0 0 2.6-2.5 1.5-3.6-1-1.1-1.4-1.2-2.5-1.9"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M-27.7-406.5h4.1v2.2h-4.1z" transform="matrix(-.67726 .73575 -.82314 -.56784 0 0)"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m308.2 290.3-1 9 1.8-2.2c.3-.6 1.5-2.1 1.8-7.4 0 0-1-2.9-1.4-2.9-.7-.4-1.2 3.5-1.2 3.5zm2.2-20.1-2.7 15.8c0 .5 1.3 1.6 2.1-1.2l1.5-10.5z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m315.9 284.6-1.4-2-.4 3.1s2 1.5 1.7 4.3l.4-.6.2-1s.5-2 .5-3.1z"/>
|
||||||
|
<path fill="#ffe000" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M307.3 299.4s3.3-3 3.5-9.7l.4-1.9s0 1.5.8.4c.6-1.5.6-2.8.6-2.8s1.2-1.6 1.6.3l-1.2 9.6-.3 2s-.7-1-1.4.1-1.8 2.7-4 2zm4-25.1-1.5 10.5s1.2.6 1.4 3c.1 1.2.6.6.8.4.2-.8 0-2.3 0-2.3l.6-7.3s-1.2-3.4-1.3-4.3z"/>
|
||||||
|
<path fill="#ffe000" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m310.1 298.8-.2 2.6s1 0 1.7-1.2c.8-1.1 1-2.8 1-2.8s-.7-1.1-1.3 0c-.5 1-1.2 1.4-1.2 1.4zm1.8-10.6c.6-1 .8-2.6.7-2.8l-.7.5c.2 1.2 0 2.3 0 2.3z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M315.3 287.1a4 4 0 0 0-1.3-1.5l-1.5 12s-.4 3-2.3 3.7c0 0 1.1 10.3 4.5 7.5.4-.4 1-3.6.9-5.5l-.9-6a27 27 0 0 1 .5-4.7l.6-2.6c.2-.2 0-1.8-.5-2.9z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m316.9 285.3 1 1c.3 0-2.3 19.3-2.3 19.3s0-2-.7-6c-.6-3.2.2-7.5.9-9.6 0 0 .8-.5 1-4.7z"/>
|
||||||
|
<path fill="#f7e214" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m312.6 278.8-.7 7.1s1.8-2.4 2.2-.2l.4-3.2s-1.5-2.4-2-3.7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M311.3 287.8s-.6-6.1-3.6-1.8a10 10 0 0 0 0 3c0 .8.9 1.8 1.2 2.3.7 1 1.3-.2 1.3-.2s.7-1 1-3.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M287.8 237c-.1-3.5-1.2-9.3-1.3-13.3l-12-12.2s-1.4 9.9-5.8 15.5l19 10"/>
|
||||||
|
<path fill="#ffe000" stroke="#000" stroke-width=".1" d="M288.7 237.3c.5-2.4 1-5 1.5-10.2l-7.7-7.6c0 3.2-3.6 8.1-3.9 14"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M295.3 244.7c1-4.3-1.3-4.8 2-10.8l-7.2-6.8c-1.3 4-2.2 6.6-2 10l6 4.4"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M267.6 270c-1.5-4.2.5-12.7.2-18-.1-3.5 2.5-16.7 2.4-20.6l-14.1-8.8s-.6 14.3-2.3 29.9q-2 12.1-.4 21.5c1.5 8.7 3 12.2 6.5 15.9 6.3 6.5 19.7 2.7 19.7 2.7 11.3-2.3 17.8-9.4 17.8-9.4s-3.7.8-9.6 1.4c-13-.9-18.2 2.4-18.6-11"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="m305.4 278.8.2-.1-5.9 1.9-8 .6c-17.2.4-15-10.3-14.3-27.5.1-6.6 1.4-14.8 1-17.7l-11.5-6.6c-3.8 10.6-2.6 18-3.3 23.5-.4 6-1.7 17.4.2 22.4 2.7 11.6 11.8 11.2 24.2 10.1 6.1-.5 9.4-2 9.4-2l8-4.6"/>
|
||||||
|
<path fill="#007a3d" stroke="#000" stroke-width=".1" d="M305.6 278.5a34 34 0 0 1-6 2.2l-8 .8c-12.5 1-19.8-7.7-18.2-27.6 0-7 .2-10.2 2.6-19.5l7 4v.7c-.5 2-1.5 7-1.5 9.2 0 16 10.1 28.2 23.9 30.3h.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M260 230.6c1 2 8 12.5 12 14.5m-11-7.6c1.3 2 9.8 14 13.6 14.6m-15.5 3.6c2 2.4 4 7 9.7 10m-7 3.3c4 3.7 13.6 11.6 23 12m-23-6c2 2.3 6.2 13.3 24 8.3m-26.7-6.3c1.2 2.6 10.1 17.3 26 11.6"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M277.8 264.8c-1.5-4.3.5-12.7.2-18.1-.1-3.5 1.5-15.5 1.5-19.5l-13.2-9.9s-.6 14.3-2.3 30a84 84 0 0 0-1.5 23.9c1.8 10 6.6 12.8 7.5 13.6 6.7 6 22 5.4 23.5 4.9 10.8-4 15.6-10.8 15.6-10.8s-5.2-.1-11.1.5c-13-1-19.7-.4-20.1-13.8"/>
|
||||||
|
<path fill="#ffe000" stroke="#000" stroke-width=".1" d="M315.6 273.5h.2l-5.9 1.8-8 .6c-17.2.5-15-10.2-14.3-27.5.1-6.5.3-12.4 0-15.3l-10.3-7.6c-3.8 10.7-2.8 16.7-3.5 22.2-.4 5.8-1.7 17.3.2 22.3 2.7 11.7 11.8 11.2 24.2 10.2 6.1-.5 9.4-2.1 9.4-2.1l8-4.6"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M315.9 273.4c-2.4 1.2-6 2.1-6 2.1l-8 .8c-12.5 1-19.8-7.7-18.2-27.6 0-7-.3-7.5 2.2-16.9 3.8 2.5 11.1 8.8 11.1 8.8s-2 2.8-1.5 6.7c0 16 6.4 24 20.2 26.1l1.5-13"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M270.1 225.3c1.2 2 8.1 12.6 12 14.6m-10.8-7.7c1.2 2 9.7 14 13.5 14.7m-15.5 3.6c2 2.3 4 7 9.7 10m-7 3.3c4 3.6 13.6 11.6 23 12m-23-6c2 2.3 6.2 13.2 24 8.3m-26.7-6.4c1.2 2.7 10.1 17.3 26 11.7"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="m256.2 224-.7 6q-.2 6.6.1 9.8c0 .2.9 5.5.6 5.8-1 1.1-1.1 1.2-2.2.4-.1-.2.5-5.6.5-6.3l.4-9.9c0-1 1-6.4 1-6.4s0-1.2.3.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m256.2 224-.7 6q-.2 6.6.1 9.8l.7 5.8c-1 1.1-1.2 1.6-2.3.7-.1-.2.5-5.9.5-6.6l.4-9.9c0-1 1-6.4 1-6.4s0-1.2.3.5z"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="M256 222.2s-1 6-1.2 9.6l-.3 7.9-.5 4.5c-.1.7.1.2 0 .2-.9.5-1.5.1-2-.3-.2-.1 1.4-3.8 1.4-4.5.9-10.8 2.4-17 2.4-17s-.6 3.7.3-.4"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M256 222.2s-1 6-1.2 9.6l-.3 7.9-.5 4.5c-.1.7.1.2 0 .2-.9.5-1.5.1-2-.3-.1-.1 1.4-3.8 1.5-4.5.8-10.8 2.3-17.1 2.3-17.1s-.6 3.8.3-.3zm-.4 17.3s-1 .4-1 .2m0-1.3s.7 0 .8-.2m0-1s-.6.3-.8 0m.8-1.6h-.6m.6-1.5h-.6m.5-2.1s-.3.1-.4-.1m.5-1.7h-.5m-.5 9.5s-.9.2-1-.1m1.1-2s-.9.1-1-.1m1-1.3h-.7m.9-1.5h-.7m.7-1.7h-.5m.7-1.5h-.6m.6-1.7s-.4.3-.4 0m0 9s-.9 0-.9-.3m12.8-19.8-.7 6a63 63 0 0 0 0 9.8c0 .3 1 5.5.7 5.8-1 1.2-1.1 1.3-2.2.4-.1-.1.5-5.5.5-6.3l.4-9.8c0-1.1 1-6.5 1-6.5s0-1.1.3.6"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m266.4 218.7-.7 6a73 73 0 0 0 .1 9.8l.7 5.8c-1 1.2-1.2 1.6-2.3.7-.1-.1.5-5.8.5-6.6l.4-9.8c0-1.1 1-6.5 1-6.5s0-1.1.3.6z"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M266.3 217s-1.2 6-1.3 9.5l-.3 8-.5 4.4v.3c-.9.5-1.5 0-2-.4-.2 0 1.4-3.7 1.4-4.5a127 127 0 0 1 2.4-17l.3-.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M266.3 217s-1.2 6-1.3 9.5l-.3 8-.5 4.4v.3c-.9.5-1.5 0-2-.4-.2 0 1.4-3.7 1.4-4.5.9-10.8 2.4-17 2.4-17zm-.5 17.2s-1 .5-1 .2m0-1.2s.7 0 .8-.3m0-1s-.6.4-.8.1m.7-1.7h-.5m.6-1.5h-.6m.5-2s-.4 0-.4-.2m.5-1.7h-.5m-.5 9.6s-.9.1-1-.2m1.1-1.9s-.9 0-1-.2m1-1.3h-.7m.9-1.4h-.7m.7-1.7-.5-.1m.7-1.4-.6-.1m.6-1.6s-.4.2-.4 0m0 9s-.9 0-.9-.4"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M274.3 211.3s.5 5.1.2 8c-.3 3.5-.3 4.5-.6 6.6v3.9c.8.5 1.5.2 2 0 .3-.1-1-3.3-1-4 .5-8.9-.4-14.3-.4-14.3l-.2-.2"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M274.3 211.3s.5 5.1.2 8c-.3 3.5-.3 4.5-.6 6.6v3.8c0 .5-.1 0 0 .1.8.5 1.4.2 2 0 .2-.1-1-3.3-1-4 .5-9-.4-14.3-.4-14.3zm-.3 14.6s.9.2 1 0m-.8-1.7s.9.2 1 0m-1-1.2h.9m-.8-1.2h.7m-.5-1.4h.5m-.5-1.3h.5m-.4-1.4s.4.2.4 0m-1 7.4s1 .1 1-.1"/>
|
||||||
|
<path fill="#005000" stroke="#000" stroke-width=".1" d="M306 221.7h.8z"/>
|
||||||
|
<path fill="#fff" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M316.7 256.4s-.3-.2-.4 0l.1.2zm-1 1.1 2.1-.1"/>
|
||||||
|
<path fill="#e8a30e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M292.2 284.2c-.2 3-7.4 6.6-12.7.1-5.7-4.5-4.5-11.4 0-12.3l54.7-53.3c2.2-1.2 2.4-2.3 3.5-3.5 2.3 2.5 7 6.8 9.6 9q-2.6 1.9-3.2 3.5z"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" stroke="#000" stroke-linejoin="round" stroke-width=".1" d="M337.8 215c2.6-3.5 12.8 5.8 10 8.7-2.6 2.8-12.4-5-10-8.6z"/>
|
||||||
|
<path fill="#cccccf" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M347 223c-2 1.4-9.3-4.8-8.1-7.2 2-2.2 10.1 5.7 8 7.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M344 227.9a16 16 0 0 1-9.9-9m-23 44.8a16 16 0 0 1-11.4-11.6m9.1 14.3a16 16 0 0 1-11.4-11.6m-2.1 25.8c-5.8-1.8-10.4-6-12.2-12.1m9.8 14.8a18 18 0 0 1-12.3-12.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M279.4 285q-.4 2-2.1 1.4m13.7-2c-2.1 3.5-4.5 2.4-6.5 2.5"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M276.4 285.2q.2 1.6 1.7 1.6a2 2 0 0 0 1.7-1.6q-.1-1.6-1.7-1.6c-1.6 0-1.7 1-1.7 1.9"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M279.5 284.9q-.5 2-2 1.3m13.5-1.7c-2.1 3.4-4.5 2.3-6.5 2.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m331.8 290.3 1 9-1.8-2.2c-.3-.6-1.5-2.1-1.8-7.4 0 0 1-2.9 1.4-2.9.7-.4 1.2 3.5 1.2 3.5zm-2.2-20.1 2.7 15.8c0 .5-1.3 1.6-2.1-1.2l-1.5-10.5z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m324.1 284.6 1.4-2 .4 3.1s-2 1.5-1.7 4.3l-.4-.6-.2-1s-.5-2-.5-3.1z"/>
|
||||||
|
<path fill="#ffe000" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M332.8 299.4s-3.4-3-3.6-9.7l-.4-1.9s0 1.5-.8.4c-.6-1.5-.6-2.8-.6-2.8s-1.2-1.6-1.6.3l1.2 9.6.3 2s.7-1 1.4.1 1.8 2.7 4 2zm-4.1-25.1 1.5 10.5s-1.2.6-1.4 3c-.1 1.2-.6.6-.8.4-.2-.8 0-2.3 0-2.3l-.6-7.3s1.2-3.4 1.3-4.3z"/>
|
||||||
|
<path fill="#ffe000" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m329.9 298.8.2 2.6s-1 0-1.7-1.2c-.8-1.1-1-2.8-1-2.8s.7-1.1 1.3 0c.5 1 1.2 1.4 1.2 1.4zm-1.8-10.6a6 6 0 0 1-.7-2.8l.7.5c-.2 1.2 0 2.3 0 2.3z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M324.8 287.1a4 4 0 0 1 1.2-1.5l1.5 12s.4 3 2.3 3.7c0 0-1.1 10.3-4.5 7.5-.4-.4-1-3.6-.9-5.5l.9-6a27 27 0 0 0-.5-4.7l-.6-2.6c-.2-.2 0-1.8.5-2.9z"/>
|
||||||
|
<path fill="#d52b1e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m323.1 285.3-1 1c-.3 0 2.3 19.3 2.3 19.3s0-2 .7-6c.6-3.2-.2-7.5-.9-9.6 0 0-.8-.5-1-4.7z"/>
|
||||||
|
<path fill="#f7e214" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m327.4 278.8.7 7.1s-1.8-2.4-2.2-.2l-.4-3.2s1.5-2.4 2-3.7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M328.7 287.8s.6-6.1 3.6-1.8c0 0 .2 2.4 0 3 0 .8-.9 1.8-1.2 2.3-.7 1-1.3-.2-1.3-.2s-.7-1-1-3.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M352.2 237c.1-3.5 1.2-9.3 1.3-13.3l12-12.2s1.4 9.9 5.8 15.5l-19 10"/>
|
||||||
|
<path fill="#ffe000" stroke="#000" stroke-width=".1" d="M351.3 237.3c-.5-2.4-1-5-1.5-10.2l7.7-7.6c0 3.2 3.6 8.1 3.9 14"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M344.7 244.7c-1-4.3 1.3-4.8-2-10.8l7.2-6.8c1.3 4 2.2 6.6 2 10l-6 4.4"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M372.4 270c1.5-4.2-.5-12.7-.2-18 .1-3.5-2.5-16.7-2.4-20.6l14.1-8.8s.6 14.3 2.3 29.9q2 12.1.4 21.5c-1.5 8.7-3 12.2-6.5 15.9-6.3 6.5-19.7 2.7-19.7 2.7-11.3-2.3-17.8-9.4-17.8-9.4s3.7.8 9.6 1.4c13-.9 18.2 2.4 18.6-11"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="m334.6 278.8-.2-.1 5.9 1.9 8 .6c17.2.4 15-10.3 14.3-27.5-.1-6.6-1.4-14.8-1-17.7l11.5-6.6c3.8 10.6 2.6 18 3.3 23.5.4 6 1.7 17.4-.2 22.4-2.7 11.6-11.8 11.2-24.2 10.1-6.1-.5-9.4-2-9.4-2l-8-4.6"/>
|
||||||
|
<path fill="#007a3d" stroke="#000" stroke-width=".1" d="M334.4 278.5a34 34 0 0 0 6 2.2l8 .8c12.5 1 19.8-7.7 18.2-27.6 0-7-.2-10.2-2.6-19.5l-7 4v.7c.5 2 1.5 7 1.5 9.2 0 16-10.1 28.2-23.9 30.3h-.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M380 230.6c-1 2-8 12.5-12 14.5m11-7.6c-1.3 2-9.8 14-13.6 14.6m15.5 3.6c-2 2.4-4 7-9.7 10m7 3.3c-4 3.7-13.6 11.6-23 12m23-6c-2 2.3-6.2 13.3-24 8.3m26.7-6.3c-1.2 2.6-10.1 17.3-26 11.6"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M362.3 264.8c1.4-4.3-.6-12.7-.4-18.1.2-3.5-1.4-15.5-1.4-19.5l13.2-9.9s.6 14.3 2.3 30a84 84 0 0 1 1.5 23.9c-1.8 10-6.6 12.8-7.5 13.6-6.7 6-22 5.4-23.5 4.9-10.8-4-15.6-10.8-15.6-10.8s5.2-.1 11.1.5c13-1 19.7-.4 20.1-13.8"/>
|
||||||
|
<path fill="#ffe000" stroke="#000" stroke-width=".1" d="M324.4 273.5h-.2l5.9 1.8 8 .6c17.2.5 15-10.2 14.3-27.5-.1-6.5-.3-12.4 0-15.3l10.3-7.6c3.8 10.7 2.8 16.7 3.5 22.2.4 5.8 1.7 17.3-.2 22.3-2.7 11.7-11.8 11.2-24.2 10.2-6.1-.5-9.4-2.1-9.4-2.1l-8-4.6"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M324.1 273.4a34 34 0 0 0 6 2.1l8 .8c12.5 1 19.8-7.7 18.2-27.6 0-7 .3-7.5-2.2-16.9-3.8 2.5-11.1 8.8-11.1 8.8s2 2.8 1.5 6.7c0 16-6.4 24-20.2 26.1l-1.5-13"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M369.9 225.3c-1.2 2-8.1 12.6-12 14.6m10.8-7.7c-1.2 2-9.7 14-13.5 14.7m15.5 3.6c-2 2.3-4 7-9.7 10m7 3.3c-4 3.6-13.6 11.6-23 12m23-6c-2 2.3-6.2 13.2-24 8.3m26.7-6.4c-1.2 2.7-10.1 17.3-26 11.7"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="m383.8 224 .7 6q.3 6.6-.1 9.8c0 .2-.8 5.5-.6 5.8 1 1.1 1.1 1.2 2.2.4.1-.2-.5-5.6-.5-6.3l-.4-9.9c0-1-1-6.4-1-6.4s0-1.2-.3.5"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="m383.8 224 .7 6q.3 6.6-.1 9.8l-.7 5.8c1 1.1 1.2 1.6 2.3.7.1-.2-.5-5.9-.5-6.6l-.4-9.9c0-1-1-6.4-1-6.4s0-1.2-.3.5z"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="M384 222.2s1 6 1.2 9.6l.3 7.9.5 4.5c.1.7 0 .2 0 .2.9.5 1.5.1 2-.3.2-.1-1.4-3.8-1.4-4.5-.9-10.8-2.4-17-2.4-17s.6 3.7-.3-.4"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M384 222.2s1 6 1.2 9.6l.3 7.9.5 4.5c.1.7-.1.2 0 .2.9.5 1.5.1 2-.3.2-.1-1.4-3.8-1.4-4.5-.9-10.8-2.4-17.1-2.4-17.1s.6 3.8-.3-.3zm.4 17.3s1 .4 1 .2m0-1.3s-.7 0-.8-.2m0-1s.6.3.8 0m-.7-1.6h.5m-.6-1.5h.6m-.5-2.1s.4.1.4-.1m-.5-1.7h.5m.5 9.5s.9.2 1-.1m-1.1-2s.9.1 1-.1m-1-1.3h.7m-.9-1.5h.7m-.7-1.7h.5m-.7-1.5h.6m-.6-1.7s.4.3.4 0m0 9s.9 0 .9-.3"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="m373.6 218.7.7 6a73 73 0 0 1-.1 9.8c0 .3-.8 5.5-.6 5.8 1 1.2 1.1 1.3 2.2.4.1-.1-.5-5.5-.5-6.3l-.4-9.8c0-1.1-1-6.5-1-6.5s0-1.1-.3.6"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="m373.6 218.7.7 6a73 73 0 0 1-.1 9.8l-.7 5.8c1 1.2 1.2 1.6 2.3.7.1-.1-.5-5.8-.5-6.6l-.4-9.8c0-1.1-1-6.5-1-6.5s0-1.1-.3.6z"/>
|
||||||
|
<path fill="#f7e214" stroke="#000" stroke-width=".1" d="M373.7 217s1.2 6 1.3 9.5l.3 8 .5 4.4v.3c.9.5 1.5 0 2-.4.2 0-1.4-3.7-1.4-4.5a127 127 0 0 0-2.4-17l-.3-.4"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M373.7 217s1.2 6 1.3 9.5l.3 8 .5 4.4v.3c.9.5 1.5 0 2-.4.2 0-1.4-3.7-1.4-4.5-.9-10.8-2.4-17-2.4-17zm.5 17.2s1 .5 1 .2m0-1.2s-.7 0-.8-.3m0-1s.6.4.8.1m-.7-1.7h.5m-.6-1.5h.6m-.5-2s.4 0 .4-.2m-.5-1.7h.5m.5 9.6s.9.1 1-.2m-1.1-1.9s.9 0 1-.2m-1-1.3h.7m-.9-1.4h.7m-.7-1.7.5-.1m-.7-1.4.6-.1m-.6-1.6s.4.2.4 0m0 9s.9 0 .9-.4m-10.5-22s-.5 5.2-.2 8.1c.3 3.5.3 4.5.6 6.6v3.9c-.8.5-1.4.2-2 0-.2-.1 1-3.3 1-4-.5-8.9.4-14.3.4-14.3l.2-.2"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M365.7 211.3s-.5 5.1-.2 8c.3 3.5.3 4.5.6 6.6v3.8c0 .5.1 0 0 .1-.8.5-1.4.2-2 0-.2-.1 1-3.3 1-4-.5-9 .4-14.3.4-14.3zm.3 14.6s-.9.2-1 0m.8-1.7s-.9.2-1 0m1-1.2h-.9m.8-1.2h-.7m.5-1.4h-.5m.5-1.3h-.5m.4-1.4s-.4.2-.4 0m1 7.4s-1 .1-1-.1"/>
|
||||||
|
<path fill="#005000" stroke="#000" stroke-width=".1" d="M334 221.7h-.8z"/>
|
||||||
|
<path fill="#fff" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M323.3 256.4s.3-.2.4 0l-.1.2zm1 1.1-2.1-.1"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M291.7 215.5c0-2.1 2-3.3 2.2-3.6 1-.6 1.7-1.2 3.7-1.5l.2.9c0 .3-.5 1.6-2 2.7a12 12 0 0 1-4.1 1.5z"/>
|
||||||
|
<path fill="#a05a2c" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m291.6 214.7 29.7 38.4 1.4-1.3-30.2-39.1z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M291.2 207.2s3.2-.4 2.8-2.2-2.6-1.8-3.5-1.9c-1 0-4 .7-4.8 1.5-.9 1-2.7 2.5-2.1 5s1.3 4.4 2.3 6 .7 3.2.4 3.9c0 .3-.4 1.3.4 1.6 1.2.5 1.5.5 2.5-.6s2.5-3 2.5-5c0-2.1 2-3.3 2.2-3.6 1-.6 1.7-1.2 3.7-1.5 0 0-.7-1.2-1.8-1-1.1 0-3.4-1-4.6-2.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M291.2 207.2s3.2-.4 2.8-2.2-2.6-1.8-3.5-1.9c-1 0-4 .7-4.8 1.5-.9 1-2.7 2.5-2.1 5s1.3 4.4 2.3 6 .7 3.2.4 3.9c0 .3-.4 1.3.4 1.6 1.2.5 1.5.5 2.5-.6s2.5-3 2.5-5c0-2.1 2-3.3 2.2-3.6 1-.6 1.7-1.2 3.7-1.5 0 0-.7-1.2-1.8-1-1.1 0-3.4-1-4.6-2.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M291.2 207.2c-.4 0-1.7-.6-2.6-.3-.9.4-2.7 1.4-2.4 3m10.4-.3s-1.8.8-3.1 1.7c-.6.3-2.4 2-3.5 3.2-1 1-1.3 2.4-3.5 3.9m9-9-1.4 1q-.9.6-1.2 1.3"/>
|
||||||
|
<path fill="#e8a30e" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M347.8 284.2c.2 3 7.4 6.6 12.7.1 5.7-4.5 4.5-11.4 0-12.3l-54.7-53.3c-2.2-1.2-2.4-2.3-3.5-3.5a133 133 0 0 1-9.6 9 10 10 0 0 1 3.2 3.5z"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" stroke="#000" stroke-linejoin="round" stroke-width=".1" d="M302.2 215c-2.6-3.5-12.8 5.8-10 8.7 2.6 2.8 12.4-5 10-8.6z"/>
|
||||||
|
<path fill="#cccccf" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M293 223c2 1.4 9.3-4.8 8.1-7.2-2-2.2-10.1 5.7-8 7.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M296 227.9a16 16 0 0 0 9.9-9m23 44.8q8.6-2.5 11.4-11.6m-9.1 14.3q8.7-2.6 11.4-11.6m2.1 25.8c5.8-1.8 10.4-6 12.2-12.1m-9.8 14.8a18 18 0 0 0 12.3-12.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M360.6 285q.5 2 2.1 1.4m-13.7-2c2.1 3.5 4.5 2.4 6.5 2.5"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="M363.6 285.2q-.2 1.6-1.7 1.6a2 2 0 0 1-1.7-1.6q.1-1.6 1.7-1.6c1.6 0 1.7 1 1.7 1.9"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M360.5 284.9q.5 2 2 1.3m-13.5-1.7c2.1 3.4 4.5 2.3 6.5 2.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M339.6 175.5c1.8 2.9 4.4 8 5.2 12a23 23 0 0 1-7 20.8c-5.2 4.7-13.3 6-16.7 6.8s-5.7 1.8-6.3 2.5q-.1-.7.5-1.6c1.6-.7 4.1-1 7.8-1.8 7.2-1.5 14.8-4.2 19-12.2 5.4-10.3 2.2-18.4-2.5-26.4z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M341.6 206.2a.4.6 49.9 0 1-.6-.6.4.6 49.9 1 1 .6.6z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M346.6 205q-1.7.7-3 1l-3.7 1.4c-.8.3-1.6 1.3-1.6 1.3s1.2 1.3 2.6 1.1q1.6-.2 2.3-.7c.6-.3.6-.6 1.4-1.2 1-.7 1.6-2 2-3zm-5.7 1.1q-.6.7-1.6.5l-.2.2q1 .1 2-.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M346.6 205q-1.7 1.6-4.7 2.8a14 14 0 0 1-5 1l-.2.3c1.4-.1 3.4-.4 5-1.1a15 15 0 0 0 4.9-3zm-2.4 4.6c-2-.1-3 .5-4.8.9s-3.7-.5-4.8 1.1c4.4 2.9 7.6 1 9.6-2z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M340.2 213.6c-.8-.8-8-3.2-9.2-.5 1.7 2 6.8 2.4 9.2.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m338.5 215.5-2.5-.4c-1-.1-1.3-.3-1.9-.4-1-.1-2.3-1.6-6-.5 1.4 3.4 6.4 4 10.4 1.3zm1.6-1.9c-3.8.8-8.3 0-10.1-1l-.3.2a16 16 0 0 0 10.4.8zm4-4c-2.2 1.5-5.2 2.6-11.4 1.8v.2c8.5.7 9.3-.8 11.4-2z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M338.5 215.5c-3 .1-4.7 1.4-10.3-1.3l-1.4-.6-.5.2 1.4.4c7 3 6.8 1.6 10.8 1.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M327.3 211.6a.4.6 66.2 1 0 .4.7.4.6 66.2 0 0-.4-.7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M327.1 212.3c-.6.1-.8.9-.8 1.3l-.4.2q.2-1 1.2-1.7z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M346 186.8a.4.6 15.8 1 1-1-.2.4.6 15.8 0 1 1 .2z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M341.6 203.2c-.1-2.1-1.1.8-3.2-3.9-.6-1.4-.6-2.2-1-4.3 1.2 1.8 3 2.3 3.8 3.6s.5 3.5.4 4.6z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M337.5 195.2s1 2.4 2.4 4a8 8 0 0 1 1.8 3.7"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M338.5 197.2c1.3 1.7 3 3.8 3 6h.3c-.3-2.8-2-4.1-3-5.6zm10.8 2.7q-1.6 1-2.7 1.4l-1.4.9-1.4.5c-.7.5-2 2-2 2s1.3 1.1 2 1c2.4-.5 3.1-1.4 4.3-2.3 1-.8 1-2.5 1.2-3.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m340.7 204.8-.2.4c1.3-.2 3.5-1.5 5-2.2 1.8-1 3-1.7 3.7-3a9 9 0 0 1-3.9 3c-1.5.7-3.7 2-4.6 1.8zm9.4-9.5q-1.3 1.2-2.4 1.8l-1.3 1.1-1.2.8c-.6.6-1.5 2.2-1.5 2.2s.7.7 1.4.4c2.4-.3 3.1-1.4 4-3.5q.9-1.4 1-2.8z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M341.5 199.4a.6.4 62 1 0 .8-.3.6.4 62 0 0-.8.3z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m342.5 201.6-.2.3c2.9-1.3 6.6-3.9 7.8-6.5a17 17 0 0 1-7.6 6.2zm8.3-10.7q-1.2 1.3-2.2 2l-1 1.1-1.2.9c-.5.6-1.2 2.2-1.2 2.2s.8.9 1.5.5l2.1-1.6c.5-.4.6-1.4 1.2-2.2q1-1.4.8-2.9z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M348.7 193.8q-2.1 2.6-4.6 3.8l-.2.4c2.6-1.5 3.7-2.8 4.9-4.1zm1-5.6q-.6 1.4-1.6 2l-.8 1.2-1 .9-.7 2.1s.6.6 1.2.2l1.8-1.6 1-2.2a4 4 0 0 0 .2-2.6zm-4.3-1.1-.5 1.6v-.4q0-.6.4-1.3z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M348.4 191a13 13 0 0 1-3.8 4v.3c2.2-1.6 3-3 3.8-4.2zm-5.6 10q-.2-.8-.6-1.4.4.7.4 1.6l.1-.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M341.9 181.3a.4.3 39.5 0 1-.5.5.4.3 39.5 0 1 .5-.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m343.1 182.5-1.2-.8 1.3 1v-.2zm5.6.4c-1.2 2.4-3.9 4-2.8 7.5 2.8 2.6 3-4.5 2.8-7.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M346.1 177.7c-.5 2.4-2.6 4.4-1.2 7.6 4 1 2-4.3 1.2-7.6z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M343.7 174c2 3.7 2.5 5.9.2 8.3 0 0-1.3-1.1-1.6-3.3-.2-1.8 1.2-4 1.4-5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M340.4 171.8c.5 2.4-1.5 3.5 1.4 6.5 2.1-2.4 1.1-3-1.4-6.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M339.6 176c-3.3.3-2-2.6-3-5.3 2 1.5 4.7 2.1 3 5.3zm2.2 4.4c-1-4.4-4-2.6-5.6-4.6.9 2.9 2.1 4.8 5.6 4.6zm1.4 4.1q-4 0-6.2-4.4c2.7 1.2 5.7 1.4 6.2 4.4z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M343.6 188.3c-1-1.2-1-2-1.5-2.7a9 9 0 0 0-3-3.7c0 3 .4 6.5 4.5 6.4z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M344.2 191.6a20 20 0 0 0-5.6-5.7c1 2.2.6 6.3 5.6 5.7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M343.7 196c-5-.1-4.5-4.8-4.5-7l2.8 3.5c.9 1 1.8 2.2 1.7 3.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M343.1 198.8c-.4-1 0-1.4-1-2.3-1-1-2.8-2.7-3.7-4.6-.1 1.7-.2 4.4 1.2 5.3 1 .7 2 .9 3.5 1.6zm-4.3 7.3c-3.9-3-1.6-5.6-1.2-7.9 1 2.6 3.7 4.8 1.2 7.9zm1.6-29.2c-1.3-2.3-2-3.6-3.7-6 2 2.7 2.8 4.5 3.9 6.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M338.9 206.8c.1-3-.7-5.7-1.3-8.5.5 3 1.3 6 1 8.8zm4.6-7.4c-.4-1.8-4.1-3-5-7.5.7 4.4 4.5 5.5 4.8 8zm.8-2.5-.1.5c-.6-3-4-4.4-5-8.4 1.5 4.3 4.3 4.9 5.1 7.9zm.7-4.4c-1.7-2.6-4.1-3.6-6.4-6.7 2.1 3 4.8 4.4 6.4 7zm0-3.5q-3-1-6-7.1c1.4 3 3 5.8 6 7.6zm-1-4c-2.1-1.7-5-2.8-7-4.9 1.7 2 5 3.3 7.1 5.2v-.3zm-1.6-4.3c-2.2-1.5-4.4-2-6.2-4.8 1.5 2.8 3.8 3.5 6.3 5zm6.3 2.2c-.8 3.1-1.5 6.3-3.6 8.4v-.4c1-.5 2.5-3.7 3.6-8zm-2.6-5.1c-.1 3.2 0 6.5-1.7 8.3l-.1-.3c1.6-1.4 1.5-5 1.8-8zm-2.2-3.6c.5 2.8.6 5.3-.2 9.5l-.1-.3c.4-2.4 1-5 .3-9.2zm-3.4-2.3c1.1 2.4 2 4.8 1.2 7.4l-.2-.3c.9-2.4-.1-4.7-1-7zm-4.4 31.2c1.4 3.2-.4 5.6-2.5 7-1.6-4.7 1.8-4.2 2.5-7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M336.2 203.1c.2 2-2 4.2-2.6 7.6l-.3.2c1-4 3-5.6 2.9-7.8z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M343.7 181.2a.3.4 1.9 1 1-.6-.1.3.4 1.9 0 1 .6.1z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M343.5 183v-1.6h-.2v1.7z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M342.8 180.9a.3.4 2 0 1-.5.2.3.4 2 0 1 .5-.2z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m343.2 182.3-.5-1.1.4 1.4v-.2z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M344.7 186.9a.4.3 80.5 1 1-.7 0 .4.3 80.5 0 1 .7 0z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m344.9 188.6-.4-1.4h-.1l.4 1.6v-.2z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M343.6 187.2a.4.3 57 1 1-.5.5.4.3 57 0 1 .5-.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m344.8 188.6-1.2-1v.1l1.3 1.1z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M343.4 199.1a.3.4 12.7 1 1-.7-.2.3.4 12.7 0 1 .7.2z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M342.8 200.8q.3-.7.2-1.5l-.4 1.7z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M344 199.9a.3.4 50.5 1 0 .5.4.3.4 50.5 1 0-.5-.4z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m342.7 201 1.3-.8v.1l-1.3 1z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M340.7 205a.3.4 40.4 1 1-.4-.6.3.4 40.4 0 1 .4.5z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m339.5 206.2.9-1.3h-.1l-1 1.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M338.8 204.5a.5.6 10 0 0 1 .1.5.6 10 1 0-1 0z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M339.4 205c-.3.7 0 1 0 1.2l-.1.3q-.4-.5-.1-1.5zm-12.1 9.5q-1-.1-1.3-.8h-.3q.5.9 1.5 1v-.3z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M327.3 215.2a.6.4 9.5 0 1 .2-.9.6.4 9.5 1 1-.2.8z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M332.7 206a5.5 5.5 0 0 1-4 6.4c-.9-4 2.8-4 4-6.3z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M332.7 206.1a21 21 0 0 1-4.8 7.2h-.5c2.7-2.1 4.2-4.9 5.3-7.2z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M327.8 213.3a.5.4 9.8 1 1-.4-.7.5.5 9.8 0 1 .4.7z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m326 213.8 1.4-.7h-.1l-1.7.6h.3zm2.5-5c0 1.2-1 2.4-2 3.2s-1 1.2-2.2 1.6c-1.2-2.8 2.8-3.3 4.2-4.8z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M328.5 208.8c-1.3 2.5-3.5 3.8-4.7 5.4h-.1c1.6-2.2 3-2.7 4.8-5.4z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M300.4 175.5c-1.7 2.9-4.4 8-5.1 12a23 23 0 0 0 7 20.8c5.2 4.7 13.3 6 16.6 6.8s5.8 1.8 6.4 2.5q.1-.7-.5-1.6c-1.6-.7-4.2-1-7.8-1.8-7.2-1.5-14.9-4.2-19-12.2-5.4-10.3-2.2-18.4 2.5-26.4z"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M314.1 213.6c-.2.4-4 3.7-7.5 3.5-2.5-.2-2.9-.8-2.9-.8s-.2-.7 2-1.1 6.2-2 8.4-1.6z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M303.8 216.3c2.7.3 5.5-.8 7.7-1.6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M311.2 208.7a11 11 0 0 1 4.6 3.7c.9 1.5.7 1.9.7 1.9s-.2.3-1.3-1c-1-1.2-3.4-3.3-4-4.6z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M316.5 214.2c-.9-1.7-2.5-3-3.7-4.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M300.3 175.8c-.1-.3.3-3.4 2-4.4 1.3-.7 1.8-.5 1.8-.5s.3.3-.5 1.3c-1 1-2.1 3.2-3.3 3.7z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M304 171c-1.4.7-2.3 2.2-3 3.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M299.4 177.4c-.2-.2-1-3-.2-4.7.5-1.3.9-1.4.9-1.4s.3 0 .1 1.3-.2 3.8-.8 4.8z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M300 171.4q-.7 2.1-.6 4.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M299.4 177.5c.2 0 2.7-.8 3.9-2.1q1-1.4.8-1.3c-.2.1-.2-.2-1.1.5-1 .8-3 2-3.6 2.9z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M304 174.1q-1.5 1.7-3.3 2.6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M298.2 179.6a10 10 0 0 1-.9-5.2c.3-1.6.6-1.7.6-1.7s.4 0 .4 1.4c0 1.5.3 4.2-.1 5.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M298 172.7c-.5 1.7-.2 3.6 0 5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M297.3 181.6c-.3-.2-2.3-3-2-5.2 0-1.7.5-2 .5-2s.3-.1.6 1.4c.3 1.6 1.2 4.3.9 5.8z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M295.7 174.5c-.2 1.8.5 3.7 1 5.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.2 184.1a10 10 0 0 1-2.4-4.7c-.2-1.5 0-1.8 0-1.8s.4-.1.8 1.3 1.6 3.9 1.6 5.2z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M293.9 177.7c.1 1.7 1 3.4 1.5 4.8"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M298.2 179.6c.3 0 3.2-.2 4.6-1.7 1-1 1-1.5 1-1.5s-.2-.3-1.4.4c-1.1.8-3.4 1.8-4.2 2.8z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M303.7 176.5c-1 1.2-2.7 1.9-4 2.5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M297.3 181.6c.2 0 3.2 0 4.6-1.4 1.1-1 1-1.4 1-1.4s0-.3-1.2.3-3.6 1.5-4.4 2.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M303 178.9q-2 1.5-4.1 2.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.4 184.2c.2 0 3.4-.2 4.9-1.7 1-1.1 1-1.5 1-1.5s-.1-.4-1.4.3c-1.2.8-3.7 1.8-4.5 2.9z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M302.3 181q-2 1.9-4.3 2.5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.8 183s-.3-.5-.3-1q.1-.9.2-.8l-.2-.2-.1.9v.3l-.5-.5-.1-.4h-.2l.3.7c.5.4.8 1.1.8 1.1"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.6 180.3a.5.4 83.5 1 1 .1 1 .5.4 83.5 1 1-.1-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.8 180.3a.4.5 19.3 1 1-.3 1 .4.5 19.3 1 1 .3-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m296.7 183 1-.7q.4-.6.3-.7h.2l-.4.8-.2.2.6-.1.4-.2.1.1-.6.4-1.3.3"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M299.4 181.6a.4.5 45.6 1 0-.8.8.4.5 45.6 1 0 .8-.8z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M298.4 180.8a.4.5 19.8 1 0-.3 1 .4.5 19.8 1 0 .3-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.4 186.9c-.2-.2-2.4-2.6-2.8-4.8-.3-1.6 0-1.9 0-1.9s.3-.1.8 1.3 2 4 2 5.4z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M292.6 180.3c.2 1.8 1.2 3.5 2 4.9"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.6 186.9c.3 0 3.5-.4 5-2 1-1.3 1-1.7 1-1.7s-.2-.4-1.4.5-3.8 2-4.6 3.2z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M301.5 183.2c-1 1.4-2.8 2.2-4.2 3"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.2 189.6c.3 0 3.5-.9 5-2.6 1.1-1.3 1-1.7 1-1.7s-.1-.2-1.4.7c-1.2 1-3.8 2.5-4.6 3.6z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M301.2 185.4c-1.1 1.4-3 2.4-4.3 3.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295 189.6c-.3 0-3.3-2.2-3.8-4.5-.3-1.7 0-2 0-2s.5-.3 1.2 1.2c.7 1.4 2.5 3.8 2.7 5.3z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M291.3 183.2c.3 1.8 1.5 3.5 2.5 4.8"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.2 193a11 11 0 0 0 4.6-3.6c.9-1.5.7-1.8.7-1.8s-.2-.3-1.3 1c-1 1.2-3.4 3.1-4 4.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M300.5 187.7q-1.7 2.4-3.8 4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295 193.2c-.3 0-3.6-1.7-4.4-4-.7-1.6-.4-2-.4-2s.4-.4 1.3 1 3.1 3.5 3.5 5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M290.3 187.2c.5 1.8 2 3.4 3.2 4.6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295 191.6s-.4-.5-.5-1v-.9h-.3v.8l.2.4-.6-.4-.3-.5-.2.2.5.6c.6.2 1.1 1 1.1 1"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M293 189a.6.4 69.4 1 1 .4 1.2.6.4 69.4 1 1-.4-1.1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M294.3 188.7a.4.6 5.2 1 1 0 1.2.4.6 5.2 1 1 0-1.2z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295 191.6s.6-.5.8-1l.2-.8h.3l-.3.8-.1.4.6-.3.4-.4.1.2-.6.5c-.6.1-1.2.7-1.2.7"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M297.5 189.4a.4.6 31.5 1 0-.6 1 .4.6 31.5 1 0 .6-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.3 188.9a.4.6 5.7 1 0-.1 1.1.4.6 5.7 1 0 0-1.1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.4 196.2a12 12 0 0 1-5.1-4c-1-1.7-.8-2.1-.8-2.1s.2-.3 1.4 1c1.3 1.4 3.8 3.6 4.5 5.1z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M289.5 190.2c1 1.8 2.8 3.3 4.2 4.6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.6 196.2c.4 0 4-1.9 5-4.4.6-1.8.3-2.3.3-2.3s-.4-.3-1.4 1.2c-1 1.4-3.4 3.8-3.9 5.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M300.9 189.6c-.7 2-2.3 3.7-3.6 5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.6 199.3c.3-.1 3.7-2.3 4.5-4.8.7-1.9.4-2.3.4-2.3s-.4-.3-1.3 1.3c-1 1.5-3.2 4-3.6 5.7z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M301.5 192.3c-.7 2-2.2 3.8-3.4 5.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M296.3 199.3c-.4 0-4.7-1.3-6.2-3.8-1-2-.9-2.5-.9-2.5s.4-.4 1.8 1c1.5 1.4 4.5 3.5 5.3 5.2z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M289.3 193.1c1.1 2 3.3 3.6 5 4.8"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M300.1 205.7c.3-.2 2.6-3 2.8-5.6.2-1.8-.2-2-.2-2s-.3-.2-.8 1.4c-.5 1.7-1.8 4.6-1.8 6.2z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M302.8 198.1c-.1 2-1 4-1.8 5.6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M300 205.6c-.5.2-5.4 0-7.7-2.1-1.6-1.6-1.5-2.2-1.5-2.2s.2-.5 2.1.5 5.7 2.3 7 3.8z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M290.9 201.4c1.7 1.7 4.4 2.6 6.5 3.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M298.2 203c-.4.1-4.8-1-6.8-3.1-1.6-1.6-1.5-2.1-1.5-2.1s.2-.4 2 .8c1.6 1.2 5 3 6.3 4.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M290 197.9c1.6 1.7 4 3 5.8 4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M298.4 203c.4-.2 3.6-2.9 4-5.6.2-2-.2-2.5-.2-2.5s-.5-.2-1.2 1.5c-.7 1.8-2.6 4.7-2.6 6.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M302.2 195c-.2 2.2-1.5 4.3-2.5 6"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="m297.4 201.5-1-.8-.5-.9h-.3l.8 1.2-.8-.1-.5-.3v.2l.7.4c.7 0 1.6.5 1.6.5"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M294.2 199.8a.7.5 45.9 1 1 1 1 .7.5 45.9 1 1-1-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M295.4 198.8a.7.5 71.6 1 1 .4 1.3.7.5 71.6 1 1-.4-1.3z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M297.5 201.4s.4-.6.4-1.3l-.1-1h.2l.1 1.3.6-.5.2-.6.2.1-.4.8c-.6.5-1 1.4-1 1.4"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M299.1 198.2a.5.7 8 1 0-.2 1.3.5.7 8 1 0 .2-1.3z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M297.6 198.1a.7.5 72.2 1 0 .4 1.3.7.5 72.2 1 0-.4-1.3z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M301.8 207.6c-.3.2-4.3 1-6.8-.3-1.8-.9-2-1.4-2-1.4s0-.5 2 0c1.8.4 5.2.7 6.8 1.7z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M293.1 206c2 1 4.4 1.2 6.3 1.4"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M302 207.5c.2-.1 2-2.5 1.9-4.8 0-1.7-.4-2-.4-2s-.3-.2-.6 1.3-1 4-.9 5.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M303.5 200.8c.2 1.8-.4 3.6-.9 5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M305.5 210.3c-.3.3-5 1.5-8 .3-2-1-2.1-1.5-2.1-1.5s0-.5 2.2-.1c2.1.3 6.2.4 8 1.3z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M295.5 209.2c2.2 1 5 1 7.3 1.1"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M305.8 210.3c.2-.3 1.2-3.8 0-5.6-1-1.4-1.5-1.3-1.5-1.3s-.4 0 0 1.5c.3 1.5.5 4.3 1.5 5.4z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M304.4 203.4c1 1.4 1.1 3.4 1.3 5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M303.3 208.7s-.5.4-1.2 0q-.9-.6-.8-.7l-.3.2 1 .7.3.2-.9.2-.6-.1v.3h1l1.5-.5"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M299.5 208.7a.8.5 24.1 1 1 1.4.7.8.5 24.1 1 1-1.4-.7z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M300.3 207.2a.8.5 49.9 1 1 1 1.2.8.5 49.9 1 1-1-1.2z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M303.4 209s.6-.5.2-1.2l-.6-.9.1-.2.7 1 .2.3.2-.9v-.6h.2v1l-.5 1.5"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M303.7 205.1a.8.5 69.5 1 0 .6 1.5.8.5 69.5 1 0-.6-1.5zm-1.5.8a.8.5 43.7 1 0 1 1 .8.5 43.7 1 0-1-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M308.3 211.8c-.3.4-4.2 3-7.4 2.3-2.3-.4-2.6-1-2.6-1s-.1-.6 2-.8c2.2-.1 6-1 8-.5z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M298.4 213.1c2.5.6 5.2 0 7.3-.5"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M310.8 212.6s-.5.7-1.3.5l-1.2-.5-.3.3 1.3.4.5.1-.9.6-.7.1v.3l1.2-.3c.2 0 1.4-1 1.4-1"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M306.6 214a.9.6 5.4 1 1 1.7.2.9.6 5.4 1 1-1.7-.1zm.4-2a.9.6 31.2 1 1 1.5 1 .9.6 31.2 1 1-1.5-1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M309.5 212.2c.2-.3.7-4-1-5.6-1.1-1.2-1.7-1-1.7-1s-.5.1.1 1.5 1.4 4.2 2.6 5.1z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M306.8 205.6c1.4 1.2 1.9 3.2 2.3 4.7"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M311.4 213s.2-.7-.5-1.2q-1-.7-1.1-.6v-.4l1.2.7.4.3c.4.3 0-.8-.2-1l-.4-.7.3-.2.5 1 .2 2"/>
|
||||||
|
<path fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M309.7 209a.9.6 42.7 1 0 1.3 1.1.9.6 42.7 1 0-1.3-1.2zm-1.2 1.5a.9.6 17 1 0 1.7.6.9.6 17 1 0-1.7-.6z" overflow="visible" style="marker:none"/>
|
||||||
|
<path fill="#452c25" d="M319.1 209s-2.3 5.4-1.5 6c0 0 2.4-4.3 4.4-5.8 1.1-1 1.8 0 2-1 0-.8-3-2.1-3-2.1l-1.8 2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".2" d="M319.1 209s-2.3 5.4-1.5 6c0 0 2.4-4.3 4.4-5.8 1.1-1 1.8 0 2-1 0-.8-3-2.1-3-2.1l-1.8 2.7"/>
|
||||||
|
<path fill="#452c25" d="M310.6 213.1s-3.4 6-2.5 6 4.5-7.5 4.5-7.5l-1.3.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M310.6 213.1s-3.4 6-2.5 6 4.5-7.5 4.5-7.5l-1.3.2z"/>
|
||||||
|
<path fill="#452c25" d="M311.6 211.5s-3.6 5.8-2.7 5.9c1 .1 4.8-7.4 4.8-7.4l-1.3.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M311.6 211.5s-3.6 5.8-2.7 5.9c1 .1 4.8-7.4 4.8-7.4l-1.3.1z"/>
|
||||||
|
<path fill="#452c25" d="M312.3 210.5s-4 5.4-3.2 5.6c1 .2 5.4-7 5.4-7l-1.3.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.3 210.5s-4 5.4-3.2 5.6c1 .2 5.4-7 5.4-7l-1.3.1z"/>
|
||||||
|
<path fill="#452c25" d="M313.4 209.5s-4.8 4.9-3.9 5.2 6.2-6.2 6.2-6.2l-1.2-.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.4 209.5s-4.8 4.9-3.9 5.2 6.2-6.2 6.2-6.2l-1.2-.1z"/>
|
||||||
|
<path fill="#452c25" d="M313.6 207.7s-4.2 5.4-3.3 5.6 5.5-6.9 5.5-6.9l-1.3.1-1 1.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.6 207.7s-4.2 5.4-3.3 5.6 5.5-6.9 5.5-6.9l-1.3.1-1 1.2z"/>
|
||||||
|
<path fill="#452c25" d="M312.5 212.4s-4 5.5-3.2 5.6c1 .2 5.4-7 5.4-7l-1.3.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.5 212.4s-4 5.5-3.2 5.6c1 .2 5.4-7 5.4-7l-1.3.1z"/>
|
||||||
|
<path fill="#452c25" d="M314.8 211.3s-2.4 4.5-2.1 4.6c.3.2 3.1-2.5 4.6-5.2 1.4-2.6-2.6.5-2.6.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.8 211.3s-2.4 4.5-2.1 4.6c.3.2 3.1-2.5 4.6-5.2 1.4-2.6-2.6.5-2.6.5"/>
|
||||||
|
<path fill="#452c25" d="M315 210.8s-2.3 5.5-1.5 6c0 0 3-3.3 3.7-5.9s0-.1 0-.1l-.2-2.9-2 2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".2" d="M315 210.8s-2.3 5.5-1.5 6c0 0 3-3.3 3.7-5.9s0-.1 0-.1l-.2-2.9-2 2.7"/>
|
||||||
|
<path fill="#452c25" d="M313.8 210.4s-4.7 4.8-3.9 5.1 6.3-6.1 6.3-6.1l-1.3-.1-1 1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.8 210.4s-4.7 4.8-3.9 5.1 6.3-6.1 6.3-6.1l-1.3-.1-1 1z"/>
|
||||||
|
<path fill="#452c25" d="M314.2 211s-4.7 5-3.9 5.2 6.2-6.2 6.2-6.2h-1.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.2 211s-4.7 5-3.9 5.2 6.2-6.2 6.2-6.2h-1.2z"/>
|
||||||
|
<path fill="#452c25" d="M314.6 211.8s-4.8 4.9-4 5.2c1 .3 6.3-6.2 6.3-6.2h-1.2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.6 211.8s-4.8 4.9-4 5.2c1 .3 6.3-6.2 6.3-6.2h-1.2z"/>
|
||||||
|
<path fill="#452c25" d="M315 205.1s-3.6 4.5-3 5.3c.4.8 3.6-2 4.7-4 1-2-1.7-1.4-1.7-1.4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M315 205.1s-3.6 4.5-3 5.3c.4.8 3.6-2 4.7-4 1-2-1.7-1.4-1.7-1.4"/>
|
||||||
|
<path fill="#452c25" d="M314.8 209.9s-3 5.8-2.2 5.5c.8-.4 3.8-4.7 4.2-5.7.3-1 .2-2 .2-2l-2.3 1.5.2 1.1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.8 209.9s-3 5.8-2.2 5.5c.8-.4 3.8-4.7 4.2-5.7.3-1 .2-2 .2-2l-2.3 1.5.2 1.1"/>
|
||||||
|
<path fill="#452c25" d="M314.8 208s2.5-4.6 0 .9-3.4 4.5-3.4 4.5c-.2-.3 2.2-4 2.2-4s1.7-2.8 2.1-3.1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.8 208s2.5-4.6 0 .9-3.4 4.5-3.4 4.5c-.2-.3 2.2-4 2.2-4s1.7-2.8 2.1-3.1"/>
|
||||||
|
<path fill="#452c25" d="M317.5 207.4s2.8-4.6 0 .9-3.9 4.5-3.9 4.5c-.2-.3 2.5-4 2.5-4s2-2.8 2.4-3.1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M317.5 207.4s2.8-4.6 0 .9-3.9 4.5-3.9 4.5c-.2-.3 2.5-4 2.5-4s2-2.8 2.4-3.1"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M316.5 205.5s-3.5 4.5-3 5.2 3.7-2 4.7-4-1.7-1.4-1.7-1.4"/>
|
||||||
|
<path fill="#e8a30e" d="M352.8 251a32.8 37 0 1 1-65.6 0 32.8 37 0 1 1 65.6 0"/>
|
||||||
|
<path fill="none" stroke="#390" stroke-width=".8" d="M293.7 251c0-17 12-30.2 26.3-30.2s26.3 13.1 26.3 30.3" color="#000" font-family="Sans" font-weight="400" overflow="visible" style="line-height:normal;text-indent:0;text-align:start;text-decoration-line:none;text-transform:none;marker:none"/>
|
||||||
|
<path fill="#007934" stroke="#eee" stroke-width=".1" d="M287.2 253c1 19.5 15.3 35 32.8 35s31.9-15.5 32.8-35z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".4" d="M352.8 251a32.8 37 0 1 1-65.6 0 32.8 37 0 1 1 65.6 0z"/>
|
||||||
|
<path fill="#d52b1e" stroke="#000" stroke-width=".1" d="M314.5 225.4q-.4 2 .7 3.6 1 1.4.9 2.7l-.7.5-5.5-3.7 3.7 5.5-.2.2a5 5 0 0 0-3-.3 5 5 0 0 1-3.7-.8 5 5 0 0 0 3.1 2.1 5 5 0 0 1 2.6 1.3l-.2.8-6.6 1.3 6.6 1.3v.3a5 5 0 0 0-2.4 1.9 5 5 0 0 1-3 2q2 .5 3.6-.7a5 5 0 0 1 2.7-.9l.4.7-3.6 5.6 5.4-3.7.3.2a5 5 0 0 0-.4 3 5 5 0 0 1-.7 3.6 5 5 0 0 0 2-3.1 5 5 0 0 1 1.4-2.5l.8.2 1.3 6.5 1.3-6.5h.3a5 5 0 0 0 1.8 2.3 5 5 0 0 1 2.1 3.1 4 4 0 0 0-.7-3.6 5 5 0 0 1-.9-2.8l.7-.4 5.5 3.7-3.7-5.5.2-.3q1.5.7 3 .4a5 5 0 0 1 3.7.7 5 5 0 0 0-3.1-2 5 5 0 0 1-2.6-1.4l.2-.8 6.6-1.3-6.6-1.2v-.4a5 5 0 0 0 2.4-1.8 5 5 0 0 1 3-2 5 5 0 0 0-3.6.7 5 5 0 0 1-2.7.8l-.4-.7 3.7-5.5-5.5 3.7-.3-.2q.7-1.5.4-3a5 5 0 0 1 .7-3.6 5 5 0 0 0-2 3 5 5 0 0 1-1.4 2.6l-.8-.2-1.3-6.5-1.2 6.5h-.4a5 5 0 0 0-1.8-2.3 5 5 0 0 1-2-3.1z" overflow="visible" style="marker:none"/>
|
||||||
|
<path d="M325.9 236.7c-1.7-1.4-3.8-1.6-4.9-.6q-1.3 1.7.2 3.8l-.3.2a5 5 0 0 1-.3-4.1c1.5-1.3 3.7-1.4 5.2.7"/>
|
||||||
|
<path d="M323.3 236.3c-1 0-1.2.2-1.6.5l-.7.4.1.2q.3 0 .9-.6.6-.4 1.3-.3c1.3 0 2 1 2.1 1s-.7-1.2-2.1-1.2"/>
|
||||||
|
<path d="M325 237.5c-1-1-2.7-1-3.4 0h.2c.8-1 2.5-.6 2.6 0"/>
|
||||||
|
<circle cx="323.1" cy="237.4" r=".6"/>
|
||||||
|
<path d="M321.6 237.6c.7.6 2.2.7 3.3-.1h-.5q-1.2 1-2.5 0"/>
|
||||||
|
<path d="M325 237.8c-1.2 1-2.4.9-3.1.5q-.9-.7-.6-.6l.8.4q.9.5 2.9-.2m-3.5 2a.4.4 0 1 1-.6.5c0 .1-.3.6-.9.6h-.1l.1.2.9-.2a.6.6 0 1 0 .6-1m.9 3c-.7-.5-1-1-1.7-1l-.7.1h-.1l.1.2c.3 0 .7-.3 1.2 0zm-.3 0c-1.4-.5-1.7-.2-2.1-.2h-.1l.1.3c.6 0 .9-.4 2.1-.1"/>
|
||||||
|
<path d="M322.4 243c-1.6-.2-1.1.8-2.4.8h-.1l.1.2c1.6 0 .9-.9 2.4-1m-8.2-6.3c1.6-1.4 3.7-1.6 4.8-.6q1.3 1.7-.2 3.8l.3.2a5 5 0 0 0 .3-4.1c-1.5-1.3-3.7-1.4-5.2.7"/>
|
||||||
|
<path d="M316.7 236.3c1 0 1.2.2 1.6.5l.7.4-.1.2-.9-.6q-.6-.4-1.3-.3c-1.3 0-2 1-2.1 1s.7-1.2 2.1-1.2"/>
|
||||||
|
<path d="M315 237.5c1-1 2.7-1 3.4 0h-.2c-.8-1-2.5-.6-2.6 0"/>
|
||||||
|
<circle cx="-316.9" cy="237.4" r=".6" transform="scale(-1 1)"/>
|
||||||
|
<path d="M318.4 237.6c-.7.6-2.2.7-3.3-.1h.5q1.3 1 2.5 0"/>
|
||||||
|
<path d="M315 237.8c1.2 1 2.4.9 3.1.5q1-.7.6-.6l-.8.4q-.8.4-2.9-.2m3.5 2a.4.4 0 1 0 .6.5c0 .1.3.6.9.6h.1l-.1.2-.9-.2a.6.6 0 1 1-.6-1m-.9 3c.7-.5 1-1 1.7-1l.7.1h.1l-.1.2c-.3 0-.7-.3-1.2 0zm.3 0c1.4-.5 1.7-.2 2.1-.2h.1l-.1.3c-.6 0-.9-.4-2.1-.1"/>
|
||||||
|
<path d="M317.6 243c1.6-.2 1.1.8 2.4.8h.1l-.1.2c-1.6 0-.9-.9-2.4-1"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-linecap="round" stroke-width=".1" d="M323.2 258.4q2.5.4 4.7-.3a10 10 0 0 1 4.2-.6c0-.2.5-.3.2-.5l-1.8-.6q-1.4-.8-2.7-2c-.1 0-.6-.3-.6-.5 2.2 3.2 7.5 1.5 12 1.2.4.1 1.5-.2 2.4-.5 1-.4 3.6 0 4.3-.5l-1.3-1c-.6-.8-2.3-.7-3-1.5-1.3-1.5-3.3-1.9-4.9-3q-.6-.3-1.6-.3c-.6-.6 0-.5-5-4.7-4.5-1.8-4.2-3.2-7-4.3-1-.5-1.9-1.4-2.7-1.1a30 30 0 0 0-6.3 3c-1.2.6-2.7 2-3.8 2.8-.2.2-3.4 2.9-7 4.8a115 115 0 0 1-9.2 4.8c8-.4-7.3 2.3 29.1 4.8z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M303.3 249c2-1 10-5.3 12.4-8.4-.4 0 .6 1 .5 1.4.8 0 .4-1 1-1q.7 0 1.3-.3c.5 0 .2.2.2.4q-1.2 1.7-3.2 2.9l.2.4q.7 0 1.1-.2l.1-.3q.5.3.1.7c-.3.6-1.4.5-1.8 1a6 6 0 0 1-1.5 1.7q.7-.8 1.7-1c1 0 1.3-.7 2.2-.9.9-.1 1.5-.9 2.2-1.4-.3.4-.9.7-.7 1.2l.6.2c-.7.8-2 1.4-2.4 2.4-.4-.2-.7.2-1 .2-.4.1-.3.8-.6 1q-1.7 1.2-2.3 2.7l-1.3.7c-.7.3-4.6 3.4-4.7 3-.3-2.1-4.3 1.6-13.3-1.6m30.6-.1-.2-.2c.1-.3-.5-.4-.5-.7 1 0 1.8 1.2 2.5.5.1-.1-.3-.4.4-.6l-.1-.2h-.8l-.8-.3q-.6-.3 0-.6c1-.1 1.9.5 2.6.2l1.7-.5c.3-.1 1.2 0 .9.2q-.4.2-1 .2-.8.2-1.4.6c.3 0 .2.3.7.2l2-.4v-.5h.3c-.3-.5.6-.2 1-.6l.1.1c-.5.2-.3.5-.4.8q-.2 0-.2.2c.2.2.2-.2.5 0h.6q.6 0 .5-.4-.5-.3-.6-.8l-.1-.1q.8 0 1.1.4t.8.7c.8.2.7-.2.7-.6.7 0 1.6.3 1.4.6q-.2.5-1 .5c-.8 0-.2.3-.4.3q-.7 0-1 .2-.2.4.3.9h1.8q.2-.4.9-.6c.4-.3-.2-.5-.5-.7-.3 0 0-.1 0-.3.3-.3.9 0 1-.2l.1-.8q.4 0 .4.3l.7-.2q.5.1.4.4-.8.3-.7.8c0 .2-.5.3-.3.5q.5.4.5.9.5.5 1.6.3c-.2-.7 1.4-.4 2-.5q.1 0 0-.2-.4-.2-.3-.6l-.1-.1c1 .3 2.8.8 3.6 1.4-1 .2-2.8-.2-3.8 0a15 15 0 0 1-3.7.6l-1.6-.3m-11.3-.7-.4-.1"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M321.2 259.1c2 0 2.2 1.4 3.5.1 1.1.2 2.2-.2 2.2-.3 2.7.6 11.1-.2 10.6-.8-.9-1-2.3-1.4-3.4-2.2l-1.1-.4c-.7-.2-1.5 0-2-.3l-2.6-1.5-1.2-1.4q-1-.5-2-.7-1.2-.7-2.2-1.9l-1.1-.4c-.7-.3-1.2-1.1-1.9-1-1 .2-1.7 1-2.8 1.6-1 .4-1.4 1.1-2.2 1.6-.2.2-2.6 1.8-5.3 2.9l-6.6 2.5s2.4 1.7 8 1.5l3.4 1 1.9-.3z"/>
|
||||||
|
<path fill="#007934" stroke="#000" stroke-width=".1" d="M309.8 254.8c1.2-.6 6-2.9 7.4-4.5-.1 0 .5.5.4.7.5 0 .2-.4.6-.5l.8-.1q.3 0 .1.2-.8.8-2 1.5l.2.2h.7v-.3q.4.2.1.4c-.2.4-.9.3-1.1.6l-1 .9q.5-.4 1.1-.5l1.3-.5q.9-.2 1.4-.8-.4.3-.4.7l.4.1c-.4.4-1.2.7-1.5 1.2l-.6.1-.4.6q-1 .6-1.4 1.4l-.8.4c-.4.2-2.8 1.8-2.8 1.6-.2-1.1-2.6.5-8-1.2"/>
|
||||||
|
<path fill="#00a6de" stroke="#000" stroke-width=".1" d="M320 214.2c-18.1 0-32.8 16.5-32.8 36.9S301.9 288 320 288s32.8-16.5 32.8-37c0-20.3-14.7-36.9-32.8-36.9zm0 8.2c13.2 0 24.7 12.3 24.7 28.7s-11.5 28.6-24.7 28.6-24.7-12.2-24.7-28.6 11.5-28.7 24.7-28.7z" color="#000" font-family="Sans" font-weight="400" overflow="visible" style="line-height:normal;text-indent:0;text-align:start;text-decoration-line:none;text-transform:none;marker:none"/>
|
||||||
|
<path fill="#e8a30e" d="M324.7 266.8q.1.2-.4.3t-.5-.2q0-.2.5-.2.4-.2.4 0z"/>
|
||||||
|
<path fill="#e8a30e" d="m324.8 266.8-.5.2h-.5q0-.2.5-.3h.5zm-2.8 9c.8-2.2 1-3.8-.2-6 2-1.9 3.3-1.2 4.6 0-1.2 2.3-1 4-.2 6a4 4 0 0 1-4.2 0"/>
|
||||||
|
<path fill="#e8a30e" d="M324 268.6v7.7h.1v-7.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M324.4 268.6a45 45 0 0 0-.3 7.7q0-4.2.4-7.6zm.7-2.4c-.4.4-.6.4-.4 1q.5-.4.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.3 266.4c.2.3.6.4.3 1q-.4-.3-.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.3 267c.2.3.5.3.3.9-.3-.3-.4-.3-.4-.8z"/>
|
||||||
|
<path fill="#e8a30e" d="M324.2 267.4q.4.2.3 1c-.4-.4-.3-.4-.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324 267.9q.6.1.4 1c-.4-.4-.2-.5-.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324 268.4c.2.2.6.3.4 1-.3-.4-.4-.4-.4-1m1.3-1.7q-.5 0-.6.8.6-.3.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M325.3 267.1q-.5.1-.7.7c.6-.2.5-.2.7-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M325.2 267.6c-.2.1-.6 0-.7.7.6-.3.6-.2.7-.8z"/>
|
||||||
|
<path fill="#e8a30e" d="M325.2 268.1q-.5-.2-.7.7.4-.1.7-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M325 268.6c-.4.1-.5 0-.6.7.5-.3.4-.2.5-.7zm.2.3c-.7 2.1-.8 4-.7 7.4h.1c0-3.4.1-5.2.8-7.3h-.1zm1.2-2.2c-.4.3-.6.2-.5.8q.5-.1.5-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M325.6 266.8q.5.2.1 1-.4-.4 0-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M325.4 267.4c.3.3.5.3.2.8q-.3-.2-.2-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M325.3 267.7q.3.3.1 1c-.3-.4-.3-.4-.1-1"/>
|
||||||
|
<path fill="#e8a30e" d="M325 268.1q.6.4.3 1c-.4-.4-.2-.5-.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.9 268.6q.5.4.2 1c-.3-.4-.3-.4-.2-1m1.6-1.4c-.2.2-.6 0-.7.7q.6-.1.7-.6z"/>
|
||||||
|
<path fill="#e8a30e" d="M326.4 267.7q-.4-.1-.8.5c.6-.1.6 0 .8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M326.3 268c-.3.2-.7 0-.9.7.7-.2.6-.2.9-.6z"/>
|
||||||
|
<path fill="#e8a30e" d="M326.1 268.6c-.2 0-.6-.2-.8.5.4 0 .4-.2.8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M325.8 269q-.4-.1-.7.6c.5-.2.5-.1.7-.6m.1.4c-1 1.9-1.1 3.6-.7 6.8l.2-.1c-.4-3-.2-4.7.6-6.6zm1.6-2.2c-.5.2-.7.2-.7.8q.5-.2.7-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M326.7 267.2c.1.3.4.5 0 1-.3-.5-.2-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M326.4 267.8q.4.2 0 .8c0-.4-.2-.4 0-.9z"/>
|
||||||
|
<path fill="#e8a30e" d="M326.2 268c.1.3.4.5 0 1-.3-.4-.2-.4 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M326 268.5c0 .3.4.5 0 1-.3-.5 0-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M325.7 268.9c.2.3.5.5 0 1q-.2-.4 0-1m1.8-1.1c-.2 0-.6-.1-.8.5.6-.1.5 0 .8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M327.3 268.2q-.4-.2-.8.4c.6 0 .6 0 .8-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M327.1 268.5q-.4-.1-.8.5c.6 0 .5 0 .8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M327 269c-.3 0-.7-.3-1 .4.5 0 .5-.1 1-.4m-.5.4q-.4-.1-.7.5c.5-.1.5 0 .7-.5m-3-.7q.3 3.5.4 7.6h.1q.2-4.2-.4-7.6zm-.6-2.6c.4.4.5.4.4 1q-.5-.3-.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323.7 266.4q-.6.2-.4 1 .5-.3.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323.7 267q-.5.3-.3.8c.2-.3.3-.2.3-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M323.8 267.4q-.4.1-.3 1c.4-.4.3-.4.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324 267.9q-.6.1-.5 1c.5-.4.2-.5.4-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M324 268.4q-.6.1-.4.9.4-.3.4-.9m-1.3-1.8c.2.2.6.2.6.8-.5-.3-.5-.2-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M322.7 267.1q.5 0 .6.7c-.5-.3-.5-.2-.6-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M322.8 267.5c.2.1.6.1.6.8q-.6-.2-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M322.8 268c.2.1.7 0 .7.8q-.4-.2-.7-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M323 268.5c.3.2.5.1.5.8q-.5-.2-.5-.8m-.2.5-.2.1c.7 2 1 3.9.9 7.2h.1a19 19 0 0 0-.8-7.3m-1.2-2.4c.4.3.5.3.5.8q-.5-.1-.5-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M322.4 266.7q-.5.3-.2 1c.3-.4.3-.4.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322.6 267.3q-.6.3-.2.9c.2-.4.3-.4.2-.9"/>
|
||||||
|
<path fill="#e8a30e" d="M322.7 267.6q-.5.4-.1 1c.3-.4.2-.4.1-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323 268.1q-.6.3-.3 1c.4-.5.1-.5.2-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M323 268.6c-.1.2-.5.4-.1 1q.3-.4.2-1zm-1.5-1.5c.2.1.6 0 .7.7-.6-.2-.5-.2-.7-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M321.6 267.6q.4-.1.7.5c-.5-.1-.5 0-.7-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321.7 268q.5-.1.8.6c-.6-.2-.6-.2-.8-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M321.9 268.5c.2 0 .6-.2.8.6q-.5 0-.8-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M322.2 269q.4-.1.6.5c-.5-.2-.4-.1-.6-.6zm0 .5h-.2c.9 2 1 3.6.7 6.6h.2c.3-3 .2-4.6-.8-6.6zm-1.7-2.5c.4.3.6.2.6.8q-.5-.1-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M321.3 267c-.1.3-.4.6 0 1 .3-.4.2-.4 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M321.6 267.6q-.5.3-.1.9c.1-.4.3-.4 0-.9z"/>
|
||||||
|
<path fill="#e8a30e" d="M321.8 268q-.4.2 0 1c.2-.5.2-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322 268.4c-.1.3-.4.5 0 1 .3-.5 0-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322.3 268.8q-.5.4-.1 1 .3-.4 0-1zm-1.8-1.2q.4-.1.8.5-.7.1-.8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M320.6 268q.5-.1.9.5c-.6-.1-.6 0-.9-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M320.8 268.4q.5-.1 1 .5c-.7-.1-.7 0-1-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321 268.9c.3 0 .7-.3 1 .5-.5 0-.5-.3-1-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321.4 269.3q.4-.1.7.5c-.5-.1-.4 0-.7-.5m2.2-3.2c.3.4.5.4.3 1q-.4-.4-.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.3 266.4q-.4.1-.4 1 .6-.3.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.4 267q-.6.2-.4.8c.2-.3.3-.2.4-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M324.4 267.4q-.4.1-.4 1c.4-.4.4-.4.4-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.5 268q-.6 0-.5.8c.5-.3.3-.4.5-.9z"/>
|
||||||
|
<path fill="#e8a30e" d="M324.5 268.4c-.2.2-.6.3-.4.9.3-.3.4-.3.4-.9m-1.2-1.8c.2.2.6.2.6.8-.5-.3-.5-.3-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M323.3 267q.4.1.6.8-.6-.2-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M323.4 267.4c.2.2.6.2.6.9-.5-.4-.5-.3-.6-.9"/>
|
||||||
|
<path fill="#e8a30e" d="M323.4 268c.2.1.7 0 .6.8-.4-.2-.3-.4-.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M323.6 268.5c.2.1.4.1.4.8-.4-.4-.4-.3-.4-.8m-.4.3c.5 2.5.7 4.7.6 7.5h.2q.2-3.7-.7-7.5m-1-2.4c.4.4.6.3.5 1q-.5-.4-.5-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323 266.6q-.5.2-.2 1c.3-.4.3-.4.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323.1 267.2q-.5.2-.2.8.3-.3.2-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M323.3 267.5q-.5.3-.2 1 .4-.3.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323.5 268q-.6.3-.3 1c.4-.4.2-.5.3-1"/>
|
||||||
|
<path fill="#e8a30e" d="M323.6 268.5c-.2.3-.6.4-.3 1q.4-.4.3-1M322 267q.6-.1.8.6c-.6-.2-.6-.1-.8-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M322.1 267.4c.2.1.7 0 .8.6-.6-.2-.6-.1-.8-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M322.3 267.8c.2.1.6 0 .7.7q-.7-.1-.7-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M322.4 268.3c.2 0 .6-.1.8.7-.5-.1-.5-.3-.8-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M322.7 268.8q.4-.1.6.6c-.5-.2-.5-.1-.6-.6m-.8.9q.9 2 .7 3.7l-.3 2.5h.1q1-3.6-.5-6.2"/>
|
||||||
|
<path fill="#e8a30e" d="m322.5 269.2-.1.1c.8 2.1.9 4 .7 7h.1c.2-3 .1-5-.7-7zm-1.4-2.4c.4.3.5.2.5.8q-.5-.1-.5-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M321.8 266.9c-.1.2-.4.4 0 1q.4-.4 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322 267.4c-.1.3-.4.4 0 1 .1-.4.2-.4 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322.3 267.8c-.2.2-.5.4 0 1 .2-.5.1-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322.5 268.2c-.2.3-.5.5 0 1 .2-.5 0-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M322.7 268.7c-.1.2-.5.5-.1 1q.4-.4.1-1m-1.7-1.3q.4-.1.8.5c-.6-.1-.6 0-.8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321.1 267.8q.5-.1.8.5c-.5-.1-.5 0-.8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321.3 268.1c.2.1.7 0 .9.6-.6-.1-.6 0-.9-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M321.5 268.7c.2 0 .6-.3.9.5-.5 0-.5-.2-.9-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M321.9 269c.3.1.4 0 .6.6-.4-.1-.4 0-.6-.5zm-2-1.6q.7.1.8.7-.5 0-.7-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M320.7 267.4c0 .3-.3.5.2 1 .2-.5.1-.5-.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M321 267.9c0 .3-.3.5.1.9.1-.4.2-.4 0-.9z"/>
|
||||||
|
<path fill="#e8a30e" d="M321.3 268.2c0 .3-.3.5.1 1 .2-.5.2-.5 0-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M321.6 268.6c0 .3-.4.5.1 1 .2-.6 0-.5 0-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M322 269c-.2.3-.5.6 0 1q.2-.4 0-1m-2-1q.4-.2.9.4c-.6 0-.6 0-1-.4z"/>
|
||||||
|
<path fill="#e8a30e" d="M320.2 268.4c.2 0 .6-.2.9.4-.6 0-.6 0-.9-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M320.4 268.7q.5-.2 1 .4c-.6 0-.6 0-1-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M320.7 269.2c.2 0 .6-.3 1 .4-.5 0-.5-.2-1-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M321.1 269.5q.4-.1.8.5c-.5 0-.5 0-.8-.5m3.8-.7a26 26 0 0 0-.7 7.5h.2q-.2-4 .7-7.4zm1-2.3c-.4.3-.5.3-.5.9q.5-.2.5-.9"/>
|
||||||
|
<path fill="#e8a30e" d="M325.1 266.6q.5.4.2 1c-.3-.4-.3-.4-.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M325 267.3q.6.2.2.8c-.2-.3-.3-.3-.2-.9z"/>
|
||||||
|
<path fill="#e8a30e" d="M324.9 267.6q.5.2.2 1-.5-.4-.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.7 268q.5.4.2 1c-.4-.4-.1-.5-.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M324.5 268.5c.2.3.6.5.3 1-.3-.4-.3-.4-.3-1m1.6-1.5c-.2.2-.6 0-.7.7q.6-.1.7-.7"/>
|
||||||
|
<path fill="#e8a30e" d="M326 267.5q-.4-.1-.7.6c.5-.2.5-.1.7-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M325.9 267.9q-.5-.1-.8.6c.6-.2.6-.1.8-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M325.7 268.4c-.2 0-.6-.2-.7.6q.4 0 .7-.6"/>
|
||||||
|
<path fill="#e8a30e" d="M325.4 268.8c-.2.1-.4 0-.5.7q.5-.1.5-.7m.2.4c-.9 2.1-1 4.1-.8 7h.2c-.2-3-.1-4.9.7-7zm1.5-2.3c-.5.3-.6.2-.6.8q.4-.1.6-.8"/>
|
||||||
|
<path fill="#e8a30e" d="M326.3 267c.1.2.4.5 0 1-.3-.5-.2-.5 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M326 267.5q.6.4.1 1c-.1-.5-.2-.5 0-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M325.9 267.8c.1.3.4.5 0 1-.3-.4-.2-.4 0-1"/>
|
||||||
|
<path fill="#e8a30e" d="M325.6 268.3q.5.4.1 1c-.3-.5 0-.5-.1-1"/>
|
||||||
|
<path fill="#e8a30e" d="M325.4 268.7q.5.3.1 1c-.2-.4-.3-.4-.1-1m1.7-1.2c-.2.1-.6 0-.8.5.6 0 .6 0 .8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M327 268q-.4-.2-.8.4c.6-.1.5 0 .8-.5z"/>
|
||||||
|
<path fill="#e8a30e" d="M326.8 268.3q-.5-.1-.8.5c.6-.1.5 0 .8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M326.6 268.8c-.2 0-.6-.3-.8.5.4 0 .4-.3.8-.5"/>
|
||||||
|
<path fill="#e8a30e" d="M326.3 269.2q-.5-.1-.7.5.4 0 .6-.5zm0 .5c-1.1 2-1 4-.6 6.3h.2l-.3-2.9a6 6 0 0 1 .8-3.3h-.1zm1.9-2c-.5.1-.7 0-.8.6q.6 0 .8-.7z"/>
|
||||||
|
<path fill="#e8a30e" d="M327.4 267.5c0 .3.3.6-.2 1-.2-.5-.1-.5.2-1"/>
|
||||||
|
<path fill="#e8a30e" d="M327 268q.4.4 0 .9c-.1-.4-.2-.4 0-.9"/>
|
||||||
|
<path fill="#e8a30e" d="M326.8 268.3q.3.4-.1 1c-.2-.5-.2-.5.1-1"/>
|
||||||
|
<path fill="#e8a30e" d="M326.5 268.7c0 .3.4.6-.1 1-.2-.6 0-.5 0-1z"/>
|
||||||
|
<path fill="#e8a30e" d="M326.2 269c.1.4.4.7 0 1-.2-.4-.2-.4 0-1m2-.8c-.3 0-.6-.2-1 .4.6 0 .6 0 1-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M327.9 268.6c-.2 0-.6-.2-.9.3.6 0 .6 0 .9-.3"/>
|
||||||
|
<path fill="#e8a30e" d="M327.7 268.9c-.3 0-.7-.2-1 .4.7 0 .6 0 1-.4"/>
|
||||||
|
<path fill="#e8a30e" d="M327.4 269.4c-.2 0-.6-.4-1 .3q.6 0 1-.3"/>
|
||||||
|
<path fill="#e8a30e" d="M327 269.7q-.4-.2-.8.4.5.1.8-.4m-3.5 2.7h1.1q1.1 0 1.2.4t-1.2.3h-1.1q-1.2 0-1.3-.4c-.1-.4.6-.3 1.3-.3"/>
|
||||||
|
<path fill="#e8a30e" d="m322.7 272.4-.4.6h.4l.4-.6zm.9 0-.4.6h.4l.4-.6zm.9 0-.4.7h.4l.4-.7zm.8 0-.3.7h.4l.3-.6h-.2z"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="m316.5 268.8.5 2.7.2.2v1.1l.5 1.6.3.6h.4l.1.2-.2.2h-.6l-.3-.1v-.3h-.2l-.1-.6-.8-1-.2-.5-.3-.3c-.5-1-1-3-1-3m-6.4-1 1.8.4-.5 2.6q-.4 1-.2 1 .2.7 1.1 2.3l.6.4.5.3h.5l.1-.2-.5-.2c0-.3-.5-.8-.5-1.2-.3-.7-.2-.7-.2-1.5l.5-1.6q.4-1 .5-2l-.8-1.7-.5-.7m-1.6-1.1c-2.9 1-1.7 3.5-.9 3.6m9.2-8.1.4-1v-.4l-.7.9"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M319.2 262.4h.4v-.6q-1-.4-1-.7v-.2c0-.2-.8-.3-1-.4l-.4-.2h-.2c-.8 0-1.1.7-1.2 1.2 0 0-.2 2.1-.5 3l-.2.3-.2.2-4.2-.2c-.8 0-1.8.7-1.8.7s-1 .6-1.1 1.6q-.1.5.1 1c1 2.7 1.8 0 2.1.1h.4c.5 0 1.4 1.5 2.7 1.8 4 .9 5-1.2 5-5.8v-.2l.2-.4v-.8l1-.2.2-.1"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="m317.3 261 .4-1v-.4l-.9 1m-8.9 7.6.7 1.8q-.1.6-.3.7h-.2l-.2.6v.6c0 .7.4 2.2.4 2.3q.2 0 .3.2v1.2q.1.3.3.3h.6l-.4-.4v-.2q0-.3.3-.5v-.3l-.1-.2-.1-.9v-1.1l.2-.2.1-.2 1-.6.8-1 .2-.5v-.2l-.3-.9-.6-.8m5.9 1.8c-.5.4-1.8.7-1.8 1 0 0 0 1.6-.2 2.2l-.3.6-.2.7-.3 1v.3l.4.3h-.4l-.6-.2V274l-.2-1.4.1-1-.1-3.6m4.7-5.4q.3.4 1.3.1.2.3.6-.2m-.3-.5h.2s.1 0 0 0z"/>
|
||||||
|
<path d="M317.8 261.4q.3.4.6.1-.3-.3-.5-.1z"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" stroke="#000" stroke-width=".1" d="M329.1 272.9v2.9c.2.2 1 .2 1.2 0v-17.2h-.9v4.9l-.1.8v3.2q-.2.4 0 .8v.7l-.1.8z"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" stroke="#000" stroke-linejoin="round" stroke-width=".1" d="M329.1 273.6c.3.2 1 .1 1.2 0m-1.2-.7h1.2m-1.2-.8h1.2m-1.1-.7h1.1m-1.1-.8h1.1m-1.1-.8h1.1m-1-.8h1m-1-.7h1m-1-.8h1m-1-.8h1m-1-.8h1m-1-.8h1m-1-.8h1m-1-.7h1m-1-.8h1m-1-.8h1m-.9-.8h.8m-.8-.8h.8m-.8-.8h.8m-.8-.7h.8m-1 15.5h1.1m-1.2.7h1.2"/>
|
||||||
|
<path fill="#007934" fill-rule="evenodd" stroke="#e7e7e7" stroke-width=".1" d="M331.5 257h2.1l-.8-.3h2.2l-1-.5 2.2.1-1-.5c.9 0 1.2 0 2 .2l-.9-.5 2.5-.2a9 9 0 0 0-8 .8l1-1.1h-.7q.5-.6 1.2-1l-.7.1 1.4-1-.9.1 1.6-1.1h-1l2-1.3c-3.3.4-5.1 2.8-5.3 4.8a7 7 0 0 0-6.6-3.6q1.5.5 2.4 1l-1 .1q1.1.3 2 1h-1l1.8.8-.8.1 1.5.8h-.8c1 .4 1.1.6 1.3.7a8 8 0 0 0-7.3 3l2.2-1-.5.8q.9-.6 2-1l-.4.7 1.8-1-.4.8 1.7-.7-.5.7 1-.4a6 6 0 0 0-2.9 5.8l.9-2v1l1-2v1l1-2v1l1.1-1.9v.8l.3-.6.7-.9.2.3q.4.7.5 1.8l.2-.9c.3.7.7 1.8.7 2.4l.2-1c.3.5.6 1.8.7 2.3l.3-.9q.4 1.3.5 2.3c.8-3-.4-4.9-1.8-6.3q.6.2 1.3 1l-.3-.9 1.5 1.4-.2-.8q.9.8 1.5 1.6l-.2-1a6 6 0 0 1 1.3 1.8l-.1-1q1 1 1.3 1.7c0-2.7-3.1-5-6-5.3z"/>
|
||||||
|
<path fill="none" stroke="#e7e7e7" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1" d="M330.3 257.1c3.1-.5 7.2 2.3 7.2 5.3l-1.3-1.8.1 1-1.3-1.7.2 1q-.6-.8-1.5-1.6l.2.8-1.5-1.4.3 1q-.8-1-1.3-1.1m-1.8-1.5c-1.9-1.2-5.8-1-8.7 2.3l2.2-.9-.5.8q.9-.6 2-1l-.4.7 1.8-1-.4.8 1.7-.7-.5.7 1-.4"/>
|
||||||
|
<path fill="none" stroke="#e7e7e7" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1" d="M329.7 256.6c-.4-2.2-3-4.8-6.9-4.6q1.5.5 2.4 1l-1 .1q1.1.3 2 1h-1l1.8.8-.8.1 1.5.7-.7.1c1 .4 1 .6 1.2.7m1.5 1c-2.6.8-5.1 3.3-4.7 6.7 0-.4.5-1.5.8-2v1l1-2v1l1-2v1l1.1-1.9v.8l1-1.5m-1.6.2-.5 1.4m-.5-.8-.5 1.9m-.5-1-.5 2"/>
|
||||||
|
<path fill="none" stroke="#e7e7e7" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1" d="M329.9 258.6v-.6" class="bo-fil1 bo-str2"/>
|
||||||
|
<path fill="none" stroke="#e7e7e7" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1" d="M332.6 261.3v1.3m-1-3 .1 1.6m-1-2.7.1 1.3m4.8-.4.6 1.2m-2-2.1.8 1.4m-2-1.8.7 1.2m-10.6-.8a5 5 0 0 1 1.7-1.2m-.2 1c.4-.6.6-.9 1.6-1.3m-.2 1q.5-.6 1.7-1.1m-.4 1.2q.7-.6 1.6-1m-3.7-4q1.2 0 2.2.6m-1.2.4q1 0 1.8.4m-1 .4q1 0 2 .6m-1.3.2a4 4 0 0 1 1.7.6m.9.2a9 9 0 0 1 8.5-1.2l-2.4.2q.6.1.8.4h-2q.6 0 1 .4H334l1 .3h-2.2l.8.3q-1.4 0-2 .2m4.8-1.6a6 6 0 0 0-2.2-.4m1 .7a6 6 0 0 0-2.5-.2m1.3.6a7 7 0 0 0-2.5-.2m1.3.7a5 5 0 0 0-2-.1m.8 1q.5.5.8 1.1m-3-3.1c.2-2 2-4.4 5.3-4.8l-2 1.3h1q-.9.4-1.6 1.1h.9l-1.4 1 .7-.1-1.2.9h.7l-1 1.1m2-4q-.8.2-1.6.7m1 .4-1.5.5m.9.4q-.7.2-1.5.7m1 .2a3 3 0 0 0-1.4.8m-.5 2.4-.3 1.1m1.2-2c1.8 1.6 4.1 3.7 3 7.6a6 6 0 0 0-.4-2.2l-.3.9a7 7 0 0 0-.7-2.4l-.2 1-.7-2.4-.2.9q0-1.4-.7-2"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-width=".1" d="m293.3 233.5 1.3.7.3-.6q.2-.5.1-.6 0-.3-.5-.5t-.7-.2q-.3 0-.5.4l-.2.5q0 .2.2.3m1.6 1 1.7.8h.3l.3-.5.3-.6v-.4l-.2-.2-.3-.2q-1.1-.6-1.7.3l-.4.7m-2.6-.8.6-1.2.6-.7.6-.4h.9q.3.1.4.6.2.3.1.7l1-.6 1 .1q.4.3.6 1t-.4 1.7l-.7 1.3-.3.7-1.4-.9-1.8-1-1.5-.7.3-.6m9-10.8q-1.2 1 .3 2.6.8 1 1.5 1 .6.2 1.3-.3.5-.4.4-1a3 3 0 0 0-.8-1.5q-.8-1-1.4-1.2-.8-.1-1.4.4m3.8-.2q.7 1 .7 2a3 3 0 0 1-1.2 2 3 3 0 0 1-2 .7 2 2 0 0 1-1.8-1 3 3 0 0 1-.7-2q0-1 1-2a3 3 0 0 1 2-.7q1.1 0 2 1m8-2.6.3 1 1.9-.7h.3l.3.7-.5.1-2.4.7-.7.2-.4-1.6-.5-2-.5-1.5.6-.2.7-.2.3 1.6.6 2m9.2-3v-1.6h.8l.7.1-.3 1.7-.2 2-.1 1.6h-.7l-.7-.1.3-1.7.2-2m9.9 5.3 3.1-2.7.4.2.4.2-4.7 3.6-.5-.3.2-2 .4-3.8 1.2.7-.5 4m8.4 4 1.2-1.3.4.6.5.4-1.3 1.1-1.5 1.4-1.1 1.1-.5-.5-.5-.5 1.3-1 1.5-1.4m5 10 1.3-1.4h-2l.7 1.4m-1.1-1.3-2 .1-.1-.3-.2-.4h5.9l.3.6-4 4.3-.2-.7-.3-.6 1.4-1.3-.4-.8-.4-1" font-family="Linux Biolinum" font-size="100" font-weight="700" letter-spacing="60" style="line-height:125%;text-align:center" text-anchor="middle" word-spacing="0"/>
|
||||||
|
<path fill="#e8a30e" stroke="#000" stroke-linecap="square" stroke-linejoin="round" stroke-width=".1" d="m325 280.6-.8 2.2h-2.4l1.9 1.5-.7 2.2 2-1.3 1.8 1.3-.6-2.2 1.8-1.4h-2.3zm9-3.9-.7 2.2H331l1.8 1.5-.7 2.2 2-1.3 1.9 1.3-.7-2.2 1.9-1.4h-2.4zm14.2-25-.8 2.3h-2.3l1.8 1.4-.6 2.3 1.9-1.4 1.9 1.4-.7-2.3 1.9-1.4h-2.4zm-6.7 17.9.7 2.2h2.4l-1.9 1.4.7 2.3-2-1.4-1.9 1.4.7-2.3-1.8-1.4h2.3zm4.7-8.2.8 2.2h2.3l-1.9 1.5.7 2.2-1.9-1.3-2 1.3.8-2.2-1.9-1.4h2.3zm-31.1 19.2.8 2.2h2.3l-1.9 1.5.7 2.2-2-1.3-1.8 1.3.6-2.2-1.8-1.4h2.3zm-9.2-3.9.8 2.2h2.4l-2 1.5.8 2.2-2-1.3-1.9 1.3.7-2.2-1.9-1.4h2.4zm-14-25 .7 2.3h2.3l-1.8 1.4.6 2.3-1.9-1.4-1.9 1.4.7-2.3-1.9-1.4h2.4zm6.6 17.9-.7 2.2h-2.4l1.9 1.4-.7 2.3 2-1.4 1.9 1.4-.7-2.3 1.8-1.4h-2.3zm-4.7-8.2-.8 2.2h-2.3l1.9 1.5-.7 2.2 1.9-1.3 2 1.3-.8-2.2 1.9-1.4h-2.3z"/>
|
||||||
|
<path fill="#e7e7e7" d="M321 248.1v-.5h.1l-.8-.5h-.7l-.8.5v.5h2.3"/>
|
||||||
|
<path fill="#e7e7e7" d="M321 248.1v-.5h.1l-.8-.5v-.7h-.6v.7l-.9.5v.5h2.3zm.3.6v.2h-2.6v-.2z"/>
|
||||||
|
<path fill="#e7e7e7" d="M321.3 248.7v.2h-2.6v-.2zm-2.5 0v-.5h.1v.5-.5h-.1v-.1h2.4v.1h-.2v.5-.5h.1v.5"/>
|
||||||
|
<path fill="#e7e7e7" d="M318.9 248.7v-.5zv-.5h-.1v-.1h2.4v.1h-.2v.5-.5h.1v.5"/>
|
||||||
|
<path fill="#e7e7e7" d="M319.4 248.6v-.4h-.4v.4z"/>
|
||||||
|
<path fill="#e7e7e7" d="M319.1 248.3v.2h.2v-.2zm1.8.3v-.4h-.3v.4z"/>
|
||||||
|
<path fill="#e7e7e7" d="M320.7 248.3v.2h.1v-.2zm.2-.3v-.3h-.3v.3z"/>
|
||||||
|
<path fill="#e7e7e7" d="M320.7 247.7v.2h.1v-.2zm-1.3.3v-.3h-.4v.3z"/>
|
||||||
|
<path fill="#e7e7e7" d="M319.1 247.7v.2h.2v-.2zm.8.3v-.3h-.3v.3z"/>
|
||||||
|
<path fill="#e7e7e7" d="M319.7 247.7v.2h.1v-.2zm.7.3v-.3h-.3v.3z"/>
|
||||||
|
<path fill="#e7e7e7" d="M320.1 247.7v.2h.2v-.2zm.4.2v.8h-1v-.8z"/>
|
||||||
|
<path fill="#e7e7e7" d="M320.5 247.9v.8h-1v-.8zm-1 .2h1m-.9.6v-.6m.7.6v-.6m.5-.5-.5-.4h-.6l-.5.4zm-1-.5h.4m-.3-.1h.2zm.1 1h.1zm.3 0h.1"/>
|
||||||
|
<path fill="#e7e7e7" fill-rule="evenodd" d="M319.8 246h.4v.4h-.4z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M320 245.6v.4m-.2-.2h.4"/>
|
||||||
|
<path fill="#452c25" d="M317.1 210.3s-2.3 5.4-1.5 6c0 0 3-3.3 3.7-5.9s0-.2 0-.2l-.2-2.8-2 2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".2" d="M317.1 210.3s-2.3 5.4-1.5 6c0 0 3-3.3 3.7-5.9s0-.2 0-.2l-.2-2.8-2 2.7"/>
|
||||||
|
<path fill="#452c25" d="M317.6 207.7s-2.8 6-2.1 6.4c0 0 2.3-2.7 3-4.8.5-2.1 0-.1 0-.1l.7-3.9"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M317.6 207.7s-2.8 6-2.1 6.4c0 0 2.3-2.7 3-4.8.5-2.1 0-.1 0-.1l.7-3.9"/>
|
||||||
|
<path fill="#452c25" d="M320.5 206.4s-2.8 6-2.1 6.4c0 0 2.3-2.7 3-4.8.5-2.2 0-.2 0-.2l.7-3.8"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M320.5 206.4s-2.8 6-2.1 6.4c0 0 2.3-2.7 3-4.8.5-2.2 0-.2 0-.2l.7-3.8"/>
|
||||||
|
<path fill="#452c25" d="m356.8 195.6 3.4 1.9s.8.7-1 .3a42 42 0 0 1-12.8-6.4c-3.3-2-4.3-2-4.3-2l4.5.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m356.8 195.6 3.4 1.9s.8.7-1 .3a42 42 0 0 1-12.8-6.4c-3.3-2-4.3-2-4.3-2l4.5.1z"/>
|
||||||
|
<path fill="#452c25" d="m358 194.8 3.3 1.9s.8.7-1 .3a45 45 0 0 1-12.8-6.4c-3.3-2.1-.6 1.7-.6 1.7l.5-2.5 10.5 5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m358 194.8 3.3 1.9s.8.7-1 .3a45 45 0 0 1-12.8-6.4c-3.3-2.1-.6 1.7-.6 1.7l.5-2.5 10.5 5z"/>
|
||||||
|
<path fill="#452c25" d="M363.5 196.6s-4-.7-5.4-1.8c0 0 .2.5-1.7-.4 0 0 .7 1.7-4.8-2s-3.6-2-3.6-2l1.6-.3 9.3 3.7z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M363.5 196.6s-4-.7-5.4-1.8c0 0 .2.5-1.7-.4 0 0 .7 1.7-4.8-2s-3.6-2-3.6-2l1.6-.3 9.3 3.7z"/>
|
||||||
|
<path fill="#452c25" d="m342.6 198.1 1.4 1.9s-.5 1.8-5.1-1.7-4.4-3.2-4.4-3.2l2.6-.3 5.6 3.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m342.6 198.1 1.4 1.9s-.5 1.8-5.1-1.7-4.4-3.2-4.4-3.2l2.6-.3 5.6 3.2"/>
|
||||||
|
<path fill="#452c25" d="M336.6 199s2.2 2.9 1.7 3.2-3 .2-4.8-2.5c-1.9-2.8 0-.2 0-.2v-4.4z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M336.6 199s2.2 2.9 1.7 3.2-3 .2-4.8-2.5c-1.9-2.8 0-.2 0-.2v-4.4l3.2 3.8"/>
|
||||||
|
<path fill="#452c25" d="M338.8 197.8s2.1 2.6 1.9 3-3.2.2-5.5-2.5c-2.3-2.6-.3-3.4-.3-3.4l3.9 2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M338.8 197.7s2.1 2.7 1.9 3.1-3.2.2-5.5-2.5c-2.3-2.6-.3-3.4-.3-3.4z"/>
|
||||||
|
<path fill="#452c25" d="M350.6 196.2s6 2.5 1.6 2.4c0 0-8.6-2.3-13-6.2l1.2-1.6 10.2 5.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M350.6 196.2s6 2.5 1.6 2.4c0 0-8.6-2.3-13-6.2l1.2-1.6 10.2 5.2"/>
|
||||||
|
<path fill="#452c25" d="M353.8 195.4s3.1 2 3.4 2.7-10-1.9-15.3-6.3l2.4-1.2 9.4 4.8z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M353.8 195.4s3.1 2 3.4 2.7-10-1.9-15.3-6.3l2.4-1.2 9.4 4.8z"/>
|
||||||
|
<path fill="#452c25" d="M344.5 197.2s2.3 1.9 2 2.1-5-.5-8.6-3.3l.4-1.7z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M344.5 197.2s2.3 1.9 2 2.1-5-.5-8.6-3.3l.4-1.7 6.2 2.8"/>
|
||||||
|
<path fill="#452c25" d="M348.4 197s2.3 1.6 1.8 1.8-2.2 1.6-10.7-3.4l-1-.6 1.3-2z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M348.4 197s2.3 1.6 1.8 1.8-2.2 1.6-10.7-3.4l-1-.6 1.3-2z"/>
|
||||||
|
<path fill="#452c25" d="M339.7 192.4s2.8 2.4 2.4 2.8-3.6-.4-5-1.5c-1.5-1-2.6-2.4-2.6-2.4l3-.7z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M339.7 192.4s2.8 2.4 2.4 2.8-3.6-.4-5-1.5c-1.5-1-2.6-2.4-2.6-2.4l3-.7z"/>
|
||||||
|
<path fill="#452c25" d="m336.4 188.3 5 3s4.1 2.8 3.7 3.1-3.7-.8-6-2c-2.2-1.3-5-4-5-4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m336.4 188.3 5 3s4.1 2.8 3.7 3.1-3.7-.8-6-2c-2.2-1.3-5-4-5-4"/>
|
||||||
|
<path fill="#452c25" d="M333.2 202.4s1 2.4.4 2.6-2-.2-3-2.3c-1.2-2.2 1-1.3 1-1.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M333.2 202.4s1 2.4.4 2.6-2-.2-3-2.3c-1.2-2.2.7-1.3.7-1.3z"/>
|
||||||
|
<path fill="#452c25" d="M334.9 200.9s1.4 2.3 1.1 2.6c-.3.2-2.2 1-4.2-2s2.1-2.3 2.1-2.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M334.9 200.9s1.4 2.3 1.1 2.6c-.3.2-2.2 1-4.2-2s2.1-2.3 2.1-2.3z"/>
|
||||||
|
<path fill="#452c25" d="M330.7 190.4s4.8 9.3 4.4 9.8-2.3 0-3.4-2.5-1.9-5.6-1.9-5.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M330.7 190.4s4.8 9.3 4.4 9.8-2.3 0-3.4-2.5-1.9-5.6-1.9-5.6z"/>
|
||||||
|
<path fill="#452c25" d="M336.3 192.8s4 3.3 3.2 3.8q-1.2.8-4.8-2.5c-2.3-2.3 1.6-1.5 1.6-1.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M336.3 192.7s4 3.5 3.2 3.9q-1.4.8-4.8-2.5c-3.4-3.3 1.6-1.4 1.6-1.4z"/>
|
||||||
|
<path fill="#452c25" d="M334.4 192.8s2.8 5.3 2.5 5.8c-.4.5-2.5-1.3-3.6-2.7-1-1.3-1.9-3.3-1.9-3.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M334.4 192.8s2.8 5.3 2.5 5.8c-.4.5-2.5-1.3-3.6-2.7-1-1.3-1.9-3.3-1.9-3.3"/>
|
||||||
|
<path fill="#452c25" d="M312.9 203.6s-.2 3.1 0 3.3c.2.3 1.7.3 1.8-2 .1-2.4-.3-2.5-.3-2.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.9 203.6s-.2 3.1 0 3.3c.2.3 1.7.3 1.8-2 .1-2.4-.3-2.5-.3-2.5l-1.5 1.1"/>
|
||||||
|
<path fill="#452c25" d="M313.8 199.9s-1 3.4 0 4.1 1.9-3.4 2-4.3c0-1-2 .2-2 .2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.8 199.9s-1 3.4 0 4.1 1.9-3.4 2-4.3c0-1-2 .2-2 .2z"/>
|
||||||
|
<path fill="#452c25" d="M314.6 204.3s.2 3.2.8 3.4 1.6-1 1.6-1.8-1-2.8-1-2.8l-1.4 1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.6 204.3s.2 3.2.8 3.4 1.6-1 1.6-1.8-1-2.8-1-2.8l-1.4 1"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M314.6 200.4s-1 3.5 0 4.2 1.9-3.4 2-4.4c0-.9-2 .2-2 .2z"/>
|
||||||
|
<path fill="#452c25" d="M314.7 194.8s-1.6 1.7-1.6 2.6 2.4-1.2 2.6-1.6-1-1-1-1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M314.7 194.8s-1.6 1.7-1.6 2.6 2.4-1.2 2.6-1.6-1-1-1-1z"/>
|
||||||
|
<path fill="#452c25" d="M313.6 194s-1.3 2.3-1 3 1.6-.5 2.2-1.4-1.2-1.7-1.2-1.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.6 194s-1.3 2.3-1 3 1.6-.5 2.2-1.4-1.2-1.7-1.2-1.7z"/>
|
||||||
|
<path fill="#452c25" d="M331.5 190.8s2 3.8 1.5 4q-1 .1-2.3-1.9c-.8-1.1.8-2.1.8-2.1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M331.5 190.8s2 3.8 1.5 4q-1 .1-2.3-1.9c-.8-1.1.8-2.1.8-2.1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M331.6 198.6s.7 3.3 0 3.6c-1.2.5-2-2.3-2-3.4s2-.2 2-.2zm-2.6 5.1s.2 2.5-.2 2.7c-.3.2-1.2.2-2.1-1.8l-.5-1.2 2.3-1.2.5 1.3"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M331.4 203.2s.2 2.5-.3 2.6c-.5 0-1.9-.8-2.5-2.3-.6-1.8 2.4-1.3 2.4-1.3z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M329 195.3s2.6 6 2.1 6.7c-.8 1.7-3.2-3.5-3.9-5.6-.8-2.2 1.7-1 1.7-1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M327.7 196.3s3.6 6.5 2.1 6.6c-1.5.2-4.3-4.6-4.7-5.8-.5-1 2.6-.8 2.6-.8z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M327.5 197.7s1.8 6.2.6 5.6c-1-.5-2.5-5.2-2.6-6.2s2 .6 2 .6zm6.2-8.8s2.7 4.1 1.8 4.2c-.8.2-4-2.7-4-3.2-.1-.5 2.2-1 2.2-1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M332 189s3.5 4.8 2.4 5c-1 .2-1.1-.5-1.1-.5s-2.8-2.5-3-3.1c-.1-.6 1.6-1.4 1.6-1.4m3.5.1s2.4 2.5 1.8 3.3-4-3.1-4.4-3.6 2.9.2 2.9.2m-6 3.5s3.8 7.5 3 8.2c-.6.8-4.9-5.6-5-6.5-.2-.9 2-1.8 2-1.8"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M327.5 193.2s1.5 2.3 1.4 3.6c0 1.2-2.3-1.8-2.5-2.4s1-1.2 1-1.2zm3.1-1.2s1.2 2.4.8 3.1-2-1.4-2.5-2.3 1.7-.8 1.7-.8z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".2" d="M325 194.4s2 2 1.9 3.1-2.8-1.6-3-2c-.3-.6 1.2-1.1 1.2-1.1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M337.2 188.4s2.8 2.5 2.4 3c-.5.3-4.3-2.4-4.9-3-.6-.5 2.5 0 2.5 0z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="m334.8 188.2 2.2 1.9s2 1.3 1.7 1.7-3.6-1.1-4.1-1.8q-.8-1.2-.6-1.4zm7.3.6s8.4 3.4 8 4.2c-.3.8-8.8-2.7-10-3.7-1.4-.9 1.8-.6 1.8-.6"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M342.4 189.8s6.3 3.6 5.5 4-5.2-1-7.5-2.5l-3.3-2.5 2.8-.6zm-15.5 4.4s1.2 2.4.8 3c-.4.8-1.5-.5-2.1-1.4s1.3-1.6 1.3-1.6zm.3 5.4s1 3.5-.1 4.2c-1.1.6-1.8-3.6-1.8-4.5s1.9.3 1.9.3z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M322.3 193.8s3.5-1.5 4.1-1.4c0 0 .8-.2 1.1-.5l1.3-1s-.6-4.3 3.9-3.8l11.5 1.1a45 45 0 0 1 6.5 2l9 3.9 4 2.3c.8.5-.1.1-.1.1s-10.7-5.7-14.5-6.4c-1-.2 0 1 0 1l-3.4-1.3a6 6 0 0 0-2.9-.6 6 6 0 0 1-2.2-.3c-.6-.2-3.8-.3-4.4-.4l-1-.2.3.4-1.6-.2-.5.6s-1.5.3-1.6-.2-1 2.3-1.3 3c-.4.9-1.9.9-2.3 1.4l-1 .9c-.2.1-1.3.8-1.7.8l-2.9.2-.6-1zm4.6 10-.1 3.3c-.2.2-1.8.1-1.8-2.2s.5-2.4.5-2.4l1.5 1.2"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M327.3 201.4s-.3-1.4-.5-1.6 0-.3 0-.3-.5-1.4-.9-1.6c-.3-.2.1-.4.1-.4s-.6-1-1-1.2c-.4-.3 0-.4 0-.4s-.5-1.2-1.5-1.8c0 0-.7-.7-1.4-.9q-1.2-.4-4.5-.3c-2.2 0-3.2 1.5-3.2 1.5l-.1 1.8.3-.2-.5 2.2c0 .5.5 1.4.5 2.5v1q.2 1.5.8 2.9v.2c.2-.2.5.6.7 1 0 0 0 1 .2.6l.6 1.1c0 .2.5 1.4.5.9 0-.6.4 1.3.4 1.5l.5-.7.2.8h.5l-.2.8s1.2-1 1.3-1.4v-.6l.4-.4.6-1s1.5 1.2 1.7 1.7l.3.7.4-.3.3.8.3-.4.2.6.1.3c.1.1.4.2.8-.6q.8-1.7.6-2c0-.3.3.2.3.2s.6-1 .5-1.6.3-.4.3-.4v-2.2q.2-.4.3-.3l-.2-2.3c-.2-.2.3-.2.3-.2z"/>
|
||||||
|
<path d="M324.3 196.7q.4.1.6.6-.2-.6-.6-.8zm.3 1.2q.7.8.5 1.8v.1q.1-1.1-.5-2zm1.1.7a3 3 0 0 1 .4 1.6q.1-1-.4-1.7m.4 2.5q0 .6-.2 1v.2zm-.4 1.9v.7-.8m.7 0q.3.2.3.6v-.1l-.3-.7zm0-2.6.4.5v-.2l-.5-.5zm-1.4 2v1zm-.7.7v1-1.1z"/>
|
||||||
|
<path fill="#452c25" d="M310.7 194.3s-.8 3.2-.2 3.5c1.3.7 2-2.2 2.2-3.2.1-1.2-2-.3-2-.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M310.7 194.3s-.8 3.2-.2 3.5c1.3.7 2-2.2 2.2-3.2.1-1.2-2-.3-2-.3z"/>
|
||||||
|
<path fill="#452c25" d="M312.6 194.3s-1.6 1.7-1.6 2.6 2.4-1.3 2.6-1.7-1-.9-1-.9"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.6 194.3s-1.6 1.7-1.6 2.6 2.4-1.3 2.6-1.7-1-.9-1-.9z"/>
|
||||||
|
<path fill="#452c25" d="M285 194.5s-3.3 1.7-3.6 2.4 10-1 15.8-5l-2.3-1.4z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M285 194.5s-3.3 1.7-3.6 2.4 10-1 15.8-5l-2.3-1.4z"/>
|
||||||
|
<path fill="#452c25" d="M289 195.2s-6 2.2-1.7 2.3c0 0 8.7-1.8 13.3-5.4l-1.1-1.7z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M289 195.1s-6 2.3-1.7 2.4c0 0 8.7-1.8 13.3-5.4l-1.1-1.7z"/>
|
||||||
|
<path fill="#452c25" d="M298 189s-6.5 3.4-5.8 3.7 5.3-.6 7.7-2l3.4-2.4-2.9-.8-2.5 1.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M298 189s-6.5 3.4-5.8 3.7 5.3-.6 7.7-2l3.4-2.4-2.9-.8-2.5 1.6z"/>
|
||||||
|
<path fill="#452c25" d="M295.5 196.3s-2.3 1.9-2 2.1 5-.5 8.6-3.4l-.5-1.7z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M295.5 196.3s-2.3 1.9-2 2.1 5-.5 8.6-3.4l-.5-1.7-6.2 2.9"/>
|
||||||
|
<path fill="#452c25" d="M291.5 196s-2.2 1.7-1.7 1.8 2.3 1.5 10.6-3.9l1-.6-1.5-2-8.4 4.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M291.5 196s-2.2 1.7-1.7 1.8 2.3 1.5 10.6-3.9l1-.6-1.5-2z"/>
|
||||||
|
<path fill="#452c25" d="M289.8 189.6a66 66 0 0 0-13.9 7.2l17.9-7m11.2 10.6s-1.5 2.3-1.2 2.5 2.1 1.2 4.3-1.7c2-3-2-2.5-2-2.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M305 200.4s-1.5 2.3-1.2 2.5 2.1 1.2 4.3-1.7c2-3-2-2.5-2-2.5z"/>
|
||||||
|
<path fill="#452c25" d="M306.6 202s-1 2.4-.5 2.6 2-.1 3.2-2.2c1.2-2.2-.6-1.1-.6-1.1zm-3.3-3.5s-2.2 2.7-1.7 3.1c.4.4 3 .3 4.9-2.4l-.5-2.5-2.7 1.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M306.6 202s-1 2.4-.5 2.6 2-.1 3.2-2.2c1.2-2.2-.6-1.1-.6-1.1zm-3.3-3.5s-2.2 2.7-1.7 3.1c.4.4 3 .3 4.9-2.4l-.5-2.5-2.7 1.7"/>
|
||||||
|
<path fill="#452c25" d="M301.2 197.1s-2.2 2.6-2 3c.2.5 3.2.3 5.6-2.2s.5-3.5.5-3.5l-4 2.6"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M301.2 197.1s-2.2 2.6-2 3c.2.5 3.2.3 5.6-2.2s.5-3.5.5-3.5l-4 2.6"/>
|
||||||
|
<path fill="#452c25" d="m297.4 197.3-1.4 1.9s.3 1.8 5.1-1.5l4.5-3-2.5-.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m297.3 197.2-1.3 2s.3 1.8 5.1-1.5l4.5-3-2.6-.5z"/>
|
||||||
|
<path fill="#452c25" d="m282.2 194.5-3 1.3s-.7.6 1 .7c1.8 0 5.5-.5 11.9-4 2.8-1.7 4.7-3.3 4.7-3.3l-2.4-.8-12.2 6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m282.2 194.5-3 1.3s-.7.6 1 .7c1.8 0 5.5-.5 11.9-4 2.8-1.7 4.7-3.3 4.7-3.3l-2.4-.8-12.2 6z"/>
|
||||||
|
<path fill="#452c25" d="M277 196.4s4.4-.5 6-1.6c0 0-.3.5 2-.4 0 0-1.2 2 5.3-2 6.5-3.8 0 0 0 0l7.2-4.1-.4-.8-14.7 6.2c-1 .3-5.4 2.7-5.4 2.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m290.3 192.4 7.2-4.1-.4-.8-14.7 6.2c-1 .3-5.4 2.7-5.4 2.7s4.4-.5 6-1.6c0 0-.3.5 2-.4 0 0-1.1 2 5.3-2z"/>
|
||||||
|
<path fill="#452c25" d="M308.5 202s-.4 3.4.1 3.5 2-1 2.6-3c.6-2.2-2.4-2-2.4-2l-.4 1.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".2" d="M308.5 202s-.4 3.4.1 3.5 2-1 2.6-3c.6-2.2-2.4-2-2.4-2l-.4 1.3"/>
|
||||||
|
<path fill="#452c25" d="M310.8 203.5s-.3 2.4 0 2.6c.4.3 1.3.4 2.3-1.6s.5-1.3.5-1.3l-2.3-1.2-.5 1.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M310.8 203.4s-.3 2.5 0 2.7c.4.3 1.3.4 2.3-1.6s.5-1.3.5-1.3l-2.3-1.2z"/>
|
||||||
|
<path fill="#452c25" d="M311.4 199s-1.9 4-.8 4 2.5-3 2.7-3.7-1.9-.3-1.9-.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M311.4 199s-1.9 4-.8 4 2.5-3 2.7-3.7-1.9-.3-1.9-.3z"/>
|
||||||
|
<path fill="#452c25" d="M311.3 199s-.8 4.5.2 4c1-.3 1.6-3.7 1.7-4.4s-2 .4-2 .4"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M311.3 199s-.8 4.5.2 4c1-.3 1.6-3.7 1.7-4.4s-2 .4-2 .4z"/>
|
||||||
|
<path fill="#452c25" d="M312.8 199.4s-1.1 3.5 0 4.2c1 .7 1.8-3.4 1.9-4.3 0-1-2 .1-2 .1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.8 199.4s-1.1 3.5 0 4.2c1 .7 1.8-3.4 1.9-4.3 0-1-2 .1-2 .1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M308.4 198.3s-1 3.2-.4 3.5c1.2.6 2.3-2.2 2.4-3.3.1-1.2-2-.2-2-.2zm-7.9-6.6s-2.8 2.4-2.5 2.7 3.6-.2 5.1-1.3l2.1-1.5-2.5-1.6z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M303 189.3s-1.8 1.5-1.5 1.9 3.6-1 4.2-1.7.6-1.3.6-1.3z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M304.9 189s-2.5 2.1-1.9 2.9 4-2.6 4.5-3-2.9 0-2.9 0"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="m303.8 188.2-5 2.3s-4.2 2.7-3.8 3 3.7-.6 6-1.7a38 38 0 0 0 5-3.1m-.9 2.1s-5.3 4.7-4.5 5.1c.8.5 4.4-1.4 6.8-3.7"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M305.8 192.4s-3 5-2.7 5.6c.4.5 2.6-1.2 3.7-2.5s2-3.2 2-3.2"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M307.6 192.8s-3.3 6.1-3 6.6c.4.5 2.4.1 3.5-2.3s1-4 1-4z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M309.5 193.1s-3.7 7.6-3 8.3 4.8-5.7 5-6.6c.3-1.6-2-1.8-2-1.8m-7.7-4.9s-3.3 1.6-2.7 2c.7.2 3.8-1.2 4.2-1.3s-1.5-.7-1.5-.7z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M302.3 188.6s-2 1.6-1.6 2c.4.5 3.5-1.2 4-1.7.7-.5-2.4-.3-2.4-.3zm4.3.2s-2.8 3.7-2 3.8c.9.2 4.1-2.4 4.2-2.8 0-.5-2.2-1-2.2-1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M307.8 189.6s-3 3.7-2.1 4 1-.4 1-.4 2.3-2 2.4-2.5-1.2-1.2-1.2-1.2"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M308.7 190.5s-2.1 3.7-1.6 4 1.5-.8 2.4-1.9-.8-2.1-.8-2.1z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M309.3 192.2s-1.3 2.3-1 3 1.6-.5 2.2-1.3-1.2-1.7-1.2-1.7zm.5 6.5s-.8 3.2-.2 3.5c1.3.7 2-2.2 2.2-3.2.1-1.2-2-.3-2-.3z"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M315.7 194.4s-.9-.3-1.5-.3c0 0-1.9-1.4-3-1.6-1-.1 0-.2 0-.2s-.2-2.4-.5-2.6c0 0-.1-2.5-1.7-2.6s-5.1.1-5.7-.1-2.6-1-6.3 0c-3.8 1-11.2 4.3-11.6 4.4-.4 0 8.5-2 11-3l3.3-.5s-2.8 1.4-.2.8 2 0 2 0-.2.6 1.3.3c1.4-.4 1.4 0 1.4 0s1.7.6 3-.2c0 0 .7 2.3 1.6 2.6 0 0 1 2.2 3.1 2.6l1.2.9 1.2.4 1.3-1"/>
|
||||||
|
<path fill="#452c25" stroke="#000" stroke-width=".1" d="M298.3 188s-8.6 3-8.3 3.9c.4.8 9-2.4 10.3-3.2 1.4-1-2-.7-2-.7"/>
|
||||||
|
<path d="m321.8 194.6.5.2-.3-.3-.7-.3.1.2zM315 201l.3.8v-.2q0-.4-.3-.7zm1.2-6.5.9-.2v-.1l-.9.1zm1.7-.2.8-.1v-.1zm-3 1.5v.2zm-.1 4.6v.7-.1z"/>
|
||||||
|
<path fill="#bd8759" d="M316.6 213.9s-1 .7-1 1l-.2.8s.8-.1.7.3c0 0 .3.2.8-.8q.6-1.5 1.1-1.5.6.2 1 .6.2.4.8.3s-.4-.6-.2-.7l.6-.1s-.3-.7-.9-.8q-1-.2-1-.6c.2-.2 1-2.4 1-2.4l-1-1.5-.5 1.3.2 1-1 2.1c0 .1-2.4 1-2.7 1l-.8.9v.5s.3-.4.4-.2q0-.2.6-.2l.1.2.5-.4.1-.1.6-.1"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M316.6 213.9s-1 .7-1 1l-.2.8s.8-.1.7.3c0 0 .3.2.8-.8q.6-1.5 1.1-1.5.6.2 1 .6.2.4.8.3s-.4-.6-.2-.7l.6-.1s-.3-.7-.9-.8q-1-.2-1-.6c.2-.2 1-2.4 1-2.4l-1-1.5-.5 1.3.2 1-1 2.1c0 .1-2.4 1-2.7 1l-.8.9v.5s.3-.4.4-.2q0-.2.6-.2l.1.2.5-.4.1-.1.6-.1"/>
|
||||||
|
<path fill="#bd8759" d="M323.1 209.2v2.1q0 .6-.2 1.1t-.7.5c-.3 0-1 0-1.2.3l-.3.4s.5-.3.6 0l-.2.6.9-.2.8-.3.4.2v1c0 .4 0 1.2.3 1.2l.4-.5.5.5-.1-1.2-.3-1 1.5.6q.2.4.5.5c.1 0 0-.5.2-.5h.4s-.4-.8-1-1l-1.2-.6-.3-.9v-2.8l-1-.2"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M323.1 209v2.3q0 .6-.2 1.1t-.7.5c-.3 0-1 0-1.2.3l-.3.4s.5-.3.6 0l-.2.6.9-.2.8-.3.4.2v1c0 .4 0 1.2.3 1.2l.4-.5.5.5-.1-1.2-.3-1s1.4.5 1.5.7l.5.4c.1 0 0-.5.2-.5h.4s-.4-.8-1-1l-1.2-.6-.3-.9v-2.8z"/>
|
||||||
|
<path fill="#dcddde" d="M315 197.3s0-1.3.3-1.4c0 0 .1-1.2 1.7-1 0 0 .5-.9 1.4-.4 0 0 .8-.4 1.3-.2l1 .7s.7-.1 1 .1.2 1.1.2 1.1.8.6.8 1.1v.9s.3.3.2.7q-.2.8-.4 1c-.1 0 0 1-.3 1.3l-.8.5c-.2 0-.6.6-1 .6-.3 0-.8-.5-.8-.7s-.6-.4-.6-.4-1 1.2-1.8 1-1.1-.8-1.2-1l-.3-1s-.8-.4-.7-.9c0-.4.4-1 .4-1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m317.8 212-.2-.1m.6-.9-.3-.1m0 .6h.2m5.5.5.5.2m-.2-.6h-.3m.4-.5h-.5m0 1.8.4.1m-.4.5h.2m-.1 1.6h.3m-5.1-2-.3.1m1-.1q-.2.1-.3.4m-4.5.5.4.3m10-1-.3.3m.7-.1v.2"/>
|
||||||
|
<path fill="#d9c0b9" d="M313.4 215h.6s-.3.7-.2.8-.5-.3-.3-.7"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M313.4 215h.6s-.3.7-.2.8-.5-.3-.3-.7z"/>
|
||||||
|
<path fill="#d9c0b9" d="M315.6 215.5s-.7 1 .1 1.5c0 0 0-1 .6-1.1z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M315.6 215.5s-.7 1 .1 1.5c0 0 0-1 .6-1.1z"/>
|
||||||
|
<path fill="#d9c0b9" d="m319.6 214.5-.2-.5.2-.3h.6s.4.9 0 1.3c0 0 0-.5-.2-.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m319.6 214.5-.2-.5.2-.3h.6s.4.9 0 1.3c0 0 0-.5-.2-.5z"/>
|
||||||
|
<path fill="#d9c0b9" d="M321 213.4h.3l.1.3v.3l-.3.2s-.5-.2-.4.5c0 0-.3-1.2.2-1.3"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M321 213.4h.3l.1.3v.3l-.3.2s-.5-.2-.4.5c0 0-.3-1.2.2-1.3z"/>
|
||||||
|
<path fill="#d9c0b9" d="m323.5 215.9.4-.4.4.2-.3 1.2-.2-.3z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m323.5 215.9.4-.4.4.2-.3 1.2-.2-.3z"/>
|
||||||
|
<path fill="#d9c0b9" d="m326 214.8.5 1s.6-.6-.2-1.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m326 214.8.5 1s.6-.6-.2-1.5z"/>
|
||||||
|
<path d="m323.3 195.9.2.2v-.1zm.1 2.2.4.7v-.2l-.4-.7zm-.4-1.6.5.6v-.2zm.5 3.5q0 .5-.2.8v.2q.3-.5.2-1.1zm.5 1v.6zm1.8 3.9.1 1.3-.1-1.5zm-.3.8-.4 1 .4-.8zm-10.3-2.9.7 1.2v-.1zm1.8 0-.5-.4v.2c.2 0 .3.4.5.4zm-1 1.8.5 1v-.2l-.5-1zm1.2.4.5.8v-.1q-.1-.5-.5-.8m0 1.3.2.7v-.1zm1.1.3.1.7v-.9.2m-.5 1q0 .5.3 1v-.2zm.2-4.4.8.3v-.1zm.2 1.2 1 .4-1-.6zm.3 1.2.6.6v-.1zm.4 1.6.2.7-.2-.8zm1-4v.5zm2.3-.9-.2.5v.1l.2-.4zm1 0v.9zm.2 1.6v.7-.8m.7 1.5q0 .5-.5.8v.1q.5-.2.5-.8m-2.4-1.5-.2.4v.1zm.3 1.8-.4.4v.1q.3 0 .4-.3zm-.9 1q-.4-.6-.3-1.3v-.1q-.2.8.3 1.5v-.2m-1-1-.1.5v-.6m2.6 1.3q0 .4-.3.7v.1l.3-.6zm1.7.7-.5.7v.2zm.8.8-.2 1.3v.1zm1-1.6v.5zM315 202l.1 1.3v-.2l-.1-1z"/>
|
||||||
|
<path fill="#fff" d="M318.8 196.3c.7-.6 1.7-1.3 2.7-1v-.2c-1-.2-2 .5-2.7 1.1zm3.5.8c-.8-.5-2-.6-2.7 0 .7-.5 2-.3 2.7.2zm-2.8 1.1q.6 1.5.5 3 .1-1.5-.5-3m-1.1.6v3zm1.5-.7q1.6.4 2.4 1.7v-.1a4 4 0 0 0-2.4-1.7zm-.8 1.3-.2 1.8q.3-.8.2-1.8m1.4-.1a3 3 0 0 1 1.2 2v-.1a3 3 0 0 0-1.2-2m-4.3 1.7q.3-.5.8-.7v-.1q-.6.1-.8.7zm1.4-5q-.2-.9-1-1.2v.1q.8.4 1 1.2zm-.8 0-1.5-.2v.1q.7 0 1.5.3zm1.5 0q.1-.9-.4-1.3.5.4.4 1.2m1.8.7q.8-.3 1.5-.2v-.2l-1.5.2zm.9 1.2a3 3 0 0 1 1.7 1v-.3a3 3 0 0 0-1.7-1zm-3.1 2c-.2.6 0 1.5-.9 1.8v.2c.8-.3.7-1.1.9-1.8z"/>
|
||||||
|
<path fill="#fff" d="M319.5 199q.6 1 .5 2.2v.3q.2-1.4-.5-2.7zm1-.1q1.1.2 1.4 1.4v-.2a2 2 0 0 0-1.4-1.5zm0 1q.3.9.1 1.8v.2c.3-.7 0-1.5 0-2.2v.2m-.4-2.6q1.1-.3 2.2.3v-.3q-1.1-.6-2.2-.2zm-1.4-1.5q.2-.9 1.2-1.3v-.2q-1 .3-1.3 1.3zm-.6.4q-.1-1-.7-1.9v.2q.5.8.6 1.8zm-.6.4a2 2 0 0 0-1-1.4v.3q.8.4 1 1.3zm-1-.3-1.2-.5v.2q.7.1 1.2.5zm2.2 3q.2 1-.2 1.9v.2q.5-1.1.2-2.3z"/>
|
||||||
|
<path fill="#fff" d="M319.4 198.9q.4 1 .4 2a6 6 0 0 0-.4-2.3zm.5-1q1 .4 1.7 1v-.3a4 4 0 0 0-1.7-.9zm-1.7-1.4v-1.7zm-1.2-.4-1.2-.8v.2l1.2.9zm-.2 3.3q-.4.3-.6.8v.2l.6-.7zm.9.5q0 .8-.6 1.2v.2q.6-.6.6-1.3s0-.3 0 0m2.1-3.6c.4-.6 1.3-.7 1.9-.9v-.2c-.6.2-1.5.2-2 .8v.3m-4 0-.5-.3v.2l.4.2v-.2m.1 3-.4.3v.2l.4-.2zm.5.1-.6.6v.2l.6-.6zm1 .3c-.1.6-.8 1.2-.3 1.8v-.2c-.3-.5.2-1 .4-1.4zm.7 0q0 .9-.2 1.7v.2l.2-2m1.9-.6q.6.7 1 1.8v-.2a5 5 0 0 0-1-1.9zm.2-.5q1.1.7 2 1.8v-.2q-.9-1.1-2-1.8z"/>
|
||||||
|
<path fill="#fff" d="M320.5 197.8h.8l.6.5.6.4.3.9v-.2l-.2-.7q-.1-.3-.5-.5l-.7-.4-.9-.2z"/>
|
||||||
|
<path fill="#fff" d="m322.3 199.3.2.6v-.1l-.2-.7zm-2-3.6 1.5-.4v-.3l-1.5.4zm-2 .2q0-.3.3-.7l.4-.8v-.3l-.4.7-.4.9zm-1.7 4.6.1 1.3v-1.2s0-.1 0 0m1 .7q-.3.4-.1 1v-.2zm.8-.5-.2.7v.2zs0-.2 0 0m0-1.5v.4zm-.3.2v.3-.7zm-.5-.2-.1.5v.2l.1-.4zm-.4 0-.8.6v.3l.8-.7zm-.6 0-.5.3v.3q.3 0 .5-.3v-.2m-1-.3-.4.2v.2q.3 0 .4-.2zm2.9 0v.4-.6zm.4-.2.1.6v-.7.1m.3 0v.3h.1l-.1-.6zm.4-.2.4.2v-.2l-.4-.2zm.2-.3.6.3v-.3l-.6-.3zm.5-1h.4v-.2l-.4-.1zm-.3-.6.4-.3v-.3l-.4.4zm-.4-.5.2-.5v-.2zm-.3-.2.4-.8v-.2l-.4.7zm-.9-.8v.9zm-1.3.2.2.3v.6l.1-.5-.3-.6zm-.2.5-.6-.4v.2l.6.5zm.8.2q0-1-.2-1.7v.3q.3.5.2 1.2zm1.2-.7v-.2z"/>
|
||||||
|
<path fill="#fff" d="m317.7 196.3.2-1v-.2l-.2 1zm3.5 2q.5 0 .7.6v-.4q-.2-.5-.7-.6zm-1 2v.7h.1v-1 .3m-.8-.5v1.2s0 .2 0 0v-1.4zm-.4.4-.3 1.2v.3s0 .1 0 0l.3-1.2zm.5.2v1.4-1.6zm-2.6.2v.5q.2-.4 0-.8zm1.2-.8v.4-.8zm.3 0h.1v.2-.2l-.1-.5v.2zm.4-.3.1.3v-.3l-.1-.4v.3m.8-.2.5.6v-.4l-.5-.6zm.5-.8.3.6v-.4l-.3-.5zm-4.3-2-.4-.2v.4l.4.3zm1.3-.5v.2zv-.4.2zm1-1v.8q-.2 0 0 .2v.1l.1-1.2v-.1.2m.2 1.5.5-.7v-.3l-.5.6zm.7 0 .2-.3v-.4l-.2.2v.4m1 .2h-.8v.4h.8zm-.3.9 1 .6v-.4l-1-.6z"/>
|
||||||
|
<path fill="#fff" d="m320 198 .8.5v-.4l-.8-.5v.2zm-2-1.4.1-.5v-.2.6m-2.3-1q.3.5.9.8l.6.7s.1-.4 0-.4l-.6-.6-1-.9v.4m.6 1-.4-.4v.3l.4.5zm-.3 2.5-.3.6v.4l.3-.6zm.3.4-.1.6v.3zv-.2zm1 1.8q.3-1 .2-2v.2q0 .8-.2 1.4zm.6-1.5v.9-1.3.2zm.6-.2q.3.3.2 1v.1q.2-.7-.2-1.5z"/>
|
||||||
|
<path fill="#fff" d="m318.6 199.3.3 1.7v-.3l-.2-1.7v.3m.5-.1.6 1v-.3l-.6-1zm1.1-1q-.1-.2-.5-.1v.3q.3 0 .5.2v-.3"/>
|
||||||
|
<path fill="#fff" d="M319.7 198.1h1.1v-.4h-1c-.1 0 0 .4 0 .4m.6-3q-.4.7-.5 1.3v.3s0 .1 0 0q0-.6.5-1.1zm-1.5.4.2 1v-.4l-.2-1zm-.3.5-.3-.5v.4l.2.5v-.3m-.5.2-1.2-.6v.4l1.2.6s.1-.4 0-.4"/>
|
||||||
|
<path fill="#dba05f" d="m318.5 196.2-2.2.4-1.8.2-1.3-.4c-.3 0-1.6-.2-2 .3l-.9.8c-.1.1-.7.6-.7.9q0 .4.4.5c.3 0 .9.6.9.7 0 .3.8.5 1.5.5 1.3 0 2-.7 4-.5 1 .2 3-.7 3.4-1.2q.7-.7.2-1.7c-.3-.6-1.4-.5-1.5-.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="m318.5 196.2-2.2.4-1.8.2-1.3-.4c-.3 0-1.6-.2-2 .3l-.9.8c-.1.1-.7.6-.7.9q0 .4.4.5c.3 0 .9.6.9.7 0 .3.8.5 1.5.5 1.3 0 2-.7 4-.5 1 .2 3-.7 3.4-1.2q.7-.7.2-1.7c-.3-.6-1.4-.5-1.5-.5z"/>
|
||||||
|
<path d="m311.4 198.1.1-.1.1-.2.4-.3h.5-.2.2-.2l-.3.1z"/>
|
||||||
|
<path fill="none" d="m311.3 198 .4-.4.6-.3h.3"/>
|
||||||
|
<path fill="none" d="m312 197.4-.4.3q-.1.3-.3.3m.7-.5h.5m-.6 0h.5"/>
|
||||||
|
<path d="m312.4 197.9-.2.1-.1.1-.3.2-.3.1.4-.2-.4.2.4-.1-.3.1h.1s.3 0 .7-.4"/>
|
||||||
|
<path fill="none" d="m312.3 197.8-.3.2-.3.2-.2.1m.4-.1.2-.2.2-.2m-.4.4-.3.1m.4-.1-.3.1"/>
|
||||||
|
<path fill="#c6262c" d="M312.9 196.5s0-.6-.6-.8l-1.3-.2-.5.2-.6.2-.1.2q-.3.3-.3.6c.2.3 0 .3.2.3s-1 .3-.8.8c.3.6.5.4.6.3l.5-.2.7-.7 1-.4h.5z"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M312.9 196.5s0-.6-.6-.8l-1.3-.2-.5.2-.6.2-.1.2q-.3.3-.3.6c.2.3 0 .3.2.3s-1 .3-.8.8c.3.6.5.4.6.3l.5-.2.7-.7 1-.4h.5z"/>
|
||||||
|
<path d="M312.2 197.8q0 .2-.3.3h-.3s.1-.4.3-.4z"/>
|
||||||
|
<path fill="#d9c0b9" d="M308.6 200.2s-.7-1.2 1.2-1.8c0 0 .6.3.8.6 0 0-.4.5-1.5.7 0 0-.5.1-.5.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M308.6 200.2s-.7-1.2 1.2-1.8c0 0 .6.3.8.6 0 0-.4.5-1.5.7 0 0-.5.1-.5.5z"/>
|
||||||
|
<path fill="#d9c0b9" d="M308.8 200s.8.2 1.3-.2q.7-.4.7-.1l-.2-.7-1 .5q-.6.1-.8.5"/>
|
||||||
|
<path fill="none" stroke="#000" stroke-width=".1" d="M308.8 200s.8.2 1.3-.2q.7-.4.7-.1l-.2-.7-1 .5q-.6.1-.8.5z"/>
|
||||||
|
<path fill="#7a2e26" d="M311 199.6h.5v-.1zm-.3-2-.1.2v.1zm.5.2.3-.2v-.1q-.3 0-.3.2zm.5.8.2-.2h-.2zm.4-.2.4-.3v-.1zm-.7-.7q0 .2-.3.4v.1q.2-.1.3-.4m.3 2q.6 0 1-.2h-1zm1.6 0q.6-.5 1-1.3a2 2 0 0 1-1 1v.2m.3-.8q.4-.2.6-.8v-.1l-.6.8zm-.2-2 .7.4v-.1l-.7-.4zm6.3 1q.2-.8-.2-1.4.4.6.2 1.2zm-4.8-.7c.3.2 1 .7.8 1.2.3-.5-.4-1-.8-1.3zm2.1 2 .1-.3v.3"/>
|
||||||
|
<path fill="#5e3f17" d="m317.9 199.2.5-.2m-8.5-.3q-.6.1-1 .7.4-.6 1-.6"/>
|
||||||
|
<path fill="#842116" d="M309.7 197.3q.2 0 .3.2v.1-.3l-.3-.1m.7.4-.1-.6v.5m.6-.5q0-.5-.3-.6v.1q.3.2.3.6zm.5-.1q0-.5-.2-.6v.1l.2.3zm-.8-.7c.4-.2 1.4-.3 1.8.2v-.2c-.4-.4-1.4-.3-1.8 0"/>
|
||||||
|
<path fill="#7a2e26" d="M309.5 198.1v-.5s-.1 0 0 .1zm4.7 1.4.3-.5-.3.3zm.7 0 .1-.3v-.1.3m4.3-2.4v.8zm-.5.5v.7q.2-.4 0-.9zm-.4.3"/>
|
||||||
|
<path fill="#452c25" d="M324.3 210.5v.2l.1.1-.1-.4z"/>
|
||||||
|
<path fill="#dcddde" d="M314.4 195c-1.7 0-3.3-1-3.3-1a3 3 0 0 1-2.3-2.4c-.8-.4-1.6-2.5-1.6-2.5-1.3.7-3 0-3 0s0-.4-1.4 0c-1.5.3-1.3-.3-1.3-.3s.6-.6-2 0 .2-.8.2-.8c-.8.2-3.3.4-3.3.4q-1.3.2-2.8.8l-2.3.6-6.8 3-5.5 2.1c.2 0 3.4-2.1 7.5-4a88 88 0 0 1 10.3-3.9 10 10 0 0 1 6.5 0c.6.2 4.2 0 5.7 0 1.6.3 1.8 2.9 1.8 2.9.3.1.4 2.6.4 2.6s-1 0 .1.1c1 .2 2.9 1.7 2.9 1.7h.7s.4-.5.9-.7l1.5-.5h2l2 .3 1.2.3h.5c1-.5 3-1.2 3.4-1.1 0 0 .7-.2 1.1-.5l1.3-1s-.6-4.5 3.9-4l11.5 1.2a44 44 0 0 1 6.5 1.8l5.2 2.4 4 1.7c2.3 1 4 2.4 4 2.4l-3.7-1.8-2.6-.9-3.6-1.7c-3.5-1.6-3.5-1.7-4.9-1.8-1 0 .7 1.2.7 1.2l-4.1-1.7a6 6 0 0 0-2.9-.5 6 6 0 0 1-2.2-.3c-.6-.2-3.8-.3-4.4-.4l-1-.2.2.4-1.5-.3-.5.7s-1.5.3-1.6-.2-1 2.2-1.3 3-2.2.6-2.7 1l-1.6 1h-1.3c-.6 0-.1 0-1 .3 0 0-.9 0-1.2-.2l-1.4-.4-3.5-.2a5 5 0 0 0-2.7 1.1"/>
|
||||||
|
<path fill="#e7e7e7" stroke="#000" stroke-width=".1" d="M314.4 195c-1.7 0-3.3-1-3.3-1a3 3 0 0 1-2.3-2.4c-.8-.4-1.6-2.5-1.6-2.5-1.3.7-3 0-3 0s0-.4-1.4 0c-1.5.3-1.3-.3-1.3-.3s.6-.6-2 0 .2-.8.2-.8c-.8.2-3.3.4-3.3.4q-1.3.2-2.8.8l-2.3.6-6.8 3-5.5 2.1c.2 0 3.4-2.1 7.5-4a88 88 0 0 1 10.3-3.9 10 10 0 0 1 6.5 0c.6.2 4.2 0 5.7 0 1.6.3 1.8 2.9 1.8 2.9.3.1.4 2.6.4 2.6s-1 0 .1.1c1 .2 2.9 1.7 2.9 1.7h.7s.4-.5.9-.7l1.5-.5h2l2 .3 1.2.3h.5c1-.5 3-1.2 3.4-1.1 0 0 .7-.2 1.1-.5l1.3-1s-.6-4.5 3.9-4l11.5 1.2a44 44 0 0 1 6.5 1.8l5.2 2.4 4 1.7c2.3 1 4 2.4 4 2.4l-3.7-1.8-2.6-.9-3.6-1.7c-3.5-1.6-3.5-1.7-4.9-1.8-1 0 .7 1.2.7 1.2l-4.1-1.7a6 6 0 0 0-2.9-.5 6 6 0 0 1-2.2-.3c-.6-.2-3.8-.3-4.4-.4l-1-.2.2.4-1.5-.3-.5.7s-1.5.3-1.6-.2-1 2.2-1.3 3-2.2.6-2.7 1l-1.6 1h-1.3c-.6 0-.1 0-1 .3 0 0-.9 0-1.2-.2l-1.4-.4c-.5-.2-3.1-.2-3.5-.2a5 5 0 0 0-2.7 1.1"/>
|
||||||
|
<path fill="#452c25" d="M314.6 194.4s-.2.2-.2.5v.2"/>
|
||||||
|
<path fill="#574f4c" d="m323.3 194 .7.5-.1-.1-.7-.6v.1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
|
||||||
|
<g stroke-width="1pt">
|
||||||
|
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
|
||||||
|
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
|
||||||
|
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
|
||||||
|
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
|
||||||
|
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
|
||||||
|
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
|
||||||
|
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
|
||||||
|
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
|
||||||
|
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
|
||||||
|
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
|
||||||
|
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
|
||||||
|
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
|
||||||
|
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
|
||||||
|
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-opacity=".5">
|
||||||
|
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
|
||||||
|
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bs" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="bs-a">
|
||||||
|
<path fill-opacity=".7" d="M-12 0h640v480H-12z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g fill-rule="evenodd" clip-path="url(#bs-a)" transform="translate(12)">
|
||||||
|
<path fill="#fff" d="M968.5 480h-979V1.8h979z"/>
|
||||||
|
<path fill="#ffe900" d="M968.5 344.5h-979V143.3h979z"/>
|
||||||
|
<path fill="#08ced6" d="M968.5 480h-979V320.6h979zm0-318.7h-979V2h979z"/>
|
||||||
|
<path fill="#000001" d="M-11 0c2.3 0 391.8 236.8 391.8 236.8L-12 479.2z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 557 B |
@@ -0,0 +1,89 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bt" viewBox="0 0 640 480">
|
||||||
|
<path fill="#ffd520" d="M.1 0h640.1v480H.1z"/>
|
||||||
|
<path fill="#ff4e12" d="M.1 480h640.1V0z"/>
|
||||||
|
<g stroke="#000" stroke-width=".5">
|
||||||
|
<g fill="#fff" stroke-width=".4">
|
||||||
|
<path d="M345.4 150c-4-1.3-6.6.7-6.4 5.9 0 5.1 2.8 8 6.8 6.1z"/>
|
||||||
|
<path d="M348.9 140.4c-3.3-2.6-6.4-1.5-8 3.4s.1 8.6 4.5 8z"/>
|
||||||
|
<path d="M354.4 131c-2.8-3-6-2.4-8.4 2.2-2.3 4.6-1.3 8.5 3.2 8.7zm-3.6 45.5c-4.9 1.8-5.4 8.5-2.3 12.6 3 4.1 8.7 4.9 11.8 0z"/>
|
||||||
|
<path d="M345.1 162.3c-4.6-1.5-8.8 4.7-9.5 10.3-.9 7-11 9.4-5.4 20.1 1.2-6.8 5.7-10.6 9.3-10.8s9-1 11.3-5.4zm14.7 27.6c-5.4 1.3-6.2 8.5-2.3 13.6 3.3 4.4 13.7 3.4 13.4-1zm15.3 43.4c.3-4.7-7.2-6.5-10.8-5.6s-10.5-.1-12-4c-1.4 3.1.5 6.5 5.7 8.2 4 1.2 3.9 4 2.7 5.4 3 .5 11.7.5 14.4-4z"/>
|
||||||
|
<path d="M370.9 203.7c-5.3-2.4-8.4 1.2-10.4 4.6-3 5-12.1-1.4-15.2 5.3 4.2-1.8 8.4 2 10.4 3.3 5.7 3.7 16.6 2.6 18.2-6.2z"/>
|
||||||
|
<path d="M374 209.8c-5.3 4-7.4 8.8-7.2 12 .1 3.2 4.6 10.3 9.5 10.7 2.8-5.8 4.3-18-2.3-22.7zm-22 24.9c0-2 2.8-2.7 4.8-2 1.9.6 4.9 2.5 3.8 4.6zM323 224c-.5-2.3 3.2-6.3 8.2-4.1s5.7 6.4 3.6 8.1z"/>
|
||||||
|
<path d="M335.2 228.4c-.4-1.3 3.3-3.9 9.4-2.4 6.2 1.6 7.7 5.6 7.5 8.7zm-12.9-4.3c3.3-2.4 2-7-.9-8.5-5.2-2.6-3.3-9.2-6.7-10.5-3.3-1.3-6.5-3.6-6.7-6-1.6 3.3-.6 6.2 1.7 8.3 2.3 2-1.8 10.4 1.2 12.6zm-69-1.8c-2.7-4.2-9.1-3.5-11.8-.5s-2.3 7.3.2 9.3zm15.5-6.3c-1-5.7-7.9-6.3-11.6-4.9-3.8 1.4-6.3 7.1-3.9 11.2z"/>
|
||||||
|
<path d="M279 215.2c2.5-4.7-2.3-11.8-7.7-12.8-4.5-1-9.8-.9-11.6-5.3-1 3.8 1.8 6.3 5.2 8.5 3.3 2.2-.6 7.8 4.8 11z"/>
|
||||||
|
<path d="M278.6 215.4c-1.2-3.4 1.1-8 5.7-7.6 4.7.3 7.3 3.6 5.3 7.9z"/>
|
||||||
|
<path d="M288.9 215.7c-.7-3.6 2.2-7.8 6.8-6.8s6.6 4.7 4 8.5l-10.8-1.8z"/>
|
||||||
|
<path d="M299 217.3c-.4-3.6 2.7-7.6 7.2-6.4s6.4 5 3.6 8.7l-10.7-2.3zm-77.6 59c-8.7 0-10.8 2-12 11-1.6 11 13.5 12.3 12-11z"/>
|
||||||
|
<path d="M225.2 264.7c-13.2-5-20.4 16-33.6 12.2 4.7 7.5 16.1 0 20.4.8 7.2 1.3 22.8-1.4 13.2-13zm-8.6 28.7c-6.6-3-13.6 7-12.4 11.5 1.7 5.5 16.7 1 12.4-11.5zM186 336.7c3.6 1 8 3 7.2 10s-14 21.1-25.8 22c-11.8.7-16 15-26.3 11 9.6-1.8 9.6-12.6 17-16-5.4-2-8.2 10.3-15.2 10.3s-10.3 11.1-18.8 10.3-9.3 13.5-26.4 13.7c-13 .1-29 15.3-34.9 8.7 12.7-1.8 17.8-8.8 25.3-16.5 12-12.3 25.7-6.8 30.4-17.7a32 32 0 0 1-18.3 5.4c-8-.2-16.6 12.6-25.5 7 5.1-.7 8.5-2.9 13.9-8.6 5.5-5.8 13.7-2 20.1-8 10-9.2 18.7-1.5 28.3-13-2.7-1.4-8.5-.5-13.9 2.4-5.4 2.8-12.3-2-18.5 1.4.7-7.6 15.2-3.3 24.2-8.5 10.2-6 18.6-4.2 26.6-3.5-11.2 0-15.5-10.7-31-7.6-6.7 1.4-12.1-9.3-18.8-3.9.2-4 7.2-7.2 14.4-3.3s10.1-3.4 24.5 5.7c6 3.7 16.1-2.4 22.5 1.6-.8-2.4-4.5-4-8.8-3.6 2.7-5.6 20.2-4.9 27.8.7z"/>
|
||||||
|
<path d="M197.4 328.3c-5.6-4.4-13.5.9-19-1.2 0 3.6 1.9 9 7.8 11.1 1.8-1.3 10-8.4 11.2-9.9z"/>
|
||||||
|
<path d="M206.3 315.8c-8.9-4.5-10.4 6.7-17.4 4.4.3 3.2 2.9 7.2 8.5 8.1z"/>
|
||||||
|
<path d="M211.6 305.6c-13.1-5.1-14.8 7.5-22.5 5 1.8 4.4 12.8 6.8 18.5 5.2zm18-55.2c-3.5-5-10.8-1-12 4.9-1.1 5.8 1.7 13.9 6.6 12z"/>
|
||||||
|
<path d="M238.5 235c-6-1.5-13-.8-12.2 5-2.4 1.1-3 8.7 3.3 10.4z"/>
|
||||||
|
<path d="M242.5 231c-6-7-12.5-6.9-16.2-4-6.9 5.5-13.5 2.4-13.7 7.8 4.1-3.2 7.8.6 11-.5 3.4-1.2 5.9 5.3 15.2 2.6zm-14.3 103.9c.9 1.7 6.4 2.4 9.1-.4 3.6-3.8-.3-14.2-6-15-5.7-.7-6.2 11.8-3.1 15.4z"/>
|
||||||
|
<path d="M221.8 335.1c8 3 11.5-3.7 7.2-8a80 80 0 0 1-7.2 8z"/>
|
||||||
|
<path d="M191.4 346.2c-1.6 4.7-9.6 5.4-18.6 19.8s-17.6 8.4-19.8 18.3c10.8-8.7 19.3-3 25.8-11.6 9.7-13.1 17.8-11.2 21.6-20 5.4-12.7 29-12.4 30.4-32.3-8-1.5-33.3 19.9-39.4 25.8zm203.2-194.7c10.3 3.4 10.5 16.7 22.4 21.1s13 15 22.7 12.4c-9-2.5-8.4-12.9-17.8-15.5-11-3-15.2-19.8-24-22.4m44 74.3c1.8 4.2 1.5 11.5-5 13.4 3.5 2.2 8.7.2 11.5-4.6-4.2 9.4-1.4 17.9 5.3 19.6-3.2-6.6 4-9.7 1.7-14 4.2 1.9 8 7.7 7.8 11.3 5.6-6.2-4-14.5-2.3-20.3zM375 287.6c-6.3-5.5-9 1.5-12-1-3-2.2-6.8-2.5-8.3-.3 5.4.2 2.8 4.4 13.3 5.4-10.5.7-8.6 12.5-15.6 11.9 7.4 7 11.3-6.3 17.4-4.1-1.8.5 2.8 4.7-.4 10.4 5.2-.1 7.3-7.3 8-11zm-139 60.2c-2.2-2-9-2.9-11.5-1.3-2.6 1.5-1.7 2 1.4 2.2s7 5.2.4 5.5c-3.1.1-2 7.6-8.5 8.1 2.6 3.2 10.2 1.1 12.9-2.4-.5 2.9 3.3 5.5 1.8 9 4.7.5 2-9.7 9.5-9.2-3 .4-1.8 7.4 3.6 5.6-3.2 1.5-1.5 5.3 2 4.4-2.2.7-3 3.7.2 5.4 3.1-4.3-.4-19.5-11.7-27.3zm280.6-142.1a17.9 17.9 0 1 0 0-35.7 17.9 17.9 0 0 0 0 35.7z"/>
|
||||||
|
<path d="M423.4 227.2c5.5-5.1 13.7-7.7 19.4-3.8 5.6 3.8 24.4 8.4 33.7 2s13.7-9.8 17.8-9q4.5 6.8 11.4 7.2c1.4 1.6 6.5 2.8 9.3 2.5 4.1 1 9.1-.3 13.1-4.7 6.2 1 12-3.7 14.2-10.7 6.6-.7 7-8 2.8-13-3.8-.7-.9-13.8-14.9-11.2 6 3.6 1.4 10.8 6.3 14.2-3.3 0-7.7 1.4-8.7 6.4 1.3-3.4-.2-5.8-1-6.5 0-3-6.5-10.3-12.7-7.6 4.4 1 2 8 5.2 10.8a8 8 0 0 0-6.2 3.3c-1.7-3-7.6-6-11.2-6.3 0-1-.2-3-.7-4.1-1.6-3.1-3-6.8-2.3-11.5a48 48 0 0 0-7.2 11.4c-4.9-3.4-17 1.5-22.7 2.8s-24.7-1.8-29-6.4a49 49 0 0 0-21-9.8c-11-3.2-11-15.2-23.1-23.5-.3 15 22.4 62.4 27.5 67.5zM297.2 341.1a17.3 17.3 0 1 0 0-34.6 17.3 17.3 0 0 0 0 34.6z"/>
|
||||||
|
<path d="M256 327.8c3.5 4.5 9.4 4.2 11.9 3.9 2 5.4 8.6 5.2 11.4 8.2s12.5 2.7 15.3 1c-2.5-.2-5.9-1.8-9-4.5-4-3.2-2.2-9.8-5.2-12a11 11 0 0 0 2.2-8.6c2.4-1.4 4.2-3.7 4.4-4.9a15 15 0 0 0 9.6-4.1c2.2 2 7.7-.6 10.7 2.8.6-8.5-7.5-13-13-10.1-2.1-1.2-7.9-.4-9 1.1-1.7-.8-6.8 1.8-9 3.5 2.5-1.4 2.9-5.7 1.9-7.2 2.2-1 4.6-3.9 4.9-6 3 .5 7.7-1.6 9.7-1.1-3.3-4.4-8.8-6-14.5-5.5-5.9.3-8.4 4.4-9.2 8.8-3.4 2.1-4.6 9-3.3 11.5-2 0-3.9 1.9-4.6 3a27 27 0 0 0-9.4-2m1.3-7c-1.2-3.4.3-6.4 1.1-9 2-6.8.8-8.6-5.3-7.7a47 47 0 0 0 4.2 16.7z"/>
|
||||||
|
<path d="M248.6 282.3c1.6 1.6 7 2.3 7.6-2.6.7-5.6-1.6-7.8-6.5-5.6-.4 1.3-.8 6.5-1 8.2z"/>
|
||||||
|
<path d="M249.8 273.9c2 .8 6.5 2.5 9-2.3 2-4-.7-7-5-6.8-1 1.2-3 5.3-4 9.1z"/>
|
||||||
|
<path d="M253.6 264.4c.5 1.6 5.8 6.7 9.6 3 3.9-3.7 3.9-9.3-1.9-11.3-1.5.2-6.2 5.6-7.7 8.3z"/>
|
||||||
|
<path d="M261.3 256c1.1 3.3 4.8 8.8 11.5 6.3s3.8-11 .7-12.7a33 33 0 0 0-12.2 6.5z"/>
|
||||||
|
<path d="M273.5 249.6c-.5 2.9 0 10.6 9.2 10.5 9.1-.2 6.6-10.9 4.2-12.4-3.7 0-10 .1-13.4 2z"/>
|
||||||
|
<path d="M287.3 248c-1 2.3-3.3 16.7 14.6 12.7 2.3-.5 8.3-13.8-14.6-12.6z"/>
|
||||||
|
<path d="M297.1 249.4c-1.8 1.8 2.8 16.3 15 13.9 12-2.5 1.9-16.3-15-14z"/>
|
||||||
|
<path d="M307.4 251.6c-2 4 1 15.8 15.9 15.8 13.5 0-.7-15.6-15.9-15.8z"/>
|
||||||
|
<path d="M319.1 255c-1 2.3-2.1 14.8 15.5 15.9 12.7.8 9.6-17.3-15.5-15.9z"/>
|
||||||
|
<path d="M338 260.3c-2.1 3.9-4.4 13.5 14.9 14.3 12.3.5 4.7-14-14.8-14.3z"/>
|
||||||
|
<path d="M354.1 263.3c-2.8 3.8-.7 11.4 6.5 12.8 9 1.8 10.3-6.7 4.1-10.8-6.2-4-10.6-2-10.6-2z"/>
|
||||||
|
<path d="M363 265c-2.1 3.7-.9 12.4 12.8 12.4 2.8 0 13.6-11-12.9-12.3zM257.1 433a20 20 0 1 0 0-40 20 20 0 0 0 0 40z"/>
|
||||||
|
<path d="M404.1 141.7a35 35 0 0 0-5.4 8c-6.7 20 11.2 35 21.6 56.7a63 63 0 0 1-5.6 60.7c-4.4 5.8-3.1 7.5-8.8 13.4-2.2 2.3-4.6 5.2-3.8 13.4 3.6-1.3 8.7 2 9.7 5 2.6-1.4 6.2-.9 7.5.7 4.4-2 8-1 11.9 3 3.3-.4 7 0 10.3 3.7 1.8-3.6 5.4-5 8-4.1-.3-4.7 4.3-8 8.4-6.2a7.6 7.6 0 0 1 9.8-9c4.7-3.6 14-3.9 18.6 1.5-8.3-2.3-8 6.5-15 5.7 1.8 5.1-2.8 8.1-7.4 9.8 3-1.4 6.2-3.1 7.2-1.3 2.6-2.3 7.7-1.4 9-.3 3.4-1 6.7-.2 8.2 3.9 4.7 2.8 7.8 10 4.4 15.4-1-5.6-4.9-5.4-6.4-7.7-3.6 1.3-7.2 1.3-8.3-1-2 2-9 3.9-12 .8-1.2 4.6-5.2 8.5-9.9 8.5 1.3 3.6-2.3 9.7-5.1 12.8 4.4 2.3 3 7.5 2 10.6 6.8 1 1 7 12.7 10.8-5.7 1.8-16.8 0-18.3-7-5.7-.2-9.5-5.9-9.3-11.8-4.4-4.1-5-10 1-14.2-5.1 1.6-8-6.7-15.4-3.3-3.7 1.7-13.5-1.2-13.4-4.6-1.5 2.5-11 1.5-12.2-2.9-3.1 1.7-10.3-1.1-10.2-5.4-4 1.8-9.4-1.4-9.1-5.5-3.8-.5-4.2-3.9-4-6.7-3.3-1.6-2.4-4.8-1-8.6-2.4-2.6-1.4-6.2.4-9.6-2.5-2.6-2-5.6-1.3-9.3-12.3-1-27.8-4-63.3-14.9-53.6-16.5-68 22.2-56.2 46.4 13.7 28-1.5 34 3.1 54.8 5 1 7.5 5.2 7.2 9.6 3 .1 5 2.8 4 8a9 9 0 0 1 7.6 2.3c1.8-3.4 7.8-4.2 10.8-.3 6.7-.5 10.1 5 9.8 11.6a18 18 0 0 1-1.5 19.3c.4-2.7 0-6.5-.1-8.9-.3-4.2-6.2-5.1-5.6-8.6-3 .3-6-1.4-7-3.7a7 7 0 0 1-6.6 1.3c3.4 1.5 6.2 7.7 5.1 11.8 1.8 3.1 1.4 8.8-.7 11.2-1 5-5 6.8-10 4.6 2.9-1.8 3.9-5 3.8-7.7a10 10 0 0 1-2.9-6.3c-5 .8-12-3.5-13.2-5.2a20 20 0 0 0-20 20.1c-.6-4.1-5.8-8.2-5.1-11.7-3.1-9.5 1.3-18.4 13.9-20.2-1.6-3.6 3.8-7.3 1.8-11.4a97 97 0 0 0-14.7-20.1c4.4-7.5 3-17.5.5-23.7-3.7-8.9-7.2-6.7-20.3 7.7-21.4 23.5-50 17-75.2 32.5-6.7 4-13.4 5.6-6.2-1.6s26.2-14.4 38.6-20.6c23.2-11.6 42.8-30.9 50.5-68.5 18.1-88.4 85-59.2 127.2-42.8 39.7 15.5 32.5-19.5 12.4-40.7-24.2-25.3-19.3-45.3-8-61.3 20.3-2.8 59.4 4.3 51.5 11.1z"/>
|
||||||
|
<path d="M475.9 358.8a22 22 0 1 0 0-44.1 22 22 0 0 0 0 44z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke-width=".4">
|
||||||
|
<path d="M391.8 142.7c-5 21.7-.8 31.5 6.4 41 14.9 19.7 26.8 64.6 9.8 94"/>
|
||||||
|
<g stroke-linecap="round">
|
||||||
|
<path d="M417.5 252.3c2-.7 6-3.2 6.8-7.4m-5.2-2c.6-3.7 6.4-5.3 6.5-9.3m-6.4-5.2c-.4-3.9 5.8-7.4 4.9-11.2m-8.3-2.7c-.5-2.2 5.2-6.3 3.6-9.8m-7.8-3.8c-1.2-2.4 2.7-5.2 1-7.8m-7.2-3c-.4-1.6 2-5.3.7-7.5m-6.9-5.2c.5-.7 2.6-2.2 1.8-4.1m-6-5.3c.8-.4 3.3-1.2 3-3"/>
|
||||||
|
<path stroke-linejoin="round" d="M266 410.9c-5-1.8-11.5.7-12.8 5.1m3.9 4c.6-4.4 7.3-6.3 9.3-4.3-4.2-2.3-6.3 6-2.5 6.2m34.6-103.8c-3 1.6-4 7.2 0 11.5m4.6-10.2c-2.1 1.8-2 7.2 1.2 8.5-2.7-2 0-5.3 2-5.4 1.9-.1 3.2 2.2.8 4.5m177 5.2c-7.2-2-13 6.4-6.4 13.9-.2-7.2 5-12 11.3-10.7m-3 5a2.7 2.7 0 0 0-2.7 2.7c0 1.4 1.2 2.8 3.2 2.8 1.3 0 2.4-1.5 2.4-2.7m22.6-161c1.2 4.4 7.2 6.3 12 5.2m0-2.8c-3.7.1-6.8-3.4-6.6-6.3 0 2.2 5 3.2 6.6 1.8"/>
|
||||||
|
<path d="M267.9 331.7c-1-2.6 3-5.2 3.2-7.6.1-2.5 4.6-4.4 9.3.2m-2.2-27.4-2.5 1.2m11.4 12.8c-1.1 0-3.4 0-4.6-.9m0 5.8c-.8.5-2.8 1.2-4 1.5m-40.1 76.3c-.2 2.3 2.2 5.7 3.4 6.7m6.6-12.2a10 10 0 0 0-1.3 7.6m20.5 0c-2.3-1.5-.8-5.4-1-8-.3-2.6 2.7-6.7 8.5-3.2M246 381c2.3-.4 4.7-.3 6.3.4m23-7.7a8 8 0 0 0-1.4 4m12.3-4.3c-1.8 0-3.4 1.3-4.2 2.6m-20.8-68.5c2 .7 7.4 4 7.6 7.4m14.3-24.2c-6.3-.1-8.8-6.5-4-6.5m15.3 15.2c-2.4 1-1.3 5.2 2.2 7.3m-17.3 33.1c-1.2-1.6.4-6 4.4-4.7m5 51.7c.3-4 5.2-6.2 7.2-1.8m-25.5 13c-.3-4.3 2-5.7 3.8-6 2-.2 4.7 1.4 6 4.2m-48.1 5c.2-2.6 2.4-5.3 4.7-4.9m231-109.4c-1.7 1.2-2.8 6.7 3.5 7.2M458 296c0 .6.8 1.5 1.3 2m29 8.3c-1.6-1.3-6 4-2 7.7m-39 35.6c-.9-3.7 2.5-4.7 5.8-3.9m-14-22.2c2-1.2 4-2.7 6.4-3.3m-7.4 17.5c0-3 1.6-5.7 3-6.4m8.4-29.1a16 16 0 0 0 2.4 9.2m28.2-9q-3 .7-4.2 2.9m2.1 7.7q1.6-1.3 2.4-2.3m46.3-110.2c0 3.6-4.5 5.6-7.5 3.3m17.3-3.1c2 1.5 8.9 0 7.3-4M528 221.4a11 11 0 0 1-4.9-3m19.1-7.7c-2.3.5-3.9 0-5-.5m-31.6 13.4a17 17 0 0 0 6.5-1.6M502 200.8q-2.5-.3-3.9.8m29.8 5.5a11 11 0 0 1-3 5.2"/>
|
||||||
|
<path stroke-linejoin="round" d="M497.5 212.8c3.2-1.4 7.2 9.5 15 5.7m.6-11.4a11 11 0 0 0-1.8 5.6"/>
|
||||||
|
</g>
|
||||||
|
<path d="M359 190.4c1-.2 2.9-.5 3.3-1.8M226.5 310.3c3.9 2.2 6.6 5.9 5 11.4m172.1-143.2c1.2.8 5.5.8 8 0m3 2.6c0 1.8.5 8.6-3 10m1.3-.8c3.2 1 9.6.6 11.7-5.3m-4.5 5c1.7 2.6 2.2 7.6-2.8 10.3m4-6c3.8 1.2 12.4 1.4 11.5-6m-3 6c2.7 3.5 14 7.8 12.2.3m-22.5 10c4.3 1.1 10.5-1.9 8-9.6m12.3 3.9c.6 3 15 6 13.1-.7m-2.7 3.9c2.7 6.2 17 5.7 12.5-2.6m-2.3 6.5c2.8 3.4 15.5 1.4 10.4-7m-.1 6.8c7.9 6 17-2.5 7-8.7m4.6 6.8c7 5.5 15.5-4.5 9.4-7.3m-64.4 5c2.2.6 6.8.4 7.9-3.6m-1.8 2.7c-.2 5.8 9.6 8 12.1 1.3m-3.3 3.7c1.8 3.9 10.5 5.4 11.9-.1m-1.4 2.6c1.4 3.8 8.9 3.4 11-.6m-2.8 2.6c2.3 5 11.9 5 14-2.3m-1.6 3.2c3.7 2.3 11.7 1.4 11-5.8m-1.5 5.2c5.6 4.5 13.4.1 9.5-7.5m-.3 13.4c3-.5 4.5-6.4 1.4-8m-70 9c6-3.3 7.3-9 3-14.5m2.2 8.9c3.9 2.3 11.2-.2 12.5-5.8m-7.2 6.4c2.2 2.8 2.6 6.3-.3 9.5m2-5.9c6.9-4.2 15.4 3.6 9 8.4m-1-8.8c1.5-.4 4.2-3.2 4.4-6.4m-1.4 9.4c2.9-3 22.2 3 10 9.3m-1-17.4c3.8 1.4 5.7 6.7 0 8.5m5 4.4c4-4.3 17-1.6 12.3 3.8m-3.1-5.5c2.3-7.9 16.1-3 11.6.2m-14.4-8c.4 1.5.5 5.3-2 7.4m13.6-9a6 6 0 0 1-.8 5.3m9.5-5.2c.8 1.4 2 4.1-.8 6.3m-109.4-65.5c.1 7.3 2.7 12.2 12.6 7.6m-9.5 1.1c-5 6.6.6 13.7 10.3 6.6M365 165c6.7 7.2 18.7 2 11-9m8 15.2c-1.2 7.1 4.6 8.5 9.3 5.3m-34.2-10.3c1.2 7.1 8.5 12.7 15.6 8.4m-6.9 1.4c0 10.2 14 11.3 17.3.8m-5 6.4c4.5 9.3 14.3 5.5 17.5-.1m-27.4-14.7c1.8 4.5 5.4 9.5 13.7 5.8m-39.5-8c1.2 3.7 7.9 8.2 15.6 3m-10.8 1.8c-4.2 6 4 11.7 14 2.7m-9.1 4.7c1.6 8.5 5 15.4 17 4.4m-6 4.4c4.4 5.4 11.1 8.7 17.4-.4"/>
|
||||||
|
<path d="M387.3 188.8c-.4 6.5.8 9.7 5.9 9.4 4-.2 7.7-3.3 9.9-6.7m-10.8 6.7c-.2 7.4 5.6 13.2 16.5 5.7m-12 3c-2 5.5 4.1 14.5 16.2 9.9m-41.9-24.6c-.7 7 5.8 11.8 16.4 2.7m-11.2 5.1c.4 5.8 7 12.8 16.6 2.7m-12 4.7c-.7 9.9 8.4 12.7 16.1 5.1M367.2 200c2 .2 3.7-1.6 4.7-3m-.4 10.2c1.8.4 5-1.2 6-3.3m-1.4 15c2 2.3 9 .7 9.9-2.3m-2.2 2.2c3.8 9 14.1 8.7 18.4-1.5m-2 3.6c2 5.3 6.9 8.6 14.9 6.8m-11-1.4c-4.5 7.3 1.4 16 11.5 7.5m-9 3.5c-.5 4.6 3.7 9.9 9.5 10.5m-28.6-24c-1.5 10.4 6 15.4 15.3 9.8m-26.2-4.8c2.4 1.9 6.8 2.1 11 .6m5 6c-2.4 8.8 6.6 15.1 14.3 5.3M380 230.2c.2 4.5 4.5 9.4 12.1 8m21.3 9c-5.2 3.4-6.2 9.6 1 13.6m-13.6-15.4c.2 5.2 2.7 8.2 8.5 8.7m-16.1-11.4c-7.8 7-.2 15.3 9 8.3m-3.6 2.2c-2.6 8.1 7 13 12.2 4.8m-28.2-22c-2.8 8 .8 13.2 7.4 12.7m-17.6-14.3c.4 4.8 4.5 6.5 9.2 5.4m-6.2-.4c-3.5 6.7 1.8 10.3 8.8 7.8m21 15c-1 4.2-.4 7.2 5.9 8.7m-5.4-2.7c-7.6 3.4-8.3 10.8-2.4 15.5m-3.8-23.7a7.6 7.6 0 0 0 1.2 12.3m-35-35.8c-4.3 4.3-.2 16.2 9.5 9.7m15.7 5.3c-6 5-3.3 13.8 6.5 11.4m-16.3-15c-3.1 8.7-.2 11.8 6.8 11.9"/>
|
||||||
|
<path d="M359.3 236.1a8.2 8.2 0 0 0-1.5 12.2c2.3 2.6 6.7 1.4 8-1.9m-17.6-13.8c-5.9 7.6 0 16.6 8.4 14m23.3 8.6c-6 2.1-10.7 7.6-7 12.5 2.3 2.8 11.8 3.2 14.5-7.8M369 248.1c-3.5 5-2.4 9.8 4 12.2m-4.4-2.8c-3.8 2-6.2 4.5-5.3 9m-3.1-16.9c-1.4 6 .3 9.8 4 11.6m-4.1-4.4c-5.8-.4-8.8 2-6.8 7.8m.5-6.4c-5.7-2-6.6-7-4.1-12m-.8 7.6c-6.2.2-9 3.3-9 7.5"/>
|
||||||
|
<path d="M340.5 229.7c-4.5 1.9-6 8-4.3 11.3s7 3.6 10.2 1.4M328 224.9c-4.1 4.6.6 13.7 8 11.5m-18.6-15.1c-3.8 5.4.4 14.4 10.3 11.8m.5 25.1c-1-6.4 5.7-10.6 14-2.3m-4-13a10 10 0 0 0-3.3 8.5m-28-33.2c-4.3 7.2.9 13.8 10 11.2m13.8 6.7c-6 6-4 12.1.5 15.6m-4.4-8.5c-9.1.1-9.6 10.7-2.2 14m-4.7-24.6c-3.8 2.6-5 9.7 1.5 12.5m-1.8 3.4c-3.9-1.8-8.5.4-8.1 4.9m2.3-4.8c-3.5-8.2-13.6-6.8-12.7 1.7m15.5-11.9c-2.1.4-6.5 1.7-8.2 5m.6-14.3a7 7 0 0 0 2.5 11.4M296.7 216c-.8 5.6 1.4 8.3 8.4 7.8m-6-.7c-2.6 6.7 1 9.7 8 9.3m-6.4-1c-4.1 4.9-1.3 10.1 2.7 12.3m-4.6-6.1c-7.4-1-8.5 7.6-6 11.5"/>
|
||||||
|
<path d="M292.9 215.5c-4.5 2-7.1 7.6-4.7 11 2.5 3.5 7.4 2.4 10 .6m-8.4 1c-3.8 5.7-.4 10 3.6 11.6"/>
|
||||||
|
<path d="M275.3 214.8c-3 3.1-1.1 9.4 6 9.6 5.9 0 8.8-5.4 7-9.3m-8 9.3c-2.3 5.1-.8 10.7 8 9.8m3.6 8c-5.3-2.4-12.6 0-9.5 6.5m-.7-15.6c-2.2 3.1-1.5 7.5 1 10.2m-1.4-2c-3.6.7-7.1 2.6-5 8.3m-.5-4.6c-4.6-1.4-10.5 2-7 6.7m-.7-4.5c-4-.7-8.7 3.6-4.9 7.8m-1.5-3.5c-3.6 1-7.9 5-4.1 8.3m22.3-28.5c-5.2 2.3-5.7 8.1-3.3 12.4m-2.2-23c-7.7 3-7.6 13.2 1 16.4m-3.7-2.3c-4.6 2.7-5.5 7.9-2.4 11.2m-5.7-29.2c-3 1-2.8 10.5 5.4 10.3m-13.9-7.1c-6.2 4.1 1.3 14.5 11.3 7m-5.5 2.7c-.9 4.2.3 8.8 7.1 9.4m-6.7-3.8c-4.9 1.8-5.8 11.9 3 12.7m-18.6-21.8c-6.3 5.8 5.2 10.8 9.4 2.4m-18.1 7.2c-3.1 3.8 7.7 13.6 12.5-2.8m1.1-.3q.4 6 7.3 6.5m0 3.7c-7.7 1.2-10.1 10.7-1.6 12.8m-12.5-14c-.3 3.5 3.5 6.5 7.7 5.9m3.9 7.8c-7 1.6-7.9 10.6-1.7 10m-3.2 8.4c-5.8-1.7-6-8.6-.8-11.2"/>
|
||||||
|
<path d="M245.7 267.8c-4.9 3-3 10-.4 11s4.7-.2 5-2.4m-.5 10c.6 3.3-11.9 2-5.5-8.4m0 8.9c-4.2 6.6 2.6 12 6.9 6.3m-6.4 1.6c-1.7 5.3 4.7 9.1 8.8 5M231 245.4c-2.3 4.7 9.3 6.5 10-3.3m-13.2 10.3c-2.3 9.3 15.2 7.4 10.7-4.6m.4 6.2a8.2 8.2 0 0 0 11.6-6.3m-5.3 6.6a10 10 0 0 0 4.7 6.3m-13.1-3.9c-.8 5 4.3 9.2 10 8.8m-9.1-3.9c-2.8 3.9-3.7 11.3 5.2 11.8M224 263c-1.4 4 7 8.1 11.8 1.5m-14.4 8.7c-.7 3 6.8 7 11-5.8m-3.3 6.5c1.8 3.2 7.9 5.8 11.6-.5m-6 3.9c-1.6 4.5 2.7 8.7 7.8 7.5m-17.5-8.5c-1 6.4 6.5 10.1 11 6.8m-15.9-4c-2.2 8.2 8.4 11.3 12.4 5.1m-2.7 2.5c.4 4.6 7.6 8.6 13.2 4.8m-26.1-1c-.5 2.1 8.1 4.2 9.4-3m-4.4 4.9c2 5.2 9 6.5 13 0m-2.3 2.6c1 5 7.2 7.7 12.8 4.3m2.3 2c-1.4 6.5 5.4 11.8 9.6 8.2m-20.6-9.4c-2 7.2 7 11.3 12 7.3m-22.8-11c-.5 6.8 4.8 10.8 10.8 7.8m-22.3-7c-1.8 4.3 7.8 7.9 12.3 4.2m-18.2 7.7c2.5 2.8 11 0 11.7-6.3m-2.3 4.6c3.1 3.6 10.5 5.6 13.4-2.2m-2.6 4c0 5.6 9.8 9.6 13-.6m11.7 2c-1 2.7 1.2 7 5.7 7.5m-13.9-9.2c-.6 3 3.9 7.4 8.7 5.7m.9 1.3c-1.3 3.3-.2 8.1 4.3 8m-3.9-1.8c-3.4 2.8-2 7.9 2.9 8m-4.6-3.2c-4.8 3.2-3.1 10.3 3.2 9.9M239 313c0 7 8.7 8 10.3 1.6m-3.3 4c-1.3 4.2 2 8.2 7.3 7m-6.3 42.8c1.2 1.7 6-1.2 4.7-4s-6.4-1.3-5.8 1.7m4.6-2.9c.6-5.6-6-6.5-7.7-1.6m2.4-2.9c1.7-2.8-4-6.7-6.3-2.5m2-1.7c1.6-4.1-4.8-6-5.3-2.2m-1.5-5.4c.8-2 8-.8 5 3.5m5.5 5.1c2.8-2.6-2-7.8-5-6m24 13.9c-2.2.2-4.5 1.7-2.9 6.5 1.2 3.3 6.1 3.4 6.9 1.5"/>
|
||||||
|
<path d="M260.5 365.9c-2.2-1.5-7 1-4.8 5.6 1.6 3.5 5.8 2 6.4.2m-14 .8c1.2 2 6.6 1 7.6-1m-3.8-5.4c1.2-.8 3.3.2 3.7 1.2m-4-33c-3.8 2.5-1.5 10 4.3 8m-5.6-1.9c-3 2.3-.2 9.6 5.3 6.8m-4.3 0c-2.1 2.1-.1 7.8 5.2 6.7m-6-3.8c-1.2-.5-4.2-.2-5.5 1.8m2.1-28c-2.8 2.8-1.4 8.3 3.8 8.8m-4.8-3c-4 2-4 10 4 10.3m-4.3-1.3c-2.3 1.8-1.2 8.8 4.6 7.9m-2.8-.3c-.8.9-1 3-.2 3.9m-2.3-6.5c-2 0-4.8 1.5-5.5 3.7m-3.7-7.5c.7-2 7.1-2 7.6 4.1m.6-7.2c-.9.2-2.6 1-3 2.8m.2-21.7c-2.7 2.6-4 10.5 4.3 12m-9.9 4.4c0-2.1 5.8-4 7.8-.8M232 322.8c.8 1.6 4.8 3.8 7.2 2m-28-14.6a6.3 6.3 0 0 0 7.2 6.5c4.3-.5 5.2-4.2 3.9-6.7m-3.2 6.9c-2.9 3.4.6 8.3 3.8 6.9m-.6-9.4c1.7-.6 7.2-1.4 8.9 1.2m-26.8-.2c-1.3 2 6.7 3.9 9.4.2m-2.7 1.9c-.3 2.6 1 7 7.6 5.1"/>
|
||||||
|
<path d="M219.8 326.6c1-2.2-3.8-5.8-7.6-1.8s.6 8.5 2.9 7M202.7 318c-2 3.4 5.5 9.5 9.8 3.8"/>
|
||||||
|
<path d="M197.7 323.8c-2.7 2.8.7 7.9 4.7 6.3 4.1-1.5 3.7-5.8 2.7-6.9"/>
|
||||||
|
<path d="M192.5 329c-2.2 2 0 6.6 3 6.6s4.9-2.3 4.3-5.4m5-1.6c-.4 3 4 5 6.9 2.2m0-5.4c.5-.6.2-1.7-.5-2.3m-23.4 9.8c-2.5 2 3 7.6 6.3 2.5m13.1-3.5c-1.6 1.5 1 5.5 3.6 4.3m-12.4-1.5c.4 2.7 5.8 4.8 9.2.6m-6.3 2.1c-.8 1.8.9 4.5 3 4.2m48.3 11.8c-2 3.9 4.4 8.5 9 3.7m-4.4 2.1c-1 2.1.2 5.1 2 6.2m-8-1.7c.4-2 3.2-3.8 5.7-2.9m-9-2.2c.3-1.8 2.6-3.5 4.4-3m147.2-77.4c-9.2.1-5.3 14.8 2.6 11.9m-5.6-1c-1.8 2.9 1.7 7.6 5.6 4.8m-1 .6c-2.8 4.5 6.9 11.4 10.8 4.3m-3.1 2.7c0 4.3 12.5 7 10.7-1.5m-1.2 4.6c3 5 14 5.5 12.9-2m-2.5 5.2c2.3 3.4 13.2 5.4 12.8-1.5m-56.7-40.8c1.5 4 6.8 5.4 12.6 3m-16-.2c3.7 2.3-1.6 12.9-7.1 8.6m7.2-2c4 1.9 8.9.5 10.3-5.2m-2.5 4.4c.4 3 4.7 5.3 10.2 4.1m-20.1-1c5.2 4.4-2.3 13.4-5.7 9.3m7.6-6c2.5 1.8 8.8.8 9.4-4m-2.8 4c.5 3.1 3 4.3 6.4 4.4m-14.4-.1c2.6 3.6 9 4.6 11.9-.1m-2.9 2.7c-.2 3.4 3.6 6.3 7.6 5.5M375 295c3.3 1.7 7-4.7 4.1-9m-.2 15.7c4 .2 4.5-5.8.7-9.2m8.4 14.7c3.4-.8 2.3-8.1-5.8-8.7m16 14.1c3.2-.9.6-9-8.3-8.5m20.5 11.4c2.2-3.4-5-9-11-6m17 10.6c4 1.2 6.8-9.4-5.9-7.7m13.3 7.7c3.5-1 6-7.2-4.2-5.2m12 5.7c3 1.6 4.4-7.5-5-5m-47-23c3.9 2.1 10-.4 9.4-5.3m-1.5 4.2c1.3 2.3.8 6.8-1.7 8m2-2.6c2.6 1 6.1.1 8.2-4m-3.6 4c.7 1.8 1 5.4-.7 7.2m1.3-4.1c2.9 1.6 6.2-.5 7.6-3.5m-1.8 2.6c2 1.3 3.9 7.3.4 9.8m2-2.8c2.4 0 6 0 8.3-3m-1.7 1.7c2.2.6 4.8 4.2 3.9 7.4m.1-1.1c2.5-.3 6.7-2 7.9-5m-1.2 2a7 7 0 0 1 3.2 6.5m0-2.1c2.5-.1 4.7-1.4 5.3-4.1m-.6 1.8c1.8.6 4 2.7 4.1 5.1m-.3-1.5q2.6-.5 4.3-3.2m4.9-.3c2.3 2.3-.8 10-5.6 8.6m-43.9-164.7c-4.7 2.9-18.3 2-11.6-9.2m13 1.6c-9.5 3.8-21-3.5-9-11.3m-3.2-2.6c-7.8 0-13.1 12.6-2.6 17M348.6 138c-2 4 5.3 8.4 10.3 4.4 3.9-3.1 3.6-11.5 1.3-14.7m-15.6 19.8c-2.6 8.5 16.4 9 13.4-4.5m-14.4 17.2c.6 6.4 18.1 4.3 12.5-8.7m2-3.6c1.4 2 5.3 5.3 11.9 4.6m-9-13.5c.6 2.3 4.5 4 9.5 2.3M185.4 334.8c-4.5 3.1 2.4 8.1 4.9 2.6m-8.4-.2c-4.4 3.2 2.4 8.2 4.9 2.7m-8.6-.3c-4.4 3.2 2.4 8.1 5 2.7m-8.8-.5c-4.3 2.3.7 7.8 5 3m-9.2-.9c-4.3 2.3.7 7.8 5 3m16.6-9.6c-.1 3.3 6.1 4.6 8.4-.3m-4.5 3.3c-2 2.5 1.6 5.5 3.5 4.4m-11.8-4.8c-.2 2.8 5.2 4.4 7.8 1.3m-4.9 1.6c-1.7 2.2 1.7 5.3 4 4.4m-10.5-4.8c.2 2.2 3.3 4.7 6.6 3.3m-4.6-.3c-1.3 1.1-.7 3.8 1.1 4.7m-7.8-5c-.5 2 2.9 5.6 6.4 3.3m-5.7-1c-2 1.7-1.8 4.1.8 4.5m-5.8-4.7c-.2 1.4 1.5 3.6 3.7 3.3m-14.7-3.4c-2 1.1 3.2 6.7 6.5 1.5m-11.6.8c-2.6 1.2 3.8 7.4 6.7 1.1m10-1.3c-2 .4-3.3 3.3-1.6 4.5m-5-3.5c-.2 1.2 2.3 3 4.3 2.4m-5.9-1.9c-2 1.2-.9 4.6 1.4 4.1m-8.3-1.8c-1.8 1.3-.5 4 2 3.4m1.3-3.6c0 .7 1.7 1.8 3 1.3m-12.5-2.5c-2.3 1-2.1 6.6 5.5 3.6m-10.6-1.3c-3.2 1.5-2.1 5.8 4.8 1.7m.5.3c-.8.7-1.7 3.5 1.5 2.4m-7.2-1c-1.3 1.1-.5 3.5 2.3 2.6m-9-2.2c-2.3 1.3 2.8 3.1 6.2-.5m-4.6 2.1c-1 1-1 4.1 1.6 3m-8.2-1.7c-1.5 1.1 1 2.7 6 .5m-4 1.1c-1.9 1.5-1.7 3.4 1 2.8m-6.4-2c-2 1.9.9 3.1 4.4 1.6m-4.4.5c-3.4 1.5-1.9 4.4.2 3.3m113.8 6.5c-.8 1.7 1.8 3.1 4.3 2.7 2.3-.3 4.6-2.3 2.8-5m.1 3.4c2.4 1.8 6.5-.3 6.5-3"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" stroke-width=".4">
|
||||||
|
<path d="M396.8 103c-10.3-5-31.7-14.6-37.8-6.9 5.6-2.3 21.8.1 35.2 12.5z"/>
|
||||||
|
<path d="M403 102.5c-11.9-13.9-19-10.8-27.5-15.5-8-4.5-20.8-5.4-23.3 1.7 11.7-5.7 22.5 3 29.2 4.2 9 1.4 14.2 8.5 16.9 11.7l4.7-2zm44.9-1.8c-6.2-14.1-19.4-10.4-25.2-16.4-8.5-8.8-30-17-39-10.9 19.6-1 28 13.5 38.4 18.6 7.5 3.6 15.5 11.3 25.8 8.7z"/>
|
||||||
|
<path d="M424.7 99.2c-10.5-13.1-26.8-24.7-34.2-20.4 9.8.6 12.9 7.4 19.8 11.8 7 4.3 3.9 10.7 14.4 8.6zm-50.2 23.2c-11-4.1-32.7-6.2-42.8 6.4 16.8 2.9 42 1.3 42.8-6.4z"/>
|
||||||
|
<path d="M372.4 127.3c-11-5.2-19.7 2-30.1 1-19.8-2-34-.8-35.8 8.8 11.3-10.2 30.4-1.8 38.1-3.9s36.3-.3 45.3 3.6c-4.6-5.7-11.8-7.2-17.5-9.5zm34.8-29.5c-2.6-8.5-2.4-17.5 10.3-16.9-3.2-4-15-6-17 8.9-14-10.3-29.4-12.1-32.2-3.2 7.2-6.2 18.4-1.7 31.8 13.5a24 24 0 0 1 7.1-2.3z"/>
|
||||||
|
<path d="M387.9 109.5c-8-5.2-18.8-13.5.1-16.9-8-4.3-20-2.4-18.7 12.5-21.6-8.7-37.1-5.8-40.4 2.9-3.6 9.5 9.8 14.8 12.1 8.7-2.4 1-10.8-1.8-6.4-7.2s26.8-1.3 48.1 9.8c6 3 26.3 2.6 5.2-9.8z"/>
|
||||||
|
<path d="M382.2 123.7c-6.1-12.6-26.1-1.2-30.1-13.4-5.6 17.9 28.4 8 30.1 13.4zm127.1 13.6c4.2 2.1 7.8-1.2 1.4-3.7 4.2 2 7.9-1.1 1.5-3.7 4.2 2 7.8-1.1 1.4-3.7-1.7 1.7-4.1 8-4.3 11.1zm2.2-24.2c9.3-9.8-.7-13.1 10.6-23.2 9.3-8.2 1.8-13.7 10.5-20 2.9-2.1 9-6.2 9.6-10.4 3.7 9.3-11.6 10.6-10.6 25.5.7 9.5-5.8 8.7-8.2 24.8-.5 3.3-2.9 10.8-11.9 3.3z"/>
|
||||||
|
<path d="M515.6 117.5c5.2-11 11.1-10.9 14-15.2 5.4-8.3 16.8 1.4 26.5-6-1.7 10.5-14.7 6.8-20.4 13.5s-10.3 9.7-20 7.7z"/>
|
||||||
|
<path d="M517 121.1c9-7.2 15.6-2.4 21.8-6.2 15.7-9.5 22 2 36-2.6-3.6 9-24.4 1.3-33.4 8s-40.7 13.2-24.5.8zm-26.3 51.4c-.2-4.1-4-9.4-9.4-10-5.4-.7-7.8-6.4-11.9-6.6-4.1-.3-6.8-8.5-12.5-8.4-5.6.1-8 7.5 5.3 14.2s28 14.4 28.5 10.8zm-16.8 3.5c-5.6.2-6.4 8.5-11.8 8.7 7.4 4 12.9-1.8 16.7-7L474 176z"/>
|
||||||
|
<path d="M478.6 177c-5 4.1-6.4 12.7.7 15.2-4.2-5.9 7.5-8.5 3.9-14z"/>
|
||||||
|
<path d="M483.4 177.3c-3.8 7.4 6.1 8.3 3.5 14 5.7-1.3 6.6-12 1.4-14.8l-4.9.7z"/>
|
||||||
|
<path d="M445.6 161.3c9.3-.5 17.7 4.5 23.4 12.6 3.6 5.1 15.7 7.2 19.9 3 4-4 1.8-12.8-8.5-10-2.6-4.3-10-2.8-13.7-6.4s-17.5-13.9-21.1.8z"/>
|
||||||
|
<path stroke-linecap="round" d="M480.4 167c-2.5.5-3.3 4.7-1.7 6.9m7-4.4c.7 1.3.2 3.1-.2 4m-25.2-11.8c4.7.2 5.6 3.7 10.8 5"/>
|
||||||
|
<path d="M457.1 150a161 161 0 0 1 37.6 12.3c8.1 4.6 20.7 6 31.6 2.8 11-3 32.2-5.9 31.1 7.8 5.8-6.9-1.5-14.2-16.2-15.5.3-6.7-6.9-12.7-12-8.7 4.7-.7 8.8 8.2-.6 11.8a8.4 8.4 0 0 0-11.7-9.8c4.4 1.3 9 8.8-1 11.6-6.3 1.8-15.4-.5-22.2-4.6s-45-19-36.6-7.8z"/>
|
||||||
|
<path d="M498.6 143.3c-5.1 2.3-1.8 7.7-9.7 10.8-8 3-13.6 10.1-11.8 16.2 5.4-11.9 15-11.3 18.3-16s8.3-11.4 3.2-11z"/>
|
||||||
|
<path d="M500 144c-.2 9.4-7.6 6-4.7 19.2.9 4.1 2.6 10.8-.3 17.5 8.3-6 3-18.7 6.7-23.5 1.8-2.4 4.2-6 5-9-1.9 5.3-1.7 15.4 3.5 18-4.2-10 11.5-18.3.7-30-1.6 3-6.5 8-10.8 7.8zm-26.2-9.4c1.3 2 2.6 6.8 1.4 9.6 2.6-1.6 6.3-5.6 7.5-8.3 5.3.8 7.3 7.3 2.3 10.2 3 0 8.4 0 11.3-3.5-3.6-4-14.8-10.2-22.5-8z"/>
|
||||||
|
<path d="M393.7 116.1a10 10 0 0 0-4.8-2c-7.8-1.6-3.7-8.6 2.9-8.5 14.2-15.5 22.1-3.6 39.4-8.4 6-1.8 10.2-1.3 13.4.2 7.8-5.2 16.8-3.8 23.4 2.3a5 5 0 0 1 3-2.4c6.1-1.7 11 3.4 12.7 10.1 4.7-.8 10.1 1.5 13.7 4.7q7.4-3.8 9.5 0c4.4-2 10-3.4 12.9 3.6s-6.7 4.9-8.3 19.6c-1 9-11 12.6-19 7.2-12.8-8.7-25.3-10-31.5 3-6.1 13.2-11 20.8-26 16.5a16 16 0 0 0-16.7 6.5c-4.4 6-11 .4-19 1 10-1.5 6.1-4 14.9-4.6 8-.5 5.9-8 11-9-20 5.1-19.3-2.4-35.8 2.8 7.2-9.3 18.6-4.1 24.2-9.5-14.9-.3-21.6-10-28.3-6-10.5 6.5-6 24.8-33.5 23.2-13.4-.7-21.9 1-29.8 9 13.9-28.8 32.8-13 42-22.1a68 68 0 0 0 12.3-14.7 6 6 0 0 1 4-3.2c-22.9-7.2-9-18.5 13.4-19.3z"/>
|
||||||
|
<path stroke-linecap="round" d="M506.9 112q.5 1.2.5 3c0 5.6-8.5 5.8-9 14.2-.3 4.4-.8 6.8-3.7 6.3s-5.6-5.1-2.8-10.5"/>
|
||||||
|
<path d="M398 107.7a11 11 0 0 0-6.2-2.1m52.8-8.3c6.8 3 10.1 10 20 10.4 9.6.3 15.5 14.2 31.5 5.1l1.2-.6M468 99.7a13 13 0 0 0-1.4 8.2m-29.8 21c-12 0-15.2 6.1-15.2 12s5.7 13.7 15.7 13.7 15.5-6.2 15.5-12.9-6.2-12.9-16-12.9z"/>
|
||||||
|
<path d="M439 154.3c-.2-3.3-6.3-2.8-6-5.4s3.3-3.6 3.3-7.2 5.4-3.9 7.2-1c1.8 2.8 7.2 8.3 8.4 5.5m-8.4-5.5a10 10 0 0 0-.4 13.1m4.3-8.8c-1 2-1.2 5-.1 6.8"/>
|
||||||
|
<path fill="none" d="M495.5 135.6c9.5.8 11-9.7 4-10.3m-15.7-17.8c-3.2-4.2-10.8-5.6-11 3.2"/>
|
||||||
|
<path d="M472 120.8c-3.2-8-11.5-9.1-15.8-5.1-3.7 3.3-3.8 12 4 13.8 2.8-3.3 8-7.5 11.8-8.7zm-3.5-5c-4.7-4.1-11.5 3.7-5.3 10.8m-83 8.7c3.2-.7 7.7.8 14.7 4 4.3 2.1 17.5 6.5 25.7 2.1-8.5 3.1-15-9.7-21.4-8.2-6.4 1.6-18.2 4-23.1-.8 12 .8 18.5-8.8 32.4-.5a24 24 0 0 0 13.1 3.6c-11.3-13.6-26.2-4.9-27.8-16 6.8 7.4 23.5-1.6 32 12.1m-29.5-10c-1.6-2-1-4-2.6-5.5"/>
|
||||||
|
</g>
|
||||||
|
<path stroke="none" d="M483.8 107.5c-2.8-3-9-.7-7.2 5.4a10 10 0 0 1 7.2-5.5zM466.3 124a21 21 0 0 1 5.7-3.2 12 12 0 0 0-3.8-5.2c-1.5-1-5.7 4.7-1.9 8.3z"/>
|
||||||
|
<path fill="none" stroke-linecap="round" stroke-width=".4" d="M458.7 113.5c-4.6-3.1-8.9-2.8-10.3-.3-3.3 0-6.1 2.4-6.2 7.2m7.1 2.1c-5.5-3.8-13.1-2.4-12.6 6.1m-3.4 4.9c2.7-2.2 6.6-3.8 9.6 0m31.8-8.4c-1 1.4-1.8 3.6-.1 7-2-2.8-7.2-2.8-12.8 4.4m25.1-11.8c-6.7.8-6.5 5.2-1.1 7m-46.5-23.4c-5.7-1.1-9.8 2.2-2 5m12.8-8.8c-8-2-11.2.1-7.6 2m-15.5 30c-.2 2.9 1.3 6.6 6 2.4m-4.4 6.1q.1 1.3-.7 2.4m-17.9-40c-4.9-1.5-5.8-6 0-5.6m-1.6 16c-5.4-1.9-5.4-7.1-1-6.6m11 3.8c-6.3-1.5-6.5-5.9-1.8-5.2m2.6-8.2c-3.4-.2-8.5 3.5.1 5.7m9.3 1.8c-7.9-1.1-7.7 2-3.1 4.1m9-11.6c-6-1.1-8.1 2.5-4 4m-15 18.3c-1.5-1.2-2.7-7 4.3-5.3m10.3 3.5c-4.7-1.3-9.2 3.4-4.9 6m11.2-11.5c-5-1-9.2.5-6.5 2.3"/>
|
||||||
|
<path fill="#fff" stroke-width=".4" d="M483.6 107.5a10 10 0 0 0-7 5.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bw" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd">
|
||||||
|
<path fill="#00cbff" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#000001" d="M0 186h640v108H0z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="flag-icons-by" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="by-a">
|
||||||
|
<path d="M0 0h200v608h8v284l-8 8H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path fill="#ce1720" d="M0 0h640v480H0Z"/>
|
||||||
|
<g fill="#fff" clip-path="url(#by-a)" transform="matrix(.52885 0 0 .53333 5 0)">
|
||||||
|
<g id="by-c">
|
||||||
|
<path id="by-b" d="M36 0v14h-9v14H16v16H8v13H-8V24H8V6H-8V0Zm26 77v15h-8v12h-8V92h-8V77h-8V57h8V42h8V30h8v12h8v15h8v20Zm-17-1h10V58H45ZM19 183h8v-18h-8zm54 0h8v-18h-8ZM-8 305H6v13h6v16h9v15h12v-15h9v-16h8v-13H38v-15h21v10h13v17h11v19h-8v14h-7v13h-6v14h-9v12h-7v11h-9v14H24v-15h-9v-14H8v-9H-8v-23H8v-20H-8Z"/>
|
||||||
|
<use xlink:href="#by-b" transform="matrix(-1 0 0 1 200 0)"/>
|
||||||
|
<path d="M96 0v32h8V0h32v14h-8v14h-12v16h-8v13H92V44h-8V28H72V14h-8V0Zm-2 274v-11h-6v-13h-7v-14h-8v-14h-8v-10h-9v-14H44v14h-9v10h-7v14h-8v14h-6v13H8v17H-8v-44H8v-20H-8v-33H8v14h10v14h10v-14h10v-14h8v-18h-8v-14H28v-14H18v14H8v14H-8v-41H8v-19H-8V77H8v13h8v16h11v13h9v15h7v12h14v-12h7v-15h9v-13h11V90h8V77h16v13h8v16h11v13h9v15h7v12h14v-12h7v-15h9v-13h11V90h8V77h16v28h-16v19h16v41h-16v-14h-10v-14h-10v14h-10v14h-8v18h8v14h10v14h10v-14h10v-14h16v33h-16v20h16v44h-16v-17h-6v-13h-6v-14h-8v-14h-7v-10h-9v-14h-12v14h-9v10h-8v14h-8v14h-7v13h-6v11zm2-167v27h8v-27zm-4 58v-14H82v-14H72v14H62v14h-8v18h8v14h10v14h10v-14h10v-14h16v14h10v14h10v-14h10v-14h8v-18h-8v-14h-10v-14h-10v14h-10v14zm4 46v27h8v-27z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#by-c" transform="matrix(1 0 0 -1 0 900)"/>
|
||||||
|
<path d="M-8 408H8v14h7v8h8v14h7v12h-7v14h-8v8H8v14H-8Zm216 0v84h-16v-14h-7v-8h-8v-14h-7v-12h7v-14h8v-8h7v-14ZM62 459h8v-18h-8zm76 0v-18h-8v18zm-42-59h8v-18h-8zm0 100v18h8v-18Zm-50-75h14v-11h10v-10h5v-10h6v-14h8v-14h4v-13h14v13h4v14h8v14h6v10h5v10h10v11h14v50h-14v11h-10v10h-5v10h-6v14h-8v14h-4v13H93v-13h-4v-14h-8v-14h-6v-10h-5v-10H60v-11H46Zm50 9v-15h-8v-10h-8v25h8v9h5v14h-5v9h-8v25h8v-10h8v-15h8v15h8v10h8v-25h-8v-9h-5v-14h5v-9h8v-25h-8v10h-8v15z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#007c30" d="M110 320h530v160H110Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,145 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-bz" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bz-a">
|
||||||
|
<stop offset="0" stop-color="#ffef5d"/>
|
||||||
|
<stop offset="1" stop-color="#ea5700"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="bz-b">
|
||||||
|
<stop offset="0" stop-color="#952d1a"/>
|
||||||
|
<stop offset="1" stop-color="#570a00"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient xlink:href="#bz-a" id="bz-c" cx="247.1" cy="238.3" r="36" fx="247.1" fy="238.3" gradientTransform="scale(1.22624 .8155)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
<radialGradient xlink:href="#bz-a" id="bz-d" cx="322" cy="152.9" r="10.6" fx="322" fy="152.9" gradientTransform="scale(.93615 1.06821)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
<radialGradient xlink:href="#bz-b" id="bz-e" cx="364.2" cy="237.8" r="36" fx="364.2" fy="237.8" gradientTransform="scale(1.2242 .81686)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
<radialGradient xlink:href="#bz-b" id="bz-f" cx="468.1" cy="156.1" r="10.7" fx="468.1" fy="156.1" gradientTransform="scale(.95596 1.04607)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
</defs>
|
||||||
|
<path fill="#ce1126" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#003f87" d="M0 34.3h640v411.4H0z"/>
|
||||||
|
<circle cx="320" cy="240" r="188.6" fill="#fff"/>
|
||||||
|
<circle cx="320" cy="239" r="156.3" fill="none" stroke="#552300" stroke-width="3.9"/>
|
||||||
|
<path fill="#007f00" d="M302 73.2c14-15.2 26.9 2.3 26.5 10.6C328.2 92 314 110 301 95.5c13.4 3.4 17.4-11.5 24.1-11.7-6.7.2-9.5-14.6-23.2-10.6zm-147.2 119c-7.4-19.4 14-23 21.3-19 7.3 3.9 17.3 24.6-1.6 29.6 9-10.4-2.7-20.5.2-26.6-2.9 6-17.5 2-19.9 16M471.9 173c19.4 7.6 7.6 25.8-.2 28.4-7.9 2.7-30-4-21-21.2 1.6 13.6 17 12 19.7 18.1-2.6-6.2 10.3-14 1.5-25.3M218.8 377.4c-20.7-2-14.3-22.7-7.5-27.4s30-4.3 26 14.7c-5.3-12.6-19.7-6.8-24-12 4.3 5.2-6 16.3 5.5 24.7m209.3-15c.3 20.7-21.1 16.8-26.6 10.6s-7.7-29 11.8-27.3c-12 6.6-4.6 20.2-9.4 25 4.8-4.8 17.1 4 24.2-8.2zM170 153c-1.9-20.6 19.8-18.3 25.7-12.6 6 5.8 10 28.4-9.6 28.2 11.5-7.6 3-20.5 7.4-25.6-4.4 5-17.3-2.8-23.5 10m22.2 193c-20.5-3.5-12.7-23.7-5.6-27.9s30.1-2.3 25 16.4c-4.5-13-19.3-8-23.1-13.6 3.8 5.5-7.2 16 3.7 25zM354.3 74c18.6-9.2 24.3 11.7 21 19.3-3.2 7.5-23 19.4-29.8 1.3 11.2 7.9 20.3-4.7 26.7-2.4-6.4-2.3-3.7-17-17.9-18.2M394 388.3c-9.3 18.5-26.5 5.3-28.5-2.7s6.5-29.3 23-19c-13.8.5-13.4 16-19.8 18 6.4-2 13.3 11.4 25.3 3.7m90.5-178.7c15.6 13.7-1.7 26.8-10 26.7-8.2-.1-26.8-13.8-12.5-27-3 13.4 12 17 12.4 23.6-.3-6.7 14.5-9.8 10-23.3zm-263-108.8c4.7-20 24.5-11.2 28.4-3.9s.5 30-18 23.8c13.3-3.7 9.3-18.6 15-22-5.7 3.4-15.5-8-25.4 2.1m-61.6 172.5c-18.2-10-4.2-26.5 4-28.1 8-1.7 29.2 7.7 18 23.7.2-13.7-15.4-14-17.2-20.5 1.8 6.5-12 12.7-4.8 25zm263.6-159.6c20.6-3 19.4 18.7 14 24.8-5.5 6.2-28 11.4-28.9-8 8.2 11 20.8 1.9 26.2 6-5.4-4.1 1.9-17.3-11.3-22.8M306.3 408.1c-19 8.4-23.8-12.7-20.3-20 3.6-7.5 23.9-18.6 30-.1-11-8.4-20.5 3.8-26.8 1.3 6.3 2.5 3 17.2 17.1 18.8M476.8 293c8.6 18.8-12.6 23.7-20.1 20.2s-18.8-23.6-.2-29.7c-8.4 10.9 4 20.3 1.4 26.6 2.5-6.3 17.3-3 19-17.1zm-325.1-60.6c-12-17 8-25.7 16-23.6s22.8 19.8 5.7 29.2c6.2-12.3-7.6-19.3-6.3-25.9-1.3 6.6-16.5 6.1-15.4 20.2zM264 395.6c-19.6 7.1-23-14.2-18.9-21.4 4-7.2 25-16.9 29.9 2-10.3-9.1-20.7 2.4-26.8-.5 6 2.9 1.8 17.3 15.8 19.9m-5.7-314c11.6-17.2 27-2 28 6.3.9 8.1-10.3 28.2-25.4 15.8 13.8 1.3 15.4-14 22-15.2-6.6 1.2-11.7-13-24.6-7zm200.2 252.1c1.3 20.6-20.3 17.7-26 11.7-5.8-5.9-9.1-28.6 10.4-27.8-11.7 7.2-3.6 20.4-8.2 25.4 4.6-5 17.3 3.3 23.8-9.3m-5.3-194.4c20.4 4 12 24 4.7 28s-30.2 1.3-24.4-17.2c4 13.1 19 8.7 22.6 14.4-3.6-5.7 7.7-15.7-3-25.2zm-258.4-14.6c.2-20.6 21.5-16.2 26.8-9.9s7 29.3-12.4 27c12.2-6.3 5-20 10-24.7-5 4.7-17-4.5-24.4 7.6M169 311c-18.6-9.4-5.1-26.4 2.9-28.3s29.5 6.7 19 23c-.4-13.7-16-13.4-18-19.8 2 6.4-11.6 13.1-4 25zM392 91c19.5-7.3 23 14 19 21.2s-24.9 17.1-29.9-1.7c10.4 9 20.7-2.6 26.9.3-6.2-2.9-2-17.3-16-19.8m-41.3 314.8c-15.2 14.2-26.7-4.1-25.8-12.3s16.3-25.3 28.2-9.9c-13-4.3-18 10.4-24.9 10 6.8.4 8.5 15.3 22.5 12.2m132.7-158c14.2 15.1-4.4 26.5-12.6 25.5s-25.4-16.3-9.8-28c-4.4 13 10.3 18 10 24.7.3-6.7 15.4-8.4 12.4-22.2"/>
|
||||||
|
<path fill="#005800" d="M301 95.5c13.4 3.4 16.7-11.3 23.5-11.5-4 4.7-14.2 16.7-23.5 11.5m1-22.3c14-15.2 26.9 2.3 26.5 10.6-11.5-20-15-17-26.6-10.6zM174.4 202.8c9-10.4-2.8-19.9 0-26 2.6 5.6 8.9 20.1 0 26m-19.7-10.7c-7.4-19.3 14-22.9 21.3-19-23.1 1.4-22 6-21.3 19m295.9-11.8c1.6 13.5 16.7 11.3 19.3 17.5-5.9-2-21-7.1-19.3-17.6zm21.2-7.3c19.4 7.6 7.6 25.8-.3 28.4 14.7-17.8 10.5-20 .3-28.4M237.3 364.7c-5.3-12.6-19.2-6.4-23.4-11.7 6.2.3 22 1.2 23.4 11.7m-18.5 12.7c-20.7-2-14.3-22.7-7.5-27.4-9.2 21.1-4.6 22.1 7.5 27.4m194.5-31.7c-12 6.6-4.2 19.6-9 24.3-.4-6-1.3-21.8 9-24.3m14.8 16.7c.3 20.7-21.1 16.8-26.6 10.6 22.3 6.6 22.7 2 26.6-10.6M186 168.4c11.5-7.5 2.7-19.8 7-25 1 6.1 3 21.8-7 25M169.9 153c-1.9-20.6 19.8-18.3 25.7-12.6-22.7-4.9-22.8-.2-25.7 12.6m41.5 181.5c-4.4-13-18.7-7.6-22.5-13.2 6.1.7 21.9 2.7 22.5 13.2M192.1 346c-20.5-3.5-12.7-23.7-5.6-27.9-10.6 20.5-6 21.8 5.6 27.9M345.4 94.6c11.3 7.9 19.7-4.7 26.1-2.5-5.4 3-19.3 10.6-26 2.5zm8.9-20.6c18.6-9.2 24.3 11.7 21 19.3-3.5-22.8-8-21.1-21-19.3m34.2 292.6c-13.8.5-12.8 15.6-19.3 17.6 2.5-5.6 9-20 19.3-17.5zm5.5 21.7c-9.3 18.5-26.5 5.3-28.5-2.7 16.7 16 19.2 12 28.5 2.7m68-179c-3.1 13.4 11.9 16.3 12.2 23-4.9-3.8-17.2-13.7-12.2-23m22.5.3c15.6 13.7-1.7 26.8-10 26.7 19.9-12 16.7-15.4 10-26.7m-252.6-89c13.3-3.6 8.8-18 14.6-21.5-1 6-4 21.5-14.6 21.6zm-10.4-19.8c4.7-20 24.5-11.2 28.4-3.9-20-11.7-21.6-7.2-28.4 4zm-39.5 168c.1-13.6-15.2-13.3-17-19.8 5.6 2.7 19.9 9.7 17 19.9zm-22 4.5c-18.3-10-4.3-26.5 3.9-28.1-16.8 15.9-13 18.6-4 28.1zm248.6-142.8c8.2 11 20.2 1.6 25.6 5.7-6.1 1.2-21.7 4.1-25.6-5.7m14.9-16.8c20.6-3 19.4 18.7 14 24.8 3.7-22.7-1-22.5-14-24.8M316 388c-11-8.4-19.9 3.9-26.1 1.4 5.5-2.8 19.7-9.8 26-1.4zm-9.7 20.1c-19 8.4-23.8-12.7-20.3-20 2.7 22.8 7.2 21.4 20.3 20m150.2-124.7c-8.4 10.9 4 19.7 1.5 25.9-2.8-5.4-10-19.5-1.5-26zm20.3 9.5c8.6 18.8-12.6 23.7-20.1 20.2 23-2.8 21.6-7.2 20.1-20.2m-303.3-55c6.2-12.3-7.7-18.6-6.4-25.2 3.8 4.8 13.5 17.3 6.4 25.2m-21.8-5.7c-12-16.9 8-25.5 16-23.5-22.1 7-19.8 11-16 23.6zm123.3 144c-10.3-9.1-20 2.5-26.2-.4 5.7-2.4 20.4-8.5 26.2.3zm-11 19.4c-19.6 7.1-23-14.2-18.9-21.4 1.1 23 5.7 21.9 18.9 21.4m-3-291.9c13.7 1.3 14.7-13.8 21.3-15-3.1 5.3-11.4 18.8-21.4 15zm-2.7-22.2c11.6-17.1 27-1.8 28 6.3-14.5-18-17.6-14.3-28-6.3M443 317.6c-11.7 7.2-3.3 19.8-7.9 24.8-.7-6.1-2.3-21.8 8-24.8zm15.6 16.1c1.3 20.6-20.3 17.7-26 11.7 22.4 5.7 22.7 1 26-11.7m-25-183.6c4 13.1 18.5 8.3 22.1 14-6-1-21.8-3.5-22-14zm19.7-10.8c20.4 4 12 24 4.7 28 11.3-20.1 6.8-21.6-4.7-28m-244 2.6c12.2-6.4 4.7-19.6 9.6-24.2.3 6.1.8 21.9-9.6 24.2m-14.4-17.2c.2-20.6 21.5-16.2 26.8-9.9-22-7.1-22.6-2.4-26.8 10zm-4 181c-.3-13.7-15.6-12.8-17.6-19.2 5.7 2.4 20.2 9 17.6 19.2M169 311c-18.5-9.4-5-26.4 3-28.3-16.2 16.4-12.3 19-3 28.3m212.2-200.4c10.4 9 20-2.8 26.2 0-5.7 2.6-20.3 8.7-26.2 0M392 91c19.5-7.3 23 14 19 21.3-1.2-23-5.8-21.9-19-21.3m-38.9 292.6c-13-4.3-17.4 10.2-24.2 10 4.3-4.5 15.4-15.8 24.2-10m-2.4 22.2c-15.2 14.2-26.7-4.1-25.8-12.3 10 20.7 13.8 17.9 25.8 12.3M461 245.3c-4.4 13 10.2 17.3 9.9 24-4.5-4.2-15.8-15.3-9.9-24m22.4 2.5c14.2 15.1-4.4 26.5-12.6 25.5 21-9.9 18.1-13.6 12.6-25.5"/>
|
||||||
|
<g fill="#730000" stroke="#000" stroke-width=".5">
|
||||||
|
<path d="M349 120.3s.6-1.3 1.7-.6c1 .8 1.3 4.2 1.3 4.2l-1.6.8s.9-1.3-1.3-4.4zm-8.8 30.7s.8-1-.3-3.4l1.5-.5s.6 1.8-.2 3.9h-1m27-5.4-3.7 2.3 1.9.8 4.5-2.6-2.7-.5m-49.7 14.6c-1 .8-1 2.1-1.7 3-.4.7-2 1.8-2 3-.2 1.1-.3 1.9.2 3.5.6 1.5 3 8.5 2 13.7-1 5.3-.6 7.5-.3 8.3.4.8 1.6 3 2.3 3 1.6-.2.9-.7 1.5-1.7 1.4-2.5 0-22.2-.7-27.6-.5-4.2-2.5-8-2.7-8.4l-.8-2.6c-1.5-3.5-3.3-6.5-3.3-13.9 0 0-4.7 1.1-8.8-8.2-2.2-4.7-5.3-4.2-9-5.7l1.6-1.1s3.2 1.6 4.7 1.6c1.6 0 1-3.2 1-3.2l1.7-1.5v6.8c0 3.1 4.3 7.4 6.8 8.7 1.6.8 3.6-2.4 3.1-5.6-.5-3 0-7.3 0-7.3l2.2 1.3s-.8 1.3-.6 2.1 1.3-.7 2.1-1.5q-.1.1 1 .2l-2.3 4c-.8 1.2-.8 6.2-.8 8 0 2 .7 6.2.7 7.6 0 1 1.8 5 3.3 7.4l1.6 3.5c1.2 1.9 1.3 3.2 2 4.7q.6 1.1 1.6 7l.7 7c.2 2.4 2.2.6 4.5-1 2.4-1.8 2.4-6 2.4-6s1.8-1.6 1.5-.7c-.2 1 1 2 1 2-1.4 3.3-.6 2.9.9 2.8s6.5-4 6.5-4c.7-.2 3.8-.2 3.3 0s-1.4 1.7-1.4 1.7l3.3-.4c-1.1.9-5.5 3.4-6.5 4a91 91 0 0 1-8.6 4.1c-2.3 1-6.7 3.2-6.9 5.4 0 2.3.3 8.3 0 10.3l-1 10.6h-10.4s2-7.6 0-9.7c-2.1-2.1-7-8.4-10-10.5-3.2-2 2-.5 2-.5v-2.1l4.8 5.7s8.3-7.3 0-17.3c-2.6-3.1-5.3-7.9-7.3-8.4-2.2-.6-3.3-1-6.9 0-3.7 1.1-12.6 1.1-16.3-4.8l3.1-.5 2.7-1s-1.1 3.7 6.3 3.7 8.4 0 7.8-6.8l2.2-1.1 1.6 1.5s-1.1 3.8-.5 5.3c.5 1.6 3 2.7 5.1 4.2q1.4 1 2.2.8c.9-.1 1-.1 2.2-1.3 1.8-2 1.4-1.8 3.2-3.4l1-1.6z"/>
|
||||||
|
<path d="M319 155c.5-.4 1.2-1.9 2-3.1 1.1-2 2.5-3.2 4-5.6 1.5-2.2 2-5.6 1.5-7.9l1.4.8v3.7l2.7-2 .8.4s-5.9 6-11.3 15.9zm-51.3-20.3c.8 1.4 1.6 4 3.9 6l1.6-.4s-3.6-4.2-3.6-6l-1.9.4m9.2-2.4s.5 5-.8 6.9l1.6-.8s1-3.4.7-5.3zm51.4-15.7s-.3 7 1.1 11.3l1.9-.5s-.3-1.3 1.2-1.9c1.6-.5 5.3-2 7.2-3.6l-.6-1.6s-2.8 2.3-4.2 2.9c-1.3.5-2 1-3.1.7s-2.4-2-1.6-6l-1.9-1.3m20.7 32.8s-.4 5-.2 7.2l1.7.4s-.4-6 .7-6.5l-2.2-1m7.5 3.7s-1.9 3.2-4 3.5l3.4-.7s1-.5 2.4-2l-1.8-.8"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#289400" stroke="#030" stroke-width=".5">
|
||||||
|
<path d="m326.6 138.4 1.3.8 1.3.2 3.2 2.6c1.2 1 1.2-.2 1.2-.2l.8 3.1h1l-.2 3.2 1.8-1.6 2.2 1.9s1.5-1.4 2.3-1.4 1.1-.8 1.1-.8h.1q-8-3.6-5.9-7.6c2.6-4.4 6.8-1.6 8.1-1l19 10.2s1.3-.8.4-1.6-1.4-2.7-1-3.5c.2-.8 2.4 2.4 3.4 2.7l3.5.7v-1s3 2.2 3 3.3c0 0 2.3-1.2 1.5-1.9s3.5 1.7 3.7 3l1-1c-.2 0 .2-3.2 1.3-4.6a5 5 0 0 1 2-1.9s1-1.7 2-1.7c0 0-1-.6-1.4-1.4-.5-.8-1.6-1.2-1.6-1.2s1.4-1 1.8-1.5q.7-.8.8-.7c.1.1-2.2-1.9-3.8-2.2 0 0-.1-2.5-2-2.7h.8s-1.4-2.8-2.8-2.8c0 0 .9-1 1.6-1.1 0 0-1.6-2.2-3.9-1.4l1.5-.6s-1-1.1-4.4-.6c0 0 .3-1.3-1.2-2.4 0 0-1.2 0-1.2 1 0 0-1.2-1-2.7-1-1.4 0 .5 1.6.5 1.6s-2-1.3-3.1-1c0 0 .5-2.2-.8-3.3 0 0-1.4.7-1.8 1.7l-.4-1s-2 2.4-2.4 1.7c-.3-.4-.6-2-.6-2l-1.1 1.7.2-2.4s-1.4.8-2.5 3.3c0 0-1.3 1.3-1.3 2.4 0 0-1.1-1.2-2 0-1 1.4 0 1.1 0 1.1s-.5.5-1.5.7l.2.8s-1.2-.7-2.2-.4l.5 1.2s-2.6.2-2.7-2c0 0-2.1-1-2.4.2l-1.6 1s-.5-1-1.3-1.2c-1-.3-.2 1.3-.2 1.3s-.3-1.2-1.5-1.8c-1.2-.5-.2 1-.2 1s-1.5-1.2-.8-2.2c0 0-1.4 1.4-.7 2.7l-1 .7s-.5-.5-.4-1c0-.3-1.7 1.3-1.7 1.3s-1-2.6 0-2.6c.1 0-1.8 0-2.1 2.2 0 0-1.3 0-2.5.4s1 1.3 1 1.3-2-.4-3 .2l.7.9s-.7-.7-2.4-.1.4.8.4.8-1.7-.4-2.6 0 .3 1 .3 1-3.2-1-3.7-.6c-.5.3 1 1.8 1 1.8s-2 .5-2.3 2l1.8.7s-.8.4-1.2 1.1 1.2.4 1.2.4-2.2 1.6-2.2 2.8c0 0 1.6.5 2.7-.7s.4-.8.4-.8l.1.8s2.3-1 2.3-1.6c0-.5-.4.8-.4.8l2.2-1.2z"/>
|
||||||
|
<path d="m324.7 108-.5-2.2 5 .6.9-2s2.4 1 3.1 1.8c0 0 4.6-1.2 6-.7l-.6 2.1s4.2.3 5.6.8c1.2.5-.6 1.6-.6 1.6s4.8.6 6.8 1.8c2.2 1.3 2.2 2.4 1.1 2.7l2.6 4.2s-4.2.5-5 1.5c-.7 1.1-1.5-1-1.5-1l-.8 2.9s-2.6-1.4-3.7-2.4c0 0-1.3 1.6-.5 2.4l-4.8-3.1s-2-.3-2 1.5l-3-3.4-2.6.8s-.8-1-1.7-1.4c-1.1-.2-.3 1.4-.3 1.4l-1.6-1.9-1.6-1.8s-1.3 1.8-2.4 1.8-1-1.2-.8-2.1c.3-.8-3.1-1-3.4 0 0 0-1-4 2.6-5l3.7-1zm-19 13.3-1.6 1.6-1-.5-1.9 2.1-.4-1.1-1 1.7-1.7-.6-.8 1.3-1.5-.3s-1.2 1.2-2 1.5c-.8.2 0-1.8 0-1.8s-1.2 2-2 2-.6-1-.6-1-.5 1.4-1.4 1.6c-.9 0-.5-1-.5-1l-1.6 1.2v-1l-.7-.3s.4-1.8 1.1-2.2-.7-1.5-.7-1.5l.7-.8s-2-.5-2-.1c0 0 1-2.6 2.4-3.1l.1-3s1.4-1.1 2.6-1.2c1.4-.2-.4-2.3-.4-2.3l3.2-1.8.4-2.3 4.7.4 1-2 3.8 1 3.3-1.7 2.3 2.7 3.5.3v3.1s4.1 1.3 4.6 1.7c0 0-.8 2.5-.8 3 0 0 3-.8 3.8-.6l.4 3.4s5 1.6 5.3 2.5c0 0-1 .7-1.7 1.6 0 0 .6 3 .6 4l-.4.5s-1.4-1.3-2.6-1.5l-1 2.1-1.7-1.1h-1.1s-.9-1-1.6-1v-1l-1.4.9s-2.2-1.1-2.2-1.8l.1-1.5-2.2 1.5s-.5-1.1-.4-1.6-.9 1.1-.9 1.1-1.2-2-1-2.4l-.4 1.7s-2.3-.8-2.6-2.4z"/>
|
||||||
|
<path d="M274.5 130.9s4.1 2 4.1 3c0 0 1.1-2.1.5-3l1.6 1.1s.5-1.9-.1-2.5c-.5-.5 3.5-2.1 4.4.5 0 0 2.4-2.5-2.3-4.7 0 0-.4-1.6 2.5-1.2 0 0-.2-1-.9-1.3h2.7s-1.6-1.4-4-1.8-1-.7-1-1.2c-.2-.5 0-2.2 0-2.2s-1.4-.5-2.5 0l.4-.4s-2.5-.7-3.5-.1c0 0-1.5-.6-1.3-2.1 0 0-1.5-.1-2.1 1.4 0 0-1.4 1.6-3.3 1.6-.3 0-1.7-.8-1.7-.8l-1.2 2.7H265l.5 1s-2.8-.8-3.7 0c0 0 1.1 1.7.8 2.8 0 0-2.2.4-2.7.8s.8.7.8.7-2.6.8-3.2 1.6l1 .7-1.2 1.6 1 .2-2.5.8c-.9.3 1.5.5 1.5.5l-1.1 1 1.8.3s-1.4 4-1.5 5.8c0 0 2.2-2.7 2.8-3l-1.8 3s3.4-1 3.7-1.6l.5-1 .1 1.6s1.3-1.7 1.5-2.4 2 .7 2 .7v-1l2.4.7q1-.2.8-.3l1.6-.2-.3 1.4s1.2-.8 1.5-1.6c.2-.8.8 0 .8 0l.8-3.1zm-13.4 13.8 1.6 3.3c-1.3 0-3.1 1.5-3.1 1.5.3 5.4-1 5.1-1 5.1.3 1-.3 2.8-.3 2.8l7.7-4.6.1-.5 9.2-4.3.3.5 2.6-1.5 1.9 4.9-1.9 1c.1 1.7 2.1 4.6 2.1 4.6l2.4-.6 2.7-1v1c0-.3 2.9-1.3 2.9-1.3l1.3 1.3c1-.1 2.7-2.5 2.7-2.5l2 .8s.7-1.2.7-2 2.6-.8 2.6-.8l1.7 1.2 2.3-1.8s.7 1.3 1.6 1.5 3.4 2 3.4 1.6 1-2.5 1-2.5l4.1 1.6c-.5-1.8-2-4-2-4s2-1.6 1.7-2-1.6 0-1.6 0l1.2-2.4h-1.6l.5-.6-2.5-1 1.3-2.8c-.6-.7-7.3-2-7.3-2l1.6-1.5s-.8-1.2-1.6-1.2-4.9.1-5-.3l-1.8-1.8-2.6 1.9-1.3-.4-2.2.8s0 1.3-.3.4-2.3-2.8-2.3-2.8c-.4.7-3 3.8-3 3.8s-.5 1.7-1.3.5c-1-1.2-3-2.4-3.4-1.5-.2 1.1-2.1 2-2.5 1.8-.4-.3-2.5-.1-2.7.5-.1.7-2.5 2.4-2.5 2.4-1.4-.7-3.8-.3-3.8-.3s-.7 1.1-.8 2zm44.2 39.6v-2l2.3 1.5 1.4-1.5 1.8 1.7 1-1.7v2.8l2.7-2.3 1.5.6v-2.2s1.9-3.1 1.1-3.7l1.6-.5s-2.7-2.6-3.4-3.1c-.8-.6-1.9-.6-1.9-.6s-2-2-3.4-2.7c-1.3-.5-2 .6-2 .6s-.8-1.5-2.2-1.8c-1.3-.3-1.3 1-1.3 1s-1-1-2.2-1.4c-1.3-.6-.4 1.2-.4 1.2l-4.5-1.4.6 1.4-4.2-1.1.5 1.5-3-.5-.2 1.3-4.2.8.8 1.4-2.6-1.1v1.9l-3 1 1.1 1-4.7 2.4c-.7.5 1.6 1.6 1.6 1.6l-4.2-.6 1.6 1.4-3.7 3.4 2.6 1.9-1.6 2.3 4 .8 1.5 2.6 3.2-.6v3l4.7-3.7s3.4 3 3.7 2.1c.2-.8.2-3.4.2-3.4h1.7V187l1.2 1 2.2-3.1 2.8 3.9 2.2-5zm19.1-11.8-.4-4.5-1.4 1.4.6-4.2-2.2-.4.8-4.1-1.6-.8.4-2 1.6-1.1 2.8-2.8 6.6.1.7-2.1 4.7.8c.8.8 3.3-1.8 3.3-1.8h1l1 2s3.4-.4 5 .6l-1.9 2.6 3.5.3 2.3.5 2.4-.8 3.2-.2.7 1.3h3l-1 1.1 3.8 1.8-.8.8s2.2.8 3.7.8h.8s.8 1.8-.3 2.6c0 0 1.1 2.6 3.4 3.1 2.4.5 2.4.5 2.7 1s0 3.2-2.7 3.2v2.7s-2 1.8-2.3 2.6l-1.6-1s-1.3 1.5-1.3 2.5c0 0-3.5-4.5-4.7-4.7-1.4-.2-1 2.6-1 2.6l-3.8-3.1-.5 2.1-3.1-2.6-1.9 1.3-3.3-3.5-3.2.4 1.2-1.6h-3.9l-.7-3.4-.6 2.8-2.4-1.6-3 3.3s-.4-.4-.9-2c-.5-1.5-1.8 2-1.8 2l-1.1-2.5-1.3 2.4s-1-1.8-2.1-2.4c-1-.5-2 3-2.4 4.5zm.6 10.3.2-1.6 5.6-2.2 4.7-1.7 2.3-1.1 1.1 1.9 1.4-2.7 2.1 2.8 1.2-1.5 1.8 1.4h1.6l.3 2s3.9.3 4.2-.5.8 2.7.8 2.7l3.7.5.8 4.2s1.8 3.9 1.2 5.4c-.5 1.7-2-2-2-2s-.3 2.9-.6 3.7-5-.8-5-.8l-2 3.9s-2.2-3.1-3.2-3.1-1 3.1-1 3.1l-4.5-5.3c-1.3-1.5-1.6 1.6-1.6 1.6s-2-3.4-2-4.4-1.9-2.2-1.9-2.2-3.2 3.7-5.6 4.7c0 0 .3-2.3-.5-3-.7-.9-3 1.5-3 1.5s-1.7-3.6 0-7.4z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#004b00" stroke-width=".5">
|
||||||
|
<path d="m350.8 124.5.5 2s1.8-.4 3-.3l-.7 2.2s2.3-.4 3.4.3l-.5 1.2 2.7 1.2-2.5 2s4 1.5 4.9 2.7c0 0-2.7 1.8-3 1.7-.5-.2-.2.8.5 1.7l-2.2-.1s.9 2 .8 2.8c-.2.8-3.8.6-3.8.6m14.1-5.3s.7-3.9-.4-4.9c0 0 2.5 2.2 4.2 2.3s0-3.2 0-3.2 2.3 1.2 3.5 1.5c1.2.2-.9-2.4-.9-2.4s.7-1.3 2.7-.1m-45.4-15.5s0-1-.3-1.4c-2.4-3.2 2.4 2.4 3.4 2.6m4.5 0s1.6 1.2 2.3 1.2c.8 0-.5-3.1-.9-3.5-.3-.4 2.9 1.3 4 1.4 1.2.3 1.4-2 1.4-2s3.6.7 4.4 1.2m-25-.7-.5-3m-24.2 12.7c-.2-.3 1-2 .5-2.9 0 0 .7.6 1.4.7s.4-2.2.4-2.2l.7.3s2-1 1.7-1.8 1.4 1.2 1.4 1.2 1.1-1.2 1.1-1.7 1.4 1.2 1.4 1.2 1.5-.3 1.5-1.2 1.8.3 1.8.3.8-1 .7-1.8m-19.7 9.8s.9-2-.1-3.4m-23.6 9.8c0-1 .5-3.6 0-4s1 .4 2.2.7m9.3-.4c-1.2-1.1-1.9-1.7-3.7-1.7m-16.2 8.3c.3-.7.8-2.4.5-3.3m25 51.7 1.3-2.6 1.6 3.7 1-2.3.4.7 2.4-2.5 2.5 3.8m40.7-16.5 1.7-3.3 2.3 1.2 1-3.3 2.4 1.5 1.3-3 2.7.7.2-2.2s1.8 1 2.3 1.9c.6.7 0-2.7 0-2.7l2.2 1s1.5-1.6-.6-2.4"/>
|
||||||
|
<path d="m343.6 163.1 2.3 3.4 2-2.2 1.7 3.8 2.3-1.4 3.2 1.9 1.5-1.2 3.5 2.5 1.4-3 3.2 1 2-3.5m-84.4-9.2s1.4-2.3 1-3.4c-.7-1 2.8 1.8 2.8 1.8s.7-2.3.3-3.4c-.6-1 3.9 3.2 3.9 3.2s.7-3.7 0-4.4c-.8-.8 4.3 1 4.3 1s2-.6 1.2-1.6c-.8-1.1 3.4 1.5 3.4 1.5s1-2.6 0-3.6 3.2 1.3 3.2 1.3l-1.6-2.8 3.4.2-.3-3.2m30.3 45.1 2.2-1 2 2 2.2-3.5 2.6 2.5.4-2.5 2.8 1.2.6-1.6 3.1 1.7.3-2.4 1.6.5.3-1.5"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#9dd7ff" d="M283.1 298.1a70 70 0 0 1-6-13l42.4-37.2 42.3 37.1a65 65 0 0 1-6.2 13.3l-.3.5-3.2-1.2s-1.4 1.3-2.5 1.4c-1.4.2-2.7-1.3-2.7-1.3s-1.8 1.6-3.7 1.3c-1.9-.2-2.6-1.4-2.6-1.4s-2 1.6-3.4 1.3c-1.2-.3-2.1-1.2-2.1-1.2s-1.2 1.5-2.8 1.2-3-1.2-3-1.2-2.5 1-4 1.3c-1.6.3-3-1.4-3-1.4s-2 1.4-2.8 1.4-3.7-1.4-3.7-1.4-3 1-4.1 1-4-1.3-4-1.3-2.9.8-4.1.8c-1.4 0-3.4-.8-3.4-.8s-2.3 1.1-3.6.8q-2.2-.6-2.4-.8c-.2-.2-2.6 1.1-4.2.8s-3.4-1.4-3.4-1.4-.3.8-3.4 1.4"/>
|
||||||
|
<path fill="#fff" stroke="#000" stroke-width=".7" d="M319.5 205.1V248l-42.3 37.4a84 84 0 0 1-4.4-27.1V205h46.7"/>
|
||||||
|
<path fill="#ffd83c" stroke="#000" stroke-width=".7" d="M319.5 205.1V248l42.2 37.4a84 84 0 0 0 4.3-27.1V205h-46.5"/>
|
||||||
|
<g stroke="#000" stroke-width=".5">
|
||||||
|
<path fill="#730000" d="M292.2 289.2c3.2 1.7 2 1.2 8.7 3.5a62 62 0 0 0 20.7 2.8c8 0 12.4-.7 15-1.4s3.1-1.8 3.1-1.8l-6.5 6.7s-26.3 1.4-31-1.7c-2-1-6-4.2-10-8z"/>
|
||||||
|
<path fill="none" d="M330.8 276.8s8.4 4.8 0 14m-37.8-1 12-11.6-1.3 15.4m6.3 1.4v-25.8m20.8 25.8v-27.4m-10.5 27.9V266"/>
|
||||||
|
<path fill="#ff0018" d="m310 270.6-5.4 1.7 5.4.6v-2.3m10.3-4-5.8 2.1 5.8.2v-2.4m10.5 1.6-5.8 2.2 5.8.3V268zm3.4 12.4c-.3-1.1 3.4-1 3.4-1s1 3.9 0 6.3l-3.1-.6s.3-2.6-.3-4.7z"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<path d="m294.8 288.1 10.3-10-.5 6s-5.3 3.5-5.3 5.9l-4.5-1.9m5.5 2.1s1-3.1 4-4.7l-.3 6.3zm14.7-17.3s4.2-.6 7.4 0c0 0-.5 4.2 0 5 0 0-5.8-1.4-8.1.8 0 0-.8-4 .7-5.8zm-8.9 1s4.2-.5 7.4 0c0 0-.8 4.8-.3 5.5 0 0-4.7-2.6-7-.4 0 0-1.6-3.3 0-5z"/>
|
||||||
|
<path d="M305.9 279.4s4.2-.4 7.3 0c0 0-.5 4.3 0 5 0 0-5.5-1.2-7.8.8 0 0-1.1-3.9.5-5.8zm8.4-.7s6-.8 9.1-.3c0 0-1.2 3.4.3 5.7 0 0-6.5-1.7-9 .3 0 0-2-3.9-.4-5.7zm-9 6.5 8.2.6s-.8 6-.3 6.7c0 0-6.6-1.5-7.8.6 0 0-2.2-5.4 0-7.9zm9.2 0s7-.5 10.3 0c0 0-1.9 4.5.2 8.4 0 0-9.2-2.6-10.5-.5 0 0-2.1-5.4 0-7.9zm10.8-12.3s4.2-.6 7.3 0c0 0-.5 4.2 0 5 0 0-5.8-1.4-8 .8 0 0-.9-4 .7-5.8zm0 5.8s4.2-.5 7.3 0c0 0-.5 4.2 0 5 0 0-5.8-1.3-8 .7 0 0-.9-3.9.7-5.7zm.5 6s4.2-.6 7.4 0c0 0-1 6.6-.6 7.4 0 0-4.8-2.3-7-.3 0 0-1.4-5.2.2-7z"/>
|
||||||
|
<path d="M334.5 285.2s1.3 5 3 5.6l-2.2 2.8s-2.9-2-3.2-4.4c0 0 1.9-2.1 2.4-4z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-width=".6">
|
||||||
|
<path fill="#006ac8" stroke="none" d="M355.4 298.7a66 66 0 0 1-36 28.3 67 67 0 0 1-36.3-28.8h.1c3-.7 3.4-1.5 3.4-1.5s1.8 1.1 3.4 1.4 4.2-.8 4.2-.8 1 .5 2.4.8 3.6-.8 3.6-.8 2 .8 3.3.8 4.1-.8 4.1-.8 3 1.3 4 1.3c1.1 0 4.2-1 4.2-1s2.9 1.4 3.7 1.4c.7 0 2.8-1.4 2.8-1.4s1.4 1.7 3 1.4c1.5-.2 4-1.3 4-1.3s1.4.9 3 1.2 2.8-1.2 2.8-1.2.9.9 2.1 1.2c1.4.3 3.3-1.3 3.3-1.3s.8 1.2 2.7 1.4c1.8.3 3.7-1.3 3.7-1.3s1.3 1.5 2.7 1.3c1.1-.1 2.5-1.4 2.5-1.4l3.3 1"/>
|
||||||
|
<path fill="none" stroke-width=".5" d="M353.7 301.2c-1.4.1-1.6-.8-1.6-.8s-2 1.6-3.8 1.3c-2-.3-2.9-1.5-2.9-1.5s-2 1.6-3.4 1.3a5 5 0 0 1-2.3-1.1s-1.3 1.4-3 1.1-3-1.1-3-1.1-2.7 1-4.4 1.3c-1.6.3-3-1.5-3-1.5s-3.8 1.2-4.6 1.2c-.9 0-3.6-1.2-3.6-1.2s-1.9 1.1-3 1.1-4.2-1.3-4.2-1.3-3 .8-4.3.8c-1.4 0-3.5-.8-3.5-.8s-2.5 1-3.9.8l-2.4-.8s-2.7 1-4.4.8c-1.6-.4-3.6-1.4-3.6-1.4s-.3.7-3.5 1.4h-.1"/>
|
||||||
|
<path fill="#5ac800" d="M293.1 305.2s-.3.7-2.8 1.2l-.7.2.6-.2-.7.3a44 44 0 0 0 11.7 11c4.5 3 9.6 6.6 18.4 9.2a71 71 0 0 0 17.8-9c6-4.5 9.2-7.2 12.6-11.7-2.7 1.5-2.3 1.5-3.7 1.3-1.3-.3-2.3-1.3-2.3-1.3s-1.3 1.5-3 1.3c-1.6-.3-3-1.3-3-1.3s-2.2 1.5-4.4 1.4c-1.6-.1-3-1.6-3-1.6s-3.8 1.3-4.6 1.3c-.9 0-3.6-1.3-3.6-1.3s-1.9 1.2-3 1.2-4.2-1.4-4.2-1.4-3 .8-4.3.8-3.4-.8-3.4-.8-2.5 1-4 .8c-1.3-.3-2.4-.8-2.4-.8s-2.7 1-4.4.8c-1.6-.3-3.6-1.4-3.6-1.4z"/>
|
||||||
|
<path fill="#ffd800" d="M319.6 326.9a89 89 0 0 0 17.1-8.6c-1 0-4-1-2.9-1.2 0 0-1.2 1-2.3 1s-4.2-1.3-4.2-1.3-3 .7-4.3.7c-1.4 0-3.4-.7-3.4-.7s-2.6 1-4 .7l-2.4-.7s-2.8 1-4.4.7-3.6-1.3-3.6-1.3-2.4 1.3-4 1.4a55 55 0 0 0 18.4 9.3z"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-width=".5">
|
||||||
|
<path fill="#b34b00" d="M313.2 249c1.9-1.6 3-3 2.3-4.2-.7-1-1.8.6-3 .2 0 0-2-1.1-2.5-2-1-1.2-2.5-2.3-3.3-3.4-.9-1-4.2-5.3-9.1-10.3-1.5-.9-1.1-4.4-2.2-5.5 0-.8-10-10.6-13.4-14-1.5-1.1-2.2-1.8-4.7.5-2 1.7-2.7 3.6-.1 5.8l12.8 11.7c1.9 1.8 4.5 2.1 5.6 3l13 13.4c1.7 1.4 2.5 2.4 1.5 3.6-1 2 .1 2.3.3 2.3.2.1 1.1.1 1.7-.4z"/>
|
||||||
|
<path fill="none" d="m280.7 214 13.4 13.2"/>
|
||||||
|
<path fill="#fff" stroke-width=".6" d="m298.8 220 1.1-2.5c.2-.4 3.8-6.2 5.6-6.3l11.3 8.2c0 .5-5 7.8-5.3 7.2 0 0-13.2-5.5-12.7-6.6z"/>
|
||||||
|
<path fill="#ccb8c8" stroke="none" d="M306 222c-1.6-1.2-4.1-2.4-6.2-3.8l-.9 2c.7.8 10 5.1 11.6 6 0 0-1.8-2-4.6-4.2z"/>
|
||||||
|
<path fill="none" stroke-width=".4" d="m299.8 218 5 3.1c2.7 2 4.4 4 6.4 5.2"/>
|
||||||
|
<path fill="#782121" d="m277.1 253 25.5-31.7c.5-.4 2.9-.4 3.2 2l-24.2 33s-2.7-.3-4.5-3.4z"/>
|
||||||
|
<path fill="#730000" d="M362.7 253.3c-1 .7-2.4.8-2.4.8-1.7-1.9-3-4-5.4-6.5l-25.5-31.3 2.7-2.6 23 26.9c.6.7 4.6 4.9 8.4 9.8 0 0 .3 2-.8 2.9z"/>
|
||||||
|
<path fill="#fff" d="m338.1 220.3-4-5s-3.2.5-3.8 2.1c0 0-2.2 3.7-8.2 4.1 0 0 1.7 7 8.6 10.7 0 0 2.5-7.2 5-8.4 0 0 2.5-1.9 2.4-3.6z"/>
|
||||||
|
<path fill="#730000" d="M327.9 242q-.9-.3-2 .4c-.9.5-1.8.3-1.7-.7 0-.8 0-.7 1-1.4a6 6 0 0 1 5-.5l-2.3 2.1m27.8-27.7c-.2-.7-.4-1.4.3-2.2s.7-2.2-1-1.5q-2.7 1-.9 5.4z"/>
|
||||||
|
<path fill="#fff" d="m327.3 242.5 28.9-28.7 7.1 7.6s-1.5-.6-2-.2.5 1.9.5 1.9-1.3-.5-1.9-.1c-.7.4 0 2 0 2s-1.2-.8-1.8-.5c-.5.4 0 2.3 0 2.3s-1.5-.5-2-.1c-.3.4 0 2 0 2s-1.5-.7-2.3 0 .4 1.9.4 1.9-2-.8-2.7.4.1 2.2.1 2.2-1.6-.7-2-.4c-.4.4 0 2.3 0 2.3s-1.3-.8-2.2.2.1 2 .1 2-1.3-.8-2-.4.2 2.2.2 2.2-1.2-.9-2.1 0 0 2 0 2-1-1-2.2 0c-1 1 0 2.1 0 2.1s-1.5-.8-2.1 0 .2 1.9.2 1.9-1.2-.7-2-.3c-.7.4 0 2.1 0 2.1s-1.1-1.3-1.6-.6 0 2.4 0 2.4-1-1.3-1.6-.6c-.7.6.4 2 .4 2s-2-.8-1.5-.8z"/>
|
||||||
|
<path fill="#7e4b7e" stroke="none" d="m354.5 216.2-2.2 2.2.5 7.7s.5-2.2 1.1-.7c0 0 .2-1.8.7-1.5-.4-.2 0-7.8 0-7.8m-4.3 4.3-1.6 1.5.6 7.7s.4-2.9 1.1-.6v-8.6m-17.6 17.2-1.5 1.7.4 4.7s.5-3 1.1-.7l-.2-2.6z"/>
|
||||||
|
<g fill="none" stroke="#7e4b7e" stroke-width=".9">
|
||||||
|
<path d="M330.9 218.3c1 1 4 5 4 5m-2.6-2.9c-.2.6-.6 2.3-1.5 3.2m2-2.8c-.2.4-.2 1.7-1.5 3m2-2.4q-.3 1-.8 1.7m1-1q-.1.5-.4.8m-3.2-2.7a29 29 0 0 1-4.8 2.8m4-1.8a14 14 0 0 1-3.1 2.2m2.8-1c-.5.4-1.5 1.4-2.3 1.7m1.8-.5q-.7.7-1.5 1.1"/>
|
||||||
|
<path stroke-linecap="square" d="M311.3 224.7c.3-.6.9-1.8 1.6-2.8 1-1.4 1.3-1.6 1.8-2"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path fill="#289400" stroke="#060" stroke-width=".5" d="M217 303.4c1.5.7 0-4.8.5-6.8.6-2.2 4.8 5.2 6.8 6.2 2.2 1 15.3-5.2 15.3-5.2l3.1-5.8 1.7 4.8s3.6-4.2 4.6-5.8c1.1-1.6 0 3.1-1 4.7-1.1 1.5 3.1 2.1 2.6 5.3-.5 3 4.2-2.2 4.2-2.2l12.1 1s2-7.3 5.3-11.5l1 4.8 5-5.1a66 66 0 0 0 41.3 39.2 67 67 0 0 0 40.2-36.6l6.7 7s5.3-1.6 6.3-2.2a70 70 0 0 1 15.5 5.6c1 .7.3-3.5.3-3.5s5.8 1.1 5.3 4.2c0 0 1.2-2.9 2.3-3.4s3.6 2.7 4.2 3.7c.5 1 13 2.6 14.7 3.6 0 0 1.3 1.6 1.3 2.7 0 0 2.7-.3 4.7-.3 0 0-1.5.8-1.5 2.4q0 2.5-2.4 3.6c-1.5.8 0 1.4 2 1.6 2.2.3 0 3-4.1 4.5 0 0-1 2.3.8 3.1 0 0-3.7.3-4.2-.8 0 0-1.3 2.5-1.3 3.8 0 0-4-1.3-5-1.9 0 0-1.6 1.6-.5 2.3 0 0-7.1-.7-8.5-3.4 0 0-2.5.8-2 1.7 0 0-5-3.4-6.6-3.4l-1 1.7s-3.5-2-4.5-2-.2 1.5-.2 1.5l-4.8-2.9-1.9 3-5-2.7c-1-.8-1.2 1.6-2 1.9 0 0-1.6-2.2-3.7-1.9 0 0-.8 2.6-.5 3.4l-7.1-1.5c-1.1-.3-.5 1.2 0 2.3 0 0-4.5-1.3-5.5-2l-.3 2s-3.7-.3-4.5-1l-2.3 3.1h-3.5l-.7 2.9s-6.6-1.9-7.2-.8c-.5 1 .3 2.6.3 2.6l-5.7-.7-.5 2.3s-6.7-2-8.2-1c0 0-2 2-3.4 2.6-1.3.5 0-.8 0-.8l-2-2.9s-2.4 1.3-3.8.8-5.5 2-6.8 1.3c0 0 0-1.9-1.1-2.4-1-.4-2.8-.4-3.6.8l-2.2-3c-1.2-2.2-3.1.1-3.1.1l-.3-2.5-3.6-.3c-2.2-.3-2.6-2.2-2.6-2.2s-5.6.6-6.4.3l1.4-1.3s-7.6 1.6-9.2 1c-1.7-.5 1.8-2.6 1.8-2.6l-8.4-2c-1.3-.4-9 1.2-10 1.2s-1.6-1.8-1.6-1.8-1.8 1.6-3.4 2.1c0 0-1.3-3.1-.5-3.4 0 0-5 1.3-5.3 2.6 0 0-1.2-2-1-3 0 0-2.4 3.8-3.5 4.6-1 .8-.2-1.9-.2-1.9s-2.9 3.7-4 4v-2.6s-8.4 4.5-10.7 3.9c0 0 0-1.9.8-2.4l-5.3-.2-4.2-1.1c-1.3-.2 1.9-1.6 4-1 0 0-5.8-2.1-7.6-1 0 0-.8-2.2 2.3-3 0 0-4-3.9-6.3-3.6 0 0 2.6-1.6 4.8-.8 0 0-2.5-6.6-4-7.4 0 0 5.5-1 7.1-.2z"/>
|
||||||
|
<path fill="#730000" stroke="#000" stroke-width=".5" d="M208.9 188.8 280 152c.3-2-.7-3.4-1.9-5-20.3 10-38.8 22.2-61.4 29.5-1.3.5-12.5 5.2-18.3 12.1a8 8 0 0 0-2 4.5s.2 3.1.9 4c0 0 .1.8 1.2 1.5 0 0 .7.3 1.1.2s1.4 0 2.6-2a52 52 0 0 1 6.7-8z"/>
|
||||||
|
<path fill="#fff" stroke="#000" stroke-width=".5" d="m266.1 152.4 9.2-4.4s3.1 2.3 2.8 5c0 0 .7 6 8.2 10.2 0 0-6.3 8-17.4 8.6 0 0 1.2-10.4-1.1-13.5 0 0-.4.4-.2-.1.3-1-.2-4.5-1.7-5.3z"/>
|
||||||
|
<path fill="none" stroke="#7e4b7e" stroke-width=".8" d="M276.9 153.6c-2 .8-8.3 4.1-8.3 4.1m5.2-2.1c0 1-.6 3.3 0 5m-.8-4.7c0 .5-.8 2.2 0 4.6m-1-4.1c-.2 1-.4 1.5 0 2.6m-.8-2v1.2m5.8-1.5c.3.7 2.9 4.8 4.5 6.5m-4.1-4.7c.3.8 1.7 3.6 2.8 4.7m-3-3c.4 1 1.1 2.7 2 3.7m-2-1.8a8 8 0 0 0 1.1 2.5"/>
|
||||||
|
<g stroke="#000" stroke-width=".5">
|
||||||
|
<path fill="#b34b00" d="m342.3 148.3 26.1 12.8c5.2 2.5 7.1.2 10 1.5l51.6 26.2c4.5 2 7 2.5 6.3 4.8-.1 3.7 1.7 3.7 2.2 3.8.4 0 2.1-.2 2.8-1.2l.6-.6c.6-.6.5-.7.7-1 2.1-3 3.3-5.8 1-7.5-2.5-1.3-2.8 1.5-5.6 1.2 0 0-4.8-1.8-6.6-3.1-3.2-1.8-7.8-3.9-8.8-5-3.3-.6-28-14.6-42.1-21.6-1.5-1.2-2.3-5.9-5.3-7.4-.8-1.3-19.8-10-29.8-15-3.6-1.3-5.7-2.2-8.3 2.3-2.3 4.4-1.8 6.8 5.2 9.8z"/>
|
||||||
|
<path fill="none" d="m344.5 142.8 29.2 14.6"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#ffa54b" stroke="#000" stroke-width=".5" transform="translate(-40)scale(.96)">
|
||||||
|
<path fill="url(#bz-c)" d="M309.7 165.1c-.2.6-.7.2-.7.4-.4 2.2-.3 3.3-.8 4.5a7 7 0 0 1-3.1 2.6c-2 .7-4.5 3.1-8.5.9l-.6-.5s1.1 4.1-3 6.2l-15.7 8.2s-1.5 2.8-.5 6.5c0 .6.3 1.1-.3 1.5-1 .7-2.7 2.8-3.7 6 0 0-3-1.3-3-3.2s1.2-3 1.5-4.4c.3-1.3 1-4.7.7-5.7-.4-1.1-2.3-1.5-3-2-.6-.3-1.4.5-1.3 1.4.1 1 .8.8 1.6 1 0 0-.9 2-.8 3.4l-9.2 4.8 1.6 2.3c.4.7 1.3 10.2 4 14.7s3.7 3.2 4.3 3.8l2 2c.8.5 4 .8 4.5-1a9 9 0 0 1 3.2-4.1c2-1.6 6-5.4 6-7.2 0 0 2.3 6.8 2 12.4 0 0 8.5 1.7 16.1 1.9 7.6 0 11.5-1.5 11.5-1.5s1-6.4.6-7.7c0 0 1.2 4.2 2.2 5.6 1 1.8 2.1 5.3 4.3 5.5 2.2.1 4-2.1 4.2-3v-6.8h8.7s3.8 1.5 5 1.3 2-1.3.8-1.7l-.4-.3c1.2 0 2.6.7 2.6.7h4.3l-.1-.5c-.3-1.2-2.6-2.2-4.2-3-1.6-.5-3.5-1.2-4.7-1.3a20 20 0 0 0-6.2.4c-.8 0-3.9.4-5.8 1.3-.5.3-1 .4-.8-1 .3-1.3.2-7.8-1.3-10.5-1.2-2-1-3.6-.9-5.2a87 87 0 0 0 0-8.8c-.3-1.7-2.5-4-6.6-4.4-3.8-.5-7-3.7-7.3-6.2-.3-2.6.4-7.2.8-9.3"/>
|
||||||
|
<g fill="none">
|
||||||
|
<path fill="url(#bz-d)" d="M292.4 153.6c-.7 3.3-.7 6.1-.6 9.3.1 2.1 1.1 4.6 2 6.7a10 10 0 0 0 2.8 3.9c3.6 2.3 7-.2 8.9-1a6 6 0 0 0 2.7-2.5c.5-1.2.3-2.1.8-4.5l.7-.4c1-1.5.7-2 1.2-3s-.5-2.2-1.8-2q1.1-3 .4-5.9c-.2-.6.5-2.2-.6-1.8-4.6 1.8-10 1.4-14.8 1.4-.4 0-.8-.8-1.4-.7l-.3.5"/>
|
||||||
|
<path fill="#000001" d="M292 155.3q-.8-.5 0-3.2c.7-2 0-2 1.1-3.2s.9-1.6 1.6-1.9 2.8-.2 3.5-.5a11 11 0 0 1 9-.3c1.5.8 2.5 2.8 3.4 3.6 1 .7 1.7 3.6 1.5 7.9-.3 4.3-1.2 3.5-1.2 3.5s-1.2-2-2.2-.3c0 0 0-1.7.2-3 .1-1.1-.6-3.4.1-4.9 0 0-5.8 1.8-8.7 1.7-3-.2-7.4-.9-7.6-1.6l-.7 2.2"/>
|
||||||
|
<path fill="#fff" stroke="#fff" d="M295 151.8c-.3-.6-1.8-2.9-.6-3.6 1-.6.4 2.8 2 3.5 0 0-.6 1.5-1.4.1z"/>
|
||||||
|
<path d="m298.3 160.1-.9 3.2m10.6 1.6c.2.6.4.3 1 .4m.3-4c.5-.5 1.2-.2.8 1.3m-17.7-4.9 2.8 1c1.5.6.6 2.9.2 4.3-.2.7-1 2-.6 2.5.5.8 1.1.3 2 .4 0-.3.6-.5.8-.5.7 0 1.1.5 1.7.3s.5-1.2.3-1.7m-.1-4.5c0-.4 1-.8 1.5-.9 2.1-.4 3.6-.7 5.4.5m-5.7 11.1c-1.8.4-3.6.7-5.2-.2m0-.8c.8-.4 1.1-.8 2.1-.3l1.1-.4q1.1 0 2.2.8m-5.5.3c1-.3 3.3-.3 5.4.1"/>
|
||||||
|
<path fill="#fff" stroke-width=".3" d="M292.3 160.1c.7 0 1.6-.9 3.2.3 1.1.8-1.8 1.5-1.3 1.4-1.4.2-1.8-.5-1.9-1.2zm8.4.8c.7-1.4 1.8-1 2.8-.8 1 .3.7.1 1.7.7-1.2.1-.9.2-1.7.6q-1.4.8-2.3.3-.5-.3-.7-1 0 .3.2.3z"/>
|
||||||
|
<path fill="#000001" stroke-width=".3" d="M293.8 161.4q.6-.1.7-.8 0-1-.7-1-.8 0-.9 1 0 .7.9.8zm8.6.2q.6 0 .7-1a1 1 0 0 0-.8-.8q-.6 0-.7.8.1 1 .8 1z"/>
|
||||||
|
</g>
|
||||||
|
<g id="bz-g">
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M345.9 213.7a4 4 0 0 0-1.9-1.4l-2-1-2.4-.9m.5 2.6-.9-.5-1-.2-.2-.2a4 4 0 0 1-2.4-1.3m-62.8-9.3c1 .7 3.2 2.2 3.4 5.1a11 11 0 0 0 1 3M264 202c.5 1.7.6 7.4 6 13.4m-5-21.4c-.4 1.1-1 2.2.2 3m14.4 10.7c1.1-2.6 4.6-2.4 4.6-4.8"/>
|
||||||
|
<path d="M284.8 207.3c-.3-2.9-1-4.5-.4-8.7.2-1.2.4-5.9 0-8.1 0 0 1.6 7.8-3.7 9.3m34.4 12.5c0-4 1-6.2 1-6.2 1.2-5.6-1.2-7.7-.3-9.1.9-1.2 2-3.8.5-8.9 0 0 2.8 10.5-4.5 10.5-7.5 0-7.2-2.7-7.2-2.7"/>
|
||||||
|
<path d="M284.4 190.5c0 2 0 8.4 9 8.4 2.8 0 5.8-2 7.4-3.3m2.2-2.7q-.4-3-.3-7m-13.6-3.4c2.4-.3 4.4-.6 9-.6m.2-6.3c.5 2.2-.3 5.8 4 6.3m4.3-7.1c-.6 1.6-.6 3.3-.8 4.4m.2 2.7c1.7 0 5-1.1 9-.3m1.4 22.8c.9 1.6 1 3.8 3.6 6.2m-31.7-11.4c.2 1.3-1.8 5.7-.3 9.3a12 12 0 0 1-1.2 11m6.7-19c-.9 1.7-2.3 3.3-2.6 4.7m6.6-1.6c0 1-2.7 8.7-4.7 10.9 0 0 3.6 1.4 3 6.6m7.4-21c0 1.2-.2 1.6 1 2.4m2.4 0c1.3 1.6 5.5 6.5 4.6 9.2m-7.4-6.5c.3 1 .3 6.3-.6 9m-2.1-.2c-1.1 0-2.8.8-3 2.7m8.7-3c1.1-.3 3.3 1.6 3.3 7m15.7-10.2c-2.1.6-3 1.7-2.4 1.1-1.1 1-2.2 1.6-2.2 1.6"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#000001" stroke-width=".3" d="M289.8 198q1 0 1.1-1-.1-.6-1.1-.7-.9.1-1 .8.1.8 1 .9zm23 .3q1 0 1.1-1-.1-.6-1-.7t-1.1.8c-.1.8.5.9 1 .9z"/>
|
||||||
|
<path fill="#fff" d="M280.5 239.8c1.3-8 5-14.5 5.1-15.2 0 0 3.9.9 10.6 1.5s8.4.4 11.5.2l6.8-.8s1.2 2.4 1.3 4.8c.3 5.2 2.3 27.7 2.5 33.7 0 0 .8 3.6.8 12.3 0 9.6 2.6 29 2.6 39.4l-2.6.5s-14.5 1.1-14.5-.8c0-2.1-.8-27 1.4-42.1 0 0-.5-3-1.4-5.5s-4.3-10-4.3-11.7c0 0-5.7 16-6 18.8-.3 2.6-1.2 38.4-1.7 41.3 0 0-8.2 6.3-9.3 7-1.1.4-9-3.4-9.3-4.4a211 211 0 0 1 4.6-33.2c1.4-7.1.6-8.4.6-10.4s.1-27.5 1.3-35.4z"/>
|
||||||
|
<path fill="none" d="M294.3 275q-.8 3.4-3.6 5.7m-2.7-53.6c-.3 1.3-2.3 7.3-7.2 10.9m14.3-9.8c-1.1 3.6-5.5 22.7-6.3 45.4m-.3 7.1-1.7 20.8m-.8 3a71 71 0 0 0-2.7 18.7m32.2-36c-.2 2-1 15.7 3.6 28m-18.5-86c-.3 2.3-.8 16.7-.8 17.8s1.6 2.8 2.4 3c0 0-1.6.6-2 6m-4-12.8c-.3 1.7-2 8.5-3 11.3m13-25.5s5 1 5.3 0c0 0 1.6 29.5 3.8 46m-11.2-42.2-.6 16.5m2.5 2.1s1.9 2.8 1.4 4m-17.8-28 3 .8m5.2 1.6c-.3.9-.6 4.6-.6 4.6m8.8 38.9c.6.8 1.4 2 .8 4.4"/>
|
||||||
|
<path fill="#9b5f00" d="M285 224.4s0-3.2 1.1-4.8c0 0 3.5.7 8.4 1.2q7.4 1 12.6.5c3.7-.4 7.4-1.3 7.4-1.3s1 4.8.5 5.5c0 0-4 .8-8.3 1a86 86 0 0 1-14.5-.8 57 57 0 0 1-7.3-1.3z"/>
|
||||||
|
<path fill="#fff" d="m288 224.8.7-4.8h1l-.2 5.1-1.4-.2m22-4.1.1 5.5 1.3-.2-.1-5.6-1.3.3m-15.6 0-.4 5.3h.7l.7-5.3h-1"/>
|
||||||
|
<path d="M276.8 320.8q-.6 2-1 5.5c0 .5-.6 3.3 1 3.4 1 0 1.4-2.9 1.4-2.9s-1 3.3.3 3.7c1.8.4 2-3.6 2-3.6-.2 1.1-1.4 4 .3 4.3 1.9.3 2.4-3.9 2.4-3.9s-1.6 4.2.3 4.3c1.7 0 1.7-4 1.7-4s-.9 5 1.5 4.5c1.4-.2 1.7-2 2-3 .4-1.5 1.3-3.4.2-6.6-.8-2.3-.5-3-.5-3s-2.9 2.2-5.1 3.6c-1 .6-6.5-2.3-6.5-2.3zm29.7-4.4c-.5-.3-.4 1.9-.6 2.9s.2 3.2 3.6 3 6.7-1 8.9-.5c2.1.6 5.3.7 7 .7q2.5 0 3.7-.5c.7-.3 2.2.4 3 .4s1.7-1 1.6-1.8c0-1.3-1.3-1.3-3-1.4a22 22 0 0 1-5-1c-1.1-.3-3.1-1.5-5.7-2l-5.9.2c-1 0-7 .3-7.6 0z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none">
|
||||||
|
<path fill="#ffb366" d="M255.7 189.8c-.5.3-1.1-2 .4-2.9 0 0 .4-2 2-2.1 0 0 1.4-1.9 3.3-1.7 0 0 2.2-1.5 3-1.3.9.1 3 2.1 3 2.9.2.7 0 1.9-.5 2.4s-1-.1-1.1-1c0 0 .4 1.6-.8 2.2s-1.1.2-1.2-.6c0 0-.4 1.6-1.2 1.9s-1.3-.5-2-.5c0 0 1.1.9.4 1.5q-1.2.6-2-.1c-.5-.5-1.2-1.9-3.3-.6z"/>
|
||||||
|
<path d="M265.7 186c-.5-1.5-2.2-3-4-2.8m2.2 4.4-.8-1.2c-.9-1.5-2.8-1.4-4.4-1.8m2.4 4.5c-.6-.4-.8-1.2-1.4-1.5q-1.4-.8-3.3-.7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-width=".5" transform="translate(-40)scale(.96)">
|
||||||
|
<path fill="url(#bz-e)" d="M439.1 165.1c.7-.3.5.6.6 1 .4 1.3.4 2.7.9 3.9s.8 1.5 1.8 2c1.6 1 6.4 3.6 9.8 1.5l.7-.5s-1.3 4.1 3 6.2l15.6 8.2s1.5 2.7.6 6.5q-.5.9.2 1.5c1 .7 2.6 2.8 3.8 6 0 0 3-1.3 3-3.2s-1.3-3-1.6-4.4c-.2-1.3-1-4.7-.6-5.7.4-1.1 2.1-1.5 2.8-2 .7-.3 1.6.5 1.3 1.4 0 1-.8.8-1.6 1 0 0 1 2 .8 3.4l9.4 4.8-1.6 2.3c-.5.7-1.5 10.2-4.2 14.6s-3.7 3.3-4.2 4l-2.1 1.8c-.6.5-3.8.9-4.5-.9a9 9 0 0 0-3.1-4.1c-2-1.6-6-5.4-6-7.2 0 0-2.3 6.8-2 12.4 0 0-8.4 1.7-16.2 1.9-7.6 0-11.5-1.5-11.5-1.5s-1-6.4-.6-7.7c0 0-1.2 4.2-2 5.6-1.1 1.8-2.2 5.3-4.4 5.5-2.2.1-4-2.1-4.2-3v-6.8h-8.7s-3.8 1.5-5 1.3-1.9-1.3-.9-1.7l.4-.3a7 7 0 0 0-2.5.7H402l.1-.5c.3-1.2 2.6-2.2 4.3-3 1.6-.5 3.3-1.2 4.6-1.3 1.9-.2 3.2-.1 6.2.4.8 0 4 .4 5.8 1.3.5.3 1 .4.8-1-.3-1.3-1.2-5.5.3-8.3 1-1.9.9-3.5.8-5.2-.2-1.7-1.1-5.6-1.1-7.3 0-5.7 4.4-8 8.2-9.2 4.3-1.4 7.1-2.8 7-5.3-.2-2.7.5-7 .1-9"/>
|
||||||
|
<g fill="none">
|
||||||
|
<path fill="url(#bz-f)" d="M456.3 153.6a20 20 0 0 1 1.2 5.3c0 1.2-.5 1.4-.1 2.8.7 2.4-.8 5-2.5 8-.8 1.2-1.4 3-2.7 3.8-3.4 2.1-7.5-.4-9.6-1.3a4 4 0 0 1-2-2.2q-.5-1.6-.8-4c0-.3 0-1.2-.6-.9-1.3-1.4-1-2-1.6-3-.5-1 1-2.2 2-2a10 10 0 0 1-.2-5.9c.2-.6-.6-2.2.5-1.8 4.5 1.8 10 1.4 14.8 1.4.4 0 .7-.8 1.3-.7l.3.5"/>
|
||||||
|
<path fill="#000001" d="M456.8 155.3c.4-.4.1-2.3-.4-4.3-.1-1-1-1.5-2-2.6s-1.6-1-2.3-1.4c-.6-.2-2-.2-2.8-.5-.6-.3-2.2-.1-3.7.1-2 .3-3.2.2-4.7 1s-2.8 1.8-3.3 3.3c-.4 1-1 2.5-.8 6.8s1 3.5 1 3.5 1.1-1.7 1.9-.6l-.3-3.6q-.2-1.8.5-4.6s4.6 1.7 9 1.7c3 0 7-.3 7.1-1l.7 2.2z"/>
|
||||||
|
<path d="M450.4 160.1c.7 1.8.1 1.3.4 2.4m-10.2 2c0 .8-.6.6-1 .6"/>
|
||||||
|
<path d="M456.9 158.8c-1-.7-2.7-.2-3.5.1-1.5.7-.7 2.3-.2 3.7.2.7 1 1.7.5 2.4-.4.8-.7.4-1.6.5 0-.3-.8 0-1.1 0-.6 0-1 .4-1.5.2s-.5-1.2-.3-1.7m.1-4.5c0-.4-1.3-.8-2.7-.9-2.3 0-2.5.1-4.2 1.3m11.5 9q-.5-1.1-2.1-.4c0-.2-1.6-.4-1.6-.4q-1 0-2.1.7m5.6.6c-1-.6-3.3-.2-5.6-.2m.2.8c1.7.5 3.5.8 5.2 0"/>
|
||||||
|
<path fill="#fff" stroke-width=".3" d="M456.4 160.1c-.7 0-1-.9-3.1.3-1.1.7 1.8 1.5 1.4 1.4 1.2.2 1.6-.5 1.7-1.2q.2-.3 0-.5zm-8.3.8c0-.8-1.8-1-2.9-.8-1 .3-.5.1-1.5.7 1 .1.7.2 1.6.6.9.5 1 .9 2.3.3q.5-.2.8-1 0 .3-.3.3z"/>
|
||||||
|
<path fill="#000001" stroke-width=".3" d="M455.1 161.4a1 1 0 0 1-.8-.8q0-1 .8-1 .6 0 .7 1 0 .7-.7.8zm-8.7.2q-.6 0-.7-1 .1-.8.8-.8.9 0 .7.8 0 1-.8 1z"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#bz-g" width="100%" height="100%" fill="#730000" transform="matrix(-1 0 0 1 748.8 0)"/>
|
||||||
|
<g fill="none">
|
||||||
|
<path fill="#730000" d="M491.6 193.5c.5.2 2-2 .5-3 0 0-.5-2-2-2 0 0-1.4-2-3.4-1.7 0 0-2-1.5-2.9-1.4-1 .2-3 2.2-3 3-.2.5 0 1.8.5 2.4s1-.1 1-1c0 0-.3 1.5.9 2.2s1.1 0 1.2-.7c0 0 .3 1.6 1.1 2 .9.2 1.4-.5 2-.5 0 0-1 .8-.3 1.3s1.5.3 2 0c.6-.5.3-1.8 2.4-.6z"/>
|
||||||
|
<path d="M482.5 189.8c.5-1.8 2.2-3.2 4-2.9m-2 4.4q.1-.6.5-1.2c1-1.6 3-1.6 4.6-1.9m-2.5 4.5c.6-.4.8-1.1 1.4-1.4a5 5 0 0 1 3.2-.7"/>
|
||||||
|
<path fill="#730000" stroke="none" d="M482.3 186.8c-.9.7-1.8 1.8-1.9 2.2 0 .7 0 1.9.5 2.5q1 .6 1.1-.8c0 .5 0 1.5.8 2 1.3.7 1.2.1 1.3-.7 0 0 .3 1.6 1 2 .9.2 1.4-.5 2-.5 0 0-1 .8-.3 1.3s1.5.3 2 0c.6-.5.3-1.8 2.5-.6q.5.1 1-1.1l-7.7-5.5z"/>
|
||||||
|
<path d="M480.7 188.2c-1.5 2.6.5 4.1 1.1 2.6-.1 2.4 1.7 2.3 2.3 1.5.7 2.5 1.9 1.4 3 1.2-1.3 2.3 1.4 1.6 2 1 1-1.3 1.4-.2 2-.3l1.3-1.3m-8-5.7q-2.5 1.5-2.6 3.7m5-2.2q-2.7 1.5-2.6 3.7m5.4-1.7c-1.7.9-1.8 1.7-2.5 2.8"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path fill="#5ac800" d="m238.5 310.7-.2-4.5 1.9 1.6.4-3.4c.8-.2 2.1 1.3 2.1 1.3l-.2-3.4 2.8 3.1s-.2-2.6.8-4.2c0 0 2.2 1.9 2.2 4 0 1.9 1.8-2.6 1.8-2.6l1.6 5 1.3-1.9 1 4 2.4-3.5 1.8 4 4.2-.3.5 2.4 1.9-2 2.4 1.5s2-.6 3.1-.6 2.4 1.4 2.4 1.4.8-1.4 1.5-1.9 1.9.8 2.2 1.6l1.6-2.9s2 2 2.3 2.9c0 0 .3-2.4.8-3.2.5-.7 1.2.3 1.8 1.3l1.3-4.2s1.4.3 2.3 1.9c1.1 1.5 1.1-2.4 1.1-2.4s2.4 3.2 3 5.3c0 0-16.2 5.6-27.6 4a306 306 0 0 1-24.5-4.3m110.9-.5s.7-2.1 1.8-3.2c0 0 2 2.4 2 3.2l1.7-1 1.6 1.5 1.8-1.3 1.8 1.6 3.2-1.4 1.3 1.4 5-.6 1.8.8 1.8-2.3 1.6 1.8 2.7-2.6s1.3 1 1.5 1.8c0 0 2.2-1 2.7-1.8 0 0 2-.8 2.3.5 0 0 1.3-1 1.2-2-.1-1.2 1.5-.4 2 1 0 0 .5-2.2 2-2.4 0 0 1.2 1.3.9 3.7 0 0 2.3-1.9 3.4-2.1 0 0 .5 2.6-.3 5 0 0 2.4-4 3-2.7.1.8.7 3.5.1 5.3l2.2-1.3.8 2.6 2.3-.5s-13.9 2.1-27 .9-25.3-5.9-25.3-5.9m-9.6 9.7c.6-.4-4.4 10.3-16.5 10.9-16.9.8-31.3-7.7-31.3-7.7s1.8-2.9 2.9-3.7c0 0 2.3 1.9 3 4.2 0 0 .4-1.5 1.2-2.9 0 0 2.9 1.4 3.4 3.2 0 0 1-1.8 1.8-2.3.8-.6 1.6 2 1.6 3.6 0 0 1.9-2 3-2 0 0 2 1 2.6 4.3 0 0 2-1.8 2.6-1.5 0 0 2.3 1.5 1.8 3.7-.3 1.2 2.3-1.4 5.5-2.2l2.9 2.2 3.4-4.7s2.1.8 2.1 2c0 0 1-3.7 1.8-4 .8-.5 1.8 1.1 1.8 2.2 0 0 .3-2.8 1.9-3.4 0 0 1.6 1 1.6 2.1 0 0 1.6-3.2 2.9-4"/>
|
||||||
|
<path fill="none" stroke="#060" stroke-width=".5" d="m238.5 310.7-.2-4.5 1.9 1.6.4-3.4c.8-.2 2.1 1.3 2.1 1.3l-.2-3.4 2.8 3.1s-.2-2.6.8-4.2c0 0 2.2 1.9 2.2 4 0 1.9 1.8-2.6 1.8-2.6l1.6 5 1.3-1.9 1 4 2.4-3.5 1.8 4 4.2-.3.5 2.4 1.9-2 2.4 1.5s2-.6 3.1-.6 2.4 1.4 2.4 1.4.8-1.4 1.5-1.9c.8-.5 1.9.8 2.2 1.6l1.6-2.9s2 2.1 2.3 2.9c0 0 .3-2.4.8-3.2.5-.7 1.2.3 1.8 1.3l1.3-4.2s1.4.3 2.3 1.9c1.1 1.5 1.1-2.4 1.1-2.4s2.4 3.2 3 5.3m58.7-.8s.8-2.1 1.9-3.2c0 0 2 2.4 2 3.2l1.7-1 1.6 1.5 1.8-1.3 1.8 1.6 3.2-1.4 1.3 1.4 5-.6 1.8.8 1.8-2.2 1.6 1.7 2.7-2.6s1.2 1 1.5 1.8c0 0 2.2-1 2.7-1.8 0 0 2-.8 2.3.5 0 0 1.3-1 1.2-2-.1-1.2 1.5-.4 2 1 0 0 .5-2.2 2-2.4 0 0 1.2 1.3.9 3.7 0 0 2.3-1.9 3.4-2 0 0 .5 2.5-.3 4.9 0 0 2.4-4 3-2.7.1.8.7 3.5.1 5.3l2.2-1.3.8 2.6 2.3-.5m-109.8 8s1.8-3 2.9-3.7c0 0 2.3 1.8 3.1 4.2 0 0 .3-1.6 1-3 0 0 3 1.4 3.5 3.2 0 0 1-1.8 1.9-2.3.7-.6 1.5 2 1.5 3.6 0 0 1.9-2 3-2 0 0 2 1 2.6 4.4 0 0 2-1.9 2.6-1.6 0 0 2.4 1.6 1.8 3.7-.3 1.2 2.3-1.4 5.5-2.1l2.9 2.1 3.4-4.7s2.1.8 2.1 2c0 0 1-3.6 1.8-4 .8-.5 1.9 1.1 1.9 2.2 0 0 .3-2.8 1.8-3.4 0 0 1.6 1 1.6 2.1 0 0 1.6-3.2 3-4"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<path d="M319.5 361.9c22 .2 35-7.8 42-12.6 16.8-11.2 20.3-11.6 23-11.4 3.1.3 7.7 1.3 8 4 .2 4.3-6.6 6.1-10.5 6.1s-14.5-2.6-14.5-2.6l-2.7 1.9c1.8.8 23.2 8 27.3 1.2s8.4-17.9 8.4-17.9-4.7-8.4-15.1-8.4c-10.6 0-20.8 6.7-27.6 11.7s-15.5 11.8-38.3 11.8c-22.9 0-31.6-6.8-38.4-11.8-6.9-5-17.1-11.7-27.6-11.7a18 18 0 0 0-15.2 8.4s4.2 11 8.4 18c4.1 6.7 25.5-.5 27.3-1.4l-2.6-1.8S261 348 257 348s-10.9-1.8-10.6-6c.2-2.8 4.8-3.8 8-4.1 2.7-.2 6.2.2 22.9 11.4a72 72 0 0 0 42.2 12.6"/>
|
||||||
|
<path d="M284.1 336s0-1.9-.3-4.6c-.4-3.5-2.4-4.4-4-3.9-1.2.4-3.3 3-3.3 3v.1l-3-1.8a18 18 0 0 1 6.3-3.6c1 0 14.5 5.4 18.8 9.1 1 1 1.8 7 1.2 9a47 47 0 0 1-15.7-7.2m-6.5 13.6s-2.9 3-3 8.3c0 6.3 4.5 6.6 7.5 6.5 3.7 0 6.6-2.1 6.5-8.9 0 0-6-2.5-11-6m77.1-13.6.4-4.6c.4-3.5 2.4-4.4 4-3.9 1 .4 3.2 3 3.2 3v.1l3-1.8c-.7-1-5.2-3.6-6.2-3.6s-14.5 5.4-18.8 9.1c-1 1-1.8 7-1.3 9a46 46 0 0 0 15.7-7.2zm6.5 13.7s2.9 3 3 8.3c.2 6.3-4.5 6.6-7.5 6.5-3.6 0-6.5-2.1-6.5-8.9 0 0 6-2.5 11-6"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#69f" d="M271.3 345.4S260.9 348 257 348s-10.9-1.8-10.6-6c.2-2.8 4.8-3.8 8-4.1 2.3-.2 5.3 0 17 7.5m96.1 0S378 348 382 348c3.9 0 10.7-1.8 10.4-6-.2-2.8-4.8-3.8-7.9-4.1-2.3-.2-5.4 0-17 7.5m-83.4-9.3-3.1-2.3-4.5-3.2s2.1-2.7 3.2-3c1.7-.6 3.7.3 4.1 3.8.3 2.7.3 4.7.3 4.7m-7.1 27.6c1.8 1 3.9 1 5.1.8 3.7 0 6.6-2.1 6.5-8.9 0 0 4.5 2.3 11.8 4 0 0 1.2 1.5 1.4 4.4.3 2.5-1.4 5.1-3.3 5 0 0-10.3-2-16.5-3-3.7-.7-5-2.3-5-2.3m77.7-27.6 3.2-2.3 4.4-3.2s-2.1-2.7-3.2-3c-1.6-.6-3.6.3-4 3.8zm7.1 27.6c-1.8 1-3.9 1-5.1.8-3.6 0-6.5-2.1-6.5-8.9 0 0-4.5 2.3-11.7 4 0 0-1.3 1.5-1.5 4.4-.2 2.5 1.5 5.1 3.4 5 0 0 10.3-2 16.5-3 3.6-.7 5-2.3 5-2.3"/>
|
||||||
|
<g fill="none" stroke="#000" stroke-width=".7">
|
||||||
|
<path d="M319.5 361.9c22 .2 35-7.8 42-12.6 16.8-11.2 20.3-11.6 23-11.4 3.1.3 7.7 1.3 8 4 .2 4.3-6.6 6.1-10.5 6.1s-14.5-2.6-14.5-2.6l-2.7 1.9c1.8.8 23.2 8 27.3 1.2s8.4-17.9 8.4-17.9-4.7-8.4-15.1-8.4c-10.6 0-20.8 6.7-27.7 11.7s-15.4 11.8-38.2 11.8-31.6-6.8-38.4-11.8c-6.9-5-17.1-11.7-27.6-11.7s-15.2 8.4-15.2 8.4 4.2 11 8.4 18c4.1 6.7 25.5-.5 27.4-1.4l-2.7-1.8S261 348 257 348s-10.9-1.8-10.6-6c.2-2.8 4.8-3.8 8-4.1 2.7-.2 6.2.2 22.9 11.4a72 72 0 0 0 42.2 12.6z"/>
|
||||||
|
<path d="M271.3 345.4S260.9 348 257 348s-10.9-1.8-10.6-6c.2-2.8 4.8-3.8 8-4.1 2.3-.2 5.3 0 17 7.5zm96.2 0S378 348 382 348c3.9 0 10.7-1.8 10.4-6-.2-2.8-4.8-3.8-7.9-4.1-2.3-.2-5.4 0-17 7.5zm-83.4-9.3-3.1-2.3-4.5-3.2s2.1-2.7 3.2-3c1.7-.6 3.7.3 4.1 3.8.3 2.8.3 4.7.3 4.7z"/>
|
||||||
|
<path d="M284.1 336s0-1.9-.3-4.6c-.4-3.5-2.4-4.4-4-3.9-1.2.4-3.3 3-3.3 3v.1l-3-1.8a18 18 0 0 1 6.3-3.6c1 0 14.5 5.4 18.8 9.1 1 1 1.8 7 1.2 9a47 47 0 0 1-15.7-7.2m-6.5 13.6s-2.9 3-3 8.3c0 6.3 4.5 6.6 7.5 6.5 3.7 0 6.6-2.1 6.5-8.9 0 0-6-2.5-11-6z"/>
|
||||||
|
<path d="M280.1 351.1s-2.3 2.5-2.7 6.5c-.2 3.1 1.8 4.9 4.5 4.7 3.7-.2 5.1-5.3 3.4-8.1l-5.2-3"/>
|
||||||
|
<path d="M277 363.7c1.8 1 3.9 1 5.2.8 3.6 0 6.5-2.1 6.4-8.9 0 0 4.5 2.3 11.8 4 0 0 1.2 1.5 1.4 4.5.3 2.4-1.4 5-3.3 4.8L282 366c-3.7-.7-5-2.3-5-2.3zm77.7-27.6 3.3-2.3 4.4-3.2s-2.2-2.7-3.3-3c-1.6-.6-3.6.3-4 3.8z"/>
|
||||||
|
<path d="m354.7 336 .4-4.6c.4-3.5 2.4-4.4 4-3.9 1 .4 3.2 3 3.2 3v.1l3-1.8c-.7-.9-5.2-3.6-6.2-3.6s-14.5 5.4-18.8 9.1c-1 1-1.8 7-1.3 9a46 46 0 0 0 15.7-7.2m6.5 13.6s2.9 3 3 8.3c.2 6.3-4.5 6.6-7.5 6.5-3.6 0-6.5-2.1-6.5-8.9 0 0 6-2.5 11-6z"/>
|
||||||
|
<path d="M358.7 351.1s2.4 2.5 2.8 6.5c.3 3.1-1.9 4.9-4.5 4.7-3.7-.2-5.1-5.3-3.4-8.1l5.1-3"/>
|
||||||
|
<path d="M361.9 363.7c-2 1-4 1-5.2.8-3.6 0-6.5-2.1-6.5-8.9 0 0-4.5 2.3-11.7 4 0 0-1.3 1.5-1.5 4.5-.2 2.4 1.5 5 3.4 4.8l16.5-2.9c3.6-.7 5-2.3 5-2.3z"/>
|
||||||
|
</g>
|
||||||
|
<path fill="none" stroke="#fff" stroke-width=".7" d="M299.6 361.4s3.3 2.4-.7 5.3m-9-6.8 10 4.3m-8.3-2.1 7.2 3.1m-4.6-5 5.4 2.5m39.7-1.3s-3.3 2.4.6 5.3m9-6.8-10 4.3m8.4-2.1-7.2 3.1m4.6-5-5.4 2.5"/>
|
||||||
|
<path stroke="#000" stroke-width=".7" d="m261 331.4.3.7v.8a2 2 0 0 1-1.2 1.6 3 3 0 0 1-2.2.4l-1-.4-1-.6-.3.4-.4-.1.4-2.9.5.1q0 .6.2 1l.3 1 .7.6q.3.3.9.4h.7a1 1 0 0 0 .9-.6l.2-.6-.1-1-.7-.7-.8-.5-.8-.6q-.7-.4-1-1t-.1-1.4q0-.5.4-.8a3 3 0 0 1 1.5-1h1l1 .4.8.6.3-.4.5.1-.5 2.8-.5-.1v-1l-.4-.8-.5-.7-.8-.4-1 .2q-.6.3-.6.8v1l.7.7.8.5 1.3 1q.3.1.5.5m12 1.6-.5-.1-.5-.1q-.3 0-.5.3l-.6 1-1.8 3.1a2 2 0 0 1-1.9 1.3h-1.1l-1-.5-1-.8-.7-1-.1-1 .2-.8 2.4-4.3.1-.4v-.3l-.3-.3-.4-.3.2-.3 3.1 1.7-.2.3-.3-.1-.4-.1h-.4l-.2.3-2.2 4-.3.7v.8l.2.7.9.8 1 .3a2 2 0 0 0 1.5-.5l.5-.6 1.6-3 .5-1v-.6q0-.2-.4-.5l-.4-.3.2-.4 3 1.7-.2.3m4.3 7.5a2 2 0 0 1 0 1.8l-.8.8-1 .3-1-.1-.9-.4-3.5-2 .2-.4.8.3h.3l.3-.3 3-5.3.1-.3-.1-.4-.3-.3-.3-.2.2-.4 3.4 2 .7.5.6.7a2 2 0 0 1 0 1.6l-.6.6-.7.3h-.7l-.8-.2v.1l.6.6zm-.6-1.8q.3 0 .5-.3l.5-.6q.3-.6.2-1.2-.2-.4-.9-.9l-.4-.2-.4-.3-1.6 2.8.6.4.8.3zm-.6 2.7q.4-.6.1-1.3 0-.6-1-1.1l-.5-.4-.4-.1-1.5 2.5v.7q.2.3.7.5.8.4 1.4.3.7-.3 1.2-1zm18.9 4.3h-.5l-.6-.1q-.2 0-.4.4l-.5 1-1.5 3.3q-.2.6-.7 1l-1 .4h-1.1a4 4 0 0 1-2.2-1l-.7-.8-.3-1 .2-.9 2-4.5v-.4l-.1-.3-.3-.3-.4-.2.2-.4 3.3 1.4-.2.4-.4-.1h-.7l-.2.3-1.9 4.3-.2.7a2 2 0 0 0 .4 1.4q.3.4 1 .7l1 .2a2 2 0 0 0 1.5-.7l.4-.6 1.3-3.1.3-1v-.7l-.4-.4-.5-.3.2-.4 3.1 1.4-.1.3m9.2 10.8-3.6-1v-.4h.6l.4.1.4-.2.2-.3 1.7-5.8h-.1l-4.8 6h-.3l-.6-7.9-1.5 4.8-.2 1.1.1.6.5.4.5.3-.2.4-3.2-1v-.4h1.1q.3 0 .5-.4l.3-1 1.2-4.2.1-.6-.1-.4-.4-.4-.5-.2.1-.4 2.8.8.6 6.7 3.5-4.5.3-.5.2-.4 2.7.8-.1.4h-.5l-.4-.1-.3.1-.1.4-1.7 5.8v.3l.1.3.8.4-.1.4m8.7-2.6a2 2 0 0 1 .7 1.7l-.4 1q-.3.4-.8.6t-1 .3h-1l-4-.5v-.4h.9l.3-.2.1-.4.8-6v-.3l-.3-.3-.4-.2h-.3v-.5l3.9.5.9.2.7.4a2 2 0 0 1 .6 1.5l-.2.8-.6.5-.6.3-.8.2.8.3zm-1.3-1.5.4-.5.2-.7-.3-1.1q-.3-.5-1.1-.5l-1-.1-.4 3.2h1.6zm.5 2.8q0-.8-.3-1.3-.5-.4-1.4-.6l-.7-.1h-.4l-.3 2.9.2.6.8.3q.8.1 1.5-.4.4-.4.6-1.4zm11.4 2.6h-2.2l-1.4-2-1.3-1.8h-1v2.7l.1.4.3.2h.4l.5.1v.4h-3.6v-.4h.4l.4-.1.2-.2.1-.4v-6.4l-.3-.3h-.4l-.4-.1v-.4h4.9l.9.4.6.6a2 2 0 0 1 0 1.8l-.4.7-.7.4-.8.3 1 1.4q.5.5 1 1.4l1 .8.8.1zm-3.1-6.2q0-.8-.4-1.2-.5-.5-1.3-.5h-1v3.6h.8q.8 0 1.3-.5t.6-1.4zm13 5.6-3.6.3v-.4l.7-.2.3-.2v-.3l-.9-1.8-3 .2-.2.7-.1.6-.1.4v.3q0 .3.4.3l.9.1v.4l-3.2.3v-.4l.4-.1.7-.6.2-.5 1.1-3.6 1.1-3.6h.5l3.4 7 .2.3.7.4h.4v.4zm-3.7-3.1-1.6-3.2-1 3.4zm15.7-6.7-.4.2-.8-.9q-.6-.4-1-.3h-.3l-.6.2-1.4.4.9 3.2 1-.2.6-.3.3-.4.1-.4v-.6l.4-.2.9 3.2-.4.1-.3-.5-.4-.4-.5-.2-.6.1-1 .3.8 2.8s0 .2.2.3l.3.2h.4l.4-.1.1.4-3.5 1-.1-.4.4-.2.4-.2.2-.3v-.3l-1.6-5.9-.2-.3-.3-.2h-.9l-.1-.3 6.4-1.8.6 2m9.4-.7 1 2.4-6 2.8-.2-.3.4-.3.3-.2.1-.3v-.4l-2.7-5.4-.2-.3h-1.2l-.2-.3 3.3-1.6.2.4-.4.2-.3.3-.2.3.1.4 2.5 5q0 .4.3.6l.3.2h.4l.7-.3.4-.2.4-.3.3-.2.2-.3.2-2 .3-.2m4.5-8.3 1.5.8 1.2 1.4.6 1.7v1.6l-.7 1.5-1.2 1a4 4 0 0 1-3.2.4l-1.4-.7-1.2-1.4-.6-1.7v-1.7a4 4 0 0 1 2-2.4l1.5-.6zm2.3 5.6-.2-1.3-.7-1.4-1-1.3-1-.8-1-.2q-.7 0-1.2.3-.6.4-.9 1l-.3 1q0 .7.3 1.3a7 7 0 0 0 1.5 2.6q.5.6 1 .8l1.2.3q.5 0 1.1-.3a2 2 0 0 0 1.2-2zm10.5-5.7-1.9 1.3-2.3-.9-2-.6-.8.5 1.6 2.1.2.3h.4l.3-.1.4-.3.3.4-3 2-.2-.3.6-.5v-.7l-3.6-5-.2-.2h-.4l-.7.3-.3-.4 3.2-2.2.9-.5.9-.2.8.1a2 2 0 0 1 1.1 1.4v.8l-.2.7-.5.8 1.6.5 1.6.5 1.2.1h.4l.4-.3zm-6.2-3.3q-.4-.6-1-.7-.7-.2-1.3.3l-.8.6 2 3 .7-.5q.6-.4.8-1.2.1-.7-.4-1.5zm13.2-2 .7 2.4-6.6 2.4-.2-.4.8-.4.2-.3v-.4l-2.2-5.6-.1-.3-.4-.1h-.9l-.1-.3 6.1-2.3.7 1.9-.4.1-.8-.7-.9-.3-.4.2-.6.2-1.2.4 1.1 3.1 1-.3q.3 0 .5-.3l.3-.4v-1.1l.4-.1 1.1 3-.4.2-.3-.5-.4-.3-.5-.2-.6.2-.9.3.9 2.4.2.5.3.3h.5l.7-.3.5-.2 1-.4.2-.3.2-1v-1l.5-.1m7.1-5.8q.6.4 1 1.3.5.7.6 1.7v1.8l-.8 1.5a4 4 0 0 1-2.7 1.6 4 4 0 0 1-3-.9l-1-1.2-.6-1.7v-1.9l.8-1.5a4 4 0 0 1 2.7-1.5h1.6zm-.1 6q.3-.6.3-1.3v-1.5l-.4-1.5-.7-1.2-.9-.7-1.1-.1q-.8 0-1.2.5-.5.4-.7 1l-.3 1.2a7 7 0 0 0 .4 3l.7 1.1q.3.6.9.7.4.3 1.2.2l1-.5.8-1z"/>
|
||||||
|
<path fill="none" stroke="#000" d="M366 205.1v53c0 10.6-1.4 52.6-46.5 69-45.3-16.4-46.7-58.4-46.7-69v-53H366"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ca" viewBox="0 0 640 480">
|
||||||
|
<path fill="#fff" d="M150.1 0h339.7v480H150z"/>
|
||||||
|
<path fill="#d52b1e" d="M-19.7 0h169.8v480H-19.7zm509.5 0h169.8v480H489.9zM201 232l-13.3 4.4 61.4 54c4.7 13.7-1.6 17.8-5.6 25l66.6-8.4-1.6 67 13.9-.3-3.1-66.6 66.7 8c-4.1-8.7-7.8-13.3-4-27.2l61.3-51-10.7-4c-8.8-6.8 3.8-32.6 5.6-48.9 0 0-35.7 12.3-38 5.8l-9.2-17.5-32.6 35.8c-3.5.9-5-.5-5.9-3.5l15-74.8-23.8 13.4q-3.2 1.3-5.2-2.2l-23-46-23.6 47.8q-2.8 2.5-5 .7L264 130.8l13.7 74.1c-1.1 3-3.7 3.8-6.7 2.2l-31.2-35.3c-4 6.5-6.8 17.1-12.2 19.5s-23.5-4.5-35.6-7c4.2 14.8 17 39.6 9 47.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ch" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="red" d="M0 0h640v480H0z"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<path d="M170 195h300v90H170z"/>
|
||||||
|
<path d="M275 90h90v300h-90z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ci" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd">
|
||||||
|
<path fill="#00cd00" d="M426.8 0H640v480H426.8z"/>
|
||||||
|
<path fill="#ff9a00" d="M0 0h212.9v480H0z"/>
|
||||||
|
<path fill="#fff" d="M212.9 0h214v480h-214z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-cl" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="cl-a">
|
||||||
|
<path fill-opacity=".7" d="M0 0h682.7v512H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g fill-rule="evenodd" clip-path="url(#cl-a)" transform="scale(.9375)">
|
||||||
|
<path fill="#fff" d="M256 0h512v256H256z"/>
|
||||||
|
<path fill="#0039a6" d="M0 0h256v256H0z"/>
|
||||||
|
<path fill="#fff" d="M167.8 191.7 128.2 162l-39.5 30 14.7-48.8L64 113.1l48.7-.5L127.8 64l15.5 48.5 48.7.1-39.2 30.4z"/>
|
||||||
|
<path fill="#d52b1e" d="M0 256h768v256H0z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-cm" viewBox="0 0 640 480">
|
||||||
|
<path fill="#007a5e" d="M0 0h213.3v480H0z"/>
|
||||||
|
<path fill="#ce1126" d="M213.3 0h213.4v480H213.3z"/>
|
||||||
|
<path fill="#fcd116" d="M426.7 0H640v480H426.7z"/>
|
||||||
|
<g fill="#fcd116" transform="translate(320 240)scale(7.1111)">
|
||||||
|
<g id="cm-b">
|
||||||
|
<path id="cm-a" d="M0-8-2.5-.4 1.3.9z"/>
|
||||||
|
<use xlink:href="#cm-a" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#cm-b" width="100%" height="100%" transform="rotate(72)"/>
|
||||||
|
<use xlink:href="#cm-b" width="100%" height="100%" transform="rotate(144)"/>
|
||||||
|
<use xlink:href="#cm-b" width="100%" height="100%" transform="rotate(-144)"/>
|
||||||
|
<use xlink:href="#cm-b" width="100%" height="100%" transform="rotate(-72)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 840 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-cn" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<path id="cn-a" fill="#ff0" d="M-.6.8 0-1 .6.8-1-.3h2z"/>
|
||||||
|
</defs>
|
||||||
|
<path fill="#ee1c25" d="M0 0h640v480H0z"/>
|
||||||
|
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(71.9991 0 0 72 120 120)"/>
|
||||||
|
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-12.33562 -20.5871 20.58684 -12.33577 240.3 48)"/>
|
||||||
|
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-3.38573 -23.75998 23.75968 -3.38578 288 95.8)"/>
|
||||||
|
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(6.5991 -23.0749 23.0746 6.59919 288 168)"/>
|
||||||
|
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(14.9991 -18.73557 18.73533 14.99929 240 216)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 813 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-co" viewBox="0 0 640 480">
|
||||||
|
<g fill-rule="evenodd" stroke-width="1pt">
|
||||||
|
<path fill="#ffe800" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#00148e" d="M0 240h640v240H0z"/>
|
||||||
|
<path fill="#da0010" d="M0 360h640v120H0z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 286 B |